Merge pull request #92 from mempool/bisq

Bisq support
This commit is contained in:
wiz 2020-07-19 20:33:16 +09:00 committed by GitHub
commit 222b7b9dd6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
100 changed files with 2586 additions and 214 deletions

View File

@ -13,6 +13,8 @@
"INITIAL_BLOCK_AMOUNT": 8,
"TX_PER_SECOND_SPAN_SECONDS": 150,
"ELECTRS_API_URL": "https://www.blockstream.info/testnet/api",
"BISQ_ENABLED": false,
"BSQ_BLOCKS_DATA_PATH": "/bisq/data",
"SSL": false,
"SSL_CERT_FILE_PATH": "/etc/letsencrypt/live/mysite/fullchain.pem",
"SSL_KEY_FILE_PATH": "/etc/letsencrypt/live/mysite/privkey.pem"

228
backend/src/api/bisq.ts Normal file
View File

@ -0,0 +1,228 @@
const config = require('../../mempool-config.json');
import * as fs from 'fs';
import * as request from 'request';
import { BisqBlocks, BisqBlock, BisqTransaction, BisqStats, BisqTrade } from '../interfaces';
import { Common } from './common';
class Bisq {
private latestBlockHeight = 0;
private blocks: BisqBlock[] = [];
private transactions: BisqTransaction[] = [];
private transactionIndex: { [txId: string]: BisqTransaction } = {};
private blockIndex: { [hash: string]: BisqBlock } = {};
private addressIndex: { [address: string]: BisqTransaction[] } = {};
private stats: BisqStats = {
minted: 0,
burnt: 0,
addresses: 0,
unspent_txos: 0,
spent_txos: 0,
};
private price: number = 0;
private priceUpdateCallbackFunction: ((price: number) => void) | undefined;
private subdirectoryWatcher: fs.FSWatcher | undefined;
constructor() {}
startBisqService(): void {
this.loadBisqDumpFile();
setInterval(this.updatePrice.bind(this), 1000 * 60 * 60);
this.updatePrice();
this.startTopLevelDirectoryWatcher();
this.restartSubDirectoryWatcher();
}
getTransaction(txId: string): BisqTransaction | undefined {
return this.transactionIndex[txId];
}
getTransactions(start: number, length: number): [BisqTransaction[], number] {
return [this.transactions.slice(start, length + start), this.transactions.length];
}
getBlock(hash: string): BisqBlock | undefined {
return this.blockIndex[hash];
}
getAddress(hash: string): BisqTransaction[] {
return this.addressIndex[hash];
}
getBlocks(start: number, length: number): [BisqBlock[], number] {
return [this.blocks.slice(start, length + start), this.blocks.length];
}
getStats(): BisqStats {
return this.stats;
}
setPriceCallbackFunction(fn: (price: number) => void) {
this.priceUpdateCallbackFunction = fn;
}
getLatestBlockHeight(): number {
return this.latestBlockHeight;
}
private startTopLevelDirectoryWatcher() {
let fsWait: NodeJS.Timeout | null = null;
fs.watch(config.BSQ_BLOCKS_DATA_PATH, () => {
if (fsWait) {
clearTimeout(fsWait);
}
fsWait = setTimeout(() => {
console.log(`Change detected in the top level Bisq data folder. Resetting inner watcher.`);
this.restartSubDirectoryWatcher();
}, 15000);
});
}
private restartSubDirectoryWatcher() {
if (this.subdirectoryWatcher) {
this.subdirectoryWatcher.close();
}
let fsWait: NodeJS.Timeout | null = null;
this.subdirectoryWatcher = fs.watch(config.BSQ_BLOCKS_DATA_PATH + '/all', () => {
if (fsWait) {
clearTimeout(fsWait);
}
fsWait = setTimeout(() => {
console.log(`Change detected in the Bisq data folder.`);
this.loadBisqDumpFile();
}, 2000);
});
}
private updatePrice() {
request('https://markets.bisq.network/api/trades/?market=bsq_btc', { json: true }, (err, res, trades: BisqTrade[]) => {
if (err) { return console.log(err); }
const prices: number[] = [];
trades.forEach((trade) => {
prices.push(parseFloat(trade.price) * 100000000);
});
prices.sort((a, b) => a - b);
this.price = Common.median(prices);
if (this.priceUpdateCallbackFunction) {
this.priceUpdateCallbackFunction(this.price);
}
});
}
private async loadBisqDumpFile(): Promise<void> {
try {
const data = await this.loadData();
await this.loadBisqBlocksDump(data);
this.buildIndex();
this.calculateStats();
} catch (e) {
console.log('loadBisqDumpFile() error.', e.message);
}
}
private buildIndex() {
const start = new Date().getTime();
this.transactions = [];
this.transactionIndex = {};
this.addressIndex = {};
this.blocks.forEach((block) => {
/* Build block index */
if (!this.blockIndex[block.hash]) {
this.blockIndex[block.hash] = block;
}
/* Build transactions index */
block.txs.forEach((tx) => {
this.transactions.push(tx);
this.transactionIndex[tx.id] = tx;
});
});
/* Build address index */
this.transactions.forEach((tx) => {
tx.inputs.forEach((input) => {
if (!this.addressIndex[input.address]) {
this.addressIndex[input.address] = [];
}
if (this.addressIndex[input.address].indexOf(tx) === -1) {
this.addressIndex[input.address].push(tx);
}
});
tx.outputs.forEach((output) => {
if (!this.addressIndex[output.address]) {
this.addressIndex[output.address] = [];
}
if (this.addressIndex[output.address].indexOf(tx) === -1) {
this.addressIndex[output.address].push(tx);
}
});
});
const time = new Date().getTime() - start;
console.log('Bisq data index rebuilt in ' + time + ' ms');
}
private calculateStats() {
let minted = 0;
let burned = 0;
let unspent = 0;
let spent = 0;
this.transactions.forEach((tx) => {
tx.outputs.forEach((output) => {
if (output.opReturn) {
return;
}
if (output.txOutputType === 'GENESIS_OUTPUT' || output.txOutputType === 'ISSUANCE_CANDIDATE_OUTPUT' && output.isVerified) {
minted += output.bsqAmount;
}
if (output.isUnspent) {
unspent++;
} else {
spent++;
}
});
burned += tx['burntFee'];
});
this.stats = {
addresses: Object.keys(this.addressIndex).length,
minted: minted,
burnt: burned,
spent_txos: spent,
unspent_txos: unspent,
};
}
private async loadBisqBlocksDump(cacheData: string): Promise<void> {
const start = new Date().getTime();
if (cacheData && cacheData.length !== 0) {
console.log('Loading Bisq data from dump...');
const data: BisqBlocks = JSON.parse(cacheData);
if (data.blocks && data.blocks.length !== this.blocks.length) {
this.blocks = data.blocks.filter((block) => block.txs.length > 0);
this.blocks.reverse();
this.latestBlockHeight = data.chainHeight;
const time = new Date().getTime() - start;
console.log('Bisq dump loaded in ' + time + ' ms');
} else {
throw new Error(`Bisq dump didn't contain any blocks`);
}
}
}
private loadData(): Promise<string> {
return new Promise((resolve, reject) => {
fs.readFile(config.BSQ_BLOCKS_DATA_PATH + '/all/blocks.json', 'utf8', (err, data) => {
if (err) {
reject(err);
}
resolve(data);
});
});
}
}
export default new Bisq();

View File

