Merge pull request #4831 from mempool/mononaut/wallet
Add multi-address wallet page
This commit is contained in:
		
						commit
						6e5cfa9bf2
					
				| @ -305,7 +305,7 @@ class ElectrsApi implements AbstractBitcoinApi { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   $getAddress(address: string): Promise<IEsploraApi.Address> { |   $getAddress(address: string): Promise<IEsploraApi.Address> { | ||||||
|     throw new Error('Method getAddress not implemented.'); |     return this.failoverRouter.$get<IEsploraApi.Address>('/address/' + address); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   $getAddressTransactions(address: string, txId?: string): Promise<IEsploraApi.Transaction[]> { |   $getAddressTransactions(address: string, txId?: string): Promise<IEsploraApi.Transaction[]> { | ||||||
|  | |||||||
| @ -8,7 +8,14 @@ import { TransactionExtended } from '../../mempool.interfaces'; | |||||||
| interface WalletAddress { | interface WalletAddress { | ||||||
|   address: string; |   address: string; | ||||||
|   active: boolean; |   active: boolean; | ||||||
|   transactions?: IEsploraApi.AddressTxSummary[]; |   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; |   lastSync: number; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| @ -37,7 +44,7 @@ class WalletApi { | |||||||
| 
 | 
 | ||||||
|   // resync wallet addresses from the services backend
 |   // resync wallet addresses from the services backend
 | ||||||
|   async $syncWallets(): Promise<void> { |   async $syncWallets(): Promise<void> { | ||||||
|     if (!config.WALLETS.ENABLED) { |     if (!config.WALLETS.ENABLED || this.syncing) { | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|     this.syncing = true; |     this.syncing = true; | ||||||
| @ -74,10 +81,13 @@ class WalletApi { | |||||||
|     const refreshTransactions = !wallet.addresses[address.address] || (address.active && (Date.now() - wallet.addresses[address.address].lastSync) > 60 * 60 * 1000); |     const refreshTransactions = !wallet.addresses[address.address] || (address.active && (Date.now() - wallet.addresses[address.address].lastSync) > 60 * 60 * 1000); | ||||||
|     if (refreshTransactions) { |     if (refreshTransactions) { | ||||||
|       try { |       try { | ||||||
|  |         const summary = await bitcoinApi.$getAddressTransactionSummary(address.address); | ||||||
|  |         const addressInfo = await bitcoinApi.$getAddress(address.address); | ||||||
|         const walletAddress: WalletAddress = { |         const walletAddress: WalletAddress = { | ||||||
|           address: address.address, |           address: address.address, | ||||||
|           active: address.active, |           active: address.active, | ||||||
|           transactions: await bitcoinApi.$getAddressTransactionSummary(address.address), |           transactions: summary, | ||||||
|  |           stats: addressInfo.chain_stats, | ||||||
|           lastSync: Date.now(), |           lastSync: Date.now(), | ||||||
|         }; |         }; | ||||||
|         wallet.addresses[address.address] = walletAddress; |         wallet.addresses[address.address] = walletAddress; | ||||||
| @ -88,36 +98,51 @@ class WalletApi { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   // check a new block for transactions that affect wallet address balances, and add relevant transactions to wallets
 |   // 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[]>> { |   processBlock(block: IEsploraApi.Block, blockTxs: TransactionExtended[]): Record<string, IEsploraApi.Transaction[]> { | ||||||
|     const walletTransactions: Record<string, Record<string, IEsploraApi.AddressTxSummary[]>> = {}; |     const walletTransactions: Record<string, IEsploraApi.Transaction[]> = {}; | ||||||
|     for (const walletKey of Object.keys(this.wallets)) { |     for (const walletKey of Object.keys(this.wallets)) { | ||||||
|       const wallet = this.wallets[walletKey]; |       const wallet = this.wallets[walletKey]; | ||||||
|       walletTransactions[walletKey] = {}; |       walletTransactions[walletKey] = []; | ||||||
|       for (const tx of blockTxs) { |       for (const tx of blockTxs) { | ||||||
|         const funded: Record<string, number> = {}; |         const funded: Record<string, number> = {}; | ||||||
|         const spent: 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) { |         for (const vin of tx.vin) { | ||||||
|           const address = vin.prevout?.scriptpubkey_address; |           const address = vin.prevout?.scriptpubkey_address; | ||||||
|           if (address && wallet.addresses[address]) { |           if (address && wallet.addresses[address]) { | ||||||
|  |             anyMatch = true; | ||||||
|             spent[address] = (spent[address] ?? 0) + (vin.prevout?.value ?? 0); |             spent[address] = (spent[address] ?? 0) + (vin.prevout?.value ?? 0); | ||||||
|  |             spentCount[address] = (spentCount[address] ?? 0) + 1; | ||||||
|           } |           } | ||||||
|         } |         } | ||||||
|         for (const vout of tx.vout) { |         for (const vout of tx.vout) { | ||||||
|           const address = vout.scriptpubkey_address; |           const address = vout.scriptpubkey_address; | ||||||
|           if (address && wallet.addresses[address]) { |           if (address && wallet.addresses[address]) { | ||||||
|  |             anyMatch = true; | ||||||
|             funded[address] = (funded[address] ?? 0) + (vout.value ?? 0); |             funded[address] = (funded[address] ?? 0) + (vout.value ?? 0); | ||||||
|  |             fundedCount[address] = (fundedCount[address] ?? 0) + 1; | ||||||
|           } |           } | ||||||
|         } |         } | ||||||
|         for (const address of Object.keys({ ...funded, ...spent })) { |         for (const address of Object.keys({ ...funded, ...spent })) { | ||||||
|           if (!walletTransactions[walletKey][address]) { |           // update address stats
 | ||||||
|             walletTransactions[walletKey][address] = []; |           wallet.addresses[address].stats.tx_count++; | ||||||
|           } |           wallet.addresses[address].stats.funded_txo_count += fundedCount[address] || 0; | ||||||
|           walletTransactions[walletKey][address].push({ |           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, |             txid: tx.txid, | ||||||
|             value: (funded[address] ?? 0) - (spent[address] ?? 0), |             value: (funded[address] ?? 0) - (spent[address] ?? 0), | ||||||
|             height: block.height, |             height: block.height, | ||||||
|             time: block.timestamp, |             time: block.timestamp, | ||||||
|           }); |           }; | ||||||
|  |           wallet.addresses[address].transactions?.push(txSummary); | ||||||
|  |         } | ||||||
|  |         if (anyMatch) { | ||||||
|  |           walletTransactions[walletKey].push(tx); | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|  | |||||||
| @ -31,7 +31,7 @@ let routes: Routes = [ | |||||||
|         data: { preload: true }, |         data: { preload: true }, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         path: 'wallet', |         path: 'widget/wallet', | ||||||
|         children: [], |         children: [], | ||||||
|         component: AddressGroupComponent, |         component: AddressGroupComponent, | ||||||
|         data: { |         data: { | ||||||
| @ -112,7 +112,7 @@ let routes: Routes = [ | |||||||
|         data: { preload: true }, |         data: { preload: true }, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         path: 'wallet', |         path: 'widget/wallet', | ||||||
|         children: [], |         children: [], | ||||||
|         component: AddressGroupComponent, |         component: AddressGroupComponent, | ||||||
|         data: { |         data: { | ||||||
| @ -153,7 +153,7 @@ let routes: Routes = [ | |||||||
|     data: { preload: true }, |     data: { preload: true }, | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     path: 'wallet', |     path: 'widget/wallet', | ||||||
|     children: [], |     children: [], | ||||||
|     component: AddressGroupComponent, |     component: AddressGroupComponent, | ||||||
|     data: { |     data: { | ||||||
| @ -234,7 +234,7 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') { | |||||||
|           data: { preload: true }, |           data: { preload: true }, | ||||||
|         }, |         }, | ||||||
|         { |         { | ||||||
|           path: 'wallet', |           path: 'widget/wallet', | ||||||
|           children: [], |           children: [], | ||||||
|           component: AddressGroupComponent, |           component: AddressGroupComponent, | ||||||
|           data: { |           data: { | ||||||
| @ -269,7 +269,7 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') { | |||||||
|       data: { preload: true }, |       data: { preload: true }, | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|       path: 'wallet', |       path: 'widget/wallet', | ||||||
|       children: [], |       children: [], | ||||||
|       component: AddressGroupComponent, |       component: AddressGroupComponent, | ||||||
|       data: { |       data: { | ||||||
|  | |||||||
| @ -117,7 +117,7 @@ | |||||||
|       </h2> |       </h2> | ||||||
|     </div> |     </div> | ||||||
| 
 | 
 | ||||||
|     <app-transactions-list [transactions]="transactions" [showConfirmations]="true" [address]="address.address" (loadMore)="loadMore()"></app-transactions-list> |     <app-transactions-list [transactions]="transactions" [showConfirmations]="true" [addresses]="[address.address]" (loadMore)="loadMore()"></app-transactions-list> | ||||||
| 
 | 
 | ||||||
|     <div class="text-center"> |     <div class="text-center"> | ||||||
|       <ng-template [ngIf]="isLoadingTransactions"> |       <ng-template [ngIf]="isLoadingTransactions"> | ||||||
|  | |||||||
| @ -0,0 +1,10 @@ | |||||||
|  | <div class="addresses-treemap-container"> | ||||||
|  |   <div *ngIf="addresses" style="height: 300px"> | ||||||
|  |     <div *browserOnly echarts [initOpts]="chartInitOptions" [options]="chartOptions" (chartInit)="onChartInit($event)"> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | 
 | ||||||
|  |   <div *ngIf="!stateService.isBrowser || isLoading" class="text-center loading-spinner"> | ||||||
|  |     <div class="spinner-border text-light"></div> | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
| @ -0,0 +1,17 @@ | |||||||
|  | .node-channels-container { | ||||||
|  |   position: relative; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .loading-spinner { | ||||||
|  |   position: absolute; | ||||||
|  |   top: 0; | ||||||
|  |   left: 0; | ||||||
|  |   right: 0; | ||||||
|  |   width: 100%; | ||||||
|  |   z-index: 100; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .spinner-border { | ||||||
|  |   position: relative; | ||||||
|  |   top: 225px; | ||||||
|  | } | ||||||
| @ -0,0 +1,150 @@ | |||||||
|  | import { ChangeDetectionStrategy, Component, Inject, Input, LOCALE_ID, NgZone, OnChanges } from '@angular/core'; | ||||||
|  | import { Router } from '@angular/router'; | ||||||
|  | import { EChartsOption, TreemapSeriesOption } from '../../graphs/echarts'; | ||||||
|  | import { lerpColor } from '../../shared/graphs.utils'; | ||||||
|  | import { AmountShortenerPipe } from '../../shared/pipes/amount-shortener.pipe'; | ||||||
|  | import { LightningApiService } from '../../lightning/lightning-api.service'; | ||||||
|  | import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; | ||||||
|  | import { StateService } from '../../services/state.service'; | ||||||
|  | import { Address } from '../../interfaces/electrs.interface'; | ||||||
|  | import { formatNumber } from '@angular/common'; | ||||||
|  | 
 | ||||||
|  | @Component({ | ||||||
|  |   selector: 'app-addresses-treemap', | ||||||
|  |   templateUrl: './addresses-treemap.component.html', | ||||||
|  |   styleUrls: ['./addresses-treemap.component.scss'], | ||||||
|  |   changeDetection: ChangeDetectionStrategy.OnPush, | ||||||
|  | }) | ||||||
|  | export class AddressesTreemap implements OnChanges { | ||||||
|  |   @Input() addresses: Address[]; | ||||||
|  |   @Input() isLoading: boolean = false; | ||||||
|  | 
 | ||||||
|  |   chartInstance: any; | ||||||
|  |   chartOptions: EChartsOption = {}; | ||||||
|  |   chartInitOptions = { | ||||||
|  |     renderer: 'svg', | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   constructor( | ||||||
|  |     @Inject(LOCALE_ID) public locale: string, | ||||||
|  |     private lightningApiService: LightningApiService, | ||||||
|  |     private amountShortenerPipe: AmountShortenerPipe, | ||||||
|  |     private zone: NgZone, | ||||||
|  |     private router: Router, | ||||||
|  |     public stateService: StateService, | ||||||
|  |   ) {} | ||||||
|  | 
 | ||||||
|  |   ngOnChanges(): void { | ||||||
|  |     this.prepareChartOptions(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   prepareChartOptions(): void { | ||||||
|  |     const data = this.addresses.map(address => ({ | ||||||
|  |       address: address.address, | ||||||
|  |       value: address.chain_stats.funded_txo_sum - address.chain_stats.spent_txo_sum, | ||||||
|  |       stats: address.chain_stats, | ||||||
|  |     })); | ||||||
|  |     // only consider visible items for the color gradient
 | ||||||
|  |     const totalValue = data.reduce((acc, address) => acc + address.value, 0); | ||||||
|  |     const maxTxs = data.filter(address => address.value > (totalValue / 2000)).reduce((max, address) => Math.max(max, address.stats.tx_count), 0); | ||||||
|  |     const dataItems = data.map(address => ({ | ||||||
|  |       ...address, | ||||||
|  |       itemStyle: { | ||||||
|  |         color: lerpColor('#1E88E5', '#D81B60', address.stats.tx_count / maxTxs), | ||||||
|  |       } | ||||||
|  |     })); | ||||||
|  |     this.chartOptions = { | ||||||
|  |       tooltip: { | ||||||
|  |         trigger: 'item', | ||||||
|  |         textStyle: { | ||||||
|  |           align: 'left', | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       series: <TreemapSeriesOption[]>[ | ||||||
|  |         { | ||||||
|  |           height: 300, | ||||||
|  |           left: 0, | ||||||
|  |           right: 0, | ||||||
|  |           bottom: 0, | ||||||
|  |           top: 0, | ||||||
|  |           roam: false, | ||||||
|  |           type: 'treemap', | ||||||
|  |           data: dataItems, | ||||||
|  |           nodeClick: 'link', | ||||||
|  |           progressive: 100, | ||||||
|  |           tooltip: { | ||||||
|  |             show: true, | ||||||
|  |             backgroundColor: 'rgba(17, 19, 31, 1)', | ||||||
|  |             borderRadius: 4, | ||||||
|  |             shadowColor: 'rgba(0, 0, 0, 0.5)', | ||||||
|  |             textStyle: { | ||||||
|  |               color: '#b1b1b1', | ||||||
|  |             }, | ||||||
|  |             borderColor: '#000', | ||||||
|  |             formatter: (value): string => { | ||||||
|  |               if (!value.data.address) { | ||||||
|  |                 return ''; | ||||||
|  |               } | ||||||
|  |               return ` | ||||||
|  |                 <table style="table-layout: fixed;"> | ||||||
|  |                   <tbody> | ||||||
|  |                     <tr> | ||||||
|  |                       <td colspan="2"><b style="color: white; margin-left: 2px">${value.data.address}</b></td> | ||||||
|  |                     </tr> | ||||||
|  |                     <tr> | ||||||
|  |                       <td>Received</td> | ||||||
|  |                       <td style="text-align: right">${this.formatValue(value.data.stats.funded_txo_sum)}</td> | ||||||
|  |                     </tr> | ||||||
|  |                     <tr> | ||||||
|  |                       <td>Sent</td> | ||||||
|  |                       <td style="text-align: right">${this.formatValue(value.data.stats.spent_txo_sum)}</td> | ||||||
|  |                     </tr> | ||||||
|  |                     <tr> | ||||||
|  |                       <td>Balance</td> | ||||||
|  |                       <td style="text-align: right">${this.formatValue(value.data.stats.funded_txo_sum - value.data.stats.spent_txo_sum)}</td> | ||||||
|  |                     </tr> | ||||||
|  |                     <tr> | ||||||
|  |                       <td>Transaction count</td> | ||||||
|  |                       <td style="text-align: right">${value.data.stats.tx_count}</td> | ||||||
|  |                     </tr> | ||||||
|  |                   </tbody> | ||||||
|  |                 </table> | ||||||
|  |               `;
 | ||||||
|  |             } | ||||||
|  |           }, | ||||||
|  |           itemStyle: { | ||||||
|  |             borderColor: 'black', | ||||||
|  |             borderWidth: 1, | ||||||
|  |           }, | ||||||
|  |           breadcrumb: { | ||||||
|  |             show: false, | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       ] | ||||||
|  |     };     | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   formatValue(sats: number): string { | ||||||
|  |     if (sats > 100000000) { | ||||||
|  |       return formatNumber(sats / 100000000, this.locale, '1.2-2') + ' BTC'; | ||||||
|  |     } else { | ||||||
|  |      return this.amountShortenerPipe.transform(sats, 2) + ' sats'; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   onChartInit(ec: any): void { | ||||||
|  |     this.chartInstance = ec; | ||||||
|  | 
 | ||||||
|  |     this.chartInstance.on('click', (e) => { | ||||||
|  |       //@ts-ignore
 | ||||||
|  |       if (!e.data.address) { | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |       this.zone.run(() => { | ||||||
|  |         //@ts-ignore
 | ||||||
|  |         const url = new RelativeUrlPipe(this.stateService).transform(`/address/${e.data.address}`); | ||||||
|  |         this.router.navigate([url]); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @ -267,9 +267,11 @@ | |||||||
|           <div class="col" style="max-height: 410px" [style.order]="isMobile && widget.mobileOrder || 8"> |           <div class="col" style="max-height: 410px" [style.order]="isMobile && widget.mobileOrder || 8"> | ||||||
|             <div class="card graph-card"> |             <div class="card graph-card"> | ||||||
|               <div class="card-body"> |               <div class="card-body"> | ||||||
|                 <span class="title-link"> |                 <a class="title-link mb-0" style="margin-top: -2px" href="" [routerLink]="['/wallet/' + widget.props.wallet | relativeUrl]"> | ||||||
|                   <h5 class="card-title d-inline" i18n="dashboard.balance-history">Balance History</h5> |                   <h5 class="card-title d-inline" i18n="dashboard.balance-history">Balance History</h5> | ||||||
|                 </span> |                   <span> </span> | ||||||
|  |                   <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: text-top; font-size: 13px; color: var(--title-fg)"></fa-icon> | ||||||
|  |                 </a> | ||||||
|                 <app-address-graph [addressSummary$]="walletSummary$" [period]="widget.props.period || 'all'" [widget]="true" [height]="graphHeight"></app-address-graph> |                 <app-address-graph [addressSummary$]="walletSummary$" [period]="widget.props.period || 'all'" [widget]="true" [height]="graphHeight"></app-address-graph> | ||||||
|               </div> |               </div> | ||||||
|             </div> |             </div> | ||||||
|  | |||||||
| @ -370,23 +370,47 @@ export class CustomDashboardComponent implements OnInit, OnDestroy, AfterViewIni | |||||||
|       const walletName = this.stateService.env.customize.dashboard.widgets.find(w => w.props?.wallet).props.wallet; |       const walletName = this.stateService.env.customize.dashboard.widgets.find(w => w.props?.wallet).props.wallet; | ||||||
|       this.websocketService.startTrackingWallet(walletName); |       this.websocketService.startTrackingWallet(walletName); | ||||||
| 
 | 
 | ||||||
|       this.walletSummary$ = this.apiService.getWallet$(walletName).pipe( |       this.walletSummary$ =  this.apiService.getWallet$(walletName).pipe( | ||||||
|         catchError(e => { |         catchError(e => { | ||||||
|           return of(null); |           return of({}); | ||||||
|         }), |         }), | ||||||
|         map((walletTransactions) => { |         switchMap(wallet => this.stateService.walletTransactions$.pipe( | ||||||
|           const transactions = Object.values(walletTransactions).flatMap(wallet => wallet.transactions); |           startWith([]), | ||||||
|           return this.deduplicateWalletTransactions(transactions); |           scan((summaries, newTransactions) => { | ||||||
|         }), |             const newSummaries: AddressTxSummary[] = []; | ||||||
|         switchMap(initial => this.stateService.walletTransactions$.pipe( |             for (const tx of newTransactions) { | ||||||
|           startWith(null), |               const funded: Record<string, number> = {}; | ||||||
|           scan((summary, walletTransactions) => { |               const spent: Record<string, number> = {}; | ||||||
|             if (walletTransactions) { |               const fundedCount: Record<string, number> = {}; | ||||||
|               const transactions: AddressTxSummary[] = [...summary, ...Object.values(walletTransactions).flat()]; |               const spentCount: Record<string, number> = {}; | ||||||
|               return this.deduplicateWalletTransactions(transactions); |               for (const vin of tx.vin) { | ||||||
|  |                 const address = vin.prevout?.scriptpubkey_address; | ||||||
|  |                 if (address && wallet[address]) { | ||||||
|  |                   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[address]) { | ||||||
|  |                   funded[address] = (funded[address] ?? 0) + (vout.value ?? 0); | ||||||
|  |                   fundedCount[address] = (fundedCount[address] ?? 0) + 1; | ||||||
|  |                 } | ||||||
|  |               } | ||||||
|  |               for (const address of Object.keys({ ...funded, ...spent })) { | ||||||
|  |                 // add tx to summary
 | ||||||
|  |                 const txSummary: AddressTxSummary = { | ||||||
|  |                   txid: tx.txid, | ||||||
|  |                   value: (funded[address] ?? 0) - (spent[address] ?? 0), | ||||||
|  |                   height: tx.status.block_height, | ||||||
|  |                   time: tx.status.block_time, | ||||||
|  |                 }; | ||||||
|  |                 wallet[address].transactions?.push(txSummary); | ||||||
|  |                 newSummaries.push(txSummary); | ||||||
|  |               } | ||||||
|             } |             } | ||||||
|             return summary; |             return this.deduplicateWalletTransactions([...summaries, ...newSummaries]); | ||||||
|           }, initial) |           }, this.deduplicateWalletTransactions(Object.values(wallet).flatMap(address => address.transactions))) | ||||||
|         )), |         )), | ||||||
|         share(), |         share(), | ||||||
|       ); |       ); | ||||||
|  | |||||||
| @ -23,7 +23,7 @@ | |||||||
|             <ng-template ngFor let-vin let-vindex="index" [ngForOf]="tx.vin.slice(0, getVinLimit(tx))" [ngForTrackBy]="trackByIndexFn"> |             <ng-template ngFor let-vin let-vindex="index" [ngForOf]="tx.vin.slice(0, getVinLimit(tx))" [ngForTrackBy]="trackByIndexFn"> | ||||||
|               <tr [ngClass]="{ |               <tr [ngClass]="{ | ||||||
|                 'assetBox': (assetsMinimal && vin.prevout && assetsMinimal[vin.prevout.asset] && !vin.is_coinbase && vin.prevout.scriptpubkey_address && tx._unblinded) || inputIndex === vindex, |                 'assetBox': (assetsMinimal && vin.prevout && assetsMinimal[vin.prevout.asset] && !vin.is_coinbase && vin.prevout.scriptpubkey_address && tx._unblinded) || inputIndex === vindex, | ||||||
|                 'highlight': this.address !== '' && (vin.prevout?.scriptpubkey_address === this.address || (vin.prevout?.scriptpubkey_type === 'p2pk' && vin.prevout?.scriptpubkey.slice(2, -2) === this.address)) |                 'highlight': this.addresses.length && ((vin.prevout?.scriptpubkey_type !== 'p2pk' && addresses.includes(vin.prevout?.scriptpubkey_address)) || this.addresses.includes(vin.prevout?.scriptpubkey.slice(2, -2))) | ||||||
|               }"> |               }"> | ||||||
|                 <td class="arrow-td"> |                 <td class="arrow-td"> | ||||||
|                   <ng-template [ngIf]="vin.prevout === null && !vin.is_pegin" [ngIfElse]="hasPrevout"> |                   <ng-template [ngIf]="vin.prevout === null && !vin.is_pegin" [ngIfElse]="hasPrevout"> | ||||||
| @ -99,7 +99,7 @@ | |||||||
|               </tr> |               </tr> | ||||||
|               <tr *ngIf="showOrdData[tx.txid + '-vin-' + vindex]?.show" [ngClass]="{ |               <tr *ngIf="showOrdData[tx.txid + '-vin-' + vindex]?.show" [ngClass]="{ | ||||||
|                 'assetBox': (assetsMinimal && vin.prevout && assetsMinimal[vin.prevout.asset] && !vin.is_coinbase && vin.prevout.scriptpubkey_address && tx._unblinded) || inputIndex === vindex, |                 'assetBox': (assetsMinimal && vin.prevout && assetsMinimal[vin.prevout.asset] && !vin.is_coinbase && vin.prevout.scriptpubkey_address && tx._unblinded) || inputIndex === vindex, | ||||||
|                 'highlight': this.address !== '' && (vin.prevout?.scriptpubkey_address === this.address || (vin.prevout?.scriptpubkey_type === 'p2pk' && vin.prevout?.scriptpubkey.slice(2, -2) === this.address)) |                 'highlight': addresses?.length && (addresses.includes(vin.prevout?.scriptpubkey_address) || (vin.prevout?.scriptpubkey_type === 'p2pk' && addresses.includes(vin.prevout?.scriptpubkey.slice(2, -2)))) | ||||||
|               }"> |               }"> | ||||||
|                 <td></td> |                 <td></td> | ||||||
|                 <td colspan="2"> |                 <td colspan="2"> | ||||||
| @ -214,7 +214,7 @@ | |||||||
|             <ng-template ngFor let-vout let-vindex="index" [ngForOf]="tx.vout.slice(0, getVoutLimit(tx))" [ngForTrackBy]="trackByIndexFn"> |             <ng-template ngFor let-vout let-vindex="index" [ngForOf]="tx.vout.slice(0, getVoutLimit(tx))" [ngForTrackBy]="trackByIndexFn"> | ||||||
|               <tr [ngClass]="{ |               <tr [ngClass]="{ | ||||||
|                 'assetBox': assetsMinimal && assetsMinimal[vout.asset] && vout.scriptpubkey_address && tx.vin && !tx.vin[0].is_coinbase && tx._unblinded || outputIndex === vindex, |                 'assetBox': assetsMinimal && assetsMinimal[vout.asset] && vout.scriptpubkey_address && tx.vin && !tx.vin[0].is_coinbase && tx._unblinded || outputIndex === vindex, | ||||||
|                 'highlight': this.address !== '' && (vout.scriptpubkey_address === this.address || (vout.scriptpubkey_type === 'p2pk' && vout.scriptpubkey.slice(2, -2) === this.address)) |                 'highlight': this.addresses.length && ((vout.scriptpubkey_type !== 'p2pk' && addresses.includes(vout.scriptpubkey_address)) || this.addresses.includes(vout.scriptpubkey.slice(2, -2))) | ||||||
|               }"> |               }"> | ||||||
|                 <td class="address-cell"> |                 <td class="address-cell"> | ||||||
|                   <a class="address" *ngIf="vout.scriptpubkey_address; else pubkey_type" [routerLink]="['/address/' | relativeUrl, vout.scriptpubkey_address]" title="{{ vout.scriptpubkey_address }}"> |                   <a class="address" *ngIf="vout.scriptpubkey_address; else pubkey_type" [routerLink]="['/address/' | relativeUrl, vout.scriptpubkey_address]" title="{{ vout.scriptpubkey_address }}"> | ||||||
| @ -294,7 +294,7 @@ | |||||||
| 
 | 
 | ||||||
|               <tr *ngIf="showOrdData[tx.txid + '-vout-' + vindex]?.show" [ngClass]="{ |               <tr *ngIf="showOrdData[tx.txid + '-vout-' + vindex]?.show" [ngClass]="{ | ||||||
|                 'assetBox': assetsMinimal && assetsMinimal[vout.asset] && vout.scriptpubkey_address && tx.vin && !tx.vin[0].is_coinbase && tx._unblinded || outputIndex === vindex, |                 'assetBox': assetsMinimal && assetsMinimal[vout.asset] && vout.scriptpubkey_address && tx.vin && !tx.vin[0].is_coinbase && tx._unblinded || outputIndex === vindex, | ||||||
|                 'highlight': this.address !== '' && (vout.scriptpubkey_address === this.address || (vout.scriptpubkey_type === 'p2pk' && vout.scriptpubkey.slice(2, -2) === this.address)) |                 'highlight': addresses?.length && (addresses.includes(vout.scriptpubkey_address) || (vout.scriptpubkey_type === 'p2pk' && addresses.includes(vout.scriptpubkey.slice(2, -2)))) | ||||||
|               }"> |               }"> | ||||||
|                 <td colspan="3"> |                 <td colspan="3"> | ||||||
|                     <app-ord-data [runestone]="showOrdData[tx.txid + '-vout-' + vindex]['runestone']" [runeInfo]="showOrdData[tx.txid + '-vout-' + vindex]['runeInfo']" [type]="'vout'"></app-ord-data> |                     <app-ord-data [runestone]="showOrdData[tx.txid + '-vout-' + vindex]['runestone']" [runeInfo]="showOrdData[tx.txid + '-vout-' + vindex]['runeInfo']" [type]="'vout'"></app-ord-data> | ||||||
| @ -353,7 +353,7 @@ | |||||||
|         <ng-container *ngIf="showConfirmations && latestBlock$ | async as latestBlock"> |         <ng-container *ngIf="showConfirmations && latestBlock$ | async as latestBlock"> | ||||||
|           <app-confirmations [chainTip]="latestBlock?.height" [height]="tx?.status?.block_height" buttonClass="mt-2"></app-confirmations> |           <app-confirmations [chainTip]="latestBlock?.height" [height]="tx?.status?.block_height" buttonClass="mt-2"></app-confirmations> | ||||||
|         </ng-container> |         </ng-container> | ||||||
|         <button *ngIf="address === ''; else viewingAddress" type="button" class="btn btn-sm btn-primary mt-2 ml-2" (click)="switchCurrency()"> |         <button *ngIf="addresses.length === 0; else viewingAddress" type="button" class="btn btn-sm btn-primary mt-2 ml-2" (click)="switchCurrency()"> | ||||||
|           <ng-template [ngIf]="(network === 'liquid' || network === 'liquidtestnet') && haveBlindedOutputValues(tx)" [ngIfElse]="defaultAmount" i18n="shared.confidential">Confidential</ng-template> |           <ng-template [ngIf]="(network === 'liquid' || network === 'liquidtestnet') && haveBlindedOutputValues(tx)" [ngIfElse]="defaultAmount" i18n="shared.confidential">Confidential</ng-template> | ||||||
|           <ng-template #defaultAmount> |           <ng-template #defaultAmount> | ||||||
|             <app-amount [blockConversion]="tx.price" [satoshis]="getTotalTxOutput(tx)"></app-amount> |             <app-amount [blockConversion]="tx.price" [satoshis]="getTotalTxOutput(tx)"></app-amount> | ||||||
|  | |||||||
| @ -34,7 +34,7 @@ export class TransactionsListComponent implements OnInit, OnChanges { | |||||||
|   @Input() paginated = false; |   @Input() paginated = false; | ||||||
|   @Input() inputIndex: number; |   @Input() inputIndex: number; | ||||||
|   @Input() outputIndex: number; |   @Input() outputIndex: number; | ||||||
|   @Input() address: string = ''; |   @Input() addresses: string[] = []; | ||||||
|   @Input() rowLimit = 12; |   @Input() rowLimit = 12; | ||||||
|   @Input() blockTime: number = 0; // Used for price calculation if all the transactions are in the same block
 |   @Input() blockTime: number = 0; // Used for price calculation if all the transactions are in the same block
 | ||||||
| 
 | 
 | ||||||
| @ -181,7 +181,7 @@ export class TransactionsListComponent implements OnInit, OnChanges { | |||||||
|         }, 10); |         }, 10); | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     if (changes.transactions || changes.address) { |     if (changes.transactions || changes.addresses) { | ||||||
|       if (!this.transactions || !this.transactions.length) { |       if (!this.transactions || !this.transactions.length) { | ||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
| @ -197,46 +197,52 @@ export class TransactionsListComponent implements OnInit, OnChanges { | |||||||
|           return; |           return; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         if (this.address) { |         if (this.addresses?.length) { | ||||||
|           const isP2PKUncompressed = this.address.length === 130; |           const addressIn = tx.vout.map(v => { | ||||||
|           const isP2PKCompressed = this.address.length === 66; |             for (const address of this.addresses) { | ||||||
|           if (isP2PKCompressed) { |               switch (address.length) { | ||||||
|             const addressIn = tx.vout |                 case 130: { | ||||||
|               .filter((v: Vout) => v.scriptpubkey === '21' + this.address + 'ac') |                   if (v.scriptpubkey === '21' + address + 'ac') { | ||||||
|               .map((v: Vout) => v.value || 0) |                     return v.value; | ||||||
|               .reduce((a: number, b: number) => a + b, 0); |                   } | ||||||
| 
 |                 } break; | ||||||
|             const addressOut = tx.vin |                 case 66: { | ||||||
|               .filter((v: Vin) => v.prevout && v.prevout.scriptpubkey === '21' + this.address + 'ac') |                   if (v.scriptpubkey === '41' + address + 'ac') { | ||||||
|               .map((v: Vin) => v.prevout.value || 0) |                     return v.value; | ||||||
|               .reduce((a: number, b: number) => a + b, 0); |                   } | ||||||
| 
 |                 } break; | ||||||
|             tx['addressValue'] = addressIn - addressOut; |                 default:{ | ||||||
|           } else if (isP2PKUncompressed) { |                   if (v.scriptpubkey_address === address) { | ||||||
|             const addressIn = tx.vout |                     return v.value; | ||||||
|               .filter((v: Vout) => v.scriptpubkey === '41' + this.address + 'ac') |                   } | ||||||
|               .map((v: Vout) => v.value || 0) |                 } break; | ||||||
|               .reduce((a: number, b: number) => a + b, 0); |               } | ||||||
| 
 |             } | ||||||
|             const addressOut = tx.vin |             return 0; | ||||||
|               .filter((v: Vin) => v.prevout && v.prevout.scriptpubkey === '41' + this.address + 'ac') |           }).reduce((acc, v) => acc + v, 0); | ||||||
|               .map((v: Vin) => v.prevout.value || 0) |           const addressOut = tx.vin.map(v => { | ||||||
|               .reduce((a: number, b: number) => a + b, 0); |             for (const address of this.addresses) { | ||||||
| 
 |               switch (address.length) { | ||||||
|             tx['addressValue'] = addressIn - addressOut; |                 case 130: { | ||||||
|           } else { |                   if (v.prevout?.scriptpubkey === '21' + address + 'ac') { | ||||||
|             const addressIn = tx.vout |                     return v.prevout?.value; | ||||||
|               .filter((v: Vout) => v.scriptpubkey_address === this.address) |                   } | ||||||
|               .map((v: Vout) => v.value || 0) |                 } break; | ||||||
|               .reduce((a: number, b: number) => a + b, 0); |                 case 66: { | ||||||
| 
 |                   if (v.prevout?.scriptpubkey === '41' + address + 'ac') { | ||||||
|             const addressOut = tx.vin |                     return v.prevout?.value; | ||||||
|               .filter((v: Vin) => v.prevout && v.prevout.scriptpubkey_address === this.address) |                   } | ||||||
|               .map((v: Vin) => v.prevout.value || 0) |                 } break; | ||||||
|               .reduce((a: number, b: number) => a + b, 0); |                 default:{ | ||||||
| 
 |                   if (v.prevout?.scriptpubkey_address === address) { | ||||||
|             tx['addressValue'] = addressIn - addressOut; |                     return v.prevout?.value; | ||||||
|           } |                   } | ||||||
|  |                 } break; | ||||||
|  |               } | ||||||
|  |             } | ||||||
|  |             return 0; | ||||||
|  |           }).reduce((acc, v) => acc + v, 0); | ||||||
|  |           tx['addressValue'] = addressIn - addressOut; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         if (!this.blockTime && tx.status.block_time && this.currency) { |         if (!this.blockTime && tx.status.block_time && this.currency) { | ||||||
|  | |||||||
							
								
								
									
										129
									
								
								frontend/src/app/components/wallet/wallet.component.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										129
									
								
								frontend/src/app/components/wallet/wallet.component.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,129 @@ | |||||||
|  | <div class="container-xl" [class.liquid-address]="network === 'liquid' || network === 'liquidtestnet'"> | ||||||
|  |   <div class="title-address"> | ||||||
|  |     <h1 i18n="shared.wallet">Wallet</h1> | ||||||
|  |   </div> | ||||||
|  | 
 | ||||||
|  |   <div class="clearfix"></div> | ||||||
|  | 
 | ||||||
|  |   <ng-container *ngIf="(walletAddresses$ | async) as walletAddresses; else loadingTemplate"> | ||||||
|  |     <div class="box"> | ||||||
|  | 
 | ||||||
|  |       <div class="row"> | ||||||
|  |         <div class="col-md"> | ||||||
|  |           <table class="table table-borderless table-striped address-table"> | ||||||
|  |             <tbody> | ||||||
|  |               <tr> | ||||||
|  |                 <td i18n="address.number-addresses">Addresses</td> | ||||||
|  |                 <td *ngIf="addressStrings.length" class="address-list"> | ||||||
|  |                   <app-truncate [text]="addressStrings[0]" [lastChars]="8" [link]="['/address/' | relativeUrl, addressStrings[0]]"></app-truncate> | ||||||
|  |                   <ng-container *ngIf="addressStrings.length > 1"> | ||||||
|  |                     <app-truncate [text]="addressStrings[1]" [lastChars]="8" [link]="['/address/' | relativeUrl, addressStrings[1]]"></app-truncate> | ||||||
|  |                   </ng-container> | ||||||
|  |                   <div> | ||||||
|  |                     <div #collapse="ngbCollapse" [(ngbCollapse)]="collapseAddresses"> | ||||||
|  |                       <app-truncate | ||||||
|  |                         *ngFor="let address of addressStrings | slice: (addressStrings.length > 1 ? 2 : 1)" | ||||||
|  |                         [text]="address" [lastChars]="8" [link]="['/address/' | relativeUrl, address]" | ||||||
|  |                       ></app-truncate> | ||||||
|  |                     </div> | ||||||
|  |                     <button *ngIf="addressStrings.length > 2" type="button" | ||||||
|  |                       class="btn btn-sm btn-primary small-button" (click)="collapse.toggle()" | ||||||
|  |                       [attr.aria-expanded]="!collapseAddresses" aria-controls="collapseExample"> | ||||||
|  |                       <div *ngIf="collapseAddresses"><span i18n="show-all">Show all</span> ({{ addressStrings.length }})</div> | ||||||
|  |                       <span *ngIf="!collapseAddresses" i18n="hide">Hide</span> | ||||||
|  |                     </button> | ||||||
|  |                   </div> | ||||||
|  |                 </td> | ||||||
|  |               </tr> | ||||||
|  |               <ng-container *ngIf="(walletStats$ | async) as walletStats"> | ||||||
|  |                 <tr> | ||||||
|  |                   <td i18n="address.confirmed-balance">Confirmed balance</td> | ||||||
|  |                   <td class="wrap-cell"><app-amount [satoshis]="walletStats.balance" [noFiat]="true"></app-amount> <span class="fiat"><app-fiat [value]="walletStats.balance"></app-fiat></span></td> | ||||||
|  |                 </tr> | ||||||
|  |                 <tr> | ||||||
|  |                   <td i18n="address.confirmed-utxos">Confirmed UTXOs</td> | ||||||
|  |                   <td class="wrap-cell">{{ walletStats.utxos }}</td> | ||||||
|  |                 </tr> | ||||||
|  |                 <tr> | ||||||
|  |                   <td i18n="address.total-received">Total received</td> | ||||||
|  |                   <td class="wrap-cell"><app-amount [satoshis]="walletStats.totalReceived"></app-amount></td> | ||||||
|  |                 </tr> | ||||||
|  |               </ng-container> | ||||||
|  |             </tbody> | ||||||
|  |           </table> | ||||||
|  |         </div> | ||||||
|  |         <div class="w-100 d-block d-md-none"></div> | ||||||
|  |         <div class="col-md treemap-col"> | ||||||
|  |           <app-addresses-treemap [addresses]="addresses" [isLoading]="isLoadingWallet"></app-addresses-treemap> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  | 
 | ||||||
|  |     <ng-container *ngIf="(walletStats$ | async) as walletStats"> | ||||||
|  |       <br> | ||||||
|  |       <div class="title-tx"> | ||||||
|  |         <h2 class="text-left" i18n="address.balance-history">Balance History</h2> | ||||||
|  |       </div> | ||||||
|  |       <div class="box"> | ||||||
|  |         <div class="row"> | ||||||
|  |           <div class="col-md"> | ||||||
|  |             <app-address-graph [addressSummary$]="walletSummary$" period="all" /> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </ng-container> | ||||||
|  |   </ng-container> | ||||||
|  | 
 | ||||||
|  |   <ng-template #loadingTemplate> | ||||||
|  | 
 | ||||||
|  |     <div class="box" *ngIf="!error; else errorTemplate"> | ||||||
|  |       <div class="row"> | ||||||
|  |         <div class="col"> | ||||||
|  |           <table class="table table-borderless table-striped"> | ||||||
|  |             <tbody> | ||||||
|  |               <tr> | ||||||
|  |                 <td colspan="2"><span class="skeleton-loader"></span></td> | ||||||
|  |               </tr> | ||||||
|  |               <tr> | ||||||
|  |                 <td colspan="2"><span class="skeleton-loader"></span></td> | ||||||
|  |               </tr> | ||||||
|  |               <tr> | ||||||
|  |                 <td colspan="2"><span class="skeleton-loader"></span></td> | ||||||
|  |               </tr> | ||||||
|  |             </tbody> | ||||||
|  |           </table> | ||||||
|  |         </div> | ||||||
|  |         <div class="w-100 d-block d-md-none"></div> | ||||||
|  |         <div class="col"> | ||||||
|  | 
 | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  | 
 | ||||||
|  |   </ng-template> | ||||||
|  | 
 | ||||||
|  |   <ng-template #errorTemplate> | ||||||
|  |     <br> | ||||||
|  |     <ng-template [ngIf]="error.status === 413 || error.status === 405 || error.status === 504" [ngIfElse]="displayServerError"> | ||||||
|  |       <div class="text-center"> | ||||||
|  |         <span i18n="address.error.loading-wallet-data">Error loading wallet data.</span> | ||||||
|  |         <br><br> | ||||||
|  |         <i class="small">({{ error | httpErrorMsg }})</i> | ||||||
|  |       </div> | ||||||
|  |     </ng-template> | ||||||
|  |     <ng-template #displayServerError> | ||||||
|  |       <app-http-error [error]="error"> | ||||||
|  |         <span i18n="address.error.loading-wallet-data">Error loading wallet data.</span> | ||||||
|  |       </app-http-error> | ||||||
|  |     </ng-template> | ||||||
|  |   </ng-template> | ||||||
|  | 
 | ||||||
|  | </div> | ||||||
|  | 
 | ||||||
|  | <br> | ||||||
|  | 
 | ||||||
|  | <ng-template #headerLoader> | ||||||
|  |   <div class="header-bg box" style="padding: 10px; margin-bottom: 10px;"> | ||||||
|  |     <span class="skeleton-loader"></span> | ||||||
|  |   </div> | ||||||
|  | </ng-template> | ||||||
							
								
								
									
										117
									
								
								frontend/src/app/components/wallet/wallet.component.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								frontend/src/app/components/wallet/wallet.component.scss
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,117 @@ | |||||||
|  | .qr-wrapper { | ||||||
|  |   background-color: #FFF; | ||||||
|  |   padding: 10px; | ||||||
|  |   padding-bottom: 5px; | ||||||
|  |   display: inline-block; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .treemap-col { | ||||||
|  |   width: 45%; | ||||||
|  |   height: 300px; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .fiat { | ||||||
|  |   display: block; | ||||||
|  |   @media (min-width: 992px){ | ||||||
|  |     display: inline-block; | ||||||
|  |     margin-left: 10px; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .table { | ||||||
|  |   tr td { | ||||||
|  |     &:last-child { | ||||||
|  |       text-align: right; | ||||||
|  |       @media (min-width: 576px) { | ||||||
|  |         text-align: left; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .address-list { | ||||||
|  |   width: 100%; | ||||||
|  |   max-width: 200px; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | h1 { | ||||||
|  |   margin: 0px; | ||||||
|  |   padding: 0px; | ||||||
|  |   margin-right: 10px; | ||||||
|  |   font-size: 1.9rem; | ||||||
|  |   @media (min-width: 576px) { | ||||||
|  |     font-size: 2rem; | ||||||
|  |     float: left; | ||||||
|  |   } | ||||||
|  |   @media (min-width: 768px) { | ||||||
|  |     font-size: 2.5rem; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .title-address { | ||||||
|  |   align-items: baseline; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .address-link { | ||||||
|  |   line-height: 56px; | ||||||
|  |   margin-left: 0px; | ||||||
|  |   top: -2px; | ||||||
|  |   position: relative; | ||||||
|  |   @media (min-width: 768px) { | ||||||
|  |     line-height: 69px; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .row{ | ||||||
|  | 	flex-direction: column; | ||||||
|  | 	@media (min-width: 576px) { | ||||||
|  | 		flex-direction: row; | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | @media (max-width: 767.98px) { | ||||||
|  | 	.mobile-bottomcol { | ||||||
|  | 		margin-top: 15px; | ||||||
|  | 	} | ||||||
|  | 	.details-table td:first-child { | ||||||
|  | 		white-space: pre-wrap; | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .tx-link { | ||||||
|  |   display: block; | ||||||
|  |   height: 100%; | ||||||
|  |   top: 9px; | ||||||
|  |   position: relative; | ||||||
|  | 	@media (min-width: 576px) { | ||||||
|  |     top: 11px; | ||||||
|  | 	} | ||||||
|  |   @media (min-width: 768px) { | ||||||
|  |     max-width: calc(100% - 180px); | ||||||
|  |     top: 17px; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .title-tx { | ||||||
|  |   h2 { | ||||||
|  |     line-height: 1; | ||||||
|  |     margin-bottom: 10px; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .liquid-address { | ||||||
|  |   .address-table { | ||||||
|  |     table-layout: fixed; | ||||||
|  | 
 | ||||||
|  |     tr td:first-child { | ||||||
|  |       width: 170px; | ||||||
|  |     } | ||||||
|  |     tr td:last-child { | ||||||
|  |       width: 80%; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   .qrcode-col { | ||||||
|  |     flex-grow: 0.5; | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										303
									
								
								frontend/src/app/components/wallet/wallet.component.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										303
									
								
								frontend/src/app/components/wallet/wallet.component.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,303 @@ | |||||||
|  | import { Component, OnInit, OnDestroy } from '@angular/core'; | ||||||
|  | import { ActivatedRoute, ParamMap } from '@angular/router'; | ||||||
|  | import { switchMap, catchError, map, tap, shareReplay, startWith, scan } from 'rxjs/operators'; | ||||||
|  | import { Address, AddressTxSummary, ChainStats, Transaction } from '../../interfaces/electrs.interface'; | ||||||
|  | import { WebsocketService } from '../../services/websocket.service'; | ||||||
|  | import { StateService } from '../../services/state.service'; | ||||||
|  | import { ApiService } from '../../services/api.service'; | ||||||
|  | import { of, Observable, Subscription } from 'rxjs'; | ||||||
|  | import { SeoService } from '../../services/seo.service'; | ||||||
|  | import { seoDescriptionNetwork } from '../../shared/common.utils'; | ||||||
|  | import { WalletAddress } from '../../interfaces/node-api.interface'; | ||||||
|  | 
 | ||||||
|  | class WalletStats implements ChainStats { | ||||||
|  |   addresses: string[]; | ||||||
|  |   funded_txo_count: number; | ||||||
|  |   funded_txo_sum: number; | ||||||
|  |   spent_txo_count: number; | ||||||
|  |   spent_txo_sum: number; | ||||||
|  |   tx_count: number; | ||||||
|  | 
 | ||||||
|  |   constructor (stats: ChainStats[], addresses: string[]) { | ||||||
|  |     Object.assign(this, stats.reduce((acc, stat) => { | ||||||
|  |         acc.funded_txo_count += stat.funded_txo_count; | ||||||
|  |         acc.funded_txo_sum += stat.funded_txo_sum; | ||||||
|  |         acc.spent_txo_count += stat.spent_txo_count; | ||||||
|  |         acc.spent_txo_sum += stat.spent_txo_sum; | ||||||
|  |         return acc; | ||||||
|  |       }, { | ||||||
|  |         funded_txo_count: 0, | ||||||
|  |         funded_txo_sum: 0, | ||||||
|  |         spent_txo_count: 0, | ||||||
|  |         spent_txo_sum: 0, | ||||||
|  |         tx_count: 0, | ||||||
|  |       }) | ||||||
|  |     ); | ||||||
|  |     this.addresses = addresses; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   public addTx(tx: Transaction): void { | ||||||
|  |     for (const vin of tx.vin) { | ||||||
|  |       if (this.addresses.includes(vin.prevout?.scriptpubkey_address)) { | ||||||
|  |         this.spendTxo(vin.prevout.value); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     for (const vout of tx.vout) { | ||||||
|  |       if (this.addresses.includes(vout.scriptpubkey_address)) { | ||||||
|  |         this.fundTxo(vout.value); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     this.tx_count++; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   public removeTx(tx: Transaction): void { | ||||||
|  |     for (const vin of tx.vin) { | ||||||
|  |       if (this.addresses.includes(vin.prevout?.scriptpubkey_address)) { | ||||||
|  |         this.unspendTxo(vin.prevout.value); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     for (const vout of tx.vout) { | ||||||
|  |       if (this.addresses.includes(vout.scriptpubkey_address)) { | ||||||
|  |         this.unfundTxo(vout.value); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     this.tx_count--; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private fundTxo(value: number): void { | ||||||
|  |     this.funded_txo_sum += value; | ||||||
|  |     this.funded_txo_count++; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private unfundTxo(value: number): void { | ||||||
|  |     this.funded_txo_sum -= value; | ||||||
|  |     this.funded_txo_count--; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private spendTxo(value: number): void { | ||||||
|  |     this.spent_txo_sum += value; | ||||||
|  |     this.spent_txo_count++; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private unspendTxo(value: number): void { | ||||||
|  |     this.spent_txo_sum -= value; | ||||||
|  |     this.spent_txo_count--; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   get balance(): number { | ||||||
|  |     return this.funded_txo_sum - this.spent_txo_sum; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   get totalReceived(): number { | ||||||
|  |     return this.funded_txo_sum; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   get utxos(): number { | ||||||
|  |     return this.funded_txo_count - this.spent_txo_count; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | @Component({ | ||||||
|  |   selector: 'app-wallet', | ||||||
|  |   templateUrl: './wallet.component.html', | ||||||
|  |   styleUrls: ['./wallet.component.scss'] | ||||||
|  | }) | ||||||
|  | export class WalletComponent implements OnInit, OnDestroy { | ||||||
|  |   network = ''; | ||||||
|  | 
 | ||||||
|  |   addresses: Address[] = []; | ||||||
|  |   addressStrings: string[] = []; | ||||||
|  |   walletName: string; | ||||||
|  |   isLoadingWallet = true; | ||||||
|  |   wallet$: Observable<Record<string, WalletAddress>>; | ||||||
|  |   walletAddresses$: Observable<Record<string, Address>>; | ||||||
|  |   walletSummary$: Observable<AddressTxSummary[]>; | ||||||
|  |   walletStats$: Observable<WalletStats>; | ||||||
|  |   error: any; | ||||||
|  |   walletSubscription: Subscription; | ||||||
|  | 
 | ||||||
|  |   collapseAddresses: boolean = true; | ||||||
|  | 
 | ||||||
|  |   fullyLoaded = false; | ||||||
|  |   txCount = 0; | ||||||
|  |   received = 0; | ||||||
|  |   sent = 0; | ||||||
|  |   chainBalance = 0; | ||||||
|  | 
 | ||||||
|  |   constructor( | ||||||
|  |     private route: ActivatedRoute, | ||||||
|  |     private websocketService: WebsocketService, | ||||||
|  |     private stateService: StateService, | ||||||
|  |     private apiService: ApiService, | ||||||
|  |     private seoService: SeoService, | ||||||
|  |   ) { } | ||||||
|  | 
 | ||||||
|  |   ngOnInit(): void { | ||||||
|  |     this.stateService.networkChanged$.subscribe((network) => this.network = network); | ||||||
|  |     this.websocketService.want(['blocks']); | ||||||
|  |     this.wallet$ = this.route.paramMap.pipe( | ||||||
|  |       map((params: ParamMap) => params.get('wallet') as string), | ||||||
|  |       tap((walletName: string) => { | ||||||
|  |         this.walletName = walletName; | ||||||
|  |         this.websocketService.startTrackingWallet(walletName); | ||||||
|  |         this.seoService.setTitle($localize`:@@wallet.component.browser-title:Wallet: ${walletName}:INTERPOLATION:`); | ||||||
|  |         this.seoService.setDescription($localize`:@@meta.description.bitcoin.wallet:See mempool transactions, confirmed transactions, balance, and more for ${this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet'?'Liquid':'Bitcoin'}${seoDescriptionNetwork(this.stateService.network)} wallet ${walletName}:INTERPOLATION:.`); | ||||||
|  |       }), | ||||||
|  |       switchMap((walletName: string) => this.apiService.getWallet$(walletName).pipe( | ||||||
|  |         catchError((err) => { | ||||||
|  |           this.error = err; | ||||||
|  |           this.seoService.logSoft404(); | ||||||
|  |           console.log(err); | ||||||
|  |           return of({}); | ||||||
|  |         }) | ||||||
|  |       )), | ||||||
|  |       shareReplay(1), | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     this.walletAddresses$ = this.wallet$.pipe( | ||||||
|  |       map(wallet => { | ||||||
|  |         const walletInfo: Record<string, Address> = {}; | ||||||
|  |         for (const address of Object.keys(wallet)) { | ||||||
|  |           walletInfo[address] = { | ||||||
|  |             address, | ||||||
|  |             chain_stats: wallet[address].stats, | ||||||
|  |             mempool_stats: { | ||||||
|  |               funded_txo_count: 0, | ||||||
|  |               funded_txo_sum: 0, | ||||||
|  |               spent_txo_count: 0, spent_txo_sum: 0, tx_count: 0 | ||||||
|  |             }, | ||||||
|  |           }; | ||||||
|  |         } | ||||||
|  |         return walletInfo; | ||||||
|  |       }), | ||||||
|  |       switchMap(initial => this.stateService.walletTransactions$.pipe( | ||||||
|  |         startWith(null), | ||||||
|  |         scan((wallet, walletTransactions) => { | ||||||
|  |           for (const tx of (walletTransactions || [])) { | ||||||
|  |             const funded: Record<string, number> = {}; | ||||||
|  |             const spent: Record<string, number> = {}; | ||||||
|  |             const fundedCount: Record<string, number> = {}; | ||||||
|  |             const spentCount: Record<string, number> = {}; | ||||||
|  |             for (const vin of tx.vin) { | ||||||
|  |               const address = vin.prevout?.scriptpubkey_address; | ||||||
|  |               if (address && wallet[address]) { | ||||||
|  |                 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[address]) { | ||||||
|  |                 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[address].chain_stats.tx_count++; | ||||||
|  |               wallet[address].chain_stats.funded_txo_count += fundedCount[address] || 0; | ||||||
|  |               wallet[address].chain_stats.spent_txo_count += spentCount[address] || 0; | ||||||
|  |               wallet[address].chain_stats.funded_txo_sum += funded[address] || 0; | ||||||
|  |               wallet[address].chain_stats.spent_txo_sum += spent[address] || 0; | ||||||
|  |             } | ||||||
|  |           } | ||||||
|  |           return wallet; | ||||||
|  |         }, initial) | ||||||
|  |       )), | ||||||
|  |       tap(() => { | ||||||
|  |         this.isLoadingWallet = false; | ||||||
|  |       }) | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     this.walletSubscription = this.walletAddresses$.subscribe(wallet => { | ||||||
|  |       this.addressStrings = Object.keys(wallet); | ||||||
|  |       this.addresses = Object.values(wallet); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     this.walletSummary$ = this.wallet$.pipe( | ||||||
|  |       switchMap(wallet => this.stateService.walletTransactions$.pipe( | ||||||
|  |         startWith([]), | ||||||
|  |         scan((summaries, newTransactions) => { | ||||||
|  |           const newSummaries: AddressTxSummary[] = []; | ||||||
|  |           for (const tx of newTransactions) { | ||||||
|  |             const funded: Record<string, number> = {}; | ||||||
|  |             const spent: Record<string, number> = {}; | ||||||
|  |             const fundedCount: Record<string, number> = {}; | ||||||
|  |             const spentCount: Record<string, number> = {}; | ||||||
|  |             for (const vin of tx.vin) { | ||||||
|  |               const address = vin.prevout?.scriptpubkey_address; | ||||||
|  |               if (address && wallet[address]) { | ||||||
|  |                 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[address]) { | ||||||
|  |                 funded[address] = (funded[address] ?? 0) + (vout.value ?? 0); | ||||||
|  |                 fundedCount[address] = (fundedCount[address] ?? 0) + 1; | ||||||
|  |               } | ||||||
|  |             } | ||||||
|  |             for (const address of Object.keys({ ...funded, ...spent })) { | ||||||
|  |               // add tx to summary
 | ||||||
|  |               const txSummary: AddressTxSummary = { | ||||||
|  |                 txid: tx.txid, | ||||||
|  |                 value: (funded[address] ?? 0) - (spent[address] ?? 0), | ||||||
|  |                 height: tx.status.block_height, | ||||||
|  |                 time: tx.status.block_time, | ||||||
|  |               }; | ||||||
|  |               wallet[address].transactions?.push(txSummary); | ||||||
|  |               newSummaries.push(txSummary); | ||||||
|  |             } | ||||||
|  |           } | ||||||
|  |           return this.deduplicateWalletTransactions([...summaries, ...newSummaries]); | ||||||
|  |         }, this.deduplicateWalletTransactions(Object.values(wallet).flatMap(address => address.transactions))) | ||||||
|  |       )) | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     this.walletStats$ = this.wallet$.pipe( | ||||||
|  |       switchMap(wallet => { | ||||||
|  |         const walletStats = new WalletStats(Object.values(wallet).map(w => w.stats), Object.keys(wallet)); | ||||||
|  |         return this.stateService.walletTransactions$.pipe( | ||||||
|  |           startWith([]), | ||||||
|  |           scan((stats, newTransactions) => { | ||||||
|  |             for (const tx of newTransactions) { | ||||||
|  |               stats.addTx(tx); | ||||||
|  |             } | ||||||
|  |             return stats; | ||||||
|  |           }, walletStats), | ||||||
|  |         ); | ||||||
|  |       }), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   deduplicateWalletTransactions(walletTransactions: AddressTxSummary[]): AddressTxSummary[] { | ||||||
|  |     const transactions = new Map<string, AddressTxSummary>(); | ||||||
|  |     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; | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   normalizeAddress(address: string): string { | ||||||
|  |     if (/^[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100}|04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}$/.test(address)) { | ||||||
|  |       return address.toLowerCase(); | ||||||
|  |     } else { | ||||||
|  |       return address; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   ngOnDestroy(): void { | ||||||
|  |     this.websocketService.stopTrackingWallet(); | ||||||
|  |     this.walletSubscription.unsubscribe(); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @ -35,9 +35,11 @@ import { HashrateChartComponent } from '../components/hashrate-chart/hashrate-ch | |||||||
| import { HashrateChartPoolsComponent } from '../components/hashrates-chart-pools/hashrate-chart-pools.component'; | import { HashrateChartPoolsComponent } from '../components/hashrates-chart-pools/hashrate-chart-pools.component'; | ||||||
| import { BlockHealthGraphComponent } from '../components/block-health-graph/block-health-graph.component'; | import { BlockHealthGraphComponent } from '../components/block-health-graph/block-health-graph.component'; | ||||||
| import { AddressComponent } from '../components/address/address.component'; | import { AddressComponent } from '../components/address/address.component'; | ||||||
|  | import { WalletComponent } from '../components/wallet/wallet.component'; | ||||||
| import { AddressGraphComponent } from '../components/address-graph/address-graph.component'; | import { AddressGraphComponent } from '../components/address-graph/address-graph.component'; | ||||||
| import { UtxoGraphComponent } from '../components/utxo-graph/utxo-graph.component'; | import { UtxoGraphComponent } from '../components/utxo-graph/utxo-graph.component'; | ||||||
| import { ActiveAccelerationBox } from '../components/acceleration/active-acceleration-box/active-acceleration-box.component'; | import { ActiveAccelerationBox } from '../components/acceleration/active-acceleration-box/active-acceleration-box.component'; | ||||||
|  | import { AddressesTreemap } from '../components/addresses-treemap/addresses-treemap.component'; | ||||||
| import { CommonModule } from '@angular/common'; | import { CommonModule } from '@angular/common'; | ||||||
| 
 | 
 | ||||||
| @NgModule({ | @NgModule({ | ||||||
| @ -46,6 +48,7 @@ import { CommonModule } from '@angular/common'; | |||||||
|     CustomDashboardComponent, |     CustomDashboardComponent, | ||||||
|     MempoolBlockComponent, |     MempoolBlockComponent, | ||||||
|     AddressComponent, |     AddressComponent, | ||||||
|  |     WalletComponent, | ||||||
| 
 | 
 | ||||||
|     MiningDashboardComponent, |     MiningDashboardComponent, | ||||||
|     AcceleratorDashboardComponent, |     AcceleratorDashboardComponent, | ||||||
| @ -79,6 +82,7 @@ import { CommonModule } from '@angular/common'; | |||||||
|     AddressGraphComponent, |     AddressGraphComponent, | ||||||
|     UtxoGraphComponent, |     UtxoGraphComponent, | ||||||
|     ActiveAccelerationBox, |     ActiveAccelerationBox, | ||||||
|  |     AddressesTreemap, | ||||||
|   ], |   ], | ||||||
|   imports: [ |   imports: [ | ||||||
|     CommonModule, |     CommonModule, | ||||||
|  | |||||||
| @ -22,6 +22,7 @@ import { CustomDashboardComponent } from '../components/custom-dashboard/custom- | |||||||
| import { AccelerationFeesGraphComponent } from '../components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component'; | import { AccelerationFeesGraphComponent } from '../components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component'; | ||||||
| import { AccelerationsListComponent } from '../components/acceleration/accelerations-list/accelerations-list.component'; | import { AccelerationsListComponent } from '../components/acceleration/accelerations-list/accelerations-list.component'; | ||||||
| import { AddressComponent } from '../components/address/address.component'; | import { AddressComponent } from '../components/address/address.component'; | ||||||
|  | import { WalletComponent } from '../components/wallet/wallet.component'; | ||||||
| 
 | 
 | ||||||
| const browserWindow = window || {}; | const browserWindow = window || {}; | ||||||
| // @ts-ignore
 | // @ts-ignore
 | ||||||
| @ -88,6 +89,15 @@ const routes: Routes = [ | |||||||
|           networkSpecific: true, |           networkSpecific: true, | ||||||
|         } |         } | ||||||
|       }, |       }, | ||||||
|  |       { | ||||||
|  |         path: 'wallet/:wallet', | ||||||
|  |         children: [], | ||||||
|  |         component: WalletComponent, | ||||||
|  |         data: { | ||||||
|  |           ogImage: true, | ||||||
|  |           networkSpecific: true, | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|       { |       { | ||||||
|         path: 'graphs', |         path: 'graphs', | ||||||
|         data: { networks: ['bitcoin', 'liquid'] }, |         data: { networks: ['bitcoin', 'liquid'] }, | ||||||
|  | |||||||
| @ -1,4 +1,4 @@ | |||||||
| import { AddressTxSummary, Block, Transaction } from "./electrs.interface"; | import { AddressTxSummary, Block, ChainStats, Transaction } from "./electrs.interface"; | ||||||
| 
 | 
 | ||||||
| export interface OptimizedMempoolStats { | export interface OptimizedMempoolStats { | ||||||
|   added: number; |   added: number; | ||||||
| @ -474,5 +474,6 @@ export interface TxResult { | |||||||
| export interface WalletAddress { | export interface WalletAddress { | ||||||
|   address: string; |   address: string; | ||||||
|   active: boolean; |   active: boolean; | ||||||
|   transactions?: AddressTxSummary[]; |   stats: ChainStats; | ||||||
|  |   transactions: AddressTxSummary[]; | ||||||
| } | } | ||||||
|  | |||||||
| @ -142,6 +142,14 @@ export class ElectrsApiService { | |||||||
|     return this.httpClient.get<Transaction[]>(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address + '/txs', { params }); |     return this.httpClient.get<Transaction[]>(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address + '/txs', { params }); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   getAddressesTransactions$(addresses: string[],  txid?: string): Observable<Transaction[]> { | ||||||
|  |     let params = new HttpParams(); | ||||||
|  |     if (txid) { | ||||||
|  |       params = params.append('after_txid', txid); | ||||||
|  |     } | ||||||
|  |     return this.httpClient.get<Transaction[]>(this.apiBaseUrl + this.apiBasePath + `/api/addresses/txs?addresses=${addresses.join(',')}`, { params }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   getAddressSummary$(address: string,  txid?: string): Observable<AddressTxSummary[]> { |   getAddressSummary$(address: string,  txid?: string): Observable<AddressTxSummary[]> { | ||||||
|     let params = new HttpParams(); |     let params = new HttpParams(); | ||||||
|     if (txid) { |     if (txid) { | ||||||
| @ -150,6 +158,14 @@ export class ElectrsApiService { | |||||||
|     return this.httpClient.get<AddressTxSummary[]>(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address + '/txs/summary', { params }); |     return this.httpClient.get<AddressTxSummary[]>(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address + '/txs/summary', { params }); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   getAddressesSummary$(addresses: string[],  txid?: string): Observable<AddressTxSummary[]> { | ||||||
|  |     let params = new HttpParams(); | ||||||
|  |     if (txid) { | ||||||
|  |       params = params.append('after_txid', txid); | ||||||
|  |     } | ||||||
|  |     return this.httpClient.get<AddressTxSummary[]>(this.apiBaseUrl + this.apiBasePath + `/api/addresses/txs/summary?addresses=${addresses.join(',')}`, { params }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   getScriptHashTransactions$(script: string,  txid?: string): Observable<Transaction[]> { |   getScriptHashTransactions$(script: string,  txid?: string): Observable<Transaction[]> { | ||||||
|     let params = new HttpParams(); |     let params = new HttpParams(); | ||||||
|     if (txid) { |     if (txid) { | ||||||
| @ -160,6 +176,16 @@ export class ElectrsApiService { | |||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   getScriptHashesTransactions$(scripts: string[],  txid?: string): Observable<Transaction[]> { | ||||||
|  |     let params = new HttpParams(); | ||||||
|  |     if (txid) { | ||||||
|  |       params = params.append('after_txid', txid); | ||||||
|  |     } | ||||||
|  |     return from(Promise.all(scripts.map(script => calcScriptHash$(script)))).pipe( | ||||||
|  |       switchMap(scriptHashes => this.httpClient.get<Transaction[]>(this.apiBaseUrl + this.apiBasePath + `/api/scripthashes/txs?scripthashes=${scriptHashes.join(',')}`, { params })), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   getScriptHashSummary$(script: string,  txid?: string): Observable<AddressTxSummary[]> { |   getScriptHashSummary$(script: string,  txid?: string): Observable<AddressTxSummary[]> { | ||||||
|     let params = new HttpParams(); |     let params = new HttpParams(); | ||||||
|     if (txid) { |     if (txid) { | ||||||
| @ -180,6 +206,16 @@ export class ElectrsApiService { | |||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   getScriptHashesSummary$(scripts: string[],  txid?: string): Observable<AddressTxSummary[]> { | ||||||
|  |     let params = new HttpParams(); | ||||||
|  |     if (txid) { | ||||||
|  |       params = params.append('after_txid', txid); | ||||||
|  |     } | ||||||
|  |     return from(Promise.all(scripts.map(script => calcScriptHash$(script)))).pipe( | ||||||
|  |       switchMap(scriptHashes => this.httpClient.get<AddressTxSummary[]>(this.apiBaseUrl + this.apiBasePath + `/api/scripthashes/txs/summary?scripthashes=${scriptHashes.join(',')}`, { params })), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   getAsset$(assetId: string): Observable<Asset> { |   getAsset$(assetId: string): Observable<Asset> { | ||||||
|     return this.httpClient.get<Asset>(this.apiBaseUrl + this.apiBasePath + '/api/asset/' + assetId); |     return this.httpClient.get<Asset>(this.apiBaseUrl + this.apiBasePath + '/api/asset/' + assetId); | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -159,7 +159,7 @@ export class StateService { | |||||||
|   mempoolRemovedTransactions$ = new Subject<Transaction>(); |   mempoolRemovedTransactions$ = new Subject<Transaction>(); | ||||||
|   multiAddressTransactions$ = new Subject<{ [address: string]: { mempool: Transaction[], confirmed: Transaction[], removed: Transaction[] }}>(); |   multiAddressTransactions$ = new Subject<{ [address: string]: { mempool: Transaction[], confirmed: Transaction[], removed: Transaction[] }}>(); | ||||||
|   blockTransactions$ = new Subject<Transaction>(); |   blockTransactions$ = new Subject<Transaction>(); | ||||||
|   walletTransactions$ = new Subject<Record<string, AddressTxSummary[]>>(); |   walletTransactions$ = new Subject<Transaction[]>(); | ||||||
|   isLoadingWebSocket$ = new ReplaySubject<boolean>(1); |   isLoadingWebSocket$ = new ReplaySubject<boolean>(1); | ||||||
|   isLoadingMempool$ = new BehaviorSubject<boolean>(true); |   isLoadingMempool$ = new BehaviorSubject<boolean>(true); | ||||||
|   vbytesPerSecond$ = new ReplaySubject<number>(1); |   vbytesPerSecond$ = new ReplaySubject<number>(1); | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user