From 96a17f09a5adf69bae96c25ea4ad38939eaa4f8e Mon Sep 17 00:00:00 2001 From: Mononaut Date: Fri, 16 Feb 2024 02:30:51 +0000 Subject: [PATCH] Add embeddable wallet balance page --- frontend/src/app/app-routing.module.ts | 41 ++++ .../address-group.component.html | 20 ++ .../address-group.component.scss | 82 +++++++ .../address-group/address-group.component.ts | 212 ++++++++++++++++++ frontend/src/app/master-page.module.ts | 1 + frontend/src/app/services/state.service.ts | 1 + .../src/app/services/websocket.service.ts | 18 ++ .../truncate/truncate.component.html | 2 +- .../components/truncate/truncate.component.ts | 1 + frontend/src/app/shared/shared.module.ts | 3 + 10 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/components/address-group/address-group.component.html create mode 100644 frontend/src/app/components/address-group/address-group.component.scss create mode 100644 frontend/src/app/components/address-group/address-group.component.ts diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index e123a1525..1fe196090 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -6,6 +6,7 @@ import { EightBlocksComponent } from './components/eight-blocks/eight-blocks.com import { MempoolBlockViewComponent } from './components/mempool-block-view/mempool-block-view.component'; import { ClockComponent } from './components/clock/clock.component'; import { StatusViewComponent } from './components/status-view/status-view.component'; +import { AddressGroupComponent } from './components/address-group/address-group.component'; const browserWindow = window || {}; // @ts-ignore @@ -26,6 +27,14 @@ let routes: Routes = [ loadChildren: () => import('./master-page.module').then(m => m.MasterPageModule), data: { preload: true }, }, + { + path: 'wallet', + children: [], + component: AddressGroupComponent, + data: { + networkSpecific: true, + } + }, { path: 'status', data: { networks: ['bitcoin', 'liquid'] }, @@ -61,6 +70,14 @@ let routes: Routes = [ loadChildren: () => import('./master-page.module').then(m => m.MasterPageModule), data: { preload: true }, }, + { + path: 'wallet', + children: [], + component: AddressGroupComponent, + data: { + networkSpecific: true, + } + }, { path: 'status', data: { networks: ['bitcoin', 'liquid'] }, @@ -88,6 +105,14 @@ let routes: Routes = [ loadChildren: () => import('./master-page.module').then(m => m.MasterPageModule), data: { preload: true }, }, + { + path: 'wallet', + children: [], + component: AddressGroupComponent, + data: { + networkSpecific: true, + } + }, { path: 'preview', children: [ @@ -168,6 +193,14 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') { loadChildren: () => import ('./liquid/liquid-master-page.module').then(m => m.LiquidMasterPageModule), data: { preload: true }, }, + { + path: 'wallet', + children: [], + component: AddressGroupComponent, + data: { + networkSpecific: true, + } + }, { path: 'status', data: { networks: ['bitcoin', 'liquid'] }, @@ -195,6 +228,14 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') { loadChildren: () => import ('./liquid/liquid-master-page.module').then(m => m.LiquidMasterPageModule), data: { preload: true }, }, + { + path: 'wallet', + children: [], + component: AddressGroupComponent, + data: { + networkSpecific: true, + } + }, { path: 'preview', children: [ diff --git a/frontend/src/app/components/address-group/address-group.component.html b/frontend/src/app/components/address-group/address-group.component.html new file mode 100644 index 000000000..52c7d3ca1 --- /dev/null +++ b/frontend/src/app/components/address-group/address-group.component.html @@ -0,0 +1,20 @@ +
+

Balances

+ + + + + + + + + + + +
Total
+ +
+ +
diff --git a/frontend/src/app/components/address-group/address-group.component.scss b/frontend/src/app/components/address-group/address-group.component.scss new file mode 100644 index 000000000..cbe35d518 --- /dev/null +++ b/frontend/src/app/components/address-group/address-group.component.scss @@ -0,0 +1,82 @@ +.frame { + position: relative; + background: #24273e; + padding: 0.5rem; + height: calc(100% + 60px); +} + +.pagination { + position: absolute; + bottom: 0.5rem; + right: 0.5rem; +} + +.table { + td, th { + padding: 0.15rem 0.5rem; + + &.address { + width: auto; + } + &.btc { + width: 140px; + text-align: right; + } + &.fiat { + width: 142px; + text-align: right; + } + } + + tr { + border-collapse: collapse; + + &:first-child { + border-bottom: solid 1px white; + td, th { + padding-bottom: 0.3rem; + } + } + &:nth-child(2) { + td, th { + padding-top: 0.3rem; + } + } + &:nth-child(even) { + background: #181b2d; + } + } + + @media (min-width: 528px) { + td, th { + &.btc { + width: 160px; + } + &.fiat { + width: 140px; + } + } + } + + @media (min-width: 576px) { + td, th { + &.btc { + width: 170px; + } + &.fiat { + width: 140px; + } + } + } + + @media (min-width: 992px) { + td, th { + &.btc { + width: 210px; + } + &.fiat { + width: 140px; + } + } + } +} \ No newline at end of file diff --git a/frontend/src/app/components/address-group/address-group.component.ts b/frontend/src/app/components/address-group/address-group.component.ts new file mode 100644 index 000000000..ec9b2ed1d --- /dev/null +++ b/frontend/src/app/components/address-group/address-group.component.ts @@ -0,0 +1,212 @@ +import { Component, OnInit, OnDestroy, ChangeDetectorRef, HostListener } from '@angular/core'; +import { ActivatedRoute, ParamMap } from '@angular/router'; +import { ElectrsApiService } from '../../services/electrs-api.service'; +import { switchMap, catchError } from 'rxjs/operators'; +import { Address, Transaction } from '../../interfaces/electrs.interface'; +import { WebsocketService } from '../../services/websocket.service'; +import { StateService } from '../../services/state.service'; +import { AudioService } from '../../services/audio.service'; +import { ApiService } from '../../services/api.service'; +import { of, Subscription, forkJoin } from 'rxjs'; +import { SeoService } from '../../services/seo.service'; +import { AddressInformation } from '../../interfaces/node-api.interface'; + +@Component({ + selector: 'app-address-group', + templateUrl: './address-group.component.html', + styleUrls: ['./address-group.component.scss'] +}) +export class AddressGroupComponent implements OnInit, OnDestroy { + network = ''; + + balance = 0; + confirmed = 0; + mempool = 0; + addresses: { [address: string]: number | null }; + addressStrings: string[] = []; + addressInfo: { [address: string]: AddressInformation | null }; + seenTxs: { [txid: string ]: boolean } = {}; + isLoadingAddress = true; + error: any; + mainSubscription: Subscription; + wsSubscription: Subscription; + + page: string[] = []; + pageIndex: number = 1; + itemsPerPage: number = 10; + + screenSize: 'lg' | 'md' | 'sm' = 'lg'; + digitsInfo: string = '1.8-8'; + + constructor( + private route: ActivatedRoute, + private electrsApiService: ElectrsApiService, + private websocketService: WebsocketService, + private stateService: StateService, + private audioService: AudioService, + private apiService: ApiService, + private seoService: SeoService, + private cd: ChangeDetectorRef, + ) { } + + ngOnInit(): void { + this.onResize(); + this.stateService.networkChanged$.subscribe((network) => this.network = network); + this.websocketService.want(['blocks']); + + this.mainSubscription = this.route.queryParamMap + .pipe( + switchMap((params: ParamMap) => { + this.error = undefined; + this.isLoadingAddress = true; + this.addresses = {}; + this.addressInfo = {}; + this.balance = 0; + + this.addressStrings = params.get('addresses').split(',').map(address => { + 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; + } + }); + + return forkJoin(this.addressStrings.map(address => { + const getLiquidInfo = ((this.stateService.network === 'liquid' || this.stateService.network === 'liquidtestnet') && /^([a-zA-HJ-NP-Z1-9]{26,35}|[a-z]{2,5}1[ac-hj-np-z02-9]{8,100}|[a-km-zA-HJ-NP-Z1-9]{80})$/.test(address)); + return forkJoin([ + of(address), + this.electrsApiService.getAddress$(address), + (getLiquidInfo ? this.apiService.validateAddress$(address) : of(null)), + ]); + })); + }), + catchError(e => { + this.error = e; + return of([]); + }) + ).subscribe((addresses) => { + for (const addressData of addresses) { + const address = addressData[0]; + const addressBalance = addressData[1] as Address; + if (addressBalance) { + this.addresses[address] = addressBalance.chain_stats.funded_txo_sum + + addressBalance.mempool_stats.funded_txo_sum + - addressBalance.chain_stats.spent_txo_sum + - addressBalance.mempool_stats.spent_txo_sum; + this.balance += this.addresses[address]; + this.confirmed += this.addresses[address]; + } + this.addressInfo[address] = addressData[2] ? addressData[2] as AddressInformation : null; + } + this.websocketService.startTrackAddresses(this.addressStrings); + this.isLoadingAddress = false; + this.pageChange(this.pageIndex); + }); + + this.wsSubscription = this.stateService.multiAddressTransactions$.subscribe(update => { + for (const address of Object.keys(update)) { + for (const tx of update[address].mempool) { + this.addTransaction(tx, false, false); + } + for (const tx of update[address].confirmed) { + this.addTransaction(tx, true, false); + } + for (const tx of update[address].removed) { + this.removeTransaction(tx, tx.status.confirmed); + } + } + }); + } + + pageChange(index): void { + this.page = this.addressStrings.slice((index - 1) * this.itemsPerPage, index * this.itemsPerPage); + } + + addTransaction(transaction: Transaction, confirmed = false, playSound: boolean = true): boolean { + if (this.seenTxs[transaction.txid]) { + this.removeTransaction(transaction, false); + } + this.seenTxs[transaction.txid] = true; + + let balance = 0; + transaction.vin.forEach((vin) => { + if (this.addressStrings.includes(vin?.prevout?.scriptpubkey_address)) { + this.addresses[vin?.prevout?.scriptpubkey_address] -= vin.prevout.value; + balance -= vin.prevout.value; + this.balance -= vin.prevout.value; + if (confirmed) { + this.confirmed -= vin.prevout.value; + } + } + }); + transaction.vout.forEach((vout) => { + if (this.addressStrings.includes(vout?.scriptpubkey_address)) { + this.addresses[vout?.scriptpubkey_address] += vout.value; + balance += vout.value; + this.balance += vout.value; + if (confirmed) { + this.confirmed += vout.value; + } + } + }); + + if (playSound) { + if (balance > 0) { + this.audioService.playSound('cha-ching'); + } else { + this.audioService.playSound('chime'); + } + } + + return true; + } + + removeTransaction(transaction: Transaction, confirmed = false): boolean { + transaction.vin.forEach((vin) => { + if (this.addressStrings.includes(vin?.prevout?.scriptpubkey_address)) { + this.addresses[vin?.prevout?.scriptpubkey_address] += vin.prevout.value; + this.balance += vin.prevout.value; + if (confirmed) { + this.confirmed += vin.prevout.value; + } + } + }); + transaction.vout.forEach((vout) => { + if (this.addressStrings.includes(vout?.scriptpubkey_address)) { + this.addresses[vout?.scriptpubkey_address] -= vout.value; + this.balance -= vout.value; + if (confirmed) { + this.confirmed -= vout.value; + } + } + }); + + return true; + } + + @HostListener('window:resize', ['$event']) + onResize(): void { + if (window.innerWidth >= 992) { + this.screenSize = 'lg'; + this.digitsInfo = '1.8-8'; + } else if (window.innerWidth >= 528) { + this.screenSize = 'md'; + this.digitsInfo = '1.4-4'; + } else { + this.screenSize = 'sm'; + this.digitsInfo = '1.2-2'; + } + const newItemsPerPage = Math.floor((window.innerHeight - 150) / 30); + if (newItemsPerPage !== this.itemsPerPage) { + this.itemsPerPage = newItemsPerPage; + this.pageIndex = 1; + this.pageChange(this.pageIndex); + } + } + + ngOnDestroy(): void { + this.mainSubscription.unsubscribe(); + this.wsSubscription.unsubscribe(); + this.websocketService.stopTrackingAddresses(); + } +} diff --git a/frontend/src/app/master-page.module.ts b/frontend/src/app/master-page.module.ts index d7ec87030..e19e86518 100644 --- a/frontend/src/app/master-page.module.ts +++ b/frontend/src/app/master-page.module.ts @@ -6,6 +6,7 @@ import { SharedModule } from './shared/shared.module'; import { StartComponent } from './components/start/start.component'; import { AddressComponent } from './components/address/address.component'; +import { AddressGroupComponent } from './components/address-group/address-group.component'; import { PushTransactionComponent } from './components/push-transaction/push-transaction.component'; import { CalculatorComponent } from './components/calculator/calculator.component'; import { BlocksList } from './components/blocks-list/blocks-list.component'; diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts index dc1365baa..d0dec76f5 100644 --- a/frontend/src/app/services/state.service.ts +++ b/frontend/src/app/services/state.service.ts @@ -119,6 +119,7 @@ export class StateService { mempoolTransactions$ = new Subject(); mempoolTxPosition$ = new Subject<{ txid: string, position: MempoolPosition, cpfp: CpfpInfo | null}>(); mempoolRemovedTransactions$ = new Subject(); + multiAddressTransactions$ = new Subject<{ [address: string]: { mempool: Transaction[], confirmed: Transaction[], removed: Transaction[] }}>(); blockTransactions$ = new Subject(); isLoadingWebSocket$ = new ReplaySubject(1); isLoadingMempool$ = new BehaviorSubject(true); diff --git a/frontend/src/app/services/websocket.service.ts b/frontend/src/app/services/websocket.service.ts index 11e24ef71..90ddb6599 100644 --- a/frontend/src/app/services/websocket.service.ts +++ b/frontend/src/app/services/websocket.service.ts @@ -32,6 +32,7 @@ export class WebsocketService { private isTrackingRbf: 'all' | 'fullRbf' | false = false; private isTrackingRbfSummary = false; private isTrackingAddress: string | false = false; + private isTrackingAddresses: string[] | false = false; private trackingMempoolBlock: number; private latestGitCommit = ''; private onlineCheckTimeout: number; @@ -126,6 +127,9 @@ export class WebsocketService { if (this.isTrackingAddress) { this.startTrackAddress(this.isTrackingAddress); } + if (this.isTrackingAddresses) { + this.startTrackAddresses(this.isTrackingAddresses); + } this.stateService.connectionState$.next(2); } @@ -175,6 +179,16 @@ export class WebsocketService { this.isTrackingAddress = false; } + startTrackAddresses(addresses: string[]) { + this.websocketSubject.next({ 'track-addresses': addresses }); + this.isTrackingAddresses = addresses; + } + + stopTrackingAddresses() { + this.websocketSubject.next({ 'track-addresses': [] }); + this.isTrackingAddresses = false; + } + startTrackAsset(asset: string) { this.websocketSubject.next({ 'track-asset': asset }); } @@ -374,6 +388,10 @@ export class WebsocketService { }); } + if (response['multi-address-transactions']) { + this.stateService.multiAddressTransactions$.next(response['multi-address-transactions']); + } + if (response['block-transactions']) { response['block-transactions'].forEach((addressTransaction: Transaction) => { this.stateService.blockTransactions$.next(addressTransaction); diff --git a/frontend/src/app/shared/components/truncate/truncate.component.html b/frontend/src/app/shared/components/truncate/truncate.component.html index 4d42ab91c..825639ad2 100644 --- a/frontend/src/app/shared/components/truncate/truncate.component.html +++ b/frontend/src/app/shared/components/truncate/truncate.component.html @@ -1,6 +1,6 @@ - + diff --git a/frontend/src/app/shared/components/truncate/truncate.component.ts b/frontend/src/app/shared/components/truncate/truncate.component.ts index 0b6d2d8c1..a00ddb193 100644 --- a/frontend/src/app/shared/components/truncate/truncate.component.ts +++ b/frontend/src/app/shared/components/truncate/truncate.component.ts @@ -9,6 +9,7 @@ import { Component, Input, Inject, LOCALE_ID, ChangeDetectionStrategy } from '@a export class TruncateComponent { @Input() text: string; @Input() link: any = null; + @Input() external: boolean = false; @Input() lastChars: number = 4; @Input() maxWidth: number = null; @Input() inline: boolean = false; diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 36e7e79b8..b3c942ccf 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -46,6 +46,7 @@ import { BlockOverviewGraphComponent } from '../components/block-overview-graph/ import { BlockOverviewTooltipComponent } from '../components/block-overview-tooltip/block-overview-tooltip.component'; import { BlockFiltersComponent } from '../components/block-filters/block-filters.component'; import { AddressComponent } from '../components/address/address.component'; +import { AddressGroupComponent } from '../components/address-group/address-group.component'; import { SearchFormComponent } from '../components/search-form/search-form.component'; import { AddressLabelsComponent } from '../components/address-labels/address-labels.component'; import { FooterComponent } from '../components/footer/footer.component'; @@ -145,6 +146,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir BlockFiltersComponent, TransactionsListComponent, AddressComponent, + AddressGroupComponent, SearchFormComponent, AddressLabelsComponent, FooterComponent, @@ -271,6 +273,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir BlockFiltersComponent, TransactionsListComponent, AddressComponent, + AddressGroupComponent, SearchFormComponent, AddressLabelsComponent, FooterComponent,