From 862c9591a17ec552a97b514326beeaf5af1d30cd Mon Sep 17 00:00:00 2001 From: Mononaut Date: Thu, 25 Jul 2024 22:33:32 +0000 Subject: [PATCH 1/4] 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 95c3ff2b6..e246f249d 100644 --- a/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts +++ b/backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts @@ -30,6 +30,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 4cbbf178a..b78c15bf2 100644 --- a/backend/src/api/bitcoin/bitcoin-api.ts +++ b/backend/src/api/bitcoin/bitcoin-api.ts @@ -255,6 +255,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 7b32115bb..b701aa8a5 100644 --- a/backend/src/api/bitcoin/esplora-api.ts +++ b/backend/src/api/bitcoin/esplora-api.ts @@ -361,6 +361,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 d0e8f2cbd..75b3abbcb 100644 --- a/backend/src/api/websocket-handler.ts +++ b/backend/src/api/websocket-handler.ts @@ -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)); } diff --git a/backend/src/config.ts b/backend/src/config.ts index 90b324198..ee95be62d 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -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 => { diff --git a/backend/src/index.ts b/backend/src/index.ts index 446a6a140..080f21335 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; @@ -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); } From e095192968877184680eaa203b40a6c4f0111567 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Thu, 25 Jul 2024 22:34:52 +0000 Subject: [PATCH 2/4] custom dashboard wallet widgets --- .../address-graph/address-graph.component.ts | 15 +++--- ...address-transactions-widget.component.html | 2 +- .../address-transactions-widget.component.ts | 10 +++- .../balance-widget.component.html | 4 +- .../balance-widget.component.ts | 4 +- .../custom-dashboard.component.html | 30 +++++++++++ .../custom-dashboard.component.ts | 52 +++++++++++++++++++ .../master-page-preview.component.html | 2 +- .../master-page/master-page.component.html | 6 +-- .../components/tracker/tracker.component.html | 2 +- .../src/app/interfaces/electrs.interface.ts | 1 + .../src/app/interfaces/node-api.interface.ts | 7 ++- .../src/app/interfaces/websocket.interface.ts | 1 + frontend/src/app/services/api.service.ts | 9 +++- frontend/src/app/services/state.service.ts | 3 +- .../src/app/services/websocket.service.ts | 21 ++++++++ .../global-footer.component.html | 2 +- .../app/shared/pipes/fiat-currency.pipe.ts | 2 +- 18 files changed, 149 insertions(+), 24 deletions(-) diff --git a/frontend/src/app/components/address-graph/address-graph.component.ts b/frontend/src/app/components/address-graph/address-graph.component.ts index 6d40a8ebb..229199aa2 100644 --- a/frontend/src/app/components/address-graph/address-graph.component.ts +++ b/frontend/src/app/components/address-graph/address-graph.component.ts @@ -83,7 +83,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { ngOnChanges(changes: SimpleChanges): void { this.isLoading = true; - if (!this.address || !this.stats) { + if (!this.addressSummary$ && (!this.address || !this.stats)) { return; } if (changes.address || changes.isPubkey || changes.addressSummary$ || changes.stats) { @@ -144,15 +144,16 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { } prepareChartOptions(summary: AddressTxSummary[]) { - if (!summary || !this.stats) { + if (!summary) { return; } - let total = (this.stats.funded_txo_sum - this.stats.spent_txo_sum); + const total = this.stats ? (this.stats.funded_txo_sum - this.stats.spent_txo_sum) : summary.reduce((acc, tx) => acc + tx.value, 0); + let runningTotal = total; const processData = summary.map(d => { - const balance = total; - const fiatBalance = total * d.price / 100_000_000; - total -= d.value; + const balance = runningTotal; + const fiatBalance = runningTotal * d.price / 100_000_000; + runningTotal -= d.value; return { time: d.time * 1000, balance, @@ -172,7 +173,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { this.fiatData = this.fiatData.filter(d => d[0] >= startFiat); } this.data.push( - {value: [now, this.stats.funded_txo_sum - this.stats.spent_txo_sum], symbol: 'none', tooltip: { show: false }} + {value: [now, total], symbol: 'none', tooltip: { show: false }} ); const maxValue = this.data.reduce((acc, d) => Math.max(acc, Math.abs(d[1] ?? d.value[1])), 0); diff --git a/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.html b/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.html index c1c999d6f..ea055a96f 100644 --- a/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.html +++ b/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.html @@ -12,7 +12,7 @@ - + diff --git a/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.ts b/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.ts index 998d269ba..83424791b 100644 --- a/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.ts +++ b/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.ts @@ -43,7 +43,7 @@ export class AddressTransactionsWidgetComponent implements OnInit, OnChanges, On startAddressSubscription(): void { this.isLoading = true; - if (!this.address || !this.addressInfo) { + if (!this.addressSummary$ && (!this.address || !this.addressInfo)) { return; } this.transactions$ = (this.addressSummary$ || (this.isPubkey @@ -55,7 +55,7 @@ export class AddressTransactionsWidgetComponent implements OnInit, OnChanges, On }) )).pipe( map(summary => { - return summary?.slice(0, 6); + return summary?.filter(tx => Math.abs(tx.value) >= 1000000)?.slice(0, 6); }), switchMap(txs => { return (zip(txs.map(tx => this.priceService.getBlockPrice$(tx.time, txs.length < 3, this.currency).pipe( @@ -68,6 +68,12 @@ export class AddressTransactionsWidgetComponent implements OnInit, OnChanges, On )))); }) ); + + } + + getAmountDigits(value: number): string { + const decimals = Math.max(0, 4 - Math.ceil(Math.log10(Math.abs(value / 100_000_000)))); + return `1.${decimals}-${decimals}`; } ngOnDestroy(): void { diff --git a/frontend/src/app/components/balance-widget/balance-widget.component.html b/frontend/src/app/components/balance-widget/balance-widget.component.html index 4923a2c06..87f14de53 100644 --- a/frontend/src/app/components/balance-widget/balance-widget.component.html +++ b/frontend/src/app/components/balance-widget/balance-widget.component.html @@ -4,10 +4,10 @@
BTC Holdings
- {{ ((addressInfo.chain_stats.funded_txo_sum - addressInfo.chain_stats.spent_txo_sum) / 100_000_000) | number: '1.2-2' }} BTC + {{ ((total) / 100_000_000) | number: '1.2-2' }} BTC
- +
diff --git a/frontend/src/app/components/balance-widget/balance-widget.component.ts b/frontend/src/app/components/balance-widget/balance-widget.component.ts index 8e1d3f442..f830587cc 100644 --- a/frontend/src/app/components/balance-widget/balance-widget.component.ts +++ b/frontend/src/app/components/balance-widget/balance-widget.component.ts @@ -19,6 +19,7 @@ export class BalanceWidgetComponent implements OnInit, OnChanges { isLoading: boolean = true; error: any; + total: number = 0; delta7d: number = 0; delta30d: number = 0; @@ -34,7 +35,7 @@ export class BalanceWidgetComponent implements OnInit, OnChanges { ngOnChanges(changes: SimpleChanges): void { this.isLoading = true; - if (!this.address || !this.addressInfo) { + if (!this.addressSummary$ && (!this.address || !this.addressInfo)) { return; } (this.addressSummary$ || (this.isPubkey @@ -57,6 +58,7 @@ export class BalanceWidgetComponent implements OnInit, OnChanges { calculateStats(summary: AddressTxSummary[]): void { let weekTotal = 0; let monthTotal = 0; + this.total = this.addressInfo ? this.addressInfo.chain_stats.funded_txo_sum - this.addressInfo.chain_stats.spent_txo_sum : summary.reduce((acc, tx) => acc + tx.value, 0); const weekAgo = (new Date(new Date().setHours(0, 0, 0, 0) - (7 * 24 * 60 * 60 * 1000)).getTime()) / 1000; const monthAgo = (new Date(new Date().setHours(0, 0, 0, 0) - (30 * 24 * 60 * 60 * 1000)).getTime()) / 1000; diff --git a/frontend/src/app/components/custom-dashboard/custom-dashboard.component.html b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.html index bf72aab69..65f0dc0ab 100644 --- a/frontend/src/app/components/custom-dashboard/custom-dashboard.component.html +++ b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.html @@ -257,6 +257,36 @@
} + @case ('walletBalance') { +
+
Treasury
+ +
+ } + @case ('wallet') { +
+
+
+ +
Balance History
+
+ +
+
+
+ } + @case ('walletTransactions') { +
+
+
+ +
Treasury Transactions
+
+ +
+
+
+ } @case ('twitter') {
diff --git a/frontend/src/app/components/custom-dashboard/custom-dashboard.component.ts b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.ts index fbaf7be74..622e6cf3a 100644 --- a/frontend/src/app/components/custom-dashboard/custom-dashboard.component.ts +++ b/frontend/src/app/components/custom-dashboard/custom-dashboard.component.ts @@ -62,8 +62,10 @@ export class CustomDashboardComponent implements OnInit, OnDestroy, AfterViewIni widgets; addressSubscription: Subscription; + walletSubscription: Subscription; blockTxSubscription: Subscription; addressSummary$: Observable; + walletSummary$: Observable; address: Address; goggleResolution = 82; @@ -107,6 +109,10 @@ export class CustomDashboardComponent implements OnInit, OnDestroy, AfterViewIni this.websocketService.stopTrackingAddress(); this.address = null; } + if (this.walletSubscription) { + this.walletSubscription.unsubscribe(); + this.websocketService.stopTrackingWallet(); + } this.destroy$.next(1); this.destroy$.complete(); } @@ -260,6 +266,7 @@ export class CustomDashboardComponent implements OnInit, OnDestroy, AfterViewIni }); this.startAddressSubscription(); + this.startWalletSubscription(); } handleNewMempoolData(mempoolStats: OptimizedMempoolStats[]) { @@ -358,6 +365,51 @@ export class CustomDashboardComponent implements OnInit, OnDestroy, AfterViewIni } } + startWalletSubscription(): void { + if (this.stateService.env.customize && this.stateService.env.customize.dashboard.widgets.some(w => w.props?.wallet)) { + const walletName = this.stateService.env.customize.dashboard.widgets.find(w => w.props?.wallet).props.wallet; + this.websocketService.startTrackingWallet(walletName); + + this.walletSummary$ = this.apiService.getWallet$(walletName).pipe( + catchError(e => { + return of(null); + }), + map((walletTransactions) => { + const transactions = Object.values(walletTransactions).flatMap(wallet => wallet.transactions); + return this.deduplicateWalletTransactions(transactions); + }), + switchMap(initial => this.stateService.walletTransactions$.pipe( + startWith(null), + scan((summary, walletTransactions) => { + if (walletTransactions) { + const transactions: AddressTxSummary[] = [...summary, ...Object.values(walletTransactions).flat()]; + return this.deduplicateWalletTransactions(transactions); + } + return summary; + }, initial) + )), + share(), + ); + } + } + + deduplicateWalletTransactions(walletTransactions: AddressTxSummary[]): AddressTxSummary[] { + const transactions = new Map(); + for (const tx of walletTransactions) { + if (transactions.has(tx.txid)) { + transactions.get(tx.txid).value += tx.value; + } else { + transactions.set(tx.txid, tx); + } + } + return Array.from(transactions.values()).sort((a, b) => { + if (a.height === b.height) { + return b.tx_position - a.tx_position; + } + return b.height - a.height; + }); + } + @HostListener('window:resize', ['$event']) onResize(): void { if (window.innerWidth >= 992) { diff --git a/frontend/src/app/components/master-page-preview/master-page-preview.component.html b/frontend/src/app/components/master-page-preview/master-page-preview.component.html index 8f3204ec4..01995906f 100644 --- a/frontend/src/app/components/master-page-preview/master-page-preview.component.html +++ b/frontend/src/app/components/master-page-preview/master-page-preview.component.html @@ -6,7 +6,7 @@ } @if (enterpriseInfo?.header_img) { - enterpriseInfo.title + } @else { diff --git a/frontend/src/app/components/master-page/master-page.component.html b/frontend/src/app/components/master-page/master-page.component.html index 1aa13e309..557529eef 100644 --- a/frontend/src/app/components/master-page/master-page.component.html +++ b/frontend/src/app/components/master-page/master-page.component.html @@ -19,7 +19,7 @@ @if (enterpriseInfo?.header_img) { - enterpriseInfo.title + } @else {
@@ -39,7 +39,7 @@ @if (enterpriseInfo?.header_img) { - enterpriseInfo.title + } @else {
@@ -49,7 +49,7 @@ @if (enterpriseInfo?.header_img) { - enterpriseInfo.title + } @else { diff --git a/frontend/src/app/components/tracker/tracker.component.html b/frontend/src/app/components/tracker/tracker.component.html index 4e222479b..2d9bd4982 100644 --- a/frontend/src/app/components/tracker/tracker.component.html +++ b/frontend/src/app/components/tracker/tracker.component.html @@ -4,7 +4,7 @@