@ -73,10 +73,10 @@ class Blocks {
console.log(`${found} of ${txIds.length} found in mempool. ${notFound} not found.`);
block.reward = transactions[0].vout.reduce((acc, curr) => acc + curr.value, 0);
block.coinbaseTx = this.stripCoinbaseTransaction(transactions[0]);
transactions.sort((a, b) => b.feePerVsize - a.feePerVsize);
block.medianFee = transactions.length > 1 ? Common.median(transactions.map((tx) => tx.feePerVsize)) : 0;
block.feeRange = transactions.length > 1 ? Common.getFeesInRange(transactions, 8, 1) : [0, 0];
block.coinbaseTx = this.stripCoinbaseTransaction(transactions[0]);
this.blocks.push(block);
if (this.blocks.length > config.KEEP_BLOCK_AMOUNT) {

View File

@ -35,6 +35,9 @@ class Mempool {
public setMempool(mempoolData: { [txId: string]: TransactionExtended }) {
this.mempoolCache = mempoolData;
if (this.mempoolChangedCallback) {
this.mempoolChangedCallback(this.mempoolCache, [], []);
}
}
public async updateMemPoolInfo() {

View File

@ -12,6 +12,7 @@ import { Common } from './common';
class WebsocketHandler {
private wss: WebSocket.Server | undefined;
private nativeAssetId = '6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d';
private extraInitProperties = {};
constructor() { }
@ -19,6 +20,10 @@ class WebsocketHandler {
this.wss = wss;
}
setExtraInitProperties(property: string, value: any) {
this.extraInitProperties[property] = value;
}
setupConnectionHandling() {
if (!this.wss) {
throw new Error('WebSocket.Server is not set');
@ -84,6 +89,7 @@ class WebsocketHandler {
'mempool-blocks': mempoolBlocks.getMempoolBlocks(),
'git-commit': backendInfo.gitCommitHash,
'hostname': backendInfo.hostname,
...this.extraInitProperties
}));
}

View File

@ -14,6 +14,7 @@ import diskCache from './api/disk-cache';
import statistics from './api/statistics';
import websocketHandler from './api/websocket-handler';
import fiatConversion from './api/fiat-conversion';
import bisq from './api/bisq';
class Server {
wss: WebSocket.Server;
@ -50,6 +51,11 @@ class Server {
fiatConversion.startService();
diskCache.loadMempoolCache();
if (config.BISQ_ENABLED) {
bisq.startBisqService();
bisq.setPriceCallbackFunction((price) => websocketHandler.setExtraInitProperties('bsq-price', price));
}
this.server.listen(config.HTTP_PORT, () => {
console.log(`Server started on port ${config.HTTP_PORT}`);
});
@ -84,6 +90,18 @@ class Server {
.get(config.API_ENDPOINT + 'statistics/1y', routes.get1YStatistics.bind(routes))
.get(config.API_ENDPOINT + 'backend-info', routes.getBackendInfo)
;
if (config.BISQ_ENABLED) {
this.app
.get(config.API_ENDPOINT + 'bisq/stats', routes.getBisqStats)
.get(config.API_ENDPOINT + 'bisq/tx/:txId', routes.getBisqTransaction)
.get(config.API_ENDPOINT + 'bisq/block/:hash', routes.getBisqBlock)
.get(config.API_ENDPOINT + 'bisq/blocks/tip/height', routes.getBisqTip)
.get(config.API_ENDPOINT + 'bisq/blocks/:index/:length', routes.getBisqBlocks)
.get(config.API_ENDPOINT + 'bisq/address/:address', routes.getBisqAddress)
.get(config.API_ENDPOINT + 'bisq/txs/:index/:length', routes.getBisqTransactions)
;
}
}
}

View File

@ -230,3 +230,95 @@ export interface VbytesPerSecond {
unixTime: number;
vSize: number;
}
export interface BisqBlocks {
chainHeight: number;
blocks: BisqBlock[];
}
export interface BisqBlock {
height: number;
time: number;
hash: string;
previousBlockHash: string;
txs: BisqTransaction[];
}
export interface BisqTransaction {
txVersion: string;
id: string;
blockHeight: number;
blockHash: string;
time: number;
inputs: BisqInput[];
outputs: BisqOutput[];
txType: string;
txTypeDisplayString: string;
burntFee: number;
invalidatedBsq: number;
unlockBlockHeight: number;
}
export interface BisqStats {
minted: number;
burnt: number;
addresses: number;
unspent_txos: number;
spent_txos: number;
}
interface BisqInput {
spendingTxOutputIndex: number;
spendingTxId: string;
bsqAmount: number;
isVerified: boolean;
address: string;
time: number;
}
interface BisqOutput {
txVersion: string;
txId: string;
index: number;
bsqAmount: number;
btcAmount: number;
height: number;
isVerified: boolean;
burntFee: number;
invalidatedBsq: number;
address: string;
scriptPubKey: BisqScriptPubKey;
time: any;
txType: string;
txTypeDisplayString: string;
txOutputType: string;
txOutputTypeDisplayString: string;
lockTime: number;
isUnspent: boolean;
spentInfo: SpentInfo;
opReturn?: string;
}
interface BisqScriptPubKey {
addresses: string[];
asm: string;
hex: string;
reqSigs: number;
type: string;
}
interface SpentInfo {
height: number;
inputIndex: number;
txId: string;
}
export interface BisqTrade {
direction: string;
price: string;
amount: string;
volume: string;
payment_method: string;
trade_id: string;
trade_date: number;
}

View File

@ -4,6 +4,7 @@ import feeApi from './api/fee-api';
import backendInfo from './api/backend-info';
import mempoolBlocks from './api/mempool-blocks';
import mempool from './api/mempool';
import bisq from './api/bisq';
class Routes {
private cache = {};
@ -25,42 +26,42 @@ class Routes {
public async get2HStatistics(req: Request, res: Response) {
const result = await statistics.$list2H();
res.send(result);
res.json(result);
}
public get24HStatistics(req: Request, res: Response) {
res.send(this.cache['24h']);
res.json(this.cache['24h']);
}
public get1WHStatistics(req: Request, res: Response) {
res.send(this.cache['1w']);
res.json(this.cache['1w']);
}
public get1MStatistics(req: Request, res: Response) {
res.send(this.cache['1m']);
res.json(this.cache['1m']);
}
public get3MStatistics(req: Request, res: Response) {
res.send(this.cache['3m']);
res.json(this.cache['3m']);
}
public get6MStatistics(req: Request, res: Response) {
res.send(this.cache['6m']);
res.json(this.cache['6m']);
}
public get1YStatistics(req: Request, res: Response) {
res.send(this.cache['1y']);
res.json(this.cache['1y']);
}
public async getRecommendedFees(req: Request, res: Response) {
const result = feeApi.getRecommendedFee();
res.send(result);
res.json(result);
}
public getMempoolBlocks(req: Request, res: Response) {
try {
const result = mempoolBlocks.getMempoolBlocks();
res.send(result);
res.json(result);
} catch (e) {
res.status(500).send(e.message);
}
@ -79,11 +80,65 @@ class Routes {
}
const times = mempool.getFirstSeenForTransactions(txIds);
res.send(times);
res.json(times);
}
public getBackendInfo(req: Request, res: Response) {
res.send(backendInfo.getBackendInfo());
res.json(backendInfo.getBackendInfo());
}
public getBisqStats(req: Request, res: Response) {
const result = bisq.getStats();
res.json(result);
}
public getBisqTip(req: Request, res: Response) {
const result = bisq.getLatestBlockHeight();
res.type('text/plain');
res.send(result.toString());
}
public getBisqTransaction(req: Request, res: Response) {
const result = bisq.getTransaction(req.params.txId);
if (result) {
res.json(result);
} else {
res.status(404).send('Bisq transaction not found');
}
}
public getBisqTransactions(req: Request, res: Response) {
const index = parseInt(req.params.index, 10) || 0;
const length = parseInt(req.params.length, 10) > 100 ? 100 : parseInt(req.params.length, 10) || 25;
const [transactions, count] = bisq.getTransactions(index, length);
res.header('X-Total-Count', count.toString());
res.json(transactions);
}
public getBisqBlock(req: Request, res: Response) {
const result = bisq.getBlock(req.params.hash);
if (result) {
res.json(result);
} else {
res.status(404).send('Bisq block not found');
}
}
public getBisqBlocks(req: Request, res: Response) {
const index = parseInt(req.params.index, 10) || 0;
const length = parseInt(req.params.length, 10) > 100 ? 100 : parseInt(req.params.length, 10) || 25;
const [transactions, count] = bisq.getBlocks(index, length);
res.header('X-Total-Count', count.toString());
res.json(transactions);
}
public getBisqAddress(req: Request, res: Response) {
const result = bisq.getAddress(req.params.address.substr(1));
if (result) {
res.json(result);
} else {
res.status(404).send('Bisq address not found');
}
}
}

View File

@ -1,6 +1,7 @@
{
"TESTNET_ENABLED": false,
"LIQUID_ENABLED": false,
"BISQ_ENABLED": false,
"ELCTRS_ITEMS_PER_PAGE": 25,
"KEEP_BLOCKS_AMOUNT": 8
}

View File

@ -40,6 +40,10 @@
"@angular/platform-browser": "~9.1.0",
"@angular/platform-browser-dynamic": "~9.1.0",
"@angular/router": "~9.1.0",
"@fortawesome/angular-fontawesome": "^0.6.1",
"@fortawesome/fontawesome-common-types": "^0.2.29",
"@fortawesome/fontawesome-svg-core": "^1.2.28",
"@fortawesome/free-solid-svg-icons": "^5.13.0",
"@ng-bootstrap/ng-bootstrap": "^6.1.0",
"@types/qrcode": "^1.3.4",
"bootstrap": "4.5.0",

View File

@ -1,18 +1,25 @@
{
"/api": {
"/api/v1": {
"target": "http://localhost:8999/",
"secure": false
},
"/ws": {
"/api/v1/ws": {
"target": "http://localhost:8999/",
"secure": false,
"ws": true
},
"/electrs": {
"target": "https://www.blockstream.info/testnet/api/",
"/bisq/api": {
"target": "http://localhost:8999/",
"secure": false,
"pathRewrite": {
"^/electrs": ""
"^/bisq/api": "/api/v1/bisq"
}
},
"/api": {
"target": "http://localhost:50001/",
"secure": false,
"pathRewrite": {
"^/api": ""
}
}
}

View File

@ -179,6 +179,11 @@ const routes: Routes = [
},
]
},
{
path: 'bisq',
component: MasterPageComponent,
loadChildren: () => import('./bisq/bisq.module').then(m => m.BisqModule)
},
{
path: 'tv',
component: TelevisionComponent,

View File

@ -37,13 +37,15 @@ export const feeLevels = [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 30, 40, 50, 60, 7
interface Env {
TESTNET_ENABLED: boolean;
LIQUID_ENABLED: boolean;
BISQ_ENABLED: boolean;
ELCTRS_ITEMS_PER_PAGE: number;
KEEP_BLOCKS_AMOUNT: number;
};
}
const defaultEnv: Env = {
'TESTNET_ENABLED': false,
'LIQUID_ENABLED': false,
'BISQ_ENABLED': false,
'ELCTRS_ITEMS_PER_PAGE': 25,
'KEEP_BLOCKS_AMOUNT': 8
};

View File

@ -3,7 +3,7 @@ import { NgModule } from '@angular/core';
import { HttpClientModule } from '@angular/common/http';
import { ReactiveFormsModule } from '@angular/forms';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { NgbButtonsModule, NgbTooltipModule, NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap';
import { NgbButtonsModule, NgbPaginationModule, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap';
import { InfiniteScrollModule } from 'ngx-infinite-scroll';
import { AppRoutingModule } from './app-routing.module';
@ -11,25 +11,17 @@ import { AppComponent } from './components/app/app.component';
import { StartComponent } from './components/start/start.component';
import { ElectrsApiService } from './services/electrs-api.service';
import { BytesPipe } from './pipes/bytes-pipe/bytes.pipe';
import { VbytesPipe } from './pipes/bytes-pipe/vbytes.pipe';
import { WuBytesPipe } from './pipes/bytes-pipe/wubytes.pipe';
import { TransactionComponent } from './components/transaction/transaction.component';
import { TransactionsListComponent } from './components/transactions-list/transactions-list.component';
import { AmountComponent } from './components/amount/amount.component';
import { StateService } from './services/state.service';
import { BlockComponent } from './components/block/block.component';
import { ShortenStringPipe } from './pipes/shorten-string-pipe/shorten-string.pipe';
import { AddressComponent } from './components/address/address.component';
import { SearchFormComponent } from './components/search-form/search-form.component';
import { LatestBlocksComponent } from './components/latest-blocks/latest-blocks.component';
import { WebsocketService } from './services/websocket.service';
import { TimeSinceComponent } from './components/time-since/time-since.component';
import { AddressLabelsComponent } from './components/address-labels/address-labels.component';
import { MempoolBlocksComponent } from './components/mempool-blocks/mempool-blocks.component';
import { CeilPipe } from './pipes/math-ceil/math-ceil.pipe';
import { QrcodeComponent } from './components/qrcode/qrcode.component';
import { ClipboardComponent } from './components/clipboard/clipboard.component';
import { MasterPageComponent } from './components/master-page/master-page.component';
import { AboutComponent } from './components/about/about.component';
import { TelevisionComponent } from './components/television/television.component';
@ -39,19 +31,16 @@ import { BlockchainBlocksComponent } from './components/blockchain-blocks/blockc
import { BlockchainComponent } from './components/blockchain/blockchain.component';
import { FooterComponent } from './components/footer/footer.component';
import { AudioService } from './services/audio.service';
import { FiatComponent } from './fiat/fiat.component';
import { MempoolBlockComponent } from './components/mempool-block/mempool-block.component';
import { FeeDistributionGraphComponent } from './components/fee-distribution-graph/fee-distribution-graph.component';
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';
import { AssetsComponent } from './assets/assets.component';
import { RelativeUrlPipe } from './pipes/relative-url/relative-url.pipe';
import { MinerComponent } from './pipes/miner/miner.component';
import { Hex2asciiPipe } from './pipes/hex2ascii/hex2ascii.pipe';
import { StatusViewComponent } from './components/status-view/status-view.component';
import { MinerComponent } from './components/miner/miner.component';
import { SharedModule } from './shared/shared.module';
@NgModule({
declarations: [
@ -66,33 +55,21 @@ import { StatusViewComponent } from './components/status-view/status-view.compon
TransactionComponent,
BlockComponent,
TransactionsListComponent,
BytesPipe,
VbytesPipe,
WuBytesPipe,
CeilPipe,
ShortenStringPipe,
AddressComponent,
AmountComponent,
SearchFormComponent,
LatestBlocksComponent,
TimeSinceComponent,
TimespanComponent,
AddressLabelsComponent,
MempoolBlocksComponent,
QrcodeComponent,
ClipboardComponent,
ChartistComponent,
FooterComponent,
FiatComponent,
MempoolBlockComponent,
FeeDistributionGraphComponent,
MempoolGraphComponent,
AssetComponent,
ScriptpubkeyTypePipe,
AssetsComponent,
RelativeUrlPipe,
MinerComponent,
Hex2asciiPipe,
StatusViewComponent,
],
imports: [
@ -102,15 +79,15 @@ import { StatusViewComponent } from './components/status-view/status-view.compon
ReactiveFormsModule,
BrowserAnimationsModule,
NgbButtonsModule,
NgbTooltipModule,
NgbPaginationModule,
NgbDropdownModule,
InfiniteScrollModule,
SharedModule,
],
providers: [
ElectrsApiService,
StateService,
WebsocketService,
VbytesPipe,
AudioService,
SeoService,
],

View File

@ -67,7 +67,7 @@ export class AssetsComponent implements OnInit {
});
this.assets = this.assets.sort((a: any, b: any) => a.name.localeCompare(b.name));
this.assetsCache = this.assets;
this.searchForm.controls['searchText'].enable();
this.searchForm.get('searchText').enable();
this.filteredAssets = this.assets.slice(0, this.itemsPerPage);
this.isLoading = false;
},

View File

@ -0,0 +1,106 @@
<div class="container-xl">
<h1 style="float: left;">Address</h1>
<a [routerLink]="['/address/' | relativeUrl, addressString]" style="line-height: 56px; margin-left: 10px;">
<span class="d-inline d-lg-none">{{ addressString | shortenString : 24 }}</span>
<span class="d-none d-lg-inline">{{ addressString }}</span>
</a>
<app-clipboard [text]="addressString"></app-clipboard>
<br>
<div class="clearfix"></div>
<ng-template [ngIf]="!isLoadingAddress && !error">
<div class="box">
<div class="row">
<div class="col">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td>Total received</td>
<td>{{ totalReceived / 100 | number: '1.2-2' }} BSQ</td>
</tr>
<tr>
<td>Total sent</td>
<td>{{ totalSent / 100 | number: '1.2-2' }} BSQ</td>
</tr>
<tr>
<td>Final balance</td>
<td>{{ (totalReceived - totalSent) / 100 | number: '1.2-2' }} BSQ (<app-bsq-amount [bsq]="totalReceived - totalSent" [forceFiat]="true" [green]="true"></app-bsq-amount>)</td>
</tr>
</tbody>
</table>
</div>
<div class="w-100 d-block d-md-none"></div>
<div class="col qrcode-col">
<div class="qr-wrapper">
<app-qrcode [data]="addressString"></app-qrcode>
</div>
</div>
</div>
</div>
<br>
<h2>{{ transactions.length | number }} transactions</h2>
<ng-template ngFor let-tx [ngForOf]="transactions">
<div class="header-bg box" style="padding: 10px; margin-bottom: 10px;">
<a [routerLink]="['/tx/' | relativeUrl, tx.id]" [state]="{ data: tx }">
<span style="float: left;" class="d-block d-md-none">{{ tx.id | shortenString : 16 }}</span>
<span style="float: left;" class="d-none d-md-block">{{ tx.id }}</span>
</a>
<div class="float-right">
{{ tx.time | date:'yyyy-MM-dd HH:mm' }}
</div>
<div class="clearfix"></div>
</div>
<app-bisq-transfers [tx]="tx" [showConfirmations]="true"></app-bisq-transfers>
<br>
</ng-template>
</ng-template>
<ng-template [ngIf]="isLoadingAddress && !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="w-100 d-block d-md-none"></div>
<div class="col">
</div>
</div>
</div>
</ng-template>
<ng-template [ngIf]="error">
<div class="text-center">
Error loading address 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,82 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { SeoService } from 'src/app/services/seo.service';
import { switchMap, filter, catchError } from 'rxjs/operators';
import { ParamMap, ActivatedRoute } from '@angular/router';
import { Subscription, of } from 'rxjs';
import { BisqTransaction } from '../bisq.interfaces';
import { BisqApiService } from '../bisq-api.service';
@Component({
selector: 'app-bisq-address',
templateUrl: './bisq-address.component.html',
styleUrls: ['./bisq-address.component.scss']
})
export class BisqAddressComponent implements OnInit, OnDestroy {
transactions: BisqTransaction[];
addressString: string;
isLoadingAddress = true;
error: any;
mainSubscription: Subscription;
totalReceived = 0;
totalSent = 0;
constructor(
private route: ActivatedRoute,
private seoService: SeoService,
private bisqApiService: BisqApiService,
) { }
ngOnInit() {
this.mainSubscription = this.route.paramMap
.pipe(
switchMap((params: ParamMap) => {
this.error = undefined;
this.isLoadingAddress = true;
this.transactions = null;
document.body.scrollTo(0, 0);
this.addressString = params.get('id') || '';
this.seoService.setTitle('Address: ' + this.addressString, true);
return this.bisqApiService.getAddress$(this.addressString)
.pipe(
catchError((err) => {
this.isLoadingAddress = false;
this.error = err;
console.log(err);
return of(null);
})
);
}),
filter((transactions) => transactions !== null)
)
.subscribe((transactions: BisqTransaction[]) => {
this.transactions = transactions;
this.updateChainStats();
this.isLoadingAddress = false;
},
(error) => {
console.log(error);
this.error = error;
this.isLoadingAddress = false;
});
}
updateChainStats() {
const shortenedAddress = this.addressString.substr(1);
this.totalSent = this.transactions.reduce((acc, tx) =>
acc + tx.inputs
.filter((input) => input.address === shortenedAddress)
.reduce((a, input) => a + input.bsqAmount, 0), 0);
this.totalReceived = this.transactions.reduce((acc, tx) =>
acc + tx.outputs
.filter((output) => output.address === shortenedAddress)
.reduce((a, output) => a + output.bsqAmount, 0), 0);
}
ngOnDestroy() {
this.mainSubscription.unsubscribe();
}
}

View File

@ -0,0 +1,41 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { BisqTransaction, BisqBlock, BisqStats } from './bisq.interfaces';
const API_BASE_URL = '/bisq/api';
@Injectable({
providedIn: 'root'
})
export class BisqApiService {
apiBaseUrl: string;
constructor(
private httpClient: HttpClient,
) { }
getStats$(): Observable<BisqStats> {
return this.httpClient.get<BisqStats>(API_BASE_URL + '/stats');
}
getTransaction$(txId: string): Observable<BisqTransaction> {
return this.httpClient.get<BisqTransaction>(API_BASE_URL + '/tx/' + txId);
}
listTransactions$(start: number, length: number): Observable<HttpResponse<BisqTransaction[]>> {
return this.httpClient.get<BisqTransaction[]>(API_BASE_URL + `/txs/${start}/${length}`, { observe: 'response' });
}
getBlock$(hash: string): Observable<BisqBlock> {
return this.httpClient.get<BisqBlock>(API_BASE_URL + '/block/' + hash);
}
listBlocks$(start: number, length: number): Observable<HttpResponse<BisqBlock[]>> {
return this.httpClient.get<BisqBlock[]>(API_BASE_URL + `/blocks/${start}/${length}`, { observe: 'response' });
}
getAddress$(address: string): Observable<BisqTransaction[]> {
return this.httpClient.get<BisqTransaction[]>(API_BASE_URL + '/address/' + address);
}
}

View File

@ -0,0 +1,108 @@
<div class="container-xl">
<div class="title-block">
<h1>Block <ng-template [ngIf]="blockHeight"><a [routerLink]="['/block/' | relativeUrl, blockHash]">{{ blockHeight }}</a></ng-template></h1>
</div>
<div class="clearfix"></div>
<ng-template [ngIf]="!isLoading && !error">
<div class="box">
<div class="row">
<div class="col-sm">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td class="td-width">Hash</td>
<td><a [routerLink]="['/block/' | relativeUrl, block.hash]" title="{{ block.hash }}">{{ block.hash | shortenString : 13 }}</a> <app-clipboard class="d-none d-sm-inline-block" [text]="block.hash"></app-clipboard></td>
</tr>
<tr>
<td>Timestamp</td>
<td>
{{ block.time | date:'yyyy-MM-dd HH:mm' }}
<div class="lg-inline">
<i>(<app-time-since [time]="block.time / 1000" [fastRender]="true"></app-time-since> ago)</i>
</div>
</td>
</tr>
</table>
</div>
<div class="col-sm">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td class="td-width">Previous hash</td>
<td><a [routerLink]="['/block/' | relativeUrl, block.previousBlockHash]" title="{{ block.hash }}">{{ block.previousBlockHash | shortenString : 13 }}</a> <app-clipboard class="d-none d-sm-inline-block" [text]="block.previousBlockHash"></app-clipboard></td>
</tr>
</table>
</div>
</div>
</div>
<div class="clearfix"></div>
<br>
<h2>{{ block.txs.length | number }} transactions</h2>
<ng-template ngFor let-tx [ngForOf]="block.txs">
<div class="header-bg box" style="padding: 10px; margin-bottom: 10px;">
<a [routerLink]="['/tx/' | relativeUrl, tx.id]" [state]="{ data: tx }">
<span style="float: left;" class="d-block d-md-none">{{ tx.id | shortenString : 16 }}</span>
<span style="float: left;" class="d-none d-md-block">{{ tx.id }}</span>
</a>
<div class="float-right">
{{ tx.time | date:'yyyy-MM-dd HH:mm' }}
</div>
<div class="clearfix"></div>
</div>
<app-bisq-transfers [tx]="tx" [showConfirmations]="true"></app-bisq-transfers>
<br>
</ng-template>
</ng-template>
<ng-template [ngIf]="isLoading && !error">
<div class="box">
<div class="row">
<div class="col-sm">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td class="td-width">Hash</td>
<td><span class="skeleton-loader"></span></td>
</tr>
<tr>
<td>Timestamp</td>
<td><span class="skeleton-loader"></span></td>
</tr>
</table>
</div>
<div class="col-sm">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td class="td-width">Previous hash</td>
<td><span class="skeleton-loader"></span></td>
</tr>
</table>
</div>
</div>
</div>
</ng-template>
<ng-template [ngIf]="error">
<div class="clearfix"></div>
<div class="text-center">
Error loading block
<br>
<i>{{ error.status }}: {{ error.statusText }}</i>
</div>
</ng-template>
</div>

View File

@ -0,0 +1,10 @@
.td-width {
width: 175px;
}
@media (max-width: 767.98px) {
.td-width {
width: 140px;
}
}

View File

@ -0,0 +1,98 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { BisqBlock } from 'src/app/bisq/bisq.interfaces';
import { Location } from '@angular/common';
import { BisqApiService } from '../bisq-api.service';
import { ActivatedRoute, ParamMap, Router } from '@angular/router';
import { Subscription, of } from 'rxjs';
import { switchMap, catchError } from 'rxjs/operators';
import { SeoService } from 'src/app/services/seo.service';
import { ElectrsApiService } from 'src/app/services/electrs-api.service';
import { HttpErrorResponse } from '@angular/common/http';
@Component({
selector: 'app-bisq-block',
templateUrl: './bisq-block.component.html',
styleUrls: ['./bisq-block.component.scss']
})
export class BisqBlockComponent implements OnInit, OnDestroy {
block: BisqBlock;
subscription: Subscription;
blockHash = '';
blockHeight = 0;
isLoading = true;
error: HttpErrorResponse | null;
constructor(
private bisqApiService: BisqApiService,
private route: ActivatedRoute,
private seoService: SeoService,
private electrsApiService: ElectrsApiService,
private router: Router,
private location: Location,
) { }
ngOnInit(): void {
this.subscription = this.route.paramMap
.pipe(
switchMap((params: ParamMap) => {
const blockHash = params.get('id') || '';
document.body.scrollTo(0, 0);
this.isLoading = true;
this.error = null;
if (history.state.data && history.state.data.blockHeight) {
this.blockHeight = history.state.data.blockHeight;
}
if (history.state.data && history.state.data.block) {
this.blockHeight = history.state.data.block.height;
return of(history.state.data.block);
}
let isBlockHeight = false;
if (/^[0-9]+$/.test(blockHash)) {
isBlockHeight = true;
} else {
this.blockHash = blockHash;
}
if (isBlockHeight) {
return this.electrsApiService.getBlockHashFromHeight$(parseInt(blockHash, 10))
.pipe(
switchMap((hash) => {
if (!hash) {
return;
}
this.blockHash = hash;
this.location.replaceState(
this.router.createUrlTree(['/bisq/block/', hash]).toString()
);
return this.bisqApiService.getBlock$(this.blockHash)
.pipe(catchError(this.caughtHttpError.bind(this)));
}),
catchError(this.caughtHttpError.bind(this))
);
}
return this.bisqApiService.getBlock$(this.blockHash)
.pipe(catchError(this.caughtHttpError.bind(this)));
})
)
.subscribe((block: BisqBlock) => {
if (!block) {
return;
}
this.isLoading = false;
this.blockHeight = block.height;
this.seoService.setTitle('Block: #' + block.height + ': ' + block.hash, true);
this.block = block;
});
}
ngOnDestroy() {
this.subscription.unsubscribe();
}
caughtHttpError(err: HttpErrorResponse){
this.error = err;
return of(null);
}
}

View File

@ -0,0 +1,36 @@
<div class="container-xl">
<h1 style="float: left;">Blocks</h1>
<br>
<div class="clearfix"></div>
<div class="table-responsive-sm">
<table class="table table-borderless table-striped">
<thead>
<th style="width: 25%;">Height</th>
<th style="width: 25%;">Confirmed</th>
<th style="width: 25%;">Total Sent</th>
<th class="d-none d-md-block" style="width: 25%;">Transactions</th>
</thead>
<tbody *ngIf="!isLoading; else loadingTmpl">
<tr *ngFor="let block of blocks; trackBy: trackByFn">
<td><a [routerLink]="['/block/' | relativeUrl, block.hash]" [state]="{ data: { block: block } }">{{ block.height }}</a></td>
<td><app-time-since [time]="block.time / 1000" [fastRender]="true"></app-time-since> ago</td>
<td>{{ calculateTotalOutput(block) / 100 | number: '1.2-2' }}<span class="d-none d-md-inline"> BSQ</span></td>
<td class="d-none d-md-block">{{ block.txs.length }}</td>
</tr>
</tbody>
</table>
</div>
<br>
<ngb-pagination [size]="paginationSize" [collectionSize]="totalCount" [rotate]="true" [pageSize]="itemsPerPage" [(page)]="page" (pageChange)="pageChange(page)" [maxSize]="paginationMaxSize" [boundaryLinks]="true"></ngb-pagination>
</div>
<ng-template #loadingTmpl>
<tr *ngFor="let i of loadingItems">
<td *ngFor="let j of [1, 2, 3, 4, 5]"><span class="skeleton-loader"></span></td>
</tr>
</ng-template>

View File

@ -0,0 +1,71 @@
import { Component, OnInit } from '@angular/core';
import { BisqApiService } from '../bisq-api.service';
import { switchMap, tap } from 'rxjs/operators';
import { Subject } from 'rxjs';
import { BisqBlock, BisqOutput, BisqTransaction } from '../bisq.interfaces';
import { SeoService } from 'src/app/services/seo.service';
@Component({
selector: 'app-bisq-blocks',
templateUrl: './bisq-blocks.component.html',
styleUrls: ['./bisq-blocks.component.scss']
})
export class BisqBlocksComponent implements OnInit {
blocks: BisqBlock[];
totalCount: number;
page = 1;
itemsPerPage: number;
contentSpace = window.innerHeight - (165 + 75);
fiveItemsPxSize = 250;
loadingItems: number[];
isLoading = true;
// @ts-ignore
paginationSize: 'sm' | 'lg' = 'md';
paginationMaxSize = 10;
pageSubject$ = new Subject<number>();
constructor(
private bisqApiService: BisqApiService,
private seoService: SeoService,
) { }
ngOnInit(): void {
this.seoService.setTitle('Blocks', true);
this.itemsPerPage = Math.max(Math.round(this.contentSpace / this.fiveItemsPxSize) * 5, 10);
this.loadingItems = Array(this.itemsPerPage);
if (document.body.clientWidth < 768) {
this.paginationSize = 'sm';
this.paginationMaxSize = 3;
}
this.pageSubject$
.pipe(
tap(() => this.isLoading = true),
switchMap((page) => this.bisqApiService.listBlocks$((page - 1) * this.itemsPerPage, this.itemsPerPage))
)
.subscribe((response) => {
this.isLoading = false;
this.blocks = response.body;
this.totalCount = parseInt(response.headers.get('x-total-count'), 10);
}, (error) => {
console.log(error);
});
this.pageSubject$.next(1);
}
calculateTotalOutput(block: BisqBlock): number {
return block.txs.reduce((a: number, tx: BisqTransaction) =>
a + tx.outputs.reduce((acc: number, output: BisqOutput) => acc + output.bsqAmount, 0), 0
);
}
trackByFn(index: number) {
return index;
}
pageChange(page: number) {
this.pageSubject$.next(page);
}
}

View File

@ -0,0 +1 @@
<router-outlet></router-outlet>

View File

@ -0,0 +1,18 @@
import { Component, OnInit } from '@angular/core';
import { WebsocketService } from 'src/app/services/websocket.service';
@Component({
selector: 'app-bisq-explorer',
templateUrl: './bisq-explorer.component.html',
styleUrls: ['./bisq-explorer.component.scss']
})
export class BisqExplorerComponent implements OnInit {
constructor(
private websocketService: WebsocketService,
) { }
ngOnInit(): void {
this.websocketService.want(['blocks']);
}
}

View File

@ -0,0 +1 @@
<fa-icon [icon]="iconProp" [fixedWidth]="true" [ngStyle]="{ 'color': '#' + color }"></fa-icon>

View File

@ -0,0 +1,81 @@
import { Component, ChangeDetectionStrategy, Input, OnChanges } from '@angular/core';
import { IconPrefix, IconName } from '@fortawesome/fontawesome-common-types';
@Component({
selector: 'app-bisq-icon',
templateUrl: './bisq-icon.component.html',
styleUrls: ['./bisq-icon.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BisqIconComponent implements OnChanges {
@Input() txType: string;
iconProp: [IconPrefix, IconName] = ['fas', 'leaf'];
color: string;
constructor() { }
ngOnChanges() {
switch (this.txType) {
case 'UNVERIFIED':
this.iconProp[1] = 'question';
this.color = 'ffac00';
break;
case 'INVALID':
this.iconProp[1] = 'exclamation-triangle';
this.color = 'ff4500';
break;
case 'GENESIS':
this.iconProp[1] = 'rocket';
this.color = '25B135';
break;
case 'TRANSFER_BSQ':
this.iconProp[1] = 'retweet';
this.color = 'a3a3a3';
break;
case 'PAY_TRADE_FEE':
this.iconProp[1] = 'leaf';
this.color = '689f43';
break;
case 'PROPOSAL':
this.iconProp[1] = 'file-alt';
this.color = '6c8b3b';
break;
case 'COMPENSATION_REQUEST':
this.iconProp[1] = 'money-bill';
this.color = '689f43';
break;
case 'REIMBURSEMENT_REQUEST':
this.iconProp[1] = 'money-bill';
this.color = '04a908';
break;
case 'BLIND_VOTE':
this.iconProp[1] = 'eye-slash';
this.color = '07579a';
break;
case 'VOTE_REVEAL':
this.iconProp[1] = 'eye';
this.color = '4AC5FF';
break;
case 'LOCKUP':
this.iconProp[1] = 'lock';
this.color = '0056c4';
break;
case 'UNLOCK':
this.iconProp[1] = 'lock-open';
this.color = '1d965f';
break;
case 'ASSET_LISTING_FEE':
this.iconProp[1] = 'file-alt';
this.color = '6c8b3b';
break;
case 'PROOF_OF_BURN':
this.iconProp[1] = 'file-alt';
this.color = '6c8b3b';
break;
default:
this.iconProp[1] = 'question';
this.color = 'ffac00';
}
}
}

View File

@ -0,0 +1,90 @@
<div class="container-xl">
<h1 style="float: left;">BSQ Statistics</h1>
<br>
<div class="clearfix"></div>
<div class="row">
<div class="col-sm">
<table class="table table-borderless table-striped">
<thead>
<th>Property</th>
<th>Value</th>
</thead>
<tbody *ngIf="!isLoading; else loadingTemplate">
<tr>
<td class="td-width">Existing amount</td>
<td>{{ (stats.minted - stats.burnt) / 100 | number: '1.2-2' }} BSQ</td>
</tr>
<tr>
<td>Minted amount</td>
<td>{{ stats.minted | number: '1.2-2' }} BSQ</td>
</tr>
<tr>
<td>Burnt amount</td>
<td>{{ stats.burnt | number: '1.2-2' }} BSQ</td>
</tr>
<tr>
<td>Addresses</td>
<td>{{ stats.addresses | number }}</td>
</tr>
<tr>
<td>Unspent TXOs</td>
<td>{{ stats.unspent_txos | number }}</td>
</tr>
<tr>
<td>Spent TXOs</td>
<td>{{ stats.spent_txos | number }}</td>
</tr>
<tr>
<td>Price</td>
<td><app-fiat [value]="price"></app-fiat></td>
</tr>
<tr>
<td>Market cap</td>
<td><app-fiat [value]="price * (stats.minted - stats.burnt) / 100"></app-fiat></td>
</tr>
</tbody>
</table>
</div>
<div class="col-sm"></div>
</div>
</div>
<ng-template #loadingTemplate>
<tbody>
<tr>
<td class="td-width">Existing amount</td>
<td><span class="skeleton-loader"></span></td>
</tr>
<tr>
<td>Minted amount</td>
<td><span class="skeleton-loader"></span></td>
</tr>
<tr>
<td>Burnt amount</td>
<td><span class="skeleton-loader"></span></td>
</tr>
<tr>
<td>Addresses</td>
<td><span class="skeleton-loader"></span></td>
</tr>
<tr>
<td>Unspent TXOs</td>
<td><span class="skeleton-loader"></span></td>
</tr>
<tr>
<td>Spent TXOs</td>
<td><span class="skeleton-loader"></span></td>
</tr>
<tr>
<td>Price</td>
<td><span class="skeleton-loader"></span></td>
</tr>
<tr>
<td>Market cap</td>
<td><span class="skeleton-loader"></span></td>
</tr>
</tbody>
</ng-template>

View File

@ -0,0 +1,9 @@
.td-width {
width: 250px;
}
@media (max-width: 767.98px) {
.td-width {
width: 175px;
}
}

View File

@ -0,0 +1,38 @@
import { Component, OnInit } from '@angular/core';
import { BisqApiService } from '../bisq-api.service';
import { BisqStats } from '../bisq.interfaces';
import { SeoService } from 'src/app/services/seo.service';
import { StateService } from 'src/app/services/state.service';
@Component({
selector: 'app-bisq-stats',
templateUrl: './bisq-stats.component.html',
styleUrls: ['./bisq-stats.component.scss']
})
export class BisqStatsComponent implements OnInit {
isLoading = true;
stats: BisqStats;
price: number;
constructor(
private bisqApiService: BisqApiService,
private seoService: SeoService,
private stateService: StateService,
) { }
ngOnInit() {
this.seoService.setTitle('BSQ Statistics', false);
this.stateService.bsqPrice$
.subscribe((bsqPrice) => {
this.price = bsqPrice;
});
this.bisqApiService.getStats$()
.subscribe((stats) => {
this.isLoading = false;
this.stats = stats;
});
}
}

View File

@ -0,0 +1,36 @@
<div class="box">
<div class="row">
<div class="col-sm">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td class="td-width">Inputs</td>
<td>{{ totalInput / 100 | number: '1.2-2' }} BSQ</td>
</tr>
<tr>
<td>Outputs</td>
<td>{{ totalOutput / 100 | number: '1.2-2' }} BSQ</td>
</tr>
<tr>
<td>Issuance</td>
<td>{{ totalIssued / 100 | number: '1.2-2' }} BSQ</td>
</tr>
</tbody>
</table>
</div>
<div class="col-sm">
<table class="table table-borderless table-striped">
<tbody class="mobile-even">
<tr>
<td class="td-width">Type</td>
<td><app-bisq-icon class="mr-1" [txType]="tx.txType"></app-bisq-icon> {{ tx.txTypeDisplayString }}</td>
</tr>
<tr>
<td>Version</td>
<td>{{ tx.txVersion }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>

View File

@ -0,0 +1,11 @@
@media (max-width: 767.98px) {
.td-width {
width: 150px;
}
.mobile-even tr:nth-of-type(even) {
background-color: #181b2d;
}
.mobile-even tr:nth-of-type(odd) {
background-color: inherit;
}
}

View File

@ -0,0 +1,26 @@
import { Component, ChangeDetectionStrategy, Input, OnChanges } from '@angular/core';
import { BisqTransaction } from 'src/app/bisq/bisq.interfaces';
@Component({
selector: 'app-bisq-transaction-details',
templateUrl: './bisq-transaction-details.component.html',
styleUrls: ['./bisq-transaction-details.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BisqTransactionDetailsComponent implements OnChanges {
@Input() tx: BisqTransaction;
totalInput: number;
totalOutput: number;
totalIssued: number;
constructor() { }
ngOnChanges() {
this.totalInput = this.tx.inputs.filter((input) => input.isVerified).reduce((acc, input) => acc + input.bsqAmount, 0);
this.totalOutput = this.tx.outputs.filter((output) => output.isVerified).reduce((acc, output) => acc + output.bsqAmount, 0);
this.totalIssued = this.tx.outputs
.filter((output) => output.isVerified && output.txOutputType === 'ISSUANCE_CANDIDATE_OUTPUT')
.reduce((acc, output) => acc + output.bsqAmount, 0);
}
}

View File

@ -0,0 +1,164 @@
<div class="container-xl">
<h1 class="float-left mr-3 mb-md-3">Transaction</h1>
<ng-template [ngIf]="!isLoading && !error">
<button *ngIf="(latestBlock$ | async) as latestBlock" type="button" class="btn btn-sm btn-success float-right mr-2 mt-1 mt-md-3">{{ latestBlock.height - bisqTx.blockHeight + 1 }} confirmation<ng-container *ngIf="latestBlock.height - bisqTx.blockHeight + 1 > 1">s</ng-container></button>
<div>
<a [routerLink]="['/bisq-tx' | relativeUrl, bisqTx.id]" style="line-height: 56px;">
<span class="d-inline d-lg-none">{{ bisqTx.id | shortenString : 24 }}</span>
<span class="d-none d-lg-inline">{{ bisqTx.id }}</span>
</a>
<app-clipboard [text]="bisqTx.id"></app-clipboard>
</div>
<div class="clearfix"></div>
<div class="box">
<div class="row">
<div class="col-sm">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td class="td-width">Included in block</td>
<td>
<a [routerLink]="['/block/' | relativeUrl, bisqTx.blockHash]" [state]="{ data: { blockHeight: bisqTx.blockHeight } }">{{ bisqTx.blockHeight }}</a>
<i> (<app-time-since [time]="bisqTx.time / 1000" [fastRender]="true"></app-time-since> ago)</i>
</td>
</tr>
<tr>
<td class="td-width">Features</td>
<td>
<app-tx-features *ngIf="tx; else loadingTx" [tx]="tx"></app-tx-features>
<ng-template #loadingTx>
<span class="skeleton-loader"></span>
</ng-template>
</td>
</tr>
</tbody>
</table>
</div>
<div class="col-sm">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td class="td-width">Burnt</td>
<td>
{{ bisqTx.burntFee / 100 | number: '1.2-2' }} BSQ (<app-bsq-amount [bsq]="bisqTx.burntFee" [forceFiat]="true" [green]="true"></app-bsq-amount>)
</tr>
<tr>
<td>Fee per vByte</td>
<td *ngIf="!isLoadingTx; else loadingTxFee">
{{ tx.fee / (tx.weight / 4) | number : '1.1-1' }} sat/vB
&nbsp;
<app-tx-fee-rating [tx]="tx"></app-tx-fee-rating>
</td>
<ng-template #loadingTxFee>
<td><span class="skeleton-loader"></span></td>
</ng-template>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<br>
<h2>Details</h2>
<app-bisq-transaction-details [tx]="bisqTx"></app-bisq-transaction-details>
<br>
<h2>Inputs & Outputs</h2>
<app-bisq-transfers [tx]="bisqTx"></app-bisq-transfers>
<br>
</ng-template>
<ng-template [ngIf="isLoading && !error">
<div class="clearfix"></div>
<div class="box">
<div class="row">
<div class="col-sm">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td class="td-width"><span class="skeleton-loader"></span></td>
<td><span class="skeleton-loader"></span></td>
</tr>
</tbody>
</table>
</div>
<div class="col-sm">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td class="td-width"><span class="skeleton-loader"></span></td>
<td><span class="skeleton-loader"></span></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<br>
<h2>Details</h2>
<div class="box">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td><span class="skeleton-loader"></span></td>
<td><span class="skeleton-loader"></span></td>
</tr>
<tr>
<td><span class="skeleton-loader"></span></td>
<td><span class="skeleton-loader"></span></td>
</tr>
<tr>
<td><span class="skeleton-loader"></span></td>
</tr>
</tbody>
</table>
</div>
<br>
<h2>Inputs & Outputs</h2>
<div class="box">
<div class="row">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td><span class="skeleton-loader"></span></td>
<td><span class="skeleton-loader"></span></td>
</tr>
</tbody>
</table>
</div>
</div>
</ng-template>
<ng-template [ngIf]="error">
<div class="clearfix"></div>
<div class="text-center">
Error loading transaction
<br><br>
<i>{{ error.status }}: {{ error.statusText }}</i>
</div>
</ng-template>
</div>

View File

@ -0,0 +1,9 @@
.td-width {
width: 175px;
}
@media (max-width: 767.98px) {
.td-width {
width: 150px;
}
}

View File

@ -0,0 +1,118 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { ActivatedRoute, ParamMap, Router } from '@angular/router';
import { BisqTransaction } from 'src/app/bisq/bisq.interfaces';
import { switchMap, map, catchError } from 'rxjs/operators';
import { of, Observable, Subscription } from 'rxjs';
import { StateService } from 'src/app/services/state.service';
import { Block, Transaction } from 'src/app/interfaces/electrs.interface';
import { BisqApiService } from '../bisq-api.service';
import { SeoService } from 'src/app/services/seo.service';
import { ElectrsApiService } from 'src/app/services/electrs-api.service';
import { HttpErrorResponse } from '@angular/common/http';
@Component({
selector: 'app-bisq-transaction',
templateUrl: './bisq-transaction.component.html',
styleUrls: ['./bisq-transaction.component.scss']
})
export class BisqTransactionComponent implements OnInit, OnDestroy {
bisqTx: BisqTransaction;
tx: Transaction;
latestBlock$: Observable<Block>;
txId: string;
price: number;
isLoading = true;
isLoadingTx = true;
error = null;
subscription: Subscription;
constructor(
private route: ActivatedRoute,
private bisqApiService: BisqApiService,
private electrsApiService: ElectrsApiService,
private stateService: StateService,
private seoService: SeoService,
private router: Router,
) { }
ngOnInit(): void {
this.subscription = this.route.paramMap.pipe(
switchMap((params: ParamMap) => {
this.isLoading = true;
this.isLoadingTx = true;
this.error = null;
document.body.scrollTo(0, 0);
this.txId = params.get('id') || '';
this.seoService.setTitle('Transaction: ' + this.txId, true);
if (history.state.data) {
return of(history.state.data);
}
return this.bisqApiService.getTransaction$(this.txId)
.pipe(
catchError((bisqTxError: HttpErrorResponse) => {
if (bisqTxError.status === 404) {
return this.electrsApiService.getTransaction$(this.txId)
.pipe(
map((tx) => {
if (tx.status.confirmed) {
this.error = {
status: 200,
statusText: 'Transaction is confirmed but not available in the Bisq database, please try reloading this page.'
};
return null;
}
return tx;
}),
catchError((txError: HttpErrorResponse) => {
console.log(txError);
this.error = txError;
return of(null);
})
);
}
this.error = bisqTxError;
return of(null);
})
);
}),
switchMap((tx) => {
if (!tx) {
return of(null);
}
if (tx.version) {
this.router.navigate(['/tx/', this.txId], { state: { data: tx, bsqTx: true }});
return of(null);
}
this.bisqTx = tx;
this.isLoading = false;
return this.electrsApiService.getTransaction$(this.txId);
}),
)
.subscribe((tx) => {
this.isLoadingTx = false;
if (!tx) {
return;
}
this.tx = tx;
},
(error) => {
this.error = error;
});
this.latestBlock$ = this.stateService.blocks$.pipe(map((([block]) => block)));
this.stateService.bsqPrice$
.subscribe((bsqPrice) => {
this.price = bsqPrice;
});
}
ngOnDestroy() {
this.subscription.unsubscribe();
}
}

View File

@ -0,0 +1,47 @@
<div class="container-xl">
<h1 style="float: left;">Transactions</h1>
<br>
<div class="clearfix"></div>
<table class="table table-borderless table-striped">
<thead>
<th style="width: 20%;">Transaction</th>
<th class="d-none d-md-block" style="width: 20%;">Type</th>
<th style="width: 20%;">Amount</th>
<th style="width: 20%;">Confirmed</th>
<th class="d-none d-md-block" style="width: 20%;">Height</th>
</thead>
<tbody *ngIf="!isLoading; else loadingTmpl">
<tr *ngFor="let tx of transactions; trackBy: trackByFn">
<td><a [routerLink]="['/tx/' | relativeUrl, tx.id]" [state]="{ data: tx }">{{ tx.id | slice : 0 : 8 }}</a></td>
<td class="d-none d-md-block">
<app-bisq-icon class="mr-1" [txType]="tx.txType"></app-bisq-icon>
<span class="d-none d-md-inline"> {{ tx.txTypeDisplayString }}</span>
</td>
<td>
<app-bisq-icon class="d-inline d-md-none mr-1" [txType]="tx.txType"></app-bisq-icon>
<ng-template [ngIf]="tx.txType === 'PAY_TRADE_FEE'" [ngIfElse]="defaultTxType">
{{ tx.burntFee / 100 | number: '1.2-2' }}<span class="d-none d-md-inline"> BSQ</span>
</ng-template>
<ng-template #defaultTxType>
{{ calculateTotalOutput(tx.outputs) / 100 | number: '1.2-2' }}<span class="d-none d-md-inline"> BSQ</span>
</ng-template>
</td>
<td><app-time-since [time]="tx.time / 1000" [fastRender]="true"></app-time-since> ago</td>
<td class="d-none d-md-block"><a [routerLink]="['/block/' | relativeUrl, tx.blockHash]" [state]="{ data: { blockHeight: tx.blockHeight } }">{{ tx.blockHeight }}</a></td>
</tr>
</tbody>
</table>
<br>
<ngb-pagination [size]="paginationSize" [collectionSize]="totalCount" [rotate]="true" [pageSize]="itemsPerPage" [(page)]="page" (pageChange)="pageChange(page)" [maxSize]="paginationMaxSize" [boundaryLinks]="true"></ngb-pagination>
</div>
<ng-template #loadingTmpl>
<tr *ngFor="let i of loadingItems">
<td *ngFor="let j of [1, 2, 3, 4, 5]"><span class="skeleton-loader"></span></td>
</tr>
</ng-template>

View File

@ -0,0 +1,71 @@
import { Component, OnInit } from '@angular/core';
import { BisqTransaction, BisqOutput } from '../bisq.interfaces';
import { Subject } from 'rxjs';
import { switchMap, tap } from 'rxjs/operators';
import { BisqApiService } from '../bisq-api.service';
import { SeoService } from 'src/app/services/seo.service';
@Component({
selector: 'app-bisq-transactions',
templateUrl: './bisq-transactions.component.html',
styleUrls: ['./bisq-transactions.component.scss']
})
export class BisqTransactionsComponent implements OnInit {
transactions: BisqTransaction[];
totalCount: number;
page = 1;
itemsPerPage: number;
contentSpace = window.innerHeight - (165 + 75);
fiveItemsPxSize = 250;
isLoading = true;
loadingItems: number[];
pageSubject$ = new Subject<number>();
// @ts-ignore
paginationSize: 'sm' | 'lg' = 'md';
paginationMaxSize = 10;
constructor(
private bisqApiService: BisqApiService,
private seoService: SeoService,
) { }
ngOnInit(): void {
this.seoService.setTitle('Transactions', true);
this.itemsPerPage = Math.max(Math.round(this.contentSpace / this.fiveItemsPxSize) * 5, 10);
this.loadingItems = Array(this.itemsPerPage);
if (document.body.clientWidth < 768) {
this.paginationSize = 'sm';
this.paginationMaxSize = 3;
}
this.pageSubject$
.pipe(
tap(() => this.isLoading = true),
switchMap((page) => this.bisqApiService.listTransactions$((page - 1) * this.itemsPerPage, this.itemsPerPage))
)
.subscribe((response) => {
this.isLoading = false;
this.transactions = response.body;
this.totalCount = parseInt(response.headers.get('x-total-count'), 10);
}, (error) => {
console.log(error);
});
this.pageSubject$.next(1);
}
pageChange(page: number) {
this.pageSubject$.next(page);
}
calculateTotalOutput(outputs: BisqOutput[]): number {
return outputs.reduce((acc: number, output: BisqOutput) => acc + output.bsqAmount, 0);
}
trackByFn(index: number) {
return index;
}
}

View File

@ -0,0 +1,77 @@
<div class="header-bg box">
<div class="row">
<div class="col">
<table class="table table-borderless smaller-text table-xs" style="margin: 0;">
<tbody>
<ng-template ngFor let-input [ngForOf]="tx.inputs" [ngForTrackBy]="trackByIndexFn">
<tr *ngIf="input.isVerified">
<td class="arrow-td">
<ng-template [ngIf]="input.spendingTxId === null" [ngIfElse]="hasPreoutput">
<i class="arrow grey"></i>
</ng-template>
<ng-template #hasPreoutput>
<a [routerLink]="['/tx/' | relativeUrl, input.spendingTxId]">
<i class="arrow red"></i>
</a>
</ng-template>
</td>
<td>
<a [routerLink]="['/address/' | relativeUrl, 'B' + input.address]" title="B{{ input.address }}">
<span class="d-block d-lg-none">B{{ input.address | shortenString : 16 }}</span>
<span class="d-none d-lg-block">B{{ input.address | shortenString : 35 }}</span>
</a>
</td>
<td class="text-right nowrap">
<app-bsq-amount [bsq]="input.bsqAmount"></app-bsq-amount>
</td>
</tr>
</ng-template>
</tbody>
</table>
</div>
<div class="w-100 d-block d-md-none"></div>
<div class="col mobile-bottomcol">
<table class="table table-borderless smaller-text table-xs" style="margin: 0;">
<tbody>
<ng-template ngFor let-output [ngForOf]="tx.outputs" [ngForTrackBy]="trackByIndexFn">
<tr *ngIf="output.isVerified && output.opReturn === undefined">
<td>
<a [routerLink]="['/address/' | relativeUrl, 'B' + output.address]" title="B{{ output.address }}">
<span class="d-block d-lg-none">B{{ output.address | shortenString : 16 }}</span>
<span class="d-none d-lg-block">B{{ output.address | shortenString : 35 }}</span>
</a>
</td>
<td class="text-right nowrap">
<app-bsq-amount [bsq]="output.bsqAmount"></app-bsq-amount>
</td>
<td class="pl-1 arrow-td">
<i *ngIf="!output.spentInfo; else spent" class="arrow green"></i>
<ng-template #spent>
<a [routerLink]="['/tx/' | relativeUrl, output.spentInfo.txId]"><i class="arrow red"></i></a>
</ng-template>
</td>
</tr>
</ng-template>
</tbody>
</table>
</div>
</div>
<div>
<div class="float-left mt-2-5" *ngIf="showConfirmations && tx.burntFee">
Burnt: {{ tx.burntFee / 100 | number: '1.2-2' }} BSQ (<app-bsq-amount [bsq]="tx.burntFee" [forceFiat]="true" [green]="true"></app-bsq-amount>)
</div>
<div class="float-right">
<span *ngIf="showConfirmations && latestBlock$ | async as latestBlock">
<button type="button" class="btn btn-sm btn-success mt-2">{{ latestBlock.height - tx.blockHeight + 1 }} confirmation<ng-container *ngIf="latestBlock.height - tx.blockHeight + 1 > 1">s</ng-container></button>
&nbsp;
</span>
<button type="button" class="btn btn-sm btn-primary mt-2" (click)="switchCurrency()">
<app-bsq-amount [bsq]="totalOutput"></app-bsq-amount>
</button>
</div>
<div class="clearfix"></div>
</div>
</div>

View File

@ -0,0 +1,84 @@
.arrow-td {
width: 22px;
}
.arrow {
display: inline-block!important;
position: relative;
width: 14px;
height: 22px;
box-sizing: content-box
}
.arrow:before {
position: absolute;
content: '';
margin: auto;
top: 0;
bottom: 0;
left: 0;
right: calc(-1*30px/3);
width: 0;
height: 0;
border-top: 6.66px solid transparent;
border-bottom: 6.66px solid transparent
}
.arrow:after {
position: absolute;
content: '';
margin: auto;
top: 0;
bottom: 0;
left: 0;
right: calc(30px/6);
width: calc(30px/3);
height: calc(20px/3);
background: rgba(0, 0, 0, 0);
}
.arrow.green:before {
border-left: 10px solid #28a745;
}
.arrow.green:after {
background-color:#28a745;
}
.arrow.red:before {
border-left: 10px solid #dc3545;
}
.arrow.red:after {
background-color:#dc3545;
}
.arrow.grey:before {
border-left: 10px solid #6c757d;
}
.arrow.grey:after {
background-color:#6c757d;
}
.scriptmessage {
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
}
.scriptmessage.longer {
max-width: 500px;
}
@media (max-width: 767.98px) {
.mobile-bottomcol {
margin-top: 15px;
}
.scriptmessage {
max-width: 90px !important;
}
.scriptmessage.longer {
max-width: 280px !important;
}
}

View File

@ -0,0 +1,42 @@
import { Component, OnInit, ChangeDetectionStrategy, Input, OnChanges } from '@angular/core';
import { BisqTransaction } from 'src/app/bisq/bisq.interfaces';
import { StateService } from 'src/app/services/state.service';
import { map } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { Block } from 'src/app/interfaces/electrs.interface';
@Component({
selector: 'app-bisq-transfers',
templateUrl: './bisq-transfers.component.html',
styleUrls: ['./bisq-transfers.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BisqTransfersComponent implements OnInit, OnChanges {
@Input() tx: BisqTransaction;
@Input() showConfirmations = false;
totalOutput: number;
latestBlock$: Observable<Block>;
constructor(
private stateService: StateService,
) { }
trackByIndexFn(index: number) {
return index;
}
ngOnInit() {
this.latestBlock$ = this.stateService.blocks$.pipe(map(([block]) => block));
}
ngOnChanges() {
this.totalOutput = this.tx.outputs.filter((output) => output.isVerified).reduce((acc, output) => acc + output.bsqAmount, 0);;
}
switchCurrency() {
const oldvalue = !this.stateService.viewFiat$.value;
this.stateService.viewFiat$.next(oldvalue);
}
}

View File

@ -0,0 +1,82 @@
export interface BisqBlocks {
chainHeight: number;
blocks: BisqBlock[];
}
export interface BisqBlock {
height: number;
time: number;
hash: string;
previousBlockHash: string;
txs: BisqTransaction[];
}
export interface BisqTransaction {
txVersion: string;
id: string;
blockHeight: number;
blockHash: string;
time: number;
inputs: BisqInput[];
outputs: BisqOutput[];
txType: string;
txTypeDisplayString: string;
burntFee: number;
invalidatedBsq: number;
unlockBlockHeight: number;
}
interface BisqInput {
spendingTxOutputIndex: number;
spendingTxId: string;
bsqAmount: number;
isVerified: boolean;
address: string;
time: number;
}
export interface BisqOutput {
txVersion: string;
txId: string;
index: number;
bsqAmount: number;
btcAmount: number;
height: number;
isVerified: boolean;
burntFee: number;
invalidatedBsq: number;
address: string;
scriptPubKey: BisqScriptPubKey;
spentInfo?: SpentInfo;
time: any;
txType: string;
txTypeDisplayString: string;
txOutputType: string;
txOutputTypeDisplayString: string;
lockTime: number;
isUnspent: boolean;
opReturn?: string;
}
export interface BisqStats {
minted: number;
burnt: number;
addresses: number;
unspent_txos: number;
spent_txos: number;
}
interface BisqScriptPubKey {
addresses: string[];
asm: string;
hex: string;
reqSigs: number;
type: string;
}
interface SpentInfo {
height: number;
inputIndex: number;
txId: string;
}

View File

@ -0,0 +1,60 @@
import { NgModule } from '@angular/core';
import { BisqRoutingModule } from './bisq.routing.module';
import { SharedModule } from '../shared/shared.module';
import { BisqTransactionsComponent } from './bisq-transactions/bisq-transactions.component';
import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap';
import { BisqTransactionComponent } from './bisq-transaction/bisq-transaction.component';
import { BisqBlockComponent } from './bisq-block/bisq-block.component';
import { BisqIconComponent } from './bisq-icon/bisq-icon.component';
import { BisqTransactionDetailsComponent } from './bisq-transaction-details/bisq-transaction-details.component';
import { BisqTransfersComponent } from './bisq-transfers/bisq-transfers.component';
import { FontAwesomeModule, FaIconLibrary } from '@fortawesome/angular-fontawesome';
import { faLeaf, faQuestion, faExclamationTriangle, faRocket, faRetweet, faFileAlt, faMoneyBill,
faEye, faEyeSlash, faLock, faLockOpen } from '@fortawesome/free-solid-svg-icons';
import { BisqBlocksComponent } from './bisq-blocks/bisq-blocks.component';
import { BisqExplorerComponent } from './bisq-explorer/bisq-explorer.component';
import { BisqApiService } from './bisq-api.service';
import { BisqAddressComponent } from './bisq-address/bisq-address.component';
import { BisqStatsComponent } from './bisq-stats/bisq-stats.component';
import { BsqAmountComponent } from './bsq-amount/bsq-amount.component';
@NgModule({
declarations: [
BisqTransactionsComponent,
BisqTransactionComponent,
BisqBlockComponent,
BisqTransactionComponent,
BisqIconComponent,
BisqTransactionDetailsComponent,
BisqTransfersComponent,
BisqBlocksComponent,
BisqExplorerComponent,
BisqAddressComponent,
BisqStatsComponent,
BsqAmountComponent,
],
imports: [
BisqRoutingModule,
SharedModule,
NgbPaginationModule,
FontAwesomeModule,
],
providers: [
BisqApiService,
]
})
export class BisqModule {
constructor(library: FaIconLibrary) {
library.addIcons(faQuestion);
library.addIcons(faExclamationTriangle);
library.addIcons(faRocket);
library.addIcons(faRetweet);
library.addIcons(faLeaf);
library.addIcons(faFileAlt);
library.addIcons(faMoneyBill);
library.addIcons(faEye);
library.addIcons(faEyeSlash);
library.addIcons(faLock);
library.addIcons(faLockOpen);
}
}

View File

@ -0,0 +1,59 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AboutComponent } from '../components/about/about.component';
import { AddressComponent } from '../components/address/address.component';
import { BisqTransactionsComponent } from './bisq-transactions/bisq-transactions.component';
import { BisqTransactionComponent } from './bisq-transaction/bisq-transaction.component';
import { BisqBlockComponent } from './bisq-block/bisq-block.component';
import { BisqBlocksComponent } from './bisq-blocks/bisq-blocks.component';
import { BisqExplorerComponent } from './bisq-explorer/bisq-explorer.component';
import { BisqAddressComponent } from './bisq-address/bisq-address.component';
import { BisqStatsComponent } from './bisq-stats/bisq-stats.component';
const routes: Routes = [
{
path: '',
component: BisqExplorerComponent,
children: [
{
path: '',
component: BisqTransactionsComponent
},
{
path: 'tx/:id',
component: BisqTransactionComponent
},
{
path: 'blocks',
children: [],
component: BisqBlocksComponent
},
{
path: 'block/:id',
component: BisqBlockComponent,
},
{
path: 'address/:id',
component: BisqAddressComponent,
},
{
path: 'stats',
component: BisqStatsComponent,
},
{
path: 'about',
component: AboutComponent,
},
{
path: '**',
redirectTo: ''
}
]
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class BisqRoutingModule { }

View File

@ -0,0 +1,6 @@
<ng-container *ngIf="(forceFiat || (viewFiat$ | async)) && (conversions$ | async) as conversions; else viewFiatVin">
<span [class.green-color]="green">{{ conversions.USD * bsq / 100 * (bsqPrice$ | async) / 100000000 | currency:'USD':'symbol':'1.2-2' }}</span>
</ng-container>
<ng-template #viewFiatVin>
{{ bsq / 100 | number : digitsInfo }} BSQ
</ng-template>

View File

@ -0,0 +1,3 @@
.green-color {
color: #3bcc49;
}

View File

@ -0,0 +1,30 @@
import { Component, OnInit, ChangeDetectionStrategy, Input } from '@angular/core';
import { StateService } from 'src/app/services/state.service';
import { Observable } from 'rxjs';
@Component({
selector: 'app-bsq-amount',
templateUrl: './bsq-amount.component.html',
styleUrls: ['./bsq-amount.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BsqAmountComponent implements OnInit {
conversions$: Observable<any>;
viewFiat$: Observable<boolean>;
bsqPrice$: Observable<number>;
@Input() bsq: number;
@Input() digitsInfo = '1.2-2';
@Input() forceFiat = false;
@Input() green = false;
constructor(
private stateService: StateService,
) { }
ngOnInit() {
this.viewFiat$ = this.stateService.viewFiat$.asObservable();
this.conversions$ = this.stateService.conversions$.asObservable();
this.bsqPrice$ = this.stateService.bsqPrice$;
}
}

View File

@ -4,15 +4,14 @@
<img src="./resources/mempool-tube.png" width="63" height="63" />
<br /><br />
<h1>Contributors</h1>
<h2>Contributors</h2>
<p>Development <a href="https://twitter.com/softsimon_">@softsimon_</a>
<br />Operations <a href="https://twitter.com/wiz">@wiz</a>
<br />Design <a href="https://instagram.com/markjborg">@markjborg</a>
<br><br>
<h2>Github</h2>
<h2>Open source</h2>
<a target="_blank" class="b2812e30 f2874b88 fw6 mb3 mt2 truncate black-80 f4 link" rel="noopener noreferrer nofollow" href="https://github.com/mempool/mempool">
<span class="_9e13d83d dib v-mid">
@ -29,54 +28,88 @@
<br><br>
<div class="text-center">
<h2>HTTP API</h2>
<h2>API</h2>
</div>
<table class="table">
<tr>
<td>Fee API</td>
<td>
<div class="mx-auto">
<input class="form-control" type="text" value="https://mempool.space/api/v1/fees/recommended" readonly>
</div>
</td>
</tr>
<tr>
<td>Mempool blocks</td>
<td>
<div class="mx-auto">
<input class="form-control" type="text" value="https://mempool.space/api/v1/fees/mempool-blocks" readonly>
</div>
</td>
</tr>
</table>
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-tabs">
<li [ngbNavItem]="1">
<a ngbNavLink>Mainnet</a>
<ng-template ngbNavContent>
<br><br>
<div class="text-center">
<h2>WebSocket API</h2>
</div>
<table class="table">
<tr>
<th style="border-top: 0;">Endpoint</th>
<th style="border-top: 0;">Description</th>
</tr>
<tr>
<td class="nowrap"><a href="/api/v1/fees/recommended" target="_blank">GET /api/v1/fees/recommended</a></td>
<td>Recommended fees</td>
</tr>
<tr>
<td class="nowrap"><a href="/api/v1/fees/mempool-blocks" target="_blank">GET /api/v1/fees/mempool-blocks</a></td>
<td>The current mempool blocks</td>
</tr>
<tr>
<td class="nowrap">wss://{{ hostname }}/api/v1/ws</td>
<td>
<span class="text-small">
Default push: <span class="code">{{ '{' }} action: 'want', data: ['blocks', ...] {{ '}' }}</span>
to express what you want pushed. Available: 'blocks', 'mempool-blocks', 'live-2h-chart' and 'stats'.
</span>
<br><br>
<span class="text-small">
Push transactions related to address: <span class="code">{{ '{' }} 'track-address': '3PbJ...bF9B' {{ '}' }}</span>
to receive all new transactions containing that address as input or output. Returns an array of transactions. 'address-transactions' for new mempool transactions and 'block-transactions' for new block confirmed transactions.
</span>
</td>
</tr>
</table>
<table class="table">
<tr>
<td>
<span class="text-small">
Default push: <span class="code">{{ '{' }} action: 'want', data: ['blocks', ...] {{ '}' }}</span>
to express what you want pushed. Available: 'blocks', 'mempool-blocks', 'live-2h-chart' and 'stats'.
</span>
<br><br>
<span class="text-small">
Push transactions related to address: <span class="code">{{ '{' }} 'track-address': '3PbJ...bF9B' {{ '}' }}</span>
to receive all new transactions containing that address as input or output. Returns an array of transactions. 'address-transactions' for new mempool transactions and 'block-transactions' for new block confirmed transactions.
</span>
</td>
</tr>
<tr>
<td style="border: 0;">
<input class="form-control" type="text" value="wss://mempool.space/api/v1/ws" readonly>
</td>
</tr>
</table>
</ng-template>
</li>
<li [ngbNavItem]="2">
<a ngbNavLink>Bisq</a>
<ng-template ngbNavContent>
<table class="table">
<tr>
<th style="border-top: 0;">Endpoint</th>
<th style="border-top: 0;">Description</th>
</tr>
<tr>
<td class="nowrap"><a href="/bisq/api/stats" target="_blank">GET /bisq/api/stats</a></td>
<td>Stats</td>
</tr>
<tr>
<td class="nowrap"><a href="/bisq/api/tx/4b5417ec5ab6112bedf539c3b4f5a806ed539542d8b717e1c4470aa3180edce5" target="_blank">GET /bisq/api/tx/:txId</a></td>
<td>Transaction</td>
</tr>
<tr>
<td class="nowrap"><a href="/bisq/api/txs/0/25" target="_blank">GET /bisq/api/txs/:index/:length</a></td>
<td>Transactions</td>
</tr>
<tr>
<td class="nowrap"><a href="/bisq/api/block/000000000000000000079aa6bfa46eb8fc20474e8673d6e8a123b211236bf82d" target="_blank">GET /bisq/api/block/:hash</a></td>
<td>Block</td>
</tr>
<tr>
<td class="nowrap"><a href="/bisq/api/blocks/0/25" target="_blank">GET /bisq/api/blocks/:index/:length</a></td>
<td>Blocks</td>
</tr>
<tr>
<td class="nowrap"><a href="/bisq/api/blocks/tip/height" target="_blank">GET /bisq/api/blocks/tip/height</a></td>
<td>Latest block height</td>
</tr>
<tr>
<td class="nowrap"><a href="/bisq/api/address/B1DgwRN92rdQ9xpEVCdXRfgeqGw9X4YtrZz" target="_blank">GET /bisq/api/address/:address</a></td>
<td>Address</td>
</tr>
</table>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="mt-2"></div>
<br> <br>

View File

@ -9,4 +9,8 @@
tr {
white-space: inherit;
}
}
.nowrap {
white-space: nowrap;
}

View File

@ -1,6 +1,7 @@
import { Component, OnInit } from '@angular/core';
import { WebsocketService } from '../../services/websocket.service';
import { SeoService } from 'src/app/services/seo.service';
import { StateService } from 'src/app/services/state.service';
@Component({
selector: 'app-about',
@ -8,15 +9,24 @@ import { SeoService } from 'src/app/services/seo.service';
styleUrls: ['./about.component.scss']
})
export class AboutComponent implements OnInit {
active = 1;
hostname = document.location.hostname;
constructor(
private websocketService: WebsocketService,
private seoService: SeoService,
private stateService: StateService,
) { }
ngOnInit() {
this.seoService.setTitle('Contributors');
this.websocketService.want(['blocks']);
if (this.stateService.network === 'bisq') {
this.active = 2;
}
if (document.location.port !== '') {
this.hostname = this.hostname + ':' + document.location.port;
}
}
}

View File

@ -157,7 +157,7 @@
<ng-template [ngIf]="error">
<div class="text-center">
Error loading block data.
<br>
<br><br>
<i>{{ error.error }}</i>
</div>
</ng-template>

View File

@ -4,7 +4,7 @@ import { ActivatedRoute, ParamMap, Router } from '@angular/router';
import { ElectrsApiService } from '../../services/electrs-api.service';
import { switchMap, tap, debounceTime, catchError } from 'rxjs/operators';
import { Block, Transaction, Vout } from '../../interfaces/electrs.interface';
import { of } from 'rxjs';
import { of, Subscription } from 'rxjs';
import { StateService } from '../../services/state.service';
import { SeoService } from 'src/app/services/seo.service';
import { env } from 'src/app/app.constants';
@ -25,6 +25,7 @@ export class BlockComponent implements OnInit, OnDestroy {
isLoadingTransactions = true;
error: any;
blockSubsidy: number;
subscription: Subscription;
fees: number;
paginationMaxSize: number;
page = 1;
@ -43,7 +44,7 @@ export class BlockComponent implements OnInit, OnDestroy {
this.paginationMaxSize = window.matchMedia('(max-width: 700px)').matches ? 3 : 5;
this.network = this.stateService.network;
this.route.paramMap
this.subscription = this.route.paramMap
.pipe(
switchMap((params: ParamMap) => {
const blockHash: string = params.get('id') || '';
@ -129,6 +130,7 @@ export class BlockComponent implements OnInit, OnDestroy {
ngOnDestroy() {
this.stateService.markBlock$.next({});
this.subscription.unsubscribe();
}
setBlockSubsidy() {

View File

@ -26,6 +26,7 @@ export class BlockchainBlocksComponent implements OnInit, OnDestroy {
gradientColors = {
'': ['#9339f4', '#105fb0'],
bisq: ['#9339f4', '#105fb0'],
liquid: ['#116761', '#183550'],
testnet: ['#1d486f', '#183550'],
};

View File

@ -17,6 +17,5 @@ export class BlockchainComponent implements OnInit {
ngOnInit() {
this.stateService.blocks$.subscribe(() => this.isLoading = false);
this.stateService.networkChanged$.subscribe(() => this.isLoading = true);
}
}

View File

@ -1,36 +1,52 @@
<header>
<nav class="navbar navbar-expand-md navbar-dark bg-dark">
<a class="navbar-brand" routerLink="/" style="position: relative;">
<a class="navbar-brand" [routerLink]="['/' | relativeUrl]" style="position: relative;">
<img src="./resources/mempool-logo.png" height="35" width="140" class="logo" [ngStyle]="{'opacity': connectionState === 2 ? 1 : 0.5 }">
<div class="badge badge-warning connection-badge" *ngIf="connectionState === 0">Offline</div>
<div class="badge badge-warning connection-badge" style="left: 30px;" *ngIf="connectionState === 1">Reconnecting...</div>
</a>
<div class="btn-group" style="margin-right: 16px;" *ngIf="env.TESTNET_ENABLED || env.LIQUID_ENABLED">
<button type="button" (click)="networkDropdownHidden = !networkDropdownHidden" class="btn btn-secondary dropdown-toggle dropdown-toggle-split" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="sr-only">Toggle Dropdown</span>
<div ngbDropdown display="dynamic" style="margin-right: 16px;" *ngIf="env.TESTNET_ENABLED || env.LIQUID_ENABLED || env.BISQ_ENABLED">
<button ngbDropdownToggle type="button" class="btn btn-secondary dropdown-toggle-split" aria-haspopup="true">
<img src="./resources/{{ network === '' ? 'bitcoin' : network }}-logo.png" style="width: 25px; height: 25px;" class="mr-1">
</button>
<div class="dropdown-menu" [class.d-block]="!networkDropdownHidden">
<a class="dropdown-item mainnet" [class.active]="network === ''" routerLink="/"><img src="./resources/bitcoin-logo.png" style="width: 35.5px;"> Mainnet</a>
<a *ngIf="env.LIQUID_ENABLED" class="dropdown-item liquid" [class.active]="network === 'liquid'" routerLink="/liquid"><img src="./resources/liquid-logo.png" style="width: 35.5px;"> Liquid</a>
<a *ngIf="env.TESTNET_ENABLED" class="dropdown-item testnet" [class.active]="network === 'testnet'" routerLink="/testnet"><img src="./resources/testnet-logo.png" style="width: 35.5px;"> Testnet</a>
<div ngbDropdownMenu>
<button ngbDropdownItem class="mainnet" routerLink="/"><img src="./resources/bitcoin-logo.png" style="width: 30px;" class="mr-1"> Mainnet</button>
<button ngbDropdownItem *ngIf="env.TESTNET_ENABLED" class="testnet" [class.active]="network === 'testnet'" routerLink="/testnet"><img src="./resources/testnet-logo.png" style="width: 30px;" class="mr-1"> Testnet</button>
<h6 *ngIf="env.LIQUID_ENABLED || env.BISQ_ENABLED" class="dropdown-header">Layer 2 Networks</h6>
<button ngbDropdownItem *ngIf="env.BISQ_ENABLED" class="mainnet" [class.active]="network === 'bisq'" routerLink="/bisq"><img src="./resources/bisq-logo.png" style="width: 30px;" class="mr-1"> Bisq</button>
<button ngbDropdownItem *ngIf="env.LIQUID_ENABLED" class="liquid" [class.active]="network === 'liquid'" routerLink="/liquid"><img src="./resources/liquid-logo.png" style="width: 30px;" class="mr-1"> Liquid</button>
</div>
</div>
<button class="navbar-toggler" type="button" (click)="collapse()" aria-controls="navbarsExampleDefault" aria-expanded="false" aria-label="Toggle navigation">
<button class="navbar-toggler" type="button" (click)="collapse()">
<span class="navbar-toggler-icon"></span>
</button>
<div class="navbar-collapse collapse" id="navbarCollapse" [ngClass]="{'show': navCollapsed}">
<ul class="navbar-nav mr-auto {{ network }}">
<li class="nav-item" routerLinkActive="active">
<a class="nav-link" [routerLink]="['/graphs' | relativeUrl]" (click)="collapse()">Graphs</a>
</li>
<li class="nav-item" routerLinkActive="active">
<a class="nav-link" [routerLink]="['/tv' | relativeUrl]" (click)="collapse()">TV view &nbsp;<img src="./resources/expand.png" width="15"/></a>
</li>
<ng-template [ngIf]="network === 'bisq'" [ngIfElse]="notBisq">
<li class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}">
<a class="nav-link" [routerLink]="['/bisq']" (click)="collapse()">Transactions</a>
</li>
<li class="nav-item" routerLinkActive="active">
<a class="nav-link" [routerLink]="['/bisq/blocks']" (click)="collapse()">Blocks</a>
</li>
<li class="nav-item" routerLinkActive="active">
<a class="nav-link" [routerLink]="['/bisq/stats']" (click)="collapse()">Stats</a>
</li>
</ng-template>
<ng-template #notBisq>
<li class="nav-item" routerLinkActive="active">
<a class="nav-link" [routerLink]="['/graphs' | relativeUrl]" (click)="collapse()">Graphs</a>
</li>
<li class="nav-item" routerLinkActive="active">
<a class="nav-link" [routerLink]="['/tv' | relativeUrl]" (click)="collapse()">TV view &nbsp;<img src="./resources/expand.png" width="15"/></a>
</li>
</ng-template>
<li *ngIf="network === 'liquid'" class="nav-item" routerLinkActive="active">
<a class="nav-link" [routerLink]="['/assets' | relativeUrl]" (click)="collapse()">Assets</a>
<a class="nav-link" [routerLink]="['liquid/assets']" (click)="collapse()">Assets</a>
</li>
<li class="nav-item" routerLinkActive="active">
<a class="nav-link" [routerLink]="['/about' | relativeUrl]" (click)="collapse()">About</a>
@ -45,6 +61,10 @@
<router-outlet></router-outlet>
<br><br><br>
<br>
<app-footer></app-footer>
<ng-template [ngIf]="network !== 'bisq'">
<br><br>
<app-footer></app-footer>
</ng-template>

View File

@ -47,3 +47,16 @@ nav {
.testnet.active {
background-color: #1d486f;
}
.dropdown-divider {
border-top: 1px solid #121420;
}
.dropdown-toggle::after {
vertical-align: 0.1em;
}
.dropdown-item {
display: flex;
align-items:center;
}

View File

@ -15,19 +15,10 @@ export class MasterPageComponent implements OnInit {
navCollapsed = false;
connectionState = 2;
networkDropdownHidden = true;
constructor(
private stateService: StateService,
) { }
@HostListener('document:click', ['$event'])
documentClick(event: any): void {
if (!event.target.classList.contains('dropdown-toggle')) {
this.networkDropdownHidden = true;
}
}
ngOnInit() {
this.stateService.connectionState$
.subscribe((state) => {
@ -37,14 +28,6 @@ export class MasterPageComponent implements OnInit {
this.stateService.networkChanged$
.subscribe((network) => {
this.network = network;
if (network === 'testnet') {
this.tvViewRoute = '/testnet-tv';
} else if (network === 'liquid') {
this.tvViewRoute = '/liquid-tv';
} else {
this.tvViewRoute = '/tv';
}
});
}

View File

@ -1,6 +1,6 @@
import { Component, OnInit, Input, Inject, LOCALE_ID, ChangeDetectionStrategy, OnChanges } from '@angular/core';
import { formatDate } from '@angular/common';
import { VbytesPipe } from 'src/app/pipes/bytes-pipe/vbytes.pipe';
import { VbytesPipe } from 'src/app/shared/pipes/bytes-pipe/vbytes.pipe';
import * as Chartist from 'chartist';
import { OptimizedMempoolStats } from 'src/app/interfaces/node-api.interface';
import { StateService } from 'src/app/services/state.service';

View File

@ -35,7 +35,7 @@ export class QrcodeComponent implements AfterViewInit {
address.toUpperCase();
}
QRCode.toCanvas(this.canvas.nativeElement, 'bitcoin:' + address, opts, (error: any) => {
QRCode.toCanvas(this.canvas.nativeElement, address, opts, (error: any) => {
if (error) {
console.error(error);
}

View File

@ -51,7 +51,12 @@
<td>After <app-timespan [time]="tx.status.block_time - transactionTime"></app-timespan></td>
</tr>
</ng-template>
<ng-container *ngTemplateOutlet="features"></ng-container>
<tr *ngIf="network !== 'liquid'">
<td class="td-width">Features</td>
<td>
<app-tx-features [tx]="tx"></app-tx-features>
</td>
</tr>
</tbody>
</table>
</div>
@ -68,9 +73,7 @@
<td>
{{ tx.fee / (tx.weight / 4) | number : '1.1-1' }} sat/vB
&nbsp;
<span *ngIf="feeRating === 1" class="badge badge-success">Optimal</span>
<span *ngIf="feeRating === 2" class="badge badge-warning" title="Only ~{{ medianFeeNeeded }} sat/vB was needed to get into this block">Overpaid {{ overpaidTimes }}x</span>
<span *ngIf="feeRating === 3" class="badge badge-danger" title="Only ~{{ medianFeeNeeded }} sat/vB was needed to get into this block">Overpaid {{ overpaidTimes }}x</span>
<app-tx-fee-rating [tx]="tx"></app-tx-fee-rating>
</td>
</tr>
</ng-template>
@ -124,7 +127,12 @@
</ng-template>
</td>
</tr>
<ng-container *ngTemplateOutlet="features"></ng-container>
<tr *ngIf="network !== 'liquid'">
<td class="td-width">Features</td>
<td>
<app-tx-features [tx]="tx"></app-tx-features>
</td>
</tr>
</tbody>
</table>
</div>
@ -244,7 +252,7 @@
<ng-template [ngIf]="error">
<div class="text-center" *ngIf="waitingForTransaction">
<div class="text-center" *ngIf="waitingForTransaction; else errorTemplate">
<h3>Transaction not found.</h3>
<h5>Waiting for it to appear in the mempool...</h5>
<div class="spinner-border text-light mt-2"></div>
@ -260,15 +268,3 @@
</div>
<br>
<ng-template #features>
<tr *ngIf="network !== 'liquid'">
<td class="td-width">Features</td>
<td>
<span *ngIf="segwitGains.realizedGains && !segwitGains.potentialBech32Gains" class="badge badge-success mr-1" ngbTooltip="This transaction saved {{ segwitGains.realizedGains * 100 | number: '1.0-0' }}% on fees by using native SegWit-Bech32" placement="bottom">SegWit</span>
<span *ngIf="segwitGains.realizedGains && segwitGains.potentialBech32Gains" class="badge badge-warning mr-1" ngbTooltip="This transaction saved {{ segwitGains.realizedGains * 100 | number: '1.0-0' }}% on fees by using SegWit and could save {{ segwitGains.potentialBech32Gains * 100 | number : '1.0-0' }}% more by fully upgrading to native SegWit-Bech32" placement="bottom">SegWit</span>
<span *ngIf="segwitGains.potentialP2shGains" class="badge badge-danger mr-1" ngbTooltip="This transaction could save {{ segwitGains.potentialBech32Gains * 100 | number : '1.0-0' }}% on fees by upgrading to native SegWit-Bech32 or {{ segwitGains.potentialP2shGains * 100 | number: '1.0-0' }}% by upgrading to SegWit-P2SH" placement="bottom"><del>SegWit</del></span>
<span *ngIf="isRbfTransaction" class="badge badge-success" ngbTooltip="This transaction support Replace-By-Fee (RBF) allowing fee bumping" placement="bottom">RBF</span>
</td>
</tr>
</ng-template>

View File

@ -9,7 +9,7 @@ import { WebsocketService } from '../../services/websocket.service';
import { AudioService } from 'src/app/services/audio.service';
import { ApiService } from 'src/app/services/api.service';
import { SeoService } from 'src/app/services/seo.service';
import { calcSegwitFeeGains } from 'src/app/bitcoin.utils';
import { BisqTransaction } from 'src/app/bisq/bisq.interfaces';
@Component({
selector: 'app-transaction',
@ -20,9 +20,6 @@ export class TransactionComponent implements OnInit, OnDestroy {
network = '';
tx: Transaction;
txId: string;
feeRating: number;
overpaidTimes: number;
medianFeeNeeded: number;
txInBlockIndex: number;
isLoadingTx = true;
error: any = undefined;
@ -30,12 +27,6 @@ export class TransactionComponent implements OnInit, OnDestroy {
latestBlock: Block;
transactionTime = -1;
subscription: Subscription;
segwitGains = {
realizedGains: 0,
potentialBech32Gains: 0,
potentialP2shGains: 0,
};
isRbfTransaction: boolean;
rbfTransaction: undefined | Transaction;
constructor(
@ -87,8 +78,6 @@ export class TransactionComponent implements OnInit, OnDestroy {
this.error = undefined;
this.waitingForTransaction = false;
this.setMempoolBlocksSubscription();
this.segwitGains = calcSegwitFeeGains(tx);
this.isRbfTransaction = tx.vin.some((v) => v.sequence < 0xfffffffe);
if (!tx.status.confirmed) {
this.websocketService.startTrackTransaction(tx.txid);
@ -98,8 +87,6 @@ export class TransactionComponent implements OnInit, OnDestroy {
} else {
this.getTransactionTime();
}
} else {
this.findBlockAndSetFeeRating();
}
if (this.tx.status.confirmed) {
this.stateService.markBlock$.next({ blockHeight: tx.status.block_height });
@ -125,7 +112,6 @@ export class TransactionComponent implements OnInit, OnDestroy {
};
this.stateService.markBlock$.next({ blockHeight: block.height });
this.audioService.playSound('magic');
this.findBlockAndSetFeeRating();
}
});
@ -169,38 +155,9 @@ export class TransactionComponent implements OnInit, OnDestroy {
});
}
findBlockAndSetFeeRating() {
this.stateService.blocks$
.pipe(
filter(([block]) => block.height === this.tx.status.block_height),
take(1)
)
.subscribe(([block]) => {
const feePervByte = this.tx.fee / (this.tx.weight / 4);
this.medianFeeNeeded = Math.round(block.feeRange[Math.round(block.feeRange.length * 0.5)]);
// Block not filled
if (block.weight < 4000000 * 0.95) {
this.medianFeeNeeded = 1;
}
this.overpaidTimes = Math.round(feePervByte / this.medianFeeNeeded);
if (feePervByte <= this.medianFeeNeeded || this.overpaidTimes < 2) {
this.feeRating = 1;
} else {
this.feeRating = 2;
if (this.overpaidTimes > 10) {
this.feeRating = 3;
}
}
});
}
resetTransaction() {
this.error = undefined;
this.tx = null;
this.feeRating = undefined;
this.waitingForTransaction = false;
this.isLoadingTx = true;
this.rbfTransaction = undefined;

View File

@ -17,7 +17,7 @@
<div class="col">
<table class="table table-borderless smaller-text table-xs" style="margin: 0;">
<tbody>
<tr *ngFor="let vin of getFilteredTxVin(tx)">
<tr *ngFor="let vin of getFilteredTxVin(tx); trackBy: trackByIndexFn">
<td class="arrow-td">
<ng-template [ngIf]="vin.prevout === null && !vin.is_pegin" [ngIfElse]="hasPrevout">
<i class="arrow grey"></i>
@ -73,7 +73,7 @@
<div class="col mobile-bottomcol">
<table class="table table-borderless smaller-text table-xs" style="margin: 0;">
<tbody>
<tr *ngFor="let vout of getFilteredTxVout(tx); let vindex = index;">
<tr *ngFor="let vout of getFilteredTxVout(tx); let vindex = index; trackBy: trackByIndexFn">
<td>
<a *ngIf="vout.scriptpubkey_address; else scriptpubkey_type" [routerLink]="['/address/' | relativeUrl, vout.scriptpubkey_address]" title="{{ vout.scriptpubkey_address }}">
<span class="d-block d-lg-none">{{ vout.scriptpubkey_address | shortenString : 16 }}</span>

View File

@ -109,4 +109,8 @@ export class TransactionsListComponent implements OnInit, OnChanges {
getFilteredTxVout(tx: Transaction) {
return tx.vout.slice(0, tx['@voutLength']);
}
trackByIndexFn(index: number) {
return index;
}
}

View File

@ -0,0 +1,4 @@
<span *ngIf="segwitGains.realizedGains && !segwitGains.potentialBech32Gains" class="badge badge-success mr-1" ngbTooltip="This transaction saved {{ segwitGains.realizedGains * 100 | number: '1.0-0' }}% on fees by using native SegWit-Bech32" placement="bottom">SegWit</span>
<span *ngIf="segwitGains.realizedGains && segwitGains.potentialBech32Gains" class="badge badge-warning mr-1" ngbTooltip="This transaction saved {{ segwitGains.realizedGains * 100 | number: '1.0-0' }}% on fees by using SegWit and could save {{ segwitGains.potentialBech32Gains * 100 | number : '1.0-0' }}% more by fully upgrading to native SegWit-Bech32" placement="bottom">SegWit</span>
<span *ngIf="segwitGains.potentialP2shGains" class="badge badge-danger mr-1" ngbTooltip="This transaction could save {{ segwitGains.potentialBech32Gains * 100 | number : '1.0-0' }}% on fees by upgrading to native SegWit-Bech32 or {{ segwitGains.potentialP2shGains * 100 | number: '1.0-0' }}% by upgrading to SegWit-P2SH" placement="bottom"><del>SegWit</del></span>
<span *ngIf="isRbfTransaction" class="badge badge-success" ngbTooltip="This transaction support Replace-By-Fee (RBF) allowing fee bumping" placement="bottom">RBF</span>

View File

@ -0,0 +1,30 @@
import { Component, ChangeDetectionStrategy, OnChanges, Input } from '@angular/core';
import { calcSegwitFeeGains } from 'src/app/bitcoin.utils';
import { Transaction } from 'src/app/interfaces/electrs.interface';
@Component({
selector: 'app-tx-features',
templateUrl: './tx-features.component.html',
styleUrls: ['./tx-features.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TxFeaturesComponent implements OnChanges {
@Input() tx: Transaction;
segwitGains = {
realizedGains: 0,
potentialBech32Gains: 0,
potentialP2shGains: 0,
};
isRbfTransaction: boolean;
constructor() { }
ngOnChanges() {
if (!this.tx) {
return;
}
this.segwitGains = calcSegwitFeeGains(this.tx);
this.isRbfTransaction = this.tx.vin.some((v) => v.sequence < 0xfffffffe);
}
}

View File

@ -0,0 +1,3 @@
<span *ngIf="feeRating === 1" class="badge badge-success">Optimal</span>
<span *ngIf="feeRating === 2" class="badge badge-warning" title="Only ~{{ medianFeeNeeded }} sat/vB was needed to get into this block">Overpaid {{ overpaidTimes }}x</span>
<span *ngIf="feeRating === 3" class="badge badge-danger" title="Only ~{{ medianFeeNeeded }} sat/vB was needed to get into this block">Overpaid {{ overpaidTimes }}x</span>

View File

@ -0,0 +1,64 @@
import { Component, ChangeDetectionStrategy, OnChanges, Input, OnInit, ChangeDetectorRef } from '@angular/core';
import { Transaction, Block } from 'src/app/interfaces/electrs.interface';
import { StateService } from 'src/app/services/state.service';
@Component({
selector: 'app-tx-fee-rating',
templateUrl: './tx-fee-rating.component.html',
styleUrls: ['./tx-fee-rating.component.scss'],
})
export class TxFeeRatingComponent implements OnInit, OnChanges {
@Input() tx: Transaction;
medianFeeNeeded: number;
overpaidTimes: number;
feeRating: number;
blocks: Block[] = [];
constructor(
private stateService: StateService,
) { }
ngOnInit() {
this.stateService.blocks$.subscribe(([block]) => {
this.blocks.push(block);
if (this.tx.status.confirmed && this.tx.status.block_height === block.height) {
this.calculateRatings(block);
}
});
}
ngOnChanges() {
this.feeRating = undefined;
if (!this.tx.status.confirmed) {
return;
}
const foundBlock = this.blocks.find((b) => b.height === this.tx.status.block_height);
if (foundBlock) {
this.calculateRatings(foundBlock);
}
}
calculateRatings(block: Block) {
const feePervByte = this.tx.fee / (this.tx.weight / 4);
this.medianFeeNeeded = Math.round(block.feeRange[Math.round(block.feeRange.length * 0.5)]);
// Block not filled
if (block.weight < 4000000 * 0.95) {
this.medianFeeNeeded = 1;
}
this.overpaidTimes = Math.round(feePervByte / this.medianFeeNeeded);
if (feePervByte <= this.medianFeeNeeded || this.overpaidTimes < 2) {
this.feeRating = 1;
} else {
this.feeRating = 2;
if (this.overpaidTimes > 10) {
this.feeRating = 3;
}
}
}
}

View File

@ -1,5 +1,5 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
import { OptimizedMempoolStats } from '../interfaces/node-api.interface';
import { Observable } from 'rxjs';
import { StateService } from './state.service';
@ -18,6 +18,9 @@ export class ApiService {
) {
this.apiBaseUrl = API_BASE_URL.replace('{network}', '');
this.stateService.networkChanged$.subscribe((network) => {
if (network === 'bisq') {
network = '';
}
this.apiBaseUrl = API_BASE_URL.replace('{network}', network ? '/' + network : '');
});
}

View File

@ -18,6 +18,9 @@ export class ElectrsApiService {
) {
this.apiBaseUrl = API_BASE_URL.replace('{network}', '');
this.stateService.networkChanged$.subscribe((network) => {
if (network === 'bisq') {
network = '';
}
this.apiBaseUrl = API_BASE_URL.replace('{network}', network ? '/' + network : '');
});
}

View File

@ -18,14 +18,9 @@ export class SeoService {
setTitle(newTitle: string, prependNetwork = false) {
let networkName = '';
if (prependNetwork) {
if (this.network === 'liquid') {
networkName = 'Liquid ';
} else if (this.network === 'testnet') {
networkName = 'Testnet ';
}
if (prependNetwork && this.network !== '') {
networkName = this.network.substr(0, 1).toUpperCase() + this.network.substr(1) + ' ';
}
this.titleService.setTitle(networkName + newTitle + ' - ' + this.defaultTitle);
}

View File

@ -23,6 +23,7 @@ export class StateService {
networkChanged$ = new ReplaySubject<string>(1);
blocks$ = new ReplaySubject<[Block, boolean, boolean]>(env.KEEP_BLOCKS_AMOUNT);
conversions$ = new ReplaySubject<any>(1);
bsqPrice$ = new ReplaySubject<number>(1);
mempoolStats$ = new ReplaySubject<MemPoolState>(1);
mempoolBlocks$ = new ReplaySubject<MempoolBlock[]>(1);
txReplaced$ = new Subject<Transaction>();
@ -64,6 +65,12 @@ export class StateService {
this.networkChanged$.next('testnet');
}
return;
case 'bisq':
if (this.network !== 'bisq') {
this.network = 'bisq';
this.networkChanged$.next('bisq');
}
return;
default:
if (this.network !== '') {
this.network = '';

View File

@ -29,11 +29,14 @@ export class WebsocketService {
constructor(
private stateService: StateService,
) {
this.network = this.stateService.network;
this.websocketSubject = webSocket<WebsocketResponse | any>(WEB_SOCKET_URL.replace('{network}', this.network ? '/' + this.network : ''));
this.network = this.stateService.network === 'bisq' ? '' : this.stateService.network;
this.websocketSubject = webSocket<WebsocketResponse>(WEB_SOCKET_URL.replace('{network}', this.network ? '/' + this.network : ''));
this.startSubscription();
this.stateService.networkChanged$.subscribe((network) => {
if (network === 'bisq') {
network = '';
}
if (network === this.network) {
return;
}
@ -45,7 +48,7 @@ export class WebsocketService {
this.websocketSubject.complete();
this.subscription.unsubscribe();
this.websocketSubject = webSocket<WebsocketResponse | any>(WEB_SOCKET_URL.replace('{network}', this.network ? '/' + this.network : ''));
this.websocketSubject = webSocket<WebsocketResponse>(WEB_SOCKET_URL.replace('{network}', this.network ? '/' + this.network : ''));
this.startSubscription();
});
@ -96,6 +99,10 @@ export class WebsocketService {
this.stateService.mempoolBlocks$.next(response['mempool-blocks']);
}
if (response['bsq-price']) {
this.stateService.bsqPrice$.next(response['bsq-price']);
}
if (response['git-commit']) {
if (!this.latestGitCommit) {
this.latestGitCommit = response['git-commit'];

View File

@ -0,0 +1,64 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { VbytesPipe } from './pipes/bytes-pipe/vbytes.pipe';
import { ShortenStringPipe } from './pipes/shorten-string-pipe/shorten-string.pipe';
import { CeilPipe } from './pipes/math-ceil/math-ceil.pipe';
import { Hex2asciiPipe } from './pipes/hex2ascii/hex2ascii.pipe';
import { RelativeUrlPipe } from './pipes/relative-url/relative-url.pipe';
import { ScriptpubkeyTypePipe } from './pipes/scriptpubkey-type-pipe/scriptpubkey-type.pipe';
import { BytesPipe } from './pipes/bytes-pipe/bytes.pipe';
import { WuBytesPipe } from './pipes/bytes-pipe/wubytes.pipe';
import { TimeSinceComponent } from '../components/time-since/time-since.component';
import { ClipboardComponent } from '../components/clipboard/clipboard.component';
import { QrcodeComponent } from '../components/qrcode/qrcode.component';
import { FiatComponent } from '../fiat/fiat.component';
import { NgbNavModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { TxFeaturesComponent } from '../components/tx-features/tx-features.component';
import { TxFeeRatingComponent } from '../components/tx-fee-rating/tx-fee-rating.component';
@NgModule({
declarations: [
ClipboardComponent,
TimeSinceComponent,
QrcodeComponent,
FiatComponent,
TxFeaturesComponent,
TxFeeRatingComponent,
ScriptpubkeyTypePipe,
RelativeUrlPipe,
Hex2asciiPipe,
BytesPipe,
VbytesPipe,
WuBytesPipe,
CeilPipe,
ShortenStringPipe,
],
imports: [
CommonModule,
NgbNavModule,
NgbTooltipModule,
],
providers: [
VbytesPipe,
],
exports: [
NgbNavModule,
CommonModule,
NgbTooltipModule,
TimeSinceComponent,
ClipboardComponent,
QrcodeComponent,
FiatComponent,
TxFeaturesComponent,
TxFeeRatingComponent,
ScriptpubkeyTypePipe,
RelativeUrlPipe,
Hex2asciiPipe,
BytesPipe,
VbytesPipe,
WuBytesPipe,
CeilPipe,
ShortenStringPipe,
]
})
export class SharedModule {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

View File

@ -171,7 +171,7 @@ body {
}
.table-striped tbody tr:nth-of-type(odd) {
background-color: #181b2d !important;
background-color: #181b2d;
}
.bordertop {
@ -375,7 +375,7 @@ h1, h2, h3 {
}
@media (min-width: 768px) {
.md-inline {
.d-md-inline {
display: inline-block;
}
}
@ -416,3 +416,7 @@ h1, h2, h3 {
background-color: #653b9c;
border-color: #3a1c61;
}
th {
white-space: nowrap;
}

View File

@ -1189,6 +1189,30 @@
lodash "^4.17.13"
to-fast-properties "^2.0.0"
"@fortawesome/angular-fontawesome@^0.6.1":
version "0.6.1"
resolved "https://registry.yarnpkg.com/@fortawesome/angular-fontawesome/-/angular-fontawesome-0.6.1.tgz#1ebe5db16bfdd4be44bdde61f78c760eb4e219fa"
integrity sha512-ARQjtRuT+ZskzJDJKPwuiGO3+7nS0iyNLU/uHVJHfG4LwGJxwVIGldwg1SU957sra0Z0OtWEajHMhiS4vB9LwQ==
"@fortawesome/fontawesome-common-types@^0.2.29":
version "0.2.29"
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.29.tgz#e1a456b643237462d390304cab6975ff3fd68397"
integrity sha512-cY+QfDTbZ7XVxzx7jxbC98Oxr/zc7R2QpTLqTxqlfyXDrAJjzi/xUIqAUsygELB62JIrbsWxtSRhayKFkGI7MA==
"@fortawesome/fontawesome-svg-core@^1.2.28":
version "1.2.29"
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.29.tgz#34ef32824664534f9e4ef37982ebf286b899a189"
integrity sha512-xmPmP2t8qrdo8RyKihTkGb09RnZoc+7HFBCnr0/6ZhStdGDSLeEd7ajV181+2W29NWIFfylO13rU+s3fpy3cnA==
dependencies:
"@fortawesome/fontawesome-common-types" "^0.2.29"
"@fortawesome/free-solid-svg-icons@^5.13.0":
version "5.13.1"
resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.13.1.tgz#010a846b718a0f110b3cd137d072639b4e8bd41a"
integrity sha512-LQH/0L1p4+rqtoSHa9qFYR84hpuRZKqaQ41cfBQx8b68p21zoWSekTAeA54I/2x9VlCHDLFlG74Nmdg4iTPQOg==
dependencies:
"@fortawesome/fontawesome-common-types" "^0.2.29"
"@istanbuljs/schema@^0.1.2":
version "0.1.2"
resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.2.tgz#26520bf09abe4a5644cd5414e37125a8954241dd"

View File

@ -223,5 +223,9 @@ http {
location /testnet/api/ {
proxy_pass http://[::1]:3002/;
}
location /bisq/api {
proxy_pass http://127.0.0.1:8999/api/v1/bisq;
}
}
}