Merge branch 'master' into nymkappa/bugfix/node-count
This commit is contained in:
commit
28d5ec34b3
8
.github/workflows/on-tag.yml
vendored
8
.github/workflows/on-tag.yml
vendored
@ -68,24 +68,24 @@ jobs:
|
|||||||
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
||||||
|
|
||||||
- name: Checkout project
|
- name: Checkout project
|
||||||
uses: actions/checkout@629c2de402a417ea7690ca6ce3f33229e27606a5 # v2
|
uses: actions/checkout@e2f20e631ae6d7dd3b768f56a5d2af784dd54791 # v2.5.0
|
||||||
|
|
||||||
- name: Init repo for Dockerization
|
- name: Init repo for Dockerization
|
||||||
run: docker/init.sh "$TAG"
|
run: docker/init.sh "$TAG"
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@27d0a4f181a40b142cce983c5393082c365d1480 # v1
|
uses: docker/setup-qemu-action@e81a89b1732b9c48d79cd809d8d81d79c4647a18 # v2.1.0
|
||||||
id: qemu
|
id: qemu
|
||||||
|
|
||||||
- name: Setup Docker buildx action
|
- name: Setup Docker buildx action
|
||||||
uses: docker/setup-buildx-action@94ab11c41e45d028884a99163086648e898eed25 # v1
|
uses: docker/setup-buildx-action@8c0edbc76e98fa90f69d9a2c020dcb50019dc325 # v2.2.1
|
||||||
id: buildx
|
id: buildx
|
||||||
|
|
||||||
- name: Available platforms
|
- name: Available platforms
|
||||||
run: echo ${{ steps.buildx.outputs.platforms }}
|
run: echo ${{ steps.buildx.outputs.platforms }}
|
||||||
|
|
||||||
- name: Cache Docker layers
|
- name: Cache Docker layers
|
||||||
uses: actions/cache@661fd3eb7f2f20d8c7c84bc2b0509efd7a826628 # v2
|
uses: actions/cache@9b0c1fce7a93df8e3bb8926b0d6e9d89e92f20a7 # v3.0.11
|
||||||
id: cache
|
id: cache
|
||||||
with:
|
with:
|
||||||
path: /tmp/.buildx-cache
|
path: /tmp/.buildx-cache
|
||||||
|
@ -24,7 +24,8 @@
|
|||||||
"STDOUT_LOG_MIN_PRIORITY": "debug",
|
"STDOUT_LOG_MIN_PRIORITY": "debug",
|
||||||
"AUTOMATIC_BLOCK_REINDEXING": false,
|
"AUTOMATIC_BLOCK_REINDEXING": false,
|
||||||
"POOLS_JSON_URL": "https://raw.githubusercontent.com/mempool/mining-pools/master/pools.json",
|
"POOLS_JSON_URL": "https://raw.githubusercontent.com/mempool/mining-pools/master/pools.json",
|
||||||
"POOLS_JSON_TREE_URL": "https://api.github.com/repos/mempool/mining-pools/git/trees/master"
|
"POOLS_JSON_TREE_URL": "https://api.github.com/repos/mempool/mining-pools/git/trees/master",
|
||||||
|
"ADVANCED_TRANSACTION_SELECTION": false
|
||||||
},
|
},
|
||||||
"CORE_RPC": {
|
"CORE_RPC": {
|
||||||
"HOST": "127.0.0.1",
|
"HOST": "127.0.0.1",
|
||||||
|
@ -25,7 +25,8 @@
|
|||||||
"STDOUT_LOG_MIN_PRIORITY": "__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__",
|
"STDOUT_LOG_MIN_PRIORITY": "__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__",
|
||||||
"INDEXING_BLOCKS_AMOUNT": 14,
|
"INDEXING_BLOCKS_AMOUNT": 14,
|
||||||
"POOLS_JSON_TREE_URL": "__POOLS_JSON_TREE_URL__",
|
"POOLS_JSON_TREE_URL": "__POOLS_JSON_TREE_URL__",
|
||||||
"POOLS_JSON_URL": "__POOLS_JSON_URL__"
|
"POOLS_JSON_URL": "__POOLS_JSON_URL__",
|
||||||
|
"ADVANCED_TRANSACTION_SELECTION": "__ADVANCED_TRANSACTION_SELECTION__"
|
||||||
},
|
},
|
||||||
"CORE_RPC": {
|
"CORE_RPC": {
|
||||||
"HOST": "__CORE_RPC_HOST__",
|
"HOST": "__CORE_RPC_HOST__",
|
||||||
|
@ -37,7 +37,8 @@ describe('Mempool Backend Config', () => {
|
|||||||
USER_AGENT: 'mempool',
|
USER_AGENT: 'mempool',
|
||||||
STDOUT_LOG_MIN_PRIORITY: 'debug',
|
STDOUT_LOG_MIN_PRIORITY: 'debug',
|
||||||
POOLS_JSON_TREE_URL: 'https://api.github.com/repos/mempool/mining-pools/git/trees/master',
|
POOLS_JSON_TREE_URL: 'https://api.github.com/repos/mempool/mining-pools/git/trees/master',
|
||||||
POOLS_JSON_URL: 'https://raw.githubusercontent.com/mempool/mining-pools/master/pools.json'
|
POOLS_JSON_URL: 'https://raw.githubusercontent.com/mempool/mining-pools/master/pools.json',
|
||||||
|
ADVANCED_TRANSACTION_SELECTION: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(config.ELECTRUM).toStrictEqual({ HOST: '127.0.0.1', PORT: 3306, TLS_ENABLED: true });
|
expect(config.ELECTRUM).toStrictEqual({ HOST: '127.0.0.1', PORT: 3306, TLS_ENABLED: true });
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
import logger from '../logger';
|
import config from '../config';
|
||||||
import { BlockExtended, TransactionExtended, MempoolBlockWithTransactions } from '../mempool.interfaces';
|
import bitcoinApi from './bitcoin/bitcoin-api-factory';
|
||||||
|
import { Common } from './common';
|
||||||
|
import { TransactionExtended, MempoolBlockWithTransactions, AuditScore } from '../mempool.interfaces';
|
||||||
|
import blocksRepository from '../repositories/BlocksRepository';
|
||||||
|
import blocksAuditsRepository from '../repositories/BlocksAuditsRepository';
|
||||||
|
import blocks from '../api/blocks';
|
||||||
|
|
||||||
const PROPAGATION_MARGIN = 180; // in seconds, time since a transaction is first seen after which it is assumed to have propagated to all miners
|
const PROPAGATION_MARGIN = 180; // in seconds, time since a transaction is first seen after which it is assumed to have propagated to all miners
|
||||||
|
|
||||||
@ -44,8 +49,6 @@ class Audit {
|
|||||||
|
|
||||||
displacedWeight += (4000 - transactions[0].weight);
|
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
|
// 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
|
// these displaced transactions should occupy the first N weight units of the next projected block
|
||||||
let displacedWeightRemaining = displacedWeight;
|
let displacedWeightRemaining = displacedWeight;
|
||||||
@ -73,6 +76,7 @@ class Audit {
|
|||||||
|
|
||||||
// mark unexpected transactions in the mined block as 'added'
|
// mark unexpected transactions in the mined block as 'added'
|
||||||
let overflowWeight = 0;
|
let overflowWeight = 0;
|
||||||
|
let totalWeight = 0;
|
||||||
for (const tx of transactions) {
|
for (const tx of transactions) {
|
||||||
if (inTemplate[tx.txid]) {
|
if (inTemplate[tx.txid]) {
|
||||||
matches.push(tx.txid);
|
matches.push(tx.txid);
|
||||||
@ -82,11 +86,13 @@ class Audit {
|
|||||||
}
|
}
|
||||||
overflowWeight += tx.weight;
|
overflowWeight += tx.weight;
|
||||||
}
|
}
|
||||||
|
totalWeight += tx.weight;
|
||||||
}
|
}
|
||||||
|
|
||||||
// transactions missing from near the end of our template are probably not being censored
|
// transactions missing from near the end of our template are probably not being censored
|
||||||
let overflowWeightRemaining = overflowWeight;
|
let overflowWeightRemaining = overflowWeight - (config.MEMPOOL.BLOCK_WEIGHT_UNITS - totalWeight);
|
||||||
let lastOverflowRate = 1.00;
|
let maxOverflowRate = 0;
|
||||||
|
let rateThreshold = 0;
|
||||||
index = projectedBlocks[0].transactionIds.length - 1;
|
index = projectedBlocks[0].transactionIds.length - 1;
|
||||||
while (index >= 0) {
|
while (index >= 0) {
|
||||||
const txid = projectedBlocks[0].transactionIds[index];
|
const txid = projectedBlocks[0].transactionIds[index];
|
||||||
@ -94,8 +100,11 @@ class Audit {
|
|||||||
if (isCensored[txid]) {
|
if (isCensored[txid]) {
|
||||||
delete isCensored[txid];
|
delete isCensored[txid];
|
||||||
}
|
}
|
||||||
lastOverflowRate = mempool[txid].effectiveFeePerVsize;
|
if (mempool[txid].effectiveFeePerVsize > maxOverflowRate) {
|
||||||
} else if (Math.floor(mempool[txid].effectiveFeePerVsize * 100) <= Math.ceil(lastOverflowRate * 100)) { // tolerance of 0.01 sat/vb
|
maxOverflowRate = mempool[txid].effectiveFeePerVsize;
|
||||||
|
rateThreshold = (Math.ceil(maxOverflowRate * 100) / 100) + 0.005;
|
||||||
|
}
|
||||||
|
} else if (mempool[txid].effectiveFeePerVsize <= rateThreshold) { // tolerance of 0.01 sat/vb + rounding
|
||||||
if (isCensored[txid]) {
|
if (isCensored[txid]) {
|
||||||
delete isCensored[txid];
|
delete isCensored[txid];
|
||||||
}
|
}
|
||||||
@ -113,6 +122,45 @@ class Audit {
|
|||||||
score
|
score
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async $getBlockAuditScores(fromHeight?: number, limit: number = 15): Promise<AuditScore[]> {
|
||||||
|
let currentHeight = fromHeight !== undefined ? fromHeight : await blocksRepository.$mostRecentBlockHeight();
|
||||||
|
const returnScores: AuditScore[] = [];
|
||||||
|
|
||||||
|
if (currentHeight < 0) {
|
||||||
|
return returnScores;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < limit && currentHeight >= 0; i++) {
|
||||||
|
const block = blocks.getBlocks().find((b) => b.height === currentHeight);
|
||||||
|
if (block?.extras?.matchRate != null) {
|
||||||
|
returnScores.push({
|
||||||
|
hash: block.id,
|
||||||
|
matchRate: block.extras.matchRate
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let currentHash;
|
||||||
|
if (!currentHash && Common.indexingEnabled()) {
|
||||||
|
const dbBlock = await blocksRepository.$getBlockByHeight(currentHeight);
|
||||||
|
if (dbBlock && dbBlock['id']) {
|
||||||
|
currentHash = dbBlock['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!currentHash) {
|
||||||
|
currentHash = await bitcoinApi.$getBlockHash(currentHeight);
|
||||||
|
}
|
||||||
|
if (currentHash) {
|
||||||
|
const auditScore = await blocksAuditsRepository.$getBlockAuditScore(currentHash);
|
||||||
|
returnScores.push({
|
||||||
|
hash: currentHash,
|
||||||
|
matchRate: auditScore?.matchRate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentHeight--;
|
||||||
|
}
|
||||||
|
return returnScores;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new Audit();
|
export default new Audit();
|
@ -34,6 +34,7 @@ class Blocks {
|
|||||||
private lastDifficultyAdjustmentTime = 0;
|
private lastDifficultyAdjustmentTime = 0;
|
||||||
private previousDifficultyRetarget = 0;
|
private previousDifficultyRetarget = 0;
|
||||||
private newBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void)[] = [];
|
private newBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void)[] = [];
|
||||||
|
private newAsyncBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => Promise<void>)[] = [];
|
||||||
|
|
||||||
constructor() { }
|
constructor() { }
|
||||||
|
|
||||||
@ -57,6 +58,10 @@ class Blocks {
|
|||||||
this.newBlockCallbacks.push(fn);
|
this.newBlockCallbacks.push(fn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setNewAsyncBlockCallback(fn: (block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => Promise<void>) {
|
||||||
|
this.newAsyncBlockCallbacks.push(fn);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the list of transaction for a block
|
* Return the list of transaction for a block
|
||||||
* @param blockHash
|
* @param blockHash
|
||||||
@ -130,7 +135,7 @@ class Blocks {
|
|||||||
const stripped = block.tx.map((tx) => {
|
const stripped = block.tx.map((tx) => {
|
||||||
return {
|
return {
|
||||||
txid: tx.txid,
|
txid: tx.txid,
|
||||||
vsize: tx.vsize,
|
vsize: tx.weight / 4,
|
||||||
fee: tx.fee ? Math.round(tx.fee * 100000000) : 0,
|
fee: tx.fee ? Math.round(tx.fee * 100000000) : 0,
|
||||||
value: Math.round(tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0) * 100000000)
|
value: Math.round(tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0) * 100000000)
|
||||||
};
|
};
|
||||||
@ -195,9 +200,9 @@ class Blocks {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const auditSummary = await BlocksAuditsRepository.$getShortBlockAudit(block.id);
|
const auditScore = await BlocksAuditsRepository.$getBlockAuditScore(block.id);
|
||||||
if (auditSummary) {
|
if (auditScore != null) {
|
||||||
blockExtended.extras.matchRate = auditSummary.matchRate;
|
blockExtended.extras.matchRate = auditScore.matchRate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -444,6 +449,9 @@ class Blocks {
|
|||||||
const blockExtended: BlockExtended = await this.$getBlockExtended(block, transactions);
|
const blockExtended: BlockExtended = await this.$getBlockExtended(block, transactions);
|
||||||
const blockSummary: BlockSummary = this.summarizeBlock(verboseBlock);
|
const blockSummary: BlockSummary = this.summarizeBlock(verboseBlock);
|
||||||
|
|
||||||
|
// start async callbacks
|
||||||
|
const callbackPromises = this.newAsyncBlockCallbacks.map((cb) => cb(blockExtended, txIds, transactions));
|
||||||
|
|
||||||
if (Common.indexingEnabled()) {
|
if (Common.indexingEnabled()) {
|
||||||
if (!fastForwarded) {
|
if (!fastForwarded) {
|
||||||
const lastBlock = await blocksRepository.$getBlockByHeight(blockExtended.height - 1);
|
const lastBlock = await blocksRepository.$getBlockByHeight(blockExtended.height - 1);
|
||||||
@ -514,6 +522,9 @@ class Blocks {
|
|||||||
if (!memPool.hasPriority()) {
|
if (!memPool.hasPriority()) {
|
||||||
diskCache.$saveCacheToDisk();
|
diskCache.$saveCacheToDisk();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wait for pending async callbacks to finish
|
||||||
|
await Promise.all(callbackPromises);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,8 +4,8 @@ import logger from '../logger';
|
|||||||
import { Common } from './common';
|
import { Common } from './common';
|
||||||
|
|
||||||
class DatabaseMigration {
|
class DatabaseMigration {
|
||||||
private static currentVersion = 41;
|
private static currentVersion = 44;
|
||||||
private queryTimeout = 120000;
|
private queryTimeout = 900_000;
|
||||||
private statisticsAddedIndexed = false;
|
private statisticsAddedIndexed = false;
|
||||||
private uniqueLogs: string[] = [];
|
private uniqueLogs: string[] = [];
|
||||||
|
|
||||||
@ -352,6 +352,19 @@ class DatabaseMigration {
|
|||||||
if (databaseSchemaVersion < 41 && isBitcoin === true) {
|
if (databaseSchemaVersion < 41 && isBitcoin === true) {
|
||||||
await this.$executeQuery('UPDATE channels SET closing_reason = NULL WHERE closing_reason = 1');
|
await this.$executeQuery('UPDATE channels SET closing_reason = NULL WHERE closing_reason = 1');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (databaseSchemaVersion < 42 && isBitcoin === true) {
|
||||||
|
await this.$executeQuery('ALTER TABLE `channels` ADD closing_resolved tinyint(1) DEFAULT 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (databaseSchemaVersion < 43 && isBitcoin === true) {
|
||||||
|
await this.$executeQuery(this.getCreateLNNodeRecordsTableQuery(), await this.$checkIfTableExists('nodes_records'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (databaseSchemaVersion < 44 && isBitcoin === true) {
|
||||||
|
await this.$executeQuery('TRUNCATE TABLE `blocks_audits`');
|
||||||
|
await this.$executeQuery('UPDATE blocks_summaries SET template = NULL');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -787,6 +800,19 @@ class DatabaseMigration {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getCreateLNNodeRecordsTableQuery(): string {
|
||||||
|
return `CREATE TABLE IF NOT EXISTS nodes_records (
|
||||||
|
public_key varchar(66) NOT NULL,
|
||||||
|
type int(10) unsigned NOT NULL,
|
||||||
|
payload blob NOT NULL,
|
||||||
|
UNIQUE KEY public_key_type (public_key, type),
|
||||||
|
INDEX (public_key),
|
||||||
|
FOREIGN KEY (public_key)
|
||||||
|
REFERENCES nodes (public_key)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||||
|
}
|
||||||
|
|
||||||
public async $truncateIndexedData(tables: string[]) {
|
public async $truncateIndexedData(tables: string[]) {
|
||||||
const allowedTables = ['blocks', 'hashrates', 'prices'];
|
const allowedTables = ['blocks', 'hashrates', 'prices'];
|
||||||
|
|
||||||
|
@ -117,6 +117,17 @@ class ChannelsApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async $getUnresolvedClosedChannels(): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const query = `SELECT * FROM channels WHERE status = 2 AND closing_reason = 2 AND closing_resolved = 0 AND closing_transaction_id != ''`;
|
||||||
|
const [rows]: any = await DB.query(query);
|
||||||
|
return rows;
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$getUnresolvedClosedChannels error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async $getChannelsWithoutCreatedDate(): Promise<any[]> {
|
public async $getChannelsWithoutCreatedDate(): Promise<any[]> {
|
||||||
try {
|
try {
|
||||||
const query = `SELECT * FROM channels WHERE created IS NULL`;
|
const query = `SELECT * FROM channels WHERE created IS NULL`;
|
||||||
|
@ -105,6 +105,18 @@ class NodesApi {
|
|||||||
node.closed_channel_count = rows[0].closed_channel_count;
|
node.closed_channel_count = rows[0].closed_channel_count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Custom records
|
||||||
|
query = `
|
||||||
|
SELECT type, payload
|
||||||
|
FROM nodes_records
|
||||||
|
WHERE public_key = ?
|
||||||
|
`;
|
||||||
|
[rows] = await DB.query(query, [public_key]);
|
||||||
|
node.custom_records = {};
|
||||||
|
for (const record of rows) {
|
||||||
|
node.custom_records[record.type] = Buffer.from(record.payload, 'binary').toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
return node;
|
return node;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.err(`Cannot get node information for ${public_key}. Reason: ${(e instanceof Error ? e.message : e)}`);
|
logger.err(`Cannot get node information for ${public_key}. Reason: ${(e instanceof Error ? e.message : e)}`);
|
||||||
|
@ -7,6 +7,15 @@ import { Common } from '../../common';
|
|||||||
* Convert a clightning "listnode" entry to a lnd node entry
|
* Convert a clightning "listnode" entry to a lnd node entry
|
||||||
*/
|
*/
|
||||||
export function convertNode(clNode: any): ILightningApi.Node {
|
export function convertNode(clNode: any): ILightningApi.Node {
|
||||||
|
let custom_records: { [type: number]: string } | undefined = undefined;
|
||||||
|
if (clNode.option_will_fund) {
|
||||||
|
try {
|
||||||
|
custom_records = { '1': Buffer.from(clNode.option_will_fund.compact_lease || '', 'hex').toString('base64') };
|
||||||
|
} catch (e) {
|
||||||
|
logger.err(`Cannot decode option_will_fund compact_lease for ${clNode.nodeid}). Reason: ` + (e instanceof Error ? e.message : e));
|
||||||
|
custom_records = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
alias: clNode.alias ?? '',
|
alias: clNode.alias ?? '',
|
||||||
color: `#${clNode.color ?? ''}`,
|
color: `#${clNode.color ?? ''}`,
|
||||||
@ -23,6 +32,7 @@ export function convertNode(clNode: any): ILightningApi.Node {
|
|||||||
};
|
};
|
||||||
}) ?? [],
|
}) ?? [],
|
||||||
last_update: clNode?.last_timestamp ?? 0,
|
last_update: clNode?.last_timestamp ?? 0,
|
||||||
|
custom_records
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -49,6 +49,7 @@ export namespace ILightningApi {
|
|||||||
}[];
|
}[];
|
||||||
color: string;
|
color: string;
|
||||||
features: { [key: number]: Feature };
|
features: { [key: number]: Feature };
|
||||||
|
custom_records?: { [type: number]: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Info {
|
export interface Info {
|
||||||
|
@ -1,12 +1,17 @@
|
|||||||
import logger from '../logger';
|
import logger from '../logger';
|
||||||
import { MempoolBlock, TransactionExtended, AuditTransaction, TransactionStripped, MempoolBlockWithTransactions, MempoolBlockDelta, Ancestor } from '../mempool.interfaces';
|
import { MempoolBlock, TransactionExtended, TransactionStripped, MempoolBlockWithTransactions, MempoolBlockDelta } 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';
|
import { StaticPool } from 'node-worker-threads-pool';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
class MempoolBlocks {
|
class MempoolBlocks {
|
||||||
private mempoolBlocks: MempoolBlockWithTransactions[] = [];
|
private mempoolBlocks: MempoolBlockWithTransactions[] = [];
|
||||||
private mempoolBlockDeltas: MempoolBlockDelta[] = [];
|
private mempoolBlockDeltas: MempoolBlockDelta[] = [];
|
||||||
|
private makeTemplatesPool = new StaticPool({
|
||||||
|
size: 1,
|
||||||
|
task: path.resolve(__dirname, './tx-selection-worker.js'),
|
||||||
|
});
|
||||||
|
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
||||||
@ -72,16 +77,15 @@ class MempoolBlocks {
|
|||||||
const time = end - start;
|
const time = end - start;
|
||||||
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 = this.calculateMempoolBlocks(memPoolArray, this.mempoolBlocks);
|
||||||
|
const deltas = this.calculateMempoolDeltas(this.mempoolBlocks, blocks);
|
||||||
|
|
||||||
this.mempoolBlocks = blocks;
|
this.mempoolBlocks = blocks;
|
||||||
this.mempoolBlockDeltas = deltas;
|
this.mempoolBlockDeltas = deltas;
|
||||||
}
|
}
|
||||||
|
|
||||||
private calculateMempoolBlocks(transactionsSorted: TransactionExtended[], prevBlocks: MempoolBlockWithTransactions[]):
|
private calculateMempoolBlocks(transactionsSorted: TransactionExtended[], prevBlocks: MempoolBlockWithTransactions[]): MempoolBlockWithTransactions[] {
|
||||||
{ blocks: MempoolBlockWithTransactions[], deltas: MempoolBlockDelta[] } {
|
|
||||||
const mempoolBlocks: MempoolBlockWithTransactions[] = [];
|
const mempoolBlocks: MempoolBlockWithTransactions[] = [];
|
||||||
const mempoolBlockDeltas: MempoolBlockDelta[] = [];
|
|
||||||
let blockWeight = 0;
|
let blockWeight = 0;
|
||||||
let blockSize = 0;
|
let blockSize = 0;
|
||||||
let transactions: TransactionExtended[] = [];
|
let transactions: TransactionExtended[] = [];
|
||||||
@ -102,7 +106,11 @@ class MempoolBlocks {
|
|||||||
mempoolBlocks.push(this.dataToMempoolBlocks(transactions, blockSize, blockWeight, mempoolBlocks.length));
|
mempoolBlocks.push(this.dataToMempoolBlocks(transactions, blockSize, blockWeight, mempoolBlocks.length));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate change from previous block states
|
return mempoolBlocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateMempoolDeltas(prevBlocks: MempoolBlockWithTransactions[], mempoolBlocks: MempoolBlockWithTransactions[]): MempoolBlockDelta[] {
|
||||||
|
const mempoolBlockDeltas: MempoolBlockDelta[] = [];
|
||||||
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[] = [];
|
||||||
let removed: string[] = [];
|
let removed: string[] = [];
|
||||||
@ -135,284 +143,25 @@ class MempoolBlocks {
|
|||||||
removed
|
removed
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return mempoolBlockDeltas;
|
||||||
return {
|
|
||||||
blocks: mempoolBlocks,
|
|
||||||
deltas: mempoolBlockDeltas
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
public async makeBlockTemplates(newMempool: { [txid: string]: TransactionExtended }, blockLimit: number, weightLimit: number | null = null, condenseRest = false): Promise<void> {
|
||||||
* Build projected mempool blocks using an approximation of the transaction selection algorithm from Bitcoin Core
|
const { mempool, blocks } = await this.makeTemplatesPool.exec({ mempool: newMempool, blockLimit, weightLimit, condenseRest });
|
||||||
* (see BlockAssembler in https://github.com/bitcoin/bitcoin/blob/master/src/node/miner.cpp)
|
const deltas = this.calculateMempoolDeltas(this.mempoolBlocks, blocks);
|
||||||
*
|
|
||||||
* 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
|
// copy CPFP info across to main thread's mempool
|
||||||
for (const tx of mempoolArray) {
|
Object.keys(newMempool).forEach((txid) => {
|
||||||
if (!tx.relativesSet) {
|
if (newMempool[txid] && mempool[txid]) {
|
||||||
this.setRelatives(tx, auditPool);
|
newMempool[txid].effectiveFeePerVsize = mempool[txid].effectiveFeePerVsize;
|
||||||
}
|
newMempool[txid].ancestors = mempool[txid].ancestors;
|
||||||
}
|
newMempool[txid].bestDescendant = mempool[txid].bestDescendant;
|
||||||
|
newMempool[txid].cpfpChecked = mempool[txid].cpfpChecked;
|
||||||
// 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) {
|
this.mempoolBlocks = blocks;
|
||||||
descendantTx.modified = true;
|
this.mempoolBlockDeltas = deltas;
|
||||||
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[],
|
||||||
|
@ -20,6 +20,8 @@ class Mempool {
|
|||||||
maxmempool: 300000000, mempoolminfee: 0.00001000, minrelaytxfee: 0.00001000 };
|
maxmempool: 300000000, mempoolminfee: 0.00001000, minrelaytxfee: 0.00001000 };
|
||||||
private mempoolChangedCallback: ((newMempool: {[txId: string]: TransactionExtended; }, newTransactions: TransactionExtended[],
|
private mempoolChangedCallback: ((newMempool: {[txId: string]: TransactionExtended; }, newTransactions: TransactionExtended[],
|
||||||
deletedTransactions: TransactionExtended[]) => void) | undefined;
|
deletedTransactions: TransactionExtended[]) => void) | undefined;
|
||||||
|
private asyncMempoolChangedCallback: ((newMempool: {[txId: string]: TransactionExtended; }, newTransactions: TransactionExtended[],
|
||||||
|
deletedTransactions: TransactionExtended[]) => void) | undefined;
|
||||||
|
|
||||||
private txPerSecondArray: number[] = [];
|
private txPerSecondArray: number[] = [];
|
||||||
private txPerSecond: number = 0;
|
private txPerSecond: number = 0;
|
||||||
@ -63,6 +65,11 @@ class Mempool {
|
|||||||
this.mempoolChangedCallback = fn;
|
this.mempoolChangedCallback = fn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setAsyncMempoolChangedCallback(fn: (newMempool: { [txId: string]: TransactionExtended; },
|
||||||
|
newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]) => Promise<void>) {
|
||||||
|
this.asyncMempoolChangedCallback = fn;
|
||||||
|
}
|
||||||
|
|
||||||
public getMempool(): { [txid: string]: TransactionExtended } {
|
public getMempool(): { [txid: string]: TransactionExtended } {
|
||||||
return this.mempoolCache;
|
return this.mempoolCache;
|
||||||
}
|
}
|
||||||
@ -72,6 +79,9 @@ class Mempool {
|
|||||||
if (this.mempoolChangedCallback) {
|
if (this.mempoolChangedCallback) {
|
||||||
this.mempoolChangedCallback(this.mempoolCache, [], []);
|
this.mempoolChangedCallback(this.mempoolCache, [], []);
|
||||||
}
|
}
|
||||||
|
if (this.asyncMempoolChangedCallback) {
|
||||||
|
this.asyncMempoolChangedCallback(this.mempoolCache, [], []);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async $updateMemPoolInfo() {
|
public async $updateMemPoolInfo() {
|
||||||
@ -187,6 +197,9 @@ class Mempool {
|
|||||||
if (this.mempoolChangedCallback && (hasChange || deletedTransactions.length)) {
|
if (this.mempoolChangedCallback && (hasChange || deletedTransactions.length)) {
|
||||||
this.mempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions);
|
this.mempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions);
|
||||||
}
|
}
|
||||||
|
if (this.asyncMempoolChangedCallback && (hasChange || deletedTransactions.length)) {
|
||||||
|
await this.asyncMempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions);
|
||||||
|
}
|
||||||
|
|
||||||
const end = new Date().getTime();
|
const end = new Date().getTime();
|
||||||
const time = end - start;
|
const time = end - start;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { Application, Request, Response } from 'express';
|
import { Application, Request, Response } from 'express';
|
||||||
import config from "../../config";
|
import config from "../../config";
|
||||||
import logger from '../../logger';
|
import logger from '../../logger';
|
||||||
|
import audits from '../audit';
|
||||||
import BlocksAuditsRepository from '../../repositories/BlocksAuditsRepository';
|
import BlocksAuditsRepository from '../../repositories/BlocksAuditsRepository';
|
||||||
import BlocksRepository from '../../repositories/BlocksRepository';
|
import BlocksRepository from '../../repositories/BlocksRepository';
|
||||||
import DifficultyAdjustmentsRepository from '../../repositories/DifficultyAdjustmentsRepository';
|
import DifficultyAdjustmentsRepository from '../../repositories/DifficultyAdjustmentsRepository';
|
||||||
@ -26,6 +27,9 @@ class MiningRoutes {
|
|||||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/sizes-weights/:interval', this.$getHistoricalBlockSizeAndWeight)
|
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/sizes-weights/:interval', this.$getHistoricalBlockSizeAndWeight)
|
||||||
.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/scores', this.$getBlockAuditScores)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/audit/scores/:height', this.$getBlockAuditScores)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/audit/score/:hash', this.$getBlockAuditScore)
|
||||||
.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)
|
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/timestamp/:timestamp', this.$getHeightFromTimestamp)
|
||||||
;
|
;
|
||||||
@ -276,6 +280,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 $getBlockAuditScores(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const height = req.params.height === undefined ? undefined : parseInt(req.params.height, 10);
|
||||||
|
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||||
|
res.json(await audits.$getBlockAuditScores(height, 15));
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async $getBlockAuditScore(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const audit = await BlocksAuditsRepository.$getBlockAuditScore(req.params.hash);
|
||||||
|
|
||||||
|
res.header('Pragma', 'public');
|
||||||
|
res.header('Cache-control', 'public');
|
||||||
|
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24).toUTCString());
|
||||||
|
res.json(audit || 'null');
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new MiningRoutes();
|
export default new MiningRoutes();
|
||||||
|
@ -14,10 +14,10 @@ interface Pool {
|
|||||||
class PoolsParser {
|
class PoolsParser {
|
||||||
miningPools: any[] = [];
|
miningPools: any[] = [];
|
||||||
unknownPool: any = {
|
unknownPool: any = {
|
||||||
'name': "Unknown",
|
'name': 'Unknown',
|
||||||
'link': "https://learnmeabitcoin.com/technical/coinbase-transaction",
|
'link': 'https://learnmeabitcoin.com/technical/coinbase-transaction',
|
||||||
'regexes': "[]",
|
'regexes': '[]',
|
||||||
'addresses': "[]",
|
'addresses': '[]',
|
||||||
'slug': 'unknown'
|
'slug': 'unknown'
|
||||||
};
|
};
|
||||||
slugWarnFlag = false;
|
slugWarnFlag = false;
|
||||||
@ -25,7 +25,7 @@ class PoolsParser {
|
|||||||
/**
|
/**
|
||||||
* Parse the pools.json file, consolidate the data and dump it into the database
|
* Parse the pools.json file, consolidate the data and dump it into the database
|
||||||
*/
|
*/
|
||||||
public async migratePoolsJson(poolsJson: object) {
|
public async migratePoolsJson(poolsJson: object): Promise<void> {
|
||||||
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === false) {
|
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === false) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -81,6 +81,7 @@ class PoolsParser {
|
|||||||
// Finally, we generate the final consolidated pools data
|
// Finally, we generate the final consolidated pools data
|
||||||
const finalPoolDataAdd: Pool[] = [];
|
const finalPoolDataAdd: Pool[] = [];
|
||||||
const finalPoolDataUpdate: Pool[] = [];
|
const finalPoolDataUpdate: Pool[] = [];
|
||||||
|
const finalPoolDataRename: Pool[] = [];
|
||||||
for (let i = 0; i < poolNames.length; ++i) {
|
for (let i = 0; i < poolNames.length; ++i) {
|
||||||
let allAddresses: string[] = [];
|
let allAddresses: string[] = [];
|
||||||
let allRegexes: string[] = [];
|
let allRegexes: string[] = [];
|
||||||
@ -127,8 +128,26 @@ class PoolsParser {
|
|||||||
finalPoolDataUpdate.push(poolObj);
|
finalPoolDataUpdate.push(poolObj);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.debug(`Add '${finalPoolName}' mining pool`);
|
// Double check that if we're not just renaming a pool (same address same regex)
|
||||||
finalPoolDataAdd.push(poolObj);
|
const [poolToRename]: any[] = await DB.query(`
|
||||||
|
SELECT * FROM pools
|
||||||
|
WHERE addresses = ? OR regexes = ?`,
|
||||||
|
[JSON.stringify(poolObj.addresses), JSON.stringify(poolObj.regexes)]
|
||||||
|
);
|
||||||
|
if (poolToRename && poolToRename.length > 0) {
|
||||||
|
// We're actually renaming an existing pool
|
||||||
|
finalPoolDataRename.push({
|
||||||
|
'name': poolObj.name,
|
||||||
|
'link': poolObj.link,
|
||||||
|
'regexes': allRegexes,
|
||||||
|
'addresses': allAddresses,
|
||||||
|
'slug': slug
|
||||||
|
});
|
||||||
|
logger.debug(`Rename '${poolToRename[0].name}' mining pool to ${poolObj.name}`);
|
||||||
|
} else {
|
||||||
|
logger.debug(`Add '${finalPoolName}' mining pool`);
|
||||||
|
finalPoolDataAdd.push(poolObj);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.miningPools.push({
|
this.miningPools.push({
|
||||||
@ -145,7 +164,9 @@ class PoolsParser {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (finalPoolDataAdd.length > 0 || finalPoolDataUpdate.length > 0) {
|
if (finalPoolDataAdd.length > 0 || finalPoolDataUpdate.length > 0 ||
|
||||||
|
finalPoolDataRename.length > 0
|
||||||
|
) {
|
||||||
logger.debug(`Update pools table now`);
|
logger.debug(`Update pools table now`);
|
||||||
|
|
||||||
// Add new mining pools into the database
|
// Add new mining pools into the database
|
||||||
@ -169,8 +190,22 @@ class PoolsParser {
|
|||||||
;`);
|
;`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rename mining pools
|
||||||
|
const renameQueries: string[] = [];
|
||||||
|
for (let i = 0; i < finalPoolDataRename.length; ++i) {
|
||||||
|
renameQueries.push(`
|
||||||
|
UPDATE pools
|
||||||
|
SET name='${finalPoolDataRename[i].name}', link='${finalPoolDataRename[i].link}',
|
||||||
|
slug='${finalPoolDataRename[i].slug}'
|
||||||
|
WHERE regexes='${JSON.stringify(finalPoolDataRename[i].regexes)}'
|
||||||
|
AND addresses='${JSON.stringify(finalPoolDataRename[i].addresses)}'
|
||||||
|
;`);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.$deleteBlocskToReindex(finalPoolDataUpdate);
|
if (finalPoolDataAdd.length > 0 || updateQueries.length > 0) {
|
||||||
|
await this.$deleteBlocskToReindex(finalPoolDataUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
if (finalPoolDataAdd.length > 0) {
|
if (finalPoolDataAdd.length > 0) {
|
||||||
await DB.query({ sql: queryAdd, timeout: 120000 });
|
await DB.query({ sql: queryAdd, timeout: 120000 });
|
||||||
@ -178,6 +213,9 @@ class PoolsParser {
|
|||||||
for (const query of updateQueries) {
|
for (const query of updateQueries) {
|
||||||
await DB.query({ sql: query, timeout: 120000 });
|
await DB.query({ sql: query, timeout: 120000 });
|
||||||
}
|
}
|
||||||
|
for (const query of renameQueries) {
|
||||||
|
await DB.query({ sql: query, timeout: 120000 });
|
||||||
|
}
|
||||||
await this.insertUnknownPool();
|
await this.insertUnknownPool();
|
||||||
logger.info('Mining pools.json import completed');
|
logger.info('Mining pools.json import completed');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
336
backend/src/api/tx-selection-worker.ts
Normal file
336
backend/src/api/tx-selection-worker.ts
Normal file
@ -0,0 +1,336 @@
|
|||||||
|
import config from '../config';
|
||||||
|
import logger from '../logger';
|
||||||
|
import { TransactionExtended, MempoolBlockWithTransactions, AuditTransaction } from '../mempool.interfaces';
|
||||||
|
import { PairingHeap } from '../utils/pairing-heap';
|
||||||
|
import { Common } from './common';
|
||||||
|
import { parentPort } from 'worker_threads';
|
||||||
|
|
||||||
|
if (parentPort) {
|
||||||
|
parentPort.on('message', (params: { mempool: { [txid: string]: TransactionExtended }, blockLimit: number, weightLimit: number | null, condenseRest: boolean}) => {
|
||||||
|
const { mempool, blocks } = makeBlockTemplates(params);
|
||||||
|
|
||||||
|
// return the result to main thread.
|
||||||
|
if (parentPort) {
|
||||||
|
parentPort.postMessage({ mempool, blocks });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
function makeBlockTemplates({ mempool, blockLimit, weightLimit, condenseRest }: { mempool: { [txid: string]: TransactionExtended }, blockLimit: number, weightLimit?: number | null, condenseRest?: boolean | null })
|
||||||
|
: { mempool: { [txid: string]: TransactionExtended }, blocks: 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) {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
mempoolTx.cpfpChecked = true;
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
mempoolTx.bestDescendant = null;
|
||||||
|
}
|
||||||
|
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 => {
|
||||||
|
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);
|
||||||
|
const queueEmpty = top >= mempoolArray.length && modified.isEmpty();
|
||||||
|
if ((exceededPackageTries || queueEmpty) && (!condenseRest || blocks.length < blockLimit - 1)) {
|
||||||
|
// construct this block
|
||||||
|
if (transactions.length) {
|
||||||
|
blocks.push(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;
|
||||||
|
const mempoolTx = mempool[tx.txid];
|
||||||
|
// update original copy of this tx with effective fee rate & relatives data
|
||||||
|
mempoolTx.effectiveFeePerVsize = tx.score;
|
||||||
|
mempoolTx.ancestors = (Array.from(tx.ancestorMap?.values()) as AuditTransaction[]).map((a) => {
|
||||||
|
return {
|
||||||
|
txid: a.txid,
|
||||||
|
fee: a.fee,
|
||||||
|
weight: a.weight,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
mempoolTx.bestDescendant = null;
|
||||||
|
mempoolTx.cpfpChecked = true;
|
||||||
|
transactions.push(tx);
|
||||||
|
tx.used = true;
|
||||||
|
}
|
||||||
|
const blockTransactions = transactions.map(t => mempool[t.txid]);
|
||||||
|
restOfArray.forEach(tx => {
|
||||||
|
blockWeight += tx.weight;
|
||||||
|
blockSize += tx.size;
|
||||||
|
tx.effectiveFeePerVsize = tx.feePerVsize;
|
||||||
|
tx.cpfpChecked = false;
|
||||||
|
tx.ancestors = [];
|
||||||
|
tx.bestDescendant = null;
|
||||||
|
blockTransactions.push(tx);
|
||||||
|
});
|
||||||
|
if (blockTransactions.length) {
|
||||||
|
blocks.push(dataToMempoolBlocks(blockTransactions, blockSize, blockWeight, blocks.length));
|
||||||
|
}
|
||||||
|
transactions = [];
|
||||||
|
} else if (transactions.length) {
|
||||||
|
blocks.push(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 {
|
||||||
|
mempool,
|
||||||
|
blocks
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// traverse in-mempool ancestors
|
||||||
|
// recursion unavoidable, but should be limited to depth < 25 by mempool policy
|
||||||
|
function 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) {
|
||||||
|
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 / 4) || 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
|
||||||
|
function 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 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 / 4);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataToMempoolBlocks(transactions: TransactionExtended[],
|
||||||
|
blockSize: number, blockWeight: number, blocksIndex: number): MempoolBlockWithTransactions {
|
||||||
|
let rangeLength = 4;
|
||||||
|
if (blocksIndex === 0) {
|
||||||
|
rangeLength = 8;
|
||||||
|
}
|
||||||
|
if (transactions.length > 4000) {
|
||||||
|
rangeLength = 6;
|
||||||
|
} else if (transactions.length > 10000) {
|
||||||
|
rangeLength = 8;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
blockSize: blockSize,
|
||||||
|
blockVSize: blockWeight / 4,
|
||||||
|
nTx: transactions.length,
|
||||||
|
totalFees: transactions.reduce((acc, cur) => acc + cur.fee, 0),
|
||||||
|
medianFee: Common.percentile(transactions.map((tx) => tx.effectiveFeePerVsize), config.MEMPOOL.RECOMMENDED_FEE_PERCENTILE),
|
||||||
|
feeRange: Common.getFeesInRange(transactions, rangeLength),
|
||||||
|
transactionIds: transactions.map((tx) => tx.txid),
|
||||||
|
transactions: transactions.map((tx) => Common.stripTransaction(tx)),
|
||||||
|
};
|
||||||
|
}
|
@ -244,13 +244,18 @@ class WebsocketHandler {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMempoolChange(newMempool: { [txid: string]: TransactionExtended },
|
async handleMempoolChange(newMempool: { [txid: string]: TransactionExtended },
|
||||||
newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]) {
|
newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]): Promise<void> {
|
||||||
if (!this.wss) {
|
if (!this.wss) {
|
||||||
throw new Error('WebSocket.Server is not set');
|
throw new Error('WebSocket.Server is not set');
|
||||||
}
|
}
|
||||||
|
|
||||||
mempoolBlocks.updateMempoolBlocks(newMempool);
|
if (config.MEMPOOL.ADVANCED_TRANSACTION_SELECTION) {
|
||||||
|
await mempoolBlocks.makeBlockTemplates(newMempool, 8, null, true);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
mempoolBlocks.updateMempoolBlocks(newMempool);
|
||||||
|
}
|
||||||
const mBlocks = mempoolBlocks.getMempoolBlocks();
|
const mBlocks = mempoolBlocks.getMempoolBlocks();
|
||||||
const mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas();
|
const mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas();
|
||||||
const mempoolInfo = memPool.getMempoolInfo();
|
const mempoolInfo = memPool.getMempoolInfo();
|
||||||
@ -405,22 +410,25 @@ class WebsocketHandler {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleNewBlock(block: BlockExtended, txIds: string[], transactions: TransactionExtended[]): void {
|
async handleNewBlock(block: BlockExtended, txIds: string[], transactions: TransactionExtended[]): Promise<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 mBlockDeltas: undefined | MempoolBlockDelta[];
|
|
||||||
let matchRate;
|
|
||||||
const _memPool = memPool.getMempool();
|
const _memPool = memPool.getMempool();
|
||||||
|
let matchRate;
|
||||||
|
|
||||||
if (Common.indexingEnabled()) {
|
if (config.MEMPOOL.ADVANCED_TRANSACTION_SELECTION) {
|
||||||
const mempoolCopy = cloneMempool(_memPool);
|
await mempoolBlocks.makeBlockTemplates(_memPool, 2);
|
||||||
const projectedBlocks = mempoolBlocks.makeBlockTemplates(mempoolCopy, 2);
|
} else {
|
||||||
|
mempoolBlocks.updateMempoolBlocks(_memPool);
|
||||||
|
}
|
||||||
|
|
||||||
const { censored, added, score } = Audit.auditBlock(transactions, projectedBlocks, mempoolCopy);
|
if (Common.indexingEnabled() && memPool.isInSync()) {
|
||||||
|
const projectedBlocks = mempoolBlocks.getMempoolBlocksWithTransactions();
|
||||||
|
|
||||||
|
const { censored, added, score } = Audit.auditBlock(transactions, projectedBlocks, _memPool);
|
||||||
matchRate = Math.round(score * 100 * 100) / 100;
|
matchRate = Math.round(score * 100 * 100) / 100;
|
||||||
|
|
||||||
const stripped = projectedBlocks[0]?.transactions ? projectedBlocks[0].transactions.map((tx) => {
|
const stripped = projectedBlocks[0]?.transactions ? projectedBlocks[0].transactions.map((tx) => {
|
||||||
@ -459,9 +467,13 @@ class WebsocketHandler {
|
|||||||
delete _memPool[txId];
|
delete _memPool[txId];
|
||||||
}
|
}
|
||||||
|
|
||||||
mempoolBlocks.updateMempoolBlocks(_memPool);
|
if (config.MEMPOOL.ADVANCED_TRANSACTION_SELECTION) {
|
||||||
mBlocks = mempoolBlocks.getMempoolBlocks();
|
await mempoolBlocks.makeBlockTemplates(_memPool, 2);
|
||||||
mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas();
|
} else {
|
||||||
|
mempoolBlocks.updateMempoolBlocks(_memPool);
|
||||||
|
}
|
||||||
|
const mBlocks = mempoolBlocks.getMempoolBlocks();
|
||||||
|
const mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas();
|
||||||
|
|
||||||
const da = difficultyAdjustment.getDifficultyAdjustment();
|
const da = difficultyAdjustment.getDifficultyAdjustment();
|
||||||
const fees = feeApi.getRecommendedFee();
|
const fees = feeApi.getRecommendedFee();
|
||||||
@ -569,14 +581,4 @@ 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();
|
||||||
|
@ -29,6 +29,7 @@ interface IConfig {
|
|||||||
AUTOMATIC_BLOCK_REINDEXING: boolean;
|
AUTOMATIC_BLOCK_REINDEXING: boolean;
|
||||||
POOLS_JSON_URL: string,
|
POOLS_JSON_URL: string,
|
||||||
POOLS_JSON_TREE_URL: string,
|
POOLS_JSON_TREE_URL: string,
|
||||||
|
ADVANCED_TRANSACTION_SELECTION: boolean;
|
||||||
};
|
};
|
||||||
ESPLORA: {
|
ESPLORA: {
|
||||||
REST_API_URL: string;
|
REST_API_URL: string;
|
||||||
@ -145,6 +146,7 @@ const defaults: IConfig = {
|
|||||||
'AUTOMATIC_BLOCK_REINDEXING': false,
|
'AUTOMATIC_BLOCK_REINDEXING': false,
|
||||||
'POOLS_JSON_URL': 'https://raw.githubusercontent.com/mempool/mining-pools/master/pools.json',
|
'POOLS_JSON_URL': 'https://raw.githubusercontent.com/mempool/mining-pools/master/pools.json',
|
||||||
'POOLS_JSON_TREE_URL': 'https://api.github.com/repos/mempool/mining-pools/git/trees/master',
|
'POOLS_JSON_TREE_URL': 'https://api.github.com/repos/mempool/mining-pools/git/trees/master',
|
||||||
|
'ADVANCED_TRANSACTION_SELECTION': false,
|
||||||
},
|
},
|
||||||
'ESPLORA': {
|
'ESPLORA': {
|
||||||
'REST_API_URL': 'http://127.0.0.1:3000',
|
'REST_API_URL': 'http://127.0.0.1:3000',
|
||||||
|
@ -216,8 +216,8 @@ class Server {
|
|||||||
websocketHandler.setupConnectionHandling();
|
websocketHandler.setupConnectionHandling();
|
||||||
if (config.MEMPOOL.ENABLED) {
|
if (config.MEMPOOL.ENABLED) {
|
||||||
statistics.setNewStatisticsEntryCallback(websocketHandler.handleNewStatistic.bind(websocketHandler));
|
statistics.setNewStatisticsEntryCallback(websocketHandler.handleNewStatistic.bind(websocketHandler));
|
||||||
blocks.setNewBlockCallback(websocketHandler.handleNewBlock.bind(websocketHandler));
|
memPool.setAsyncMempoolChangedCallback(websocketHandler.handleMempoolChange.bind(websocketHandler));
|
||||||
memPool.setMempoolChangedCallback(websocketHandler.handleMempoolChange.bind(websocketHandler));
|
blocks.setNewAsyncBlockCallback(websocketHandler.handleNewBlock.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));
|
||||||
|
@ -32,6 +32,11 @@ export interface BlockAudit {
|
|||||||
matchRate: number,
|
matchRate: number,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuditScore {
|
||||||
|
hash: string,
|
||||||
|
matchRate?: number,
|
||||||
|
}
|
||||||
|
|
||||||
export interface MempoolBlock {
|
export interface MempoolBlock {
|
||||||
blockSize: number;
|
blockSize: number;
|
||||||
blockVSize: number;
|
blockVSize: number;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import DB from '../database';
|
import DB from '../database';
|
||||||
import logger from '../logger';
|
import logger from '../logger';
|
||||||
import { BlockAudit } from '../mempool.interfaces';
|
import { BlockAudit, AuditScore } from '../mempool.interfaces';
|
||||||
|
|
||||||
class BlocksAuditRepositories {
|
class BlocksAuditRepositories {
|
||||||
public async $saveAudit(audit: BlockAudit): Promise<void> {
|
public async $saveAudit(audit: BlockAudit): Promise<void> {
|
||||||
@ -72,10 +72,10 @@ class BlocksAuditRepositories {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async $getShortBlockAudit(hash: string): Promise<any> {
|
public async $getBlockAuditScore(hash: string): Promise<AuditScore> {
|
||||||
try {
|
try {
|
||||||
const [rows]: any[] = await DB.query(
|
const [rows]: any[] = await DB.query(
|
||||||
`SELECT hash as id, match_rate as matchRate
|
`SELECT hash, match_rate as matchRate
|
||||||
FROM blocks_audits
|
FROM blocks_audits
|
||||||
WHERE blocks_audits.hash = "${hash}"
|
WHERE blocks_audits.hash = "${hash}"
|
||||||
`);
|
`);
|
||||||
|
67
backend/src/repositories/NodeRecordsRepository.ts
Normal file
67
backend/src/repositories/NodeRecordsRepository.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { ResultSetHeader, RowDataPacket } from 'mysql2';
|
||||||
|
import DB from '../database';
|
||||||
|
import logger from '../logger';
|
||||||
|
|
||||||
|
export interface NodeRecord {
|
||||||
|
publicKey: string; // node public key
|
||||||
|
type: number; // TLV extension record type
|
||||||
|
payload: string; // base64 record payload
|
||||||
|
}
|
||||||
|
|
||||||
|
class NodesRecordsRepository {
|
||||||
|
public async $saveRecord(record: NodeRecord): Promise<void> {
|
||||||
|
try {
|
||||||
|
const payloadBytes = Buffer.from(record.payload, 'base64');
|
||||||
|
await DB.query(`
|
||||||
|
INSERT INTO nodes_records(public_key, type, payload)
|
||||||
|
VALUE (?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
payload = ?
|
||||||
|
`, [record.publicKey, record.type, payloadBytes, payloadBytes]);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.errno !== 1062) { // ER_DUP_ENTRY - Not an issue, just ignore this
|
||||||
|
logger.err(`Cannot save node record (${[record.publicKey, record.type, record.payload]}) into db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||||
|
// We don't throw, not a critical issue if we miss some nodes records
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async $getRecordTypes(publicKey: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const query = `
|
||||||
|
SELECT type FROM nodes_records
|
||||||
|
WHERE public_key = ?
|
||||||
|
`;
|
||||||
|
const [rows] = await DB.query<RowDataPacket[][]>(query, [publicKey]);
|
||||||
|
return rows.map(row => row['type']);
|
||||||
|
} catch (e) {
|
||||||
|
logger.err(`Cannot retrieve custom records for ${publicKey} from db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async $deleteUnusedRecords(publicKey: string, recordTypes: number[]): Promise<number> {
|
||||||
|
try {
|
||||||
|
let query;
|
||||||
|
if (recordTypes.length) {
|
||||||
|
query = `
|
||||||
|
DELETE FROM nodes_records
|
||||||
|
WHERE public_key = ?
|
||||||
|
AND type NOT IN (${recordTypes.map(type => `${type}`).join(',')})
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
query = `
|
||||||
|
DELETE FROM nodes_records
|
||||||
|
WHERE public_key = ?
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
const [result] = await DB.query<ResultSetHeader>(query, [publicKey]);
|
||||||
|
return result.affectedRows;
|
||||||
|
} catch (e) {
|
||||||
|
logger.err(`Cannot delete unused custom records for ${publicKey} from db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new NodesRecordsRepository();
|
@ -13,6 +13,7 @@ import fundingTxFetcher from './sync-tasks/funding-tx-fetcher';
|
|||||||
import NodesSocketsRepository from '../../repositories/NodesSocketsRepository';
|
import NodesSocketsRepository from '../../repositories/NodesSocketsRepository';
|
||||||
import { Common } from '../../api/common';
|
import { Common } from '../../api/common';
|
||||||
import blocks from '../../api/blocks';
|
import blocks from '../../api/blocks';
|
||||||
|
import NodeRecordsRepository from '../../repositories/NodeRecordsRepository';
|
||||||
|
|
||||||
class NetworkSyncService {
|
class NetworkSyncService {
|
||||||
loggerTimer = 0;
|
loggerTimer = 0;
|
||||||
@ -63,6 +64,7 @@ class NetworkSyncService {
|
|||||||
let progress = 0;
|
let progress = 0;
|
||||||
|
|
||||||
let deletedSockets = 0;
|
let deletedSockets = 0;
|
||||||
|
let deletedRecords = 0;
|
||||||
const graphNodesPubkeys: string[] = [];
|
const graphNodesPubkeys: string[] = [];
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
const latestUpdated = await channelsApi.$getLatestChannelUpdateForNode(node.pub_key);
|
const latestUpdated = await channelsApi.$getLatestChannelUpdateForNode(node.pub_key);
|
||||||
@ -84,8 +86,23 @@ class NetworkSyncService {
|
|||||||
addresses.push(socket.addr);
|
addresses.push(socket.addr);
|
||||||
}
|
}
|
||||||
deletedSockets += await NodesSocketsRepository.$deleteUnusedSockets(node.pub_key, addresses);
|
deletedSockets += await NodesSocketsRepository.$deleteUnusedSockets(node.pub_key, addresses);
|
||||||
|
|
||||||
|
const oldRecordTypes = await NodeRecordsRepository.$getRecordTypes(node.pub_key);
|
||||||
|
const customRecordTypes: number[] = [];
|
||||||
|
for (const [type, payload] of Object.entries(node.custom_records || {})) {
|
||||||
|
const numericalType = parseInt(type);
|
||||||
|
await NodeRecordsRepository.$saveRecord({
|
||||||
|
publicKey: node.pub_key,
|
||||||
|
type: numericalType,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
customRecordTypes.push(numericalType);
|
||||||
|
}
|
||||||
|
if (oldRecordTypes.reduce((changed, type) => changed || customRecordTypes.indexOf(type) === -1, false)) {
|
||||||
|
deletedRecords += await NodeRecordsRepository.$deleteUnusedRecords(node.pub_key, customRecordTypes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
logger.info(`${progress} nodes updated. ${deletedSockets} sockets deleted`);
|
logger.info(`${progress} nodes updated. ${deletedSockets} sockets deleted. ${deletedRecords} custom records deleted.`);
|
||||||
|
|
||||||
// If a channel if not present in the graph, mark it as inactive
|
// If a channel if not present in the graph, mark it as inactive
|
||||||
await nodesApi.$setNodesInactive(graphNodesPubkeys);
|
await nodesApi.$setNodesInactive(graphNodesPubkeys);
|
||||||
@ -309,7 +326,7 @@ class NetworkSyncService {
|
|||||||
└──────────────────┘
|
└──────────────────┘
|
||||||
*/
|
*/
|
||||||
|
|
||||||
private async $runClosedChannelsForensics(): Promise<void> {
|
private async $runClosedChannelsForensics(skipUnresolved: boolean = false): Promise<void> {
|
||||||
if (!config.ESPLORA.REST_API_URL) {
|
if (!config.ESPLORA.REST_API_URL) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -318,9 +335,18 @@ class NetworkSyncService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
logger.info(`Started running closed channel forensics...`);
|
logger.info(`Started running closed channel forensics...`);
|
||||||
const channels = await channelsApi.$getClosedChannelsWithoutReason();
|
let channels;
|
||||||
|
const closedChannels = await channelsApi.$getClosedChannelsWithoutReason();
|
||||||
|
if (skipUnresolved) {
|
||||||
|
channels = closedChannels;
|
||||||
|
} else {
|
||||||
|
const unresolvedChannels = await channelsApi.$getUnresolvedClosedChannels();
|
||||||
|
channels = [...closedChannels, ...unresolvedChannels];
|
||||||
|
}
|
||||||
|
|
||||||
for (const channel of channels) {
|
for (const channel of channels) {
|
||||||
let reason = 0;
|
let reason = 0;
|
||||||
|
let resolvedForceClose = false;
|
||||||
// Only Esplora backend can retrieve spent transaction outputs
|
// Only Esplora backend can retrieve spent transaction outputs
|
||||||
try {
|
try {
|
||||||
let outspends: IEsploraApi.Outspend[] | undefined;
|
let outspends: IEsploraApi.Outspend[] | undefined;
|
||||||
@ -350,6 +376,7 @@ class NetworkSyncService {
|
|||||||
reason = 3;
|
reason = 3;
|
||||||
} else {
|
} else {
|
||||||
reason = 2;
|
reason = 2;
|
||||||
|
resolvedForceClose = true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
/*
|
/*
|
||||||
@ -374,6 +401,9 @@ class NetworkSyncService {
|
|||||||
if (reason) {
|
if (reason) {
|
||||||
logger.debug('Setting closing reason ' + reason + ' for channel: ' + channel.id + '.');
|
logger.debug('Setting closing reason ' + reason + ' for channel: ' + channel.id + '.');
|
||||||
await DB.query(`UPDATE channels SET closing_reason = ? WHERE id = ?`, [reason, channel.id]);
|
await DB.query(`UPDATE channels SET closing_reason = ? WHERE id = ?`, [reason, channel.id]);
|
||||||
|
if (reason === 2 && resolvedForceClose) {
|
||||||
|
await DB.query(`UPDATE channels SET closing_resolved = ? WHERE id = ?`, [true, channel.id]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.err(`$runClosedChannelsForensics() failed for channel ${channel.short_id}. Reason: ${e instanceof Error ? e.message : e}`);
|
logger.err(`$runClosedChannelsForensics() failed for channel ${channel.short_id}. Reason: ${e instanceof Error ? e.message : e}`);
|
||||||
|
@ -6,6 +6,7 @@ import DB from '../../../database';
|
|||||||
import logger from '../../../logger';
|
import logger from '../../../logger';
|
||||||
import { ResultSetHeader } from 'mysql2';
|
import { ResultSetHeader } from 'mysql2';
|
||||||
import * as IPCheck from '../../../utils/ipcheck.js';
|
import * as IPCheck from '../../../utils/ipcheck.js';
|
||||||
|
import { Reader } from 'mmdb-lib';
|
||||||
|
|
||||||
export async function $lookupNodeLocation(): Promise<void> {
|
export async function $lookupNodeLocation(): Promise<void> {
|
||||||
let loggerTimer = new Date().getTime() / 1000;
|
let loggerTimer = new Date().getTime() / 1000;
|
||||||
@ -18,7 +19,10 @@ export async function $lookupNodeLocation(): Promise<void> {
|
|||||||
const nodes = await nodesApi.$getAllNodes();
|
const nodes = await nodesApi.$getAllNodes();
|
||||||
const lookupCity = await maxmind.open<CityResponse>(config.MAXMIND.GEOLITE2_CITY);
|
const lookupCity = await maxmind.open<CityResponse>(config.MAXMIND.GEOLITE2_CITY);
|
||||||
const lookupAsn = await maxmind.open<AsnResponse>(config.MAXMIND.GEOLITE2_ASN);
|
const lookupAsn = await maxmind.open<AsnResponse>(config.MAXMIND.GEOLITE2_ASN);
|
||||||
const lookupIsp = await maxmind.open<IspResponse>(config.MAXMIND.GEOIP2_ISP);
|
let lookupIsp: Reader<IspResponse> | null = null;
|
||||||
|
try {
|
||||||
|
lookupIsp = await maxmind.open<IspResponse>(config.MAXMIND.GEOIP2_ISP);
|
||||||
|
} catch (e) { }
|
||||||
|
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
const sockets: string[] = node.sockets.split(',');
|
const sockets: string[] = node.sockets.split(',');
|
||||||
@ -29,7 +33,10 @@ export async function $lookupNodeLocation(): Promise<void> {
|
|||||||
if (hasClearnet && ip !== '127.0.1.1' && ip !== '127.0.0.1') {
|
if (hasClearnet && ip !== '127.0.1.1' && ip !== '127.0.0.1') {
|
||||||
const city = lookupCity.get(ip);
|
const city = lookupCity.get(ip);
|
||||||
const asn = lookupAsn.get(ip);
|
const asn = lookupAsn.get(ip);
|
||||||
const isp = lookupIsp.get(ip);
|
let isp: IspResponse | null = null;
|
||||||
|
if (lookupIsp) {
|
||||||
|
isp = lookupIsp.get(ip);
|
||||||
|
}
|
||||||
|
|
||||||
let asOverwrite: any | undefined;
|
let asOverwrite: any | undefined;
|
||||||
if (asn && (IPCheck.match(ip, '170.75.160.0/20') || IPCheck.match(ip, '172.81.176.0/21'))) {
|
if (asn && (IPCheck.match(ip, '170.75.160.0/20') || IPCheck.match(ip, '172.81.176.0/21'))) {
|
||||||
|
@ -79,7 +79,7 @@ export const poolsColor = {
|
|||||||
'binancepool': '#1E88E5',
|
'binancepool': '#1E88E5',
|
||||||
'viabtc': '#039BE5',
|
'viabtc': '#039BE5',
|
||||||
'btccom': '#00897B',
|
'btccom': '#00897B',
|
||||||
'slushpool': '#00ACC1',
|
'braiinspool': '#00ACC1',
|
||||||
'sbicrypto': '#43A047',
|
'sbicrypto': '#43A047',
|
||||||
'marapool': '#7CB342',
|
'marapool': '#7CB342',
|
||||||
'luxor': '#C0CA33',
|
'luxor': '#C0CA33',
|
||||||
|
@ -129,7 +129,7 @@
|
|||||||
<span>Gemini</span>
|
<span>Gemini</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://exodus.com/" target="_blank" title="Exodus">
|
<a href="https://exodus.com/" target="_blank" title="Exodus">
|
||||||
<svg width="80" height="80" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="81" height="81" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<circle cx="250" cy="250" r="250" fill="#1F2033"/>
|
<circle cx="250" cy="250" r="250" fill="#1F2033"/>
|
||||||
<g clip-path="url(#clip0_2_14)">
|
<g clip-path="url(#clip0_2_14)">
|
||||||
<path d="M411.042 178.303L271.79 87V138.048L361.121 196.097L350.612 229.351H271.79V271.648H350.612L361.121 304.903L271.79 362.952V414L411.042 322.989L388.271 250.646L411.042 178.303Z" fill="url(#paint0_linear_2_14)"/>
|
<path d="M411.042 178.303L271.79 87V138.048L361.121 196.097L350.612 229.351H271.79V271.648H350.612L361.121 304.903L271.79 362.952V414L411.042 322.989L388.271 250.646L411.042 178.303Z" fill="url(#paint0_linear_2_14)"/>
|
||||||
@ -274,6 +274,10 @@
|
|||||||
<img class="image" src="/resources/profile/schildbach.svg" />
|
<img class="image" src="/resources/profile/schildbach.svg" />
|
||||||
<span>Schildbach</span>
|
<span>Schildbach</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="https://github.com/nunchuk-io" target="_blank" title="Nunchuck">
|
||||||
|
<img class="image" src="/resources/profile/nunchuk.svg" />
|
||||||
|
<span>Nunchuk</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -3,8 +3,8 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
.image {
|
.image {
|
||||||
width: 80px;
|
width: 81px;
|
||||||
height: 80px;
|
height: 81px;
|
||||||
background-size: 100%, 100%;
|
background-size: 100%, 100%;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
margin: 25px;
|
margin: 25px;
|
||||||
|
@ -41,10 +41,6 @@
|
|||||||
</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]="'‎' + (blockAudit.size | bytes: 2)"></td>
|
<td [innerHTML]="'‎' + (blockAudit.size | bytes: 2)"></td>
|
||||||
@ -61,6 +57,10 @@
|
|||||||
<div class="col-sm" *ngIf="blockAudit">
|
<div class="col-sm" *ngIf="blockAudit">
|
||||||
<table class="table table-borderless table-striped">
|
<table class="table table-borderless table-striped">
|
||||||
<tbody>
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="td-width" i18n="shared.transaction-count">Transactions</td>
|
||||||
|
<td>{{ blockAudit.tx_count }}</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td i18n="block.health">Block health</td>
|
<td i18n="block.health">Block health</td>
|
||||||
<td>{{ blockAudit.matchRate }}%</td>
|
<td>{{ blockAudit.matchRate }}%</td>
|
||||||
@ -69,18 +69,10 @@
|
|||||||
<td i18n="block.missing-txs">Removed 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>
|
||||||
@ -97,21 +89,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ng-template [ngIf]="!error && isLoading">
|
<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>
|
|
||||||
|
|
||||||
<a *ngIf="blockAudit" [routerLink]="['/block/' | relativeUrl, blockHash]">{{ blockAudit.height }}</a>
|
|
||||||
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div class="grow"></div>
|
|
||||||
|
|
||||||
<button [routerLink]="['/' | relativeUrl]" class="btn btn-sm">✕</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- OVERVIEW -->
|
<!-- OVERVIEW -->
|
||||||
<div class="box mb-3">
|
<div class="box mb-3">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@ -123,7 +100,6 @@
|
|||||||
<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>
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@ -136,7 +112,6 @@
|
|||||||
<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>
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@ -180,16 +155,16 @@
|
|||||||
<div class="col-sm" *ngIf="webGlEnabled">
|
<div class="col-sm" *ngIf="webGlEnabled">
|
||||||
<h3 class="block-subtitle" *ngIf="!isMobile" i18n="block.projected-block">Projected Block</h3>
|
<h3 class="block-subtitle" *ngIf="!isMobile" i18n="block.projected-block">Projected Block</h3>
|
||||||
<app-block-overview-graph #blockGraphProjected [isLoading]="isLoading" [resolution]="75"
|
<app-block-overview-graph #blockGraphProjected [isLoading]="isLoading" [resolution]="75"
|
||||||
[blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false"
|
[blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false" [mirrorTxid]="hoverTx"
|
||||||
(txClickEvent)="onTxClick($event)"></app-block-overview-graph>
|
(txClickEvent)="onTxClick($event)" (txHoverEvent)="onTxHover($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">
|
||||||
<h3 class="block-subtitle" *ngIf="!isMobile" i18n="block.actual-block">Actual Block</h3>
|
<h3 class="block-subtitle" *ngIf="!isMobile" i18n="block.actual-block">Actual Block</h3>
|
||||||
<app-block-overview-graph #blockGraphActual [isLoading]="isLoading" [resolution]="75"
|
<app-block-overview-graph #blockGraphActual [isLoading]="isLoading" [resolution]="75"
|
||||||
[blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false"
|
[blockLimit]="stateService.blockVSize" [orientation]="'top'" [flip]="false" [mirrorTxid]="hoverTx"
|
||||||
(txClickEvent)="onTxClick($event)"></app-block-overview-graph>
|
(txClickEvent)="onTxClick($event)" (txHoverEvent)="onTxHover($event)"></app-block-overview-graph>
|
||||||
</div>
|
</div>
|
||||||
</div> <!-- row -->
|
</div> <!-- row -->
|
||||||
</div> <!-- box -->
|
</div> <!-- box -->
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import { Component, OnDestroy, OnInit, AfterViewInit, ViewChildren, QueryList } 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 { Subscription, combineLatest } from 'rxjs';
|
import { Subscription, combineLatest, of } from 'rxjs';
|
||||||
import { map, switchMap, startWith, catchError } from 'rxjs/operators';
|
import { map, switchMap, startWith, catchError, filter } 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 { ElectrsApiService } from '../../services/electrs-api.service';
|
||||||
import { StateService } from '../../services/state.service';
|
import { StateService } from '../../services/state.service';
|
||||||
import { detectWebGL } from '../../shared/graphs.utils';
|
import { detectWebGL } from '../../shared/graphs.utils';
|
||||||
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
|
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
|
||||||
@ -37,6 +38,7 @@ export class BlockAuditComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
isLoading = true;
|
isLoading = true;
|
||||||
webGlEnabled = true;
|
webGlEnabled = true;
|
||||||
isMobile = window.innerWidth <= 767.98;
|
isMobile = window.innerWidth <= 767.98;
|
||||||
|
hoverTx: string;
|
||||||
|
|
||||||
childChangeSubscription: Subscription;
|
childChangeSubscription: Subscription;
|
||||||
|
|
||||||
@ -51,7 +53,8 @@ export class BlockAuditComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
public stateService: StateService,
|
public stateService: StateService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private apiService: ApiService
|
private apiService: ApiService,
|
||||||
|
private electrsApiService: ElectrsApiService,
|
||||||
) {
|
) {
|
||||||
this.webGlEnabled = detectWebGL();
|
this.webGlEnabled = detectWebGL();
|
||||||
}
|
}
|
||||||
@ -76,69 +79,95 @@ export class BlockAuditComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
this.auditSubscription = this.route.paramMap.pipe(
|
this.auditSubscription = this.route.paramMap.pipe(
|
||||||
switchMap((params: ParamMap) => {
|
switchMap((params: ParamMap) => {
|
||||||
this.blockHash = params.get('id') || null;
|
const blockHash = params.get('id') || null;
|
||||||
if (!this.blockHash) {
|
if (!blockHash) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let isBlockHeight = false;
|
||||||
|
if (/^[0-9]+$/.test(blockHash)) {
|
||||||
|
isBlockHeight = true;
|
||||||
|
} else {
|
||||||
|
this.blockHash = blockHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBlockHeight) {
|
||||||
|
return this.electrsApiService.getBlockHashFromHeight$(parseInt(blockHash, 10))
|
||||||
|
.pipe(
|
||||||
|
switchMap((hash: string) => {
|
||||||
|
if (hash) {
|
||||||
|
this.blockHash = hash;
|
||||||
|
return this.apiService.getBlockAudit$(this.blockHash)
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
catchError((err) => {
|
||||||
|
this.error = err;
|
||||||
|
return of(null);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
return this.apiService.getBlockAudit$(this.blockHash)
|
return this.apiService.getBlockAudit$(this.blockHash)
|
||||||
.pipe(
|
}),
|
||||||
map((response) => {
|
filter((response) => response != null),
|
||||||
const blockAudit = response.body;
|
map((response) => {
|
||||||
const inTemplate = {};
|
const blockAudit = response.body;
|
||||||
const inBlock = {};
|
const inTemplate = {};
|
||||||
const isAdded = {};
|
const inBlock = {};
|
||||||
const isCensored = {};
|
const isAdded = {};
|
||||||
const isMissing = {};
|
const isCensored = {};
|
||||||
const isSelected = {};
|
const isMissing = {};
|
||||||
this.numMissing = 0;
|
const isSelected = {};
|
||||||
this.numUnexpected = 0;
|
this.numMissing = 0;
|
||||||
for (const tx of blockAudit.template) {
|
this.numUnexpected = 0;
|
||||||
inTemplate[tx.txid] = true;
|
for (const tx of blockAudit.template) {
|
||||||
}
|
inTemplate[tx.txid] = true;
|
||||||
for (const tx of blockAudit.transactions) {
|
}
|
||||||
inBlock[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.addedTxs) {
|
||||||
}
|
isAdded[txid] = true;
|
||||||
for (const txid of blockAudit.missingTxs) {
|
}
|
||||||
isCensored[txid] = true;
|
for (const txid of blockAudit.missingTxs) {
|
||||||
}
|
isCensored[txid] = true;
|
||||||
// set transaction statuses
|
}
|
||||||
for (const tx of blockAudit.template) {
|
// set transaction statuses
|
||||||
if (isCensored[tx.txid]) {
|
for (const tx of blockAudit.template) {
|
||||||
tx.status = 'censored';
|
if (isCensored[tx.txid]) {
|
||||||
} else if (inBlock[tx.txid]) {
|
tx.status = 'censored';
|
||||||
tx.status = 'found';
|
} else if (inBlock[tx.txid]) {
|
||||||
} else {
|
tx.status = 'found';
|
||||||
tx.status = 'missing';
|
} else {
|
||||||
isMissing[tx.txid] = true;
|
tx.status = 'missing';
|
||||||
this.numMissing++;
|
isMissing[tx.txid] = true;
|
||||||
}
|
this.numMissing++;
|
||||||
}
|
}
|
||||||
for (const [index, tx] of blockAudit.transactions.entries()) {
|
}
|
||||||
if (isAdded[tx.txid]) {
|
for (const [index, tx] of blockAudit.transactions.entries()) {
|
||||||
tx.status = 'added';
|
if (index === 0) {
|
||||||
} else if (index === 0 || inTemplate[tx.txid]) {
|
tx.status = null;
|
||||||
tx.status = 'found';
|
} else if (isAdded[tx.txid]) {
|
||||||
} else {
|
tx.status = 'added';
|
||||||
tx.status = 'selected';
|
} else if (inTemplate[tx.txid]) {
|
||||||
isSelected[tx.txid] = true;
|
tx.status = 'found';
|
||||||
this.numUnexpected++;
|
} else {
|
||||||
}
|
tx.status = 'selected';
|
||||||
}
|
isSelected[tx.txid] = true;
|
||||||
for (const tx of blockAudit.transactions) {
|
this.numUnexpected++;
|
||||||
inBlock[tx.txid] = true;
|
}
|
||||||
}
|
}
|
||||||
return blockAudit;
|
for (const tx of blockAudit.transactions) {
|
||||||
})
|
inBlock[tx.txid] = true;
|
||||||
);
|
}
|
||||||
|
return blockAudit;
|
||||||
}),
|
}),
|
||||||
catchError((err) => {
|
catchError((err) => {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
this.error = err;
|
this.error = err;
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
return null;
|
return of(null);
|
||||||
}),
|
}),
|
||||||
).subscribe((blockAudit) => {
|
).subscribe((blockAudit) => {
|
||||||
this.blockAudit = blockAudit;
|
this.blockAudit = blockAudit;
|
||||||
@ -189,4 +218,12 @@ export class BlockAuditComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onTxHover(txid: string): void {
|
||||||
|
if (txid && txid.length) {
|
||||||
|
this.hoverTx = txid;
|
||||||
|
} else {
|
||||||
|
this.hoverTx = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -18,7 +18,9 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
|
|||||||
@Input() orientation = 'left';
|
@Input() orientation = 'left';
|
||||||
@Input() flip = true;
|
@Input() flip = true;
|
||||||
@Input() disableSpinner = false;
|
@Input() disableSpinner = false;
|
||||||
|
@Input() mirrorTxid: string | void;
|
||||||
@Output() txClickEvent = new EventEmitter<TransactionStripped>();
|
@Output() txClickEvent = new EventEmitter<TransactionStripped>();
|
||||||
|
@Output() txHoverEvent = new EventEmitter<string>();
|
||||||
@Output() readyEvent = new EventEmitter();
|
@Output() readyEvent = new EventEmitter();
|
||||||
|
|
||||||
@ViewChild('blockCanvas')
|
@ViewChild('blockCanvas')
|
||||||
@ -37,6 +39,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
|
|||||||
scene: BlockScene;
|
scene: BlockScene;
|
||||||
hoverTx: TxView | void;
|
hoverTx: TxView | void;
|
||||||
selectedTx: TxView | void;
|
selectedTx: TxView | void;
|
||||||
|
mirrorTx: TxView | void;
|
||||||
tooltipPosition: Position;
|
tooltipPosition: Position;
|
||||||
|
|
||||||
readyNextFrame = false;
|
readyNextFrame = false;
|
||||||
@ -63,6 +66,9 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
|
|||||||
this.scene.setOrientation(this.orientation, this.flip);
|
this.scene.setOrientation(this.orientation, this.flip);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (changes.mirrorTxid) {
|
||||||
|
this.setMirror(this.mirrorTxid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
@ -76,6 +82,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
|
|||||||
this.exit(direction);
|
this.exit(direction);
|
||||||
this.hoverTx = null;
|
this.hoverTx = null;
|
||||||
this.selectedTx = null;
|
this.selectedTx = null;
|
||||||
|
this.onTxHover(null);
|
||||||
this.start();
|
this.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -181,7 +188,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
|
|||||||
this.gl.viewport(0, 0, this.displayWidth, this.displayHeight);
|
this.gl.viewport(0, 0, this.displayWidth, this.displayHeight);
|
||||||
}
|
}
|
||||||
if (this.scene) {
|
if (this.scene) {
|
||||||
this.scene.resize({ width: this.displayWidth, height: this.displayHeight });
|
this.scene.resize({ width: this.displayWidth, height: this.displayHeight, animate: false });
|
||||||
this.start();
|
this.start();
|
||||||
} else {
|
} else {
|
||||||
this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: this.resolution,
|
this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: this.resolution,
|
||||||
@ -301,6 +308,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
|
|||||||
}
|
}
|
||||||
this.hoverTx = null;
|
this.hoverTx = null;
|
||||||
this.selectedTx = null;
|
this.selectedTx = null;
|
||||||
|
this.onTxHover(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -352,17 +360,20 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
|
|||||||
this.selectedTx = selected;
|
this.selectedTx = selected;
|
||||||
} else {
|
} else {
|
||||||
this.hoverTx = selected;
|
this.hoverTx = selected;
|
||||||
|
this.onTxHover(this.hoverTx ? this.hoverTx.txid : null);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (clicked) {
|
if (clicked) {
|
||||||
this.selectedTx = null;
|
this.selectedTx = null;
|
||||||
}
|
}
|
||||||
this.hoverTx = null;
|
this.hoverTx = null;
|
||||||
|
this.onTxHover(null);
|
||||||
}
|
}
|
||||||
} else if (clicked) {
|
} else if (clicked) {
|
||||||
if (selected === this.selectedTx) {
|
if (selected === this.selectedTx) {
|
||||||
this.hoverTx = this.selectedTx;
|
this.hoverTx = this.selectedTx;
|
||||||
this.selectedTx = null;
|
this.selectedTx = null;
|
||||||
|
this.onTxHover(this.hoverTx ? this.hoverTx.txid : null);
|
||||||
} else {
|
} else {
|
||||||
this.selectedTx = selected;
|
this.selectedTx = selected;
|
||||||
}
|
}
|
||||||
@ -370,6 +381,18 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setMirror(txid: string | void) {
|
||||||
|
if (this.mirrorTx) {
|
||||||
|
this.scene.setHover(this.mirrorTx, false);
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
if (txid && this.scene.txs[txid]) {
|
||||||
|
this.mirrorTx = this.scene.txs[txid];
|
||||||
|
this.scene.setHover(this.mirrorTx, true);
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onTxClick(cssX: number, cssY: number) {
|
onTxClick(cssX: number, cssY: number) {
|
||||||
const x = cssX * window.devicePixelRatio;
|
const x = cssX * window.devicePixelRatio;
|
||||||
const y = cssY * window.devicePixelRatio;
|
const y = cssY * window.devicePixelRatio;
|
||||||
@ -378,6 +401,10 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
|
|||||||
this.txClickEvent.emit(selected);
|
this.txClickEvent.emit(selected);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onTxHover(hoverId: string) {
|
||||||
|
this.txHoverEvent.emit(hoverId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebGL shader attributes
|
// WebGL shader attributes
|
||||||
|
@ -29,7 +29,7 @@ export default class BlockScene {
|
|||||||
this.init({ width, height, resolution, blockLimit, orientation, flip, vertexArray });
|
this.init({ width, height, resolution, blockLimit, orientation, flip, vertexArray });
|
||||||
}
|
}
|
||||||
|
|
||||||
resize({ width = this.width, height = this.height }: { width?: number, height?: number}): void {
|
resize({ width = this.width, height = this.height, animate = true }: { width?: number, height?: number, animate: boolean }): void {
|
||||||
this.width = width;
|
this.width = width;
|
||||||
this.height = height;
|
this.height = height;
|
||||||
this.gridSize = this.width / this.gridWidth;
|
this.gridSize = this.width / this.gridWidth;
|
||||||
@ -38,7 +38,7 @@ export default class BlockScene {
|
|||||||
|
|
||||||
this.dirty = true;
|
this.dirty = true;
|
||||||
if (this.initialised && this.scene) {
|
if (this.initialised && this.scene) {
|
||||||
this.updateAll(performance.now(), 50);
|
this.updateAll(performance.now(), 50, 'left', animate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -212,7 +212,7 @@ export default class BlockScene {
|
|||||||
this.vbytesPerUnit = blockLimit / Math.pow(resolution / 1.02, 2);
|
this.vbytesPerUnit = blockLimit / Math.pow(resolution / 1.02, 2);
|
||||||
this.gridWidth = resolution;
|
this.gridWidth = resolution;
|
||||||
this.gridHeight = resolution;
|
this.gridHeight = resolution;
|
||||||
this.resize({ width, height });
|
this.resize({ width, height, animate: true });
|
||||||
this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight });
|
this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight });
|
||||||
|
|
||||||
this.txs = {};
|
this.txs = {};
|
||||||
@ -225,14 +225,14 @@ export default class BlockScene {
|
|||||||
this.animateUntil = Math.max(this.animateUntil, tx.update(update));
|
this.animateUntil = Math.max(this.animateUntil, tx.update(update));
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateTx(tx: TxView, startTime: number, delay: number, direction: string = 'left'): void {
|
private updateTx(tx: TxView, startTime: number, delay: number, direction: string = 'left', animate: boolean = true): void {
|
||||||
if (tx.dirty || this.dirty) {
|
if (tx.dirty || this.dirty) {
|
||||||
this.saveGridToScreenPosition(tx);
|
this.saveGridToScreenPosition(tx);
|
||||||
this.setTxOnScreen(tx, startTime, delay, direction);
|
this.setTxOnScreen(tx, startTime, delay, direction, animate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private setTxOnScreen(tx: TxView, startTime: number, delay: number = 50, direction: string = 'left'): void {
|
private setTxOnScreen(tx: TxView, startTime: number, delay: number = 50, direction: string = 'left', animate: boolean = true): void {
|
||||||
if (!tx.initialised) {
|
if (!tx.initialised) {
|
||||||
const txColor = tx.getColor();
|
const txColor = tx.getColor();
|
||||||
this.applyTxUpdate(tx, {
|
this.applyTxUpdate(tx, {
|
||||||
@ -252,30 +252,42 @@ export default class BlockScene {
|
|||||||
position: tx.screenPosition,
|
position: tx.screenPosition,
|
||||||
color: txColor
|
color: txColor
|
||||||
},
|
},
|
||||||
duration: 1000,
|
duration: animate ? 1000 : 1,
|
||||||
start: startTime,
|
start: startTime,
|
||||||
delay,
|
delay: animate ? delay : 0,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.applyTxUpdate(tx, {
|
this.applyTxUpdate(tx, {
|
||||||
display: {
|
display: {
|
||||||
position: tx.screenPosition
|
position: tx.screenPosition
|
||||||
},
|
},
|
||||||
duration: 1000,
|
duration: animate ? 1000 : 0,
|
||||||
minDuration: 500,
|
minDuration: animate ? 500 : 0,
|
||||||
start: startTime,
|
start: startTime,
|
||||||
delay,
|
delay: animate ? delay : 0,
|
||||||
adjust: true
|
adjust: animate
|
||||||
});
|
});
|
||||||
|
if (!animate) {
|
||||||
|
this.applyTxUpdate(tx, {
|
||||||
|
display: {
|
||||||
|
position: tx.screenPosition
|
||||||
|
},
|
||||||
|
duration: 0,
|
||||||
|
minDuration: 0,
|
||||||
|
start: startTime,
|
||||||
|
delay: 0,
|
||||||
|
adjust: false
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateAll(startTime: number, delay: number = 50, direction: string = 'left'): void {
|
private updateAll(startTime: number, delay: number = 50, direction: string = 'left', animate: boolean = true): void {
|
||||||
this.scene.count = 0;
|
this.scene.count = 0;
|
||||||
const ids = this.getTxList();
|
const ids = this.getTxList();
|
||||||
startTime = startTime || performance.now();
|
startTime = startTime || performance.now();
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
this.updateTx(this.txs[id], startTime, delay, direction);
|
this.updateTx(this.txs[id], startTime, delay, direction, animate);
|
||||||
}
|
}
|
||||||
this.dirty = false;
|
this.dirty = false;
|
||||||
}
|
}
|
||||||
|
@ -12,8 +12,8 @@ const auditFeeColors = feeColors.map((color) => desaturate(color, 0.3));
|
|||||||
const auditColors = {
|
const auditColors = {
|
||||||
censored: hexToColor('f344df'),
|
censored: hexToColor('f344df'),
|
||||||
missing: darken(desaturate(hexToColor('f344df'), 0.3), 0.7),
|
missing: darken(desaturate(hexToColor('f344df'), 0.3), 0.7),
|
||||||
added: hexToColor('03E1E5'),
|
added: hexToColor('0099ff'),
|
||||||
selected: darken(desaturate(hexToColor('039BE5'), 0.3), 0.7),
|
selected: darken(desaturate(hexToColor('0099ff'), 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
|
||||||
|
@ -37,9 +37,9 @@
|
|||||||
<ng-container [ngSwitch]="tx?.status">
|
<ng-container [ngSwitch]="tx?.status">
|
||||||
<td *ngSwitchCase="'found'" i18n="transaction.audit.match">match</td>
|
<td *ngSwitchCase="'found'" i18n="transaction.audit.match">match</td>
|
||||||
<td *ngSwitchCase="'censored'" i18n="transaction.audit.removed">removed</td>
|
<td *ngSwitchCase="'censored'" i18n="transaction.audit.removed">removed</td>
|
||||||
<td *ngSwitchCase="'missing'" i18n="transaction.audit.missing">missing</td>
|
<td *ngSwitchCase="'missing'" i18n="transaction.audit.omitted">omitted</td>
|
||||||
<td *ngSwitchCase="'added'" i18n="transaction.audit.added">added</td>
|
<td *ngSwitchCase="'added'" i18n="transaction.audit.added">added</td>
|
||||||
<td *ngSwitchCase="'selected'" i18n="transaction.audit.included">included</td>
|
<td *ngSwitchCase="'selected'" i18n="transaction.audit.extra">extra</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
@ -114,7 +114,7 @@
|
|||||||
<td i18n="block.health">Block health</td>
|
<td i18n="block.health">Block health</td>
|
||||||
<td>
|
<td>
|
||||||
<a *ngIf="block.extras?.matchRate != null" [routerLink]="['/block-audit/' | relativeUrl, blockHash]">{{ block.extras.matchRate }}%</a>
|
<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>
|
<span *ngIf="block.extras?.matchRate === null" i18n="unknown">Unknown</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
@ -4,7 +4,7 @@ import { ActivatedRoute, ParamMap, Router } from '@angular/router';
|
|||||||
import { ElectrsApiService } from '../../services/electrs-api.service';
|
import { ElectrsApiService } from '../../services/electrs-api.service';
|
||||||
import { switchMap, tap, throttleTime, catchError, map, shareReplay, startWith, pairwise } from 'rxjs/operators';
|
import { switchMap, tap, throttleTime, catchError, map, shareReplay, startWith, pairwise } from 'rxjs/operators';
|
||||||
import { Transaction, Vout } from '../../interfaces/electrs.interface';
|
import { Transaction, Vout } from '../../interfaces/electrs.interface';
|
||||||
import { Observable, of, Subscription, asyncScheduler, EMPTY } from 'rxjs';
|
import { Observable, of, Subscription, asyncScheduler, EMPTY, Subject } from 'rxjs';
|
||||||
import { StateService } from '../../services/state.service';
|
import { StateService } from '../../services/state.service';
|
||||||
import { SeoService } from '../../services/seo.service';
|
import { SeoService } from '../../services/seo.service';
|
||||||
import { WebsocketService } from '../../services/websocket.service';
|
import { WebsocketService } from '../../services/websocket.service';
|
||||||
@ -60,6 +60,8 @@ export class BlockComponent implements OnInit, OnDestroy {
|
|||||||
nextBlockTxListSubscription: Subscription = undefined;
|
nextBlockTxListSubscription: Subscription = undefined;
|
||||||
timeLtrSubscription: Subscription;
|
timeLtrSubscription: Subscription;
|
||||||
timeLtr: boolean;
|
timeLtr: boolean;
|
||||||
|
fetchAuditScore$ = new Subject<string>();
|
||||||
|
fetchAuditScoreSubscription: Subscription;
|
||||||
|
|
||||||
@ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent;
|
@ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent;
|
||||||
|
|
||||||
@ -105,12 +107,30 @@ export class BlockComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
if (block.id === this.blockHash) {
|
if (block.id === this.blockHash) {
|
||||||
this.block = block;
|
this.block = block;
|
||||||
|
if (this.block.id && this.block?.extras?.matchRate == null) {
|
||||||
|
this.fetchAuditScore$.next(this.block.id);
|
||||||
|
}
|
||||||
if (block?.extras?.reward != undefined) {
|
if (block?.extras?.reward != undefined) {
|
||||||
this.fees = block.extras.reward / 100000000 - this.blockSubsidy;
|
this.fees = block.extras.reward / 100000000 - this.blockSubsidy;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (this.indexingAvailable) {
|
||||||
|
this.fetchAuditScoreSubscription = this.fetchAuditScore$
|
||||||
|
.pipe(
|
||||||
|
switchMap((hash) => this.apiService.getBlockAuditScore$(hash)),
|
||||||
|
catchError(() => EMPTY),
|
||||||
|
)
|
||||||
|
.subscribe((score) => {
|
||||||
|
if (score && score.hash === this.block.id) {
|
||||||
|
this.block.extras.matchRate = score.matchRate || null;
|
||||||
|
} else {
|
||||||
|
this.block.extras.matchRate = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const block$ = this.route.paramMap.pipe(
|
const block$ = this.route.paramMap.pipe(
|
||||||
switchMap((params: ParamMap) => {
|
switchMap((params: ParamMap) => {
|
||||||
const blockHash: string = params.get('id') || '';
|
const blockHash: string = params.get('id') || '';
|
||||||
@ -209,6 +229,9 @@ export class BlockComponent implements OnInit, OnDestroy {
|
|||||||
this.fees = block.extras.reward / 100000000 - this.blockSubsidy;
|
this.fees = block.extras.reward / 100000000 - this.blockSubsidy;
|
||||||
}
|
}
|
||||||
this.stateService.markBlock$.next({ blockHeight: this.blockHeight });
|
this.stateService.markBlock$.next({ blockHeight: this.blockHeight });
|
||||||
|
if (this.block.id && this.block?.extras?.matchRate == null) {
|
||||||
|
this.fetchAuditScore$.next(this.block.id);
|
||||||
|
}
|
||||||
this.isLoadingTransactions = true;
|
this.isLoadingTransactions = true;
|
||||||
this.transactions = null;
|
this.transactions = null;
|
||||||
this.transactionsError = null;
|
this.transactionsError = null;
|
||||||
@ -311,6 +334,7 @@ export class BlockComponent implements OnInit, OnDestroy {
|
|||||||
this.networkChangedSubscription.unsubscribe();
|
this.networkChangedSubscription.unsubscribe();
|
||||||
this.queryParamsSubscription.unsubscribe();
|
this.queryParamsSubscription.unsubscribe();
|
||||||
this.timeLtrSubscription.unsubscribe();
|
this.timeLtrSubscription.unsubscribe();
|
||||||
|
this.fetchAuditScoreSubscription?.unsubscribe();
|
||||||
this.unsubscribeNextBlockSubscriptions();
|
this.unsubscribeNextBlockSubscriptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -46,22 +46,17 @@
|
|||||||
<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}">
|
<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]">
|
<a class="clear-link" [routerLink]="auditScores[block.id] != null ? ['/block-audit/' | relativeUrl, block.id] : null">
|
||||||
<div class="progress progress-health">
|
<div class="progress progress-health">
|
||||||
<div class="progress-bar progress-bar-health" role="progressbar"
|
<div class="progress-bar progress-bar-health" role="progressbar"
|
||||||
[ngStyle]="{'width': (100 - (block.extras?.matchRate || 0)) + '%' }"></div>
|
[ngStyle]="{'width': (100 - (auditScores[block.id] || 0)) + '%' }"></div>
|
||||||
<div class="progress-text">
|
<div class="progress-text">
|
||||||
<span>{{ block.extras.matchRate }}%</span>
|
<span *ngIf="auditScores[block.id] != null;">{{ auditScores[block.id] }}%</span>
|
||||||
|
<span *ngIf="auditScores[block.id] === undefined" class="skeleton-loader"></span>
|
||||||
|
<span *ngIf="auditScores[block.id] === null">~</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</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>
|
||||||
<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>
|
||||||
|
@ -196,6 +196,10 @@ tr, td, th {
|
|||||||
@media (max-width: 950px) {
|
@media (max-width: 950px) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.progress-text .skeleton-loader {
|
||||||
|
top: -8.5px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.health.widget {
|
.health.widget {
|
||||||
width: 25%;
|
width: 25%;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Component, OnInit, ChangeDetectionStrategy, Input } from '@angular/core';
|
import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, Input } from '@angular/core';
|
||||||
import { BehaviorSubject, combineLatest, concat, Observable, timer } from 'rxjs';
|
import { BehaviorSubject, combineLatest, concat, Observable, timer, EMPTY, Subscription, of } from 'rxjs';
|
||||||
import { delayWhen, map, retryWhen, scan, skip, switchMap, tap } from 'rxjs/operators';
|
import { catchError, delayWhen, map, retryWhen, scan, skip, switchMap, tap } from 'rxjs/operators';
|
||||||
import { BlockExtended } from '../../interfaces/node-api.interface';
|
import { BlockExtended } 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';
|
||||||
@ -12,10 +12,14 @@ import { WebsocketService } from '../../services/websocket.service';
|
|||||||
styleUrls: ['./blocks-list.component.scss'],
|
styleUrls: ['./blocks-list.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class BlocksList implements OnInit {
|
export class BlocksList implements OnInit, OnDestroy {
|
||||||
@Input() widget: boolean = false;
|
@Input() widget: boolean = false;
|
||||||
|
|
||||||
blocks$: Observable<BlockExtended[]> = undefined;
|
blocks$: Observable<BlockExtended[]> = undefined;
|
||||||
|
auditScores: { [hash: string]: number | void } = {};
|
||||||
|
|
||||||
|
auditScoreSubscription: Subscription;
|
||||||
|
latestScoreSubscription: Subscription;
|
||||||
|
|
||||||
indexingAvailable = false;
|
indexingAvailable = false;
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
@ -105,6 +109,53 @@ export class BlocksList implements OnInit {
|
|||||||
return acc;
|
return acc;
|
||||||
}, [])
|
}, [])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (this.indexingAvailable) {
|
||||||
|
this.auditScoreSubscription = this.fromHeightSubject.pipe(
|
||||||
|
switchMap((fromBlockHeight) => {
|
||||||
|
return this.apiService.getBlockAuditScores$(this.page === 1 ? undefined : fromBlockHeight)
|
||||||
|
.pipe(
|
||||||
|
catchError(() => {
|
||||||
|
return EMPTY;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
).subscribe((scores) => {
|
||||||
|
Object.values(scores).forEach(score => {
|
||||||
|
this.auditScores[score.hash] = score?.matchRate != null ? score.matchRate : null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.latestScoreSubscription = this.stateService.blocks$.pipe(
|
||||||
|
switchMap((block) => {
|
||||||
|
if (block[0]?.extras?.matchRate != null) {
|
||||||
|
return of({
|
||||||
|
hash: block[0].id,
|
||||||
|
matchRate: block[0]?.extras?.matchRate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (block[0]?.id && this.auditScores[block[0].id] === undefined) {
|
||||||
|
return this.apiService.getBlockAuditScore$(block[0].id)
|
||||||
|
.pipe(
|
||||||
|
catchError(() => {
|
||||||
|
return EMPTY;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
).subscribe((score) => {
|
||||||
|
if (score && score.hash) {
|
||||||
|
this.auditScores[score.hash] = score?.matchRate != null ? score.matchRate : null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.auditScoreSubscription?.unsubscribe();
|
||||||
|
this.latestScoreSubscription?.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
pageChange(page: number) {
|
pageChange(page: number) {
|
||||||
|
@ -126,9 +126,13 @@ export class LiquidUnblinding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async checkUnblindedTx(tx: Transaction) {
|
async checkUnblindedTx(tx: Transaction) {
|
||||||
const windowLocationHash = window.location.hash.substring('#blinded='.length);
|
if (!window.location.hash?.length) {
|
||||||
if (windowLocationHash.length > 0) {
|
return tx;
|
||||||
const blinders = this.parseBlinders(windowLocationHash);
|
}
|
||||||
|
const fragmentParams = new URLSearchParams(window.location.hash.slice(1) || '');
|
||||||
|
const blinderStr = fragmentParams.get('blinded');
|
||||||
|
if (blinderStr && blinderStr.length) {
|
||||||
|
const blinders = this.parseBlinders(blinderStr);
|
||||||
if (blinders) {
|
if (blinders) {
|
||||||
this.commitments = await this.makeCommitmentMap(blinders);
|
this.commitments = await this.makeCommitmentMap(blinders);
|
||||||
return this.tryUnblindTx(tx);
|
return this.tryUnblindTx(tx);
|
||||||
|
@ -29,7 +29,7 @@
|
|||||||
|
|
||||||
|
|
||||||
<div class="row graph-wrapper">
|
<div class="row graph-wrapper">
|
||||||
<tx-bowtie-graph [tx]="tx" [width]="1112" [height]="346" [network]="network"></tx-bowtie-graph>
|
<tx-bowtie-graph [tx]="tx" [width]="1132" [height]="346" [network]="network"></tx-bowtie-graph>
|
||||||
<div class="above-bow">
|
<div class="above-bow">
|
||||||
<p class="field pair">
|
<p class="field pair">
|
||||||
<span [innerHTML]="'‎' + (tx.size | bytes: 2)"></span>
|
<span [innerHTML]="'‎' + (tx.size | bytes: 2)"></span>
|
||||||
@ -41,24 +41,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="overlaid">
|
<div class="overlaid">
|
||||||
<ng-container [ngSwitch]="extraData">
|
<ng-container [ngSwitch]="extraData">
|
||||||
<table class="opreturns" *ngSwitchCase="'coinbase'">
|
<div class="opreturns" *ngSwitchCase="'coinbase'">
|
||||||
<tbody>
|
<div class="opreturn-row">
|
||||||
<tr>
|
<span class="label">Coinbase</span>
|
||||||
<td class="label">Coinbase</td>
|
<span class="message">{{ tx.vin[0].scriptsig | hex2ascii }}</span>
|
||||||
<td class="message">{{ tx.vin[0].scriptsig | hex2ascii }}</td>
|
</div>
|
||||||
</tr>
|
</div>
|
||||||
</tbody>
|
<div class="opreturns" *ngSwitchCase="'opreturn'">
|
||||||
</table>
|
|
||||||
<table class="opreturns" *ngSwitchCase="'opreturn'">
|
|
||||||
<tbody>
|
|
||||||
<ng-container *ngFor="let vout of opReturns.slice(0,3)">
|
<ng-container *ngFor="let vout of opReturns.slice(0,3)">
|
||||||
<tr>
|
<div class="opreturn-row">
|
||||||
<td class="label">OP_RETURN</td>
|
<span class="label">OP_RETURN</span>
|
||||||
<td *ngIf="vout.scriptpubkey_asm !== 'OP_RETURN'" class="message">{{ vout.scriptpubkey_asm | hex2ascii }}</td>
|
<span *ngIf="vout.scriptpubkey_asm !== 'OP_RETURN'" class="message">{{ vout.scriptpubkey_asm | hex2ascii }}</span>
|
||||||
</tr>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -29,6 +29,8 @@
|
|||||||
.features {
|
.features {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
margin-left: 1em;
|
margin-left: 1em;
|
||||||
|
margin-top: 0.5em;
|
||||||
|
margin-right: -4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-data {
|
.top-data {
|
||||||
@ -60,6 +62,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.top-data .field {
|
||||||
|
&:first-child {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
&:last-child {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.tx-link {
|
.tx-link {
|
||||||
display: inline;
|
display: inline;
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
@ -69,7 +80,7 @@
|
|||||||
.graph-wrapper {
|
.graph-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: #181b2d;
|
background: #181b2d;
|
||||||
padding: 10px;
|
padding: 10px 0;
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
|
|
||||||
.above-bow {
|
.above-bow {
|
||||||
@ -92,26 +103,37 @@
|
|||||||
max-width: 90%;
|
max-width: 90%;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
.opreturns {
|
.opreturns {
|
||||||
|
display: inline-block;
|
||||||
width: auto;
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
table-layout: auto;
|
table-layout: auto;
|
||||||
background: #2d3348af;
|
background: #2d3348af;
|
||||||
border-top-left-radius: 5px;
|
border-top-left-radius: 5px;
|
||||||
border-top-right-radius: 5px;
|
border-top-right-radius: 5px;
|
||||||
|
|
||||||
td {
|
.opreturn-row {
|
||||||
padding: 10px 10px;
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
&.message {
|
.label {
|
||||||
overflow: hidden;
|
margin-right: 1em;
|
||||||
display: inline-block;
|
}
|
||||||
vertical-align: bottom;
|
|
||||||
text-overflow: ellipsis;
|
.message {
|
||||||
white-space: nowrap;
|
flex-shrink: 1;
|
||||||
text-align: left;
|
white-space: nowrap;
|
||||||
}
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -117,8 +117,9 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy {
|
|||||||
}),
|
}),
|
||||||
switchMap(() => {
|
switchMap(() => {
|
||||||
let transactionObservable$: Observable<Transaction>;
|
let transactionObservable$: Observable<Transaction>;
|
||||||
if (history.state.data && history.state.data.fee !== -1) {
|
const cached = this.stateService.getTxFromCache(this.txId);
|
||||||
transactionObservable$ = of(history.state.data);
|
if (cached && cached.fee !== -1) {
|
||||||
|
transactionObservable$ = of(cached);
|
||||||
} else {
|
} else {
|
||||||
transactionObservable$ = this.electrsApiService
|
transactionObservable$ = this.electrsApiService
|
||||||
.getTransaction$(this.txId)
|
.getTransaction$(this.txId)
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
<div class="title-block">
|
<div class="title-block">
|
||||||
<div *ngIf="rbfTransaction" class="alert alert-mempool" role="alert">
|
<div *ngIf="rbfTransaction" class="alert alert-mempool" role="alert">
|
||||||
<span i18n="transaction.rbf.replacement|RBF replacement">This transaction has been replaced by:</span>
|
<span i18n="transaction.rbf.replacement|RBF replacement">This transaction has been replaced by:</span>
|
||||||
<a class="alert-link" [routerLink]="['/tx/' | relativeUrl, rbfTransaction.txid]" [state]="{ data: rbfTransaction.size ? rbfTransaction : null }">
|
<a class="alert-link" [routerLink]="['/tx/' | relativeUrl, rbfTransaction.txid]">
|
||||||
<span class="d-inline d-lg-none">{{ rbfTransaction.txid | shortenString : 24 }}</span>
|
<span class="d-inline d-lg-none">{{ rbfTransaction.txid | shortenString : 24 }}</span>
|
||||||
<span class="d-none d-lg-inline">{{ rbfTransaction.txid }}</span>
|
<span class="d-none d-lg-inline">{{ rbfTransaction.txid }}</span>
|
||||||
</a>
|
</a>
|
||||||
@ -209,6 +209,7 @@
|
|||||||
[maxStrands]="graphExpanded ? maxInOut : 24"
|
[maxStrands]="graphExpanded ? maxInOut : 24"
|
||||||
[network]="network"
|
[network]="network"
|
||||||
[tooltip]="true"
|
[tooltip]="true"
|
||||||
|
[connectors]="true"
|
||||||
[inputIndex]="inputIndex" [outputIndex]="outputIndex"
|
[inputIndex]="inputIndex" [outputIndex]="outputIndex"
|
||||||
>
|
>
|
||||||
</tx-bowtie-graph>
|
</tx-bowtie-graph>
|
||||||
|
@ -86,7 +86,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: #181b2d;
|
background: #181b2d;
|
||||||
padding: 10px;
|
padding: 10px 0;
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -183,8 +183,9 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}),
|
}),
|
||||||
switchMap(() => {
|
switchMap(() => {
|
||||||
let transactionObservable$: Observable<Transaction>;
|
let transactionObservable$: Observable<Transaction>;
|
||||||
if (history.state.data && history.state.data.fee !== -1) {
|
const cached = this.stateService.getTxFromCache(this.txId);
|
||||||
transactionObservable$ = of(history.state.data);
|
if (cached && cached.fee !== -1) {
|
||||||
|
transactionObservable$ = of(cached);
|
||||||
} else {
|
} else {
|
||||||
transactionObservable$ = this.electrsApiService
|
transactionObservable$ = this.electrsApiService
|
||||||
.getTransaction$(this.txId)
|
.getTransaction$(this.txId)
|
||||||
@ -279,6 +280,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.waitingForTransaction = false;
|
this.waitingForTransaction = false;
|
||||||
}
|
}
|
||||||
this.rbfTransaction = rbfTransaction;
|
this.rbfTransaction = rbfTransaction;
|
||||||
|
this.stateService.setTxCache([this.rbfTransaction]);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.queryParamsSubscription = this.route.queryParams.subscribe((params) => {
|
this.queryParamsSubscription = this.route.queryParams.subscribe((params) => {
|
||||||
@ -402,7 +404,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
@HostListener('window:resize', ['$event'])
|
@HostListener('window:resize', ['$event'])
|
||||||
setGraphSize(): void {
|
setGraphSize(): void {
|
||||||
if (this.graphContainer) {
|
if (this.graphContainer) {
|
||||||
this.graphWidth = this.graphContainer.nativeElement.clientWidth - 24;
|
this.graphWidth = this.graphContainer.nativeElement.clientWidth;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<ng-container *ngFor="let tx of transactions; let i = index; trackBy: trackByFn">
|
<ng-container *ngFor="let tx of transactions; let i = index; trackBy: trackByFn">
|
||||||
<div *ngIf="!transactionPage" class="header-bg box tx-page-container">
|
<div *ngIf="!transactionPage" class="header-bg box tx-page-container">
|
||||||
<a class="float-left" [routerLink]="['/tx/' | relativeUrl, tx.txid]" [state]="{ data: tx }">
|
<a class="float-left" [routerLink]="['/tx/' | relativeUrl, tx.txid]">
|
||||||
<span style="float: left;" class="d-block d-md-none">{{ tx.txid | shortenString : 16 }}</span>
|
<span style="float: left;" class="d-block d-md-none">{{ tx.txid | shortenString : 16 }}</span>
|
||||||
<span style="float: left;" class="d-none d-md-block">{{ tx.txid }}</span>
|
<span style="float: left;" class="d-none d-md-block">{{ tx.txid }}</span>
|
||||||
</a>
|
</a>
|
||||||
|
@ -119,7 +119,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.transactionsLength = this.transactions.length;
|
this.transactionsLength = this.transactions.length;
|
||||||
|
this.stateService.setTxCache(this.transactions);
|
||||||
|
|
||||||
this.transactions.forEach((tx) => {
|
this.transactions.forEach((tx) => {
|
||||||
tx['@voutLimit'] = true;
|
tx['@voutLimit'] = true;
|
||||||
|
@ -22,13 +22,13 @@
|
|||||||
|
|
||||||
<ng-template #pegin>
|
<ng-template #pegin>
|
||||||
<ng-container *ngIf="line.pegin; else pegout">
|
<ng-container *ngIf="line.pegin; else pegout">
|
||||||
<p>Peg In</p>
|
<p *ngIf="!isConnector">Peg In</p>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<ng-template #pegout>
|
<ng-template #pegout>
|
||||||
<ng-container *ngIf="line.pegout; else normal">
|
<ng-container *ngIf="line.pegout; else normal">
|
||||||
<p>Peg Out</p>
|
<p *ngIf="!isConnector">Peg Out</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>
|
||||||
<p class="address">
|
<p class="address">
|
||||||
<span class="first">{{ line.pegout.slice(0, -4) }}</span>
|
<span class="first">{{ line.pegout.slice(0, -4) }}</span>
|
||||||
@ -38,7 +38,7 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<ng-template #normal>
|
<ng-template #normal>
|
||||||
<p>
|
<p *ngIf="!isConnector">
|
||||||
<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>
|
||||||
@ -46,6 +46,17 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
<span *ngIf="line.type !== 'fee'"> #{{ line.index + 1 }}</span>
|
<span *ngIf="line.type !== 'fee'"> #{{ line.index + 1 }}</span>
|
||||||
</p>
|
</p>
|
||||||
|
<ng-container *ngIf="isConnector && line.txid">
|
||||||
|
<p>
|
||||||
|
<span i18n="transaction">Transaction</span>
|
||||||
|
<span class="first">{{ line.txid.slice(0, 8) }}</span>...
|
||||||
|
<span class="last-four">{{ line.txid.slice(-4) }}</span>
|
||||||
|
</p>
|
||||||
|
<ng-container [ngSwitch]="line.type">
|
||||||
|
<p *ngSwitchCase="'input'"><span i18n="transaction.output">Output</span> #{{ line.vout + 1 }}</p>
|
||||||
|
<p *ngSwitchCase="'output'"><span i18n="transaction.input">Input</span> #{{ line.vin + 1 }}</p>
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
<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>
|
||||||
<p *ngIf="line.type !== 'fee' && line.address" class="address">
|
<p *ngIf="line.type !== 'fee' && line.address" class="address">
|
||||||
|
@ -5,6 +5,9 @@ interface Xput {
|
|||||||
type: 'input' | 'output' | 'fee';
|
type: 'input' | 'output' | 'fee';
|
||||||
value?: number;
|
value?: number;
|
||||||
index?: number;
|
index?: number;
|
||||||
|
txid?: string;
|
||||||
|
vin?: number;
|
||||||
|
vout?: number;
|
||||||
address?: string;
|
address?: string;
|
||||||
rest?: number;
|
rest?: number;
|
||||||
coinbase?: boolean;
|
coinbase?: boolean;
|
||||||
@ -21,6 +24,7 @@ interface Xput {
|
|||||||
export class TxBowtieGraphTooltipComponent implements OnChanges {
|
export class TxBowtieGraphTooltipComponent implements OnChanges {
|
||||||
@Input() line: Xput | void;
|
@Input() line: Xput | void;
|
||||||
@Input() cursorPosition: { x: number, y: number };
|
@Input() cursorPosition: { x: number, y: number };
|
||||||
|
@Input() isConnector: boolean = false;
|
||||||
|
|
||||||
tooltipPosition = { x: 0, y: 0 };
|
tooltipPosition = { x: 0, y: 0 };
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<div class="bowtie-graph">
|
<div class="bowtie-graph">
|
||||||
<svg *ngIf="inputs && outputs" class="bowtie" [attr.height]="(height + 10) + 'px'" [attr.width]="width + 'px'">
|
<svg *ngIf="inputs && outputs" class="bowtie" [class.rtl]="dir === 'rtl'" [attr.height]="(height + 10) + 'px'" [attr.width]="width + 'px'">
|
||||||
<defs>
|
<defs>
|
||||||
<marker id="input-arrow" viewBox="-5 -5 10 10"
|
<marker id="input-arrow" viewBox="-5 -5 10 10"
|
||||||
refX="0" refY="0"
|
refX="0" refY="0"
|
||||||
@ -21,6 +21,15 @@
|
|||||||
markerWidth="1.5" markerHeight="1"
|
markerWidth="1.5" markerHeight="1"
|
||||||
orient="auto">
|
orient="auto">
|
||||||
</marker>
|
</marker>
|
||||||
|
<radialGradient id="gradient0" x1="0%" y1="0%" x2="100%" y2="100%" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop [attr.stop-color]="gradient[0]" />
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="gradient1" x1="0%" y1="0%" x2="100%" y2="100%" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop [attr.stop-color]="gradient[1]" />
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="gradient2" x1="0%" y1="0%" x2="100%" y2="100%" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop [attr.stop-color]="gradient[2]" />
|
||||||
|
</radialGradient>
|
||||||
<linearGradient id="input-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
<linearGradient id="input-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
<stop offset="0%" [attr.stop-color]="gradient[0]" />
|
<stop offset="0%" [attr.stop-color]="gradient[0]" />
|
||||||
<stop offset="100%" [attr.stop-color]="gradient[1]" />
|
<stop offset="100%" [attr.stop-color]="gradient[1]" />
|
||||||
@ -29,6 +38,14 @@
|
|||||||
<stop offset="0%" [attr.stop-color]="gradient[1]" />
|
<stop offset="0%" [attr.stop-color]="gradient[1]" />
|
||||||
<stop offset="100%" [attr.stop-color]="gradient[0]" />
|
<stop offset="100%" [attr.stop-color]="gradient[0]" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<linearGradient id="input-connector-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" [attr.stop-color]="gradient[2]" />
|
||||||
|
<stop offset="80%" [attr.stop-color]="gradient[0]" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="output-connector-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="20%" [attr.stop-color]="gradient[0]" />
|
||||||
|
<stop offset="100%" [attr.stop-color]="gradient[2]" />
|
||||||
|
</linearGradient>
|
||||||
<linearGradient id="input-hover-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
<linearGradient id="input-hover-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
<stop offset="0%" [attr.stop-color]="gradient[0]" />
|
<stop offset="0%" [attr.stop-color]="gradient[0]" />
|
||||||
<stop offset="2%" [attr.stop-color]="gradient[0]" />
|
<stop offset="2%" [attr.stop-color]="gradient[0]" />
|
||||||
@ -41,6 +58,14 @@
|
|||||||
<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-hover-connector-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="white" />
|
||||||
|
<stop offset="80%" [attr.stop-color]="gradient[0]" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="output-hover-connector-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="20%" [attr.stop-color]="gradient[0]" />
|
||||||
|
<stop offset="100%" stop-color="white" />
|
||||||
|
</linearGradient>
|
||||||
<linearGradient id="input-highlight-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
<linearGradient id="input-highlight-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
<stop offset="0%" [attr.stop-color]="gradient[0]" />
|
<stop offset="0%" [attr.stop-color]="gradient[0]" />
|
||||||
<stop offset="2%" [attr.stop-color]="gradient[0]" />
|
<stop offset="2%" [attr.stop-color]="gradient[0]" />
|
||||||
@ -65,6 +90,22 @@
|
|||||||
</defs>
|
</defs>
|
||||||
<path [attr.d]="middle.path" class="line middle" [style]="middle.style"/>
|
<path [attr.d]="middle.path" class="line middle" [style]="middle.style"/>
|
||||||
<ng-container *ngFor="let input of inputs; let i = index">
|
<ng-container *ngFor="let input of inputs; let i = index">
|
||||||
|
<path *ngIf="connectors && !inputData[i].coinbase && !inputData[i].pegin"
|
||||||
|
[attr.d]="input.connectorPath"
|
||||||
|
class="input connector {{input.class}}"
|
||||||
|
[class.highlight]="inputData[i].index === inputIndex"
|
||||||
|
(pointerover)="onHover($event, 'input-connector', i);"
|
||||||
|
(pointerout)="onBlur($event, 'input-connector', i);"
|
||||||
|
(click)="onClick($event, 'input-connector', inputData[i].index);"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
[attr.d]="input.markerPath"
|
||||||
|
class="input marker-target {{input.class}}"
|
||||||
|
[class.highlight]="inputData[i].index === inputIndex"
|
||||||
|
(pointerover)="onHover($event, 'input', i);"
|
||||||
|
(pointerout)="onBlur($event, 'input', i);"
|
||||||
|
(click)="onClick($event, 'input', inputData[i].index);"
|
||||||
|
/>
|
||||||
<path
|
<path
|
||||||
[attr.d]="input.path"
|
[attr.d]="input.path"
|
||||||
class="line {{input.class}}"
|
class="line {{input.class}}"
|
||||||
@ -77,7 +118,23 @@
|
|||||||
/>
|
/>
|
||||||
</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 *ngIf="connectors && outspends[outputData[i].index]?.spent"
|
||||||
|
[attr.d]="output.connectorPath"
|
||||||
|
class="output connector {{output.class}}"
|
||||||
|
[class.highlight]="outputData[i].index === outputIndex"
|
||||||
|
(pointerover)="onHover($event, 'output-connector', i);"
|
||||||
|
(pointerout)="onBlur($event, 'output-connector', i);"
|
||||||
|
(click)="onClick($event, 'output-connector', outputData[i].index);"
|
||||||
|
/>
|
||||||
|
<path *ngIf="!output.zeroValue"
|
||||||
|
[attr.d]="output.markerPath"
|
||||||
|
class="output marker-target {{output.class}}"
|
||||||
|
[class.highlight]="outputData[i].index === outputIndex"
|
||||||
|
(pointerover)="onHover($event, 'output', i);"
|
||||||
|
(pointerout)="onBlur($event, 'output', i);"
|
||||||
|
(click)="onClick($event, 'output', outputData[i].index);"
|
||||||
|
/>
|
||||||
|
<path *ngIf="!output.zeroValue"
|
||||||
[attr.d]="output.path"
|
[attr.d]="output.path"
|
||||||
class="line {{output.class}}"
|
class="line {{output.class}}"
|
||||||
[class.highlight]="outputIndex != null && outputData[i].index === outputIndex"
|
[class.highlight]="outputIndex != null && outputData[i].index === outputIndex"
|
||||||
@ -87,6 +144,16 @@
|
|||||||
(pointerout)="onBlur($event, 'output', i);"
|
(pointerout)="onBlur($event, 'output', i);"
|
||||||
(click)="onClick($event, 'output', outputData[i].index);"
|
(click)="onClick($event, 'output', outputData[i].index);"
|
||||||
/>
|
/>
|
||||||
|
<path *ngIf="output.zeroValue"
|
||||||
|
[attr.d]="output.path"
|
||||||
|
class="line {{output.class}} zerovalue"
|
||||||
|
[class.highlight]="outputIndex != null && outputData[i].index === outputIndex"
|
||||||
|
[class.zerovalue]="output.zeroValue"
|
||||||
|
[style]="output.style"
|
||||||
|
(pointerover)="onHover($event, 'output', i);"
|
||||||
|
(pointerout)="onBlur($event, 'output', i);"
|
||||||
|
(click)="onClick($event, 'output', outputData[i].index);"
|
||||||
|
/>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
@ -94,5 +161,6 @@
|
|||||||
*ngIf=[tooltip]
|
*ngIf=[tooltip]
|
||||||
[line]="hoverLine"
|
[line]="hoverLine"
|
||||||
[cursorPosition]="tooltipPosition"
|
[cursorPosition]="tooltipPosition"
|
||||||
|
[isConnector]="hoverConnector"
|
||||||
></app-tx-bowtie-graph-tooltip>
|
></app-tx-bowtie-graph-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,4 +1,8 @@
|
|||||||
.bowtie {
|
.bowtie {
|
||||||
|
&.rtl {
|
||||||
|
transform: scale(-1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
.line {
|
.line {
|
||||||
fill: none;
|
fill: none;
|
||||||
|
|
||||||
@ -11,6 +15,10 @@
|
|||||||
&.fee {
|
&.fee {
|
||||||
stroke: url(#fee-gradient);
|
stroke: url(#fee-gradient);
|
||||||
}
|
}
|
||||||
|
&.zerovalue {
|
||||||
|
stroke: url(#gradient0);
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
&.highlight {
|
&.highlight {
|
||||||
z-index: 8;
|
z-index: 8;
|
||||||
@ -21,20 +29,53 @@
|
|||||||
&.output {
|
&.output {
|
||||||
stroke: url(#output-highlight-gradient);
|
stroke: url(#output-highlight-gradient);
|
||||||
}
|
}
|
||||||
}
|
&.zerovalue {
|
||||||
|
stroke: #1bd8f4;
|
||||||
&:hover {
|
|
||||||
z-index: 10;
|
|
||||||
cursor: pointer;
|
|
||||||
&.input {
|
|
||||||
stroke: url(#input-hover-gradient);
|
|
||||||
}
|
|
||||||
&.output {
|
|
||||||
stroke: url(#output-hover-gradient);
|
|
||||||
}
|
|
||||||
&.fee {
|
|
||||||
stroke: url(#fee-hover-gradient);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
.line:hover, .marker-target:hover + .line {
|
||||||
|
z-index: 10;
|
||||||
|
cursor: pointer;
|
||||||
|
&.input {
|
||||||
|
stroke: url(#input-hover-gradient);
|
||||||
|
}
|
||||||
|
&.output {
|
||||||
|
stroke: url(#output-hover-gradient);
|
||||||
|
}
|
||||||
|
&.fee {
|
||||||
|
stroke: url(#fee-hover-gradient);
|
||||||
|
}
|
||||||
|
&.zerovalue {
|
||||||
|
stroke: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.connector {
|
||||||
|
stroke: none;
|
||||||
|
opacity: 0.75;
|
||||||
|
cursor: pointer;
|
||||||
|
&.input {
|
||||||
|
fill: url(#input-connector-gradient);
|
||||||
|
}
|
||||||
|
&.output {
|
||||||
|
fill: url(#output-connector-gradient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.connector:hover {
|
||||||
|
&.input {
|
||||||
|
fill: url(#input-hover-connector-gradient);
|
||||||
|
}
|
||||||
|
&.output {
|
||||||
|
fill: url(#output-hover-connector-gradient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-target {
|
||||||
|
stroke: none;
|
||||||
|
fill: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, Input, OnChanges, HostListener } from '@angular/core';
|
import { Component, OnInit, Input, OnChanges, HostListener, Inject, LOCALE_ID } from '@angular/core';
|
||||||
import { StateService } from '../../services/state.service';
|
import { StateService } from '../../services/state.service';
|
||||||
import { Outspend, Transaction } from '../../interfaces/electrs.interface';
|
import { Outspend, Transaction } from '../../interfaces/electrs.interface';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
@ -11,12 +11,18 @@ interface SvgLine {
|
|||||||
path: string;
|
path: string;
|
||||||
style: string;
|
style: string;
|
||||||
class?: string;
|
class?: string;
|
||||||
|
connectorPath?: string;
|
||||||
|
markerPath?: string;
|
||||||
|
zeroValue?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Xput {
|
interface Xput {
|
||||||
type: 'input' | 'output' | 'fee';
|
type: 'input' | 'output' | 'fee';
|
||||||
value?: number;
|
value?: number;
|
||||||
index?: number;
|
index?: number;
|
||||||
|
txid?: string;
|
||||||
|
vin?: number;
|
||||||
|
vout?: number;
|
||||||
address?: string;
|
address?: string;
|
||||||
rest?: number;
|
rest?: number;
|
||||||
coinbase?: boolean;
|
coinbase?: boolean;
|
||||||
@ -40,35 +46,43 @@ 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() connectors = false;
|
||||||
@Input() inputIndex: number;
|
@Input() inputIndex: number;
|
||||||
@Input() outputIndex: number;
|
@Input() outputIndex: number;
|
||||||
|
|
||||||
|
dir: 'rtl' | 'ltr' = 'ltr';
|
||||||
|
|
||||||
inputData: Xput[];
|
inputData: Xput[];
|
||||||
outputData: Xput[];
|
outputData: Xput[];
|
||||||
inputs: SvgLine[];
|
inputs: SvgLine[];
|
||||||
outputs: SvgLine[];
|
outputs: SvgLine[];
|
||||||
middle: SvgLine;
|
middle: SvgLine;
|
||||||
midWidth: number;
|
midWidth: number;
|
||||||
|
txWidth: number;
|
||||||
|
connectorWidth: number;
|
||||||
combinedWeight: number;
|
combinedWeight: number;
|
||||||
isLiquid: boolean = false;
|
isLiquid: boolean = false;
|
||||||
hoverLine: Xput | void = null;
|
hoverLine: Xput | void = null;
|
||||||
|
hoverConnector: boolean = false;
|
||||||
tooltipPosition = { x: 0, y: 0 };
|
tooltipPosition = { x: 0, y: 0 };
|
||||||
outspends: Outspend[] = [];
|
outspends: Outspend[] = [];
|
||||||
|
zeroValueWidth = 60;
|
||||||
|
zeroValueThickness = 20;
|
||||||
|
|
||||||
outspendsSubscription: Subscription;
|
outspendsSubscription: Subscription;
|
||||||
refreshOutspends$: ReplaySubject<string> = new ReplaySubject();
|
refreshOutspends$: ReplaySubject<string> = new ReplaySubject();
|
||||||
|
|
||||||
gradientColors = {
|
gradientColors = {
|
||||||
'': ['#9339f4', '#105fb0'],
|
'': ['#9339f4', '#105fb0', '#9339f400'],
|
||||||
bisq: ['#9339f4', '#105fb0'],
|
bisq: ['#9339f4', '#105fb0', '#9339f400'],
|
||||||
// liquid: ['#116761', '#183550'],
|
// liquid: ['#116761', '#183550'],
|
||||||
liquid: ['#09a197', '#0f62af'],
|
liquid: ['#09a197', '#0f62af', '#09a19700'],
|
||||||
// 'liquidtestnet': ['#494a4a', '#272e46'],
|
// 'liquidtestnet': ['#494a4a', '#272e46'],
|
||||||
'liquidtestnet': ['#d2d2d2', '#979797'],
|
'liquidtestnet': ['#d2d2d2', '#979797', '#d2d2d200'],
|
||||||
// testnet: ['#1d486f', '#183550'],
|
// testnet: ['#1d486f', '#183550'],
|
||||||
testnet: ['#4edf77', '#10a0af'],
|
testnet: ['#4edf77', '#10a0af', '#4edf7700'],
|
||||||
// signet: ['#6f1d5d', '#471850'],
|
// signet: ['#6f1d5d', '#471850'],
|
||||||
signet: ['#d24fc8', '#a84fd2'],
|
signet: ['#d24fc8', '#a84fd2', '#d24fc800'],
|
||||||
};
|
};
|
||||||
|
|
||||||
gradient: string[] = ['#105fb0', '#105fb0'];
|
gradient: string[] = ['#105fb0', '#105fb0'];
|
||||||
@ -78,7 +92,12 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges {
|
|||||||
private relativeUrlPipe: RelativeUrlPipe,
|
private relativeUrlPipe: RelativeUrlPipe,
|
||||||
private stateService: StateService,
|
private stateService: StateService,
|
||||||
private apiService: ApiService,
|
private apiService: ApiService,
|
||||||
) { }
|
@Inject(LOCALE_ID) private locale: string,
|
||||||
|
) {
|
||||||
|
if (this.locale.startsWith('ar') || this.locale.startsWith('fa') || this.locale.startsWith('he')) {
|
||||||
|
this.dir = 'rtl';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.initGraph();
|
this.initGraph();
|
||||||
@ -118,7 +137,10 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges {
|
|||||||
this.isLiquid = (this.network === 'liquid' || this.network === 'liquidtestnet');
|
this.isLiquid = (this.network === 'liquid' || this.network === 'liquidtestnet');
|
||||||
this.gradient = this.gradientColors[this.network];
|
this.gradient = this.gradientColors[this.network];
|
||||||
this.midWidth = Math.min(10, Math.ceil(this.width / 100));
|
this.midWidth = Math.min(10, Math.ceil(this.width / 100));
|
||||||
this.combinedWeight = Math.min(this.maxCombinedWeight, Math.floor((this.width - (2 * this.midWidth)) / 6));
|
this.txWidth = this.connectors ? Math.max(this.width - 200, this.width * 0.8) : this.width - 20;
|
||||||
|
this.combinedWeight = Math.min(this.maxCombinedWeight, Math.floor((this.txWidth - (2 * this.midWidth)) / 6));
|
||||||
|
this.connectorWidth = (this.width - this.txWidth) / 2;
|
||||||
|
this.zeroValueWidth = Math.max(20, Math.min((this.txWidth / 2) - this.midWidth - 110, 60));
|
||||||
|
|
||||||
const totalValue = this.calcTotalValue(this.tx);
|
const totalValue = this.calcTotalValue(this.tx);
|
||||||
let voutWithFee = this.tx.vout.map((v, i) => {
|
let voutWithFee = this.tx.vout.map((v, i) => {
|
||||||
@ -141,6 +163,8 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges {
|
|||||||
return {
|
return {
|
||||||
type: 'input',
|
type: 'input',
|
||||||
value: v?.prevout?.value,
|
value: v?.prevout?.value,
|
||||||
|
txid: v.txid,
|
||||||
|
vout: v.vout,
|
||||||
address: v?.prevout?.scriptpubkey_address || v?.prevout?.scriptpubkey_type?.toUpperCase(),
|
address: v?.prevout?.scriptpubkey_address || v?.prevout?.scriptpubkey_type?.toUpperCase(),
|
||||||
index: i,
|
index: i,
|
||||||
coinbase: v?.is_coinbase,
|
coinbase: v?.is_coinbase,
|
||||||
@ -223,10 +247,10 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges {
|
|||||||
}
|
}
|
||||||
|
|
||||||
linesFromWeights(side: 'in' | 'out', xputs: Xput[], weights: number[], maxVisibleStrands: number): SvgLine[] {
|
linesFromWeights(side: 'in' | 'out', xputs: Xput[], weights: number[], maxVisibleStrands: number): SvgLine[] {
|
||||||
const lineParams = weights.map((w) => {
|
const lineParams = weights.map((w, i) => {
|
||||||
return {
|
return {
|
||||||
weight: w,
|
weight: w,
|
||||||
thickness: Math.max(this.minWeight - 1, w) + 1,
|
thickness: xputs[i].value === 0 ? this.zeroValueThickness : Math.max(this.minWeight - 1, w) + 1,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
innerY: 0,
|
innerY: 0,
|
||||||
outerY: 0,
|
outerY: 0,
|
||||||
@ -243,7 +267,7 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges {
|
|||||||
let lastOuter = 0;
|
let lastOuter = 0;
|
||||||
let lastInner = innerTop;
|
let lastInner = innerTop;
|
||||||
// gap between strands
|
// gap between strands
|
||||||
const spacing = (this.height - visibleWeight) / gaps;
|
const spacing = Math.max(4, (this.height - visibleWeight) / gaps);
|
||||||
|
|
||||||
// curve adjustments to prevent overlaps
|
// curve adjustments to prevent overlaps
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
@ -252,6 +276,12 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges {
|
|||||||
let lastWeight = 0;
|
let lastWeight = 0;
|
||||||
let pad = 0;
|
let pad = 0;
|
||||||
lineParams.forEach((line, i) => {
|
lineParams.forEach((line, i) => {
|
||||||
|
if (xputs[i].value === 0) {
|
||||||
|
line.outerY = lastOuter + (this.zeroValueThickness / 2);
|
||||||
|
lastOuter += this.zeroValueThickness + spacing;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// set the vertical position of the (center of the) outer side of the line
|
// set the vertical position of the (center of the) outer side of the line
|
||||||
line.outerY = lastOuter + (line.thickness / 2);
|
line.outerY = lastOuter + (line.thickness / 2);
|
||||||
line.innerY = Math.min(innerBottom + (line.thickness / 2), Math.max(innerTop + (line.thickness / 2), lastInner + (line.weight / 2)));
|
line.innerY = Math.min(innerBottom + (line.thickness / 2), Math.max(innerTop + (line.thickness / 2), lastInner + (line.weight / 2)));
|
||||||
@ -268,7 +298,7 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges {
|
|||||||
// required to prevent this line overlapping its neighbor
|
// required to prevent this line overlapping its neighbor
|
||||||
|
|
||||||
if (this.tooltip || !xputs[i].rest) {
|
if (this.tooltip || !xputs[i].rest) {
|
||||||
const w = (this.width - Math.max(lastWeight, line.weight)) / 2; // approximate horizontal width of the curved section of the line
|
const w = (this.width - Math.max(lastWeight, line.weight) - (2 * this.connectorWidth)) / 2; // approximate horizontal width of the curved section of the line
|
||||||
const y1 = line.outerY;
|
const y1 = line.outerY;
|
||||||
const y2 = line.innerY;
|
const y2 = line.innerY;
|
||||||
const t = (lastWeight + line.weight) / 2; // distance between center of this line and center of previous line
|
const t = (lastWeight + line.weight) / 2; // distance between center of this line and center of previous line
|
||||||
@ -305,17 +335,28 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges {
|
|||||||
maxOffset -= minOffset;
|
maxOffset -= minOffset;
|
||||||
|
|
||||||
return lineParams.map((line, i) => {
|
return lineParams.map((line, i) => {
|
||||||
return {
|
if (xputs[i].value === 0) {
|
||||||
path: this.makePath(side, line.outerY, line.innerY, line.thickness, line.offset, pad + maxOffset),
|
return {
|
||||||
style: this.makeStyle(line.thickness, xputs[i].type),
|
path: this.makeZeroValuePath(side, line.outerY),
|
||||||
class: xputs[i].type
|
style: this.makeStyle(this.zeroValueThickness, xputs[i].type),
|
||||||
};
|
class: xputs[i].type,
|
||||||
|
zeroValue: true,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
path: this.makePath(side, line.outerY, line.innerY, line.thickness, line.offset, pad + maxOffset),
|
||||||
|
style: this.makeStyle(line.thickness, xputs[i].type),
|
||||||
|
class: xputs[i].type,
|
||||||
|
connectorPath: this.connectors ? this.makeConnectorPath(side, line.outerY, line.innerY, line.thickness): null,
|
||||||
|
markerPath: this.makeMarkerPath(side, line.outerY, line.innerY, line.thickness),
|
||||||
|
};
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
makePath(side: 'in' | 'out', outer: number, inner: number, weight: number, offset: number, pad: number): string {
|
makePath(side: 'in' | 'out', outer: number, inner: number, weight: number, offset: number, pad: number): string {
|
||||||
const start = (weight * 0.5);
|
const start = (weight * 0.5) + this.connectorWidth;
|
||||||
const curveStart = Math.max(start + 1, pad - offset);
|
const curveStart = Math.max(start + 5, pad - offset);
|
||||||
const end = this.width / 2 - (this.midWidth * 0.9) + 1;
|
const end = this.width / 2 - (this.midWidth * 0.9) + 1;
|
||||||
const curveEnd = end - offset - 10;
|
const curveEnd = end - offset - 10;
|
||||||
const midpoint = (curveStart + curveEnd) / 2;
|
const midpoint = (curveStart + curveEnd) / 2;
|
||||||
@ -332,6 +373,50 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
makeZeroValuePath(side: 'in' | 'out', y: number): string {
|
||||||
|
const offset = this.zeroValueThickness / 2;
|
||||||
|
const start = (this.connectorWidth / 2) + 10;
|
||||||
|
if (side === 'in') {
|
||||||
|
return `M ${start + offset} ${y} L ${start + this.zeroValueWidth + offset} ${y}`;
|
||||||
|
} else { // mirrored in y-axis for the right hand side
|
||||||
|
return `M ${this.width - start - offset} ${y} L ${this.width - start - this.zeroValueWidth - offset} ${y}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
makeConnectorPath(side: 'in' | 'out', y: number, inner, weight: number): string {
|
||||||
|
const halfWidth = weight * 0.5;
|
||||||
|
const offset = 10; //Math.max(2, halfWidth * 0.2);
|
||||||
|
const lineEnd = this.connectorWidth;
|
||||||
|
|
||||||
|
// align with for svg horizontal gradient bug correction
|
||||||
|
if (Math.round(y) === Math.round(inner)) {
|
||||||
|
y -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (side === 'in') {
|
||||||
|
return `M ${lineEnd - offset} ${y - halfWidth} L ${halfWidth + lineEnd - offset} ${y} L ${lineEnd - offset} ${y + halfWidth} L -${10} ${ y + halfWidth} L -${10} ${y - halfWidth}`;
|
||||||
|
} else {
|
||||||
|
return `M ${this.width - halfWidth - lineEnd + offset} ${y - halfWidth} L ${this.width - lineEnd + offset} ${y} L ${this.width - halfWidth - lineEnd + offset} ${y + halfWidth} L ${this.width + 10} ${ y + halfWidth} L ${this.width + 10} ${y - halfWidth}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
makeMarkerPath(side: 'in' | 'out', y: number, inner, weight: number): string {
|
||||||
|
const halfWidth = weight * 0.5;
|
||||||
|
const offset = 10; //Math.max(2, halfWidth * 0.2);
|
||||||
|
const lineEnd = this.connectorWidth;
|
||||||
|
|
||||||
|
// align with for svg horizontal gradient bug correction
|
||||||
|
if (Math.round(y) === Math.round(inner)) {
|
||||||
|
y -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (side === 'in') {
|
||||||
|
return `M ${lineEnd - offset} ${y - halfWidth} L ${halfWidth + lineEnd - offset} ${y} L ${lineEnd - offset} ${y + halfWidth} L ${weight + lineEnd} ${ y + halfWidth} L ${weight + lineEnd} ${y - halfWidth}`;
|
||||||
|
} else {
|
||||||
|
return `M ${this.width - halfWidth - lineEnd + offset} ${y - halfWidth} L ${this.width - lineEnd + offset} ${y} L ${this.width - halfWidth - lineEnd + offset} ${y + halfWidth} L ${this.width - halfWidth - lineEnd} ${ y + halfWidth} L ${this.width - halfWidth - lineEnd} ${y - halfWidth}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
makeStyle(minWeight, type): string {
|
makeStyle(minWeight, type): string {
|
||||||
if (type === 'fee') {
|
if (type === 'fee') {
|
||||||
return `stroke-width: ${minWeight}`;
|
return `stroke-width: ${minWeight}`;
|
||||||
@ -342,30 +427,39 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges {
|
|||||||
|
|
||||||
@HostListener('pointermove', ['$event'])
|
@HostListener('pointermove', ['$event'])
|
||||||
onPointerMove(event) {
|
onPointerMove(event) {
|
||||||
this.tooltipPosition = { x: event.offsetX, y: event.offsetY };
|
if (this.dir === 'rtl') {
|
||||||
|
this.tooltipPosition = { x: this.width - event.offsetX, y: event.offsetY };
|
||||||
|
} else {
|
||||||
|
this.tooltipPosition = { x: event.offsetX, y: event.offsetY };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onHover(event, side, index): void {
|
onHover(event, side, index): void {
|
||||||
if (side === 'input') {
|
if (side.startsWith('input')) {
|
||||||
this.hoverLine = {
|
this.hoverLine = {
|
||||||
...this.inputData[index],
|
...this.inputData[index],
|
||||||
index
|
index
|
||||||
};
|
};
|
||||||
|
this.hoverConnector = (side === 'input-connector');
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
this.hoverLine = {
|
this.hoverLine = {
|
||||||
...this.outputData[index]
|
...this.outputData[index],
|
||||||
|
...this.outspends[this.outputData[index].index]
|
||||||
};
|
};
|
||||||
|
this.hoverConnector = (side === 'output-connector');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onBlur(event, side, index): void {
|
onBlur(event, side, index): void {
|
||||||
this.hoverLine = null;
|
this.hoverLine = null;
|
||||||
|
this.hoverConnector = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
onClick(event, side, index): void {
|
onClick(event, side, index): void {
|
||||||
if (side === 'input') {
|
if (side.startsWith('input')) {
|
||||||
const input = this.tx.vin[index];
|
const input = this.tx.vin[index];
|
||||||
if (input && !input.is_coinbase && !input.is_pegin && input.txid && input.vout != null) {
|
if (side === 'input-connector' && input && !input.is_coinbase && !input.is_pegin && input.txid && input.vout != null) {
|
||||||
this.router.navigate([this.relativeUrlPipe.transform('/tx'), input.txid], {
|
this.router.navigate([this.relativeUrlPipe.transform('/tx'), input.txid], {
|
||||||
queryParamsHandling: 'merge',
|
queryParamsHandling: 'merge',
|
||||||
fragment: (new URLSearchParams({
|
fragment: (new URLSearchParams({
|
||||||
@ -385,7 +479,7 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges {
|
|||||||
} else {
|
} else {
|
||||||
const output = this.tx.vout[index];
|
const output = this.tx.vout[index];
|
||||||
const outspend = this.outspends[index];
|
const outspend = this.outspends[index];
|
||||||
if (output && outspend && outspend.spent && outspend.txid) {
|
if (side === 'output-connector' && output && outspend && outspend.spent && outspend.txid) {
|
||||||
this.router.navigate([this.relativeUrlPipe.transform('/tx'), outspend.txid], {
|
this.router.navigate([this.relativeUrlPipe.transform('/tx'), outspend.txid], {
|
||||||
queryParamsHandling: 'merge',
|
queryParamsHandling: 'merge',
|
||||||
fragment: (new URLSearchParams({
|
fragment: (new URLSearchParams({
|
||||||
|
@ -9,6 +9,11 @@
|
|||||||
|
|
||||||
<div class="doc-content">
|
<div class="doc-content">
|
||||||
|
|
||||||
|
<div id="disclaimer">
|
||||||
|
<table><tr><td><svg viewBox="0 0 304 304" xmlns="http://www.w3.org/2000/svg"><g fill-rule="evenodd" style="fill:#ffc107;fill-opacity:1"><path d="M135.3 34.474c-15.62 27.306-54.206 95.63-85.21 150.534L9.075 257.583C5.382 264.08 6.76 269.217 7.908 271.7c2.326 5.028 7.29 7.537 11.155 8.215l.78.133 264.698.006-.554-.02c4.152.255 9.664-1.24 12.677-6.194 1.926-3.18 3.31-8.589-1.073-16.278L213.637 114.37l-45.351-79.205c-5.681-9.932-12.272-12.022-16.8-12.022-4.42 0-10.818 1.964-16.181 11.331h-.006zm-69.072 159.94c30.997-54.885 69.563-123.184 85.16-150.446l.186-.297c.2.303.393.582.618.981l45.363 79.22s72.377 126.47 78.569 137.283l-247.618-.007 37.719-66.734" style="fill:#ffc107;fill-opacity:1"/><path d="M152.597 247.445c8.02 0 14.518-6.728 14.518-15.025 0-8.29-6.499-15.018-14.518-15.018-8.031 0-14.529 6.728-14.529 15.018 0 8.297 6.498 15.025 14.53 15.025m-.001-147.18c11.586 0 22.23 10.958 20.977 21.7l-9.922 75.564c-.966 6.601-4.95 11.433-11.055 11.433s-10.102-4.832-11.056-11.433l-9.927-75.564c-1.26-10.742 9.39-21.7 20.983-21.7" style="fill:#ffc107;fill-opacity:1"/></g></svg></td><td><p><b>mempool.space merely provides data about the Bitcoin network.</b> It cannot help you with retrieving funds, confirming your transaction quicker, etc.</p><p>For any such requests, you need to get in touch with the entity that helped make the transaction (wallet software, exchange company, etc).</p></td></tr></table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="doc-item-container" *ngFor="let item of faq">
|
<div class="doc-item-container" *ngFor="let item of faq">
|
||||||
<h3 *ngIf="item.type === 'category'">{{ item.title }}</h3>
|
<h3 *ngIf="item.type === 'category'">{{ item.title }}</h3>
|
||||||
<div *ngIf="item.type !== 'category'" class="endpoint-container" id="{{ item.fragment }}">
|
<div *ngIf="item.type !== 'category'" class="endpoint-container" id="{{ item.fragment }}">
|
||||||
|
@ -219,6 +219,22 @@ h3 {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#disclaimer {
|
||||||
|
background-color: #1d1f31;
|
||||||
|
padding: 24px;
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#disclaimer svg {
|
||||||
|
width: 50px;
|
||||||
|
height: auto;
|
||||||
|
margin-right: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#disclaimer p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 992px) {
|
@media (max-width: 992px) {
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
|
@ -30,7 +30,7 @@
|
|||||||
<pre><code [innerText]="wrapEsModule(code)"></code></pre>
|
<pre><code [innerText]="wrapEsModule(code)"></code></pre>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
<li ngbNavItem *ngIf="showCodeExample[network] && network !== 'liquid' && network !== 'liquidtestnet'" role="presentation">
|
<li ngbNavItem *ngIf="code.codeTemplate.python && network !== 'liquid' && network !== 'liquidtestnet'" role="presentation">
|
||||||
<a ngbNavLink (click)="adjustContainerHeight( $event )" role="tab">Python</a>
|
<a ngbNavLink (click)="adjustContainerHeight( $event )" role="tab">Python</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
<div class="subtitle"><ng-container i18n="API Docs code example">Code Example</ng-container> <app-clipboard [text]="wrapEsModule(code)"></app-clipboard></div>
|
<div class="subtitle"><ng-container i18n="API Docs code example">Code Example</ng-container> <app-clipboard [text]="wrapEsModule(code)"></app-clipboard></div>
|
||||||
|
@ -152,6 +152,11 @@ export interface RewardStats {
|
|||||||
totalTx: number;
|
totalTx: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuditScore {
|
||||||
|
hash: string;
|
||||||
|
matchRate?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ITopNodesPerChannels {
|
export interface ITopNodesPerChannels {
|
||||||
publicKey: string,
|
publicKey: string,
|
||||||
alias: string,
|
alias: string,
|
||||||
|
31
frontend/src/app/lightning/node/liquidity-ad.ts
Normal file
31
frontend/src/app/lightning/node/liquidity-ad.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
export interface ILiquidityAd {
|
||||||
|
funding_weight: number;
|
||||||
|
lease_fee_basis: number; // lease fee rate in parts-per-thousandth
|
||||||
|
lease_fee_base_sat: number; // fixed lease fee in sats
|
||||||
|
channel_fee_max_rate: number; // max routing fee rate in parts-per-thousandth
|
||||||
|
channel_fee_max_base: number; // max routing base fee in milli-sats
|
||||||
|
compact_lease?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLiquidityAdHex(compact_lease: string): ILiquidityAd | false {
|
||||||
|
if (!compact_lease || compact_lease.length < 20 || compact_lease.length > 28) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const liquidityAd: ILiquidityAd = {
|
||||||
|
funding_weight: parseInt(compact_lease.slice(0, 4), 16),
|
||||||
|
lease_fee_basis: parseInt(compact_lease.slice(4, 8), 16),
|
||||||
|
channel_fee_max_rate: parseInt(compact_lease.slice(8, 12), 16),
|
||||||
|
lease_fee_base_sat: parseInt(compact_lease.slice(12, 20), 16),
|
||||||
|
channel_fee_max_base: compact_lease.length > 20 ? parseInt(compact_lease.slice(20), 16) : 0,
|
||||||
|
}
|
||||||
|
if (Object.values(liquidityAd).reduce((valid: boolean, value: number): boolean => (valid && !isNaN(value) && value >= 0), true)) {
|
||||||
|
liquidityAd.compact_lease = compact_lease;
|
||||||
|
return liquidityAd;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
@ -52,6 +52,10 @@
|
|||||||
<span i18n="unknown">Unknown</span>
|
<span i18n="unknown">Unknown</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr *ngIf="(avgChannelDistance$ | async) as avgDistance;">
|
||||||
|
<td i18n="lightning.avg-distance" class="text-truncate">Avg channel distance</td>
|
||||||
|
<td>{{ avgDistance | number : '1.0-0' }} <span class="symbol">km</span> <span class="separator">/</span> {{ kmToMiles(avgDistance) | number : '1.0-0' }} <span class="symbol">mi</span></td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@ -125,6 +129,93 @@
|
|||||||
<app-clipboard [button]="true" [text]="node.socketsObject[selectedSocketIndex].socket" [leftPadding]="false"></app-clipboard>
|
<app-clipboard [button]="true" [text]="node.socketsObject[selectedSocketIndex].socket" [leftPadding]="false"></app-clipboard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="hasDetails" [hidden]="!showDetails" id="details" class="details mt-3">
|
||||||
|
<div class="box">
|
||||||
|
<ng-template [ngIf]="liquidityAd">
|
||||||
|
<div class="detail-section">
|
||||||
|
<h5 class="mb-3" i18n="node.liquidity-ad">Liquidity ad</h5>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
<table class="table table-borderless table-striped">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="label" i18n="liquidity-ad.lease-fee-rate|Liquidity ad lease fee rate">Lease fee rate</td>
|
||||||
|
<td>
|
||||||
|
<span class="d-inline-block">
|
||||||
|
{{ liquidityAd.lease_fee_basis !== null ? ((liquidityAd.lease_fee_basis * 1000) | amountShortener : 2 : undefined : true) : '-' }} <span class="symbol">ppm {{ liquidityAd.lease_fee_basis !== null ? '(' + (liquidityAd.lease_fee_basis / 10 | amountShortener : 2 : undefined : true) + '%)' : '' }}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label" i18n="liquidity-ad.lease-base-fee">Lease base fee</td>
|
||||||
|
<td>
|
||||||
|
<app-sats [valueOverride]="liquidityAd.lease_fee_base_sat === null ? '- ' : undefined" [satoshis]="liquidityAd.lease_fee_base_sat"></app-sats>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label" i18n="liquidity-ad.funding-weight">Funding weight</td>
|
||||||
|
<td [innerHTML]="'‎' + (liquidityAd.funding_weight | wuBytes: 2)"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="col-md">
|
||||||
|
<table class="table table-borderless table-striped">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="label" i18n="liquidity-ad.channel-fee-rate|Liquidity ad channel fee rate">Channel fee rate</td>
|
||||||
|
<td>
|
||||||
|
<span class="d-inline-block">
|
||||||
|
{{ liquidityAd.channel_fee_max_rate !== null ? ((liquidityAd.channel_fee_max_rate * 1000) | amountShortener : 2 : undefined : true) : '-' }} <span class="symbol">ppm {{ liquidityAd.channel_fee_max_rate !== null ? '(' + (liquidityAd.channel_fee_max_rate / 10 | amountShortener : 2 : undefined : true) + '%)' : '' }}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label" i18n="liquidity-ad.channel-base-fee">Channel base fee</td>
|
||||||
|
<td>
|
||||||
|
<span *ngIf="liquidityAd.channel_fee_max_base !== null">
|
||||||
|
{{ liquidityAd.channel_fee_max_base | amountShortener : 0 }}
|
||||||
|
<span class="symbol" i18n="shared.m-sats">mSats</span>
|
||||||
|
</span>
|
||||||
|
<span *ngIf="liquidityAd.channel_fee_max_base === null">
|
||||||
|
-
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label" i18n="liquidity-ad.compact-lease">Compact lease</td>
|
||||||
|
<td class="compact-lease">{{ liquidityAd.compact_lease }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template [ngIf]="tlvRecords?.length">
|
||||||
|
<div class="detail-section">
|
||||||
|
<h5 class="mb-3" i18n="node.tlv.records">TLV extension records</h5>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<table class="table table-borderless table-striped">
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let recordItem of tlvRecords">
|
||||||
|
<td class="tlv-type">{{ recordItem.type }}</td>
|
||||||
|
<td class="tlv-payload">{{ recordItem.payload }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="hasDetails" class="text-right mt-3">
|
||||||
|
<button type="button" class="btn btn-outline-info btn-sm btn-details" (click)="toggleShowDetails()" i18n="node.details|Node Details">Details</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div *ngIf="!error">
|
<div *ngIf="!error">
|
||||||
<div class="row" *ngIf="node.as_number && node.active_channel_count">
|
<div class="row" *ngIf="node.as_number && node.active_channel_count">
|
||||||
<div class="col-sm">
|
<div class="col-sm">
|
||||||
|
@ -72,3 +72,36 @@ app-fiat {
|
|||||||
height: 28px !important;
|
height: 28px !important;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
|
||||||
|
.detail-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tlv-type {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #ffffff66;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tlv-payload {
|
||||||
|
font-size: 12px;
|
||||||
|
width: 100%;
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: normal;
|
||||||
|
font-family: "Courier New", Courier, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-lease {
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: normal;
|
||||||
|
font-family: "Courier New", Courier, monospace;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
margin: 0 1em;
|
||||||
|
}
|
||||||
|
@ -1,10 +1,18 @@
|
|||||||
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
|
||||||
import { ActivatedRoute, ParamMap } from '@angular/router';
|
import { ActivatedRoute, ParamMap } from '@angular/router';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { catchError, map, switchMap } from 'rxjs/operators';
|
import { catchError, map, switchMap, tap } from 'rxjs/operators';
|
||||||
import { SeoService } from '../../services/seo.service';
|
import { SeoService } from '../../services/seo.service';
|
||||||
|
import { ApiService } from '../../services/api.service';
|
||||||
import { LightningApiService } from '../lightning-api.service';
|
import { LightningApiService } from '../lightning-api.service';
|
||||||
import { GeolocationData } from '../../shared/components/geolocation/geolocation.component';
|
import { GeolocationData } from '../../shared/components/geolocation/geolocation.component';
|
||||||
|
import { ILiquidityAd, parseLiquidityAdHex } from './liquidity-ad';
|
||||||
|
import { haversineDistance, kmToMiles } from 'src/app/shared/common.utils';
|
||||||
|
|
||||||
|
interface CustomRecord {
|
||||||
|
type: string;
|
||||||
|
payload: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-node',
|
selector: 'app-node',
|
||||||
@ -24,8 +32,16 @@ export class NodeComponent implements OnInit {
|
|||||||
channelListLoading = false;
|
channelListLoading = false;
|
||||||
clearnetSocketCount = 0;
|
clearnetSocketCount = 0;
|
||||||
torSocketCount = 0;
|
torSocketCount = 0;
|
||||||
|
hasDetails = false;
|
||||||
|
showDetails = false;
|
||||||
|
liquidityAd: ILiquidityAd;
|
||||||
|
tlvRecords: CustomRecord[];
|
||||||
|
avgChannelDistance$: Observable<number | null>;
|
||||||
|
|
||||||
|
kmToMiles = kmToMiles;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
private apiService: ApiService,
|
||||||
private lightningApiService: LightningApiService,
|
private lightningApiService: LightningApiService,
|
||||||
private activatedRoute: ActivatedRoute,
|
private activatedRoute: ActivatedRoute,
|
||||||
private seoService: SeoService,
|
private seoService: SeoService,
|
||||||
@ -36,6 +52,8 @@ export class NodeComponent implements OnInit {
|
|||||||
.pipe(
|
.pipe(
|
||||||
switchMap((params: ParamMap) => {
|
switchMap((params: ParamMap) => {
|
||||||
this.publicKey = params.get('public_key');
|
this.publicKey = params.get('public_key');
|
||||||
|
this.tlvRecords = [];
|
||||||
|
this.liquidityAd = null;
|
||||||
return this.lightningApiService.getNode$(params.get('public_key'));
|
return this.lightningApiService.getNode$(params.get('public_key'));
|
||||||
}),
|
}),
|
||||||
map((node) => {
|
map((node) => {
|
||||||
@ -79,6 +97,26 @@ export class NodeComponent implements OnInit {
|
|||||||
|
|
||||||
return node;
|
return node;
|
||||||
}),
|
}),
|
||||||
|
tap((node) => {
|
||||||
|
this.hasDetails = Object.keys(node.custom_records).length > 0;
|
||||||
|
for (const [type, payload] of Object.entries(node.custom_records)) {
|
||||||
|
if (typeof payload !== 'string') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed = false;
|
||||||
|
if (type === '1') {
|
||||||
|
const ad = parseLiquidityAdHex(payload);
|
||||||
|
if (ad) {
|
||||||
|
parsed = true;
|
||||||
|
this.liquidityAd = ad;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!parsed) {
|
||||||
|
this.tlvRecords.push({ type, payload });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
catchError(err => {
|
catchError(err => {
|
||||||
this.error = err;
|
this.error = err;
|
||||||
return [{
|
return [{
|
||||||
@ -87,6 +125,30 @@ export class NodeComponent implements OnInit {
|
|||||||
}];
|
}];
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.avgChannelDistance$ = this.activatedRoute.paramMap
|
||||||
|
.pipe(
|
||||||
|
switchMap((params: ParamMap) => {
|
||||||
|
return this.apiService.getChannelsGeo$(params.get('public_key'), 'nodepage');
|
||||||
|
}),
|
||||||
|
map((channelsGeo) => {
|
||||||
|
if (channelsGeo?.length) {
|
||||||
|
const totalDistance = channelsGeo.reduce((sum, chan) => {
|
||||||
|
return sum + haversineDistance(chan[3], chan[2], chan[7], chan[6]);
|
||||||
|
}, 0);
|
||||||
|
return totalDistance / channelsGeo.length;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
catchError(() => {
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
) as Observable<number | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleShowDetails(): void {
|
||||||
|
this.showDetails = !this.showDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
changeSocket(index: number) {
|
changeSocket(index: number) {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators,
|
import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators,
|
||||||
PoolStat, BlockExtended, TransactionStripped, RewardStats } from '../interfaces/node-api.interface';
|
PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore } from '../interfaces/node-api.interface';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { StateService } from './state.service';
|
import { StateService } from './state.service';
|
||||||
import { WebsocketResponse } from '../interfaces/websocket.interface';
|
import { WebsocketResponse } from '../interfaces/websocket.interface';
|
||||||
@ -234,6 +234,19 @@ export class ApiService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getBlockAuditScores$(from: number): Observable<AuditScore[]> {
|
||||||
|
return this.httpClient.get<AuditScore[]>(
|
||||||
|
this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/blocks/audit/scores` +
|
||||||
|
(from !== undefined ? `/${from}` : ``)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getBlockAuditScore$(hash: string) : Observable<any> {
|
||||||
|
return this.httpClient.get<any>(
|
||||||
|
this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/blocks/audit/score/` + hash
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
getRewardStats$(blockCount: number = 144): Observable<RewardStats> {
|
getRewardStats$(blockCount: number = 144): Observable<RewardStats> {
|
||||||
return this.httpClient.get<RewardStats>(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/reward-stats/${blockCount}`);
|
return this.httpClient.get<RewardStats>(this.apiBaseUrl + this.apiBasePath + `/api/v1/mining/reward-stats/${blockCount}`);
|
||||||
}
|
}
|
||||||
|
@ -112,6 +112,8 @@ export class StateService {
|
|||||||
timeLtr: BehaviorSubject<boolean>;
|
timeLtr: BehaviorSubject<boolean>;
|
||||||
hideFlow: BehaviorSubject<boolean>;
|
hideFlow: BehaviorSubject<boolean>;
|
||||||
|
|
||||||
|
txCache: { [txid: string]: Transaction } = {};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(PLATFORM_ID) private platformId: any,
|
@Inject(PLATFORM_ID) private platformId: any,
|
||||||
@Inject(LOCALE_ID) private locale: string,
|
@Inject(LOCALE_ID) private locale: string,
|
||||||
@ -265,4 +267,19 @@ export class StateService {
|
|||||||
isLiquid() {
|
isLiquid() {
|
||||||
return this.network === 'liquid' || this.network === 'liquidtestnet';
|
return this.network === 'liquid' || this.network === 'liquidtestnet';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setTxCache(transactions) {
|
||||||
|
this.txCache = {};
|
||||||
|
transactions.forEach(tx => {
|
||||||
|
this.txCache[tx.txid] = tx;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getTxFromCache(txid) {
|
||||||
|
if (this.txCache && this.txCache[txid]) {
|
||||||
|
return this.txCache[txid];
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -118,3 +118,21 @@ export function convertRegion(input, to: 'name' | 'abbreviated'): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function haversineDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const rlat1 = lat1 * Math.PI / 180;
|
||||||
|
const rlon1 = lon1 * Math.PI / 180;
|
||||||
|
const rlat2 = lat2 * Math.PI / 180;
|
||||||
|
const rlon2 = lon2 * Math.PI / 180;
|
||||||
|
|
||||||
|
const dlat = Math.sin((rlat2 - rlat1) / 2);
|
||||||
|
const dlon = Math.sin((rlon2 - rlon1) / 2);
|
||||||
|
const a = Math.min(1, Math.max(0, (dlat * dlat) + (Math.cos(rlat1) * Math.cos(rlat2) * dlon * dlon)));
|
||||||
|
const d = 2 * 6371 * Math.asin(Math.sqrt(a));
|
||||||
|
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function kmToMiles(km: number): number {
|
||||||
|
return km * 0.62137119;
|
||||||
|
}
|
1
frontend/src/resources/profile/nunchuk.svg
Normal file
1
frontend/src/resources/profile/nunchuk.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg width="120" height="120" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#a)"><path d="M93.28 40.282 69.81 63.75a1.107 1.107 0 0 1-1.889-.784v-10.81h-14.24a1.107 1.107 0 0 1-1.106-1.108V40.3H38.939L13.156 66.15 26.72 79.718l23.265-23.27a1.108 1.108 0 0 1 1.89.783v10.976h14.442a1.108 1.108 0 0 1 1.107 1.108V79.69H81.06l25.783-25.85-13.564-13.559Z" fill="#FFCB2E"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h120v120H0z"/></clipPath></defs></svg>
|
After Width: | Height: | Size: 478 B |
Loading…
x
Reference in New Issue
Block a user