From b2d2fd225c37023ccd48840a20806813854a5f41 Mon Sep 17 00:00:00 2001 From: softsimon Date: Tue, 28 Apr 2020 17:10:31 +0700 Subject: [PATCH] Basic Liquid Asset support. --- frontend/src/app/app-routing.module.ts | 5 + frontend/src/app/app.module.ts | 4 + .../app/components/asset/asset.component.html | 124 +++++++++++ .../app/components/asset/asset.component.scss | 23 ++ .../app/components/asset/asset.component.ts | 198 ++++++++++++++++++ .../transactions-list.component.html | 2 +- .../src/app/interfaces/electrs.interface.ts | 71 +++++++ .../src/app/interfaces/websocket.interface.ts | 1 + .../scriptpubkey-type.pipe.ts | 18 ++ .../src/app/services/electrs-api.service.ts | 14 +- .../src/app/services/websocket.service.ts | 8 + 11 files changed, 466 insertions(+), 2 deletions(-) create mode 100644 frontend/src/app/components/asset/asset.component.html create mode 100644 frontend/src/app/components/asset/asset.component.scss create mode 100644 frontend/src/app/components/asset/asset.component.ts create mode 100644 frontend/src/app/pipes/scriptpubkey-type-pipe/scriptpubkey-type.pipe.ts diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index db0206921..c57fc9750 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -10,6 +10,7 @@ import { TelevisionComponent } from './components/television/television.componen import { StatisticsComponent } from './components/statistics/statistics.component'; import { MempoolBlockComponent } from './components/mempool-block/mempool-block.component'; import { LatestBlocksComponent } from './components/latest-blocks/latest-blocks.component'; +import { AssetComponent } from './components/asset/asset.component'; const routes: Routes = [ { @@ -36,6 +37,10 @@ const routes: Routes = [ path: 'mempool-block/:id', component: MempoolBlockComponent }, + { + path: 'asset/:id', + component: AssetComponent + }, ], }, { diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 1aa94dc82..1af136245 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -45,6 +45,8 @@ import { FeeDistributionGraphComponent } from './components/fee-distribution-gra import { TimespanComponent } from './components/timespan/timespan.component'; import { SeoService } from './services/seo.service'; import { MempoolGraphComponent } from './components/mempool-graph/mempool-graph.component'; +import { AssetComponent } from './components/asset/asset.component'; +import { ScriptpubkeyTypePipe } from './pipes/scriptpubkey-type-pipe/scriptpubkey-type.pipe'; @NgModule({ declarations: [ @@ -80,6 +82,8 @@ import { MempoolGraphComponent } from './components/mempool-graph/mempool-graph. MempoolBlockComponent, FeeDistributionGraphComponent, MempoolGraphComponent, + AssetComponent, + ScriptpubkeyTypePipe, ], imports: [ BrowserModule, diff --git a/frontend/src/app/components/asset/asset.component.html b/frontend/src/app/components/asset/asset.component.html new file mode 100644 index 000000000..d91ff40bd --- /dev/null +++ b/frontend/src/app/components/asset/asset.component.html @@ -0,0 +1,124 @@ +
+

Asset

+ + {{ assetString | shortenString : 24 }} + {{ assetString }} + + +
+ +
+ + +
+ +
+
+ + + + + + + + + + + + + + + + + + + +
Name{{ asset.name }} ({{ asset.ticker }})
Precision{{ asset.precision }}
Issuer{{ asset.contract.entity.domain }}
Issuance tx{{ asset.issuance_txin.txid | shortenString : 13 }}
+
+
+ + + + + + + + + + + + + + + +
Circulating amount{{ (asset.chain_stats.issued_amount - asset.chain_stats.burned_amount) / 100000000 | number: '1.0-' + asset.precision }}
Issued amount{{ asset.chain_stats.issued_amount / 100000000 | number: '1.0-' + asset.precision }}
Burned amount{{ asset.chain_stats.burned_amount / 100000000 | number: '1.0-' + asset.precision }}
+
+
+ +
+ +
+ +

{{ (transactions?.length | number) || '?' }} of {{ txCount | number }} transactions

+ + + +
+ +
+ +
+ +
+
+
+ +
+
+ +
+
+
+
+
+ +
+ + + +
+
+
+ + + + + + + + + + + + +
+
+
+ +
+
+
+ +
+ + +
+ Error loading asset data. +
+ {{ error.error }} +
+
+ +
+ +
\ No newline at end of file diff --git a/frontend/src/app/components/asset/asset.component.scss b/frontend/src/app/components/asset/asset.component.scss new file mode 100644 index 000000000..c5961e428 --- /dev/null +++ b/frontend/src/app/components/asset/asset.component.scss @@ -0,0 +1,23 @@ +.qr-wrapper { + background-color: #FFF; + padding: 10px; + padding-bottom: 5px; + display: inline-block; + margin-right: 25px; +} + +@media (min-width: 576px) { + .qrcode-col { + text-align: right; + } +} +@media (max-width: 575.98px) { + .qrcode-col { + text-align: center; + } + + .qrcode-col > div { + margin-top: 20px; + margin-right: 0px; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/asset/asset.component.ts b/frontend/src/app/components/asset/asset.component.ts new file mode 100644 index 000000000..e444382a8 --- /dev/null +++ b/frontend/src/app/components/asset/asset.component.ts @@ -0,0 +1,198 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { ActivatedRoute, ParamMap } from '@angular/router'; +import { ElectrsApiService } from '../../services/electrs-api.service'; +import { switchMap, filter, catchError } from 'rxjs/operators'; +import { Asset, Transaction } from '../../interfaces/electrs.interface'; +import { WebsocketService } from 'src/app/services/websocket.service'; +import { StateService } from 'src/app/services/state.service'; +import { AudioService } from 'src/app/services/audio.service'; +import { ApiService } from 'src/app/services/api.service'; +import { of, merge, Subscription } from 'rxjs'; +import { SeoService } from 'src/app/services/seo.service'; +import { environment } from 'src/environments/environment'; + +@Component({ + selector: 'app-asset', + templateUrl: './asset.component.html', + styleUrls: ['./asset.component.scss'] +}) +export class AssetComponent implements OnInit, OnDestroy { + network = environment.network; + + asset: Asset; + assetString: string; + isLoadingAsset = true; + transactions: Transaction[]; + isLoadingTransactions = true; + error: any; + mainSubscription: Subscription; + + totalConfirmedTxCount = 0; + loadedConfirmedTxCount = 0; + txCount = 0; + receieved = 0; + sent = 0; + + private tempTransactions: Transaction[]; + private timeTxIndexes: number[]; + private lastTransactionTxId: string; + + constructor( + private route: ActivatedRoute, + private electrsApiService: ElectrsApiService, + private websocketService: WebsocketService, + private stateService: StateService, + private audioService: AudioService, + private apiService: ApiService, + private seoService: SeoService, + ) { } + + ngOnInit() { + this.websocketService.want(['blocks', 'stats', 'mempool-blocks']); + + this.mainSubscription = this.route.paramMap + .pipe( + switchMap((params: ParamMap) => { + this.error = undefined; + this.isLoadingAsset = true; + this.loadedConfirmedTxCount = 0; + this.asset = null; + this.isLoadingTransactions = true; + this.transactions = null; + document.body.scrollTo(0, 0); + this.assetString = params.get('id') || ''; + this.seoService.setTitle('Asset: ' + this.assetString, true); + + return merge( + of(true), + this.stateService.connectionState$ + .pipe(filter((state) => state === 2 && this.transactions && this.transactions.length > 0)) + ) + .pipe( + switchMap(() => this.electrsApiService.getAsset$(this.assetString) + .pipe( + catchError((err) => { + this.isLoadingAsset = false; + this.error = err; + console.log(err); + return of(null); + }) + ) + ) + ); + }) + ) + .pipe( + switchMap((asset: Asset) => { + this.asset = asset; + this.updateChainStats(); + this.websocketService.startTrackAsset(asset.asset_id); + this.isLoadingAsset = false; + this.isLoadingTransactions = true; + return this.electrsApiService.getAssetTransactions$(asset.asset_id); + }), + switchMap((transactions) => { + this.tempTransactions = transactions; + if (transactions.length) { + this.lastTransactionTxId = transactions[transactions.length - 1].txid; + this.loadedConfirmedTxCount += transactions.filter((tx) => tx.status.confirmed).length; + } + + const fetchTxs: string[] = []; + this.timeTxIndexes = []; + transactions.forEach((tx, index) => { + if (!tx.status.confirmed) { + fetchTxs.push(tx.txid); + this.timeTxIndexes.push(index); + } + }); + if (!fetchTxs.length) { + return of([]); + } + return this.apiService.getTransactionTimes$(fetchTxs); + }) + ) + .subscribe((times: number[]) => { + times.forEach((time, index) => { + this.tempTransactions[this.timeTxIndexes[index]].firstSeen = time; + }); + this.tempTransactions.sort((a, b) => { + return b.status.block_time - a.status.block_time || b.firstSeen - a.firstSeen; + }); + + this.transactions = this.tempTransactions; + this.isLoadingTransactions = false; + }, + (error) => { + console.log(error); + this.error = error; + this.isLoadingAsset = false; + }); + + this.stateService.mempoolTransactions$ + .subscribe((transaction) => { + if (this.transactions.some((t) => t.txid === transaction.txid)) { + return; + } + + this.transactions.unshift(transaction); + this.transactions = this.transactions.slice(); + this.txCount++; + + // if (transaction.vout.some((vout) => vout.scriptpubkey_asset === this.asset.asset)) { + // this.audioService.playSound('cha-ching'); + // } else { + // this.audioService.playSound('chime'); + // } + + // transaction.vin.forEach((vin) => { + // if (vin.prevout.scriptpubkey_asset === this.asset.asset) { + // this.sent += vin.prevout.value; + // } + // }); + // transaction.vout.forEach((vout) => { + // if (vout.scriptpubkey_asset === this.asset.asset) { + // this.receieved += vout.value; + // } + // }); + }); + + this.stateService.blockTransactions$ + .subscribe((transaction) => { + const tx = this.transactions.find((t) => t.txid === transaction.txid); + if (tx) { + tx.status = transaction.status; + this.transactions = this.transactions.slice(); + this.audioService.playSound('magic'); + } + this.totalConfirmedTxCount++; + this.loadedConfirmedTxCount++; + }); + } + + loadMore() { + if (this.isLoadingTransactions || !this.totalConfirmedTxCount || this.loadedConfirmedTxCount >= this.totalConfirmedTxCount) { + return; + } + this.isLoadingTransactions = true; + this.electrsApiService.getAddressTransactionsFromHash$(this.asset.asset_id, this.lastTransactionTxId) + .subscribe((transactions: Transaction[]) => { + this.lastTransactionTxId = transactions[transactions.length - 1].txid; + this.loadedConfirmedTxCount += transactions.length; + this.transactions = this.transactions.concat(transactions); + this.isLoadingTransactions = false; + }); + } + + updateChainStats() { + // this.receieved = this.asset.chain_stats.funded_txo_sum + this.asset.mempool_stats.funded_txo_sum; + // this.sent = this.asset.chain_stats.spent_txo_sum + this.asset.mempool_stats.spent_txo_sum; + this.txCount = this.asset.chain_stats.tx_count + this.asset.mempool_stats.tx_count; + // this.totalConfirmedTxCount = this.asset.chain_stats.tx_count; + } + + ngOnDestroy() { + this.mainSubscription.unsubscribe(); + this.websocketService.stopTrackingAsset(); + } +} diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.html b/frontend/src/app/components/transactions-list/transactions-list.component.html index 487c716e4..d03d065c9 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.html +++ b/frontend/src/app/components/transactions-list/transactions-list.component.html @@ -66,7 +66,7 @@ {{ vout.scriptpubkey_address | shortenString : 42 }} - OP_RETURN + {{ vout.scriptpubkey_type | scriptpubkeyType }} diff --git a/frontend/src/app/interfaces/electrs.interface.ts b/frontend/src/app/interfaces/electrs.interface.ts index 5f52d75cb..47c3a3af1 100644 --- a/frontend/src/app/interfaces/electrs.interface.ts +++ b/frontend/src/app/interfaces/electrs.interface.ts @@ -98,3 +98,74 @@ export interface Outspend { vin: number; status: Status; } + +export interface Asset { + asset_id: string; + issuance_txin: IssuanceTxin; + issuance_prevout: IssuancePrevout; + reissuance_token: string; + contract_hash: string; + status: Status; + chain_stats: AssetChainStats; + mempool_stats: AssetMempoolStats; + contract: Contract; + entity: Entity; + precision: number; + name: string; + ticker: string; +} + +interface IssuanceTxin { + txid: string; + vin: number; +} + +interface IssuancePrevout { + txid: string; + vout: number; +} + +interface AssetChainStats { + tx_count: number; + issuance_count: number; + issued_amount: number; + burned_amount: number; + has_blinded_issuances: boolean; + reissuance_tokens: number; + burned_reissuance_tokens: number; + + peg_in_count: number; + peg_in_amount: number; + peg_out_count: number; + peg_out_amount: number; + burn_count: number; +} + +interface AssetMempoolStats { + tx_count: number; + issuance_count: number; + issued_amount: number; + burned_amount: number; + has_blinded_issuances: boolean; + reissuance_tokens: any; + burned_reissuance_tokens: number; + + peg_in_count: number; + peg_in_amount: number; + peg_out_count: number; + peg_out_amount: number; + burn_count: number; +} + +interface Contract { + entity: Entity; + issuer_pubkey: string; + name: string; + precision: number; + ticker: string; + version: number; +} + +interface Entity { + domain: string; +} diff --git a/frontend/src/app/interfaces/websocket.interface.ts b/frontend/src/app/interfaces/websocket.interface.ts index 15b557746..eabff1583 100644 --- a/frontend/src/app/interfaces/websocket.interface.ts +++ b/frontend/src/app/interfaces/websocket.interface.ts @@ -13,6 +13,7 @@ export interface WebsocketResponse { tx?: Transaction; 'track-tx'?: string; 'track-address'?: string; + 'track-asset'?: string; 'watch-mempool'?: boolean; } diff --git a/frontend/src/app/pipes/scriptpubkey-type-pipe/scriptpubkey-type.pipe.ts b/frontend/src/app/pipes/scriptpubkey-type-pipe/scriptpubkey-type.pipe.ts new file mode 100644 index 000000000..7c9239517 --- /dev/null +++ b/frontend/src/app/pipes/scriptpubkey-type-pipe/scriptpubkey-type.pipe.ts @@ -0,0 +1,18 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'scriptpubkeyType' +}) +export class ScriptpubkeyTypePipe implements PipeTransform { + + transform(value: string): string { + switch (value) { + case 'fee': + return 'Transaction fee'; + case 'op_return': + default: + return 'Script'; + } + } + +} diff --git a/frontend/src/app/services/electrs-api.service.ts b/frontend/src/app/services/electrs-api.service.ts index ae1d1a856..9896e1f15 100644 --- a/frontend/src/app/services/electrs-api.service.ts +++ b/frontend/src/app/services/electrs-api.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; -import { Block, Transaction, Address, Outspend, Recent } from '../interfaces/electrs.interface'; +import { Block, Transaction, Address, Outspend, Recent, Asset } from '../interfaces/electrs.interface'; const API_BASE_URL = document.location.protocol + '//' + document.location.hostname + ':' + document.location.port + '/electrs'; @@ -54,4 +54,16 @@ export class ElectrsApiService { return this.httpClient.get(API_BASE_URL + '/address/' + address + '/txs/chain/' + txid); } + getAsset$(assetId: string): Observable { + return this.httpClient.get(API_BASE_URL + '/asset/' + assetId); + } + + getAssetTransactions$(assetId: string): Observable { + return this.httpClient.get(API_BASE_URL + '/asset/' + assetId + '/txs'); + } + + getAssetTransactionsFromHash$(assetId: string, txid: string): Observable { + return this.httpClient.get(API_BASE_URL + '/asset/' + assetId + '/txs/chain/' + txid); + } + } diff --git a/frontend/src/app/services/websocket.service.ts b/frontend/src/app/services/websocket.service.ts index 34d14c1e0..8ba46b3bb 100644 --- a/frontend/src/app/services/websocket.service.ts +++ b/frontend/src/app/services/websocket.service.ts @@ -157,6 +157,14 @@ export class WebsocketService { this.websocketSubject.next({ 'track-address': 'stop' }); } + startTrackAsset(asset: string) { + this.websocketSubject.next({ 'track-asset': asset }); + } + + stopTrackingAsset() { + this.websocketSubject.next({ 'track-asset': 'stop' }); + } + fetchStatistics(historicalDate: string) { this.websocketSubject.next({ historicalDate }); }