mempool/backend/src/api/services/wallets.ts
2026-02-04 10:24:49 +00:00

350 lines
No EOL
12 KiB
TypeScript

import config from '../../config';
import logger from '../../logger';
import { IEsploraApi } from '../bitcoin/esplora-api.interface';
import bitcoinApi from '../bitcoin/bitcoin-api-factory';
import axios from 'axios';
import { TransactionExtended } from '../../mempool.interfaces';
import { promises as fsPromises } from 'fs';
interface WalletAddress {
address: string;
active: boolean;
stats: {
funded_txo_count: number;
funded_txo_sum: number;
spent_txo_count: number;
spent_txo_sum: number;
tx_count: number;
};
transactions: IEsploraApi.AddressTxSummary[];
lastSync: number;
}
interface Wallet {
name: string;
addresses: Record<string, WalletAddress>;
lastPoll: number;
}
interface Treasury {
id: number,
name: string,
wallet: string,
enterprise: string,
verifiedAddresses: string[],
balances: { balance: number, time: number }[], // off-chain balances
}
const POLL_FREQUENCY = 5 * 60 * 1000; // 5 minutes
class WalletApi {
private treasuries: Treasury[] = [];
private wallets: Record<string, Wallet> = {};
private syncing = false;
private lastSync = 0;
private isSaving = false;
private cacheSchemaVersion = 1;
private static TMP_FILE_NAME = config.MEMPOOL.CACHE_DIR + '/tmp-wallets-cache.json';
private static FILE_NAME = config.MEMPOOL.CACHE_DIR + '/wallets-cache.json';
constructor() {
this.wallets = config.WALLETS.ENABLED ? (config.WALLETS.WALLETS as string[]).reduce((acc, wallet) => {
acc[wallet] = { name: wallet, addresses: {}, lastPoll: 0 };
return acc;
}, {} as Record<string, Wallet>) : {};
// Load cache on startup
if (config.WALLETS.ENABLED) {
void this.$loadCache();
}
}
/** @asyncSafe */
private async $loadCache(): Promise<void> {
try {
const cacheData = await fsPromises.readFile(WalletApi.FILE_NAME, 'utf8');
if (!cacheData) {
return;
}
const data = JSON.parse(cacheData);
if (data.cacheSchemaVersion !== this.cacheSchemaVersion) {
logger.notice('Wallets cache contains an outdated schema version. Clearing it.');
return this.$wipeCache();
}
this.wallets = data.wallets;
this.treasuries = data.treasuries || [];
// Reset lastSync time to force transaction history refresh
for (const wallet of Object.values(this.wallets)) {
wallet.lastPoll = 0;
for (const address of Object.values(wallet.addresses)) {
address.lastSync = 0;
}
}
logger.info('Restored wallets data from disk cache');
} catch (e) {
logger.warn('Failed to parse wallets cache. Skipping. Reason: ' + (e instanceof Error ? e.message : e));
}
}
private async $saveCache(): Promise<void> {
if (this.isSaving || !config.WALLETS.ENABLED) {
return;
}
try {
this.isSaving = true;
logger.debug('Writing wallets data to disk cache...');
const cacheData = {
cacheSchemaVersion: this.cacheSchemaVersion,
wallets: this.wallets,
treasuries: this.treasuries,
};
await fsPromises.writeFile(
WalletApi.TMP_FILE_NAME,
JSON.stringify(cacheData),
{ flag: 'w' }
);
await fsPromises.rename(WalletApi.TMP_FILE_NAME, WalletApi.FILE_NAME);
logger.debug('Wallets data saved to disk cache');
} catch (e) {
logger.warn('Error writing to wallets cache file: ' + (e instanceof Error ? e.message : e));
} finally {
this.isSaving = false;
}
}
private async $wipeCache(): Promise<void> {
try {
await fsPromises.unlink(WalletApi.FILE_NAME);
} catch (e: any) {
if (e?.code !== 'ENOENT') {
logger.err(`Cannot wipe wallets cache file ${WalletApi.FILE_NAME}. Exception ${JSON.stringify(e)}`);
}
}
}
public getWallet(wallet: string): Record<string, WalletAddress> | null {
if (wallet in this.wallets) {
return this.wallets?.[wallet]?.addresses || {};
} else {
return null;
}
}
public getWallets(): string[] {
return Object.keys(this.wallets);
}
public getTreasuries(): Treasury[] {
return this.treasuries?.filter(treasury => !!this.wallets[treasury.wallet]) || [];
}
// resync wallet addresses from the services backend
/** @asyncSafe */
async $syncWallets(): Promise<void> {
if (!config.WALLETS.ENABLED || this.syncing) {
return;
}
this.syncing = true;
if (config.WALLETS.AUTO && (Date.now() - this.lastSync) > POLL_FREQUENCY) {
try {
// update list of active wallets
this.lastSync = Date.now();
const response = await axios.get(config.MEMPOOL_SERVICES.API + `/wallets`);
const walletList: string[] = response.data;
if (walletList) {
// create a quick lookup dictionary of active wallets
const newWallets: Record<string, boolean> = Object.fromEntries(
walletList.map(wallet => [wallet, true])
);
for (const wallet of walletList) {
// don't overwrite existing wallets
if (!this.wallets[wallet]) {
this.wallets[wallet] = { name: wallet, addresses: {}, lastPoll: 0 };
}
}
// remove wallets that are no longer active
for (const wallet of Object.keys(this.wallets)) {
if (!newWallets[wallet]) {
delete this.wallets[wallet];
}
}
}
// update list of treasuries
const treasuriesResponse = await axios.get(config.MEMPOOL_SERVICES.API + `/treasuries`);
this.treasuries = treasuriesResponse.data || [];
} catch (e) {
logger.err(`Error updating active wallets: ${(e instanceof Error ? e.message : e)}`);
}
try {
// update list of active treasuries
this.lastSync = Date.now();
const response = await axios.get(config.MEMPOOL_SERVICES.API + `/treasuries`);
const treasuries: Treasury[] = response.data;
if (treasuries) {
this.treasuries = treasuries;
}
} catch (e) {
logger.err(`Error updating active treasuries: ${(e instanceof Error ? e.message : e)}`);
}
// insert dummy address data to represent off-chain balance history
for (const treasury of this.treasuries) {
if (treasury.balances?.length) {
if (this.wallets[treasury.wallet]) {
this.wallets[treasury.wallet].addresses['private'] = convertBalancesToWalletAddress(treasury.wallet, treasury.balances);
}
}
}
}
for (const walletKey of Object.keys(this.wallets)) {
const wallet = this.wallets[walletKey];
if (wallet.lastPoll < (Date.now() - POLL_FREQUENCY)) {
try {
const response = await axios.get(config.MEMPOOL_SERVICES.API + `/wallets/${wallet.name}`);
const addresses: Record<string, WalletAddress> = response.data;
const addressList: WalletAddress[] = Object.values(addresses);
// sync all current addresses
for (const address of addressList) {
await this.$syncWalletAddress(wallet, address);
}
// remove old addresses
for (const address of Object.keys(wallet.addresses)) {
if (address !== 'private' && !addresses[address]) {
delete wallet.addresses[address];
}
}
wallet.lastPoll = Date.now();
logger.debug(`Synced ${Object.keys(wallet.addresses).length} addresses for wallet ${wallet.name}`);
// Update cache
await this.$saveCache();
} catch (e) {
logger.err(`Error syncing wallet ${wallet.name}: ${(e instanceof Error ? e.message : e)}`);
}
}
}
this.syncing = false;
}
// resync address transactions from esplora
async $syncWalletAddress(wallet: Wallet, address: WalletAddress): Promise<void> {
// fetch full transaction data if the address is new or still active and hasn't been synced in the last hour
const refreshTransactions = !wallet.addresses[address.address] || (address.active && (Date.now() - wallet.addresses[address.address].lastSync) > 60 * 60 * 1000);
if (refreshTransactions) {
try {
const summary = await bitcoinApi.$getAddressTransactionSummary(address.address);
const addressInfo = await bitcoinApi.$getAddress(address.address);
const walletAddress: WalletAddress = {
address: address.address,
active: address.active,
transactions: summary,
stats: addressInfo.chain_stats,
lastSync: Date.now(),
};
wallet.addresses[address.address] = walletAddress;
} catch (e) {
logger.err(`Error syncing wallet address ${address.address}: ${(e instanceof Error ? e.message : e)}`);
}
}
}
// check a new block for transactions that affect wallet address balances, and add relevant transactions to wallets
processBlock(block: IEsploraApi.Block, blockTxs: TransactionExtended[]): Record<string, IEsploraApi.Transaction[]> {
const walletTransactions: Record<string, IEsploraApi.Transaction[]> = {};
for (const walletKey of Object.keys(this.wallets)) {
const wallet = this.wallets[walletKey];
walletTransactions[walletKey] = [];
for (const tx of blockTxs) {
const funded: Record<string, number> = {};
const spent: Record<string, number> = {};
const fundedCount: Record<string, number> = {};
const spentCount: Record<string, number> = {};
let anyMatch = false;
for (const vin of tx.vin) {
const address = vin.prevout?.scriptpubkey_address;
if (address && wallet.addresses[address]) {
anyMatch = true;
spent[address] = (spent[address] ?? 0) + (vin.prevout?.value ?? 0);
spentCount[address] = (spentCount[address] ?? 0) + 1;
}
}
for (const vout of tx.vout) {
const address = vout.scriptpubkey_address;
if (address && wallet.addresses[address]) {
anyMatch = true;
funded[address] = (funded[address] ?? 0) + (vout.value ?? 0);
fundedCount[address] = (fundedCount[address] ?? 0) + 1;
}
}
for (const address of Object.keys({ ...funded, ...spent })) {
// update address stats
wallet.addresses[address].stats.tx_count++;
wallet.addresses[address].stats.funded_txo_count += fundedCount[address] || 0;
wallet.addresses[address].stats.spent_txo_count += spentCount[address] || 0;
wallet.addresses[address].stats.funded_txo_sum += funded[address] || 0;
wallet.addresses[address].stats.spent_txo_sum += spent[address] || 0;
// add tx to summary
const txSummary: IEsploraApi.AddressTxSummary = {
txid: tx.txid,
value: (funded[address] ?? 0) - (spent[address] ?? 0),
height: block.height,
time: block.timestamp,
};
wallet.addresses[address].transactions?.push(txSummary);
}
if (anyMatch) {
walletTransactions[walletKey].push(tx);
}
}
}
return walletTransactions;
}
}
function convertBalancesToWalletAddress(wallet: string, balances: { balance: number, time: number }[]): WalletAddress {
// represent the off-chain balance as a series of transactions modifying a single notional UTXO
const sortedBalances = balances.sort((a, b) => a.time - b.time);
const walletAddress: WalletAddress = {
address: 'private',
active: false,
stats: {
funded_txo_count: 0,
funded_txo_sum: sortedBalances[sortedBalances.length - 1].balance,
spent_txo_count: 0,
spent_txo_sum: 0,
tx_count: 0,
},
transactions: [],
lastSync: sortedBalances[sortedBalances.length - 1].time,
};
let lastBalance = 0;
for (const [index, entry] of sortedBalances.entries()) {
const diff = entry.balance - lastBalance;
walletAddress.transactions.push({
txid: `${wallet}-private-${index}`,
value: diff,
height: index,
time: entry.time,
});
lastBalance = entry.balance;
}
return walletAddress;
}
export default new WalletApi();