wallet tracking backend support

This commit is contained in:
Mononaut 2024-07-25 22:33:32 +00:00
parent ca7221f8b7
commit 862c9591a1
No known key found for this signature in database
GPG key ID: A3F058E41374C04E
9 changed files with 213 additions and 0 deletions

View file

@ -30,6 +30,7 @@ export interface AbstractBitcoinApi {
$getBatchedOutspendsInternal(txId: string[]): Promise<IEsploraApi.Outspend[][]>;
$getOutSpendsByOutpoint(outpoints: { txid: string, vout: number }[]): Promise<IEsploraApi.Outspend[]>;
$getCoinbaseTx(blockhash: string): Promise<IEsploraApi.Transaction>;
$getAddressTransactionSummary(address: string): Promise<IEsploraApi.AddressTxSummary[]>;
startHealthChecks(): void;
getHealthStatus(): HealthCheckHost[];

View file

@ -255,6 +255,10 @@ class BitcoinApi implements AbstractBitcoinApi {
return this.$getRawTransaction(txids[0]);
}
async $getAddressTransactionSummary(address: string): Promise<IEsploraApi.AddressTxSummary[]> {
throw new Error('Method getAddressTransactionSummary not supported by the Bitcoin RPC API.');
}
$getEstimatedHashrate(blockHeight: number): Promise<number> {
// 120 is the default block span in Core
return this.bitcoindClient.getNetworkHashPs(120, blockHeight);

View file

@ -179,4 +179,11 @@ export namespace IEsploraApi {
burn_count: number;
}
export interface AddressTxSummary {
txid: string;
value: number;
height: number;
time: number;
tx_position?: number;
}
}

View file

@ -361,6 +361,10 @@ class ElectrsApi implements AbstractBitcoinApi {
return this.failoverRouter.$get<IEsploraApi.Transaction>('/tx/' + txid);
}
async $getAddressTransactionSummary(address: string): Promise<IEsploraApi.AddressTxSummary[]> {
return this.failoverRouter.$get<IEsploraApi.AddressTxSummary[]>('/address/' + address + '/txs/summary');
}
public startHealthChecks(): void {
this.failoverRouter.startHealthChecks();
}

View file

@ -0,0 +1,26 @@
import { Application, Request, Response } from 'express';
import config from '../../config';
import WalletApi from './wallets';
class ServicesRoutes {
public initRoutes(app: Application): void {
app
.get(config.MEMPOOL.API_URL_PREFIX + 'wallet/:walletId', this.$getWallet)
;
}
private async $getWallet(req: Request, res: Response): Promise<void> {
try {
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 5).toUTCString());
const walletId = req.params.walletId;
const wallet = await WalletApi.getWallet(walletId);
res.status(200).send(wallet);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
}
export default new ServicesRoutes();

View file

@ -0,0 +1,131 @@
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';
interface WalletAddress {
address: string;
active: boolean;
transactions?: IEsploraApi.AddressTxSummary[];
}
interface WalletConfig {
url: string;
name: string;
apiKey: string;
}
interface Wallet extends WalletConfig {
addresses: Record<string, WalletAddress>;
lastPoll: number;
}
const POLL_FREQUENCY = 60 * 60 * 1000; // 1 hour
class WalletApi {
private wallets: Record<string, Wallet> = {};
private syncing = false;
constructor() {
this.wallets = (config.WALLETS.WALLETS as WalletConfig[]).reduce((acc, wallet) => {
acc[wallet.name] = { ...wallet, addresses: {}, lastPoll: 0 };
return acc;
}, {} as Record<string, Wallet>);
}
public getWallet(wallet: string): Record<string, WalletAddress> {
return this.wallets?.[wallet]?.addresses || {};
}
// resync wallet addresses from the provided API
async $syncWallets(): Promise<void> {
this.syncing = true;
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(`${wallet.url}/${wallet.name}`, { headers: { 'Authorization': `${wallet.apiKey}` } });
const data: { walletBalances: WalletAddress[] } = response.data;
const addresses = data.walletBalances;
const newAddresses: Record<string, boolean> = {};
// sync all current addresses
for (const address of addresses) {
await this.$syncWalletAddress(wallet, address);
newAddresses[address.address] = true;
}
// remove old addresses
for (const address of Object.keys(wallet.addresses)) {
if (!newAddresses[address]) {
delete wallet.addresses[address];
}
}
wallet.lastPoll = Date.now();
logger.debug(`Synced ${Object.keys(wallet.addresses).length} addresses for wallet ${wallet.name}`);
} 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
const refreshTransactions = !wallet.addresses[address.address] || address.active;
if (refreshTransactions) {
try {
const walletAddress: WalletAddress = {
address: address.address,
active: address.active,
transactions: await bitcoinApi.$getAddressTransactionSummary(address.address),
};
logger.debug(`Synced ${walletAddress.transactions?.length || 0} transactions for wallet ${wallet.name} address ${address.address}`);
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, Record<string, IEsploraApi.AddressTxSummary[]>> {
const walletTransactions: Record<string, Record<string, IEsploraApi.AddressTxSummary[]>> = {};
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> = {};
for (const vin of tx.vin) {
const address = vin.prevout?.scriptpubkey_address;
if (address && wallet.addresses[address]) {
spent[address] = (spent[address] ?? 0) + (vin.prevout?.value ?? 0);
}
}
for (const vout of tx.vout) {
const address = vout.scriptpubkey_address;
if (address && wallet.addresses[address]) {
funded[address] = (funded[address] ?? 0) + (vout.value ?? 0);
}
}
for (const address of Object.keys({ ...funded, ...spent })) {
if (!walletTransactions[walletKey][address]) {
walletTransactions[walletKey][address] = [];
}
walletTransactions[walletKey][address].push({
txid: tx.txid,
value: (funded[address] ?? 0) - (spent[address] ?? 0),
height: block.height,
time: block.timestamp,
});
}
}
}
return walletTransactions;
}
}
export default new WalletApi();

View file

@ -27,6 +27,7 @@ import mempool from './mempool';
import statistics from './statistics/statistics';
import accelerationRepository from '../repositories/AccelerationRepository';
import bitcoinApi from './bitcoin/bitcoin-api-factory';
import walletApi from './services/wallets';
interface AddressTransactions {
mempool: MempoolTransactionExtended[],
@ -307,6 +308,14 @@ class WebsocketHandler {
}
}
if (parsedMessage && parsedMessage['track-wallet']) {
if (parsedMessage['track-wallet'] === 'stop') {
client['track-wallet'] = null;
} else {
client['track-wallet'] = parsedMessage['track-wallet'];
}
}
if (parsedMessage && parsedMessage['track-asset']) {
if (/^[a-fA-F0-9]{64}$/.test(parsedMessage['track-asset'])) {
client['track-asset'] = parsedMessage['track-asset'];
@ -1112,6 +1121,9 @@ class WebsocketHandler {
replaced: replacedTransactions,
};
// check for wallet transactions
const walletTransactions = config.WALLETS.ENABLED ? walletApi.processBlock(block, transactions) : [];
const responseCache = { ...this.socketData };
function getCachedResponse(key, data): string {
if (!responseCache[key]) {
@ -1316,6 +1328,11 @@ class WebsocketHandler {
response['mempool-transactions'] = getCachedResponse('mempool-transactions', mempoolDelta);
}
if (client['track-wallet']) {
const trackedWallet = client['track-wallet'];
response['wallet-transactions'] = getCachedResponse(`wallet-transactions-${trackedWallet}`, walletTransactions[trackedWallet] ?? {});
}
if (Object.keys(response).length) {
client.send(this.serializeResponse(response));
}

View file

@ -162,6 +162,14 @@ interface IConfig {
PAID: boolean;
API_KEY: string;
},
WALLETS: {
ENABLED: boolean;
WALLETS: {
url: string;
name: string;
apiKey: string;
}[];
}
}
const defaults: IConfig = {
@ -324,6 +332,10 @@ const defaults: IConfig = {
'PAID': false,
'API_KEY': '',
},
'WALLETS': {
'ENABLED': false,
'WALLETS': [],
},
};
class Config implements IConfig {
@ -345,6 +357,7 @@ class Config implements IConfig {
MEMPOOL_SERVICES: IConfig['MEMPOOL_SERVICES'];
REDIS: IConfig['REDIS'];
FIAT_PRICE: IConfig['FIAT_PRICE'];
WALLETS: IConfig['WALLETS'];
constructor() {
const configs = this.merge(configFromFile, defaults);
@ -366,6 +379,7 @@ class Config implements IConfig {
this.MEMPOOL_SERVICES = configs.MEMPOOL_SERVICES;
this.REDIS = configs.REDIS;
this.FIAT_PRICE = configs.FIAT_PRICE;
this.WALLETS = configs.WALLETS;
}
merge = (...objects: object[]): IConfig => {

View file

@ -32,6 +32,7 @@ import pricesRoutes from './api/prices/prices.routes';
import miningRoutes from './api/mining/mining-routes';
import liquidRoutes from './api/liquid/liquid.routes';
import bitcoinRoutes from './api/bitcoin/bitcoin.routes';
import servicesRoutes from './api/services/services-routes';
import fundingTxFetcher from './tasks/lightning/sync-tasks/funding-tx-fetcher';
import forensicsService from './tasks/lightning/forensics.service';
import priceUpdater from './tasks/price-updater';
@ -46,6 +47,7 @@ import bitcoinSecondClient from './api/bitcoin/bitcoin-second-client';
import accelerationRoutes from './api/acceleration/acceleration.routes';
import aboutRoutes from './api/about.routes';
import mempoolBlocks from './api/mempool-blocks';
import walletApi from './api/services/wallets';
class Server {
private wss: WebSocket.Server | undefined;
@ -238,6 +240,10 @@ class Server {
await memPool.$updateMempool(newMempool, newAccelerations, minFeeMempool, minFeeTip, pollRate);
}
indexer.$run();
if (config.WALLETS.ENABLED) {
// might take a while, so run in the background
walletApi.$syncWallets();
}
if (config.FIAT_PRICE.ENABLED) {
priceUpdater.$run();
}
@ -335,6 +341,9 @@ class Server {
if (config.MEMPOOL_SERVICES.ACCELERATIONS) {
accelerationRoutes.initRoutes(this.app);
}
if (config.WALLETS.ENABLED) {
servicesRoutes.initRoutes(this.app);
}
if (!config.MEMPOOL.OFFICIAL) {
aboutRoutes.initRoutes(this.app);
}