Basic Liquid Asset support.

This commit is contained in:
softsimon 2020-04-28 17:10:31 +07:00
parent 7e7b536acb
commit b2d2fd225c
No known key found for this signature in database
GPG Key ID: 488D7DCFB5A430D7
11 changed files with 466 additions and 2 deletions

View File

@ -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
},
],
},
{

View File

@ -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,

View File

@ -0,0 +1,124 @@
<div class="container-xl">
<h1 style="float: left;">Asset</h1>
<a [routerLink]="['/asset/', assetString]" style="line-height: 56px; margin-left: 10px;">
<span class="d-inline d-lg-none">{{ assetString | shortenString : 24 }}</span>
<span class="d-none d-lg-inline">{{ assetString }}</span>
</a>
<app-clipboard [text]="assetString"></app-clipboard>
<br>
<div class="clearfix"></div>
<ng-template [ngIf]="!isLoadingAsset && !error">
<div class="box">
<div class="row">
<div class="col">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td>Name</td>
<td>{{ asset.name }} ({{ asset.ticker }})</td>
</tr>
<tr>
<td>Precision</td>
<td>{{ asset.precision }}</td>
</tr>
<tr>
<td>Issuer</td>
<td><a target="_blank" href="{{ 'http://' + asset.contract.entity.domain }}">{{ asset.contract.entity.domain }}</a></td>
</tr>
<tr>
<td>Issuance tx</td>
<td><a [routerLink]="['/tx/', asset.issuance_txin.txid]">{{ asset.issuance_txin.txid | shortenString : 13 }}</a> <app-clipboard class="d-none d-sm-inline-block" [text]="asset.issuance_txin.txid"></app-clipboard></td>
</tr>
</tbody>
</table>
</div>
<div class="col">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td>Circulating amount</td>
<td>{{ (asset.chain_stats.issued_amount - asset.chain_stats.burned_amount) / 100000000 | number: '1.0-' + asset.precision }}</td>
</tr>
<tr>
<td>Issued amount</td>
<td>{{ asset.chain_stats.issued_amount / 100000000 | number: '1.0-' + asset.precision }}</td>
</tr>
<tr>
<td>Burned amount</td>
<td>{{ asset.chain_stats.burned_amount / 100000000 | number: '1.0-' + asset.precision }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<br>
<h2><ng-template [ngIf]="transactions?.length">{{ (transactions?.length | number) || '?' }} of </ng-template>{{ txCount | number }} transactions</h2>
<app-transactions-list [transactions]="transactions" [showConfirmations]="true" (loadMore)="loadMore()"></app-transactions-list>
<div class="text-center">
<ng-template [ngIf]="isLoadingTransactions">
<div class="header-bg box" style="padding: 10px; margin-bottom: 10px;">
<span class="skeleton-loader"></span>
</div>
<div class="header-bg box">
<div class="row" style="height: 107px;">
<div class="col-sm">
<span class="skeleton-loader"></span>
</div>
<div class="col-sm">
<span class="skeleton-loader"></span>
</div>
</div>
</div>
</ng-template>
</div>
</ng-template>
<ng-template [ngIf]="isLoadingAsset && !error">
<div class="box">
<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="col">
</div>
</div>
</div>
</ng-template>
<ng-template [ngIf]="error">
<div class="text-center">
Error loading asset data.
<br>
<i>{{ error.error }}</i>
</div>
</ng-template>
</div>
<br>

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -66,7 +66,7 @@
<span class="d-none d-lg-block">{{ vout.scriptpubkey_address | shortenString : 42 }}</span>
</a>
<ng-template #scriptpubkey_type>
OP_RETURN
{{ vout.scriptpubkey_type | scriptpubkeyType }}
</ng-template>
</td>
<td class="text-right nowrap">

View File

@ -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;
}

View File

@ -13,6 +13,7 @@ export interface WebsocketResponse {
tx?: Transaction;
'track-tx'?: string;
'track-address'?: string;
'track-asset'?: string;
'watch-mempool'?: boolean;
}

View File

@ -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';
}
}
}

View File

@ -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<Transaction[]>(API_BASE_URL + '/address/' + address + '/txs/chain/' + txid);
}
getAsset$(assetId: string): Observable<Asset> {
return this.httpClient.get<Asset>(API_BASE_URL + '/asset/' + assetId);
}
getAssetTransactions$(assetId: string): Observable<Transaction[]> {
return this.httpClient.get<Transaction[]>(API_BASE_URL + '/asset/' + assetId + '/txs');
}
getAssetTransactionsFromHash$(assetId: string, txid: string): Observable<Transaction[]> {
return this.httpClient.get<Transaction[]>(API_BASE_URL + '/asset/' + assetId + '/txs/chain/' + txid);
}
}

View File

@ -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 });
}