Merge branch 'master' into nymkappa/bugfix/node-count

This commit is contained in:
wiz
2022-11-21 18:19:53 +09:00
committed by GitHub
176 changed files with 81575 additions and 17353 deletions

2
.gitignore vendored
View File

@@ -3,3 +3,5 @@ data
docker-compose.yml docker-compose.yml
backend/mempool-config.json backend/mempool-config.json
*.swp *.swp
frontend/src/resources/config.template.js
frontend/src/resources/config.js

View File

@@ -2,6 +2,7 @@
"MEMPOOL": { "MEMPOOL": {
"NETWORK": "mainnet", "NETWORK": "mainnet",
"BACKEND": "electrum", "BACKEND": "electrum",
"ENABLED": true,
"HTTP_PORT": 8999, "HTTP_PORT": 8999,
"SPAWN_CLUSTER_PROCS": 0, "SPAWN_CLUSTER_PROCS": 0,
"API_URL_PREFIX": "/api/v1/", "API_URL_PREFIX": "/api/v1/",

View File

@@ -1,7 +1,9 @@
{ {
"MEMPOOL": { "MEMPOOL": {
"ENABLED": true,
"NETWORK": "__MEMPOOL_NETWORK__", "NETWORK": "__MEMPOOL_NETWORK__",
"BACKEND": "__MEMPOOL_BACKEND__", "BACKEND": "__MEMPOOL_BACKEND__",
"ENABLED": true,
"BLOCKS_SUMMARIES_INDEXING": true, "BLOCKS_SUMMARIES_INDEXING": true,
"HTTP_PORT": 1, "HTTP_PORT": 1,
"SPAWN_CLUSTER_PROCS": 2, "SPAWN_CLUSTER_PROCS": 2,

View File

@@ -13,6 +13,7 @@ describe('Mempool Backend Config', () => {
const config = jest.requireActual('../config').default; const config = jest.requireActual('../config').default;
expect(config.MEMPOOL).toStrictEqual({ expect(config.MEMPOOL).toStrictEqual({
ENABLED: true,
NETWORK: 'mainnet', NETWORK: 'mainnet',
BACKEND: 'none', BACKEND: 'none',
BLOCKS_SUMMARIES_INDEXING: false, BLOCKS_SUMMARIES_INDEXING: false,

118
backend/src/api/audit.ts Normal file
View File

@@ -0,0 +1,118 @@
import logger from '../logger';
import { BlockExtended, TransactionExtended, MempoolBlockWithTransactions } from '../mempool.interfaces';
const PROPAGATION_MARGIN = 180; // in seconds, time since a transaction is first seen after which it is assumed to have propagated to all miners
class Audit {
auditBlock(transactions: TransactionExtended[], projectedBlocks: MempoolBlockWithTransactions[], mempool: { [txId: string]: TransactionExtended })
: { censored: string[], added: string[], score: number } {
if (!projectedBlocks?.[0]?.transactionIds || !mempool) {
return { censored: [], added: [], score: 0 };
}
const matches: string[] = []; // present in both mined block and template
const added: string[] = []; // present in mined block, not in template
const fresh: string[] = []; // missing, but firstSeen within PROPAGATION_MARGIN
const isCensored = {}; // missing, without excuse
const isDisplaced = {};
let displacedWeight = 0;
const inBlock = {};
const inTemplate = {};
const now = Math.round((Date.now() / 1000));
for (const tx of transactions) {
inBlock[tx.txid] = tx;
}
// coinbase is always expected
if (transactions[0]) {
inTemplate[transactions[0].txid] = true;
}
// look for transactions that were expected in the template, but missing from the mined block
for (const txid of projectedBlocks[0].transactionIds) {
if (!inBlock[txid]) {
// tx is recent, may have reached the miner too late for inclusion
if (mempool[txid]?.firstSeen != null && (now - (mempool[txid]?.firstSeen || 0)) <= PROPAGATION_MARGIN) {
fresh.push(txid);
} else {
isCensored[txid] = true;
}
displacedWeight += mempool[txid].weight;
}
inTemplate[txid] = true;
}
displacedWeight += (4000 - transactions[0].weight);
logger.warn(`${fresh.length} fresh, ${Object.keys(isCensored).length} possibly censored, ${displacedWeight} displaced weight`);
// we can expect an honest miner to include 'displaced' transactions in place of recent arrivals and censored txs
// these displaced transactions should occupy the first N weight units of the next projected block
let displacedWeightRemaining = displacedWeight;
let index = 0;
let lastFeeRate = Infinity;
let failures = 0;
while (projectedBlocks[1] && index < projectedBlocks[1].transactionIds.length && failures < 500) {
const txid = projectedBlocks[1].transactionIds[index];
const fits = (mempool[txid].weight - displacedWeightRemaining) < 4000;
const feeMatches = mempool[txid].effectiveFeePerVsize >= lastFeeRate;
if (fits || feeMatches) {
isDisplaced[txid] = true;
if (fits) {
lastFeeRate = Math.min(lastFeeRate, mempool[txid].effectiveFeePerVsize);
}
if (mempool[txid].firstSeen == null || (now - (mempool[txid]?.firstSeen || 0)) > PROPAGATION_MARGIN) {
displacedWeightRemaining -= mempool[txid].weight;
}
failures = 0;
} else {
failures++;
}
index++;
}
// mark unexpected transactions in the mined block as 'added'
let overflowWeight = 0;
for (const tx of transactions) {
if (inTemplate[tx.txid]) {
matches.push(tx.txid);
} else {
if (!isDisplaced[tx.txid]) {
added.push(tx.txid);
}
overflowWeight += tx.weight;
}
}
// transactions missing from near the end of our template are probably not being censored
let overflowWeightRemaining = overflowWeight;
let lastOverflowRate = 1.00;
index = projectedBlocks[0].transactionIds.length - 1;
while (index >= 0) {
const txid = projectedBlocks[0].transactionIds[index];
if (overflowWeightRemaining > 0) {
if (isCensored[txid]) {
delete isCensored[txid];
}
lastOverflowRate = mempool[txid].effectiveFeePerVsize;
} else if (Math.floor(mempool[txid].effectiveFeePerVsize * 100) <= Math.ceil(lastOverflowRate * 100)) { // tolerance of 0.01 sat/vb
if (isCensored[txid]) {
delete isCensored[txid];
}
}
overflowWeightRemaining -= (mempool[txid]?.weight || 0);
index--;
}
const numCensored = Object.keys(isCensored).length;
const score = matches.length > 0 ? (matches.length / (matches.length + numCensored)) : 0;
return {
censored: Object.keys(isCensored),
added,
score
};
}
}
export default new Audit();

View File

@@ -20,6 +20,7 @@ import indexer from '../indexer';
import fiatConversion from './fiat-conversion'; import fiatConversion from './fiat-conversion';
import poolsParser from './pools-parser'; import poolsParser from './pools-parser';
import BlocksSummariesRepository from '../repositories/BlocksSummariesRepository'; import BlocksSummariesRepository from '../repositories/BlocksSummariesRepository';
import BlocksAuditsRepository from '../repositories/BlocksAuditsRepository';
import mining from './mining/mining'; import mining from './mining/mining';
import DifficultyAdjustmentsRepository from '../repositories/DifficultyAdjustmentsRepository'; import DifficultyAdjustmentsRepository from '../repositories/DifficultyAdjustmentsRepository';
import PricesRepository from '../repositories/PricesRepository'; import PricesRepository from '../repositories/PricesRepository';
@@ -186,14 +187,18 @@ class Blocks {
if (!pool) { // We should never have this situation in practise if (!pool) { // We should never have this situation in practise
logger.warn(`Cannot assign pool to block ${blockExtended.height} and 'unknown' pool does not exist. ` + logger.warn(`Cannot assign pool to block ${blockExtended.height} and 'unknown' pool does not exist. ` +
`Check your "pools" table entries`); `Check your "pools" table entries`);
return blockExtended; } else {
blockExtended.extras.pool = {
id: pool.id,
name: pool.name,
slug: pool.slug,
};
} }
blockExtended.extras.pool = { const auditSummary = await BlocksAuditsRepository.$getShortBlockAudit(block.id);
id: pool.id, if (auditSummary) {
name: pool.name, blockExtended.extras.matchRate = auditSummary.matchRate;
slug: pool.slug, }
};
} }
return blockExtended; return blockExtended;

View File

@@ -4,7 +4,7 @@ import logger from '../logger';
import { Common } from './common'; import { Common } from './common';
class DatabaseMigration { class DatabaseMigration {
private static currentVersion = 40; private static currentVersion = 41;
private queryTimeout = 120000; private queryTimeout = 120000;
private statisticsAddedIndexed = false; private statisticsAddedIndexed = false;
private uniqueLogs: string[] = []; private uniqueLogs: string[] = [];
@@ -348,6 +348,10 @@ class DatabaseMigration {
await this.$executeQuery('ALTER TABLE `nodes` ADD channels int(11) unsigned DEFAULT NULL'); await this.$executeQuery('ALTER TABLE `nodes` ADD channels int(11) unsigned DEFAULT NULL');
await this.$executeQuery('ALTER TABLE `nodes` ADD INDEX `capacity` (`capacity`);'); await this.$executeQuery('ALTER TABLE `nodes` ADD INDEX `capacity` (`capacity`);');
} }
if (databaseSchemaVersion < 41 && isBitcoin === true) {
await this.$executeQuery('UPDATE channels SET closing_reason = NULL WHERE closing_reason = 1');
}
} }
/** /**

View File

@@ -129,6 +129,56 @@ class NodesApi {
} }
} }
public async $getFeeHistogram(node_public_key: string): Promise<unknown> {
try {
const inQuery = `
SELECT CASE WHEN fee_rate <= 10.0 THEN CEIL(fee_rate)
WHEN (fee_rate > 10.0 and fee_rate <= 100.0) THEN CEIL(fee_rate / 10.0) * 10.0
WHEN (fee_rate > 100.0 and fee_rate <= 1000.0) THEN CEIL(fee_rate / 100.0) * 100.0
WHEN fee_rate > 1000.0 THEN CEIL(fee_rate / 1000.0) * 1000.0
END as bucket,
count(short_id) as count,
sum(capacity) as capacity
FROM (
SELECT CASE WHEN node1_public_key = ? THEN node2_fee_rate WHEN node2_public_key = ? THEN node1_fee_rate END as fee_rate,
short_id as short_id,
capacity as capacity
FROM channels
WHERE status = 1 AND (channels.node1_public_key = ? OR channels.node2_public_key = ?)
) as fee_rate_table
GROUP BY bucket;
`;
const [inRows]: any[] = await DB.query(inQuery, [node_public_key, node_public_key, node_public_key, node_public_key]);
const outQuery = `
SELECT CASE WHEN fee_rate <= 10.0 THEN CEIL(fee_rate)
WHEN (fee_rate > 10.0 and fee_rate <= 100.0) THEN CEIL(fee_rate / 10.0) * 10.0
WHEN (fee_rate > 100.0 and fee_rate <= 1000.0) THEN CEIL(fee_rate / 100.0) * 100.0
WHEN fee_rate > 1000.0 THEN CEIL(fee_rate / 1000.0) * 1000.0
END as bucket,
count(short_id) as count,
sum(capacity) as capacity
FROM (
SELECT CASE WHEN node1_public_key = ? THEN node1_fee_rate WHEN node2_public_key = ? THEN node2_fee_rate END as fee_rate,
short_id as short_id,
capacity as capacity
FROM channels
WHERE status = 1 AND (channels.node1_public_key = ? OR channels.node2_public_key = ?)
) as fee_rate_table
GROUP BY bucket;
`;
const [outRows]: any[] = await DB.query(outQuery, [node_public_key, node_public_key, node_public_key, node_public_key]);
return {
incoming: inRows.length > 0 ? inRows : [],
outgoing: outRows.length > 0 ? outRows : [],
};
} catch (e) {
logger.err(`Cannot get node fee distribution for ${node_public_key}. Reason: ${(e instanceof Error ? e.message : e)}`);
throw e;
}
}
public async $getAllNodes(): Promise<any> { public async $getAllNodes(): Promise<any> {
try { try {
const query = `SELECT * FROM nodes`; const query = `SELECT * FROM nodes`;

View File

@@ -20,6 +20,7 @@ class NodesRoutes {
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/rankings/connectivity', this.$getTopNodesByChannels) .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/rankings/connectivity', this.$getTopNodesByChannels)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/rankings/age', this.$getOldestNodes) .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/rankings/age', this.$getOldestNodes)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key/statistics', this.$getHistoricalNodeStats) .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key/statistics', this.$getHistoricalNodeStats)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key/fees/histogram', this.$getFeeHistogram)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key', this.$getNode) .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key', this.$getNode)
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/group/:name', this.$getNodeGroup) .get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/group/:name', this.$getNodeGroup)
; ;
@@ -95,6 +96,22 @@ class NodesRoutes {
} }
} }
private async $getFeeHistogram(req: Request, res: Response) {
try {
const node = await nodesApi.$getFeeHistogram(req.params.public_key);
if (!node) {
res.status(404).send('Node not found');
return;
}
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(node);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getNodesRanking(req: Request, res: Response): Promise<void> { private async $getNodesRanking(req: Request, res: Response): Promise<void> {
try { try {
const topCapacityNodes = await nodesApi.$getTopCapacityNodes(false); const topCapacityNodes = await nodesApi.$getTopCapacityNodes(false);

View File

@@ -70,6 +70,8 @@ export async function convertAndmergeBidirectionalChannels(clChannels: any[]): P
logger.info(`Building partial channels from clightning output. Channels processed: ${channelProcessed + 1} of ${keys.length}`); logger.info(`Building partial channels from clightning output. Channels processed: ${channelProcessed + 1} of ${keys.length}`);
loggerTimer = new Date().getTime() / 1000; loggerTimer = new Date().getTime() / 1000;
} }
channelProcessed++;
} }
return consolidatedChannelList; return consolidatedChannelList;

View File

@@ -1,7 +1,8 @@
import logger from '../logger'; import logger from '../logger';
import { MempoolBlock, TransactionExtended, TransactionStripped, MempoolBlockWithTransactions, MempoolBlockDelta } from '../mempool.interfaces'; import { MempoolBlock, TransactionExtended, AuditTransaction, TransactionStripped, MempoolBlockWithTransactions, MempoolBlockDelta, Ancestor } from '../mempool.interfaces';
import { Common } from './common'; import { Common } from './common';
import config from '../config'; import config from '../config';
import { PairingHeap } from '../utils/pairing-heap';
class MempoolBlocks { class MempoolBlocks {
private mempoolBlocks: MempoolBlockWithTransactions[] = []; private mempoolBlocks: MempoolBlockWithTransactions[] = [];
@@ -72,6 +73,7 @@ class MempoolBlocks {
logger.debug('Mempool blocks calculated in ' + time / 1000 + ' seconds'); logger.debug('Mempool blocks calculated in ' + time / 1000 + ' seconds');
const { blocks, deltas } = this.calculateMempoolBlocks(memPoolArray, this.mempoolBlocks); const { blocks, deltas } = this.calculateMempoolBlocks(memPoolArray, this.mempoolBlocks);
this.mempoolBlocks = blocks; this.mempoolBlocks = blocks;
this.mempoolBlockDeltas = deltas; this.mempoolBlockDeltas = deltas;
} }
@@ -99,6 +101,7 @@ class MempoolBlocks {
if (transactions.length) { if (transactions.length) {
mempoolBlocks.push(this.dataToMempoolBlocks(transactions, blockSize, blockWeight, mempoolBlocks.length)); mempoolBlocks.push(this.dataToMempoolBlocks(transactions, blockSize, blockWeight, mempoolBlocks.length));
} }
// Calculate change from previous block states // Calculate change from previous block states
for (let i = 0; i < Math.max(mempoolBlocks.length, prevBlocks.length); i++) { for (let i = 0; i < Math.max(mempoolBlocks.length, prevBlocks.length); i++) {
let added: TransactionStripped[] = []; let added: TransactionStripped[] = [];
@@ -132,12 +135,286 @@ class MempoolBlocks {
removed removed
}); });
} }
return { return {
blocks: mempoolBlocks, blocks: mempoolBlocks,
deltas: mempoolBlockDeltas deltas: mempoolBlockDeltas
}; };
} }
/*
* Build projected mempool blocks using an approximation of the transaction selection algorithm from Bitcoin Core
* (see BlockAssembler in https://github.com/bitcoin/bitcoin/blob/master/src/node/miner.cpp)
*
* blockLimit: number of blocks to build in total.
* weightLimit: maximum weight of transactions to consider using the selection algorithm.
* if weightLimit is significantly lower than the mempool size, results may start to diverge from getBlockTemplate
* condenseRest: whether to ignore excess transactions or append them to the final block.
*/
public makeBlockTemplates(mempool: { [txid: string]: TransactionExtended }, blockLimit: number, weightLimit: number | null = null, condenseRest = false): MempoolBlockWithTransactions[] {
const start = Date.now();
const auditPool: { [txid: string]: AuditTransaction } = {};
const mempoolArray: AuditTransaction[] = [];
const restOfArray: TransactionExtended[] = [];
let weight = 0;
const maxWeight = weightLimit ? Math.max(4_000_000 * blockLimit, weightLimit) : Infinity;
// grab the top feerate txs up to maxWeight
Object.values(mempool).sort((a, b) => b.feePerVsize - a.feePerVsize).forEach(tx => {
weight += tx.weight;
if (weight >= maxWeight) {
restOfArray.push(tx);
return;
}
// initializing everything up front helps V8 optimize property access later
auditPool[tx.txid] = {
txid: tx.txid,
fee: tx.fee,
size: tx.size,
weight: tx.weight,
feePerVsize: tx.feePerVsize,
vin: tx.vin,
relativesSet: false,
ancestorMap: new Map<string, AuditTransaction>(),
children: new Set<AuditTransaction>(),
ancestorFee: 0,
ancestorWeight: 0,
score: 0,
used: false,
modified: false,
modifiedNode: null,
}
mempoolArray.push(auditPool[tx.txid]);
})
// Build relatives graph & calculate ancestor scores
for (const tx of mempoolArray) {
if (!tx.relativesSet) {
this.setRelatives(tx, auditPool);
}
}
// Sort by descending ancestor score
mempoolArray.sort((a, b) => (b.score || 0) - (a.score || 0));
// Build blocks by greedily choosing the highest feerate package
// (i.e. the package rooted in the transaction with the best ancestor score)
const blocks: MempoolBlockWithTransactions[] = [];
let blockWeight = 4000;
let blockSize = 0;
let transactions: AuditTransaction[] = [];
const modified: PairingHeap<AuditTransaction> = new PairingHeap((a, b): boolean => (a.score || 0) > (b.score || 0));
let overflow: AuditTransaction[] = [];
let failures = 0;
let top = 0;
while ((top < mempoolArray.length || !modified.isEmpty()) && (condenseRest || blocks.length < blockLimit)) {
// skip invalid transactions
while (top < mempoolArray.length && (mempoolArray[top].used || mempoolArray[top].modified)) {
top++;
}
// Select best next package
let nextTx;
const nextPoolTx = mempoolArray[top];
const nextModifiedTx = modified.peek();
if (nextPoolTx && (!nextModifiedTx || (nextPoolTx.score || 0) > (nextModifiedTx.score || 0))) {
nextTx = nextPoolTx;
top++;
} else {
modified.pop();
if (nextModifiedTx) {
nextTx = nextModifiedTx;
nextTx.modifiedNode = undefined;
}
}
if (nextTx && !nextTx?.used) {
// Check if the package fits into this block
if (blockWeight + nextTx.ancestorWeight < config.MEMPOOL.BLOCK_WEIGHT_UNITS) {
blockWeight += nextTx.ancestorWeight;
const ancestors: AuditTransaction[] = Array.from(nextTx.ancestorMap.values());
// sort ancestors by dependency graph (equivalent to sorting by ascending ancestor count)
const sortedTxSet = [...ancestors.sort((a, b) => { return (a.ancestorMap.size || 0) - (b.ancestorMap.size || 0); }), nextTx];
const effectiveFeeRate = nextTx.ancestorFee / (nextTx.ancestorWeight / 4);
sortedTxSet.forEach((ancestor, i, arr) => {
const mempoolTx = mempool[ancestor.txid];
if (ancestor && !ancestor?.used) {
ancestor.used = true;
// update original copy of this tx with effective fee rate & relatives data
mempoolTx.effectiveFeePerVsize = effectiveFeeRate;
mempoolTx.ancestors = (Array.from(ancestor.ancestorMap?.values()) as AuditTransaction[]).map((a) => {
return {
txid: a.txid,
fee: a.fee,
weight: a.weight,
}
})
if (i < arr.length - 1) {
mempoolTx.bestDescendant = {
txid: arr[arr.length - 1].txid,
fee: arr[arr.length - 1].fee,
weight: arr[arr.length - 1].weight,
};
}
transactions.push(ancestor);
blockSize += ancestor.size;
}
});
// remove these as valid package ancestors for any descendants remaining in the mempool
if (sortedTxSet.length) {
sortedTxSet.forEach(tx => {
this.updateDescendants(tx, auditPool, modified);
});
}
failures = 0;
} else {
// hold this package in an overflow list while we check for smaller options
overflow.push(nextTx);
failures++;
}
}
// this block is full
const exceededPackageTries = failures > 1000 && blockWeight > (config.MEMPOOL.BLOCK_WEIGHT_UNITS - 4000);
if (exceededPackageTries && (!condenseRest || blocks.length < blockLimit - 1)) {
// construct this block
if (transactions.length) {
blocks.push(this.dataToMempoolBlocks(transactions.map(t => mempool[t.txid]), blockSize, blockWeight, blocks.length));
}
// reset for the next block
transactions = [];
blockSize = 0;
blockWeight = 4000;
// 'overflow' packages didn't fit in this block, but are valid candidates for the next
for (const overflowTx of overflow.reverse()) {
if (overflowTx.modified) {
overflowTx.modifiedNode = modified.add(overflowTx);
} else {
top--;
mempoolArray[top] = overflowTx;
}
}
overflow = [];
}
}
if (condenseRest) {
// pack any leftover transactions into the last block
for (const tx of overflow) {
if (!tx || tx?.used) {
continue;
}
blockWeight += tx.weight;
blockSize += tx.size;
transactions.push(tx);
tx.used = true;
}
const blockTransactions = transactions.map(t => mempool[t.txid])
restOfArray.forEach(tx => {
blockWeight += tx.weight;
blockSize += tx.size;
blockTransactions.push(tx);
});
if (blockTransactions.length) {
blocks.push(this.dataToMempoolBlocks(blockTransactions, blockSize, blockWeight, blocks.length));
}
transactions = [];
} else if (transactions.length) {
blocks.push(this.dataToMempoolBlocks(transactions.map(t => mempool[t.txid]), blockSize, blockWeight, blocks.length));
}
const end = Date.now();
const time = end - start;
logger.debug('Mempool templates calculated in ' + time / 1000 + ' seconds');
return blocks;
}
// traverse in-mempool ancestors
// recursion unavoidable, but should be limited to depth < 25 by mempool policy
public setRelatives(
tx: AuditTransaction,
mempool: { [txid: string]: AuditTransaction },
): void {
for (const parent of tx.vin) {
const parentTx = mempool[parent.txid];
if (parentTx && !tx.ancestorMap!.has(parent.txid)) {
tx.ancestorMap.set(parent.txid, parentTx);
parentTx.children.add(tx);
// visit each node only once
if (!parentTx.relativesSet) {
this.setRelatives(parentTx, mempool);
}
parentTx.ancestorMap.forEach((ancestor) => {
tx.ancestorMap.set(ancestor.txid, ancestor);
});
}
};
tx.ancestorFee = tx.fee || 0;
tx.ancestorWeight = tx.weight || 0;
tx.ancestorMap.forEach((ancestor) => {
tx.ancestorFee += ancestor.fee;
tx.ancestorWeight += ancestor.weight;
});
tx.score = tx.ancestorFee / (tx.ancestorWeight || 1);
tx.relativesSet = true;
}
// iterate over remaining descendants, removing the root as a valid ancestor & updating the ancestor score
// avoids recursion to limit call stack depth
private updateDescendants(
rootTx: AuditTransaction,
mempool: { [txid: string]: AuditTransaction },
modified: PairingHeap<AuditTransaction>,
): void {
const descendantSet: Set<AuditTransaction> = new Set();
// stack of nodes left to visit
const descendants: AuditTransaction[] = [];
let descendantTx;
let ancestorIndex;
let tmpScore;
rootTx.children.forEach(childTx => {
if (!descendantSet.has(childTx)) {
descendants.push(childTx);
descendantSet.add(childTx);
}
});
while (descendants.length) {
descendantTx = descendants.pop();
if (descendantTx && descendantTx.ancestorMap && descendantTx.ancestorMap.has(rootTx.txid)) {
// remove tx as ancestor
descendantTx.ancestorMap.delete(rootTx.txid);
descendantTx.ancestorFee -= rootTx.fee;
descendantTx.ancestorWeight -= rootTx.weight;
tmpScore = descendantTx.score;
descendantTx.score = descendantTx.ancestorFee / descendantTx.ancestorWeight;
if (!descendantTx.modifiedNode) {
descendantTx.modified = true;
descendantTx.modifiedNode = modified.add(descendantTx);
} else {
// rebalance modified heap if score has changed
if (descendantTx.score < tmpScore) {
modified.decreasePriority(descendantTx.modifiedNode);
} else if (descendantTx.score > tmpScore) {
modified.increasePriority(descendantTx.modifiedNode);
}
}
// add this node's children to the stack
descendantTx.children.forEach(childTx => {
// visit each node only once
if (!descendantSet.has(childTx)) {
descendants.push(childTx);
descendantSet.add(childTx);
}
});
}
}
}
private dataToMempoolBlocks(transactions: TransactionExtended[], private dataToMempoolBlocks(transactions: TransactionExtended[],
blockSize: number, blockWeight: number, blocksIndex: number): MempoolBlockWithTransactions { blockSize: number, blockWeight: number, blocksIndex: number): MempoolBlockWithTransactions {
let rangeLength = 4; let rangeLength = 4;

View File

@@ -103,12 +103,11 @@ class Mempool {
return txTimes; return txTimes;
} }
public async $updateMempool() { public async $updateMempool(): Promise<void> {
logger.debug('Updating mempool'); logger.debug(`Updating mempool...`);
const start = new Date().getTime(); const start = new Date().getTime();
let hasChange: boolean = false; let hasChange: boolean = false;
const currentMempoolSize = Object.keys(this.mempoolCache).length; const currentMempoolSize = Object.keys(this.mempoolCache).length;
let txCount = 0;
const transactions = await bitcoinApi.$getRawMempool(); const transactions = await bitcoinApi.$getRawMempool();
const diff = transactions.length - currentMempoolSize; const diff = transactions.length - currentMempoolSize;
const newTransactions: TransactionExtended[] = []; const newTransactions: TransactionExtended[] = [];
@@ -124,7 +123,6 @@ class Mempool {
try { try {
const transaction = await transactionUtils.$getTransactionExtended(txid); const transaction = await transactionUtils.$getTransactionExtended(txid);
this.mempoolCache[txid] = transaction; this.mempoolCache[txid] = transaction;
txCount++;
if (this.inSync) { if (this.inSync) {
this.txPerSecondArray.push(new Date().getTime()); this.txPerSecondArray.push(new Date().getTime());
this.vBytesPerSecondArray.push({ this.vBytesPerSecondArray.push({
@@ -133,14 +131,9 @@ class Mempool {
}); });
} }
hasChange = true; hasChange = true;
if (diff > 0) {
logger.debug('Fetched transaction ' + txCount + ' / ' + diff);
} else {
logger.debug('Fetched transaction ' + txCount);
}
newTransactions.push(transaction); newTransactions.push(transaction);
} catch (e) { } catch (e) {
logger.debug('Error finding transaction in mempool: ' + (e instanceof Error ? e.message : e)); logger.debug(`Error finding transaction '${txid}' in the mempool: ` + (e instanceof Error ? e.message : e));
} }
} }
@@ -197,8 +190,7 @@ class Mempool {
const end = new Date().getTime(); const end = new Date().getTime();
const time = end - start; const time = end - start;
logger.debug(`New mempool size: ${Object.keys(this.mempoolCache).length} Change: ${diff}`); logger.debug(`Mempool updated in ${time / 1000} seconds. New size: ${Object.keys(this.mempoolCache).length} (${diff > 0 ? '+' + diff : diff})`);
logger.debug('Mempool updated in ' + time / 1000 + ' seconds');
} }
public handleRbfTransactions(rbfTransactions: { [txid: string]: TransactionExtended; }) { public handleRbfTransactions(rbfTransactions: { [txid: string]: TransactionExtended; }) {

View File

@@ -27,6 +27,7 @@ class MiningRoutes {
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/difficulty-adjustments/:interval', this.$getDifficultyAdjustments) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/difficulty-adjustments/:interval', this.$getDifficultyAdjustments)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/predictions/:interval', this.$getHistoricalBlockPrediction) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/predictions/:interval', this.$getHistoricalBlockPrediction)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/audit/:hash', this.$getBlockAudit) .get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/audit/:hash', this.$getBlockAudit)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/timestamp/:timestamp', this.$getHeightFromTimestamp)
; ;
} }
@@ -238,6 +239,12 @@ class MiningRoutes {
public async $getBlockAudit(req: Request, res: Response) { public async $getBlockAudit(req: Request, res: Response) {
try { try {
const audit = await BlocksAuditsRepository.$getBlockAudit(req.params.hash); const audit = await BlocksAuditsRepository.$getBlockAudit(req.params.hash);
if (!audit) {
res.status(404).send(`This block has not been audited.`);
return;
}
res.header('Pragma', 'public'); res.header('Pragma', 'public');
res.header('Cache-control', 'public'); res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24).toUTCString());
@@ -246,6 +253,29 @@ class MiningRoutes {
res.status(500).send(e instanceof Error ? e.message : e); res.status(500).send(e instanceof Error ? e.message : e);
} }
} }
private async $getHeightFromTimestamp(req: Request, res: Response) {
try {
const timestamp = parseInt(req.params.timestamp, 10);
// This will prevent people from entering milliseconds etc.
// Block timestamps are allowed to be up to 2 hours off, so 24 hours
// will never put the maximum value before the most recent block
const nowPlus1day = Math.floor(Date.now() / 1000) + 60 * 60 * 24;
// Prevent non-integers that are not seconds
if (!/^[1-9][0-9]*$/.test(req.params.timestamp) || timestamp > nowPlus1day) {
throw new Error(`Invalid timestamp, value must be Unix seconds`);
}
const result = await BlocksRepository.$getBlockHeightFromTimestamp(
timestamp,
);
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString());
res.json(result);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
} }
export default new MiningRoutes(); export default new MiningRoutes();

View File

@@ -18,6 +18,7 @@ import difficultyAdjustment from './difficulty-adjustment';
import feeApi from './fee-api'; import feeApi from './fee-api';
import BlocksAuditsRepository from '../repositories/BlocksAuditsRepository'; import BlocksAuditsRepository from '../repositories/BlocksAuditsRepository';
import BlocksSummariesRepository from '../repositories/BlocksSummariesRepository'; import BlocksSummariesRepository from '../repositories/BlocksSummariesRepository';
import Audit from './audit';
class WebsocketHandler { class WebsocketHandler {
private wss: WebSocket.Server | undefined; private wss: WebSocket.Server | undefined;
@@ -405,75 +406,63 @@ class WebsocketHandler {
}); });
} }
handleNewBlock(block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) { handleNewBlock(block: BlockExtended, txIds: string[], transactions: TransactionExtended[]): void {
if (!this.wss) { if (!this.wss) {
throw new Error('WebSocket.Server is not set'); throw new Error('WebSocket.Server is not set');
} }
let mBlocks: undefined | MempoolBlock[]; let mBlocks: undefined | MempoolBlock[];
let mBlockDeltas: undefined | MempoolBlockDelta[]; let mBlockDeltas: undefined | MempoolBlockDelta[];
let matchRate = 0; let matchRate;
const _memPool = memPool.getMempool(); const _memPool = memPool.getMempool();
const _mempoolBlocks = mempoolBlocks.getMempoolBlocksWithTransactions();
if (_mempoolBlocks[0]) { if (Common.indexingEnabled()) {
const matches: string[] = []; const mempoolCopy = cloneMempool(_memPool);
const added: string[] = []; const projectedBlocks = mempoolBlocks.makeBlockTemplates(mempoolCopy, 2);
const missing: string[] = [];
for (const txId of txIds) { const { censored, added, score } = Audit.auditBlock(transactions, projectedBlocks, mempoolCopy);
if (_mempoolBlocks[0].transactionIds.indexOf(txId) > -1) { matchRate = Math.round(score * 100 * 100) / 100;
matches.push(txId);
} else { const stripped = projectedBlocks[0]?.transactions ? projectedBlocks[0].transactions.map((tx) => {
added.push(txId); return {
txid: tx.txid,
vsize: tx.vsize,
fee: tx.fee ? Math.round(tx.fee) : 0,
value: tx.value,
};
}) : [];
BlocksSummariesRepository.$saveSummary({
height: block.height,
template: {
id: block.id,
transactions: stripped
} }
delete _memPool[txId]; });
}
for (const txId of _mempoolBlocks[0].transactionIds) { BlocksAuditsRepository.$saveAudit({
if (matches.includes(txId) || added.includes(txId)) { time: block.timestamp,
continue; height: block.height,
} hash: block.id,
missing.push(txId); addedTxs: added,
} missingTxs: censored,
matchRate: matchRate,
});
matchRate = Math.round((Math.max(0, matches.length - missing.length - added.length) / txIds.length * 100) * 100) / 100; if (block.extras) {
mempoolBlocks.updateMempoolBlocks(_memPool); block.extras.matchRate = matchRate;
mBlocks = mempoolBlocks.getMempoolBlocks();
mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas();
if (Common.indexingEnabled()) {
const stripped = _mempoolBlocks[0].transactions.map((tx) => {
return {
txid: tx.txid,
vsize: tx.vsize,
fee: tx.fee ? Math.round(tx.fee) : 0,
value: tx.value,
};
});
BlocksSummariesRepository.$saveSummary({
height: block.height,
template: {
id: block.id,
transactions: stripped
}
});
BlocksAuditsRepository.$saveAudit({
time: block.timestamp,
height: block.height,
hash: block.id,
addedTxs: added,
missingTxs: missing,
matchRate: matchRate,
});
} }
} }
if (block.extras) { // Update mempool to remove transactions included in the new block
block.extras.matchRate = matchRate; for (const txId of txIds) {
delete _memPool[txId];
} }
mempoolBlocks.updateMempoolBlocks(_memPool);
mBlocks = mempoolBlocks.getMempoolBlocks();
mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas();
const da = difficultyAdjustment.getDifficultyAdjustment(); const da = difficultyAdjustment.getDifficultyAdjustment();
const fees = feeApi.getRecommendedFee(); const fees = feeApi.getRecommendedFee();
@@ -580,4 +569,14 @@ class WebsocketHandler {
} }
} }
function cloneMempool(mempool: { [txid: string]: TransactionExtended }): { [txid: string]: TransactionExtended } {
const cloned = {};
Object.keys(mempool).forEach(id => {
cloned[id] = {
...mempool[id]
};
});
return cloned;
}
export default new WebsocketHandler(); export default new WebsocketHandler();

View File

@@ -4,6 +4,7 @@ const configFromFile = require(
interface IConfig { interface IConfig {
MEMPOOL: { MEMPOOL: {
ENABLED: boolean;
NETWORK: 'mainnet' | 'testnet' | 'signet' | 'liquid' | 'liquidtestnet'; NETWORK: 'mainnet' | 'testnet' | 'signet' | 'liquid' | 'liquidtestnet';
BACKEND: 'esplora' | 'electrum' | 'none'; BACKEND: 'esplora' | 'electrum' | 'none';
HTTP_PORT: number; HTTP_PORT: number;
@@ -119,6 +120,7 @@ interface IConfig {
const defaults: IConfig = { const defaults: IConfig = {
'MEMPOOL': { 'MEMPOOL': {
'ENABLED': true,
'NETWORK': 'mainnet', 'NETWORK': 'mainnet',
'BACKEND': 'none', 'BACKEND': 'none',
'HTTP_PORT': 8999, 'HTTP_PORT': 8999,
@@ -224,11 +226,11 @@ const defaults: IConfig = {
'BISQ_URL': 'https://bisq.markets/api', 'BISQ_URL': 'https://bisq.markets/api',
'BISQ_ONION': 'http://bisqmktse2cabavbr2xjq7xw3h6g5ottemo5rolfcwt6aly6tp5fdryd.onion/api' 'BISQ_ONION': 'http://bisqmktse2cabavbr2xjq7xw3h6g5ottemo5rolfcwt6aly6tp5fdryd.onion/api'
}, },
"MAXMIND": { 'MAXMIND': {
'ENABLED': false, 'ENABLED': false,
"GEOLITE2_CITY": "/usr/local/share/GeoIP/GeoLite2-City.mmdb", 'GEOLITE2_CITY': '/usr/local/share/GeoIP/GeoLite2-City.mmdb',
"GEOLITE2_ASN": "/usr/local/share/GeoIP/GeoLite2-ASN.mmdb", 'GEOLITE2_ASN': '/usr/local/share/GeoIP/GeoLite2-ASN.mmdb',
"GEOIP2_ISP": "/usr/local/share/GeoIP/GeoIP2-ISP.mmdb" 'GEOIP2_ISP': '/usr/local/share/GeoIP/GeoIP2-ISP.mmdb'
}, },
}; };

View File

@@ -1,4 +1,4 @@
import express from "express"; import express from 'express';
import { Application, Request, Response, NextFunction } from 'express'; import { Application, Request, Response, NextFunction } from 'express';
import * as http from 'http'; import * as http from 'http';
import * as WebSocket from 'ws'; import * as WebSocket from 'ws';
@@ -34,7 +34,7 @@ import miningRoutes from './api/mining/mining-routes';
import bisqRoutes from './api/bisq/bisq.routes'; import bisqRoutes from './api/bisq/bisq.routes';
import liquidRoutes from './api/liquid/liquid.routes'; import liquidRoutes from './api/liquid/liquid.routes';
import bitcoinRoutes from './api/bitcoin/bitcoin.routes'; import bitcoinRoutes from './api/bitcoin/bitcoin.routes';
import fundingTxFetcher from "./tasks/lightning/sync-tasks/funding-tx-fetcher"; import fundingTxFetcher from './tasks/lightning/sync-tasks/funding-tx-fetcher';
class Server { class Server {
private wss: WebSocket.Server | undefined; private wss: WebSocket.Server | undefined;
@@ -74,7 +74,7 @@ class Server {
} }
} }
async startServer(worker = false) { async startServer(worker = false): Promise<void> {
logger.notice(`Starting Mempool Server${worker ? ' (worker)' : ''}... (${backendInfo.getShortCommitHash()})`); logger.notice(`Starting Mempool Server${worker ? ' (worker)' : ''}... (${backendInfo.getShortCommitHash()})`);
this.app this.app
@@ -92,7 +92,9 @@ class Server {
this.setUpWebsocketHandling(); this.setUpWebsocketHandling();
await syncAssets.syncAssets$(); await syncAssets.syncAssets$();
diskCache.loadMempoolCache(); if (config.MEMPOOL.ENABLED) {
diskCache.loadMempoolCache();
}
if (config.DATABASE.ENABLED) { if (config.DATABASE.ENABLED) {
await DB.checkDbConnection(); await DB.checkDbConnection();
@@ -127,7 +129,10 @@ class Server {
fiatConversion.startService(); fiatConversion.startService();
this.setUpHttpApiRoutes(); this.setUpHttpApiRoutes();
this.runMainUpdateLoop();
if (config.MEMPOOL.ENABLED) {
this.runMainUpdateLoop();
}
if (config.BISQ.ENABLED) { if (config.BISQ.ENABLED) {
bisq.startBisqService(); bisq.startBisqService();
@@ -149,7 +154,7 @@ class Server {
}); });
} }
async runMainUpdateLoop() { async runMainUpdateLoop(): Promise<void> {
try { try {
try { try {
await memPool.$updateMemPoolInfo(); await memPool.$updateMemPoolInfo();
@@ -183,7 +188,7 @@ class Server {
} }
} }
async $runLightningBackend() { async $runLightningBackend(): Promise<void> {
try { try {
await fundingTxFetcher.$init(); await fundingTxFetcher.$init();
await networkSyncService.$startService(); await networkSyncService.$startService();
@@ -195,7 +200,7 @@ class Server {
}; };
} }
setUpWebsocketHandling() { setUpWebsocketHandling(): void {
if (this.wss) { if (this.wss) {
websocketHandler.setWebsocketServer(this.wss); websocketHandler.setWebsocketServer(this.wss);
} }
@@ -209,19 +214,21 @@ class Server {
}); });
} }
websocketHandler.setupConnectionHandling(); websocketHandler.setupConnectionHandling();
statistics.setNewStatisticsEntryCallback(websocketHandler.handleNewStatistic.bind(websocketHandler)); if (config.MEMPOOL.ENABLED) {
blocks.setNewBlockCallback(websocketHandler.handleNewBlock.bind(websocketHandler)); statistics.setNewStatisticsEntryCallback(websocketHandler.handleNewStatistic.bind(websocketHandler));
memPool.setMempoolChangedCallback(websocketHandler.handleMempoolChange.bind(websocketHandler)); blocks.setNewBlockCallback(websocketHandler.handleNewBlock.bind(websocketHandler));
memPool.setMempoolChangedCallback(websocketHandler.handleMempoolChange.bind(websocketHandler));
}
fiatConversion.setProgressChangedCallback(websocketHandler.handleNewConversionRates.bind(websocketHandler)); fiatConversion.setProgressChangedCallback(websocketHandler.handleNewConversionRates.bind(websocketHandler));
loadingIndicators.setProgressChangedCallback(websocketHandler.handleLoadingChanged.bind(websocketHandler)); loadingIndicators.setProgressChangedCallback(websocketHandler.handleLoadingChanged.bind(websocketHandler));
} }
setUpHttpApiRoutes() { setUpHttpApiRoutes(): void {
bitcoinRoutes.initRoutes(this.app); bitcoinRoutes.initRoutes(this.app);
if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED) { if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED && config.MEMPOOL.ENABLED) {
statisticsRoutes.initRoutes(this.app); statisticsRoutes.initRoutes(this.app);
} }
if (Common.indexingEnabled()) { if (Common.indexingEnabled() && config.MEMPOOL.ENABLED) {
miningRoutes.initRoutes(this.app); miningRoutes.initRoutes(this.app);
} }
if (config.BISQ.ENABLED) { if (config.BISQ.ENABLED) {
@@ -238,4 +245,4 @@ class Server {
} }
} }
const server = new Server(); ((): Server => new Server())();

View File

@@ -1,4 +1,5 @@
import { IEsploraApi } from './api/bitcoin/esplora-api.interface'; import { IEsploraApi } from './api/bitcoin/esplora-api.interface';
import { HeapNode } from "./utils/pairing-heap";
export interface PoolTag { export interface PoolTag {
id: number; // mysql row id id: number; // mysql row id
@@ -70,12 +71,40 @@ export interface TransactionExtended extends IEsploraApi.Transaction {
deleteAfter?: number; deleteAfter?: number;
} }
interface Ancestor { export interface AuditTransaction {
txid: string;
fee: number;
size: number;
weight: number;
feePerVsize: number;
vin: IEsploraApi.Vin[];
relativesSet: boolean;
ancestorMap: Map<string, AuditTransaction>;
children: Set<AuditTransaction>;
ancestorFee: number;
ancestorWeight: number;
score: number;
used: boolean;
modified: boolean;
modifiedNode: HeapNode<AuditTransaction>;
}
export interface Ancestor {
txid: string; txid: string;
weight: number; weight: number;
fee: number; fee: number;
} }
export interface TransactionSet {
fee: number;
weight: number;
score: number;
children?: Set<string>;
available?: boolean;
modified?: boolean;
modifiedNode?: HeapNode<string>;
}
interface BestDescendant { interface BestDescendant {
txid: string; txid: string;
weight: number; weight: number;

View File

@@ -58,10 +58,12 @@ class BlocksAuditRepositories {
WHERE blocks_audits.hash = "${hash}" WHERE blocks_audits.hash = "${hash}"
`); `);
rows[0].missingTxs = JSON.parse(rows[0].missingTxs); if (rows.length) {
rows[0].addedTxs = JSON.parse(rows[0].addedTxs); rows[0].missingTxs = JSON.parse(rows[0].missingTxs);
rows[0].transactions = JSON.parse(rows[0].transactions); rows[0].addedTxs = JSON.parse(rows[0].addedTxs);
rows[0].template = JSON.parse(rows[0].template); rows[0].transactions = JSON.parse(rows[0].transactions);
rows[0].template = JSON.parse(rows[0].template);
}
return rows[0]; return rows[0];
} catch (e: any) { } catch (e: any) {
@@ -69,6 +71,20 @@ class BlocksAuditRepositories {
throw e; throw e;
} }
} }
public async $getShortBlockAudit(hash: string): Promise<any> {
try {
const [rows]: any[] = await DB.query(
`SELECT hash as id, match_rate as matchRate
FROM blocks_audits
WHERE blocks_audits.hash = "${hash}"
`);
return rows[0];
} catch (e: any) {
logger.err(`Cannot fetch block audit from db. Reason: ` + (e instanceof Error ? e.message : e));
throw e;
}
}
} }
export default new BlocksAuditRepositories(); export default new BlocksAuditRepositories();

View File

@@ -392,6 +392,36 @@ class BlocksRepository {
} }
} }
/**
* Get the first block at or directly after a given timestamp
* @param timestamp number unix time in seconds
* @returns The height and timestamp of a block (timestamp might vary from given timestamp)
*/
public async $getBlockHeightFromTimestamp(
timestamp: number,
): Promise<{ height: number; hash: string; timestamp: number }> {
try {
// Get first block at or after the given timestamp
const query = `SELECT height, hash, blockTimestamp as timestamp FROM blocks
WHERE blockTimestamp <= FROM_UNIXTIME(?)
ORDER BY blockTimestamp DESC
LIMIT 1`;
const params = [timestamp];
const [rows]: any[][] = await DB.query(query, params);
if (rows.length === 0) {
throw new Error(`No block was found before timestamp ${timestamp}`);
}
return rows[0];
} catch (e) {
logger.err(
'Cannot get block height from timestamp from the db. Reason: ' +
(e instanceof Error ? e.message : e),
);
throw e;
}
}
/** /**
* Return blocks height * Return blocks height
*/ */

View File

@@ -289,6 +289,24 @@ class NetworkSyncService {
1. Mutually closed 1. Mutually closed
2. Forced closed 2. Forced closed
3. Forced closed with penalty 3. Forced closed with penalty
┌────────────────────────────────────┐ ┌────────────────────────────┐
│ outputs contain revocation script? ├──yes──► force close w/ penalty = 3 │
└──────────────┬─────────────────────┘ └────────────────────────────┘
no
┌──────────────▼──────────────────────────┐
│ outputs contain other lightning script? ├──┐
└──────────────┬──────────────────────────┘ │
no yes
┌──────────────▼─────────────┐ │
│ sequence starts with 0x80 │ ┌────────▼────────┐
│ and ├──────► force close = 2 │
│ locktime starts with 0x20? │ └─────────────────┘
└──────────────┬─────────────┘
no
┌─────────▼────────┐
│ mutual close = 1 │
└──────────────────┘
*/ */
private async $runClosedChannelsForensics(): Promise<void> { private async $runClosedChannelsForensics(): Promise<void> {
@@ -326,36 +344,31 @@ class NetworkSyncService {
lightningScriptReasons.push(lightningScript); lightningScriptReasons.push(lightningScript);
} }
} }
if (lightningScriptReasons.length === outspends.length const filteredReasons = lightningScriptReasons.filter((r) => r !== 1);
&& lightningScriptReasons.filter((r) => r === 1).length === outspends.length) { if (filteredReasons.length) {
reason = 1; if (filteredReasons.some((r) => r === 2 || r === 4)) {
} else { reason = 3;
const filteredReasons = lightningScriptReasons.filter((r) => r !== 1);
if (filteredReasons.length) {
if (filteredReasons.some((r) => r === 2 || r === 4)) {
reason = 3;
} else {
reason = 2;
}
} else { } else {
/* reason = 2;
We can detect a commitment transaction (force close) by reading Sequence and Locktime }
https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction } else {
*/ /*
let closingTx: IEsploraApi.Transaction | undefined; We can detect a commitment transaction (force close) by reading Sequence and Locktime
try { https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction
closingTx = await bitcoinApi.$getRawTransaction(channel.closing_transaction_id); */
} catch (e) { let closingTx: IEsploraApi.Transaction | undefined;
logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + channel.closing_transaction_id}. Reason ${e instanceof Error ? e.message : e}`); try {
continue; closingTx = await bitcoinApi.$getRawTransaction(channel.closing_transaction_id);
} } catch (e) {
const sequenceHex: string = closingTx.vin[0].sequence.toString(16); logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + channel.closing_transaction_id}. Reason ${e instanceof Error ? e.message : e}`);
const locktimeHex: string = closingTx.locktime.toString(16); continue;
if (sequenceHex.substring(0, 2) === '80' && locktimeHex.substring(0, 2) === '20') { }
reason = 2; // Here we can't be sure if it's a penalty or not const sequenceHex: string = closingTx.vin[0].sequence.toString(16);
} else { const locktimeHex: string = closingTx.locktime.toString(16);
reason = 1; if (sequenceHex.substring(0, 2) === '80' && locktimeHex.substring(0, 2) === '20') {
} reason = 2; // Here we can't be sure if it's a penalty or not
} else {
reason = 1;
} }
} }
if (reason) { if (reason) {

View File

@@ -0,0 +1,174 @@
export type HeapNode<T> = {
element: T
child?: HeapNode<T>
next?: HeapNode<T>
prev?: HeapNode<T>
} | null | undefined;
// minimal pairing heap priority queue implementation
export class PairingHeap<T> {
private root: HeapNode<T> = null;
private comparator: (a: T, b: T) => boolean;
// comparator function should return 'true' if a is higher priority than b
constructor(comparator: (a: T, b: T) => boolean) {
this.comparator = comparator;
}
isEmpty(): boolean {
return !this.root;
}
add(element: T): HeapNode<T> {
const node: HeapNode<T> = {
element
};
this.root = this.meld(this.root, node);
return node;
}
// returns the top priority element without modifying the queue
peek(): T | void {
return this.root?.element;
}
// removes and returns the top priority element
pop(): T | void {
let element;
if (this.root) {
const node = this.root;
element = node.element;
this.root = this.mergePairs(node.child);
}
return element;
}
deleteNode(node: HeapNode<T>): void {
if (!node) {
return;
}
if (node === this.root) {
this.root = this.mergePairs(node.child);
}
else {
if (node.prev) {
if (node.prev.child === node) {
node.prev.child = node.next;
}
else {
node.prev.next = node.next;
}
}
if (node.next) {
node.next.prev = node.prev;
}
this.root = this.meld(this.root, this.mergePairs(node.child));
}
node.child = null;
node.prev = null;
node.next = null;
}
// fix the heap after increasing the priority of a given node
increasePriority(node: HeapNode<T>): void {
// already the top priority element
if (!node || node === this.root) {
return;
}
// extract from siblings
if (node.prev) {
if (node.prev?.child === node) {
if (this.comparator(node.prev.element, node.element)) {
// already in a valid position
return;
}
node.prev.child = node.next;
}
else {
node.prev.next = node.next;
}
}
if (node.next) {
node.next.prev = node.prev;
}
this.root = this.meld(this.root, node);
}
decreasePriority(node: HeapNode<T>): void {
this.deleteNode(node);
this.root = this.meld(this.root, node);
}
meld(a: HeapNode<T>, b: HeapNode<T>): HeapNode<T> {
if (!a) {
return b;
}
if (!b || a === b) {
return a;
}
let parent: HeapNode<T> = b;
let child: HeapNode<T> = a;
if (this.comparator(a.element, b.element)) {
parent = a;
child = b;
}
child.next = parent.child;
if (parent.child) {
parent.child.prev = child;
}
child.prev = parent;
parent.child = child;
parent.next = null;
parent.prev = null;
return parent;
}
mergePairs(node: HeapNode<T>): HeapNode<T> {
if (!node) {
return null;
}
let current: HeapNode<T> = node;
let next: HeapNode<T>;
let nextCurrent: HeapNode<T>;
let pairs: HeapNode<T>;
let melded: HeapNode<T>;
while (current) {
next = current.next;
if (next) {
nextCurrent = next.next;
melded = this.meld(current, next);
if (melded) {
melded.prev = pairs;
}
pairs = melded;
}
else {
nextCurrent = null;
current.prev = pairs;
pairs = current;
break;
}
current = nextCurrent;
}
melded = null;
let prev: HeapNode<T>;
while (pairs) {
prev = pairs.prev;
melded = this.meld(melded, pairs);
pairs = prev;
}
return melded;
}
}

View File

@@ -89,6 +89,7 @@ Below we list all settings from `mempool-config.json` and the corresponding over
"MEMPOOL": { "MEMPOOL": {
"NETWORK": "mainnet", "NETWORK": "mainnet",
"BACKEND": "electrum", "BACKEND": "electrum",
"ENABLED": true,
"HTTP_PORT": 8999, "HTTP_PORT": 8999,
"SPAWN_CLUSTER_PROCS": 0, "SPAWN_CLUSTER_PROCS": 0,
"API_URL_PREFIX": "/api/v1/", "API_URL_PREFIX": "/api/v1/",

View File

@@ -2,6 +2,7 @@
"MEMPOOL": { "MEMPOOL": {
"NETWORK": "__MEMPOOL_NETWORK__", "NETWORK": "__MEMPOOL_NETWORK__",
"BACKEND": "__MEMPOOL_BACKEND__", "BACKEND": "__MEMPOOL_BACKEND__",
"ENABLED": __MEMPOOL_ENABLED__,
"HTTP_PORT": __MEMPOOL_HTTP_PORT__, "HTTP_PORT": __MEMPOOL_HTTP_PORT__,
"SPAWN_CLUSTER_PROCS": __MEMPOOL_SPAWN_CLUSTER_PROCS__, "SPAWN_CLUSTER_PROCS": __MEMPOOL_SPAWN_CLUSTER_PROCS__,
"API_URL_PREFIX": "__MEMPOOL_API_URL_PREFIX__", "API_URL_PREFIX": "__MEMPOOL_API_URL_PREFIX__",

View File

@@ -3,6 +3,7 @@
# MEMPOOL # MEMPOOL
__MEMPOOL_NETWORK__=${MEMPOOL_NETWORK:=mainnet} __MEMPOOL_NETWORK__=${MEMPOOL_NETWORK:=mainnet}
__MEMPOOL_BACKEND__=${MEMPOOL_BACKEND:=electrum} __MEMPOOL_BACKEND__=${MEMPOOL_BACKEND:=electrum}
__MEMPOOL_ENABLED__=${MEMPOOL_ENABLED:=true}
__MEMPOOL_HTTP_PORT__=${BACKEND_HTTP_PORT:=8999} __MEMPOOL_HTTP_PORT__=${BACKEND_HTTP_PORT:=8999}
__MEMPOOL_SPAWN_CLUSTER_PROCS__=${MEMPOOL_SPAWN_CLUSTER_PROCS:=0} __MEMPOOL_SPAWN_CLUSTER_PROCS__=${MEMPOOL_SPAWN_CLUSTER_PROCS:=0}
__MEMPOOL_API_URL_PREFIX__=${MEMPOOL_API_URL_PREFIX:=/api/v1/} __MEMPOOL_API_URL_PREFIX__=${MEMPOOL_API_URL_PREFIX:=/api/v1/}
@@ -111,6 +112,7 @@ mkdir -p "${__MEMPOOL_CACHE_DIR__}"
sed -i "s/__MEMPOOL_NETWORK__/${__MEMPOOL_NETWORK__}/g" mempool-config.json sed -i "s/__MEMPOOL_NETWORK__/${__MEMPOOL_NETWORK__}/g" mempool-config.json
sed -i "s/__MEMPOOL_BACKEND__/${__MEMPOOL_BACKEND__}/g" mempool-config.json sed -i "s/__MEMPOOL_BACKEND__/${__MEMPOOL_BACKEND__}/g" mempool-config.json
sed -i "s/__MEMPOOL_ENABLED__/${__MEMPOOL_ENABLED__}/g" mempool-config.json
sed -i "s/__MEMPOOL_HTTP_PORT__/${__MEMPOOL_HTTP_PORT__}/g" mempool-config.json sed -i "s/__MEMPOOL_HTTP_PORT__/${__MEMPOOL_HTTP_PORT__}/g" mempool-config.json
sed -i "s/__MEMPOOL_SPAWN_CLUSTER_PROCS__/${__MEMPOOL_SPAWN_CLUSTER_PROCS__}/g" mempool-config.json sed -i "s/__MEMPOOL_SPAWN_CLUSTER_PROCS__/${__MEMPOOL_SPAWN_CLUSTER_PROCS__}/g" mempool-config.json
sed -i "s!__MEMPOOL_API_URL_PREFIX__!${__MEMPOOL_API_URL_PREFIX__}!g" mempool-config.json sed -i "s!__MEMPOOL_API_URL_PREFIX__!${__MEMPOOL_API_URL_PREFIX__}!g" mempool-config.json

View File

@@ -8,7 +8,9 @@ WORKDIR /build
COPY . . COPY . .
RUN apt-get update RUN apt-get update
RUN apt-get install -y build-essential rsync RUN apt-get install -y build-essential rsync
RUN cp mempool-frontend-config.sample.json mempool-frontend-config.json
RUN npm install --omit=dev --omit=optional RUN npm install --omit=dev --omit=optional
RUN npm run build RUN npm run build
FROM nginx:1.17.8-alpine FROM nginx:1.17.8-alpine
@@ -28,7 +30,9 @@ RUN chown -R 1000:1000 /patch && chmod -R 755 /patch && \
chown -R 1000:1000 /var/cache/nginx && \ chown -R 1000:1000 /var/cache/nginx && \
chown -R 1000:1000 /var/log/nginx && \ chown -R 1000:1000 /var/log/nginx && \
chown -R 1000:1000 /etc/nginx/nginx.conf && \ chown -R 1000:1000 /etc/nginx/nginx.conf && \
chown -R 1000:1000 /etc/nginx/conf.d chown -R 1000:1000 /etc/nginx/conf.d && \
chown -R 1000:1000 /var/www/mempool
RUN touch /var/run/nginx.pid && \ RUN touch /var/run/nginx.pid && \
chown -R 1000:1000 /var/run/nginx.pid chown -R 1000:1000 /var/run/nginx.pid

View File

@@ -10,4 +10,51 @@ cp /etc/nginx/nginx.conf /patch/nginx.conf
sed -i "s/__MEMPOOL_FRONTEND_HTTP_PORT__/${__MEMPOOL_FRONTEND_HTTP_PORT__}/g" /patch/nginx.conf sed -i "s/__MEMPOOL_FRONTEND_HTTP_PORT__/${__MEMPOOL_FRONTEND_HTTP_PORT__}/g" /patch/nginx.conf
cat /patch/nginx.conf > /etc/nginx/nginx.conf cat /patch/nginx.conf > /etc/nginx/nginx.conf
# Runtime overrides - read env vars defined in docker compose
__TESTNET_ENABLED__=${TESTNET_ENABLED:=false}
__SIGNET_ENABLED__=${SIGNET_ENABLED:=false}
__LIQUID_ENABLED__=${LIQUID_EANBLED:=false}
__LIQUID_TESTNET_ENABLED__=${LIQUID_TESTNET_ENABLED:=false}
__BISQ_ENABLED__=${BISQ_ENABLED:=false}
__BISQ_SEPARATE_BACKEND__=${BISQ_SEPARATE_BACKEND:=false}
__ITEMS_PER_PAGE__=${ITEMS_PER_PAGE:=10}
__KEEP_BLOCKS_AMOUNT__=${KEEP_BLOCKS_AMOUNT:=8}
__NGINX_PROTOCOL__=${NGINX_PROTOCOL:=http}
__NGINX_HOSTNAME__=${NGINX_HOSTNAME:=localhost}
__NGINX_PORT__=${NGINX_PORT:=8999}
__BLOCK_WEIGHT_UNITS__=${BLOCK_WEIGHT_UNITS:=4000000}
__MEMPOOL_BLOCKS_AMOUNT__=${MEMPOOL_BLOCKS_AMOUNT:=8}
__BASE_MODULE__=${BASE_MODULE:=mempool}
__MEMPOOL_WEBSITE_URL__=${MEMPOOL_WEBSITE_URL:=https://mempool.space}
__LIQUID_WEBSITE_URL__=${LIQUID_WEBSITE_URL:=https://liquid.network}
__BISQ_WEBSITE_URL__=${BISQ_WEBSITE_URL:=https://bisq.markets}
__MINING_DASHBOARD__=${MINING_DASHBOARD:=true}
__LIGHTNING__=${LIGHTNING:=false}
# Export as environment variables to be used by envsubst
export __TESTNET_ENABLED__
export __SIGNET_ENABLED__
export __LIQUID_ENABLED__
export __LIQUID_TESTNET_ENABLED__
export __BISQ_ENABLED__
export __BISQ_SEPARATE_BACKEND__
export __ITEMS_PER_PAGE__
export __KEEP_BLOCKS_AMOUNT__
export __NGINX_PROTOCOL__
export __NGINX_HOSTNAME__
export __NGINX_PORT__
export __BLOCK_WEIGHT_UNITS__
export __MEMPOOL_BLOCKS_AMOUNT__
export __BASE_MODULE__
export __MEMPOOL_WEBSITE_URL__
export __LIQUID_WEBSITE_URL__
export __BISQ_WEBSITE_URL__
export __MINING_DASHBOARD__
export __LIGHTNING__
folder=$(find /var/www/mempool -name "config.js" | xargs dirname)
echo ${folder}
envsubst < ${folder}/config.template.js > ${folder}/config.js
exec "$@" exec "$@"

View File

@@ -113,7 +113,7 @@ https://www.transifex.com/mempool/mempool/dashboard/
* French @Bayernatoor * French @Bayernatoor
* Korean @kcalvinalvinn * Korean @kcalvinalvinn
* Italian @HodlBits * Italian @HodlBits
* Hebrew @Sh0ham * Hebrew @rapidlab309
* Georgian @wyd_idk * Georgian @wyd_idk
* Hungarian @btcdragonlord * Hungarian @btcdragonlord
* Dutch @m__btc * Dutch @m__btc

View File

@@ -152,15 +152,14 @@
"assets": [ "assets": [
"src/favicon.ico", "src/favicon.ico",
"src/resources", "src/resources",
"src/robots.txt" "src/robots.txt",
"src/config.js",
"src/config.template.js"
], ],
"styles": [ "styles": [
"src/styles.scss", "src/styles.scss",
"node_modules/@fortawesome/fontawesome-svg-core/styles.css" "node_modules/@fortawesome/fontawesome-svg-core/styles.css"
], ],
"scripts": [
"generated-config.js"
],
"vendorChunk": true, "vendorChunk": true,
"extractLicenses": false, "extractLicenses": false,
"buildOptimizer": false, "buildOptimizer": false,
@@ -222,6 +221,10 @@
"proxyConfig": "proxy.conf.local.js", "proxyConfig": "proxy.conf.local.js",
"verbose": true "verbose": true
}, },
"local-esplora": {
"proxyConfig": "proxy.conf.local-esplora.js",
"verbose": true
},
"mixed": { "mixed": {
"proxyConfig": "proxy.conf.mixed.js", "proxyConfig": "proxy.conf.mixed.js",
"verbose": true "verbose": true

View File

@@ -2,7 +2,8 @@ var fs = require('fs');
const { spawnSync } = require('child_process'); const { spawnSync } = require('child_process');
const CONFIG_FILE_NAME = 'mempool-frontend-config.json'; const CONFIG_FILE_NAME = 'mempool-frontend-config.json';
const GENERATED_CONFIG_FILE_NAME = 'generated-config.js'; const GENERATED_CONFIG_FILE_NAME = 'src/resources/config.js';
const GENERATED_TEMPLATE_CONFIG_FILE_NAME = 'src/resources/config.template.js';
let settings = []; let settings = [];
let configContent = {}; let configContent = {};
@@ -67,10 +68,17 @@ if (process.env.DOCKER_COMMIT_HASH) {
const newConfig = `(function (window) { const newConfig = `(function (window) {
window.__env = window.__env || {};${settings.reduce((str, obj) => `${str} window.__env = window.__env || {};${settings.reduce((str, obj) => `${str}
window.__env.${obj.key} = ${ typeof obj.value === 'string' ? `'${obj.value}'` : obj.value };`, '')} window.__env.${obj.key} = ${typeof obj.value === 'string' ? `'${obj.value}'` : obj.value};`, '')}
window.__env.GIT_COMMIT_HASH = '${gitCommitHash}'; window.__env.GIT_COMMIT_HASH = '${gitCommitHash}';
window.__env.PACKAGE_JSON_VERSION = '${packetJsonVersion}'; window.__env.PACKAGE_JSON_VERSION = '${packetJsonVersion}';
}(global || this));`; }(this));`;
const newConfigTemplate = `(function (window) {
window.__env = window.__env || {};${settings.reduce((str, obj) => `${str}
window.__env.${obj.key} = ${typeof obj.value === 'string' ? `'\${__${obj.key}__}'` : `\${__${obj.key}__}`};`, '')}
window.__env.GIT_COMMIT_HASH = '${gitCommitHash}';
window.__env.PACKAGE_JSON_VERSION = '${packetJsonVersion}';
}(this));`;
function readConfig(path) { function readConfig(path) {
try { try {
@@ -89,6 +97,16 @@ function writeConfig(path, config) {
} }
} }
function writeConfigTemplate(path, config) {
try {
fs.writeFileSync(path, config, 'utf8');
} catch (e) {
throw new Error(e);
}
}
writeConfigTemplate(GENERATED_TEMPLATE_CONFIG_FILE_NAME, newConfigTemplate);
const currentConfig = readConfig(GENERATED_CONFIG_FILE_NAME); const currentConfig = readConfig(GENERATED_CONFIG_FILE_NAME);
if (currentConfig && currentConfig === newConfig) { if (currentConfig && currentConfig === newConfig) {
@@ -106,4 +124,4 @@ if (currentConfig && currentConfig === newConfig) {
console.log('NEW CONFIG: ', newConfig); console.log('NEW CONFIG: ', newConfig);
writeConfig(GENERATED_CONFIG_FILE_NAME, newConfig); writeConfig(GENERATED_CONFIG_FILE_NAME, newConfig);
console.log(`${GENERATED_CONFIG_FILE_NAME} file updated`); console.log(`${GENERATED_CONFIG_FILE_NAME} file updated`);
}; }

View File

@@ -22,13 +22,14 @@
"scripts": { "scripts": {
"ng": "./node_modules/@angular/cli/bin/ng.js", "ng": "./node_modules/@angular/cli/bin/ng.js",
"tsc": "./node_modules/typescript/bin/tsc", "tsc": "./node_modules/typescript/bin/tsc",
"i18n-extract-from-source": "./node_modules/@angular/cli/bin/ng extract-i18n --out-file ./src/locale/messages.xlf", "i18n-extract-from-source": "npm run ng -- extract-i18n --out-file ./src/locale/messages.xlf",
"i18n-pull-from-transifex": "tx pull -a --parallel --minimum-perc 1 --force", "i18n-pull-from-transifex": "tx pull -a --parallel --minimum-perc 1 --force",
"serve": "npm run generate-config && npm run ng -- serve -c local", "serve": "npm run generate-config && npm run ng -- serve -c local",
"serve:stg": "npm run generate-config && npm run ng -- serve -c staging", "serve:stg": "npm run generate-config && npm run ng -- serve -c staging",
"serve:local-prod": "npm run generate-config && npm run ng -- serve -c local-prod", "serve:local-prod": "npm run generate-config && npm run ng -- serve -c local-prod",
"serve:local-staging": "npm run generate-config && npm run ng -- serve -c local-staging", "serve:local-staging": "npm run generate-config && npm run ng -- serve -c local-staging",
"start": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c local", "start": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c local",
"start:local-esplora": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c local-esplora",
"start:stg": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c staging", "start:stg": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c staging",
"start:local-prod": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c local-prod", "start:local-prod": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c local-prod",
"start:local-staging": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c local-staging", "start:local-staging": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c local-staging",

View File

@@ -0,0 +1,137 @@
const fs = require('fs');
const FRONTEND_CONFIG_FILE_NAME = 'mempool-frontend-config.json';
let configContent;
// Read frontend config
try {
const rawConfig = fs.readFileSync(FRONTEND_CONFIG_FILE_NAME);
configContent = JSON.parse(rawConfig);
console.log(`${FRONTEND_CONFIG_FILE_NAME} file found, using provided config`);
} catch (e) {
console.log(e);
if (e.code !== 'ENOENT') {
throw new Error(e);
} else {
console.log(`${FRONTEND_CONFIG_FILE_NAME} file not found, using default config`);
}
}
let PROXY_CONFIG = [];
if (configContent && configContent.BASE_MODULE === 'liquid') {
PROXY_CONFIG.push(...[
{
context: ['/liquid/api/v1/**'],
target: `http://127.0.0.1:8999`,
secure: false,
ws: true,
changeOrigin: true,
proxyTimeout: 30000,
pathRewrite: {
"^/liquid": ""
},
},
{
context: ['/liquid/api/**'],
target: `http://127.0.0.1:3000`,
secure: false,
changeOrigin: true,
proxyTimeout: 30000,
pathRewrite: {
"^/liquid/api/": ""
},
},
{
context: ['/liquidtestnet/api/v1/**'],
target: `http://127.0.0.1:8999`,
secure: false,
ws: true,
changeOrigin: true,
proxyTimeout: 30000,
pathRewrite: {
"^/liquidtestnet": ""
},
},
{
context: ['/liquidtestnet/api/**'],
target: `http://127.0.0.1:3000`,
secure: false,
changeOrigin: true,
proxyTimeout: 30000,
pathRewrite: {
"^/liquidtestnet/api/": "/"
},
},
]);
}
if (configContent && configContent.BASE_MODULE === 'bisq') {
PROXY_CONFIG.push(...[
{
context: ['/bisq/api/v1/ws'],
target: `http://127.0.0.1:8999`,
secure: false,
ws: true,
changeOrigin: true,
proxyTimeout: 30000,
pathRewrite: {
"^/bisq": ""
},
},
{
context: ['/bisq/api/v1/**'],
target: `http://127.0.0.1:8999`,
secure: false,
changeOrigin: true,
proxyTimeout: 30000,
},
{
context: ['/bisq/api/**'],
target: `http://127.0.0.1:8999`,
secure: false,
changeOrigin: true,
proxyTimeout: 30000,
pathRewrite: {
"^/bisq/api/": "/api/v1/bisq/"
},
}
]);
}
PROXY_CONFIG.push(...[
{
context: ['/testnet/api/v1/lightning/**'],
target: `http://127.0.0.1:8999`,
secure: false,
changeOrigin: true,
proxyTimeout: 30000,
pathRewrite: {
"^/testnet": ""
},
},
{
context: ['/api/v1/**'],
target: `http://127.0.0.1:8999`,
secure: false,
ws: true,
changeOrigin: true,
proxyTimeout: 30000,
},
{
context: ['/api/**'],
target: `http://127.0.0.1:3000`,
secure: false,
changeOrigin: true,
proxyTimeout: 30000,
pathRewrite: {
"^/api": ""
},
}
]);
console.log(PROXY_CONFIG);
module.exports = PROXY_CONFIG;

View File

@@ -3,9 +3,9 @@ const fs = require('fs');
let PROXY_CONFIG = require('./proxy.conf'); let PROXY_CONFIG = require('./proxy.conf');
PROXY_CONFIG.forEach(entry => { PROXY_CONFIG.forEach(entry => {
entry.target = entry.target.replace("mempool.space", "mempool.ninja"); entry.target = entry.target.replace("mempool.space", "mempool-staging.fra.mempool.space");
entry.target = entry.target.replace("liquid.network", "liquid.place"); entry.target = entry.target.replace("liquid.network", "liquid-staging.fra.mempool.space");
entry.target = entry.target.replace("bisq.markets", "bisq.ninja"); entry.target = entry.target.replace("bisq.markets", "bisq-staging.fra.mempool.space");
}); });
module.exports = PROXY_CONFIG; module.exports = PROXY_CONFIG;

View File

@@ -74,12 +74,14 @@ let routes: Routes = [
children: [], children: [],
component: AddressComponent, component: AddressComponent,
data: { data: {
ogImage: true ogImage: true,
networkSpecific: true,
} }
}, },
{ {
path: 'tx', path: 'tx',
component: StartComponent, component: StartComponent,
data: { networkSpecific: true },
children: [ children: [
{ {
path: ':id', path: ':id',
@@ -90,6 +92,7 @@ let routes: Routes = [
{ {
path: 'block', path: 'block',
component: StartComponent, component: StartComponent,
data: { networkSpecific: true },
children: [ children: [
{ {
path: ':id', path: ':id',
@@ -102,6 +105,7 @@ let routes: Routes = [
}, },
{ {
path: 'block-audit', path: 'block-audit',
data: { networkSpecific: true },
children: [ children: [
{ {
path: ':id', path: ':id',
@@ -121,12 +125,13 @@ let routes: Routes = [
{ {
path: 'lightning', path: 'lightning',
loadChildren: () => import('./lightning/lightning.module').then(m => m.LightningModule), loadChildren: () => import('./lightning/lightning.module').then(m => m.LightningModule),
data: { preload: browserWindowEnv && browserWindowEnv.LIGHTNING === true }, data: { preload: browserWindowEnv && browserWindowEnv.LIGHTNING === true, networks: ['bitcoin'] },
}, },
], ],
}, },
{ {
path: 'status', path: 'status',
data: { networks: ['bitcoin', 'liquid'] },
component: StatusViewComponent component: StatusViewComponent
}, },
{ {
@@ -185,11 +190,13 @@ let routes: Routes = [
children: [], children: [],
component: AddressComponent, component: AddressComponent,
data: { data: {
ogImage: true ogImage: true,
networkSpecific: true,
} }
}, },
{ {
path: 'tx', path: 'tx',
data: { networkSpecific: true },
component: StartComponent, component: StartComponent,
children: [ children: [
{ {
@@ -200,6 +207,7 @@ let routes: Routes = [
}, },
{ {
path: 'block', path: 'block',
data: { networkSpecific: true },
component: StartComponent, component: StartComponent,
children: [ children: [
{ {
@@ -213,6 +221,7 @@ let routes: Routes = [
}, },
{ {
path: 'block-audit', path: 'block-audit',
data: { networkSpecific: true },
children: [ children: [
{ {
path: ':id', path: ':id',
@@ -230,12 +239,14 @@ let routes: Routes = [
}, },
{ {
path: 'lightning', path: 'lightning',
data: { networks: ['bitcoin'] },
loadChildren: () => import('./lightning/lightning.module').then(m => m.LightningModule) loadChildren: () => import('./lightning/lightning.module').then(m => m.LightningModule)
}, },
], ],
}, },
{ {
path: 'status', path: 'status',
data: { networks: ['bitcoin', 'liquid'] },
component: StatusViewComponent component: StatusViewComponent
}, },
{ {
@@ -291,11 +302,13 @@ let routes: Routes = [
children: [], children: [],
component: AddressComponent, component: AddressComponent,
data: { data: {
ogImage: true ogImage: true,
networkSpecific: true,
} }
}, },
{ {
path: 'tx', path: 'tx',
data: { networkSpecific: true },
component: StartComponent, component: StartComponent,
children: [ children: [
{ {
@@ -306,6 +319,7 @@ let routes: Routes = [
}, },
{ {
path: 'block', path: 'block',
data: { networkSpecific: true },
component: StartComponent, component: StartComponent,
children: [ children: [
{ {
@@ -319,6 +333,7 @@ let routes: Routes = [
}, },
{ {
path: 'block-audit', path: 'block-audit',
data: { networkSpecific: true },
children: [ children: [
{ {
path: ':id', path: ':id',
@@ -336,6 +351,7 @@ let routes: Routes = [
}, },
{ {
path: 'lightning', path: 'lightning',
data: { networks: ['bitcoin'] },
loadChildren: () => import('./lightning/lightning.module').then(m => m.LightningModule) loadChildren: () => import('./lightning/lightning.module').then(m => m.LightningModule)
}, },
], ],
@@ -359,6 +375,7 @@ let routes: Routes = [
}, },
{ {
path: 'status', path: 'status',
data: { networks: ['bitcoin', 'liquid'] },
component: StatusViewComponent component: StatusViewComponent
}, },
{ {
@@ -422,11 +439,13 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') {
children: [], children: [],
component: AddressComponent, component: AddressComponent,
data: { data: {
ogImage: true ogImage: true,
networkSpecific: true,
} }
}, },
{ {
path: 'tx', path: 'tx',
data: { networkSpecific: true },
component: StartComponent, component: StartComponent,
children: [ children: [
{ {
@@ -437,6 +456,7 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') {
}, },
{ {
path: 'block', path: 'block',
data: { networkSpecific: true },
component: StartComponent, component: StartComponent,
children: [ children: [
{ {
@@ -450,18 +470,22 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') {
}, },
{ {
path: 'assets', path: 'assets',
data: { networks: ['liquid'] },
component: AssetsNavComponent, component: AssetsNavComponent,
children: [ children: [
{ {
path: 'all', path: 'all',
data: { networks: ['liquid'] },
component: AssetsComponent, component: AssetsComponent,
}, },
{ {
path: 'asset/:id', path: 'asset/:id',
data: { networkSpecific: true },
component: AssetComponent component: AssetComponent
}, },
{ {
path: 'group/:id', path: 'group/:id',
data: { networkSpecific: true },
component: AssetGroupComponent component: AssetGroupComponent
}, },
{ {
@@ -482,6 +506,7 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') {
}, },
{ {
path: 'status', path: 'status',
data: { networks: ['bitcoin', 'liquid'] },
component: StatusViewComponent component: StatusViewComponent
}, },
{ {
@@ -532,11 +557,13 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') {
children: [], children: [],
component: AddressComponent, component: AddressComponent,
data: { data: {
ogImage: true ogImage: true,
networkSpecific: true,
} }
}, },
{ {
path: 'tx', path: 'tx',
data: { networkSpecific: true },
component: StartComponent, component: StartComponent,
children: [ children: [
{ {
@@ -547,6 +574,7 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') {
}, },
{ {
path: 'block', path: 'block',
data: { networkSpecific: true },
component: StartComponent, component: StartComponent,
children: [ children: [
{ {
@@ -560,22 +588,27 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') {
}, },
{ {
path: 'assets', path: 'assets',
data: { networks: ['liquid'] },
component: AssetsNavComponent, component: AssetsNavComponent,
children: [ children: [
{ {
path: 'featured', path: 'featured',
data: { networkSpecific: true },
component: AssetsFeaturedComponent, component: AssetsFeaturedComponent,
}, },
{ {
path: 'all', path: 'all',
data: { networks: ['liquid'] },
component: AssetsComponent, component: AssetsComponent,
}, },
{ {
path: 'asset/:id', path: 'asset/:id',
data: { networkSpecific: true },
component: AssetComponent component: AssetComponent
}, },
{ {
path: 'group/:id', path: 'group/:id',
data: { networkSpecific: true },
component: AssetGroupComponent component: AssetGroupComponent
}, },
{ {
@@ -609,6 +642,7 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') {
}, },
{ {
path: 'status', path: 'status',
data: { networks: ['bitcoin', 'liquid']},
component: StatusViewComponent component: StatusViewComponent
}, },
{ {

View File

@@ -20,14 +20,17 @@ const routes: Routes = [
}, },
{ {
path: 'markets', path: 'markets',
data: { networks: ['bisq'] },
component: BisqDashboardComponent, component: BisqDashboardComponent,
}, },
{ {
path: 'transactions', path: 'transactions',
data: { networks: ['bisq'] },
component: BisqTransactionsComponent component: BisqTransactionsComponent
}, },
{ {
path: 'market/:pair', path: 'market/:pair',
data: { networkSpecific: true },
component: BisqMarketComponent, component: BisqMarketComponent,
}, },
{ {
@@ -36,6 +39,7 @@ const routes: Routes = [
}, },
{ {
path: 'tx/:id', path: 'tx/:id',
data: { networkSpecific: true },
component: BisqTransactionComponent component: BisqTransactionComponent
}, },
{ {
@@ -45,14 +49,17 @@ const routes: Routes = [
}, },
{ {
path: 'block/:id', path: 'block/:id',
data: { networkSpecific: true },
component: BisqBlockComponent, component: BisqBlockComponent,
}, },
{ {
path: 'address/:id', path: 'address/:id',
data: { networkSpecific: true },
component: BisqAddressComponent, component: BisqAddressComponent,
}, },
{ {
path: 'stats', path: 'stats',
data: { networks: ['bisq'] },
component: BisqStatsComponent, component: BisqStatsComponent,
}, },
{ {

View File

@@ -25,6 +25,8 @@ export class AppComponent implements OnInit {
if (this.locale.startsWith('ar') || this.locale.startsWith('fa') || this.locale.startsWith('he')) { if (this.locale.startsWith('ar') || this.locale.startsWith('fa') || this.locale.startsWith('he')) {
this.dir = 'rtl'; this.dir = 'rtl';
this.class = 'rtl-layout'; this.class = 'rtl-layout';
} else {
this.class = 'ltr-layout';
} }
tooltipConfig.animation = false; tooltipConfig.animation = false;

View File

@@ -44,13 +44,13 @@
<app-svg-images name="bisq" width="20" height="20" viewBox="0 0 75 75" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> <app-svg-images name="bisq" width="20" height="20" viewBox="0 0 75 75" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images>
</button> </button>
<div ngbDropdownMenu [ngClass]="{'dropdown-menu-right' : isMobile}"> <div ngbDropdownMenu [ngClass]="{'dropdown-menu-right' : isMobile}">
<a [href]="env.MEMPOOL_WEBSITE_URL + urlLanguage" ngbDropdownItem class="mainnet"><app-svg-images name="bitcoin" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Mainnet</a> <a [href]="env.MEMPOOL_WEBSITE_URL + urlLanguage + (networkPaths['mainnet'] || '/')" ngbDropdownItem class="mainnet"><app-svg-images name="bitcoin" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Mainnet</a>
<a [href]="env.MEMPOOL_WEBSITE_URL + urlLanguage + '/signet'" ngbDropdownItem *ngIf="env.SIGNET_ENABLED" class="signet"><app-svg-images name="signet" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Signet</a> <a [href]="env.MEMPOOL_WEBSITE_URL + urlLanguage + (networkPaths['signet'] || '/signet')" ngbDropdownItem *ngIf="env.SIGNET_ENABLED" class="signet"><app-svg-images name="signet" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Signet</a>
<a [href]="env.MEMPOOL_WEBSITE_URL + urlLanguage + '/testnet'" ngbDropdownItem *ngIf="env.TESTNET_ENABLED" class="testnet"><app-svg-images name="testnet" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Testnet</a> <a [href]="env.MEMPOOL_WEBSITE_URL + urlLanguage + (networkPaths['testnet'] || '/testnet')" ngbDropdownItem *ngIf="env.TESTNET_ENABLED" class="testnet"><app-svg-images name="testnet" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Testnet</a>
<h6 class="dropdown-header" i18n="master-page.layer2-networks-header">Layer 2 Networks</h6> <h6 class="dropdown-header" i18n="master-page.layer2-networks-header">Layer 2 Networks</h6>
<a ngbDropdownItem class="mainnet active" routerLink="/"><app-svg-images name="bisq" width="20" height="20" viewBox="0 0 75 75" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Bisq</a> <a ngbDropdownItem class="mainnet active" [routerLink]="networkPaths['bisq'] || '/'"><app-svg-images name="bisq" width="20" height="20" viewBox="0 0 75 75" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Bisq</a>
<a [href]="env.LIQUID_WEBSITE_URL + urlLanguage" ngbDropdownItem *ngIf="env.LIQUID_ENABLED" class="liquid"><app-svg-images name="liquid" width="22" height="22" viewBox="0 0 125 125" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Liquid</a> <a [href]="env.LIQUID_WEBSITE_URL + urlLanguage + (networkPaths['liquid'] || '/')" ngbDropdownItem *ngIf="env.LIQUID_ENABLED" class="liquid"><app-svg-images name="liquid" width="22" height="22" viewBox="0 0 125 125" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Liquid</a>
<a [href]="env.LIQUID_WEBSITE_URL + urlLanguage + '/testnet'" ngbDropdownItem *ngIf="env.LIQUID_TESTNET_ENABLED" class="liquidtestnet"><app-svg-images name="liquidtestnet" width="22" height="22" viewBox="0 0 125 125" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Liquid Testnet</a> <a [href]="env.LIQUID_WEBSITE_URL + urlLanguage + (networkPaths['liquidtestnet'] || '/testnet')" ngbDropdownItem *ngIf="env.LIQUID_TESTNET_ENABLED" class="liquidtestnet"><app-svg-images name="liquidtestnet" width="22" height="22" viewBox="0 0 125 125" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Liquid Testnet</a>
</div> </div>
</div> </div>

View File

@@ -3,6 +3,7 @@ import { Env, StateService } from '../../services/state.service';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { LanguageService } from '../../services/language.service'; import { LanguageService } from '../../services/language.service';
import { EnterpriseService } from '../../services/enterprise.service'; import { EnterpriseService } from '../../services/enterprise.service';
import { NavigationService } from '../../services/navigation.service';
@Component({ @Component({
selector: 'app-bisq-master-page', selector: 'app-bisq-master-page',
@@ -15,17 +16,23 @@ export class BisqMasterPageComponent implements OnInit {
env: Env; env: Env;
isMobile = window.innerWidth <= 767.98; isMobile = window.innerWidth <= 767.98;
urlLanguage: string; urlLanguage: string;
networkPaths: { [network: string]: string };
constructor( constructor(
private stateService: StateService, private stateService: StateService,
private languageService: LanguageService, private languageService: LanguageService,
private enterpriseService: EnterpriseService, private enterpriseService: EnterpriseService,
private navigationService: NavigationService,
) { } ) { }
ngOnInit() { ngOnInit() {
this.env = this.stateService.env; this.env = this.stateService.env;
this.connectionState$ = this.stateService.connectionState$; this.connectionState$ = this.stateService.connectionState$;
this.urlLanguage = this.languageService.getLanguageForUrl(); this.urlLanguage = this.languageService.getLanguageForUrl();
this.navigationService.subnetPaths.subscribe((paths) => {
console.log('network paths updated...');
this.networkPaths = paths;
});
} }
collapse(): void { collapse(): void {

View File

@@ -1,21 +1,22 @@
<div class="container-xl" (window:resize)="onResize($event)"> <div class="container-xl" (window:resize)="onResize($event)">
<div *ngIf="(auditObservable$ | async) as blockAudit; else skeleton"> <div class="title-block" id="block">
<div class="title-block" id="block"> <h1>
<h1> <span class="next-previous-blocks">
<span class="next-previous-blocks"> <span i18n="shared.block-audit-title">Block Audit</span>
<span i18n="shared.block-title">Block </span> &nbsp;
&nbsp; <a *ngIf="blockAudit" [routerLink]="['/block/' | relativeUrl, blockHash]">{{ blockAudit.height }}</a>
<a [routerLink]="['/block/' | relativeUrl, blockAudit.id]">{{ blockAudit.height }}</a> &nbsp;
&nbsp; </span>
<span i18n="shared.template-vs-mined">Template vs Mined</span> </h1>
</span>
</h1>
<div class="grow"></div> <div class="grow"></div>
<button [routerLink]="['/' | relativeUrl]" class="btn btn-sm">&#10005;</button> <button [routerLink]="['/block/' | relativeUrl, blockHash]" class="btn btn-sm">&#10005;</button>
</div> </div>
<div *ngIf="!error && !isLoading">
<!-- OVERVIEW --> <!-- OVERVIEW -->
<div class="box mb-3"> <div class="box mb-3">
@@ -26,8 +27,8 @@
<tbody> <tbody>
<tr> <tr>
<td class="td-width" i18n="block.hash">Hash</td> <td class="td-width" i18n="block.hash">Hash</td>
<td><a [routerLink]="['/block/' | relativeUrl, blockAudit.id]" title="{{ blockAudit.id }}">{{ blockAudit.id | shortenString : 13 }}</a> <td><a [routerLink]="['/block/' | relativeUrl, blockHash]" title="{{ blockHash }}">{{ blockHash | shortenString : 13 }}</a>
<app-clipboard class="d-none d-sm-inline-block" [text]="blockAudit.id"></app-clipboard> <app-clipboard class="d-none d-sm-inline-block" [text]="blockHash"></app-clipboard>
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -40,6 +41,10 @@
</div> </div>
</td> </td>
</tr> </tr>
<tr>
<td class="td-width" i18n="shared.transaction-count">Transactions</td>
<td>{{ blockAudit.tx_count }}</td>
</tr>
<tr> <tr>
<td i18n="blockAudit.size">Size</td> <td i18n="blockAudit.size">Size</td>
<td [innerHTML]="'&lrm;' + (blockAudit.size | bytes: 2)"></td> <td [innerHTML]="'&lrm;' + (blockAudit.size | bytes: 2)"></td>
@@ -57,21 +62,25 @@
<table class="table table-borderless table-striped"> <table class="table table-borderless table-striped">
<tbody> <tbody>
<tr> <tr>
<td class="td-width" i18n="shared.transaction-count">Transactions</td> <td i18n="block.health">Block health</td>
<td>{{ blockAudit.tx_count }}</td>
</tr>
<tr>
<td i18n="block.match-rate">Match rate</td>
<td>{{ blockAudit.matchRate }}%</td> <td>{{ blockAudit.matchRate }}%</td>
</tr> </tr>
<tr> <tr>
<td i18n="block.missing-txs">Missing txs</td> <td i18n="block.missing-txs">Removed txs</td>
<td>{{ blockAudit.missingTxs.length }}</td> <td>{{ blockAudit.missingTxs.length }}</td>
</tr> </tr>
<tr>
<td i18n="block.missing-txs">Omitted txs</td>
<td>{{ numMissing }}</td>
</tr>
<tr> <tr>
<td i18n="block.added-txs">Added txs</td> <td i18n="block.added-txs">Added txs</td>
<td>{{ blockAudit.addedTxs.length }}</td> <td>{{ blockAudit.addedTxs.length }}</td>
</tr> </tr>
<tr>
<td i18n="block.missing-txs">Included txs</td>
<td>{{ numUnexpected }}</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -79,33 +88,110 @@
</div> <!-- box --> </div> <!-- box -->
<!-- ADDED vs MISSING button --> <!-- ADDED vs MISSING button -->
<div class="d-flex justify-content-center menu mt-3" *ngIf="isMobile"> <div class="d-flex justify-content-center menu mt-3 mb-3" *ngIf="isMobile">
<a routerLinkActive="active" class="btn btn-primary w-50 mr-1 ml-1 menu-button" i18n="block.missing-txs" <a class="btn btn-primary w-50 mr-1 ml-1 menu-button" [class.active]="mode === 'projected'" i18n="block.projected"
fragment="missing" (click)="changeMode('missing')">Missing</a> fragment="projected" (click)="changeMode('projected')">Projected</a>
<a routerLinkActive="active" class="btn btn-primary w-50 mr-1 ml-1 menu-button" i18n="block.added-txs" <a class="btn btn-primary w-50 mr-1 ml-1 menu-button" [class.active]="mode === 'actual'" i18n="block.actual"
fragment="added" (click)="changeMode('added')">Added</a> fragment="actual" (click)="changeMode('actual')">Actual</a>
</div> </div>
</div> </div>
<ng-template [ngIf]="!error && isLoading">
<div class="title-block" id="block">
<h1>
<span class="next-previous-blocks">
<span i18n="shared.block-audit-title">Block Audit</span>
&nbsp;
<a *ngIf="blockAudit" [routerLink]="['/block/' | relativeUrl, blockHash]">{{ blockAudit.height }}</a>
&nbsp;
</span>
</h1>
<div class="grow"></div>
<button [routerLink]="['/' | relativeUrl]" class="btn btn-sm">&#10005;</button>
</div>
<!-- OVERVIEW -->
<div class="box mb-3">
<div class="row">
<!-- LEFT COLUMN -->
<div class="col-sm">
<table class="table table-borderless table-striped">
<tbody>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
</tbody>
</table>
</div>
<!-- RIGHT COLUMN -->
<div class="col-sm">
<table class="table table-borderless table-striped">
<tbody>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
<tr><td class="td-width" colspan="2"><span class="skeleton-loader"></span></td></tr>
</tbody>
</table>
</div>
</div> <!-- row -->
</div> <!-- box -->
<!-- ADDED vs MISSING button -->
<div class="d-flex justify-content-center menu mt-3 mb-3" *ngIf="isMobile">
<a class="btn btn-primary w-50 mr-1 ml-1 menu-button" [class.active]="mode === 'projected'" i18n="block.projected"
fragment="projected" (click)="changeMode('projected')">Projected</a>
<a class="btn btn-primary w-50 mr-1 ml-1 menu-button" [class.active]="mode === 'actual'" i18n="block.actual"
fragment="actual" (click)="changeMode('actual')">Actual</a>
</div>
</ng-template>
<ng-template [ngIf]="error">
<div *ngIf="error && error.status === 404; else generalError" class="text-center">
<br>
<b i18n="error.audit-unavailable">audit unavailable</b>
<br><br>
<i>{{ error.error }}</i>
<br>
<br>
</div>
<ng-template #generalError>
<div class="text-center">
<br>
<span i18n="error.general-loading-data">Error loading data.</span>
<br><br>
<i>{{ error }}</i>
<br>
<br>
</div>
</ng-template>
</ng-template>
<!-- VISUALIZATIONS --> <!-- VISUALIZATIONS -->
<div class="box"> <div class="box" *ngIf="!error">
<div class="row"> <div class="row">
<!-- MISSING TX RENDERING --> <!-- MISSING TX RENDERING -->
<div class="col-sm" *ngIf="webGlEnabled"> <div class="col-sm" *ngIf="webGlEnabled">
<app-block-overview-graph #blockGraphTemplate [isLoading]="isLoading" [resolution]="75" <h3 class="block-subtitle" *ngIf="!isMobile" i18n="block.projected-block">Projected Block</h3>
<app-block-overview-graph #blockGraphProjected [isLoading]="isLoading" [resolution]="75"
[blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false" [blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false"
(txClickEvent)="onTxClick($event)"></app-block-overview-graph> (txClickEvent)="onTxClick($event)"></app-block-overview-graph>
</div> </div>
<!-- ADDED TX RENDERING --> <!-- ADDED TX RENDERING -->
<div class="col-sm" *ngIf="webGlEnabled && !isMobile"> <div class="col-sm" *ngIf="webGlEnabled && !isMobile">
<app-block-overview-graph #blockGraphMined [isLoading]="isLoading" [resolution]="75" <h3 class="block-subtitle" *ngIf="!isMobile" i18n="block.actual-block">Actual Block</h3>
<app-block-overview-graph #blockGraphActual [isLoading]="isLoading" [resolution]="75"
[blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false" [blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false"
(txClickEvent)="onTxClick($event)"></app-block-overview-graph> (txClickEvent)="onTxClick($event)"></app-block-overview-graph>
</div> </div>
</div> <!-- row --> </div> <!-- row -->
</div> <!-- box --> </div> <!-- box -->
<ng-template #skeleton></ng-template>
</div> </div>

View File

@@ -37,4 +37,8 @@
@media (min-width: 768px) { @media (min-width: 768px) {
max-width: 150px; max-width: 150px;
} }
}
.block-subtitle {
text-align: center;
} }

View File

@@ -1,7 +1,7 @@
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { Component, OnDestroy, OnInit, AfterViewInit, ViewChildren, QueryList } from '@angular/core';
import { ActivatedRoute, ParamMap, Router } from '@angular/router'; import { ActivatedRoute, ParamMap, Router } from '@angular/router';
import { Observable } from 'rxjs'; import { Subscription, combineLatest } from 'rxjs';
import { map, share, switchMap, tap } from 'rxjs/operators'; import { map, switchMap, startWith, catchError } from 'rxjs/operators';
import { BlockAudit, TransactionStripped } from '../../interfaces/node-api.interface'; import { BlockAudit, TransactionStripped } from '../../interfaces/node-api.interface';
import { ApiService } from '../../services/api.service'; import { ApiService } from '../../services/api.service';
import { StateService } from '../../services/state.service'; import { StateService } from '../../services/state.service';
@@ -22,22 +22,30 @@ import { BlockOverviewGraphComponent } from '../block-overview-graph/block-overv
} }
`], `],
}) })
export class BlockAuditComponent implements OnInit, OnDestroy { export class BlockAuditComponent implements OnInit, AfterViewInit, OnDestroy {
blockAudit: BlockAudit = undefined; blockAudit: BlockAudit = undefined;
transactions: string[]; transactions: string[];
auditObservable$: Observable<BlockAudit>; auditSubscription: Subscription;
urlFragmentSubscription: Subscription;
paginationMaxSize: number; paginationMaxSize: number;
page = 1; page = 1;
itemsPerPage: number; itemsPerPage: number;
mode: 'missing' | 'added' = 'missing'; mode: 'projected' | 'actual' = 'projected';
error: any;
isLoading = true; isLoading = true;
webGlEnabled = true; webGlEnabled = true;
isMobile = window.innerWidth <= 767.98; isMobile = window.innerWidth <= 767.98;
@ViewChild('blockGraphTemplate') blockGraphTemplate: BlockOverviewGraphComponent; childChangeSubscription: Subscription;
@ViewChild('blockGraphMined') blockGraphMined: BlockOverviewGraphComponent;
blockHash: string;
numMissing: number = 0;
numUnexpected: number = 0;
@ViewChildren('blockGraphProjected') blockGraphProjected: QueryList<BlockOverviewGraphComponent>;
@ViewChildren('blockGraphActual') blockGraphActual: QueryList<BlockOverviewGraphComponent>;
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
@@ -48,73 +56,137 @@ export class BlockAuditComponent implements OnInit, OnDestroy {
this.webGlEnabled = detectWebGL(); this.webGlEnabled = detectWebGL();
} }
ngOnDestroy(): void { ngOnDestroy() {
this.childChangeSubscription.unsubscribe();
this.urlFragmentSubscription.unsubscribe();
} }
ngOnInit(): void { ngOnInit(): void {
this.paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5; this.paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5;
this.itemsPerPage = this.stateService.env.ITEMS_PER_PAGE; this.itemsPerPage = this.stateService.env.ITEMS_PER_PAGE;
this.auditObservable$ = this.route.paramMap.pipe( this.urlFragmentSubscription = this.route.fragment.subscribe((fragment) => {
if (fragment === 'actual') {
this.mode = 'actual';
} else {
this.mode = 'projected'
}
this.setupBlockGraphs();
});
this.auditSubscription = this.route.paramMap.pipe(
switchMap((params: ParamMap) => { switchMap((params: ParamMap) => {
const blockHash: string = params.get('id') || ''; this.blockHash = params.get('id') || null;
return this.apiService.getBlockAudit$(blockHash) if (!this.blockHash) {
return null;
}
return this.apiService.getBlockAudit$(this.blockHash)
.pipe( .pipe(
map((response) => { map((response) => {
const blockAudit = response.body; const blockAudit = response.body;
for (let i = 0; i < blockAudit.template.length; ++i) { const inTemplate = {};
if (blockAudit.missingTxs.includes(blockAudit.template[i].txid)) { const inBlock = {};
blockAudit.template[i].status = 'missing'; const isAdded = {};
} else if (blockAudit.addedTxs.includes(blockAudit.template[i].txid)) { const isCensored = {};
blockAudit.template[i].status = 'added'; const isMissing = {};
const isSelected = {};
this.numMissing = 0;
this.numUnexpected = 0;
for (const tx of blockAudit.template) {
inTemplate[tx.txid] = true;
}
for (const tx of blockAudit.transactions) {
inBlock[tx.txid] = true;
}
for (const txid of blockAudit.addedTxs) {
isAdded[txid] = true;
}
for (const txid of blockAudit.missingTxs) {
isCensored[txid] = true;
}
// set transaction statuses
for (const tx of blockAudit.template) {
if (isCensored[tx.txid]) {
tx.status = 'censored';
} else if (inBlock[tx.txid]) {
tx.status = 'found';
} else { } else {
blockAudit.template[i].status = 'found'; tx.status = 'missing';
isMissing[tx.txid] = true;
this.numMissing++;
} }
} }
for (let i = 0; i < blockAudit.transactions.length; ++i) { for (const [index, tx] of blockAudit.transactions.entries()) {
if (blockAudit.missingTxs.includes(blockAudit.transactions[i].txid)) { if (isAdded[tx.txid]) {
blockAudit.transactions[i].status = 'missing'; tx.status = 'added';
} else if (blockAudit.addedTxs.includes(blockAudit.transactions[i].txid)) { } else if (index === 0 || inTemplate[tx.txid]) {
blockAudit.transactions[i].status = 'added'; tx.status = 'found';
} else { } else {
blockAudit.transactions[i].status = 'found'; tx.status = 'selected';
isSelected[tx.txid] = true;
this.numUnexpected++;
} }
} }
for (const tx of blockAudit.transactions) {
inBlock[tx.txid] = true;
}
return blockAudit; return blockAudit;
}), })
tap((blockAudit) => {
this.changeMode(this.mode);
if (this.blockGraphTemplate) {
this.blockGraphTemplate.destroy();
this.blockGraphTemplate.setup(blockAudit.template);
}
if (this.blockGraphMined) {
this.blockGraphMined.destroy();
this.blockGraphMined.setup(blockAudit.transactions);
}
this.isLoading = false;
}),
); );
}), }),
share() catchError((err) => {
); console.log(err);
this.error = err;
this.isLoading = false;
return null;
}),
).subscribe((blockAudit) => {
this.blockAudit = blockAudit;
this.setupBlockGraphs();
this.isLoading = false;
});
}
ngAfterViewInit() {
this.childChangeSubscription = combineLatest([this.blockGraphProjected.changes.pipe(startWith(null)), this.blockGraphActual.changes.pipe(startWith(null))]).subscribe(() => {
this.setupBlockGraphs();
})
}
setupBlockGraphs() {
if (this.blockAudit) {
this.blockGraphProjected.forEach(graph => {
graph.destroy();
if (this.isMobile && this.mode === 'actual') {
graph.setup(this.blockAudit.transactions);
} else {
graph.setup(this.blockAudit.template);
}
})
this.blockGraphActual.forEach(graph => {
graph.destroy();
graph.setup(this.blockAudit.transactions);
})
}
} }
onResize(event: any) { onResize(event: any) {
this.isMobile = event.target.innerWidth <= 767.98; const isMobile = event.target.innerWidth <= 767.98;
const changed = isMobile !== this.isMobile;
this.isMobile = isMobile;
this.paginationMaxSize = event.target.innerWidth < 670 ? 3 : 5; this.paginationMaxSize = event.target.innerWidth < 670 ? 3 : 5;
if (changed) {
this.changeMode(this.mode);
}
} }
changeMode(mode: 'missing' | 'added') { changeMode(mode: 'projected' | 'actual') {
this.router.navigate([], { fragment: mode }); this.router.navigate([], { fragment: mode });
this.mode = mode;
} }
onTxClick(event: TransactionStripped): void { onTxClick(event: TransactionStripped): void {
const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`); const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.txid}`);
this.router.navigate([url]); this.router.navigate([url]);
} }
pageChange(page: number, target: HTMLElement) {
}
} }

View File

@@ -7,6 +7,15 @@ import { feeLevels, mempoolFeeColors } from '../../app.constants';
const hoverTransitionTime = 300; const hoverTransitionTime = 300;
const defaultHoverColor = hexToColor('1bd8f4'); const defaultHoverColor = hexToColor('1bd8f4');
const feeColors = mempoolFeeColors.map(hexToColor);
const auditFeeColors = feeColors.map((color) => desaturate(color, 0.3));
const auditColors = {
censored: hexToColor('f344df'),
missing: darken(desaturate(hexToColor('f344df'), 0.3), 0.7),
added: hexToColor('03E1E5'),
selected: darken(desaturate(hexToColor('039BE5'), 0.3), 0.7),
}
// convert from this class's update format to TxSprite's update format // convert from this class's update format to TxSprite's update format
function toSpriteUpdate(params: ViewUpdateParams): SpriteUpdateParams { function toSpriteUpdate(params: ViewUpdateParams): SpriteUpdateParams {
return { return {
@@ -25,7 +34,7 @@ export default class TxView implements TransactionStripped {
vsize: number; vsize: number;
value: number; value: number;
feerate: number; feerate: number;
status?: 'found' | 'missing' | 'added'; status?: 'found' | 'missing' | 'added' | 'censored' | 'selected';
initialised: boolean; initialised: boolean;
vertexArray: FastVertexArray; vertexArray: FastVertexArray;
@@ -142,16 +151,23 @@ export default class TxView implements TransactionStripped {
} }
getColor(): Color { getColor(): Color {
// Block audit
if (this.status === 'missing') {
return hexToColor('039BE5');
} else if (this.status === 'added') {
return hexToColor('D81B60');
}
// Block component
const feeLevelIndex = feeLevels.findIndex((feeLvl) => Math.max(1, this.feerate) < feeLvl) - 1; const feeLevelIndex = feeLevels.findIndex((feeLvl) => Math.max(1, this.feerate) < feeLvl) - 1;
return hexToColor(mempoolFeeColors[feeLevelIndex] || mempoolFeeColors[mempoolFeeColors.length - 1]); const feeLevelColor = feeColors[feeLevelIndex] || feeColors[mempoolFeeColors.length - 1];
// Block audit
switch(this.status) {
case 'censored':
return auditColors.censored;
case 'missing':
return auditColors.missing;
case 'added':
return auditColors.added;
case 'selected':
return auditColors.selected;
case 'found':
return auditFeeColors[feeLevelIndex] || auditFeeColors[mempoolFeeColors.length - 1];
default:
return feeLevelColor;
}
} }
} }
@@ -163,3 +179,22 @@ function hexToColor(hex: string): Color {
a: 1 a: 1
}; };
} }
function desaturate(color: Color, amount: number): Color {
const gray = (color.r + color.g + color.b) / 6;
return {
r: color.r + ((gray - color.r) * amount),
g: color.g + ((gray - color.g) * amount),
b: color.b + ((gray - color.b) * amount),
a: color.a,
};
}
function darken(color: Color, amount: number): Color {
return {
r: color.r * amount,
g: color.g * amount,
b: color.b * amount,
a: color.a,
}
}

View File

@@ -32,6 +32,16 @@
<td class="td-width" i18n="transaction.vsize|Transaction Virtual Size">Virtual size</td> <td class="td-width" i18n="transaction.vsize|Transaction Virtual Size">Virtual size</td>
<td [innerHTML]="'&lrm;' + (vsize | vbytes: 2)"></td> <td [innerHTML]="'&lrm;' + (vsize | vbytes: 2)"></td>
</tr> </tr>
<tr *ngIf="tx && tx.status && tx.status.length">
<td class="td-width" i18n="transaction.audit-status">Audit status</td>
<ng-container [ngSwitch]="tx?.status">
<td *ngSwitchCase="'found'" i18n="transaction.audit.match">match</td>
<td *ngSwitchCase="'censored'" i18n="transaction.audit.removed">removed</td>
<td *ngSwitchCase="'missing'" i18n="transaction.audit.missing">missing</td>
<td *ngSwitchCase="'added'" i18n="transaction.audit.added">added</td>
<td *ngSwitchCase="'selected'" i18n="transaction.audit.included">included</td>
</ng-container>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@@ -110,6 +110,13 @@
</span> </span>
</td> </td>
</tr> </tr>
<tr *ngIf="indexingAvailable">
<td i18n="block.health">Block health</td>
<td>
<a *ngIf="block.extras?.matchRate != null" [routerLink]="['/block-audit/' | relativeUrl, blockHash]">{{ block.extras.matchRate }}%</a>
<span *ngIf="block.extras?.matchRate == null" i18n="unknown">Unknown</span>
</td>
</tr>
</ng-template> </ng-template>
</tbody> </tbody>
</table> </table>

View File

@@ -47,6 +47,7 @@ export class BlockComponent implements OnInit, OnDestroy {
transactionsError: any = null; transactionsError: any = null;
overviewError: any = null; overviewError: any = null;
webGlEnabled = true; webGlEnabled = true;
indexingAvailable = false;
transactionSubscription: Subscription; transactionSubscription: Subscription;
overviewSubscription: Subscription; overviewSubscription: Subscription;
@@ -86,6 +87,9 @@ export class BlockComponent implements OnInit, OnDestroy {
this.timeLtr = !!ltr; this.timeLtr = !!ltr;
}); });
this.indexingAvailable = (this.stateService.env.BASE_MODULE === 'mempool' &&
this.stateService.env.MINING_DASHBOARD === true);
this.txsLoadingStatus$ = this.route.paramMap this.txsLoadingStatus$ = this.route.paramMap
.pipe( .pipe(
switchMap(() => this.stateService.loadingIndicators$), switchMap(() => this.stateService.loadingIndicators$),

View File

@@ -27,7 +27,6 @@
left: 0; left: 0;
top: 75px; top: 75px;
transform: translateX(50vw); transform: translateX(50vw);
transition: transform 1s;
} }
.position-container.liquid, .position-container.liquidtestnet { .position-container.liquid, .position-container.liquidtestnet {
@@ -84,9 +83,9 @@
.time-toggle { .time-toggle {
color: white; color: white;
font-size: 1rem; font-size: 0.8rem;
position: absolute; position: absolute;
bottom: -1.5em; bottom: -1.8em;
left: 1px; left: 1px;
transform: translateX(-50%); transform: translateX(-50%);
background: none; background: none;
@@ -97,14 +96,31 @@
} }
.blockchain-wrapper.ltr-transition .blocks-wrapper, .blockchain-wrapper.ltr-transition .blocks-wrapper,
.blockchain-wrapper.ltr-transition .position-container,
.blockchain-wrapper.ltr-transition .time-toggle { .blockchain-wrapper.ltr-transition .time-toggle {
transition: transform 1s; transition: transform 1s;
} }
.blockchain-wrapper.time-ltr .blocks-wrapper { .blockchain-wrapper.time-ltr {
transform: scaleX(-1); .blocks-wrapper {
transform: scaleX(-1);
}
.time-toggle {
transform: translateX(-50%) scaleX(-1);
}
} }
.blockchain-wrapper.time-ltr .time-toggle { :host-context(.ltr-layout) {
transform: translateX(-50%) scaleX(-1); .blockchain-wrapper.time-ltr .blocks-wrapper,
.blockchain-wrapper .blocks-wrapper {
direction: ltr;
}
}
:host-context(.rtl-layout) {
.blockchain-wrapper.time-ltr .blocks-wrapper,
.blockchain-wrapper .blocks-wrapper {
direction: rtl;
}
} }

View File

@@ -14,6 +14,8 @@
i18n-ngbTooltip="mining.pool-name" ngbTooltip="Pool" placement="bottom" #miningpool [disableTooltip]="!isEllipsisActive(miningpool)">Pool</th> i18n-ngbTooltip="mining.pool-name" ngbTooltip="Pool" placement="bottom" #miningpool [disableTooltip]="!isEllipsisActive(miningpool)">Pool</th>
<th class="timestamp" i18n="latest-blocks.timestamp" *ngIf="!widget" [class]="indexingAvailable ? '' : 'legacy'">Timestamp</th> <th class="timestamp" i18n="latest-blocks.timestamp" *ngIf="!widget" [class]="indexingAvailable ? '' : 'legacy'">Timestamp</th>
<th class="mined" i18n="latest-blocks.mined" *ngIf="!widget" [class]="indexingAvailable ? '' : 'legacy'">Mined</th> <th class="mined" i18n="latest-blocks.mined" *ngIf="!widget" [class]="indexingAvailable ? '' : 'legacy'">Mined</th>
<th *ngIf="indexingAvailable" class="health text-left" i18n="latest-blocks.health" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}"
i18n-ngbTooltip="latest-blocks.health" ngbTooltip="Health" placement="bottom" #health [disableTooltip]="!isEllipsisActive(health)">Health</th>
<th *ngIf="indexingAvailable" class="reward text-right" i18n="latest-blocks.reward" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}" <th *ngIf="indexingAvailable" class="reward text-right" i18n="latest-blocks.reward" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}"
i18n-ngbTooltip="latest-blocks.reward" ngbTooltip="Reward" placement="bottom" #reward [disableTooltip]="!isEllipsisActive(reward)">Reward</th> i18n-ngbTooltip="latest-blocks.reward" ngbTooltip="Reward" placement="bottom" #reward [disableTooltip]="!isEllipsisActive(reward)">Reward</th>
<th *ngIf="indexingAvailable && !widget" class="fees text-right" i18n="latest-blocks.fees" [class]="indexingAvailable ? '' : 'legacy'">Fees</th> <th *ngIf="indexingAvailable && !widget" class="fees text-right" i18n="latest-blocks.fees" [class]="indexingAvailable ? '' : 'legacy'">Fees</th>
@@ -37,12 +39,30 @@
<span *ngIf="!widget" class="tooltiptext badge badge-secondary scriptmessage">{{ block.extras.coinbaseRaw | hex2ascii }}</span> <span *ngIf="!widget" class="tooltiptext badge badge-secondary scriptmessage">{{ block.extras.coinbaseRaw | hex2ascii }}</span>
</div> </div>
</td> </td>
<td class="timestamp" *ngIf="!widget"> <td class="timestamp" *ngIf="!widget" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}">
&lrm;{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }} &lrm;{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }}
</td> </td>
<td class="mined" *ngIf="!widget" [class]="indexingAvailable ? '' : 'legacy'"> <td class="mined" *ngIf="!widget" [class]="indexingAvailable ? '' : 'legacy'">
<app-time-since [time]="block.timestamp" [fastRender]="true"></app-time-since> <app-time-since [time]="block.timestamp" [fastRender]="true"></app-time-since>
</td> </td>
<td *ngIf="indexingAvailable" class="health text-right" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}">
<a *ngIf="block.extras?.matchRate != null" class="clear-link" [routerLink]="['/block-audit/' | relativeUrl, block.id]">
<div class="progress progress-health">
<div class="progress-bar progress-bar-health" role="progressbar"
[ngStyle]="{'width': (100 - (block.extras?.matchRate || 0)) + '%' }"></div>
<div class="progress-text">
<span>{{ block.extras.matchRate }}%</span>
</div>
</div>
</a>
<div *ngIf="block.extras?.matchRate == null" class="progress progress-health">
<div class="progress-bar progress-bar-health" role="progressbar"
[ngStyle]="{'width': '100%' }"></div>
<div class="progress-text">
<span>~</span>
</div>
</div>
</td>
<td *ngIf="indexingAvailable" class="reward text-right" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}"> <td *ngIf="indexingAvailable" class="reward text-right" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}">
<app-amount [satoshis]="block.extras.reward" [noFiat]="true" digitsInfo="1.2-2"></app-amount> <app-amount [satoshis]="block.extras.reward" [noFiat]="true" digitsInfo="1.2-2"></app-amount>
</td> </td>
@@ -77,6 +97,9 @@
<td class="mined" *ngIf="!widget" [class]="indexingAvailable ? '' : 'legacy'"> <td class="mined" *ngIf="!widget" [class]="indexingAvailable ? '' : 'legacy'">
<span class="skeleton-loader" style="max-width: 125px"></span> <span class="skeleton-loader" style="max-width: 125px"></span>
</td> </td>
<td *ngIf="indexingAvailable" class="health text-left" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}">
<span class="skeleton-loader" style="max-width: 75px"></span>
</td>
<td *ngIf="indexingAvailable" class="reward text-right" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}"> <td *ngIf="indexingAvailable" class="reward text-right" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}">
<span class="skeleton-loader" style="max-width: 75px"></span> <span class="skeleton-loader" style="max-width: 75px"></span>
</td> </td>

View File

@@ -63,7 +63,7 @@ tr, td, th {
} }
.height { .height {
width: 10%; width: 8%;
} }
.height.widget { .height.widget {
width: 15%; width: 15%;
@@ -77,12 +77,18 @@ tr, td, th {
.timestamp { .timestamp {
width: 18%; width: 18%;
@media (max-width: 900px) { @media (max-width: 1100px) {
display: none; display: none;
} }
} }
.timestamp.legacy { .timestamp.legacy {
width: 20%; width: 20%;
@media (max-width: 1100px) {
display: table-cell;
}
@media (max-width: 850px) {
display: none;
}
} }
.mined { .mined {
@@ -93,6 +99,10 @@ tr, td, th {
} }
.mined.legacy { .mined.legacy {
width: 15%; width: 15%;
@media (max-width: 1000px) {
padding-right: 20px;
width: 20%;
}
@media (max-width: 576px) { @media (max-width: 576px) {
display: table-cell; display: table-cell;
} }
@@ -100,6 +110,7 @@ tr, td, th {
.txs { .txs {
padding-right: 40px; padding-right: 40px;
width: 8%;
@media (max-width: 1100px) { @media (max-width: 1100px) {
padding-right: 10px; padding-right: 10px;
} }
@@ -113,17 +124,21 @@ tr, td, th {
} }
.txs.widget { .txs.widget {
padding-right: 0; padding-right: 0;
display: none;
@media (max-width: 650px) { @media (max-width: 650px) {
display: none; display: none;
} }
} }
.txs.legacy { .txs.legacy {
padding-right: 80px; width: 18%;
width: 10%; display: table-cell;
@media (max-width: 1000px) {
padding-right: 20px;
}
} }
.fees { .fees {
width: 10%; width: 8%;
@media (max-width: 650px) { @media (max-width: 650px) {
display: none; display: none;
} }
@@ -133,7 +148,7 @@ tr, td, th {
} }
.reward { .reward {
width: 10%; width: 8%;
@media (max-width: 576px) { @media (max-width: 576px) {
width: 7%; width: 7%;
padding-right: 30px; padding-right: 30px;
@@ -152,8 +167,11 @@ tr, td, th {
} }
.size { .size {
width: 12%; width: 10%;
@media (max-width: 1000px) { @media (max-width: 1000px) {
width: 13%;
}
@media (max-width: 950px) {
width: 15%; width: 15%;
} }
@media (max-width: 650px) { @media (max-width: 650px) {
@@ -164,12 +182,34 @@ tr, td, th {
} }
} }
.size.legacy { .size.legacy {
width: 20%; width: 30%;
@media (max-width: 576px) { @media (max-width: 576px) {
display: table-cell; display: table-cell;
} }
} }
.health {
width: 10%;
@media (max-width: 1000px) {
width: 13%;
}
@media (max-width: 950px) {
display: none;
}
}
.health.widget {
width: 25%;
@media (max-width: 1000px) {
display: none;
}
@media (max-width: 767px) {
display: table-cell;
}
@media (max-width: 500px) {
display: none;
}
}
/* Tooltip text */ /* Tooltip text */
.tooltip-custom { .tooltip-custom {
position: relative; position: relative;

View File

@@ -31,17 +31,17 @@
<button class="btn btn-primary w-100" id="dropdownBasic1" ngbDropdownToggle i18n="lightning">Lightning</button> <button class="btn btn-primary w-100" id="dropdownBasic1" ngbDropdownToggle i18n="lightning">Lightning</button>
<div ngbDropdownMenu aria-labelledby="dropdownBasic1"> <div ngbDropdownMenu aria-labelledby="dropdownBasic1">
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-networks' | relativeUrl]" <a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-networks' | relativeUrl]"
i18n="lightning.nodes-networks">Lightning nodes per network</a> i18n="lightning.nodes-networks">Lightning Nodes Per Network</a>
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/capacity' | relativeUrl]" <a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/capacity' | relativeUrl]"
i18n="lightning.capacity">Network capacity</a> i18n="lightning.network-capacity">Lightning Network Capacity</a>
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-per-isp' | relativeUrl]" <a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-per-isp' | relativeUrl]"
i18n="lightning.nodes-per-isp">Lightning nodes per ISP</a> i18n="lightning.nodes-per-isp">Lightning Nodes Per ISP</a>
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-per-country' | relativeUrl]" <a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-per-country' | relativeUrl]"
i18n="lightning.nodes-per-country">Lightning nodes per country</a> i18n="lightning.nodes-per-country">Lightning Nodes Per Country</a>
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-map' | relativeUrl]" <a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-map' | relativeUrl]"
i18n="lightning.lightning.nodes-heatmap">Lightning nodes world map</a> i18n="lightning.lightning.nodes-heatmap">Lightning Nodes World Map</a>
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-channels-map' | relativeUrl]" <a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-channels-map' | relativeUrl]"
i18n="lightning.nodes-channels-world-map">Lightning nodes channels world map</a> i18n="lightning.nodes-channels-world-map">Lightning Nodes Channels World Map</a>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -49,13 +49,13 @@
<app-svg-images [name]="network.val === '' ? 'liquid' : network.val" width="22" height="22" viewBox="0 0 125 125" style="width: 30px; height: 30px; margin-right: 5px;"></app-svg-images> <app-svg-images [name]="network.val === '' ? 'liquid' : network.val" width="22" height="22" viewBox="0 0 125 125" style="width: 30px; height: 30px; margin-right: 5px;"></app-svg-images>
</button> </button>
<div ngbDropdownMenu [ngClass]="{'dropdown-menu-right' : isMobile}"> <div ngbDropdownMenu [ngClass]="{'dropdown-menu-right' : isMobile}">
<a [href]="env.MEMPOOL_WEBSITE_URL + urlLanguage" ngbDropdownItem class="mainnet"><app-svg-images name="bitcoin" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Mainnet</a> <a [href]="env.MEMPOOL_WEBSITE_URL + urlLanguage + (networkPaths['mainnet'] || '')" ngbDropdownItem class="mainnet"><app-svg-images name="bitcoin" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Mainnet</a>
<a [href]="env.MEMPOOL_WEBSITE_URL + urlLanguage + '/signet'" ngbDropdownItem *ngIf="env.SIGNET_ENABLED" class="signet"><app-svg-images name="signet" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Signet</a> <a [href]="env.MEMPOOL_WEBSITE_URL + urlLanguage + (networkPaths['signet'] || '/signet')" ngbDropdownItem *ngIf="env.SIGNET_ENABLED" class="signet"><app-svg-images name="signet" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Signet</a>
<a [href]="env.MEMPOOL_WEBSITE_URL + urlLanguage + '/testnet'" ngbDropdownItem *ngIf="env.TESTNET_ENABLED" class="testnet"><app-svg-images name="testnet" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Testnet</a> <a [href]="env.MEMPOOL_WEBSITE_URL + urlLanguage + (networkPaths['testnet'] || '/testnet')" ngbDropdownItem *ngIf="env.TESTNET_ENABLED" class="testnet"><app-svg-images name="testnet" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Testnet</a>
<h6 class="dropdown-header" i18n="master-page.layer2-networks-header">Layer 2 Networks</h6> <h6 class="dropdown-header" i18n="master-page.layer2-networks-header">Layer 2 Networks</h6>
<a [href]="env.BISQ_WEBSITE_URL + urlLanguage" ngbDropdownItem class="mainnet"><app-svg-images name="bisq" width="22" height="22" viewBox="0 0 75 75" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Bisq</a> <a [href]="env.BISQ_WEBSITE_URL + urlLanguage + (networkPaths['bisq'] || '')" ngbDropdownItem class="mainnet"><app-svg-images name="bisq" width="22" height="22" viewBox="0 0 75 75" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Bisq</a>
<a ngbDropdownItem class="liquid mr-1" [class.active]="network.val === 'liquid'" routerLink="/"><app-svg-images name="liquid" width="22" height="22" viewBox="0 0 125 125" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Liquid</a> <a ngbDropdownItem class="liquid mr-1" [class.active]="network.val === 'liquid'" [routerLink]="networkPaths['liquid'] || '/'"><app-svg-images name="liquid" width="22" height="22" viewBox="0 0 125 125" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Liquid</a>
<a ngbDropdownItem *ngIf="env.LIQUID_TESTNET_ENABLED" class="liquidtestnet" [class.active]="network.val === 'liquidtestnet'" routerLink="/testnet"><app-svg-images name="liquidtestnet" width="22" height="22" viewBox="0 0 125 125" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Liquid Testnet</a> <a ngbDropdownItem *ngIf="env.LIQUID_TESTNET_ENABLED" class="liquidtestnet" [class.active]="network.val === 'liquidtestnet'" [routerLink]="networkPaths['liquidtestnet'] || '/testnet'"><app-svg-images name="liquidtestnet" width="22" height="22" viewBox="0 0 125 125" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Liquid Testnet</a>
</div> </div>
</div> </div>

View File

@@ -3,6 +3,7 @@ import { Env, StateService } from '../../services/state.service';
import { merge, Observable, of} from 'rxjs'; import { merge, Observable, of} from 'rxjs';
import { LanguageService } from '../../services/language.service'; import { LanguageService } from '../../services/language.service';
import { EnterpriseService } from '../../services/enterprise.service'; import { EnterpriseService } from '../../services/enterprise.service';
import { NavigationService } from '../../services/navigation.service';
@Component({ @Component({
selector: 'app-liquid-master-page', selector: 'app-liquid-master-page',
@@ -17,11 +18,13 @@ export class LiquidMasterPageComponent implements OnInit {
officialMempoolSpace = this.stateService.env.OFFICIAL_MEMPOOL_SPACE; officialMempoolSpace = this.stateService.env.OFFICIAL_MEMPOOL_SPACE;
network$: Observable<string>; network$: Observable<string>;
urlLanguage: string; urlLanguage: string;
networkPaths: { [network: string]: string };
constructor( constructor(
private stateService: StateService, private stateService: StateService,
private languageService: LanguageService, private languageService: LanguageService,
private enterpriseService: EnterpriseService, private enterpriseService: EnterpriseService,
private navigationService: NavigationService,
) { } ) { }
ngOnInit() { ngOnInit() {
@@ -29,6 +32,10 @@ export class LiquidMasterPageComponent implements OnInit {
this.connectionState$ = this.stateService.connectionState$; this.connectionState$ = this.stateService.connectionState$;
this.network$ = merge(of(''), this.stateService.networkChanged$); this.network$ = merge(of(''), this.stateService.networkChanged$);
this.urlLanguage = this.languageService.getLanguageForUrl(); this.urlLanguage = this.languageService.getLanguageForUrl();
this.navigationService.subnetPaths.subscribe((paths) => {
console.log('network paths updated...');
this.networkPaths = paths;
});
} }
collapse(): void { collapse(): void {

View File

@@ -22,13 +22,13 @@
<app-svg-images [name]="network.val === '' ? 'bitcoin' : network.val" width="20" height="20" viewBox="0 0 65 65" style="width: 30px; height: 30px; margin-right: 5px;"></app-svg-images> <app-svg-images [name]="network.val === '' ? 'bitcoin' : network.val" width="20" height="20" viewBox="0 0 65 65" style="width: 30px; height: 30px; margin-right: 5px;"></app-svg-images>
</button> </button>
<div ngbDropdownMenu [ngClass]="{'dropdown-menu-right' : isMobile}"> <div ngbDropdownMenu [ngClass]="{'dropdown-menu-right' : isMobile}">
<a ngbDropdownItem class="mainnet" routerLink="/"><app-svg-images name="bitcoin" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Mainnet</a> <a ngbDropdownItem class="mainnet" [routerLink]="networkPaths['mainnet'] || '/'"><app-svg-images name="bitcoin" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Mainnet</a>
<a ngbDropdownItem *ngIf="env.SIGNET_ENABLED" class="signet" [class.active]="network.val === 'signet'" routerLink="/signet"><app-svg-images name="signet" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Signet</a> <a ngbDropdownItem *ngIf="env.SIGNET_ENABLED" class="signet" [class.active]="network.val === 'signet'" [routerLink]="networkPaths['signet'] || '/signet'"><app-svg-images name="signet" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Signet</a>
<a ngbDropdownItem *ngIf="env.TESTNET_ENABLED" class="testnet" [class.active]="network.val === 'testnet'" routerLink="/testnet"><app-svg-images name="testnet" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Testnet</a> <a ngbDropdownItem *ngIf="env.TESTNET_ENABLED" class="testnet" [class.active]="network.val === 'testnet'" [routerLink]="networkPaths['testnet'] || '/testnet'"><app-svg-images name="testnet" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Testnet</a>
<h6 *ngIf="env.LIQUID_ENABLED || env.BISQ_ENABLED" class="dropdown-header" i18n="master-page.layer2-networks-header">Layer 2 Networks</h6> <h6 *ngIf="env.LIQUID_ENABLED || env.BISQ_ENABLED" class="dropdown-header" i18n="master-page.layer2-networks-header">Layer 2 Networks</h6>
<a [href]="env.BISQ_WEBSITE_URL + urlLanguage" ngbDropdownItem *ngIf="env.BISQ_ENABLED" class="bisq"><app-svg-images name="bisq" width="20" height="20" viewBox="0 0 75 75" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Bisq</a> <a [href]="env.BISQ_WEBSITE_URL + urlLanguage + (networkPaths['bisq'] || '')" ngbDropdownItem *ngIf="env.BISQ_ENABLED" class="bisq"><app-svg-images name="bisq" width="20" height="20" viewBox="0 0 75 75" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Bisq</a>
<a [href]="env.LIQUID_WEBSITE_URL + urlLanguage" ngbDropdownItem *ngIf="env.LIQUID_ENABLED" class="liquid" [class.active]="network.val === 'liquid'"><app-svg-images name="liquid" width="22" height="22" viewBox="0 0 125 125" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Liquid</a> <a [href]="env.LIQUID_WEBSITE_URL + urlLanguage + (networkPaths['liquid'] || '')" ngbDropdownItem *ngIf="env.LIQUID_ENABLED" class="liquid" [class.active]="network.val === 'liquid'"><app-svg-images name="liquid" width="22" height="22" viewBox="0 0 125 125" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Liquid</a>
<a [href]="env.LIQUID_WEBSITE_URL + urlLanguage + '/testnet'" ngbDropdownItem *ngIf="env.LIQUID_TESTNET_ENABLED" class="liquidtestnet" [class.active]="network.val === 'liquid'"><app-svg-images name="liquidtestnet" width="22" height="22" viewBox="0 0 125 125" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Liquid Testnet</a> <a [href]="env.LIQUID_WEBSITE_URL + urlLanguage + (networkPaths['liquidtestnet'] || '/testnet')" ngbDropdownItem *ngIf="env.LIQUID_TESTNET_ENABLED" class="liquidtestnet" [class.active]="network.val === 'liquid'"><app-svg-images name="liquidtestnet" width="22" height="22" viewBox="0 0 125 125" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Liquid Testnet</a>
</div> </div>
</div> </div>

View File

@@ -3,6 +3,7 @@ import { Env, StateService } from '../../services/state.service';
import { Observable, merge, of } from 'rxjs'; import { Observable, merge, of } from 'rxjs';
import { LanguageService } from '../../services/language.service'; import { LanguageService } from '../../services/language.service';
import { EnterpriseService } from '../../services/enterprise.service'; import { EnterpriseService } from '../../services/enterprise.service';
import { NavigationService } from '../../services/navigation.service';
@Component({ @Component({
selector: 'app-master-page', selector: 'app-master-page',
@@ -18,11 +19,13 @@ export class MasterPageComponent implements OnInit {
officialMempoolSpace = this.stateService.env.OFFICIAL_MEMPOOL_SPACE; officialMempoolSpace = this.stateService.env.OFFICIAL_MEMPOOL_SPACE;
urlLanguage: string; urlLanguage: string;
subdomain = ''; subdomain = '';
networkPaths: { [network: string]: string };
constructor( constructor(
public stateService: StateService, public stateService: StateService,
private languageService: LanguageService, private languageService: LanguageService,
private enterpriseService: EnterpriseService, private enterpriseService: EnterpriseService,
private navigationService: NavigationService,
) { } ) { }
ngOnInit() { ngOnInit() {
@@ -31,6 +34,10 @@ export class MasterPageComponent implements OnInit {
this.network$ = merge(of(''), this.stateService.networkChanged$); this.network$ = merge(of(''), this.stateService.networkChanged$);
this.urlLanguage = this.languageService.getLanguageForUrl(); this.urlLanguage = this.languageService.getLanguageForUrl();
this.subdomain = this.enterpriseService.getSubdomain(); this.subdomain = this.enterpriseService.getSubdomain();
this.navigationService.subnetPaths.subscribe((paths) => {
console.log('network paths updated...');
this.networkPaths = paths;
});
} }
collapse(): void { collapse(): void {

View File

@@ -146,4 +146,10 @@
.block-body { .block-body {
transform: scaleX(-1); transform: scaleX(-1);
} }
}
:host-context(.rtl-layout) {
#arrow-up {
transform: translateX(70px);
}
} }

View File

@@ -287,11 +287,12 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy {
this.arrowVisible = true; this.arrowVisible = true;
for (const block of this.mempoolBlocks) { let found = false;
for (let i = 0; i < block.feeRange.length - 1; i++) { for (let txInBlockIndex = 0; txInBlockIndex < this.mempoolBlocks.length && !found; txInBlockIndex++) {
const block = this.mempoolBlocks[txInBlockIndex];
for (let i = 0; i < block.feeRange.length - 1 && !found; i++) {
if (this.txFeePerVSize < block.feeRange[i + 1] && this.txFeePerVSize >= block.feeRange[i]) { if (this.txFeePerVSize < block.feeRange[i + 1] && this.txFeePerVSize >= block.feeRange[i]) {
const txInBlockIndex = this.mempoolBlocks.indexOf(block); const feeRangeIndex = i;
const feeRangeIndex = block.feeRange.findIndex((val, index) => this.txFeePerVSize < block.feeRange[index + 1]);
const feeRangeChunkSize = 1 / (block.feeRange.length - 1); const feeRangeChunkSize = 1 / (block.feeRange.length - 1);
const txFee = this.txFeePerVSize - block.feeRange[i]; const txFee = this.txFeePerVSize - block.feeRange[i];
@@ -306,9 +307,13 @@ export class MempoolBlocksComponent implements OnInit, OnDestroy {
+ ((1 - feePosition) * blockedFilledPercentage * this.blockWidth); + ((1 - feePosition) * blockedFilledPercentage * this.blockWidth);
this.rightPosition = arrowRightPosition; this.rightPosition = arrowRightPosition;
break; found = true;
} }
} }
if (this.txFeePerVSize >= block.feeRange[block.feeRange.length - 1]) {
this.rightPosition = txInBlockIndex * (this.blockWidth + this.blockPadding);
found = true;
}
} }
} }

View File

@@ -2,9 +2,7 @@
<div class="d-flex"> <div class="d-flex">
<div class="search-box-container mr-2"> <div class="search-box-container mr-2">
<input (focus)="focus$.next($any($event).target.value)" (click)="click$.next($any($event).target.value)" formControlName="searchText" type="text" class="form-control" i18n-placeholder="search-form.searchbar-placeholder" placeholder="Explore the full Bitcoin ecosystem"> <input (focus)="focus$.next($any($event).target.value)" (click)="click$.next($any($event).target.value)" formControlName="searchText" type="text" class="form-control" i18n-placeholder="search-form.searchbar-placeholder" placeholder="Explore the full Bitcoin ecosystem">
<app-search-results #searchResults [hidden]="dropdownHidden" [results]="typeAhead$ | async" (selectedResult)="selectedResult($event)"></app-search-results>
<app-search-results #searchResults [results]="typeAhead$ | async" (selectedResult)="selectedResult($event)"></app-search-results>
</div> </div>
<div> <div>
<button [disabled]="isSearching" type="submit" class="btn btn-block btn-primary"> <button [disabled]="isSearching" type="submit" class="btn btn-block btn-primary">

View File

@@ -1,10 +1,10 @@
import { Component, OnInit, ChangeDetectionStrategy, EventEmitter, Output, ViewChild, HostListener } from '@angular/core'; import { Component, OnInit, ChangeDetectionStrategy, EventEmitter, Output, ViewChild, HostListener, ElementRef } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AssetsService } from '../../services/assets.service'; import { AssetsService } from '../../services/assets.service';
import { StateService } from '../../services/state.service'; import { StateService } from '../../services/state.service';
import { Observable, of, Subject, zip, BehaviorSubject } from 'rxjs'; import { Observable, of, Subject, zip, BehaviorSubject, combineLatest } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap, catchError, map } from 'rxjs/operators'; import { debounceTime, distinctUntilChanged, switchMap, catchError, map, startWith, tap } from 'rxjs/operators';
import { ElectrsApiService } from '../../services/electrs-api.service'; import { ElectrsApiService } from '../../services/electrs-api.service';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
import { ApiService } from '../../services/api.service'; import { ApiService } from '../../services/api.service';
@@ -23,8 +23,18 @@ export class SearchFormComponent implements OnInit {
isTypeaheading$ = new BehaviorSubject<boolean>(false); isTypeaheading$ = new BehaviorSubject<boolean>(false);
typeAhead$: Observable<any>; typeAhead$: Observable<any>;
searchForm: FormGroup; searchForm: FormGroup;
dropdownHidden = false;
regexAddress = /^([a-km-zA-HJ-NP-Z1-9]{26,35}|[a-km-zA-HJ-NP-Z1-9]{80}|[a-z]{2,5}1[ac-hj-np-z02-9]{8,100}|[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100})$/; @HostListener('document:click', ['$event'])
onDocumentClick(event) {
if (this.elementRef.nativeElement.contains(event.target)) {
this.dropdownHidden = false;
} else {
this.dropdownHidden = true;
}
}
regexAddress = /^([a-km-zA-HJ-NP-Z1-9]{26,35}|[a-km-zA-HJ-NP-Z1-9]{80}|[A-z]{2,5}1[a-zA-HJ-NP-Z0-9]{39,59})$/;
regexBlockhash = /^[0]{8}[a-fA-F0-9]{56}$/; regexBlockhash = /^[0]{8}[a-fA-F0-9]{56}$/;
regexTransaction = /^([a-fA-F0-9]{64})(:\d+)?$/; regexTransaction = /^([a-fA-F0-9]{64})(:\d+)?$/;
regexBlockheight = /^[0-9]{1,9}$/; regexBlockheight = /^[0-9]{1,9}$/;
@@ -33,7 +43,7 @@ export class SearchFormComponent implements OnInit {
@Output() searchTriggered = new EventEmitter(); @Output() searchTriggered = new EventEmitter();
@ViewChild('searchResults') searchResults: SearchResultsComponent; @ViewChild('searchResults') searchResults: SearchResultsComponent;
@HostListener('keydown', ['$event']) keydown($event) { @HostListener('keydown', ['$event']) keydown($event): void {
this.handleKeyDown($event); this.handleKeyDown($event);
} }
@@ -45,9 +55,10 @@ export class SearchFormComponent implements OnInit {
private electrsApiService: ElectrsApiService, private electrsApiService: ElectrsApiService,
private apiService: ApiService, private apiService: ApiService,
private relativeUrlPipe: RelativeUrlPipe, private relativeUrlPipe: RelativeUrlPipe,
private elementRef: ElementRef,
) { } ) { }
ngOnInit() { ngOnInit(): void {
this.stateService.networkChanged$.subscribe((network) => this.network = network); this.stateService.networkChanged$.subscribe((network) => this.network = network);
this.searchForm = this.formBuilder.group({ this.searchForm = this.formBuilder.group({
@@ -61,70 +72,111 @@ export class SearchFormComponent implements OnInit {
}); });
} }
this.typeAhead$ = this.searchForm.get('searchText').valueChanges const searchText$ = this.searchForm.get('searchText').valueChanges
.pipe( .pipe(
map((text) => { map((text) => {
if (this.network === 'bisq' && text.match(/^(b)[^c]/i)) { if (this.network === 'bisq' && text.match(/^(b)[^c]/i)) {
return text.substr(1); return text.substr(1);
} }
return text.trim(); return text.trim();
}), }),
debounceTime(200), distinctUntilChanged(),
distinctUntilChanged(), );
switchMap((text) => {
if (!text.length) { const searchResults$ = searchText$.pipe(
return of([ debounceTime(200),
'', switchMap((text) => {
[], if (!text.length) {
{ return of([
nodes: [], [],
channels: [], { nodes: [], channels: [] }
} ]);
]); }
} this.isTypeaheading$.next(true);
this.isTypeaheading$.next(true); if (!this.stateService.env.LIGHTNING) {
if (!this.stateService.env.LIGHTNING) {
return zip(
of(text),
this.electrsApiService.getAddressesByPrefix$(text).pipe(catchError(() => of([]))),
[{ nodes: [], channels: [] }],
of(this.regexBlockheight.test(text)),
);
}
return zip( return zip(
of(text),
this.electrsApiService.getAddressesByPrefix$(text).pipe(catchError(() => of([]))), this.electrsApiService.getAddressesByPrefix$(text).pipe(catchError(() => of([]))),
this.apiService.lightningSearch$(text).pipe(catchError(() => of({ [{ nodes: [], channels: [] }],
);
}
return zip(
this.electrsApiService.getAddressesByPrefix$(text).pipe(catchError(() => of([]))),
this.apiService.lightningSearch$(text).pipe(catchError(() => of({
nodes: [],
channels: [],
}))),
);
}),
tap((result: any[]) => {
this.isTypeaheading$.next(false);
})
);
this.typeAhead$ = combineLatest(
[
searchText$,
searchResults$.pipe(
startWith([
[],
{
nodes: [],
channels: [],
}
]))
]
).pipe(
map((latestData) => {
const searchText = latestData[0];
if (!searchText.length) {
return {
searchText: '',
hashQuickMatch: false,
blockHeight: false,
txId: false,
address: false,
addresses: [],
nodes: [], nodes: [],
channels: [], channels: [],
}))), };
);
}),
map((result: any[]) => {
this.isTypeaheading$.next(false);
if (this.network === 'bisq') {
return result[0].map((address: string) => 'B' + address);
} }
const result = latestData[1];
const addressPrefixSearchResults = result[0];
const lightningResults = result[1];
if (this.network === 'bisq') {
return searchText.map((address: string) => 'B' + address);
}
const matchesBlockHeight = this.regexBlockheight.test(searchText);
const matchesTxId = this.regexTransaction.test(searchText) && !this.regexBlockhash.test(searchText);
const matchesBlockHash = this.regexBlockhash.test(searchText);
const matchesAddress = this.regexAddress.test(searchText);
return { return {
searchText: result[0], searchText: searchText,
blockHeight: this.regexBlockheight.test(result[0]) ? [parseInt(result[0], 10)] : [], hashQuickMatch: +(matchesBlockHeight || matchesBlockHash || matchesTxId || matchesAddress),
addresses: result[1], blockHeight: matchesBlockHeight,
nodes: result[2].nodes, txId: matchesTxId,
channels: result[2].channels, blockHash: matchesBlockHash,
totalResults: result[1].length + result[2].nodes.length + result[2].channels.length, address: matchesAddress,
addresses: addressPrefixSearchResults,
nodes: lightningResults.nodes,
channels: lightningResults.channels,
}; };
}) })
); );
} }
handleKeyDown($event) {
handleKeyDown($event): void {
this.searchResults.handleKeyDown($event); this.searchResults.handleKeyDown($event);
} }
itemSelected() { itemSelected(): void {
setTimeout(() => this.search()); setTimeout(() => this.search());
} }
selectedResult(result: any) { selectedResult(result: any): void {
if (typeof result === 'string') { if (typeof result === 'string') {
this.search(result); this.search(result);
} else if (typeof result === 'number') { } else if (typeof result === 'number') {
@@ -136,7 +188,7 @@ export class SearchFormComponent implements OnInit {
} }
} }
search(result?: string) { search(result?: string): void {
const searchText = result || this.searchForm.value.searchText.trim(); const searchText = result || this.searchForm.value.searchText.trim();
if (searchText) { if (searchText) {
this.isSearching = true; this.isSearching = true;
@@ -170,7 +222,7 @@ export class SearchFormComponent implements OnInit {
} }
} }
navigate(url: string, searchText: string, extras?: any) { navigate(url: string, searchText: string, extras?: any): void {
this.router.navigate([this.relativeUrlPipe.transform(url), searchText], extras); this.router.navigate([this.relativeUrlPipe.transform(url), searchText], extras);
this.searchTriggered.emit(); this.searchTriggered.emit();
this.searchForm.setValue({ this.searchForm.setValue({

View File

@@ -1,14 +1,32 @@
<div class="dropdown-menu show" *ngIf="results" [hidden]="!results.blockHeight.length && !results.addresses.length && !results.nodes.length && !results.channels.length"> <div class="dropdown-menu show" *ngIf="results" [hidden]="!results.hashQuickMatch && !results.addresses.length && !results.nodes.length && !results.channels.length">
<ng-template [ngIf]="results.blockHeight.length"> <ng-template [ngIf]="results.blockHeight">
<div class="card-title">Bitcoin Block Height</div> <div class="card-title">Bitcoin Block Height</div>
<button (click)="clickItem(0)" [class.active]="0 === activeIdx" type="button" role="option" class="dropdown-item"> <button (click)="clickItem(0)" [class.active]="0 === activeIdx" type="button" role="option" class="dropdown-item">
Go to "{{ results.searchText }}" Go to "{{ results.searchText }}"
</button> </button>
</ng-template> </ng-template>
<ng-template [ngIf]="results.txId">
<div class="card-title">Bitcoin Transaction</div>
<button (click)="clickItem(0)" [class.active]="0 === activeIdx" type="button" role="option" class="dropdown-item">
Go to "{{ results.searchText | shortenString : 13 }}"
</button>
</ng-template>
<ng-template [ngIf]="results.address">
<div class="card-title">Bitcoin Address</div>
<button (click)="clickItem(0)" [class.active]="0 === activeIdx" type="button" role="option" class="dropdown-item">
Go to "{{ results.searchText | shortenString : isMobile ? 20 : 30 }}"
</button>
</ng-template>
<ng-template [ngIf]="results.blockHash">
<div class="card-title">Bitcoin Block</div>
<button (click)="clickItem(0)" [class.active]="0 === activeIdx" type="button" role="option" class="dropdown-item">
Go to "{{ results.searchText | shortenString : 13 }}"
</button>
</ng-template>
<ng-template [ngIf]="results.addresses.length"> <ng-template [ngIf]="results.addresses.length">
<div class="card-title" *ngIf="stateService.env.LIGHTNING">Bitcoin Addresses</div> <div class="card-title">Bitcoin Addresses</div>
<ng-template ngFor [ngForOf]="results.addresses" let-address let-i="index"> <ng-template ngFor [ngForOf]="results.addresses" let-address let-i="index">
<button (click)="clickItem(results.blockHeight.length + i)" [class.active]="(results.blockHeight.length + i) === activeIdx" type="button" role="option" class="dropdown-item"> <button (click)="clickItem(results.hashQuickMatch + i)" [class.active]="(results.hashQuickMatch + i) === activeIdx" type="button" role="option" class="dropdown-item">
<ngb-highlight [result]="address | shortenString : isMobile ? 25 : 36" [term]="results.searchText"></ngb-highlight> <ngb-highlight [result]="address | shortenString : isMobile ? 25 : 36" [term]="results.searchText"></ngb-highlight>
</button> </button>
</ng-template> </ng-template>
@@ -16,7 +34,7 @@
<ng-template [ngIf]="results.nodes.length"> <ng-template [ngIf]="results.nodes.length">
<div class="card-title">Lightning Nodes</div> <div class="card-title">Lightning Nodes</div>
<ng-template ngFor [ngForOf]="results.nodes" let-node let-i="index"> <ng-template ngFor [ngForOf]="results.nodes" let-node let-i="index">
<button (click)="clickItem(results.blockHeight.length + results.addresses.length + i)" [class.inactive]="node.status === 0" [class.active]="results.blockHeight.length + results.addresses.length + i === activeIdx" [routerLink]="['/lightning/node' | relativeUrl, node.public_key]" type="button" role="option" class="dropdown-item"> <button (click)="clickItem(results.hashQuickMatch + results.addresses.length + i)" [class.inactive]="node.status === 0" [class.active]="results.hashQuickMatch + results.addresses.length + i === activeIdx" [routerLink]="['/lightning/node' | relativeUrl, node.public_key]" type="button" role="option" class="dropdown-item">
<ngb-highlight [result]="node.alias" [term]="results.searchText"></ngb-highlight> &nbsp;<span class="symbol">{{ node.public_key | shortenString : 10 }}</span> <ngb-highlight [result]="node.alias" [term]="results.searchText"></ngb-highlight> &nbsp;<span class="symbol">{{ node.public_key | shortenString : 10 }}</span>
</button> </button>
</ng-template> </ng-template>
@@ -24,7 +42,7 @@
<ng-template [ngIf]="results.channels.length"> <ng-template [ngIf]="results.channels.length">
<div class="card-title">Lightning Channels</div> <div class="card-title">Lightning Channels</div>
<ng-template ngFor [ngForOf]="results.channels" let-channel let-i="index"> <ng-template ngFor [ngForOf]="results.channels" let-channel let-i="index">
<button (click)="clickItem(results.blockHeight.length + results.addresses.length + results.nodes.length + i)" [class.inactive]="channel.status === 2" [class.active]="results.blockHeight.length + results.addresses.length + results.nodes.length + i === activeIdx" type="button" role="option" class="dropdown-item"> <button (click)="clickItem(results.hashQuickMatch + results.addresses.length + results.nodes.length + i)" [class.inactive]="channel.status === 2" [class.active]="results.hashQuickMatch + results.addresses.length + results.nodes.length + i === activeIdx" type="button" role="option" class="dropdown-item">
<ngb-highlight [result]="channel.short_id" [term]="results.searchText"></ngb-highlight> &nbsp;<span class="symbol">{{ channel.id }}</span> <ngb-highlight [result]="channel.short_id" [term]="results.searchText"></ngb-highlight> &nbsp;<span class="symbol">{{ channel.id }}</span>
</button> </button>
</ng-template> </ng-template>

View File

@@ -22,7 +22,7 @@ export class SearchResultsComponent implements OnChanges {
ngOnChanges() { ngOnChanges() {
this.activeIdx = 0; this.activeIdx = 0;
if (this.results) { if (this.results) {
this.resultsFlattened = [...this.results.blockHeight, ...this.results.addresses, ...this.results.nodes, ...this.results.channels]; this.resultsFlattened = [...(this.results.hashQuickMatch ? [this.results.searchText] : []), ...this.results.addresses, ...this.results.nodes, ...this.results.channels];
} }
} }

View File

@@ -8,7 +8,7 @@
<div *ngIf="countdown > 0" class="warning-label">{{ eventName }} in {{ countdown | number }} block{{ countdown === 1 ? '' : 's' }}!</div> <div *ngIf="countdown > 0" class="warning-label">{{ eventName }} in {{ countdown | number }} block{{ countdown === 1 ? '' : 's' }}!</div>
<div id="blockchain-container" dir="ltr" #blockchainContainer <div id="blockchain-container" [dir]="timeLtr ? 'rtl' : 'ltr'" #blockchainContainer
(mousedown)="onMouseDown($event)" (mousedown)="onMouseDown($event)"
(dragstart)="onDragStart($event)" (dragstart)="onDragStart($event)"
> >

View File

@@ -1,4 +1,5 @@
import { Component, ElementRef, HostListener, OnInit, ViewChild } from '@angular/core'; import { Component, ElementRef, HostListener, OnInit, OnDestroy, ViewChild } from '@angular/core';
import { Subscription } from 'rxjs';
import { StateService } from '../../services/state.service'; import { StateService } from '../../services/state.service';
import { specialBlocks } from '../../app.constants'; import { specialBlocks } from '../../app.constants';
@@ -7,7 +8,7 @@ import { specialBlocks } from '../../app.constants';
templateUrl: './start.component.html', templateUrl: './start.component.html',
styleUrls: ['./start.component.scss'], styleUrls: ['./start.component.scss'],
}) })
export class StartComponent implements OnInit { export class StartComponent implements OnInit, OnDestroy {
interval = 60; interval = 60;
colors = ['#5E35B1', '#ffffff']; colors = ['#5E35B1', '#ffffff'];
@@ -16,6 +17,8 @@ export class StartComponent implements OnInit {
eventName = ''; eventName = '';
mouseDragStartX: number; mouseDragStartX: number;
blockchainScrollLeftInit: number; blockchainScrollLeftInit: number;
timeLtrSubscription: Subscription;
timeLtr: boolean = this.stateService.timeLtr.value;
@ViewChild('blockchainContainer') blockchainContainer: ElementRef; @ViewChild('blockchainContainer') blockchainContainer: ElementRef;
constructor( constructor(
@@ -23,6 +26,9 @@ export class StartComponent implements OnInit {
) { } ) { }
ngOnInit() { ngOnInit() {
this.timeLtrSubscription = this.stateService.timeLtr.subscribe((ltr) => {
this.timeLtr = !!ltr;
});
this.stateService.blocks$ this.stateService.blocks$
.subscribe((blocks: any) => { .subscribe((blocks: any) => {
if (this.stateService.network !== '') { if (this.stateService.network !== '') {
@@ -72,4 +78,8 @@ export class StartComponent implements OnInit {
this.mouseDragStartX = null; this.mouseDragStartX = null;
this.stateService.setBlockScrollingInProgress(false); this.stateService.setBlockScrollingInProgress(false);
} }
ngOnDestroy() {
this.timeLtrSubscription.unsubscribe();
}
} }

View File

@@ -11,11 +11,15 @@
[showZoom]="false" [showZoom]="false"
></app-mempool-graph> ></app-mempool-graph>
</div> </div>
<div class="blockchain-wrapper"> <div class="blockchain-wrapper" [dir]="timeLtr ? 'rtl' : 'ltr'" [class.time-ltr]="timeLtr">
<div class="position-container"> <div class="position-container">
<app-mempool-blocks></app-mempool-blocks> <span>
<app-blockchain-blocks></app-blockchain-blocks> <div class="blocks-wrapper">
<div id="divider"></div> <app-mempool-blocks></app-mempool-blocks>
<app-blockchain-blocks></app-blockchain-blocks>
</div>
<div id="divider"></div>
</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -31,8 +31,9 @@
.position-container { .position-container {
position: absolute; position: absolute;
left: 50%; left: 0;
bottom: 170px; bottom: 170px;
transform: translateX(50vw);
} }
#divider { #divider {
@@ -47,9 +48,33 @@
top: -28px; top: -28px;
} }
} }
&.time-ltr {
.blocks-wrapper {
transform: scaleX(-1);
}
}
} }
:host-context(.ltr-layout) {
.blockchain-wrapper.time-ltr .blocks-wrapper,
.blockchain-wrapper .blocks-wrapper {
direction: ltr;
}
}
:host-context(.rtl-layout) {
.blockchain-wrapper.time-ltr .blocks-wrapper,
.blockchain-wrapper .blocks-wrapper {
direction: rtl;
}
}
.tv-container { .tv-container {
display: flex; display: flex;
margin-top: 0px; margin-top: 0px;
flex-direction: column; flex-direction: column;
} }

View File

@@ -1,4 +1,4 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit, OnDestroy } from '@angular/core';
import { WebsocketService } from '../../services/websocket.service'; import { WebsocketService } from '../../services/websocket.service';
import { OptimizedMempoolStats } from '../../interfaces/node-api.interface'; import { OptimizedMempoolStats } from '../../interfaces/node-api.interface';
import { StateService } from '../../services/state.service'; import { StateService } from '../../services/state.service';
@@ -6,7 +6,7 @@ import { ApiService } from '../../services/api.service';
import { SeoService } from '../../services/seo.service'; import { SeoService } from '../../services/seo.service';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { map, scan, startWith, switchMap, tap } from 'rxjs/operators'; import { map, scan, startWith, switchMap, tap } from 'rxjs/operators';
import { interval, merge, Observable } from 'rxjs'; import { interval, merge, Observable, Subscription } from 'rxjs';
import { ChangeDetectionStrategy } from '@angular/core'; import { ChangeDetectionStrategy } from '@angular/core';
@Component({ @Component({
@@ -15,11 +15,13 @@ import { ChangeDetectionStrategy } from '@angular/core';
styleUrls: ['./television.component.scss'], styleUrls: ['./television.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class TelevisionComponent implements OnInit { export class TelevisionComponent implements OnInit, OnDestroy {
mempoolStats: OptimizedMempoolStats[] = []; mempoolStats: OptimizedMempoolStats[] = [];
statsSubscription$: Observable<OptimizedMempoolStats[]>; statsSubscription$: Observable<OptimizedMempoolStats[]>;
fragment: string; fragment: string;
timeLtrSubscription: Subscription;
timeLtr: boolean = this.stateService.timeLtr.value;
constructor( constructor(
private websocketService: WebsocketService, private websocketService: WebsocketService,
@@ -37,6 +39,10 @@ export class TelevisionComponent implements OnInit {
this.seoService.setTitle($localize`:@@46ce8155c9ab953edeec97e8950b5a21e67d7c4e:TV view`); this.seoService.setTitle($localize`:@@46ce8155c9ab953edeec97e8950b5a21e67d7c4e:TV view`);
this.websocketService.want(['blocks', 'live-2h-chart', 'mempool-blocks']); this.websocketService.want(['blocks', 'live-2h-chart', 'mempool-blocks']);
this.timeLtrSubscription = this.stateService.timeLtr.subscribe((ltr) => {
this.timeLtr = !!ltr;
});
this.statsSubscription$ = merge( this.statsSubscription$ = merge(
this.stateService.live2Chart$.pipe(map(stats => [stats])), this.stateService.live2Chart$.pipe(map(stats => [stats])),
this.route.fragment this.route.fragment
@@ -70,4 +76,8 @@ export class TelevisionComponent implements OnInit {
}) })
); );
} }
ngOnDestroy() {
this.timeLtrSubscription.unsubscribe();
}
} }

View File

@@ -24,7 +24,7 @@
</ng-template> </ng-template>
</span> </span>
<span class="field col-sm-4 text-center"><ng-container *ngIf="transactionTime > 0">&lrm;{{ transactionTime * 1000 | date:'yyyy-MM-dd HH:mm' }}</ng-container></span> <span class="field col-sm-4 text-center"><ng-container *ngIf="transactionTime > 0">&lrm;{{ transactionTime * 1000 | date:'yyyy-MM-dd HH:mm' }}</ng-container></span>
<span class="field col-sm-4 text-right"><span class="label" i18n="transaction.fee|Transaction fee">Fee </span>{{ tx.fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span></span> <span class="field col-sm-4 text-right"><span class="label" i18n="transaction.fee|Transaction fee">Fee</span> {{ tx.fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span></span>
</div> </div>

View File

@@ -190,12 +190,12 @@
<br> <br>
<ng-container *ngIf="showFlow; else flowPlaceholder"> <ng-container *ngIf="flowEnabled; else flowPlaceholder">
<div class="title float-left"> <div class="title float-left">
<h2 id="flow" i18n="transaction.flow|Transaction flow">Flow</h2> <h2 id="flow" i18n="transaction.flow|Transaction flow">Flow</h2>
</div> </div>
<button type="button" class="btn btn-outline-info flow-toggle btn-sm float-right" (click)="toggleGraph()" i18n="hide-flow-diagram">Hide flow diagram</button> <button type="button" class="btn btn-outline-info flow-toggle btn-sm float-right" (click)="toggleGraph()" i18n="hide-diagram">Hide diagram</button>
<div class="clearfix"></div> <div class="clearfix"></div>
@@ -208,7 +208,9 @@
[lineLimit]="inOutLimit" [lineLimit]="inOutLimit"
[maxStrands]="graphExpanded ? maxInOut : 24" [maxStrands]="graphExpanded ? maxInOut : 24"
[network]="network" [network]="network"
[tooltip]="true"> [tooltip]="true"
[inputIndex]="inputIndex" [outputIndex]="outputIndex"
>
</tx-bowtie-graph> </tx-bowtie-graph>
</div> </div>
<div class="toggle-wrapper" *ngIf="maxInOut > 24"> <div class="toggle-wrapper" *ngIf="maxInOut > 24">
@@ -234,13 +236,13 @@
</div> </div>
<div class="title-buttons"> <div class="title-buttons">
<button *ngIf="!showFlow" type="button" class="btn btn-outline-info flow-toggle btn-sm" (click)="toggleGraph()" i18n="show">Show flow diagram</button> <button *ngIf="!flowEnabled" type="button" class="btn btn-outline-info flow-toggle btn-sm" (click)="toggleGraph()" i18n="show-diagram">Show diagram</button>
<button type="button" class="btn btn-outline-info btn-sm" (click)="txList.toggleDetails()" i18n="transaction.details|Transaction Details">Details</button> <button type="button" class="btn btn-outline-info btn-sm" (click)="txList.toggleDetails()" i18n="transaction.details|Transaction Details">Details</button>
</div> </div>
</div> </div>
<app-transactions-list #txList [transactions]="[tx]" [errorUnblinded]="errorUnblinded" [outputIndex]="outputIndex" [transactionPage]="true"></app-transactions-list> <app-transactions-list #txList [transactions]="[tx]" [errorUnblinded]="errorUnblinded" [inputIndex]="inputIndex" [outputIndex]="outputIndex" [transactionPage]="true"></app-transactions-list>
<div class="title text-left"> <div class="title text-left">
<h2 i18n="transaction.details">Details</h2> <h2 i18n="transaction.details">Details</h2>
@@ -325,7 +327,7 @@
<br> <br>
<ng-container *ngIf="showFlow"> <ng-container *ngIf="flowEnabled">
<div class="title"> <div class="title">
<h2 i18n="transaction.flow|Transaction flow">Flow</h2> <h2 i18n="transaction.flow|Transaction flow">Flow</h2>
</div> </div>

View File

@@ -7,34 +7,34 @@
} }
.title-block { .title-block {
flex-wrap: wrap; flex-wrap: wrap;
align-items: baseline;
@media (min-width: 650px) { @media (min-width: 650px) {
flex-direction: row; flex-direction: row;
} }
h1 { h1 {
margin: 0rem; margin: 0rem;
margin-right: 15px;
line-height: 1; line-height: 1;
} }
} }
.tx-link { .tx-link {
display: flex;
flex-grow: 1;
margin-bottom: 0px; margin-bottom: 0px;
margin-top: 8px; margin-top: 8px;
@media (min-width: 650px) { display: inline-block;
align-self: end; width: 100%;
margin-left: 15px; flex-shrink: 0;
margin-top: 0px; @media (min-width: 651px) {
margin-bottom: -3px; display: flex;
} width: auto;
@media (min-width: 768px) { flex-grow: 1;
margin-bottom: 0px; margin-bottom: 0px;
top: 1px; top: 1px;
position: relative; position: relative;
} }
@media (max-width: 768px) { @media (max-width: 650px) {
order: 3; order: 3;
} }
} }
.td-width { .td-width {

View File

@@ -18,6 +18,7 @@ import { ApiService } from '../../services/api.service';
import { SeoService } from '../../services/seo.service'; import { SeoService } from '../../services/seo.service';
import { BlockExtended, CpfpInfo } from '../../interfaces/node-api.interface'; import { BlockExtended, CpfpInfo } from '../../interfaces/node-api.interface';
import { LiquidUnblinding } from './liquid-ublinding'; import { LiquidUnblinding } from './liquid-ublinding';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
@Component({ @Component({
selector: 'app-transaction', selector: 'app-transaction',
@@ -40,6 +41,8 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
txReplacedSubscription: Subscription; txReplacedSubscription: Subscription;
blocksSubscription: Subscription; blocksSubscription: Subscription;
queryParamsSubscription: Subscription; queryParamsSubscription: Subscription;
urlFragmentSubscription: Subscription;
fragmentParams: URLSearchParams;
rbfTransaction: undefined | Transaction; rbfTransaction: undefined | Transaction;
cpfpInfo: CpfpInfo | null; cpfpInfo: CpfpInfo | null;
showCpfpDetails = false; showCpfpDetails = false;
@@ -47,13 +50,17 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
now = new Date().getTime(); now = new Date().getTime();
timeAvg$: Observable<number>; timeAvg$: Observable<number>;
liquidUnblinding = new LiquidUnblinding(); liquidUnblinding = new LiquidUnblinding();
inputIndex: number;
outputIndex: number; outputIndex: number;
showFlow: boolean = true;
graphExpanded: boolean = false; graphExpanded: boolean = false;
graphWidth: number = 1000; graphWidth: number = 1000;
graphHeight: number = 360; graphHeight: number = 360;
inOutLimit: number = 150; inOutLimit: number = 150;
maxInOut: number = 0; maxInOut: number = 0;
flowPrefSubscription: Subscription;
hideFlow: boolean = this.stateService.hideFlow.value;
overrideFlowPreference: boolean = null;
flowEnabled: boolean;
tooltipPosition: { x: number, y: number }; tooltipPosition: { x: number, y: number };
@@ -63,6 +70,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private relativeUrlPipe: RelativeUrlPipe,
private electrsApiService: ElectrsApiService, private electrsApiService: ElectrsApiService,
private stateService: StateService, private stateService: StateService,
private websocketService: WebsocketService, private websocketService: WebsocketService,
@@ -77,12 +85,26 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
(network) => (this.network = network) (network) => (this.network = network)
); );
this.setFlowEnabled();
this.flowPrefSubscription = this.stateService.hideFlow.subscribe((hide) => {
this.hideFlow = !!hide;
this.setFlowEnabled();
});
this.timeAvg$ = timer(0, 1000) this.timeAvg$ = timer(0, 1000)
.pipe( .pipe(
switchMap(() => this.stateService.difficultyAdjustment$), switchMap(() => this.stateService.difficultyAdjustment$),
map((da) => da.timeAvg) map((da) => da.timeAvg)
); );
this.urlFragmentSubscription = this.route.fragment.subscribe((fragment) => {
this.fragmentParams = new URLSearchParams(fragment || '');
const vin = parseInt(this.fragmentParams.get('vin'), 10);
const vout = parseInt(this.fragmentParams.get('vout'), 10);
this.inputIndex = (!isNaN(vin) && vin >= 0) ? vin : null;
this.outputIndex = (!isNaN(vout) && vout >= 0) ? vout : null;
});
this.fetchCpfpSubscription = this.fetchCpfp$ this.fetchCpfpSubscription = this.fetchCpfp$
.pipe( .pipe(
switchMap((txId) => switchMap((txId) =>
@@ -121,8 +143,31 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
.pipe( .pipe(
switchMap((params: ParamMap) => { switchMap((params: ParamMap) => {
const urlMatch = (params.get('id') || '').split(':'); const urlMatch = (params.get('id') || '').split(':');
this.txId = urlMatch[0]; if (urlMatch.length === 2 && urlMatch[1].length === 64) {
this.outputIndex = urlMatch[1] === undefined ? null : parseInt(urlMatch[1], 10); const vin = parseInt(urlMatch[0], 10);
this.txId = urlMatch[1];
// rewrite legacy vin syntax
if (!isNaN(vin)) {
this.fragmentParams.set('vin', vin.toString());
this.fragmentParams.delete('vout');
}
this.router.navigate([this.relativeUrlPipe.transform('/tx'), this.txId], {
queryParamsHandling: 'merge',
fragment: this.fragmentParams.toString(),
});
} else {
this.txId = urlMatch[0];
const vout = parseInt(urlMatch[1], 10);
if (urlMatch.length > 1 && !isNaN(vout)) {
// rewrite legacy vout syntax
this.fragmentParams.set('vout', vout.toString());
this.fragmentParams.delete('vin');
this.router.navigate([this.relativeUrlPipe.transform('/tx'), this.txId], {
queryParamsHandling: 'merge',
fragment: this.fragmentParams.toString(),
});
}
}
this.seoService.setTitle( this.seoService.setTitle(
$localize`:@@bisq.transaction.browser-title:Transaction: ${this.txId}:INTERPOLATION:` $localize`:@@bisq.transaction.browser-title:Transaction: ${this.txId}:INTERPOLATION:`
); );
@@ -205,6 +250,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.fetchCpfp$.next(this.tx.txid); this.fetchCpfp$.next(this.tx.txid);
} }
} }
setTimeout(() => { this.applyFragment(); }, 0);
}, },
(error) => { (error) => {
this.error = error; this.error = error;
@@ -237,11 +283,14 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.queryParamsSubscription = this.route.queryParams.subscribe((params) => { this.queryParamsSubscription = this.route.queryParams.subscribe((params) => {
if (params.showFlow === 'false') { if (params.showFlow === 'false') {
this.showFlow = false; this.overrideFlowPreference = false;
} else if (params.showFlow === 'true') {
this.overrideFlowPreference = true;
} else { } else {
this.showFlow = true; this.overrideFlowPreference = null;
this.setGraphSize();
} }
this.setFlowEnabled();
this.setGraphSize();
}); });
} }
@@ -317,15 +366,20 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
} }
toggleGraph() { toggleGraph() {
this.showFlow = !this.showFlow; const showFlow = !this.flowEnabled;
this.stateService.hideFlow.next(!showFlow);
this.router.navigate([], { this.router.navigate([], {
relativeTo: this.route, relativeTo: this.route,
queryParams: { showFlow: this.showFlow }, queryParams: { showFlow: showFlow },
queryParamsHandling: 'merge', queryParamsHandling: 'merge',
fragment: 'flow' fragment: 'flow'
}); });
} }
setFlowEnabled() {
this.flowEnabled = (this.overrideFlowPreference != null ? this.overrideFlowPreference : !this.hideFlow);
}
expandGraph() { expandGraph() {
this.graphExpanded = true; this.graphExpanded = true;
} }
@@ -334,6 +388,17 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.graphExpanded = false; this.graphExpanded = false;
} }
// simulate normal anchor fragment behavior
applyFragment(): void {
const anchor = Array.from(this.fragmentParams.entries()).find(([frag, value]) => value === '');
if (anchor) {
const anchorElement = document.getElementById(anchor[0]);
if (anchorElement) {
anchorElement.scrollIntoView();
}
}
}
@HostListener('window:resize', ['$event']) @HostListener('window:resize', ['$event'])
setGraphSize(): void { setGraphSize(): void {
if (this.graphContainer) { if (this.graphContainer) {
@@ -347,6 +412,8 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.txReplacedSubscription.unsubscribe(); this.txReplacedSubscription.unsubscribe();
this.blocksSubscription.unsubscribe(); this.blocksSubscription.unsubscribe();
this.queryParamsSubscription.unsubscribe(); this.queryParamsSubscription.unsubscribe();
this.flowPrefSubscription.unsubscribe();
this.urlFragmentSubscription.unsubscribe();
this.leaveTransaction(); this.leaveTransaction();
} }
} }

View File

@@ -20,9 +20,9 @@
<div class="col"> <div class="col">
<table class="table table-borderless smaller-text table-sm table-tx-vin"> <table class="table table-borderless smaller-text table-sm table-tx-vin">
<tbody> <tbody>
<ng-template ngFor let-vin let-vindex="index" [ngForOf]="tx['@vinLimit'] ? ((tx.vin.length > rowLimit) ? tx.vin.slice(0, rowLimit - 2) : tx.vin.slice(0, rowLimit)) : tx.vin" [ngForTrackBy]="trackByIndexFn"> <ng-template ngFor let-vin let-vindex="index" [ngForOf]="tx['@vinLimit'] ? ((tx.vin.length > inputRowLimit) ? tx.vin.slice(0, inputRowLimit - 2) : tx.vin.slice(0, inputRowLimit)) : tx.vin" [ngForTrackBy]="trackByIndexFn">
<tr [ngClass]="{ <tr [ngClass]="{
'assetBox': assetsMinimal && vin.prevout && assetsMinimal[vin.prevout.asset] && !vin.is_coinbase && vin.prevout.scriptpubkey_address && tx._unblinded, 'assetBox': (assetsMinimal && vin.prevout && assetsMinimal[vin.prevout.asset] && !vin.is_coinbase && vin.prevout.scriptpubkey_address && tx._unblinded) || inputIndex === vindex,
'highlight': vin.prevout?.scriptpubkey_address === this.address && this.address !== '' 'highlight': vin.prevout?.scriptpubkey_address === this.address && this.address !== ''
}"> }">
<td class="arrow-td"> <td class="arrow-td">
@@ -43,7 +43,7 @@
</ng-template> </ng-template>
</ng-template> </ng-template>
<ng-template #defaultPrevout> <ng-template #defaultPrevout>
<a [routerLink]="['/tx/' | relativeUrl, vin.txid + ':' + vin.vout]" class="red"> <a [routerLink]="['/tx/' | relativeUrl, vin.txid]" [fragment]="'vout=' + vin.vout" class="red">
<fa-icon [icon]="['fas', 'arrow-alt-circle-right']" [fixedWidth]="true"></fa-icon> <fa-icon [icon]="['fas', 'arrow-alt-circle-right']" [fixedWidth]="true"></fa-icon>
</a> </a>
</ng-template> </ng-template>
@@ -146,7 +146,7 @@
</td> </td>
</tr> </tr>
</ng-template> </ng-template>
<tr *ngIf="tx.vin.length > rowLimit && tx['@vinLimit']"> <tr *ngIf="tx.vin.length > inputRowLimit && tx['@vinLimit']">
<td colspan="3" class="text-center"> <td colspan="3" class="text-center">
<button class="btn btn-sm btn-primary mt-2" (click)="loadMoreInputs(tx);"><span i18n="show-all">Show all</span> ({{ tx.vin.length }})</button> <button class="btn btn-sm btn-primary mt-2" (click)="loadMoreInputs(tx);"><span i18n="show-all">Show all</span> ({{ tx.vin.length }})</button>
</td> </td>
@@ -158,7 +158,7 @@
<div class="col mobile-bottomcol"> <div class="col mobile-bottomcol">
<table class="table table-borderless smaller-text table-sm table-tx-vout"> <table class="table table-borderless smaller-text table-sm table-tx-vout">
<tbody> <tbody>
<ng-template ngFor let-vout let-vindex="index" [ngForOf]="tx['@voutLimit'] && !outputIndex ? ((tx.vout.length > rowLimit) ? tx.vout.slice(0, rowLimit - 2) : tx.vout.slice(0, rowLimit)) : tx.vout" [ngForTrackBy]="trackByIndexFn"> <ng-template ngFor let-vout let-vindex="index" [ngForOf]="tx['@voutLimit'] ? ((tx.vout.length > outputRowLimit) ? tx.vout.slice(0, outputRowLimit - 2) : tx.vout.slice(0, outputRowLimit)) : tx.vout" [ngForTrackBy]="trackByIndexFn">
<tr [ngClass]="{ <tr [ngClass]="{
'assetBox': assetsMinimal && assetsMinimal[vout.asset] && vout.scriptpubkey_address && tx.vin && !tx.vin[0].is_coinbase && tx._unblinded || outputIndex === vindex, 'assetBox': assetsMinimal && assetsMinimal[vout.asset] && vout.scriptpubkey_address && tx.vin && !tx.vin[0].is_coinbase && tx._unblinded || outputIndex === vindex,
'highlight': vout.scriptpubkey_address === this.address && this.address !== '' 'highlight': vout.scriptpubkey_address === this.address && this.address !== ''
@@ -220,7 +220,7 @@
<fa-icon [icon]="['fas', 'arrow-alt-circle-right']" [fixedWidth]="true"></fa-icon> <fa-icon [icon]="['fas', 'arrow-alt-circle-right']" [fixedWidth]="true"></fa-icon>
</span> </span>
<ng-template #spent> <ng-template #spent>
<a *ngIf="tx._outspends[vindex].txid else outputNoTxId" [routerLink]="['/tx/' | relativeUrl, tx._outspends[vindex].txid]" class="red"> <a *ngIf="tx._outspends[vindex].txid else outputNoTxId" [routerLink]="['/tx/' | relativeUrl, tx._outspends[vindex].txid]" [fragment]="'vin=' + tx._outspends[vindex].vin" class="red">
<fa-icon [icon]="['fas', 'arrow-alt-circle-right']" [fixedWidth]="true"></fa-icon> <fa-icon [icon]="['fas', 'arrow-alt-circle-right']" [fixedWidth]="true"></fa-icon>
</a> </a>
<ng-template #outputNoTxId> <ng-template #outputNoTxId>
@@ -257,7 +257,7 @@
</td> </td>
</tr> </tr>
</ng-template> </ng-template>
<tr *ngIf="tx.vout.length > rowLimit && tx['@voutLimit'] && !outputIndex"> <tr *ngIf="tx.vout.length > outputRowLimit && tx['@voutLimit']">
<td colspan="3" class="text-center"> <td colspan="3" class="text-center">
<button class="btn btn-sm btn-primary mt-2" (click)="tx['@voutLimit'] = false;"><span i18n="show-all">Show all</span> ({{ tx.vout.length }})</button> <button class="btn btn-sm btn-primary mt-2" (click)="tx['@voutLimit'] = false;"><span i18n="show-all">Show all</span> ({{ tx.vout.length }})</button>
</td> </td>

View File

@@ -24,6 +24,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
@Input() transactionPage = false; @Input() transactionPage = false;
@Input() errorUnblinded = false; @Input() errorUnblinded = false;
@Input() paginated = false; @Input() paginated = false;
@Input() inputIndex: number;
@Input() outputIndex: number; @Input() outputIndex: number;
@Input() address: string = ''; @Input() address: string = '';
@Input() rowLimit = 12; @Input() rowLimit = 12;
@@ -37,6 +38,8 @@ export class TransactionsListComponent implements OnInit, OnChanges {
showDetails$ = new BehaviorSubject<boolean>(false); showDetails$ = new BehaviorSubject<boolean>(false);
assetsMinimal: any; assetsMinimal: any;
transactionsLength: number = 0; transactionsLength: number = 0;
inputRowLimit: number = 12;
outputRowLimit: number = 12;
constructor( constructor(
public stateService: StateService, public stateService: StateService,
@@ -97,50 +100,57 @@ export class TransactionsListComponent implements OnInit, OnChanges {
).subscribe(() => this.ref.markForCheck()); ).subscribe(() => this.ref.markForCheck());
} }
ngOnChanges(): void { ngOnChanges(changes): void {
if (!this.transactions || !this.transactions.length) { if (changes.inputIndex || changes.outputIndex || changes.rowLimit) {
return; this.inputRowLimit = Math.max(this.rowLimit, (this.inputIndex || 0) + 3);
this.outputRowLimit = Math.max(this.rowLimit, (this.outputIndex || 0) + 3);
if ((this.inputIndex || this.outputIndex) && !changes.transactions) {
setTimeout(() => {
const assetBoxElements = document.getElementsByClassName('assetBox');
if (assetBoxElements && assetBoxElements[0]) {
assetBoxElements[0].scrollIntoView({block: "center"});
}
}, 10);
}
} }
if (changes.transactions || changes.address) {
this.transactionsLength = this.transactions.length; if (!this.transactions || !this.transactions.length) {
if (this.outputIndex) {
setTimeout(() => {
const assetBoxElements = document.getElementsByClassName('assetBox');
if (assetBoxElements && assetBoxElements[0]) {
assetBoxElements[0].scrollIntoView();
}
}, 10);
}
this.transactions.forEach((tx) => {
tx['@voutLimit'] = true;
tx['@vinLimit'] = true;
if (tx['addressValue'] !== undefined) {
return; return;
} }
if (this.address) { this.transactionsLength = this.transactions.length;
const addressIn = tx.vout
.filter((v: Vout) => v.scriptpubkey_address === this.address)
.map((v: Vout) => v.value || 0)
.reduce((a: number, b: number) => a + b, 0);
const addressOut = tx.vin
.filter((v: Vin) => v.prevout && v.prevout.scriptpubkey_address === this.address)
.map((v: Vin) => v.prevout.value || 0)
.reduce((a: number, b: number) => a + b, 0);
tx['addressValue'] = addressIn - addressOut; this.transactions.forEach((tx) => {
} tx['@voutLimit'] = true;
}); tx['@vinLimit'] = true;
const txIds = this.transactions.filter((tx) => !tx._outspends).map((tx) => tx.txid); if (tx['addressValue'] !== undefined) {
if (txIds.length) { return;
this.refreshOutspends$.next(txIds); }
}
if (this.stateService.env.LIGHTNING) { if (this.address) {
const txIds = this.transactions.filter((tx) => !tx._channels).map((tx) => tx.txid); const addressIn = tx.vout
.filter((v: Vout) => v.scriptpubkey_address === this.address)
.map((v: Vout) => v.value || 0)
.reduce((a: number, b: number) => a + b, 0);
const addressOut = tx.vin
.filter((v: Vin) => v.prevout && v.prevout.scriptpubkey_address === this.address)
.map((v: Vin) => v.prevout.value || 0)
.reduce((a: number, b: number) => a + b, 0);
tx['addressValue'] = addressIn - addressOut;
}
});
const txIds = this.transactions.filter((tx) => !tx._outspends).map((tx) => tx.txid);
if (txIds.length) { if (txIds.length) {
this.refreshChannels$.next(txIds); this.refreshOutspends$.next(txIds);
}
if (this.stateService.env.LIGHTNING) {
const txIds = this.transactions.filter((tx) => !tx._channels).map((tx) => tx.txid);
if (txIds.length) {
this.refreshChannels$.next(txIds);
}
} }
} }
} }

View File

@@ -42,9 +42,9 @@
<ng-container [ngSwitch]="line.type"> <ng-container [ngSwitch]="line.type">
<span *ngSwitchCase="'input'" i18n="transaction.input">Input</span> <span *ngSwitchCase="'input'" i18n="transaction.input">Input</span>
<span *ngSwitchCase="'output'" i18n="transaction.output">Output</span> <span *ngSwitchCase="'output'" i18n="transaction.output">Output</span>
<span *ngSwitchCase="'fee'" i18n="transaction.fee">Fee</span> <span *ngSwitchCase="'fee'" i18n="transaction.fee|Transaction fee">Fee</span>
</ng-container> </ng-container>
<span *ngIf="line.type !== 'fee'"> #{{ line.index }}</span> <span *ngIf="line.type !== 'fee'"> #{{ line.index + 1 }}</span>
</p> </p>
<p *ngIf="line.value == null && line.confidential" i18n="shared.confidential">Confidential</p> <p *ngIf="line.value == null && line.confidential" i18n="shared.confidential">Confidential</p>
<p *ngIf="line.value != null"><app-amount [satoshis]="line.value"></app-amount></p> <p *ngIf="line.value != null"><app-amount [satoshis]="line.value"></app-amount></p>

View File

@@ -41,6 +41,18 @@
<stop offset="98%" [attr.stop-color]="gradient[0]" /> <stop offset="98%" [attr.stop-color]="gradient[0]" />
<stop offset="100%" [attr.stop-color]="gradient[0]" /> <stop offset="100%" [attr.stop-color]="gradient[0]" />
</linearGradient> </linearGradient>
<linearGradient id="input-highlight-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" [attr.stop-color]="gradient[0]" />
<stop offset="2%" [attr.stop-color]="gradient[0]" />
<stop offset="30%" stop-color="#1bd8f4" />
<stop offset="100%" [attr.stop-color]="gradient[1]" />
</linearGradient>
<linearGradient id="output-highlight-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" [attr.stop-color]="gradient[1]" />
<stop offset="70%" stop-color="#1bd8f4" />
<stop offset="98%" [attr.stop-color]="gradient[0]" />
<stop offset="100%" [attr.stop-color]="gradient[0]" />
</linearGradient>
<linearGradient id="fee-hover-gradient" x1="0%" y1="0%" x2="100%" y2="0%"> <linearGradient id="fee-hover-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" [attr.stop-color]="gradient[1]" /> <stop offset="0%" [attr.stop-color]="gradient[1]" />
<stop offset="100%" stop-color="white" /> <stop offset="100%" stop-color="white" />
@@ -56,20 +68,24 @@
<path <path
[attr.d]="input.path" [attr.d]="input.path"
class="line {{input.class}}" class="line {{input.class}}"
[class.highlight]="inputIndex != null && inputData[i].index === inputIndex"
[style]="input.style" [style]="input.style"
attr.marker-start="url(#{{input.class}}-arrow)" attr.marker-start="url(#{{input.class}}-arrow)"
(pointerover)="onHover($event, 'input', i);" (pointerover)="onHover($event, 'input', i);"
(pointerout)="onBlur($event, 'input', i);" (pointerout)="onBlur($event, 'input', i);"
(click)="onClick($event, 'input', inputData[i].index);"
/> />
</ng-container> </ng-container>
<ng-container *ngFor="let output of outputs; let i = index"> <ng-container *ngFor="let output of outputs; let i = index">
<path <path
[attr.d]="output.path" [attr.d]="output.path"
class="line {{output.class}}" class="line {{output.class}}"
[class.highlight]="outputIndex != null && outputData[i].index === outputIndex"
[style]="output.style" [style]="output.style"
attr.marker-start="url(#{{output.class}}-arrow)" attr.marker-start="url(#{{output.class}}-arrow)"
(pointerover)="onHover($event, 'output', i);" (pointerover)="onHover($event, 'output', i);"
(pointerout)="onBlur($event, 'output', i);" (pointerout)="onBlur($event, 'output', i);"
(click)="onClick($event, 'output', outputData[i].index);"
/> />
</ng-container> </ng-container>
</svg> </svg>

View File

@@ -12,6 +12,17 @@
stroke: url(#fee-gradient); stroke: url(#fee-gradient);
} }
&.highlight {
z-index: 8;
cursor: pointer;
&.input {
stroke: url(#input-highlight-gradient);
}
&.output {
stroke: url(#output-highlight-gradient);
}
}
&:hover { &:hover {
z-index: 10; z-index: 10;
cursor: pointer; cursor: pointer;

View File

@@ -1,5 +1,11 @@
import { Component, OnInit, Input, OnChanges, HostListener } from '@angular/core'; import { Component, OnInit, Input, OnChanges, HostListener } from '@angular/core';
import { Transaction } from '../../interfaces/electrs.interface'; import { StateService } from '../../services/state.service';
import { Outspend, Transaction } from '../../interfaces/electrs.interface';
import { Router } from '@angular/router';
import { ReplaySubject, merge, Subscription } from 'rxjs';
import { tap, switchMap } from 'rxjs/operators';
import { ApiService } from '../../services/api.service';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
interface SvgLine { interface SvgLine {
path: string; path: string;
@@ -34,6 +40,8 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges {
@Input() minWeight = 2; // @Input() minWeight = 2; //
@Input() maxStrands = 24; // number of inputs/outputs to keep fully on-screen. @Input() maxStrands = 24; // number of inputs/outputs to keep fully on-screen.
@Input() tooltip = false; @Input() tooltip = false;
@Input() inputIndex: number;
@Input() outputIndex: number;
inputData: Xput[]; inputData: Xput[];
outputData: Xput[]; outputData: Xput[];
@@ -45,6 +53,10 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges {
isLiquid: boolean = false; isLiquid: boolean = false;
hoverLine: Xput | void = null; hoverLine: Xput | void = null;
tooltipPosition = { x: 0, y: 0 }; tooltipPosition = { x: 0, y: 0 };
outspends: Outspend[] = [];
outspendsSubscription: Subscription;
refreshOutspends$: ReplaySubject<string> = new ReplaySubject();
gradientColors = { gradientColors = {
'': ['#9339f4', '#105fb0'], '': ['#9339f4', '#105fb0'],
@@ -61,12 +73,45 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges {
gradient: string[] = ['#105fb0', '#105fb0']; gradient: string[] = ['#105fb0', '#105fb0'];
constructor(
private router: Router,
private relativeUrlPipe: RelativeUrlPipe,
private stateService: StateService,
private apiService: ApiService,
) { }
ngOnInit(): void { ngOnInit(): void {
this.initGraph(); this.initGraph();
this.outspendsSubscription = merge(
this.refreshOutspends$
.pipe(
switchMap((txid) => this.apiService.getOutspendsBatched$([txid])),
tap((outspends: Outspend[][]) => {
if (!this.tx || !outspends || !outspends.length) {
return;
}
this.outspends = outspends[0];
}),
),
this.stateService.utxoSpent$
.pipe(
tap((utxoSpent) => {
for (const i in utxoSpent) {
this.outspends[i] = {
spent: true,
txid: utxoSpent[i].txid,
vin: utxoSpent[i].vin,
};
}
}),
),
).subscribe(() => {});
} }
ngOnChanges(): void { ngOnChanges(): void {
this.initGraph(); this.initGraph();
this.refreshOutspends$.next(this.tx.txid);
} }
initGraph(): void { initGraph(): void {
@@ -76,11 +121,12 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges {
this.combinedWeight = Math.min(this.maxCombinedWeight, Math.floor((this.width - (2 * this.midWidth)) / 6)); this.combinedWeight = Math.min(this.maxCombinedWeight, Math.floor((this.width - (2 * this.midWidth)) / 6));
const totalValue = this.calcTotalValue(this.tx); const totalValue = this.calcTotalValue(this.tx);
let voutWithFee = this.tx.vout.map(v => { let voutWithFee = this.tx.vout.map((v, i) => {
return { return {
type: v.scriptpubkey_type === 'fee' ? 'fee' : 'output', type: v.scriptpubkey_type === 'fee' ? 'fee' : 'output',
value: v?.value, value: v?.value,
address: v?.scriptpubkey_address || v?.scriptpubkey_type?.toUpperCase(), address: v?.scriptpubkey_address || v?.scriptpubkey_type?.toUpperCase(),
index: i,
pegout: v?.pegout?.scriptpubkey_address, pegout: v?.pegout?.scriptpubkey_address,
confidential: (this.isLiquid && v?.value === undefined), confidential: (this.isLiquid && v?.value === undefined),
} as Xput; } as Xput;
@@ -91,11 +137,12 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges {
} }
const outputCount = voutWithFee.length; const outputCount = voutWithFee.length;
let truncatedInputs = this.tx.vin.map(v => { let truncatedInputs = this.tx.vin.map((v, i) => {
return { return {
type: 'input', type: 'input',
value: v?.prevout?.value, value: v?.prevout?.value,
address: v?.prevout?.scriptpubkey_address || v?.prevout?.scriptpubkey_type?.toUpperCase(), address: v?.prevout?.scriptpubkey_address || v?.prevout?.scriptpubkey_type?.toUpperCase(),
index: i,
coinbase: v?.is_coinbase, coinbase: v?.is_coinbase,
pegin: v?.is_pegin, pegin: v?.is_pegin,
confidential: (this.isLiquid && v?.prevout?.value === undefined), confidential: (this.isLiquid && v?.prevout?.value === undefined),
@@ -306,8 +353,7 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges {
}; };
} else { } else {
this.hoverLine = { this.hoverLine = {
...this.outputData[index], ...this.outputData[index]
index
}; };
} }
} }
@@ -315,4 +361,47 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges {
onBlur(event, side, index): void { onBlur(event, side, index): void {
this.hoverLine = null; this.hoverLine = null;
} }
onClick(event, side, index): void {
if (side === 'input') {
const input = this.tx.vin[index];
if (input && !input.is_coinbase && !input.is_pegin && input.txid && input.vout != null) {
this.router.navigate([this.relativeUrlPipe.transform('/tx'), input.txid], {
queryParamsHandling: 'merge',
fragment: (new URLSearchParams({
flow: '',
vout: input.vout.toString(),
})).toString(),
});
} else if (index != null) {
this.router.navigate([this.relativeUrlPipe.transform('/tx'), this.tx.txid], {
queryParamsHandling: 'merge',
fragment: (new URLSearchParams({
flow: '',
vin: index.toString(),
})).toString(),
});
}
} else {
const output = this.tx.vout[index];
const outspend = this.outspends[index];
if (output && outspend && outspend.spent && outspend.txid) {
this.router.navigate([this.relativeUrlPipe.transform('/tx'), outspend.txid], {
queryParamsHandling: 'merge',
fragment: (new URLSearchParams({
flow: '',
vin: outspend.vin.toString(),
})).toString(),
});
} else if (index != null) {
this.router.navigate([this.relativeUrlPipe.transform('/tx'), this.tx.txid], {
queryParamsHandling: 'merge',
fragment: (new URLSearchParams({
flow: '',
vout: index.toString(),
})).toString(),
});
}
}
}
} }

View File

@@ -13,7 +13,10 @@
<ng-template #notFullyTaproot> <ng-template #notFullyTaproot>
<span *ngIf="segwitGains.realizedTaprootGains && segwitGains.potentialTaprootGains; else noTaproot" class="badge badge-warning mr-1" i18n-ngbTooltip="Tooltip about fees that saved and could be saved with taproot" ngbTooltip="This transaction uses Taproot and already saved at least {{ segwitGains.realizedTaprootGains * 100 | number: '1.0-0' }}% on fees, but could save an additional {{ segwitGains.potentialTaprootGains * 100 | number: '1.0-0' }}% by fully using Taproot" placement="bottom" i18n="tx-features.tag.taproot|Taproot">Taproot</span> <span *ngIf="segwitGains.realizedTaprootGains && segwitGains.potentialTaprootGains; else noTaproot" class="badge badge-warning mr-1" i18n-ngbTooltip="Tooltip about fees that saved and could be saved with taproot" ngbTooltip="This transaction uses Taproot and already saved at least {{ segwitGains.realizedTaprootGains * 100 | number: '1.0-0' }}% on fees, but could save an additional {{ segwitGains.potentialTaprootGains * 100 | number: '1.0-0' }}% by fully using Taproot" placement="bottom" i18n="tx-features.tag.taproot|Taproot">Taproot</span>
<ng-template #noTaproot> <ng-template #noTaproot>
<span *ngIf="segwitGains.potentialTaprootGains; else taprootButNoGains" class="badge badge-danger mr-1" i18n-ngbTooltip="Tooltip about fees that could be saved with taproot" ngbTooltip="This transaction could save {{ segwitGains.potentialTaprootGains * 100 | number: '1.0-0' }}% on fees by using Taproot" placement="bottom"><del i18n="tx-features.tag.taproot|Taproot">Taproot</del></span> <span *ngIf="segwitGains.potentialTaprootGains && segwitGains.potentialTaprootGains > 0; else negativeTaprootGains" class="badge badge-danger mr-1" i18n-ngbTooltip="Tooltip about fees that could be saved with taproot" ngbTooltip="This transaction could save {{ segwitGains.potentialTaprootGains * 100 | number: '1.0-0' }}% on fees by using Taproot" placement="bottom"><del i18n="tx-features.tag.taproot|Taproot">Taproot</del></span>
<ng-template #negativeTaprootGains>
<span *ngIf="!isTaproot; else taprootButNoGains" class="badge badge-danger mr-1" i18n-ngbTooltip="Tooltip about using taproot" ngbTooltip="This transaction does not use Taproot" placement="bottom"><del i18n="tx-features.tag.taproot|Taproot">Taproot</del></span>
</ng-template>
<ng-template #taprootButNoGains> <ng-template #taprootButNoGains>
<span *ngIf="isTaproot" class="badge badge-success mr-1" i18n-ngbTooltip="Tooltip about taproot" ngbTooltip="This transaction uses Taproot" placement="bottom" i18n="tx-features.tag.taproot|Taproot">Taproot</span> <span *ngIf="isTaproot" class="badge badge-success mr-1" i18n-ngbTooltip="Tooltip about taproot" ngbTooltip="This transaction uses Taproot" placement="bottom" i18n="tx-features.tag.taproot|Taproot">Taproot</span>
</ng-template> </ng-template>

View File

@@ -123,7 +123,7 @@
<th class="table-cell-txid" i18n="dashboard.latest-transactions.txid">TXID</th> <th class="table-cell-txid" i18n="dashboard.latest-transactions.txid">TXID</th>
<th class="table-cell-satoshis" i18n="dashboard.latest-transactions.amount">Amount</th> <th class="table-cell-satoshis" i18n="dashboard.latest-transactions.amount">Amount</th>
<th class="table-cell-fiat" *ngIf="(network$ | async) === ''" i18n="dashboard.latest-transactions.USD">USD</th> <th class="table-cell-fiat" *ngIf="(network$ | async) === ''" i18n="dashboard.latest-transactions.USD">USD</th>
<th class="table-cell-fees" i18n="dashboard.latest-transactions.fee">Fee</th> <th class="table-cell-fees" i18n="transaction.fee|Transaction fee">Fee</th>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let transaction of transactions$ | async; let i = index;"> <tr *ngFor="let transaction of transactions$ | async; let i = index;">

View File

@@ -106,6 +106,20 @@
</div> </div>
</div> </div>
<div id="electrs" *ngIf="whichTab === 'electrs'">
<div class="doc-content no-sidebar">
<div class="doc-item-container">
<p class='subtitle'>Hostname</p>
<p>{{plainHostname}}</p>
<p class="subtitle">Port</p>
<p>{{electrsPort}}</p>
<p class="subtitle">SSL</p>
<p>Enabled</p>
<p class="note" *ngIf="network.val !== 'signet'">Electrum RPC interface for Bitcoin Signet is <a href="/signet/docs/api/electrs">publicly available</a>. Electrum RPC interface for all other networks is available to <a href='/enterprise'>sponsors</a> only—whitelisting is required.</p>
</div>
</div>
</div>
</div> </div>
</ng-container> </ng-container>

View File

@@ -1,7 +1,21 @@
.center {
text-align: center;
}
.note {
font-style: italic;
}
.text-small { .text-small {
font-size: 12px; font-size: 12px;
} }
.container-xl {
display: flex;
min-height: 75vh;
flex-direction: column;
}
code { code {
background-color: #1d1f31; background-color: #1d1f31;
font-family: Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New; font-family: Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New;
@@ -116,6 +130,10 @@ li.nav-item {
float: right; float: right;
} }
.doc-content.no-sidebar {
width: 100%
}
h3 { h3 {
margin: 2rem 0 0 0; margin: 2rem 0 0 0;
} }

View File

@@ -12,6 +12,8 @@ import { FaqTemplateDirective } from '../faq-template/faq-template.component';
styleUrls: ['./api-docs.component.scss'] styleUrls: ['./api-docs.component.scss']
}) })
export class ApiDocsComponent implements OnInit, AfterViewInit { export class ApiDocsComponent implements OnInit, AfterViewInit {
plainHostname = document.location.hostname;
electrsPort = 0;
hostname = document.location.hostname; hostname = document.location.hostname;
network$: Observable<string>; network$: Observable<string>;
active = 0; active = 0;
@@ -82,6 +84,20 @@ export class ApiDocsComponent implements OnInit, AfterViewInit {
this.network$.subscribe((network) => { this.network$.subscribe((network) => {
this.active = (network === 'liquid' || network === 'liquidtestnet') ? 2 : 0; this.active = (network === 'liquid' || network === 'liquidtestnet') ? 2 : 0;
switch( network ) {
case "":
this.electrsPort = 50002; break;
case "mainnet":
this.electrsPort = 50002; break;
case "testnet":
this.electrsPort = 60002; break;
case "signet":
this.electrsPort = 60602; break;
case "liquid":
this.electrsPort = 51002; break;
case "liquidtestnet":
this.electrsPort = 51302; break;
}
}); });
} }

View File

@@ -39,6 +39,7 @@ if (browserWindowEnv.BASE_MODULE && (browserWindowEnv.BASE_MODULE === 'bisq' ||
}, },
{ {
path: 'faq', path: 'faq',
data: { networks: ['bitcoin'] },
component: DocsComponent component: DocsComponent
}, },
{ {

View File

@@ -32,6 +32,15 @@
</ng-template> </ng-template>
</li> </li>
<li [ngbNavItem]="3" *ngIf="showElectrsTab" role="presentation">
<a ngbNavLink [routerLink]="['/docs/api/electrs' | relativeUrl]" role="tab">API - Electrum RPC</a>
<ng-template ngbNavContent>
<app-api-docs [whichTab]="'electrs'"></app-api-docs>
</ng-template>
</li>
</ul> </ul>
<div id="main-tab-content" [ngbNavOutlet]="nav"></div> <div id="main-tab-content" [ngbNavOutlet]="nav"></div>

View File

@@ -15,6 +15,7 @@ export class DocsComponent implements OnInit {
env: Env; env: Env;
showWebSocketTab = true; showWebSocketTab = true;
showFaqTab = true; showFaqTab = true;
showElectrsTab = true;
@HostBinding('attr.dir') dir = 'ltr'; @HostBinding('attr.dir') dir = 'ltr';
@@ -34,14 +35,18 @@ export class DocsComponent implements OnInit {
} else if( url[1].path === "rest" ) { } else if( url[1].path === "rest" ) {
this.activeTab = 1; this.activeTab = 1;
this.seoService.setTitle($localize`:@@e351b40b3869a5c7d19c3d4918cb1ac7aaab95c4:API`); this.seoService.setTitle($localize`:@@e351b40b3869a5c7d19c3d4918cb1ac7aaab95c4:API`);
} else { } else if( url[1].path === "websocket" ) {
this.activeTab = 2; this.activeTab = 2;
this.seoService.setTitle($localize`:@@e351b40b3869a5c7d19c3d4918cb1ac7aaab95c4:API`); this.seoService.setTitle($localize`:@@e351b40b3869a5c7d19c3d4918cb1ac7aaab95c4:API`);
} else {
this.activeTab = 3;
this.seoService.setTitle($localize`:@@e351b40b3869a5c7d19c3d4918cb1ac7aaab95c4:API`);
} }
this.env = this.stateService.env; this.env = this.stateService.env;
this.showWebSocketTab = ( ! ( ( this.stateService.network === "bisq" ) || ( this.stateService.network === "liquidtestnet" ) ) ); this.showWebSocketTab = ( ! ( ( this.stateService.network === "bisq" ) || ( this.stateService.network === "liquidtestnet" ) ) );
this.showFaqTab = ( this.env.BASE_MODULE === 'mempool' ) ? true : false; this.showFaqTab = ( this.env.BASE_MODULE === 'mempool' ) ? true : false;
this.showElectrsTab = this.stateService.env.OFFICIAL_MEMPOOL_SPACE && ( this.stateService.network !== "bisq" );
document.querySelector<HTMLElement>( "html" ).style.scrollBehavior = "smooth"; document.querySelector<HTMLElement>( "html" ).style.scrollBehavior = "smooth";
} }

View File

@@ -37,10 +37,12 @@ const routes: Routes = [
children: [ children: [
{ {
path: 'mining/pool/:slug', path: 'mining/pool/:slug',
data: { networks: ['bitcoin'] },
component: PoolComponent, component: PoolComponent,
}, },
{ {
path: 'mining', path: 'mining',
data: { networks: ['bitcoin'] },
component: StartComponent, component: StartComponent,
children: [ children: [
{ {
@@ -51,6 +53,7 @@ const routes: Routes = [
}, },
{ {
path: 'mempool-block/:id', path: 'mempool-block/:id',
data: { networks: ['bitcoin', 'liquid'] },
component: StartComponent, component: StartComponent,
children: [ children: [
{ {
@@ -61,62 +64,77 @@ const routes: Routes = [
}, },
{ {
path: 'graphs', path: 'graphs',
data: { networks: ['bitcoin', 'liquid'] },
component: GraphsComponent, component: GraphsComponent,
children: [ children: [
{ {
path: 'mempool', path: 'mempool',
data: { networks: ['bitcoin', 'liquid'] },
component: StatisticsComponent, component: StatisticsComponent,
}, },
{ {
path: 'mining/hashrate-difficulty', path: 'mining/hashrate-difficulty',
data: { networks: ['bitcoin'] },
component: HashrateChartComponent, component: HashrateChartComponent,
}, },
{ {
path: 'mining/pools-dominance', path: 'mining/pools-dominance',
data: { networks: ['bitcoin'] },
component: HashrateChartPoolsComponent, component: HashrateChartPoolsComponent,
}, },
{ {
path: 'mining/pools', path: 'mining/pools',
data: { networks: ['bitcoin'] },
component: PoolRankingComponent, component: PoolRankingComponent,
}, },
{ {
path: 'mining/block-fees', path: 'mining/block-fees',
data: { networks: ['bitcoin'] },
component: BlockFeesGraphComponent, component: BlockFeesGraphComponent,
}, },
{ {
path: 'mining/block-rewards', path: 'mining/block-rewards',
data: { networks: ['bitcoin'] },
component: BlockRewardsGraphComponent, component: BlockRewardsGraphComponent,
}, },
{ {
path: 'mining/block-fee-rates', path: 'mining/block-fee-rates',
data: { networks: ['bitcoin'] },
component: BlockFeeRatesGraphComponent, component: BlockFeeRatesGraphComponent,
}, },
{ {
path: 'mining/block-sizes-weights', path: 'mining/block-sizes-weights',
data: { networks: ['bitcoin'] },
component: BlockSizesWeightsGraphComponent, component: BlockSizesWeightsGraphComponent,
}, },
{ {
path: 'lightning/nodes-networks', path: 'lightning/nodes-networks',
data: { networks: ['bitcoin'] },
component: NodesNetworksChartComponent, component: NodesNetworksChartComponent,
}, },
{ {
path: 'lightning/capacity', path: 'lightning/capacity',
data: { networks: ['bitcoin'] },
component: LightningStatisticsChartComponent, component: LightningStatisticsChartComponent,
}, },
{ {
path: 'lightning/nodes-per-isp', path: 'lightning/nodes-per-isp',
data: { networks: ['bitcoin'] },
component: NodesPerISPChartComponent, component: NodesPerISPChartComponent,
}, },
{ {
path: 'lightning/nodes-per-country', path: 'lightning/nodes-per-country',
data: { networks: ['bitcoin'] },
component: NodesPerCountryChartComponent, component: NodesPerCountryChartComponent,
}, },
{ {
path: 'lightning/nodes-map', path: 'lightning/nodes-map',
data: { networks: ['bitcoin'] },
component: NodesMap, component: NodesMap,
}, },
{ {
path: 'lightning/nodes-channels-map', path: 'lightning/nodes-channels-map',
data: { networks: ['bitcoin'] },
component: NodesChannelsMap, component: NodesChannelsMap,
}, },
{ {
@@ -125,6 +143,7 @@ const routes: Routes = [
}, },
{ {
path: 'mining/block-prediction', path: 'mining/block-prediction',
data: { networks: ['bitcoin'] },
component: BlockPredictionGraphComponent, component: BlockPredictionGraphComponent,
}, },
] ]
@@ -141,6 +160,7 @@ const routes: Routes = [
}, },
{ {
path: 'tv', path: 'tv',
data: { networks: ['bitcoin', 'liquid'] },
component: TelevisionComponent component: TelevisionComponent
}, },
]; ];

View File

@@ -141,7 +141,7 @@ export interface TransactionStripped {
fee: number; fee: number;
vsize: number; vsize: number;
value: number; value: number;
status?: 'found' | 'missing' | 'added'; status?: 'found' | 'missing' | 'added' | 'censored' | 'selected';
} }
export interface RewardStats { export interface RewardStats {

View File

@@ -70,7 +70,7 @@ export interface TransactionStripped {
fee: number; fee: number;
vsize: number; vsize: number;
value: number; value: number;
status?: 'found' | 'missing' | 'added'; status?: 'found' | 'missing' | 'added' | 'censored' | 'selected';
} }
export interface IBackendInfo { export interface IBackendInfo {

View File

@@ -7,7 +7,7 @@
<app-clipboard [text]="channel.public_key"></app-clipboard> <app-clipboard [text]="channel.public_key"></app-clipboard>
</div> </div>
<div class="box-right"> <div class="box-right">
<div class="second-line">{{ channel.channels }} channels</div> <div class="second-line"><ng-container *ngTemplateOutlet="xChannels; context: {$implicit: channel.channels }"></ng-container></div>
<div class="second-line"><app-amount [satoshis]="channel.capacity" digitsInfo="1.2-2"></app-amount></div> <div class="second-line"><app-amount [satoshis]="channel.capacity" digitsInfo="1.2-2"></app-amount></div>
</div> </div>
</div> </div>
@@ -16,7 +16,7 @@
<table class="table table-borderless table-striped"> <table class="table table-borderless table-striped">
<tbody> <tbody>
<tr> <tr>
<td i18n="lightning.fee-rate">Fee rate</td> <td i18n="transaction.fee-rate|Transaction fee rate">Fee rate</td>
<td> <td>
<span class="d-inline-block d-md-none"> <span class="d-inline-block d-md-none">
{{ channel.fee_rate !== null ? (channel.fee_rate | amountShortener : 2 : undefined : true) : '-' }} <span class="symbol">ppm {{ channel.fee_rate !== null ? '(' + (channel.fee_rate / 10000 | amountShortener : 2 : undefined : true) + '%)' : '' }}</span> {{ channel.fee_rate !== null ? (channel.fee_rate | amountShortener : 2 : undefined : true) : '-' }} <span class="symbol">ppm {{ channel.fee_rate !== null ? '(' + (channel.fee_rate / 10000 | amountShortener : 2 : undefined : true) + '%)' : '' }}</span>
@@ -33,19 +33,24 @@
<span> <span>
<span *ngIf="channel.base_fee_mtokens !== null"> <span *ngIf="channel.base_fee_mtokens !== null">
{{ channel.base_fee_mtokens | amountShortener : 0 }} {{ channel.base_fee_mtokens | amountShortener : 0 }}
<span class="symbol">msats</span> <span class="symbol" i18n="shared.m-sats">mSats</span>
</span> </span>
<span *ngIf="channel.base_fee_mtokens === null"> <span *ngIf="channel.base_fee_mtokens === null">
- -
</span> </span>
</span> </span>
<span *ngIf="channel.base_fee_mtokens !== null" class="badge" [class]="channel.base_fee_mtokens === 0 ? 'badge-success' : 'badge-danger'" <ng-template [ngIf]="channel.base_fee_mtokens !== null">
i18n-ngbTooltip="lightning.zero-base-fee" <span class="badge badge-success" *ngIf="channel.base_fee_mtokens === 0; else nonZeroBaseFee"
[ngbTooltip]="channel.base_fee_mtokens === 0 ? 'This channel supports zero base fee routing' : i18n-ngbTooltip="lightning.zero-base-fee-tooltip"
'This channel does not support zero base fee routing'" ngbTooltip="This channel supports zero base fee routing"
placement="bottom" i18n="lightning.zerobasefee"> placement="bottom" i18n="lightning.zero-base-fee">Zero base fee</span>
{{ channel.base_fee_mtokens === 0 ? 'Zero base fee' : 'Non-zero base fee' }} </ng-template>
</span> <ng-template #nonZeroBaseFee>
<span class="badge badge-danger"
i18n-ngbTooltip="lightning.non-zero-base-fee-tooltip"
ngbTooltip="This channel does not support zero base fee routing"
placement="bottom" i18n="lightning.non-zero-base-fee">Non-zero base fee</span>
</ng-template>
</span> </span>
</td> </td>
</tr> </tr>
@@ -62,7 +67,7 @@
</td> </td>
</tr> </tr>
<tr> <tr>
<td i18n="lightning.timelock-detla">Timelock delta</td> <td i18n="lightning.timelock-delta">Timelock delta</td>
<td> <td>
<ng-container *ngTemplateOutlet="blocksPlural; context: {$implicit: channel.cltv_delta ?? '-' }"></ng-container> <ng-container *ngTemplateOutlet="blocksPlural; context: {$implicit: channel.cltv_delta ?? '-' }"></ng-container>
</td> </td>
@@ -72,3 +77,4 @@
</div> </div>
<ng-template #blocksPlural let-i i18n="shared.blocks">{{ i }} <span class="shared-block">blocks</span></ng-template> <ng-template #blocksPlural let-i i18n="shared.blocks">{{ i }} <span class="shared-block">blocks</span></ng-template>
<ng-template #xChannels let-i i18n="lightning.x-channels">{{ i }} channels</ng-template>

View File

@@ -7,9 +7,9 @@
<h1 class="title">{{ channel.short_id }}</h1> <h1 class="title">{{ channel.short_id }}</h1>
</div> </div>
<div class="badges mb-2"> <div class="badges mb-2">
<span class="badge rounded-pill badge-secondary" *ngIf="channel.status === 0">Inactive</span> <span class="badge rounded-pill badge-secondary" *ngIf="channel.status === 0" i18n="status.inactive">Inactive</span>
<span class="badge rounded-pill badge-success" *ngIf="channel.status === 1">Active</span> <span class="badge rounded-pill badge-success" *ngIf="channel.status === 1" i18n="status.active">Active</span>
<span class="badge rounded-pill badge-danger" *ngIf="channel.status === 2">Closed</span> <span class="badge rounded-pill badge-danger" *ngIf="channel.status === 2" i18n="status.closed">Closed</span>
<app-closing-type [type]="channel.closing_reason" *ngIf="channel.status === 2"></app-closing-type> <app-closing-type [type]="channel.closing_reason" *ngIf="channel.status === 2"></app-closing-type>
</div> </div>
@@ -20,20 +20,20 @@
<table class="table table-borderless table-striped"> <table class="table table-borderless table-striped">
<tbody> <tbody>
<tr> <tr>
<td i18n="channel.created">Created</td> <td i18n="lightning.created">Created</td>
<td>{{ channel.created | date:'yyyy-MM-dd HH:mm' }}</td> <td>{{ channel.created | date:'yyyy-MM-dd HH:mm' }}</td>
</tr> </tr>
<tr> <tr>
<td i18n="channel.capacity">Capacity</td> <td i18n="lightning.capacity">Capacity</td>
<td><app-amount [satoshis]="channel.capacity" [noFiat]="true"></app-amount></td> <td><app-amount [satoshis]="channel.capacity" [noFiat]="true"></app-amount></td>
</tr> </tr>
<tr> <tr>
<td i18n="channel.fee-rate">Fee rate</td> <td i18n="transaction.fee-rate|Transaction fee rate">Fee rate</td>
<td> <td>
<div class="dual-cell"> <div class="dual-cell">
<span>{{ channel.node_left.fee_rate }} <span class="symbol">ppm</span></span> <span>{{ channel.node_left.fee_rate }} <span class="symbol" i18n="lightning.ppm">ppm</span></span>
<fa-icon class="between-arrow" [icon]="['fas', 'arrow-right-arrow-left']" [fixedWidth]="true"></fa-icon> <fa-icon class="between-arrow" [icon]="['fas', 'arrow-right-arrow-left']" [fixedWidth]="true"></fa-icon>
<span>{{ channel.node_right.fee_rate }} <span class="symbol">ppm</span></span> <span>{{ channel.node_right.fee_rate }} <span class="symbol" i18n="lightning.ppm">ppm</span></span>
</div> </div>
</td> </td>
</tr> </tr>

View File

@@ -8,9 +8,9 @@
</span> </span>
</div> </div>
<div class="badges mb-2"> <div class="badges mb-2">
<span class="badge rounded-pill badge-secondary" *ngIf="channel.status === 0">Inactive</span> <span class="badge rounded-pill badge-secondary" *ngIf="channel.status === 0" i18n="status.inactive">Inactive</span>
<span class="badge rounded-pill badge-success" *ngIf="channel.status === 1">Active</span> <span class="badge rounded-pill badge-success" *ngIf="channel.status === 1" i18n="status.active">Active</span>
<span class="badge rounded-pill badge-danger" *ngIf="channel.status === 2">Closed</span> <span class="badge rounded-pill badge-danger" *ngIf="channel.status === 2" i18n="status.closed">Closed</span>
<app-closing-type *ngIf="channel.closing_reason" [type]="channel.closing_reason"></app-closing-type> <app-closing-type *ngIf="channel.closing_reason" [type]="channel.closing_reason"></app-closing-type>
</div> </div>
@@ -45,7 +45,7 @@
<table class="table table-borderless table-striped"> <table class="table table-borderless table-striped">
<tbody> <tbody>
<tr> <tr>
<td i18n="address.total-received">Capacity</td> <td i18n="lightning.capacity">Capacity</td>
<td><app-sats [satoshis]="channel.capacity"></app-sats><app-fiat [value]="channel.capacity" digitsInfo="1.0-0"></app-fiat></td> <td><app-sats [satoshis]="channel.capacity"></app-sats><app-fiat [value]="channel.capacity" digitsInfo="1.0-0"></app-fiat></td>
</tr> </tr>
</tbody> </tbody>
@@ -70,7 +70,7 @@
<ng-container *ngIf="transactions$ | async as transactions"> <ng-container *ngIf="transactions$ | async as transactions">
<ng-template [ngIf]="transactions[0]"> <ng-template [ngIf]="transactions[0]">
<div class="d-flex"> <div class="d-flex">
<h3>Opening transaction</h3> <h3 i18n="lightning.opening-transaction">Opening transaction</h3>
<button type="button" class="btn btn-outline-info details-button btn-sm" (click)="txList1.toggleDetails()" <button type="button" class="btn btn-outline-info details-button btn-sm" (click)="txList1.toggleDetails()"
i18n="transaction.details|Transaction Details">Details</button> i18n="transaction.details|Transaction Details">Details</button>
</div> </div>
@@ -79,7 +79,7 @@
</ng-template> </ng-template>
<ng-template [ngIf]="transactions[1]"> <ng-template [ngIf]="transactions[1]">
<div class="closing-header d-flex"> <div class="closing-header d-flex">
<h3 style="margin: 0;">Closing transaction</h3>&nbsp;&nbsp;<app-closing-type [type]="channel.closing_reason"> <h3 style="margin: 0;" i18n="lightning.closing-transaction">Closing transaction</h3>&nbsp;&nbsp;<app-closing-type [type]="channel.closing_reason">
</app-closing-type> </app-closing-type>
<button type="button" class="btn btn-outline-info details-button btn-sm" (click)="txList2.toggleDetails()" <button type="button" class="btn btn-outline-info details-button btn-sm" (click)="txList2.toggleDetails()"
i18n="transaction.details|Transaction Details">Details</button> i18n="transaction.details|Transaction Details">Details</button>

View File

@@ -34,7 +34,7 @@ export class ChannelComponent implements OnInit {
return this.lightningApiService.getChannel$(params.get('short_id')) return this.lightningApiService.getChannel$(params.get('short_id'))
.pipe( .pipe(
tap((value) => { tap((value) => {
this.seoService.setTitle(`Channel: ${value.short_id}`); this.seoService.setTitle($localize`Channel: ${value.short_id}`);
}), }),
catchError((err) => { catchError((err) => {
this.error = err; this.error = err;

View File

@@ -2,10 +2,10 @@
<form [formGroup]="channelStatusForm" class="formRadioGroup"> <form [formGroup]="channelStatusForm" class="formRadioGroup">
<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="status"> <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" formControlName="status">
<label ngbButtonLabel class="btn-primary btn-sm"> <label ngbButtonLabel class="btn-primary btn-sm">
<input ngbButton type="radio" [value]="'open'" fragment="open" i18n="open">Open <input ngbButton type="radio" [value]="'open'" fragment="open"><span i18n="open">Open</span>
</label> </label>
<label ngbButtonLabel class="btn-primary btn-sm"> <label ngbButtonLabel class="btn-primary btn-sm">
<input ngbButton type="radio" [value]="'closed'" fragment="closed" i18n="closed">Closed <input ngbButton type="radio" [value]="'closed'" fragment="closed"><span i18n="closed">Closed</span>
</label> </label>
</div> </div>
</form> </form>
@@ -32,12 +32,12 @@
<ng-template #tableHeader> <ng-template #tableHeader>
<thead> <thead>
<th class="alias text-left" i18n="nodes.alias">Node Alias</th> <th class="alias text-left" i18n="lightning.alias">Alias</th>
<th class="alias text-left d-none d-md-table-cell" i18n="channels.transaction">&nbsp;</th> <th class="alias text-left d-none d-md-table-cell">&nbsp;</th>
<th class="alias text-left d-none d-md-table-cell" i18n="status">Status</th> <th class="alias text-left d-none d-md-table-cell" i18n="status">Status</th>
<th *ngIf="status !== 'closed'" class="channels text-left d-none d-md-table-cell" i18n="channels.rate">Fee Rate</th> <th *ngIf="status !== 'closed'" class="channels text-left d-none d-md-table-cell" i18n="transaction.fee-rate|Transaction fee rate">Fee rate</th>
<th *ngIf="status === 'closed'" class="channels text-left d-none d-md-table-cell" i18n="channels.closing_date">Closing date</th> <th *ngIf="status === 'closed'" class="channels text-left d-none d-md-table-cell" i18n="channels.closing_date">Closing date</th>
<th class="capacity text-right d-none d-md-table-cell" i18n="nodes.capacity">Capacity</th> <th class="capacity text-right d-none d-md-table-cell" i18n="lightning.capacity">Capacity</th>
<th class="capacity text-right" i18n="channels.id">Channel ID</th> <th class="capacity text-right" i18n="channels.id">Channel ID</th>
</thead> </thead>
</ng-template> </ng-template>
@@ -53,7 +53,7 @@
</div> </div>
</td> </td>
<td class="alias text-left d-none d-md-table-cell"> <td class="alias text-left d-none d-md-table-cell">
<div class="second-line">{{ node.channels }} channels</div> <div class="second-line"><ng-container *ngTemplateOutlet="xChannels; context: {$implicit: node.channels }"></ng-container></div>
<div class="second-line"> <div class="second-line">
<app-amount *ngIf="node.capacity > 100000000; else smallnode" [satoshis]="node.capacity" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount> <app-amount *ngIf="node.capacity > 100000000; else smallnode" [satoshis]="node.capacity" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount>
<ng-template #smallnode> <ng-template #smallnode>
@@ -63,10 +63,10 @@
</div> </div>
</td> </td>
<td class="d-none d-md-table-cell"> <td class="d-none d-md-table-cell">
<span class="badge rounded-pill badge-secondary" *ngIf="channel.status === 0" i18n="lightning.inactive">Inactive</span> <span class="badge rounded-pill badge-secondary" *ngIf="channel.status === 0" i18n="status.inactive">Inactive</span>
<span class="badge rounded-pill badge-success" *ngIf="channel.status === 1" i18n="lightning.active">Active</span> <span class="badge rounded-pill badge-success" *ngIf="channel.status === 1" i18n="status.active">Active</span>
<ng-template [ngIf]="channel.status === 2"> <ng-template [ngIf]="channel.status === 2">
<span class="badge rounded-pill badge-secondary" *ngIf="!channel.closing_reason; else closingReason" i18n="lightning.closed">Closed</span> <span class="badge rounded-pill badge-secondary" *ngIf="!channel.closing_reason; else closingReason" i18n="status.closed">Closed</span>
<ng-template #closingReason> <ng-template #closingReason>
<app-closing-type [type]="channel.closing_reason"></app-closing-type> <app-closing-type [type]="channel.closing_reason"></app-closing-type>
</ng-template> </ng-template>
@@ -117,3 +117,5 @@
</tbody> </tbody>
</table> </table>
</ng-template> </ng-template>
<ng-template #xChannels let-i i18n="lightning.x-channels">{{ i }} channels</ng-template>

View File

@@ -1,9 +1,9 @@
<div class="widget-toggler"> <div class="widget-toggler">
<a href="javascript:;" (click)="switchMode('avg')" class="toggler-option" <a href="" (click)="switchMode('avg')" class="toggler-option"
[ngClass]="{'inactive': mode !== 'avg'}"><small>avg</small></a> [ngClass]="{'inactive': mode === 'avg'}"><small>avg</small></a>
<span style="color: #ffffff66; font-size: 8px"> | </span> <span style="color: #ffffff66; font-size: 8px"> | </span>
<a href="javascript:;" (click)="switchMode('med')" class="toggler-option" <a href="" (click)="switchMode('med')" class="toggler-option"
[ngClass]="{'inactive': mode !== 'med'}"><small>med</small></a> [ngClass]="{'inactive': mode === 'med'}"><small>med</small></a>
</div> </div>
<div class="fee-estimation-wrapper" *ngIf="statistics$ | async as statistics; else loadingReward"> <div class="fee-estimation-wrapper" *ngIf="statistics$ | async as statistics; else loadingReward">
@@ -14,7 +14,7 @@
<div class="card-text"> <div class="card-text">
<div class="fee-text" [class]="!statistics.previous ? 'no-border' : ''"> <div class="fee-text" [class]="!statistics.previous ? 'no-border' : ''">
{{ statistics.latest?.avg_capacity || 0 | number: '1.0-0' }} {{ statistics.latest?.avg_capacity || 0 | number: '1.0-0' }}
<span i18n="shared.sat-vbyte|sat/vB">sats</span> <span i18n="shared.sats">sats</span>
</div> </div>
<span class="fiat" *ngIf="statistics.previous"> <span class="fiat" *ngIf="statistics.previous">
<app-change [current]="statistics.latest?.avg_capacity" [previous]="statistics.previous?.avg_capacity"></app-change> <app-change [current]="statistics.latest?.avg_capacity" [previous]="statistics.previous?.avg_capacity"></app-change>
@@ -29,7 +29,7 @@
placement="bottom"> placement="bottom">
<div class="fee-text" [class]="!statistics.previous ? 'no-border' : ''"> <div class="fee-text" [class]="!statistics.previous ? 'no-border' : ''">
{{ statistics.latest?.avg_fee_rate || 0 | number: '1.0-0' }} {{ statistics.latest?.avg_fee_rate || 0 | number: '1.0-0' }}
<span i18n="shared.sat-vbyte|sat/vB">ppm</span> <span i18n="lightning.ppm">ppm</span>
</div> </div>
<span class="fiat" *ngIf="statistics.previous"> <span class="fiat" *ngIf="statistics.previous">
<app-change [current]="statistics.latest?.avg_fee_rate" [previous]="statistics.previous?.avg_fee_rate"></app-change> <app-change [current]="statistics.latest?.avg_fee_rate" [previous]="statistics.previous?.avg_fee_rate"></app-change>
@@ -44,7 +44,7 @@
<div class="card-text"> <div class="card-text">
<div class="fee-text" [class]="!statistics.previous ? 'no-border' : ''"> <div class="fee-text" [class]="!statistics.previous ? 'no-border' : ''">
{{ statistics.latest?.avg_base_fee_mtokens || 0 | number: '1.0-0' }} {{ statistics.latest?.avg_base_fee_mtokens || 0 | number: '1.0-0' }}
<span i18n="shared.sat-vbyte|sat/vB">msats</span> <span i18n="shared.m-sats">mSats</span>
</div> </div>
<span class="fiat" *ngIf="statistics.previous"> <span class="fiat" *ngIf="statistics.previous">
<app-change [current]="statistics.latest?.avg_base_fee_mtokens" [previous]="statistics.previous?.avg_base_fee_mtokens"></app-change> <app-change [current]="statistics.latest?.avg_base_fee_mtokens" [previous]="statistics.previous?.avg_base_fee_mtokens"></app-change>
@@ -60,7 +60,7 @@
<div class="card-text"> <div class="card-text">
<div class="fee-text" [class]="!statistics.previous ? 'no-border' : ''"> <div class="fee-text" [class]="!statistics.previous ? 'no-border' : ''">
{{ statistics.latest?.med_capacity || 0 | number: '1.0-0' }} {{ statistics.latest?.med_capacity || 0 | number: '1.0-0' }}
<span i18n="shared.sat-vbyte|sat/vB">sats</span> <span i18n="shared.sats">sats</span>
</div> </div>
<span class="fiat" *ngIf="statistics.previous"> <span class="fiat" *ngIf="statistics.previous">
<app-change [current]="statistics.latest?.med_capacity" [previous]="statistics.previous?.med_capacity"></app-change> <app-change [current]="statistics.latest?.med_capacity" [previous]="statistics.previous?.med_capacity"></app-change>
@@ -75,7 +75,7 @@
placement="bottom"> placement="bottom">
<div class="fee-text" [class]="!statistics.previous ? 'no-border' : ''"> <div class="fee-text" [class]="!statistics.previous ? 'no-border' : ''">
{{ statistics.latest?.med_fee_rate || 0 | number: '1.0-0' }} {{ statistics.latest?.med_fee_rate || 0 | number: '1.0-0' }}
<span i18n="shared.sat-vbyte|sat/vB">ppm</span> <span i18n="lightning.ppm">ppm</span>
</div> </div>
<span class="fiat" *ngIf="statistics.previous"> <span class="fiat" *ngIf="statistics.previous">
<app-change [current]="statistics.latest?.med_fee_rate" [previous]="statistics.previous?.med_fee_rate"></app-change> <app-change [current]="statistics.latest?.med_fee_rate" [previous]="statistics.previous?.med_fee_rate"></app-change>
@@ -90,7 +90,7 @@
<div class="card-text"> <div class="card-text">
<div class="fee-text" [class]="!statistics.previous ? 'no-border' : ''"> <div class="fee-text" [class]="!statistics.previous ? 'no-border' : ''">
{{ statistics.latest?.med_base_fee_mtokens || 0 | number: '1.0-0' }} {{ statistics.latest?.med_base_fee_mtokens || 0 | number: '1.0-0' }}
<span i18n="shared.sat-vbyte|sat/vB">msats</span> <span i18n="shared.m-sats">mSats</span>
</div> </div>
</div> </div>
<span class="fiat" *ngIf="statistics.previous"> <span class="fiat" *ngIf="statistics.previous">

View File

@@ -18,5 +18,6 @@ export class ChannelsStatisticsComponent implements OnInit {
switchMode(mode: 'avg' | 'med') { switchMode(mode: 'avg' | 'med') {
this.mode = mode; this.mode = mode;
return false;
} }
} }

View File

@@ -1,6 +1,6 @@
<div class="box preview-box" *ngIf="nodes$ | async as nodes"> <div class="box preview-box" *ngIf="nodes$ | async as nodes">
<app-preview-title> <app-preview-title>
<span i18n="lightning.node">Lightning node group</span> <span i18n="lightning.node-group">Lightning node group</span>
</app-preview-title> </app-preview-title>
<div class="row d-flex justify-content-between full-width-row"> <div class="row d-flex justify-content-between full-width-row">
<div class="logo-wrapper"> <div class="logo-wrapper">

View File

@@ -1,5 +1,5 @@
<div class="container-xl full-height" style="min-height: 335px"> <div class="container-xl full-height" style="min-height: 335px">
<h5 class="mb-1" style="color: #ffffff66" i18n="lightning.node">Lightning node group</h5> <h5 class="mb-1" style="color: #ffffff66" i18n="lightning.node-group">Lightning node group</h5>
<div class="header"> <div class="header">
<div class="logo-container"> <div class="logo-container">

View File

@@ -53,6 +53,10 @@ export class LightningApiService {
return this.httpClient.get<any>(this.apiBasePath + '/api/v1/lightning/nodes/' + publicKey + '/statistics'); return this.httpClient.get<any>(this.apiBasePath + '/api/v1/lightning/nodes/' + publicKey + '/statistics');
} }
getNodeFeeHistogram$(publicKey: string): Observable<any> {
return this.httpClient.get<any>(this.apiBasePath + '/api/v1/lightning/nodes/' + publicKey + '/fees/histogram');
}
getNodesRanking$(): Observable<INodesRanking> { getNodesRanking$(): Observable<INodesRanking> {
return this.httpClient.get<INodesRanking>(this.apiBasePath + '/api/v1/lightning/nodes/rankings'); return this.httpClient.get<INodesRanking>(this.apiBasePath + '/api/v1/lightning/nodes/rankings');
} }

View File

@@ -7,7 +7,7 @@
<!-- Network capacity/channels/nodes --> <!-- Network capacity/channels/nodes -->
<div class="col"> <div class="col">
<div class="main-title"> <div class="main-title">
<span i18n="lightning.statistics-title">Network Statistics</span>&nbsp; <span i18n="lightning.network-statistics-title">Network Statistics</span>&nbsp;
</div> </div>
<div class="card-wrapper"> <div class="card-wrapper">
<div class="card" style="height: 123px"> <div class="card" style="height: 123px">
@@ -21,7 +21,7 @@
<!-- Channels stats --> <!-- Channels stats -->
<div class="col"> <div class="col">
<div class="main-title"> <div class="main-title">
<span i18n="lightning.statistics-title">Channels Statistics</span>&nbsp; <span i18n="lightning.channel-statistics-title">Channels Statistics</span>&nbsp;
</div> </div>
<div class="card-wrapper"> <div class="card-wrapper">
<div class="card" style="height: 123px"> <div class="card" style="height: 123px">
@@ -46,7 +46,7 @@
<div class="col"> <div class="col">
<div class="card graph-card"> <div class="card graph-card">
<div class="card-body pl-2 pr-2 pt-1"> <div class="card-body pl-2 pr-2 pt-1">
<h5 class="card-title mt-3" i18n="lightning.network-history">Lightning network history</h5> <h5 class="card-title mt-3" i18n="lightning.network-history">Lightning Network History</h5>
<app-lightning-statistics-chart [widget]=true></app-lightning-statistics-chart> <app-lightning-statistics-chart [widget]=true></app-lightning-statistics-chart>
<app-nodes-networks-chart [widget]=true></app-nodes-networks-chart> <app-nodes-networks-chart [widget]=true></app-nodes-networks-chart>
<div><a [routerLink]="['/graphs/lightning/nodes-networks' | relativeUrl]" i18n="dashboard.view-more">View more &raquo;</a></div> <div><a [routerLink]="['/graphs/lightning/nodes-networks' | relativeUrl]" i18n="dashboard.view-more">View more &raquo;</a></div>
@@ -59,7 +59,7 @@
<div class="card" style="height: 409px"> <div class="card" style="height: 409px">
<div class="card-body"> <div class="card-body">
<a class="title-link" href="" [routerLink]="['/lightning/nodes/rankings/liquidity' | relativeUrl]"> <a class="title-link" href="" [routerLink]="['/lightning/nodes/rankings/liquidity' | relativeUrl]">
<h5 class="card-title d-inline" i18n="lightning.liquidity-ranking">Liquidity ranking</h5> <h5 class="card-title d-inline" i18n="lightning.liquidity-ranking">Liquidity Ranking</h5>
<span>&nbsp;</span> <span>&nbsp;</span>
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: 'text-top'; font-size: 13px; color: '#4a68b9'"></fa-icon> <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: 'text-top'; font-size: 13px; color: '#4a68b9'"></fa-icon>
</a> </a>
@@ -73,7 +73,7 @@
<div class="card" style="height: 409px"> <div class="card" style="height: 409px">
<div class="card-body"> <div class="card-body">
<a class="title-link" href="" [routerLink]="['/lightning/nodes/rankings/connectivity' | relativeUrl]"> <a class="title-link" href="" [routerLink]="['/lightning/nodes/rankings/connectivity' | relativeUrl]">
<h5 class="card-title d-inline" i18n="lightning.connectivity-ranking">Connectivity ranking</h5> <h5 class="card-title d-inline" i18n="lightning.connectivity-ranking">Connectivity Ranking</h5>
<span>&nbsp;</span> <span>&nbsp;</span>
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: 'text-top'; font-size: 13px; color: '#4a68b9'"></fa-icon> <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: 'text-top'; font-size: 13px; color: '#4a68b9'"></fa-icon>
</a> </a>

View File

@@ -24,7 +24,7 @@ export class LightningDashboardComponent implements OnInit {
) { } ) { }
ngOnInit(): void { ngOnInit(): void {
this.seoService.setTitle($localize`Lightning Network`); this.seoService.setTitle($localize`:@@142e923d3b04186ac6ba23387265d22a2fa404e0:Lightning Explorer`);
this.nodesRanking$ = this.lightningApiService.getNodesRanking$().pipe(share()); this.nodesRanking$ = this.lightningApiService.getNodesRanking$().pipe(share());
this.statistics$ = this.lightningApiService.getLatestStatistics$().pipe(share()); this.statistics$ = this.lightningApiService.getLatestStatistics$().pipe(share());

View File

@@ -15,6 +15,7 @@ import { ChannelBoxComponent } from './channel/channel-box/channel-box.component
import { ClosingTypeComponent } from './channel/closing-type/closing-type.component'; import { ClosingTypeComponent } from './channel/closing-type/closing-type.component';
import { LightningStatisticsChartComponent } from './statistics-chart/lightning-statistics-chart.component'; import { LightningStatisticsChartComponent } from './statistics-chart/lightning-statistics-chart.component';
import { NodeStatisticsChartComponent } from './node-statistics-chart/node-statistics-chart.component'; import { NodeStatisticsChartComponent } from './node-statistics-chart/node-statistics-chart.component';
import { NodeFeeChartComponent } from './node-fee-chart/node-fee-chart.component';
import { GraphsModule } from '../graphs/graphs.module'; import { GraphsModule } from '../graphs/graphs.module';
import { NodesNetworksChartComponent } from './nodes-networks-chart/nodes-networks-chart.component'; import { NodesNetworksChartComponent } from './nodes-networks-chart/nodes-networks-chart.component';
import { ChannelsStatisticsComponent } from './channels-statistics/channels-statistics.component'; import { ChannelsStatisticsComponent } from './channels-statistics/channels-statistics.component';
@@ -38,6 +39,7 @@ import { GroupComponent } from './group/group.component';
NodesListComponent, NodesListComponent,
NodeStatisticsComponent, NodeStatisticsComponent,
NodeStatisticsChartComponent, NodeStatisticsChartComponent,
NodeFeeChartComponent,
NodeComponent, NodeComponent,
ChannelsListComponent, ChannelsListComponent,
ChannelComponent, ChannelComponent,
@@ -73,6 +75,7 @@ import { GroupComponent } from './group/group.component';
NodesListComponent, NodesListComponent,
NodeStatisticsComponent, NodeStatisticsComponent,
NodeStatisticsChartComponent, NodeStatisticsChartComponent,
NodeFeeChartComponent,
NodeComponent, NodeComponent,
ChannelsListComponent, ChannelsListComponent,
ChannelComponent, ChannelComponent,

View File

@@ -21,10 +21,12 @@ const routes: Routes = [
}, },
{ {
path: 'node/:public_key', path: 'node/:public_key',
data: { networkSpecific: true },
component: NodeComponent, component: NodeComponent,
}, },
{ {
path: 'channel/:short_id', path: 'channel/:short_id',
data: { networkSpecific: true },
component: ChannelComponent, component: ChannelComponent,
}, },
{ {

View File

@@ -0,0 +1,7 @@
<div class="full-container">
<h2 i18n="lightning.node-fee-distribution">Fee distribution</h2>
<div class="chart" echarts [initOpts]="chartInitOptions" [options]="chartOptions" (chartInit)="onChartInit($event)"></div>
<div class="text-center loadingGraphs" *ngIf="isLoading">
<div class="spinner-border text-light"></div>d
</div>
</div>

View File

@@ -0,0 +1,5 @@
.full-container {
margin-top: 25px;
margin-bottom: 25px;
min-height: 100%;
}

Some files were not shown because too many files have changed in this diff Show More