From 4d06636d83db492c5e00e5cfdc97dfbbf70b32ed Mon Sep 17 00:00:00 2001 From: Mononaut Date: Thu, 25 Jul 2024 22:33:32 +0000 Subject: [PATCH] wallet tracking backend support --- .../bitcoin/bitcoin-api-abstract-factory.ts | 1 + backend/src/api/bitcoin/bitcoin-api.ts | 4 + .../src/api/bitcoin/esplora-api.interface.ts | 7 + backend/src/api/bitcoin/esplora-api.ts | 4 + backend/src/api/services/services-routes.ts | 26 ++++ backend/src/api/services/wallets.ts | 131 ++++++++++++++++++ backend/src/api/websocket-handler.ts | 17 +++ backend/src/config.ts | 14 ++ backend/src/index.ts | 9 ++ 9 files changed, 213 insertions(+) create mode 100644 backend/src/api/services/services-routes.ts create mode 100644 backend/src/api/services/wallets.ts diff --git a/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts b/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts index a08f43238..8413afd9f 100644 --- a/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts +++ b/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts @@ -29,6 +29,7 @@ export interface AbstractBitcoinApi { $getBatchedOutspendsInternal(txId: string[]): Promise; $getOutSpendsByOutpoint(outpoints: { txid: string, vout: number }[]): Promise; $getCoinbaseTx(blockhash: string): Promise; + $getAddressTransactionSummary(address: string): Promise; startHealthChecks(): void; getHealthStatus(): HealthCheckHost[]; diff --git a/backend/src/api/bitcoin/bitcoin-api.ts b/backend/src/api/bitcoin/bitcoin-api.ts index 3e1fe2108..0b62f672d 100644 --- a/backend/src/api/bitcoin/bitcoin-api.ts +++ b/backend/src/api/bitcoin/bitcoin-api.ts @@ -251,6 +251,10 @@ class BitcoinApi implements AbstractBitcoinApi { return this.$getRawTransaction(txids[0]); } + async $getAddressTransactionSummary(address: string): Promise { + throw new Error('Method getAddressTransactionSummary not supported by the Bitcoin RPC API.'); + } + $getEstimatedHashrate(blockHeight: number): Promise { // 120 is the default block span in Core return this.bitcoindClient.getNetworkHashPs(120, blockHeight); diff --git a/backend/src/api/bitcoin/esplora-api.interface.ts b/backend/src/api/bitcoin/esplora-api.interface.ts index 6e6860a41..13fb3526d 100644 --- a/backend/src/api/bitcoin/esplora-api.interface.ts +++ b/backend/src/api/bitcoin/esplora-api.interface.ts @@ -179,4 +179,11 @@ export namespace IEsploraApi { burn_count: number; } + export interface AddressTxSummary { + txid: string; + value: number; + height: number; + time: number; + tx_position?: number; + } } diff --git a/backend/src/api/bitcoin/esplora-api.ts b/backend/src/api/bitcoin/esplora-api.ts index b4ae35da9..a27ca35e9 100644 --- a/backend/src/api/bitcoin/esplora-api.ts +++ b/backend/src/api/bitcoin/esplora-api.ts @@ -357,6 +357,10 @@ class ElectrsApi implements AbstractBitcoinApi { return this.failoverRouter.$get('/tx/' + txid); } + async $getAddressTransactionSummary(address: string): Promise { + return this.failoverRouter.$get('/address/' + address + '/txs/summary'); + } + public startHealthChecks(): void { this.failoverRouter.startHealthChecks(); } diff --git a/backend/src/api/services/services-routes.ts b/backend/src/api/services/services-routes.ts new file mode 100644 index 000000000..cff163174 --- /dev/null +++ b/backend/src/api/services/services-routes.ts @@ -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 { + 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(); diff --git a/backend/src/api/services/wallets.ts b/backend/src/api/services/wallets.ts new file mode 100644 index 000000000..b20087ead --- /dev/null +++ b/backend/src/api/services/wallets.ts @@ -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; + lastPoll: number; +} + +const POLL_FREQUENCY = 60 * 60 * 1000; // 1 hour + +class WalletApi { + private wallets: Record = {}; + 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); + } + + public getWallet(wallet: string): Record { + return this.wallets?.[wallet]?.addresses || {}; + } + + // resync wallet addresses from the provided API + async $syncWallets(): Promise { + 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 = {}; + // 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 { + // 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> { + const walletTransactions: Record> = {}; + for (const walletKey of Object.keys(this.wallets)) { + const wallet = this.wallets[walletKey]; + walletTransactions[walletKey] = {}; + for (const tx of blockTxs) { + const funded: Record = {}; + const spent: Record = {}; + 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(); \ No newline at end of file diff --git a/backend/src/api/websocket-handler.ts b/backend/src/api/websocket-handler.ts index 32d306ad2..37f50fafc 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -26,6 +26,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[], @@ -305,6 +306,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']; @@ -1085,6 +1094,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]) { @@ -1287,6 +1299,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)); } diff --git a/backend/src/config.ts b/backend/src/config.ts index b0afe7f23..e272d8d39 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -160,6 +160,14 @@ interface IConfig { PAID: boolean; API_KEY: string; }, + WALLETS: { + ENABLED: boolean; + WALLETS: { + url: string; + name: string; + apiKey: string; + }[]; + } } const defaults: IConfig = { @@ -320,6 +328,10 @@ const defaults: IConfig = { 'PAID': false, 'API_KEY': '', }, + 'WALLETS': { + 'ENABLED': false, + 'WALLETS': [], + }, }; class Config implements IConfig { @@ -341,6 +353,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); @@ -362,6 +375,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 => { diff --git a/backend/src/index.ts b/backend/src/index.ts index 1d83c56a3..0e43dbe31 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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; @@ -236,6 +238,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(); } @@ -333,6 +339,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); }