Compare commits

..

2 Commits

Author SHA1 Message Date
Mononaut
3b1f8ef4a2 fix custom x widget 2024-05-12 15:54:15 +00:00
Mononaut
86ef1e5fce update onbtc config 2024-05-12 15:47:00 +00:00
171 changed files with 19612 additions and 32619 deletions

View File

@@ -257,7 +257,7 @@ jobs:
spec: |
cypress/e2e/mainnet/*.spec.ts
cypress/e2e/signet/*.spec.ts
cypress/e2e/testnet4/*.spec.ts
cypress/e2e/testnet/*.spec.ts
- module: "liquid"
spec: |
cypress/e2e/liquid/liquid.spec.ts

View File

@@ -20,7 +20,6 @@
"@typescript-eslint/no-this-alias": 1,
"@typescript-eslint/no-var-requires": 1,
"@typescript-eslint/explicit-function-return-type": 1,
"@typescript-eslint/no-unused-vars": 1,
"no-console": 1,
"no-constant-condition": 1,
"no-dupe-else-if": 1,
@@ -33,7 +32,6 @@
"prefer-rest-params": 1,
"quotes": [1, "single", { "allowTemplateLiterals": true }],
"semi": 1,
"curly": [1, "all"],
"eqeqeq": 1
}
}

View File

@@ -139,8 +139,6 @@
"ENABLED": false,
"AUDIT": false,
"AUDIT_START_HEIGHT": 774000,
"STATISTICS": false,
"STATISTICS_START_TIME": 1481932800,
"SERVERS": [
"list",
"of",

View File

@@ -13,12 +13,12 @@
"@babel/core": "^7.24.0",
"@mempool/electrum-client": "1.1.9",
"@types/node": "^18.15.3",
"axios": "~1.7.2",
"axios": "~1.6.1",
"bitcoinjs-lib": "~6.1.3",
"crypto-js": "~4.2.0",
"express": "~4.19.2",
"maxmind": "~4.3.11",
"mysql2": "~3.10.0",
"mysql2": "~3.9.7",
"redis": "^4.6.6",
"rust-gbt": "file:./rust-gbt",
"socks-proxy-agent": "~7.0.0",
@@ -2318,11 +2318,11 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/axios": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz",
"integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==",
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.6.1.tgz",
"integrity": "sha512-vfBmhDpKafglh0EldBEbVuoe7DyAavGSLWhuSm5ZSEKQnHhBf0xAAwybbNH1IkrJNGnS/VG4I5yxig1pCEXE4g==",
"dependencies": {
"follow-redirects": "^1.15.6",
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
@@ -6197,9 +6197,9 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/mysql2": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.10.0.tgz",
"integrity": "sha512-qx0mfWYt1DpTPkw8mAcHW/OwqqyNqBLBHvY5IjN8+icIYTjt6znrgYJ+gxqNNRpVknb5Wc/gcCM4XjbCR0j5tw==",
"version": "3.9.7",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.7.tgz",
"integrity": "sha512-KnJT8vYRcNAZv73uf9zpXqNbvBG7DJrs+1nACsjZP1HMJ1TgXEy8wnNilXAn/5i57JizXKtrUtwDB7HxT9DDpw==",
"dependencies": {
"denque": "^2.1.0",
"generate-function": "^2.3.1",
@@ -9509,11 +9509,11 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"axios": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz",
"integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==",
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.6.1.tgz",
"integrity": "sha512-vfBmhDpKafglh0EldBEbVuoe7DyAavGSLWhuSm5ZSEKQnHhBf0xAAwybbNH1IkrJNGnS/VG4I5yxig1pCEXE4g==",
"requires": {
"follow-redirects": "^1.15.6",
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
@@ -12382,9 +12382,9 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"mysql2": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.10.0.tgz",
"integrity": "sha512-qx0mfWYt1DpTPkw8mAcHW/OwqqyNqBLBHvY5IjN8+icIYTjt6znrgYJ+gxqNNRpVknb5Wc/gcCM4XjbCR0j5tw==",
"version": "3.9.7",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.7.tgz",
"integrity": "sha512-KnJT8vYRcNAZv73uf9zpXqNbvBG7DJrs+1nACsjZP1HMJ1TgXEy8wnNilXAn/5i57JizXKtrUtwDB7HxT9DDpw==",
"requires": {
"denque": "^2.1.0",
"generate-function": "^2.3.1",

View File

@@ -42,12 +42,12 @@
"@babel/core": "^7.24.0",
"@mempool/electrum-client": "1.1.9",
"@types/node": "^18.15.3",
"axios": "~1.7.2",
"axios": "~1.6.1",
"bitcoinjs-lib": "~6.1.3",
"crypto-js": "~4.2.0",
"express": "~4.19.2",
"maxmind": "~4.3.11",
"mysql2": "~3.10.0",
"mysql2": "~3.9.7",
"rust-gbt": "file:./rust-gbt",
"redis": "^4.6.6",
"socks-proxy-agent": "~7.0.0",

View File

@@ -131,8 +131,6 @@
"ENABLED": false,
"AUDIT": false,
"AUDIT_START_HEIGHT": 774000,
"STATISTICS": false,
"STATISTICS_START_TIME": 1481932800,
"SERVERS": []
},
"MEMPOOL_SERVICES": {

View File

@@ -135,8 +135,6 @@ describe('Mempool Backend Config', () => {
ENABLED: false,
AUDIT: false,
AUDIT_START_HEIGHT: 774000,
STATISTICS: false,
STATISTICS_START_TIME: 1481932800,
SERVERS: []
});

View File

@@ -160,8 +160,7 @@ class BitcoinRoutes {
effectiveFeePerVsize: tx.effectiveFeePerVsize || null,
sigops: tx.sigops,
adjustedVsize: tx.adjustedVsize,
acceleration: tx.acceleration,
acceleratedBy: tx.acceleratedBy || undefined,
acceleration: tx.acceleration
});
return;
}

View File

@@ -7,7 +7,7 @@ import cpfpRepository from '../repositories/CpfpRepository';
import { RowDataPacket } from 'mysql2';
class DatabaseMigration {
private static currentVersion = 79;
private static currentVersion = 78;
private queryTimeout = 3600_000;
private statisticsAddedIndexed = false;
private uniqueLogs: string[] = [];
@@ -674,18 +674,6 @@ class DatabaseMigration {
await this.$executeQuery('ALTER TABLE `prices` CHANGE `time` `time` datetime NOT NULL');
await this.updateToSchemaVersion(78);
}
if (databaseSchemaVersion < 79 && config.MEMPOOL.NETWORK === 'mainnet') {
// Clear bad data
await this.$executeQuery(`TRUNCATE accelerations`);
this.uniqueLog(logger.notice, `'accelerations' table has been truncated`);
await this.$executeQuery(`
UPDATE state
SET number = 0
WHERE name = 'last_acceleration_block'
`);
await this.updateToSchemaVersion(79);
}
}
/**

View File

@@ -6,7 +6,6 @@ import config from '../config';
import { Worker } from 'worker_threads';
import path from 'path';
import mempool from './mempool';
import { Acceleration } from './services/acceleration';
const MAX_UINT32 = Math.pow(2, 32) - 1;
@@ -334,7 +333,7 @@ class MempoolBlocks {
}
}
private processBlockTemplates(mempool: { [txid: string]: MempoolTransactionExtended }, blocks: string[][], blockWeights: number[] | null, rates: [string, number][], clusters: string[][], candidates: GbtCandidates | undefined, accelerations: { [txid: string]: Acceleration }, accelerationPool, saveResults): MempoolBlockWithTransactions[] {
private processBlockTemplates(mempool: { [txid: string]: MempoolTransactionExtended }, blocks: string[][], blockWeights: number[] | null, rates: [string, number][], clusters: string[][], candidates: GbtCandidates | undefined, accelerations, accelerationPool, saveResults): MempoolBlockWithTransactions[] {
for (const txid of Object.keys(candidates?.txs ?? mempool)) {
if (txid in mempool) {
mempool[txid].cpfpDirty = false;
@@ -397,7 +396,7 @@ class MempoolBlocks {
}
}
const isAcceleratedBy : { [txid: string]: number[] | false } = {};
const isAccelerated : { [txid: string]: boolean } = {};
const sizeLimit = (config.MEMPOOL.BLOCK_WEIGHT_UNITS / 4) * 1.2;
// update this thread's mempool with the results
@@ -428,19 +427,17 @@ class MempoolBlocks {
};
const acceleration = accelerations[txid];
if (isAcceleratedBy[txid] || (acceleration && (!accelerationPool || acceleration.pools.includes(accelerationPool)))) {
if (isAccelerated[txid] || (acceleration && (!accelerationPool || acceleration.pools.includes(accelerationPool)))) {
if (!mempoolTx.acceleration) {
mempoolTx.cpfpDirty = true;
}
mempoolTx.acceleration = true;
mempoolTx.acceleratedBy = isAcceleratedBy[txid] || acceleration?.pools;
for (const ancestor of mempoolTx.ancestors || []) {
if (!mempool[ancestor.txid].acceleration) {
mempool[ancestor.txid].cpfpDirty = true;
}
mempool[ancestor.txid].acceleration = true;
mempool[ancestor.txid].acceleratedBy = mempoolTx.acceleratedBy;
isAcceleratedBy[ancestor.txid] = mempoolTx.acceleratedBy;
isAccelerated[ancestor.txid] = true;
}
} else {
if (mempoolTx.acceleration) {

View File

@@ -24,7 +24,6 @@ class MiningRoutes {
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/difficulty-adjustments', this.$getDifficultyAdjustments)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/reward-stats/:blockCount', this.$getRewardStats)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/fees/:interval', this.$getHistoricalBlockFees)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/fees', this.$getBlockFeesTimespan)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/rewards/:interval', this.$getHistoricalBlockRewards)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/fee-rates/:interval', this.$getHistoricalBlockFeeRates)
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/sizes-weights/:interval', this.$getHistoricalBlockSizeAndWeight)
@@ -218,24 +217,6 @@ class MiningRoutes {
}
}
private async $getBlockFeesTimespan(req: Request, res: Response) {
try {
if (!parseInt(req.query.from as string, 10) || !parseInt(req.query.to as string, 10)) {
throw new Error('Invalid timestamp range');
}
if (parseInt(req.query.from as string, 10) > parseInt(req.query.to as string, 10)) {
throw new Error('from must be less than to');
}
const blockFees = await mining.$getBlockFeesTimespan(parseInt(req.query.from as string, 10), parseInt(req.query.to as string, 10));
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(blockFees);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getHistoricalBlockRewards(req: Request, res: Response) {
try {
const blockRewards = await mining.$getHistoricalBlockRewards(req.params.interval);

View File

@@ -45,22 +45,11 @@ class Mining {
*/
public async $getHistoricalBlockFees(interval: string | null = null): Promise<any> {
return await BlocksRepository.$getHistoricalBlockFees(
this.getTimeRange(interval),
this.getTimeRange(interval, 5),
Common.getSqlInterval(interval)
);
}
/**
* Get timespan block total fees
*/
public async $getBlockFeesTimespan(from: number, to: number): Promise<number> {
return await BlocksRepository.$getHistoricalBlockFees(
this.getTimeRangeFromTimespan(from, to),
null,
{from, to}
);
}
/**
* Get historical block rewards
*/
@@ -657,24 +646,6 @@ class Mining {
}
}
private getTimeRangeFromTimespan(from: number, to: number, scale = 1): number {
const timespan = to - from;
switch (true) {
case timespan > 3600 * 24 * 365 * 4: return 86400 * scale; // 24h
case timespan > 3600 * 24 * 365 * 3: return 43200 * scale; // 12h
case timespan > 3600 * 24 * 365 * 2: return 43200 * scale; // 12h
case timespan > 3600 * 24 * 365: return 28800 * scale; // 8h
case timespan > 3600 * 24 * 30 * 6: return 28800 * scale; // 8h
case timespan > 3600 * 24 * 30 * 3: return 10800 * scale; // 3h
case timespan > 3600 * 24 * 30: return 7200 * scale; // 2h
case timespan > 3600 * 24 * 7: return 1800 * scale; // 30min
case timespan > 3600 * 24 * 3: return 300 * scale; // 5min
case timespan > 3600 * 24: return 1 * scale;
default: return 1 * scale;
}
}
// Finds the oldest block in a consecutive chain back from the tip
// assumes `blocks` is sorted in ascending height order
private getOldestConsecutiveBlock(blocks: DifficultyBlock[]): DifficultyBlock {

View File

@@ -52,28 +52,6 @@ class PoolsParser {
continue;
}
// One of the two fields 'addresses' or 'regexes' must be a non-empty array
if (!pool.addresses && !pool.regexes) {
logger.err(`Mining pool ${pool.name} must have at least one of the fields 'addresses' or 'regexes'. Skipping.`);
continue;
}
pool.addresses = pool.addresses || [];
pool.regexes = pool.regexes || [];
if (pool.addresses.length === 0 && pool.regexes.length === 0) {
logger.err(`Mining pool ${pool.name} has no 'addresses' nor 'regexes' defined. Skipping.`);
continue;
}
if (pool.addresses.length === 0) {
logger.warn(`Mining pool ${pool.name} has no 'addresses' defined.`);
}
if (pool.regexes.length === 0) {
logger.warn(`Mining pool ${pool.name} has no 'regexes' defined.`);
}
const poolDB = await PoolsRepository.$getPoolByUniqueId(pool.id, false);
if (!poolDB) {
// New mining pool

View File

@@ -64,7 +64,7 @@ class StatisticsApi {
}
}
public async $create(statistics: Statistic, convertToDatetime = false): Promise<number | undefined> {
public async $create(statistics: Statistic): Promise<number | undefined> {
try {
const query = `INSERT INTO statistics(
added,
@@ -114,7 +114,7 @@ class StatisticsApi {
vsize_1800,
vsize_2000
)
VALUES (${convertToDatetime ? `FROM_UNIXTIME(${statistics.added})` : statistics.added}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
VALUES (${statistics.added}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
const params: (string | number)[] = [
@@ -456,59 +456,6 @@ class StatisticsApi {
};
});
}
public mapOptimizedStatisticToStatistic(statistic: OptimizedStatistic[]): Statistic[] {
return statistic.map((s) => {
return {
added: s.added,
unconfirmed_transactions: s.count,
tx_per_second: 0,
vbytes_per_second: s.vbytes_per_second,
mempool_byte_weight: s.mempool_byte_weight || 0,
total_fee: s.total_fee || 0,
min_fee: s.min_fee,
fee_data: '',
vsize_1: s.vsizes[0],
vsize_2: s.vsizes[1],
vsize_3: s.vsizes[2],
vsize_4: s.vsizes[3],
vsize_5: s.vsizes[4],
vsize_6: s.vsizes[5],
vsize_8: s.vsizes[6],
vsize_10: s.vsizes[7],
vsize_12: s.vsizes[8],
vsize_15: s.vsizes[9],
vsize_20: s.vsizes[10],
vsize_30: s.vsizes[11],
vsize_40: s.vsizes[12],
vsize_50: s.vsizes[13],
vsize_60: s.vsizes[14],
vsize_70: s.vsizes[15],
vsize_80: s.vsizes[16],
vsize_90: s.vsizes[17],
vsize_100: s.vsizes[18],
vsize_125: s.vsizes[19],
vsize_150: s.vsizes[20],
vsize_175: s.vsizes[21],
vsize_200: s.vsizes[22],
vsize_250: s.vsizes[23],
vsize_300: s.vsizes[24],
vsize_350: s.vsizes[25],
vsize_400: s.vsizes[26],
vsize_500: s.vsizes[27],
vsize_600: s.vsizes[28],
vsize_700: s.vsizes[29],
vsize_800: s.vsizes[30],
vsize_900: s.vsizes[31],
vsize_1000: s.vsizes[32],
vsize_1200: s.vsizes[33],
vsize_1400: s.vsizes[34],
vsize_1600: s.vsizes[35],
vsize_1800: s.vsizes[36],
vsize_2000: s.vsizes[37],
}
});
}
}
export default new StatisticsApi();

View File

@@ -820,7 +820,6 @@ class WebsocketHandler {
position: {
...mempoolTx.position,
accelerated: mempoolTx.acceleration || undefined,
acceleratedBy: mempoolTx.acceleratedBy || undefined,
}
};
if (!mempoolTx.cpfpChecked && !mempoolTx.acceleration) {
@@ -859,7 +858,6 @@ class WebsocketHandler {
txInfo.position = {
...mempoolTx.position,
accelerated: mempoolTx.acceleration || undefined,
acceleratedBy: mempoolTx.acceleratedBy || undefined,
};
if (!mempoolTx.cpfpChecked) {
calculateCpfp(mempoolTx, newMempool);
@@ -1136,7 +1134,6 @@ class WebsocketHandler {
position: {
...mempoolTx.position,
accelerated: mempoolTx.acceleration || undefined,
acceleratedBy: mempoolTx.acceleratedBy || undefined,
}
});
}
@@ -1156,7 +1153,6 @@ class WebsocketHandler {
...mempoolTx.position,
},
accelerated: mempoolTx.acceleration || undefined,
acceleratedBy: mempoolTx.acceleratedBy || undefined,
};
}
}

View File

@@ -141,8 +141,6 @@ interface IConfig {
ENABLED: boolean;
AUDIT: boolean;
AUDIT_START_HEIGHT: number;
STATISTICS: boolean;
STATISTICS_START_TIME: number | string;
SERVERS: string[];
},
MEMPOOL_SERVICES: {
@@ -300,8 +298,6 @@ const defaults: IConfig = {
'ENABLED': false,
'AUDIT': false,
'AUDIT_START_HEIGHT': 774000,
'STATISTICS': false,
'STATISTICS_START_TIME': 1481932800,
'SERVERS': [],
},
'MEMPOOL_SERVICES': {

View File

@@ -8,7 +8,6 @@ import priceUpdater from './tasks/price-updater';
import PricesRepository from './repositories/PricesRepository';
import config from './config';
import auditReplicator from './replication/AuditReplication';
import statisticsReplicator from './replication/StatisticsReplication';
import AccelerationRepository from './repositories/AccelerationRepository';
export interface CoreIndex {
@@ -189,7 +188,6 @@ class Indexer {
await blocks.$generateCPFPDatabase();
await blocks.$generateAuditStats();
await auditReplicator.$sync();
await statisticsReplicator.$sync();
await AccelerationRepository.$indexPastAccelerations();
// do not wait for classify blocks to finish
blocks.$classifyBlocks();

View File

@@ -111,7 +111,6 @@ export interface TransactionExtended extends IEsploraApi.Transaction {
vsize: number,
};
acceleration?: boolean;
acceleratedBy?: number[];
replacement?: boolean;
uid?: number;
flags?: number;
@@ -423,7 +422,6 @@ export interface Statistic {
export interface OptimizedStatistic {
added: string;
count: number;
vbytes_per_second: number;
total_fee: number;
mempool_byte_weight: number;
@@ -433,7 +431,7 @@ export interface OptimizedStatistic {
export interface TxTrackingInfo {
replacedBy?: string,
position?: { block: number, vsize: number, accelerated?: boolean, acceleratedBy?: number[] },
position?: { block: number, vsize: number, accelerated?: boolean },
cpfp?: {
ancestors?: Ancestor[],
bestDescendant?: Ancestor | null,
@@ -444,7 +442,6 @@ export interface TxTrackingInfo {
},
utxoSpent?: { [vout: number]: { vin: number, txid: string } },
accelerated?: boolean,
acceleratedBy?: number[],
confirmed?: boolean
}

View File

@@ -1,228 +0,0 @@
import DB from '../database';
import logger from '../logger';
import { $sync } from './replicator';
import config from '../config';
import { Common } from '../api/common';
import statistics from '../api/statistics/statistics-api';
interface MissingStatistics {
'24h': Set<number>;
'1w': Set<number>;
'1m': Set<number>;
'3m': Set<number>;
'6m': Set<number>;
'2y': Set<number>;
'all': Set<number>;
}
const steps = {
'24h': 60,
'1w': 300,
'1m': 1800,
'3m': 7200,
'6m': 10800,
'2y': 28800,
'all': 43200,
};
/**
* Syncs missing statistics data from trusted servers
*/
class StatisticsReplication {
inProgress: boolean = false;
public async $sync(): Promise<void> {
if (!config.REPLICATION.ENABLED || !config.REPLICATION.STATISTICS || !config.STATISTICS.ENABLED) {
// replication not enabled, or statistics not enabled
return;
}
if (this.inProgress) {
logger.info(`StatisticsReplication sync already in progress`, 'Replication');
return;
}
this.inProgress = true;
const missingStatistics = await this.$getMissingStatistics();
const missingIntervals = Object.keys(missingStatistics).filter(key => missingStatistics[key].size > 0);
const totalMissing = missingIntervals.reduce((total, key) => total + missingStatistics[key].size, 0);
if (totalMissing === 0) {
this.inProgress = false;
logger.info(`Statistics table is complete, no replication needed`, 'Replication');
return;
}
for (const interval of missingIntervals) {
logger.debug(`Missing ${missingStatistics[interval].size} statistics rows in '${interval}' timespan`, 'Replication');
}
logger.debug(`Fetching ${missingIntervals.join(', ')} statistics endpoints from trusted servers to fill ${totalMissing} rows missing in statistics`, 'Replication');
let totalSynced = 0;
let totalMissed = 0;
for (const interval of missingIntervals) {
const results = await this.$syncStatistics(interval, missingStatistics[interval]);
totalSynced += results.synced;
totalMissed += results.missed;
logger.info(`Found ${totalSynced} / ${totalSynced + totalMissed} of ${totalMissing} missing statistics rows`, 'Replication');
await Common.sleep$(3000);
}
logger.debug(`Synced ${totalSynced} statistics rows, ${totalMissed} still missing`, 'Replication');
this.inProgress = false;
}
private async $syncStatistics(interval: string, missingTimes: Set<number>): Promise<any> {
let success = false;
let synced = 0;
let missed = new Set(missingTimes);
const syncResult = await $sync(`/api/v1/statistics/${interval}`);
if (syncResult && syncResult.data?.length) {
success = true;
logger.info(`Fetched /api/v1/statistics/${interval} from ${syncResult.server}`);
for (const stat of syncResult.data) {
const time = this.roundToNearestStep(stat.added, steps[interval]);
if (missingTimes.has(time)) {
try {
await statistics.$create(statistics.mapOptimizedStatisticToStatistic([stat])[0], true);
if (missed.delete(time)) {
synced++;
}
} catch (e: any) {
logger.err(`Failed to insert statistics row at ${stat.added} (${interval}) from ${syncResult.server}. Reason: ` + (e instanceof Error ? e.message : e));
}
}
}
} else {
logger.warn(`An error occured when trying to fetch /api/v1/statistics/${interval}`);
}
return { success, synced, missed: missed.size };
}
private async $getMissingStatistics(): Promise<MissingStatistics> {
try {
const now = Math.floor(Date.now() / 1000);
const day = 60 * 60 * 24;
const startTime = this.getStartTimeFromConfig();
const missingStatistics: MissingStatistics = {
'24h': new Set<number>(),
'1w': new Set<number>(),
'1m': new Set<number>(),
'3m': new Set<number>(),
'6m': new Set<number>(),
'2y': new Set<number>(),
'all': new Set<number>()
};
const intervals = [ // [start, end, label ]
[now - day, now - 60, '24h'] , // from 24 hours ago to now = 1 minute granularity
startTime < now - day ? [now - day * 7, now - day, '1w' ] : null, // from 1 week ago to 24 hours ago = 5 minutes granularity
startTime < now - day * 7 ? [now - day * 30, now - day * 7, '1m' ] : null, // from 1 month ago to 1 week ago = 30 minutes granularity
startTime < now - day * 30 ? [now - day * 90, now - day * 30, '3m' ] : null, // from 3 months ago to 1 month ago = 2 hours granularity
startTime < now - day * 90 ? [now - day * 180, now - day * 90, '6m' ] : null, // from 6 months ago to 3 months ago = 3 hours granularity
startTime < now - day * 180 ? [now - day * 365 * 2, now - day * 180, '2y' ] : null, // from 2 years ago to 6 months ago = 8 hours granularity
startTime < now - day * 365 * 2 ? [startTime, now - day * 365 * 2, 'all'] : null, // from start of statistics to 2 years ago = 12 hours granularity
];
for (const interval of intervals) {
if (!interval) {
continue;
}
missingStatistics[interval[2] as string] = await this.$getMissingStatisticsInterval(interval, startTime);
}
return missingStatistics;
} catch (e: any) {
logger.err(`Cannot fetch missing statistics times from db. Reason: ` + (e instanceof Error ? e.message : e));
throw e;
}
}
private async $getMissingStatisticsInterval(interval: any, startTime: number): Promise<Set<number>> {
try {
const start = interval[0];
const end = interval[1];
const step = steps[interval[2]];
const [rows]: any[] = await DB.query(`
SELECT UNIX_TIMESTAMP(added) as added
FROM statistics
WHERE added >= FROM_UNIXTIME(?) AND added <= FROM_UNIXTIME(?)
GROUP BY UNIX_TIMESTAMP(added) DIV ${step} ORDER BY statistics.added DESC
`, [start, end]);
const startingTime = Math.max(startTime, start) - Math.max(startTime, start) % step;
const timeSteps: number[] = [];
for (let time = startingTime; time < end; time += step) {
timeSteps.push(time);
}
if (timeSteps.length === 0) {
return new Set<number>();
}
const roundedTimesAlreadyHere = new Set(rows.map(row => this.roundToNearestStep(row.added, step)));
const missingTimes = new Set(timeSteps.filter(time => !roundedTimesAlreadyHere.has(time)));
// Don't bother fetching if very few rows are missing
if (missingTimes.size < timeSteps.length * 0.005) {
return new Set();
}
return missingTimes;
} catch (e: any) {
logger.err(`Cannot fetch missing statistics times from db. Reason: ` + (e instanceof Error ? e.message : e));
throw e;
}
}
private roundToNearestStep(time: number, step: number): number {
const remainder = time % step;
if (remainder < step / 2) {
return time - remainder;
} else {
return time + (step - remainder);
}
}
private getStartTimeFromConfig(): number {
const now = Math.floor(Date.now() / 1000);
const day = 60 * 60 * 24;
let startTime: number;
if (typeof(config.REPLICATION.STATISTICS_START_TIME) === 'string' && ['24h', '1w', '1m', '3m', '6m', '2y', 'all'].includes(config.REPLICATION.STATISTICS_START_TIME)) {
if (config.REPLICATION.STATISTICS_START_TIME === 'all') {
startTime = 1481932800;
} else if (config.REPLICATION.STATISTICS_START_TIME === '2y') {
startTime = now - day * 365 * 2;
} else if (config.REPLICATION.STATISTICS_START_TIME === '6m') {
startTime = now - day * 180;
} else if (config.REPLICATION.STATISTICS_START_TIME === '3m') {
startTime = now - day * 90;
} else if (config.REPLICATION.STATISTICS_START_TIME === '1m') {
startTime = now - day * 30;
} else if (config.REPLICATION.STATISTICS_START_TIME === '1w') {
startTime = now - day * 7;
} else {
startTime = now - day;
}
} else {
startTime = Math.max(config.REPLICATION.STATISTICS_START_TIME as number || 1481932800, 1481932800);
}
return startTime;
}
}
export default new StatisticsReplication();

View File

@@ -244,8 +244,6 @@ class AccelerationRepository {
let count = 0;
try {
while (!done) {
// don't DDoS the services backend
Common.sleep$(500 + (Math.random() * 1000));
const accelerations = await accelerationApi.$fetchAccelerationHistory(page);
page++;
if (!accelerations?.length) {
@@ -311,7 +309,7 @@ class AccelerationRepository {
pools: acc.pools.map(pool => pool.pool_unique_id),
}))
for (const acc of accelerations) {
if (blockTxs[acc.txid] && acc.pools.some(pool => pool.pool_unique_id === block.extras.pool.id)) {
if (blockTxs[acc.txid]) {
const tx = blockTxs[acc.txid];
const accelerationInfo = accelerationCosts.getAccelerationInfo(tx, boostRate, transactions);
accelerationInfo.cost = Math.max(0, Math.min(acc.feeDelta, accelerationInfo.cost));

View File

@@ -663,7 +663,7 @@ class BlocksRepository {
/**
* Get the historical averaged block fees
*/
public async $getHistoricalBlockFees(div: number, interval: string | null, timespan?: {from: number, to: number}): Promise<any> {
public async $getHistoricalBlockFees(div: number, interval: string | null): Promise<any> {
try {
let query = `SELECT
CAST(AVG(blocks.height) as INT) as avgHeight,
@@ -677,8 +677,6 @@ class BlocksRepository {
if (interval !== null) {
query += ` WHERE blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
} else if (timespan) {
query += ` WHERE blockTimestamp BETWEEN FROM_UNIXTIME(${timespan.from}) AND FROM_UNIXTIME(${timespan.to})`;
}
query += ` GROUP BY UNIX_TIMESTAMP(blockTimestamp) DIV ${div}`;

View File

@@ -1,3 +0,0 @@
I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of January 25, 2022.
Signed: bitcoinmechanic

View File

@@ -1,4 +1,4 @@
FROM node:20.13.1-buster-slim AS builder
FROM node:20.12.0-buster-slim AS builder
ARG commitHash
ENV MEMPOOL_COMMIT_HASH=${commitHash}
@@ -24,7 +24,7 @@ RUN npm install --omit=dev --omit=optional
WORKDIR /build
RUN npm run package
FROM node:20.13.1-buster-slim
FROM node:20.12.0-buster-slim
WORKDIR /backend

View File

@@ -137,8 +137,6 @@
"ENABLED": __REPLICATION_ENABLED__,
"AUDIT": __REPLICATION_AUDIT__,
"AUDIT_START_HEIGHT": __REPLICATION_AUDIT_START_HEIGHT__,
"STATISTICS": __REPLICATION_STATISTICS__,
"STATISTICS_START_TIME": __REPLICATION_STATISTICS_START_TIME__,
"SERVERS": __REPLICATION_SERVERS__
},
"MEMPOOL_SERVICES": {

View File

@@ -138,8 +138,6 @@ __MAXMIND_GEOIP2_ISP__=${MAXMIND_GEOIP2_ISP:=""}
__REPLICATION_ENABLED__=${REPLICATION_ENABLED:=false}
__REPLICATION_AUDIT__=${REPLICATION_AUDIT:=false}
__REPLICATION_AUDIT_START_HEIGHT__=${REPLICATION_AUDIT_START_HEIGHT:=774000}
__REPLICATION_STATISTICS__=${REPLICATION_STATISTICS:=false}
__REPLICATION_STATISTICS_START_TIME__=${REPLICATION_STATISTICS_START_TIME:=1481932800}
__REPLICATION_SERVERS__=${REPLICATION_SERVERS:=[]}
# MEMPOOL_SERVICES
@@ -286,8 +284,6 @@ sed -i "s!__MAXMIND_GEOIP2_ISP__!${__MAXMIND_GEOIP2_ISP__}!g" mempool-config.jso
sed -i "s!__REPLICATION_ENABLED__!${__REPLICATION_ENABLED__}!g" mempool-config.json
sed -i "s!__REPLICATION_AUDIT__!${__REPLICATION_AUDIT__}!g" mempool-config.json
sed -i "s!__REPLICATION_AUDIT_START_HEIGHT__!${__REPLICATION_AUDIT_START_HEIGHT__}!g" mempool-config.json
sed -i "s!__REPLICATION_STATISTICS__!${__REPLICATION_STATISTICS__}!g" mempool-config.json
sed -i "s!__REPLICATION_STATISTICS_START_TIME__!${__REPLICATION_STATISTICS_START_TIME__}!g" mempool-config.json
sed -i "s!__REPLICATION_SERVERS__!${__REPLICATION_SERVERS__}!g" mempool-config.json
# MEMPOOL_SERVICES

View File

@@ -1,4 +1,4 @@
FROM node:20.13.1-buster-slim AS builder
FROM node:20.12.0-buster-slim AS builder
ARG commitHash
ENV DOCKER_COMMIT_HASH=${commitHash}

View File

@@ -34,7 +34,6 @@
"prefer-rest-params": 1,
"quotes": [1, "single", { "allowTemplateLiterals": true }],
"semi": 1,
"curly": [1, "all"],
"eqeqeq": 1
}
}

View File

@@ -45,7 +45,6 @@ describe('Liquid', () => {
it('loads a specific block page', () => {
cy.visit(`${basePath}/block/7e1369a23a5ab861e7bdede2aadcccae4ea873ffd9caf11c7c5541eb5bcdff54`);
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone();
});

View File

@@ -46,8 +46,7 @@ describe('Liquid Testnet', () => {
});
it('loads a specific block page', () => {
cy.visit(`${basePath}/block/fb4cbcbff3993ca4bf8caf657d55a23db5ed4ab1cfa33c489303c2e04e1c38e0`);
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.visit(`${basePath}/block/7e1369a23a5ab861e7bdede2aadcccae4ea873ffd9caf11c7c5541eb5bcdff54`);
cy.waitForSkeletonGone();
});

View File

@@ -103,7 +103,6 @@ describe('Mainnet', () => {
it('check op_return tx tooltip', () => {
cy.visit('/block/00000000000000000003c5f542bed265319c6cf64238cf1f1bb9bca3ebf686d2');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone();
cy.get('tbody > :nth-child(2) > :nth-child(1) > a').first().trigger('onmouseover');
cy.get('tbody > :nth-child(2) > :nth-child(1) > a').first().trigger('mouseenter');
@@ -112,10 +111,9 @@ describe('Mainnet', () => {
it('check op_return coinbase tooltip', () => {
cy.visit('/block/00000000000000000003c5f542bed265319c6cf64238cf1f1bb9bca3ebf686d2');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone();
cy.get('tbody > :nth-child(2) > :nth-child(1) > a').first().trigger('onmouseover');
cy.get('tbody > :nth-child(2) > :nth-child(1) > a').first().trigger('mouseenter');
cy.get('div > a > .badge').first().trigger('onmouseover');
cy.get('div > a > .badge').first().trigger('mouseenter');
cy.get('.tooltip-inner').should('be.visible');
});
@@ -285,7 +283,6 @@ describe('Mainnet', () => {
it('loads genesis block and keypress arrow right', () => {
cy.viewport('macbook-16');
cy.visit('/block/0');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone();
cy.waitForPageIdle();
@@ -298,7 +295,6 @@ describe('Mainnet', () => {
it('loads genesis block and keypress arrow left', () => {
cy.viewport('macbook-16');
cy.visit('/block/0');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone();
cy.waitForPageIdle();
@@ -327,7 +323,6 @@ describe('Mainnet', () => {
it('loads genesis block and click on the arrow left', () => {
cy.viewport('macbook-16');
cy.visit('/block/0');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone();
cy.waitForPageIdle();
cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible');
@@ -344,7 +339,7 @@ describe('Mainnet', () => {
cy.visit('/');
cy.waitForSkeletonGone();
cy.changeNetwork('testnet4');
cy.changeNetwork('testnet');
cy.changeNetwork('signet');
cy.changeNetwork('mainnet');
});
@@ -444,7 +439,6 @@ describe('Mainnet', () => {
describe('blocks', () => {
it('shows empty blocks properly', () => {
cy.visit('/block/0000000000000000000bd14f744ef2e006e61c32214670de7eb891a5732ee775');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone();
cy.waitForPageIdle();
cy.get('h2').invoke('text').should('equal', '1 transaction');
@@ -452,7 +446,6 @@ describe('Mainnet', () => {
it('expands and collapses the block details', () => {
cy.visit('/block/0');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone();
cy.waitForPageIdle();
cy.get('.btn.btn-outline-info').click().then(() => {
@@ -465,7 +458,6 @@ describe('Mainnet', () => {
});
it('shows blocks with no pagination', () => {
cy.visit('/block/00000000000000000001ba40caf1ad4cec0ceb77692662315c151953bfd7c4c4');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone();
cy.waitForPageIdle();
cy.get('.block-tx-title h2').invoke('text').should('equal', '19 transactions');
@@ -475,7 +467,6 @@ describe('Mainnet', () => {
it('supports pagination on the block screen', () => {
// 41 txs
cy.visit('/block/00000000000000000009f9b7b0f63ad50053ad12ec3b7f5ca951332f134f83d8');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone();
cy.get('.pagination-container a').invoke('text').then((text1) => {
cy.get('.active + li').first().click().then(() => {
@@ -491,7 +482,6 @@ describe('Mainnet', () => {
it('shows blocks pagination with 5 pages (desktop)', () => {
cy.viewport(760, 800);
cy.visit('/block/000000000000000000049281946d26fcba7d99fdabc1feac524bc3a7003d69b3').then(() => {
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone();
cy.waitForPageIdle();
});
@@ -503,7 +493,6 @@ describe('Mainnet', () => {
it('shows blocks pagination with 3 pages (mobile)', () => {
cy.viewport(669, 800);
cy.visit('/block/000000000000000000049281946d26fcba7d99fdabc1feac524bc3a7003d69b3').then(() => {
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone();
cy.waitForPageIdle();
});

View File

@@ -95,14 +95,12 @@ describe('Signet', () => {
describe('blocks', () => {
it('shows empty blocks properly', () => {
cy.visit('/signet/block/00000133d54e4589f6436703b067ec23209e0a21b8a9b12f57d0592fd85f7a42');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone();
cy.get('h2').invoke('text').should('equal', '1 transaction');
});
it('expands and collapses the block details', () => {
cy.visit('/signet/block/0');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone();
cy.get('.btn.btn-outline-info').click().then(() => {
cy.get('#details').should('be.visible');
@@ -115,7 +113,6 @@ describe('Signet', () => {
it('shows blocks with no pagination', () => {
cy.visit('/signet/block/00000078f920a96a69089877b934ce7fd009ab55e3170920a021262cb258e7cc');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone();
cy.get('h2').invoke('text').should('equal', '13 transactions');
cy.get('ul.pagination').first().children().should('have.length', 5);
@@ -124,7 +121,6 @@ describe('Signet', () => {
it('supports pagination on the block screen', () => {
// 43 txs
cy.visit('/signet/block/00000094bd52f73bdbfc4bece3a94c21fec2dc968cd54210496e69e4059d66a6');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.waitForSkeletonGone();
cy.get('.header-bg.box > a').invoke('text').then((text1) => {
cy.get('.active + li').first().click().then(() => {

View File

@@ -2,7 +2,7 @@ import { emitMempoolInfo } from '../../support/websocket';
const baseModule = Cypress.env('BASE_MODULE');
describe('Testnet4', () => {
describe('Testnet', () => {
beforeEach(() => {
cy.intercept('/api/block-height/*').as('block-height');
cy.intercept('/api/block/*').as('block');
@@ -13,7 +13,7 @@ describe('Testnet4', () => {
if (baseModule === 'mempool') {
it('loads the dashboard', () => {
cy.visit('/testnet4');
cy.visit('/testnet');
cy.waitForSkeletonGone();
});
@@ -25,7 +25,7 @@ describe('Testnet4', () => {
it.skip('loads the dashboard with the skeleton blocks', () => {
cy.mockMempoolSocket();
cy.visit('/testnet4');
cy.visit('/testnet');
cy.get(':nth-child(1) > #bitcoin-block-0').should('be.visible');
cy.get(':nth-child(2) > #bitcoin-block-0').should('be.visible');
cy.get(':nth-child(3) > #bitcoin-block-0').should('be.visible');
@@ -45,7 +45,7 @@ describe('Testnet4', () => {
});
it('loads the pools screen', () => {
cy.visit('/testnet4');
cy.visit('/testnet');
cy.waitForSkeletonGone();
cy.get('#btn-pools').click().then(() => {
cy.wait(1000);
@@ -53,7 +53,7 @@ describe('Testnet4', () => {
});
it('loads the graphs screen', () => {
cy.visit('/testnet4');
cy.visit('/testnet');
cy.waitForSkeletonGone();
cy.get('#btn-graphs').click().then(() => {
cy.wait(1000);
@@ -63,7 +63,7 @@ describe('Testnet4', () => {
describe('tv mode', () => {
it('loads the tv screen - desktop', () => {
cy.viewport('macbook-16');
cy.visit('/testnet4/graphs');
cy.visit('/testnet/graphs');
cy.waitForSkeletonGone();
cy.get('#btn-tv').click().then(() => {
cy.wait(1000);
@@ -73,7 +73,7 @@ describe('Testnet4', () => {
});
it('loads the tv screen - mobile', () => {
cy.visit('/testnet4/graphs');
cy.visit('/testnet/graphs');
cy.waitForSkeletonGone();
cy.get('#btn-tv').click().then(() => {
cy.viewport('iphone-6');
@@ -85,7 +85,7 @@ describe('Testnet4', () => {
it('loads the api screen', () => {
cy.visit('/testnet4');
cy.visit('/testnet');
cy.waitForSkeletonGone();
cy.get('#btn-docs').click().then(() => {
cy.wait(1000);
@@ -94,15 +94,13 @@ describe('Testnet4', () => {
describe('blocks', () => {
it('shows empty blocks properly', () => {
cy.visit('/testnet4/block/0');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.visit('/testnet/block/0');
cy.waitForSkeletonGone();
cy.get('h2').invoke('text').should('equal', '1 transaction');
});
it('expands and collapses the block details', () => {
cy.visit('/testnet4/block/0');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.visit('/testnet/block/0');
cy.waitForSkeletonGone();
cy.get('.btn.btn-outline-info').click().then(() => {
cy.get('#details').should('be.visible');
@@ -114,17 +112,15 @@ describe('Testnet4', () => {
});
it('shows blocks with no pagination', () => {
cy.visit('/testnet4/block/000000000066e8b6cc78a93f8989587f5819624bae2eb1c05f535cadded19f99');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.visit('/testnet/block/000000000000002f8ce27716e74ecc7ad9f7b5101fed12d09e28bb721b9460ea');
cy.waitForSkeletonGone();
cy.get('h2').invoke('text').should('equal', '18 transactions');
cy.get('h2').invoke('text').should('equal', '11 transactions');
cy.get('ul.pagination').first().children().should('have.length', 5);
});
it('supports pagination on the block screen', () => {
// 48 txs
cy.visit('/testnet4/block/000000000000006982d53f8273bdff21dafc380c292eabc669b5ab6d732311c3');
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
cy.visit('/testnet/block/000000000000002ca3878ebd98b313a1c2d531f2e70a6575d232ca7564dea7a9');
cy.waitForSkeletonGone();
cy.get('.header-bg.box > a').invoke('text').then((text1) => {
cy.get('.active + li').first().click().then(() => {

View File

@@ -72,7 +72,7 @@ Cypress.Commands.add('mockMempoolSocket', () => {
mockWebSocket();
});
Cypress.Commands.add('changeNetwork', (network: "testnet" | "testnet4" | "signet" | "liquid" | "mainnet") => {
Cypress.Commands.add('changeNetwork', (network: "testnet" | "signet" | "liquid" | "mainnet") => {
cy.get('.dropdown-toggle').click().then(() => {
cy.get(`a.${network}`).click().then(() => {
cy.waitForPageIdle();

View File

@@ -5,6 +5,6 @@ declare namespace Cypress {
waitForSkeletonGone(): Chainable<any>
waitForPageIdle(): Chainable<any>
mockMempoolSocket(): Chainable<any>
changeNetwork(network: "testnet"|"testnet4"|"signet"|"liquid"|"mainnet"): Chainable<any>
changeNetwork(network: "testnet"|"signet"|"liquid"|"mainnet"): Chainable<any>
}
}

View File

@@ -11,7 +11,6 @@ let configContent = {};
let gitCommitHash = '';
let packetJsonVersion = '';
let customConfig;
let customConfigContent;
try {
const rawConfig = fs.readFileSync(CONFIG_FILE_NAME);
@@ -26,16 +25,11 @@ try {
}
if (configContent && configContent.CUSTOMIZATION) {
try {
customConfig = readConfig(configContent.CUSTOMIZATION);
customConfigContent = JSON.parse(customConfig);
} catch (e) {
console.log(`failed to load customization config from ${configContent.CUSTOMIZATION}`);
}
customConfig = readConfig(configContent.CUSTOMIZATION);
}
const baseModuleName = configContent.BASE_MODULE || 'mempool';
const customBuildName = (customConfigContent && customConfigContent.enterprise) ? ('.' + customConfigContent.enterprise) : '';
const customBuildName = (customConfig && configContent.enterprise) ? ('.' + configContent.enterprise) : '';
const indexFilePath = 'src/index.' + baseModuleName + customBuildName + '.html';
try {

View File

@@ -1,6 +1,5 @@
{
"TESTNET_ENABLED": false,
"TESTNET4_ENABLED": false,
"SIGNET_ENABLED": false,
"LIQUID_ENABLED": false,
"LIQUID_TESTNET_ENABLED": false,

View File

@@ -32,11 +32,12 @@
"bootstrap": "~4.6.2",
"browserify": "^17.0.0",
"clipboard": "^2.0.11",
"cypress": "^13.9.0",
"domino": "^2.1.6",
"echarts": "~5.5.0",
"esbuild": "^0.21.1",
"lightweight-charts": "~3.8.0",
"ngx-echarts": "~17.2.0",
"ngx-echarts": "~17.1.0",
"ngx-infinite-scroll": "^17.0.0",
"qrcode": "1.5.1",
"rxjs": "~7.8.1",
@@ -62,7 +63,7 @@
"optionalDependencies": {
"@cypress/schematic": "^2.5.0",
"@types/cypress": "^1.1.3",
"cypress": "^13.10.0",
"cypress": "^13.9.0",
"cypress-fail-on-console-error": "~5.1.0",
"cypress-wait-until": "^2.0.1",
"mock-socket": "~9.3.1",
@@ -8028,9 +8029,9 @@
"peer": true
},
"node_modules/cypress": {
"version": "13.10.0",
"resolved": "https://registry.npmjs.org/cypress/-/cypress-13.10.0.tgz",
"integrity": "sha512-tOhwRlurVOQbMduX+KonoMeQILs2cwR3yHGGENoFvvSoLUBHmJ8b9/n21gFSDqjlOJ+SRVcwuh+fG/JDsHsT6Q==",
"version": "13.9.0",
"resolved": "https://registry.npmjs.org/cypress/-/cypress-13.9.0.tgz",
"integrity": "sha512-atNjmYfHsvTuCaxTxLZr9xGoHz53LLui3266WWxXJHY7+N6OdwJdg/feEa3T+buez9dmUXHT1izCOklqG82uCQ==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
@@ -13289,9 +13290,9 @@
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="
},
"node_modules/ngx-echarts": {
"version": "17.2.0",
"resolved": "https://registry.npmjs.org/ngx-echarts/-/ngx-echarts-17.2.0.tgz",
"integrity": "sha512-i3XDE9d53zmJH4bp8RQ/271oPlhBkczO1M3VtWk8nCXdxQq9qx8UckjWEQ7oV1AbSDLGK5sRiFu5EaY5hvdWPA==",
"version": "17.1.0",
"resolved": "https://registry.npmjs.org/ngx-echarts/-/ngx-echarts-17.1.0.tgz",
"integrity": "sha512-DSNF/aKmJSxJWb9UwPUgNtY8Ma9SmViDBRacvAwpakc/5mJerunxndDgoBQkYk5JFKAjXX6bp4ZWLRKL3/5AGA==",
"dependencies": {
"tslib": "^2.3.0"
},
@@ -24111,9 +24112,9 @@
"peer": true
},
"cypress": {
"version": "13.10.0",
"resolved": "https://registry.npmjs.org/cypress/-/cypress-13.10.0.tgz",
"integrity": "sha512-tOhwRlurVOQbMduX+KonoMeQILs2cwR3yHGGENoFvvSoLUBHmJ8b9/n21gFSDqjlOJ+SRVcwuh+fG/JDsHsT6Q==",
"version": "13.9.0",
"resolved": "https://registry.npmjs.org/cypress/-/cypress-13.9.0.tgz",
"integrity": "sha512-atNjmYfHsvTuCaxTxLZr9xGoHz53LLui3266WWxXJHY7+N6OdwJdg/feEa3T+buez9dmUXHT1izCOklqG82uCQ==",
"optional": true,
"requires": {
"@cypress/request": "^3.0.0",
@@ -28068,9 +28069,9 @@
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="
},
"ngx-echarts": {
"version": "17.2.0",
"resolved": "https://registry.npmjs.org/ngx-echarts/-/ngx-echarts-17.2.0.tgz",
"integrity": "sha512-i3XDE9d53zmJH4bp8RQ/271oPlhBkczO1M3VtWk8nCXdxQq9qx8UckjWEQ7oV1AbSDLGK5sRiFu5EaY5hvdWPA==",
"version": "17.1.0",
"resolved": "https://registry.npmjs.org/ngx-echarts/-/ngx-echarts-17.1.0.tgz",
"integrity": "sha512-DSNF/aKmJSxJWb9UwPUgNtY8Ma9SmViDBRacvAwpakc/5mJerunxndDgoBQkYk5JFKAjXX6bp4ZWLRKL3/5AGA==",
"requires": {
"tslib": "^2.3.0"
}

View File

@@ -50,16 +50,16 @@
"dev:ssr": "npm run generate-config && ng run mempool:serve-ssr",
"serve:ssr": "npm run generate-config && node server.run.js",
"build:ssr": "npm run build && ng run mempool:server:production && ./node_modules/typescript/bin/tsc server.run.ts",
"config:defaults:mempool": "node update-config.js TESTNET_ENABLED=true TESTNET4_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true LIQUID_TESTNET_ENABLED=true ITEMS_PER_PAGE=25 BASE_MODULE=mempool BLOCK_WEIGHT_UNITS=4000000 && npm run generate-config",
"config:defaults:liquid": "node update-config.js TESTNET_ENABLED=true TESTNET4_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true LIQUID_TESTNET_ENABLED=true ITEMS_PER_PAGE=25 BASE_MODULE=liquid BLOCK_WEIGHT_UNITS=300000 && npm run generate-config",
"config:defaults:mempool": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true LIQUID_TESTNET_ENABLED=true ITEMS_PER_PAGE=25 BASE_MODULE=mempool BLOCK_WEIGHT_UNITS=4000000 && npm run generate-config",
"config:defaults:liquid": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true LIQUID_TESTNET_ENABLED=true ITEMS_PER_PAGE=25 BASE_MODULE=liquid BLOCK_WEIGHT_UNITS=300000 && npm run generate-config",
"prerender": "npm run ng -- run mempool:prerender",
"cypress:open": "cypress open",
"cypress:run": "cypress run",
"cypress:run:record": "cypress run --record",
"cypress:open:ci": "node update-config.js TESTNET_ENABLED=true TESTNET4_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-prod 4200 cypress:open",
"cypress:run:ci": "node update-config.js TESTNET_ENABLED=true TESTNET4_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-prod 4200 cypress:run:record",
"cypress:open:ci:staging": "node update-config.js TESTNET_ENABLED=true TESTNET4_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-staging 4200 cypress:open",
"cypress:run:ci:staging": "node update-config.js TESTNET_ENABLED=true TESTNET4_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-staging 4200 cypress:run:record"
"cypress:open:ci": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-prod 4200 cypress:open",
"cypress:run:ci": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-prod 4200 cypress:run:record",
"cypress:open:ci:staging": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-staging 4200 cypress:open",
"cypress:run:ci:staging": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-staging 4200 cypress:run:record"
},
"dependencies": {
"@angular-devkit/build-angular": "^17.3.1",
@@ -88,7 +88,7 @@
"domino": "^2.1.6",
"echarts": "~5.5.0",
"lightweight-charts": "~3.8.0",
"ngx-echarts": "~17.2.0",
"ngx-echarts": "~17.1.0",
"ngx-infinite-scroll": "^17.0.0",
"qrcode": "1.5.1",
"rxjs": "~7.8.1",
@@ -115,7 +115,7 @@
"optionalDependencies": {
"@cypress/schematic": "^2.5.0",
"@types/cypress": "^1.1.3",
"cypress": "^13.10.0",
"cypress": "^13.9.0",
"cypress-fail-on-console-error": "~5.1.0",
"cypress-wait-until": "^2.0.1",
"mock-socket": "~9.3.1",

View File

@@ -24,7 +24,7 @@ PROXY_CONFIG = [
'/api/**', '!/api/v1/ws',
'!/liquid', '!/liquid/**', '!/liquid/',
'!/liquidtestnet', '!/liquidtestnet/**', '!/liquidtestnet/',
'/testnet/api/**', '/signet/api/**', '/testnet4/api/**'
'/testnet/api/**', '/signet/api/**'
],
target: "https://mempool.space",
ws: true,

View File

@@ -12,7 +12,6 @@ import { PriceService } from './services/price.service';
import { EnterpriseService } from './services/enterprise.service';
import { WebsocketService } from './services/websocket.service';
import { AudioService } from './services/audio.service';
import { PreloadService } from './services/preload.service';
import { SeoService } from './services/seo.service';
import { OpenGraphService } from './services/opengraph.service';
import { ZoneService } from './services/zone-shim.service';
@@ -47,7 +46,6 @@ const providers = [
CapAddressPipe,
AppPreloadingStrategy,
ServicesApiServices,
PreloadService,
{ provide: HTTP_INTERCEPTORS, useClass: HttpCacheInterceptor, multi: true },
{ provide: ZONE_SERVICE, useClass: ZoneService },
];

View File

@@ -189,7 +189,7 @@
<ng-container>
<ng-template ngFor let-sponsor [ngForOf]="profiles.whales">
<a [href]="'https://twitter.com/' + sponsor.username" target="_blank" rel="sponsored" [title]="sponsor.username">
<img class="image" [src]="'/api/v1/services/account/images/' + sponsor.username + '/md5=' + sponsor.imageMd5" onError="this.src = '/resources/profile/unknown.svg'; this.className = 'image unknown'"/>
<img class="image" [src]="'/api/v1/services/account/images/' + sponsor.username + '?md5=' + sponsor.imageMd5" onError="this.src = '/resources/profile/unknown.svg'; this.className = 'image unknown'"/>
</a>
</ng-template>
</ng-container>
@@ -201,7 +201,7 @@
<div class="wrapper">
<ng-template ngFor let-sponsor [ngForOf]="profiles.chads">
<a [href]="'https://twitter.com/' + sponsor.username" target="_blank" rel="sponsored" [title]="sponsor.username">
<img class="image" [src]="'/api/v1/services/account/images/' + sponsor.username + '/md5=' + sponsor.imageMd5" onError="this.src = '/resources/profile/unknown.svg'; this.className = 'image unknown'"/>
<img class="image" [src]="'/api/v1/services/account/images/' + sponsor.username + '?md5=' + sponsor.imageMd5" onError="this.src = '/resources/profile/unknown.svg'; this.className = 'image unknown'"/>
</a>
</ng-template>
</div>
@@ -343,8 +343,8 @@
<a href="https://opencrypto.org/" title="Coppa - Crypto Open Patent Alliance">
<img class="copa" src="/resources/profile/copa.png" />
</a>
<a href="https://bitcoin.gob.sv" title="Oficina Nacional del Bitcoin">
<img class="sv" src="/resources/profile/onbtc-full.svg" />
<a href="https://bisq.network/" title="Bisq Network">
<img class="bisq" src="/resources/profile/bisq.svg" />
</a>
</div>
</div>

View File

@@ -129,9 +129,8 @@
position: relative;
width: 300px;
}
.sv {
height: 85px;
width: auto;
.bisq {
top: 3px;
position: relative;
}
}

View File

@@ -10,7 +10,7 @@
</div>
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="daysAvailable">
<div class="btn-group btn-group-toggle" name="radioBasic">
<div class="btn-group btn-group-toggle" name="radioBasic" [class]="{'disabled': isLoading}">
<label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '24h'">
<input type="radio" [value]="'24h'" fragment="24h" [routerLink]="['/graphs/acceleration/fees' | relativeUrl]" formControlName="dateSpan"> 24H
</label>
@@ -46,7 +46,7 @@
</div>
<div [class.chart]="!widget" [class.chart-widget]="widget" *browserOnly [style]="{ height: widget ? ((height + 20) + 'px') : null}" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
(chartInit)="onChartInit($event)" [style]="{opacity: isLoading ? 0.5 : 1}">
(chartInit)="onChartInit($event)">
</div>
<div class="text-center loadingGraphs" *ngIf="!stateService.isBrowser || isLoading">
<div class="spinner-border text-light"></div>

View File

@@ -65,3 +65,8 @@ h5 {
font-size: 1rem;
color: var(--title-fg);
}
.disabled {
pointer-events: none;
opacity: 0.5;
}

View File

@@ -1,41 +0,0 @@
<table>
<tbody>
<tr>
<td class="td-width field-label" i18n="transaction.accelerated-to-feerate|Accelerated to feerate">Accelerated to</td>
<td class="field-value">
<div class="effective-fee-container">
@if (accelerationInfo?.acceleratedFeeRate && (!tx.effectiveFeePerVsize || accelerationInfo.acceleratedFeeRate >= tx.effectiveFeePerVsize)) {
<app-fee-rate [fee]="accelerationInfo.acceleratedFeeRate"></app-fee-rate>
} @else {
<app-fee-rate [fee]="tx.effectiveFeePerVsize"></app-fee-rate>
}
</div>
</td>
<td class="pie-chart" rowspan="2">
<div class="chart-container">
@if (tx && (tx.acceleratedBy || accelerationInfo) && miningStats) {
<div
echarts
*browserOnly
class="chart"
[initOpts]="chartInitOptions"
[options]="chartOptions"
style="height: 72px; width: 72px;"
(chartInit)="onChartInit($event)"
></div>
} @else {
<div class="chart-loading">
<div class="spinner-border text-light"></div>
</div>
}
</div>
</td>
</tr>
<tr>
<td class="td-width field-label" i18n="transaction.accelerated-by-hashrate|Accelerated to hashrate">Accelerated by</td>
<td class="field-value" *ngIf="acceleratedByPercentage">
{{ acceleratedByPercentage }} <span class="symbol hashrate-label">of hashrate</span>
</td>
</tr>
</tbody>
</table>

View File

@@ -1,50 +0,0 @@
.td-width {
width: 150px;
min-width: 150px;
@media (max-width: 768px) {
width: 175px;
min-width: 175px;
}
}
.field-label {
@media (max-width: 849px) {
text-align: left;
}
@media (max-width: 649px) {
width: auto;
min-width: auto;
}
}
.field-value {
@media (max-width: 849px) {
width: 100%;
}
.hashrate-label {
@media (max-width: 420px) {
display: none;
}
}
}
.pie-chart {
width: 100%;
vertical-align: middle;
text-align: center;
.chart-container {
width: 72px;
height: 100%;
margin-left: auto;
}
@media (max-width: 850px) {
width: 150px;
}
@media (max-width: 420px) {
padding-left: 0;
}
}

View File

@@ -1,128 +0,0 @@
import { Component, ChangeDetectionStrategy, Input, OnChanges, SimpleChanges } from '@angular/core';
import { Transaction } from '../../../interfaces/electrs.interface';
import { Acceleration, SinglePoolStats } from '../../../interfaces/node-api.interface';
import { EChartsOption, PieSeriesOption } from '../../../graphs/echarts';
import { MiningStats } from '../../../services/mining.service';
@Component({
selector: 'app-active-acceleration-box',
templateUrl: './active-acceleration-box.component.html',
styleUrls: ['./active-acceleration-box.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ActiveAccelerationBox implements OnChanges {
@Input() tx: Transaction;
@Input() accelerationInfo: Acceleration;
@Input() miningStats: MiningStats;
acceleratedByPercentage: string = '';
chartOptions: EChartsOption = {};
chartInitOptions = {
renderer: 'svg',
};
timespan = '';
chartInstance: any = undefined;
constructor() {}
ngOnChanges(changes: SimpleChanges): void {
if (this.tx && (this.tx.acceleratedBy || this.accelerationInfo) && this.miningStats) {
this.prepareChartOptions();
}
}
getChartData() {
const data: object[] = [];
const pools: { [id: number]: SinglePoolStats } = {};
for (const pool of this.miningStats.pools) {
pools[pool.poolUniqueId] = pool;
}
const getDataItem = (value, color, tooltip) => ({
value,
itemStyle: {
color,
borderColor: 'rgba(0,0,0,0)',
borderWidth: 1,
},
avoidLabelOverlap: false,
label: {
show: false,
},
labelLine: {
show: false
},
emphasis: {
disabled: true,
},
tooltip: {
show: true,
backgroundColor: 'rgba(17, 19, 31, 1)',
borderRadius: 4,
shadowColor: 'rgba(0, 0, 0, 0.5)',
textStyle: {
color: 'var(--tooltip-grey)',
},
borderColor: '#000',
formatter: () => {
return tooltip;
}
}
});
let totalAcceleratedHashrate = 0;
for (const poolId of (this.accelerationInfo?.pools || this.tx.acceleratedBy || [])) {
const pool = pools[poolId];
if (!pool) {
continue;
}
totalAcceleratedHashrate += parseFloat(pool.lastEstimatedHashrate);
}
this.acceleratedByPercentage = ((totalAcceleratedHashrate / parseFloat(this.miningStats.lastEstimatedHashrate)) * 100).toFixed(1) + '%';
data.push(getDataItem(
totalAcceleratedHashrate,
'var(--mainnet-alt)',
`${this.acceleratedByPercentage} accelerating`,
) as PieSeriesOption);
const notAcceleratedByPercentage = ((1 - (totalAcceleratedHashrate / parseFloat(this.miningStats.lastEstimatedHashrate))) * 100).toFixed(1) + '%';
data.push(getDataItem(
(parseFloat(this.miningStats.lastEstimatedHashrate) - totalAcceleratedHashrate),
'rgba(127, 127, 127, 0.3)',
`${notAcceleratedByPercentage} not accelerating`,
) as PieSeriesOption);
return data;
}
prepareChartOptions() {
this.chartOptions = {
animation: false,
grid: {
top: 0,
right: 0,
bottom: 0,
left: 0,
},
tooltip: {
show: true,
trigger: 'item',
},
series: [
{
type: 'pie',
radius: '100%',
data: this.getChartData(),
}
]
};
}
onChartInit(ec) {
if (this.chartInstance !== undefined) {
return;
}
this.chartInstance = ec;
}
}

View File

@@ -175,9 +175,6 @@ export class AddressComponent implements OnInit, OnDestroy {
});
this.transactions = this.tempTransactions;
if (this.transactions.length === this.txCount) {
this.fullyLoaded = true;
}
this.isLoadingTransactions = false;
if (!this.showBalancePeriod()) {

View File

@@ -4,8 +4,6 @@ import { Router, NavigationEnd } from '@angular/router';
import { StateService } from '../../services/state.service';
import { OpenGraphService } from '../../services/opengraph.service';
import { NgbTooltipConfig } from '@ng-bootstrap/ng-bootstrap';
import { ThemeService } from '../../services/theme.service';
import { SeoService } from '../../services/seo.service';
@Component({
selector: 'app-root',
@@ -14,12 +12,12 @@ import { SeoService } from '../../services/seo.service';
providers: [NgbTooltipConfig]
})
export class AppComponent implements OnInit {
link: HTMLElement = document.getElementById('canonical');
constructor(
public router: Router,
private stateService: StateService,
private openGraphService: OpenGraphService,
private seoService: SeoService,
private themeService: ThemeService,
private location: Location,
tooltipConfig: NgbTooltipConfig,
@Inject(LOCALE_ID) private locale: string,
@@ -54,7 +52,11 @@ export class AppComponent implements OnInit {
ngOnInit() {
this.router.events.subscribe((val) => {
if (val instanceof NavigationEnd) {
this.seoService.updateCanonical(this.location.path());
let domain = 'mempool.space';
if (this.stateService.env.BASE_MODULE === 'liquid') {
domain = 'liquid.network';
}
this.link.setAttribute('href', 'https://' + domain + this.location.path());
}
});
}

View File

@@ -57,9 +57,8 @@ export class BalanceWidgetComponent implements OnInit, OnChanges {
calculateStats(summary: AddressTxSummary[]): void {
let weekTotal = 0;
let monthTotal = 0;
const weekAgo = (new Date(new Date().setHours(0, 0, 0, 0) - (7 * 24 * 60 * 60 * 1000)).getTime()) / 1000;
const monthAgo = (new Date(new Date().setHours(0, 0, 0, 0) - (30 * 24 * 60 * 60 * 1000)).getTime()) / 1000;
const weekAgo = (Date.now() / 1000) - (60 * 60 * 24 * 7);
const monthAgo = (Date.now() / 1000) - (60 * 60 * 24 * 30);
for (let i = 0; i < summary.length && summary[i].time >= monthAgo; i++) {
monthTotal += summary[i].value;
if (summary[i].time >= weekAgo) {

View File

@@ -10,7 +10,7 @@
</div>
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
<div class="btn-group btn-group-toggle" name="radioBasic">
<div class="btn-group btn-group-toggle" name="radioBasic" [class]="{'disabled': isLoading}">
<label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 144" [class.active]="radioGroupForm.get('dateSpan').value === '24h'">
<input type="radio" [value]="'24h'" fragment="24h" [routerLink]="['/graphs/mining/block-fee-rates' | relativeUrl]" formControlName="dateSpan"> 24h
</label>
@@ -63,7 +63,7 @@
</div>
<div [class.chart]="!widget" [class.chart-widget]="widget" *browserOnly echarts [initOpts]="chartInitOptions" [options]="chartOptions"
(chartInit)="onChartInit($event)" [style]="{opacity: isLoading ? 0.5 : 1}">
(chartInit)="onChartInit($event)">
</div>
<div class="text-center loadingGraphs" *ngIf="!stateService.isBrowser || isLoading">
<div class="spinner-border text-light"></div>

View File

@@ -139,3 +139,8 @@
max-width: 80px;
margin: 15px auto 3px;
}
.disabled {
pointer-events: none;
opacity: 0.5;
}

View File

@@ -10,7 +10,7 @@
</div>
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
<div class="btn-group btn-group-toggle" name="radioBasic">
<div class="btn-group btn-group-toggle" name="radioBasic" [class]="{'disabled': isLoading}">
<label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 4320" [class.active]="radioGroupForm.get('dateSpan').value === '1m'">
<input type="radio" [value]="'1m'" fragment="1m" [routerLink]="['/graphs/mining/block-fees' | relativeUrl]" formControlName="dateSpan"> 1M
</label>
@@ -37,7 +37,7 @@
</div>
<div class="chart" *browserOnly echarts [initOpts]="chartInitOptions" [options]="chartOptions"
(chartInit)="onChartInit($event)" [style]="{opacity: isLoading ? 0.5 : 1}">
(chartInit)="onChartInit($event)">
</div>
<div class="text-center loadingGraphs" *ngIf="!stateService.isBrowser || isLoading">
<div class="spinner-border text-light"></div>

View File

@@ -60,3 +60,8 @@
height: 100%;
max-height: 270px;
}
.disabled {
pointer-events: none;
opacity: 0.5;
}

View File

@@ -1,55 +0,0 @@
<app-indexing-progress></app-indexing-progress>
<div class="full-container">
<div class="card-header mb-0 mb-md-4">
<div class="d-flex d-md-block align-items-baseline">
<span i18n="mining.block-fees-subsidy-subsidy">Block Fees Vs Subsidy</span>
<button class="btn p-0 pl-2" style="margin: 0 0 4px 0px" (click)="onSaveChart()">
<fa-icon [icon]="['fas', 'download']" [fixedWidth]="true"></fa-icon>
</button>
</div>
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
<div class="btn-group btn-group-toggle" name="radioBasic">
<label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 144" [class.active]="radioGroupForm.get('dateSpan').value === '24h'">
<input type="radio" [value]="'24h'" fragment="24h" [routerLink]="['/graphs/mining/block-fees-subsidy' | relativeUrl]" formControlName="dateSpan"> 24h
</label>
<label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 432" [class.active]="radioGroupForm.get('dateSpan').value === '3d'">
<input type="radio" [value]="'3d'" fragment="3d" [routerLink]="['/graphs/mining/block-fees-subsidy' | relativeUrl]" formControlName="dateSpan"> 3D
</label>
<label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 1008" [class.active]="radioGroupForm.get('dateSpan').value === '1w'">
<input type="radio" [value]="'1w'" fragment="1w" [routerLink]="['/graphs/mining/block-fees-subsidy' | relativeUrl]" formControlName="dateSpan"> 1W
</label>
<label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 4320" [class.active]="radioGroupForm.get('dateSpan').value === '1m'">
<input type="radio" [value]="'1m'" fragment="1m" [routerLink]="['/graphs/mining/block-fees-subsidy' | relativeUrl]" formControlName="dateSpan"> 1M
</label>
<label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 12960" [class.active]="radioGroupForm.get('dateSpan').value === '3m'">
<input type="radio" [value]="'3m'" fragment="3m" [routerLink]="['/graphs/mining/block-fees-subsidy' | relativeUrl]" formControlName="dateSpan"> 3M
</label>
<label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 25920" [class.active]="radioGroupForm.get('dateSpan').value === '6m'">
<input type="radio" [value]="'6m'" fragment="6m" [routerLink]="['/graphs/mining/block-fees-subsidy' | relativeUrl]" formControlName="dateSpan"> 6M
</label>
<label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 52560" [class.active]="radioGroupForm.get('dateSpan').value === '1y'">
<input type="radio" [value]="'1y'" fragment="1y" [routerLink]="['/graphs/mining/block-fees-subsidy' | relativeUrl]" formControlName="dateSpan"> 1Y
</label>
<label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 105120" [class.active]="radioGroupForm.get('dateSpan').value === '2y'">
<input type="radio" [value]="'2y'" fragment="2y" [routerLink]="['/graphs/mining/block-fees-subsidy' | relativeUrl]" formControlName="dateSpan"> 2Y
</label>
<label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 157680" [class.active]="radioGroupForm.get('dateSpan').value === '3y'">
<input type="radio" [value]="'3y'" fragment="3y" [routerLink]="['/graphs/mining/block-fees-subsidy' | relativeUrl]" formControlName="dateSpan"> 3Y
</label>
<label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === 'all'">
<input type="radio" [value]="'all'" fragment="all" [routerLink]="['/graphs/mining/block-fees-subsidy' | relativeUrl]" formControlName="dateSpan"> ALL
</label>
</div>
</form>
</div>
<div class="chart" *browserOnly echarts [initOpts]="chartInitOptions" [options]="chartOptions" [style]="{opacity: isLoading ? 0.5 : 1}"
(chartInit)="onChartInit($event)">
</div>
<div class="text-center loadingGraphs" *ngIf="!stateService.isBrowser || isLoading">
<div class="spinner-border text-light"></div>
</div>
</div>

View File

@@ -1,61 +0,0 @@
.card-header {
border-bottom: 0;
font-size: 18px;
@media (min-width: 465px) {
font-size: 20px;
}
@media (min-width: 992px) {
height: 40px;
}
}
.main-title {
position: relative;
color: #ffffff91;
margin-top: -13px;
font-size: 10px;
text-transform: uppercase;
font-weight: 500;
text-align: center;
padding-bottom: 3px;
}
.full-container {
display: flex;
flex-direction: column;
padding: 0px 15px;
width: 100%;
height: calc(100vh - 225px);
min-height: 400px;
@media (min-width: 992px) {
height: calc(100vh - 150px);
}
}
.chart {
display: flex;
flex: 1;
width: 100%;
padding-bottom: 20px;
padding-right: 10px;
@media (max-width: 992px) {
padding-bottom: 25px;
}
@media (max-width: 829px) {
padding-bottom: 50px;
}
@media (max-width: 767px) {
padding-bottom: 25px;
}
@media (max-width: 629px) {
padding-bottom: 55px;
}
@media (max-width: 567px) {
padding-bottom: 55px;
}
}
.chart-widget {
width: 100%;
height: 100%;
max-height: 270px;
}

View File

@@ -1,573 +0,0 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostListener, Inject, Input, LOCALE_ID, NgZone, OnInit } from '@angular/core';
import { EChartsOption } from '../../graphs/echarts';
import { Observable } from 'rxjs';
import { catchError, map, share, startWith, switchMap, tap } from 'rxjs/operators';
import { ApiService } from '../../services/api.service';
import { SeoService } from '../../services/seo.service';
import { formatNumber } from '@angular/common';
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { download, formatterXAxis } from '../../shared/graphs.utils';
import { ActivatedRoute, Router } from '@angular/router';
import { FiatShortenerPipe } from '../../shared/pipes/fiat-shortener.pipe';
import { FiatCurrencyPipe } from '../../shared/pipes/fiat-currency.pipe';
import { StateService } from '../../services/state.service';
import { MiningService } from '../../services/mining.service';
import { StorageService } from '../../services/storage.service';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
@Component({
selector: 'app-block-fees-subsidy-graph',
templateUrl: './block-fees-subsidy-graph.component.html',
styleUrls: ['./block-fees-subsidy-graph.component.scss'],
styles: [`
.loadingGraphs {
position: absolute;
top: 50%;
left: calc(50% - 15px);
z-index: 100;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BlockFeesSubsidyGraphComponent implements OnInit {
@Input() right: number | string = 45;
@Input() left: number | string = 75;
miningWindowPreference: string;
radioGroupForm: UntypedFormGroup;
chartOptions: EChartsOption = {};
chartInitOptions = {
renderer: 'svg',
};
statsObservable$: Observable<any>;
data: any;
subsidies: { [key: number]: number } = {};
isLoading = true;
formatNumber = formatNumber;
timespan = '';
chartInstance: any = undefined;
displayMode: 'normal' | 'fiat' | 'percentage' = 'normal';
updateZoom = false;
zoomSpan = 100;
zoomTimeSpan = '';
constructor(
@Inject(LOCALE_ID) public locale: string,
private seoService: SeoService,
private apiService: ApiService,
private formBuilder: UntypedFormBuilder,
public stateService: StateService,
private storageService: StorageService,
private miningService: MiningService,
private route: ActivatedRoute,
private router: Router,
private zone: NgZone,
private fiatShortenerPipe: FiatShortenerPipe,
private fiatCurrencyPipe: FiatCurrencyPipe,
private cd: ChangeDetectorRef,
) {
this.radioGroupForm = this.formBuilder.group({ dateSpan: '1y' });
this.radioGroupForm.controls.dateSpan.setValue('1y');
this.subsidies = this.initSubsidies();
}
ngOnInit(): void {
this.seoService.setTitle($localize`:@@mining.block-fees-subsidy:Block Fees Vs Subsidy`);
this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.block-fees-subsidy:See the mining fees earned per Bitcoin block compared to the Bitcoin block subsidy, visualized in BTC and USD over time.`);
this.miningWindowPreference = this.miningService.getDefaultTimespan('24h');
this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference });
this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference);
this.route
.fragment
.subscribe((fragment) => {
if (['24h', '3d', '1w', '1m', '3m', '6m', '1y', '2y', '3y', 'all'].indexOf(fragment) > -1) {
this.radioGroupForm.controls.dateSpan.setValue(fragment, { emitEvent: false });
}
});
this.statsObservable$ = this.radioGroupForm.get('dateSpan').valueChanges
.pipe(
startWith(this.radioGroupForm.controls.dateSpan.value),
switchMap((timespan) => {
this.isLoading = true;
this.storageService.setValue('miningWindowPreference', timespan);
this.timespan = timespan;
this.zoomTimeSpan = timespan;
return this.apiService.getHistoricalBlockFees$(timespan)
.pipe(
tap((response) => {
this.data = {
timestamp: response.body.map(val => val.timestamp * 1000),
blockHeight: response.body.map(val => val.avgHeight),
blockFees: response.body.map(val => val.avgFees / 100_000_000),
blockFeesFiat: response.body.filter(val => val['USD'] > 0).map(val => val.avgFees / 100_000_000 * val['USD']),
blockFeesPercent: response.body.map(val => val.avgFees / (val.avgFees + this.subsidyAt(val.avgHeight)) * 100),
blockSubsidy: response.body.map(val => this.subsidyAt(val.avgHeight) / 100_000_000),
blockSubsidyFiat: response.body.filter(val => val['USD'] > 0).map(val => this.subsidyAt(val.avgHeight) / 100_000_000 * val['USD']),
blockSubsidyPercent: response.body.map(val => this.subsidyAt(val.avgHeight) / (val.avgFees + this.subsidyAt(val.avgHeight)) * 100),
};
this.prepareChartOptions();
this.isLoading = false;
}),
map((response) => {
return {
blockCount: parseInt(response.headers.get('x-total-count'), 10),
};
}),
);
}),
share()
);
}
prepareChartOptions() {
let title: object;
if (this.data.blockFees.length === 0) {
title = {
textStyle: {
color: 'grey',
fontSize: 15
},
text: $localize`:@@23555386d8af1ff73f297e89dd4af3f4689fb9dd:Indexing blocks`,
left: 'center',
top: 'center'
};
}
this.chartOptions = {
title: title,
color: [
'#ff9f00',
'#0aab2f',
],
animation: false,
grid: {
top: 80,
bottom: 80,
right: this.right,
left: this.left,
},
tooltip: {
show: !this.isMobile(),
trigger: 'axis',
axisPointer: {
type: 'line'
},
backgroundColor: 'rgba(17, 19, 31, 1)',
borderRadius: 4,
shadowColor: 'rgba(0, 0, 0, 0.5)',
textStyle: {
color: 'var(--tooltip-grey)',
align: 'left',
},
borderColor: 'var(--active-bg)',
formatter: function (data) {
if (data.length <= 0) {
return '';
}
let tooltip = `<b style="color: white; margin-left: 2px">${formatterXAxis(this.locale, this.zoomTimeSpan, parseInt(this.data.timestamp[data[0].dataIndex], 10))}</b><br>`;
for (let i = data.length - 1; i >= 0; i--) {
const tick = data[i];
tooltip += `${tick.marker} ${tick.seriesName.split(' ')[0]}: `;
if (this.displayMode === 'normal') tooltip += `${formatNumber(tick.data, this.locale, '1.0-3')} BTC<br>`;
else if (this.displayMode === 'fiat') tooltip += `${this.fiatCurrencyPipe.transform(tick.data, null, 'USD') }<br>`;
else tooltip += `${formatNumber(tick.data, this.locale, '1.0-2')}%<br>`;
}
if (this.displayMode === 'normal') tooltip += `<div style="margin-left: 2px">${formatNumber(data.reduce((acc, val) => acc + val.data, 0), this.locale, '1.0-3')} BTC</div>`;
else if (this.displayMode === 'fiat') tooltip += `<div style="margin-left: 2px">${this.fiatCurrencyPipe.transform(data.reduce((acc, val) => acc + val.data, 0), null, 'USD')}</div>`;
if (['24h', '3d'].includes(this.zoomTimeSpan)) {
tooltip += `<small>` + $localize`At block <b style="color: white; margin-left: 2px">${data[0].axisValue}` + `</small>`;
} else {
tooltip += `<small>` + $localize`Around block <b style="color: white; margin-left: 2px">${data[0].axisValue}` + `</small>`;
}
return tooltip;
}.bind(this)
},
xAxis: this.data.blockFees.length === 0 ? undefined : [
{
type: 'category',
data: this.data.blockHeight,
show: false,
axisLabel: {
hideOverlap: true,
}
},
{
type: 'category',
data: this.data.timestamp,
show: true,
position: 'bottom',
axisLabel: {
color: 'var(--grey)',
formatter: (val) => {
return formatterXAxis(this.locale, this.timespan, parseInt(val, 10));
}
},
axisTick: {
show: false,
},
axisLine: {
show: false,
},
splitLine: {
show: false,
},
}
],
legend: this.data.blockFees.length === 0 ? undefined : {
data: [
{
name: 'Subsidy',
inactiveColor: 'var(--grey)',
textStyle: {
color: 'white',
},
icon: 'roundRect',
},
{
name: 'Fees',
inactiveColor: 'var(--grey)',
textStyle: {
color: 'white',
},
icon: 'roundRect',
},
{
name: 'Subsidy (USD)',
inactiveColor: 'var(--grey)',
textStyle: {
color: 'white',
},
icon: 'roundRect',
},
{
name: 'Fees (USD)',
inactiveColor: 'var(--grey)',
textStyle: {
color: 'white',
},
icon: 'roundRect',
},
{
name: 'Subsidy (%)',
inactiveColor: 'var(--grey)',
textStyle: {
color: 'white',
},
icon: 'roundRect',
},
{
name: 'Fees (%)',
inactiveColor: 'var(--grey)',
textStyle: {
color: 'white',
},
icon: 'roundRect',
},
],
selected: {
'Subsidy (USD)': this.displayMode === 'fiat',
'Fees (USD)': this.displayMode === 'fiat',
'Subsidy': this.displayMode === 'normal',
'Fees': this.displayMode === 'normal',
'Subsidy (%)': this.displayMode === 'percentage',
'Fees (%)': this.displayMode === 'percentage',
},
},
yAxis: this.data.blockFees.length === 0 ? undefined : [
{
type: 'value',
axisLabel: {
color: 'var(--grey)',
formatter: (val) => {
return `${val}${this.displayMode === 'percentage' ? '%' : ' BTC'}`;
}
},
min: 0,
max: (value) => {
if (this.displayMode === 'percentage') {
return 100;
}
},
splitLine: {
lineStyle: {
type: 'dotted',
color: 'var(--transparent-fg)',
opacity: 0.25,
}
},
},
{
type: 'value',
position: 'right',
axisLabel: {
color: 'var(--grey)',
formatter: function(val) {
return this.fiatShortenerPipe.transform(val, null, 'USD');
}.bind(this)
},
splitLine: {
show: false,
},
},
],
series: this.data.blockFees.length === 0 ? undefined : [
{
name: 'Subsidy',
yAxisIndex: 0,
type: 'bar',
barWidth: '90%',
stack: 'total',
data: this.data.blockSubsidy,
},
{
name: 'Fees',
yAxisIndex: 0,
type: 'bar',
barWidth: '90%',
stack: 'total',
data: this.data.blockFees,
},
{
name: 'Subsidy (USD)',
yAxisIndex: 1,
type: 'bar',
barWidth: '90%',
stack: 'total',
data: this.data.blockSubsidyFiat,
},
{
name: 'Fees (USD)',
yAxisIndex: 1,
type: 'bar',
barWidth: '90%',
stack: 'total',
data: this.data.blockFeesFiat,
},
{
name: 'Subsidy (%)',
yAxisIndex: 0,
type: 'bar',
barWidth: '90%',
stack: 'total',
data: this.data.blockSubsidyPercent,
},
{
name: 'Fees (%)',
yAxisIndex: 0,
type: 'bar',
barWidth: '90%',
stack: 'total',
data: this.data.blockFeesPercent,
},
],
dataZoom: this.data.blockFees.length === 0 ? undefined : [{
type: 'inside',
realtime: true,
zoomLock: true,
maxSpan: 100,
minSpan: 1,
moveOnMouseMove: false,
}, {
showDetail: false,
show: true,
type: 'slider',
brushSelect: false,
realtime: true,
left: 20,
right: 15,
selectedDataBackground: {
lineStyle: {
color: '#fff',
opacity: 0.45,
},
},
}],
};
}
onChartInit(ec) {
this.chartInstance = ec;
this.chartInstance.on('legendselectchanged', (params) => {
if (this.isLoading) {
return;
}
let mode: 'normal' | 'fiat' | 'percentage';
if (params.name.includes('USD')) {
mode = 'fiat';
} else if (params.name.includes('%')) {
mode = 'percentage';
} else {
mode = 'normal';
}
if (this.displayMode === mode) return;
const isActivation = params.selected[params.name];
if (isActivation) {
this.displayMode = mode;
this.chartInstance.dispatchAction({ type: this.displayMode === 'normal' ? 'legendSelect' : 'legendUnSelect', name: 'Subsidy' });
this.chartInstance.dispatchAction({ type: this.displayMode === 'normal' ? 'legendSelect' : 'legendUnSelect', name: 'Fees' });
this.chartInstance.dispatchAction({ type: this.displayMode === 'fiat' ? 'legendSelect' : 'legendUnSelect', name: 'Subsidy (USD)' });
this.chartInstance.dispatchAction({ type: this.displayMode === 'fiat' ? 'legendSelect' : 'legendUnSelect', name: 'Fees (USD)' });
this.chartInstance.dispatchAction({ type: this.displayMode === 'percentage' ? 'legendSelect' : 'legendUnSelect', name: 'Subsidy (%)' });
this.chartInstance.dispatchAction({ type: this.displayMode === 'percentage' ? 'legendSelect' : 'legendUnSelect', name: 'Fees (%)' });
}
});
this.chartInstance.on('datazoom', (params) => {
if (params.silent || this.isLoading || ['24h', '3d'].includes(this.timespan)) {
return;
}
this.updateZoom = true;
});
this.chartInstance.on('click', (e) => {
this.zone.run(() => {
if (['24h', '3d'].includes(this.zoomTimeSpan)) {
const url = new RelativeUrlPipe(this.stateService).transform(`/block/${e.name}`);
if (e.event.event.shiftKey || e.event.event.ctrlKey || e.event.event.metaKey) {
window.open(url);
} else {
this.router.navigate([url]);
}
}
});
});
}
@HostListener('document:pointerup', ['$event'])
onPointerUp(event: PointerEvent) {
if (this.updateZoom) {
this.onZoom();
this.updateZoom = false;
}
}
isMobile() {
return (window.innerWidth <= 767.98);
}
initSubsidies(): { [key: number]: number } {
let blockReward = 50 * 100_000_000;
const subsidies = {};
for (let i = 0; i <= 33; i++) {
subsidies[i] = blockReward;
blockReward = Math.floor(blockReward / 2);
}
return subsidies;
}
subsidyAt(height: number): number {
return this.subsidies[Math.floor(Math.min(height / 210000, 33))];
}
onZoom() {
const option = this.chartInstance.getOption();
const timestamps = option.xAxis[1].data;
const startTimestamp = timestamps[option.dataZoom[0].startValue];
const endTimestamp = timestamps[option.dataZoom[0].endValue];
this.isLoading = true;
this.cd.detectChanges();
const subscription = this.apiService.getBlockFeesFromTimespan$(Math.floor(startTimestamp / 1000), Math.floor(endTimestamp / 1000))
.pipe(
tap((response) => {
const startIndex = option.dataZoom[0].startValue;
const endIndex = option.dataZoom[0].endValue;
// Update series with more granular data
const lengthBefore = this.data.timestamp.length;
this.data.timestamp.splice(startIndex, endIndex - startIndex, ...response.body.map(val => val.timestamp * 1000));
this.data.blockHeight.splice(startIndex, endIndex - startIndex, ...response.body.map(val => val.avgHeight));
this.data.blockFees.splice(startIndex, endIndex - startIndex, ...response.body.map(val => val.avgFees / 100_000_000));
this.data.blockFeesFiat.splice(startIndex, endIndex - startIndex, ...response.body.filter(val => val['USD'] > 0).map(val => val.avgFees / 100_000_000 * val['USD']));
this.data.blockFeesPercent.splice(startIndex, endIndex - startIndex, ...response.body.map(val => val.avgFees / (val.avgFees + this.subsidyAt(val.avgHeight)) * 100));
this.data.blockSubsidy.splice(startIndex, endIndex - startIndex, ...response.body.map(val => this.subsidyAt(val.avgHeight) / 100_000_000));
this.data.blockSubsidyFiat.splice(startIndex, endIndex - startIndex, ...response.body.filter(val => val['USD'] > 0).map(val => this.subsidyAt(val.avgHeight) / 100_000_000 * val['USD']));
this.data.blockSubsidyPercent.splice(startIndex, endIndex - startIndex, ...response.body.map(val => this.subsidyAt(val.avgHeight) / (val.avgFees + this.subsidyAt(val.avgHeight)) * 100));
option.series[0].data = this.data.blockSubsidy;
option.series[1].data = this.data.blockFees;
option.series[2].data = this.data.blockSubsidyFiat;
option.series[3].data = this.data.blockFeesFiat;
option.series[4].data = this.data.blockSubsidyPercent;
option.series[5].data = this.data.blockFeesPercent;
option.xAxis[0].data = this.data.blockHeight;
option.xAxis[1].data = this.data.timestamp;
this.chartInstance.setOption(option, true);
const lengthAfter = this.data.timestamp.length;
// Update the zoom to keep the same range after the update
this.chartInstance.dispatchAction({
type: 'dataZoom',
startValue: startIndex,
endValue: endIndex + lengthAfter - lengthBefore,
silent: true,
});
// Update the chart
const newOption = this.chartInstance.getOption();
this.zoomSpan = newOption.dataZoom[0].end - newOption.dataZoom[0].start;
this.zoomTimeSpan = this.getTimeRangeFromTimespan(Math.floor(this.data.timestamp[newOption.dataZoom[0].startValue] / 1000), Math.floor(this.data.timestamp[newOption.dataZoom[0].endValue] / 1000));
this.isLoading = false;
}),
catchError(() => {
const newOption = this.chartInstance.getOption();
this.zoomSpan = newOption.dataZoom[0].end - newOption.dataZoom[0].start;
this.zoomTimeSpan = this.getTimeRangeFromTimespan(Math.floor(this.data.timestamp[newOption.dataZoom[0].startValue] / 1000), Math.floor(this.data.timestamp[newOption.dataZoom[0].endValue] / 1000));
this.isLoading = false;
this.cd.detectChanges();
return [];
})
).subscribe(() => {
subscription.unsubscribe();
this.cd.detectChanges();
});
}
getTimeRangeFromTimespan(from: number, to: number): string {
const timespan = to - from;
switch (true) {
case timespan >= 3600 * 24 * 365 * 4: return 'all';
case timespan >= 3600 * 24 * 365 * 3: return '4y';
case timespan >= 3600 * 24 * 365 * 2: return '3y';
case timespan >= 3600 * 24 * 365: return '2y';
case timespan >= 3600 * 24 * 30 * 6: return '1y';
case timespan >= 3600 * 24 * 30 * 3: return '6m';
case timespan >= 3600 * 24 * 30: return '3m';
case timespan >= 3600 * 24 * 7: return '1m';
case timespan >= 3600 * 24 * 3: return '1w';
case timespan >= 3600 * 24: return '3d';
default: return '24h';
}
}
onSaveChart() {
// @ts-ignore
const prevBottom = this.chartOptions.grid.bottom;
const now = new Date();
// @ts-ignore
this.chartOptions.grid.bottom = 40;
this.chartOptions.backgroundColor = 'var(--active-bg)';
this.chartInstance.setOption(this.chartOptions);
download(this.chartInstance.getDataURL({
pixelRatio: 2,
excludeComponents: ['dataZoom'],
}), `block-fees-subsidy-${this.timespan}-${Math.round(now.getTime() / 1000)}.svg`);
// @ts-ignore
this.chartOptions.grid.bottom = prevBottom;
this.chartOptions.backgroundColor = 'none';
this.chartInstance.setOption(this.chartOptions);
}
}

View File

@@ -10,7 +10,7 @@
</div>
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
<div class="btn-group btn-group-toggle" name="radioBasic">
<div class="btn-group btn-group-toggle" name="radioBasic" [class]="{'disabled': isLoading}">
<label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 144" [class.active]="radioGroupForm.get('dateSpan').value === '24h'">
<input type="radio" [value]="'24h'" fragment="24h" [routerLink]="['/graphs/mining/block-health' | relativeUrl]" formControlName="dateSpan"> 24h
</label>
@@ -46,7 +46,7 @@
</div>
<div class="chart" *browserOnly echarts [initOpts]="chartInitOptions" [options]="chartOptions"
(chartInit)="onChartInit($event)" [style]="{opacity: isLoading ? 0.5 : 1}">
(chartInit)="onChartInit($event)">
</div>
<div class="text-center loadingGraphs" *ngIf="!stateService.isBrowser || isLoading">
<div class="spinner-border text-light"></div>

View File

@@ -60,3 +60,8 @@
height: 100%;
max-height: 270px;
}
.disabled {
pointer-events: none;
opacity: 0.5;
}

View File

@@ -11,7 +11,7 @@
</div>
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
<div class="btn-group btn-group-toggle" name="radioBasic">
<div class="btn-group btn-group-toggle" name="radioBasic" [class]="{'disabled': isLoading}">
<label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 4320" [class.active]="radioGroupForm.get('dateSpan').value === '1m'">
<input type="radio" [value]="'1m'" fragment="1m" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]" formControlName="dateSpan"> 1M
</label>
@@ -38,7 +38,7 @@
</div>
<div class="chart" *browserOnly echarts [initOpts]="chartInitOptions" [options]="chartOptions"
(chartInit)="onChartInit($event)" [style]="{opacity: isLoading ? 0.5 : 1}">
(chartInit)="onChartInit($event)">
</div>
<div class="text-center loadingGraphs" *ngIf="!stateService.isBrowser || isLoading">
<div class="spinner-border text-light"></div>

View File

@@ -60,3 +60,8 @@
height: 100%;
max-height: 270px;
}
.disabled {
pointer-events: none;
opacity: 0.5;
}

View File

@@ -9,7 +9,7 @@
</div>
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(blockSizesWeightsObservable$ | async) as stats">
<div class="btn-group btn-group-toggle" name="radioBasic">
<div class="btn-group btn-group-toggle" name="radioBasic" [class]="{'disabled': isLoading}">
<label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 144" [class.active]="radioGroupForm.get('dateSpan').value === '24h'">
<input type="radio" [value]="'24h'" fragment="24h" [routerLink]="['/graphs/mining/block-sizes-weights' | relativeUrl]" formControlName="dateSpan"> 24h
</label>
@@ -45,7 +45,7 @@
</div>
<div class="chart" *browserOnly echarts [initOpts]="chartInitOptions" [options]="chartOptions"
(chartInit)="onChartInit($event)" [style]="{opacity: isLoading ? 0.5 : 1}">
(chartInit)="onChartInit($event)">
</div>
<div class="text-center loadingGraphs" *ngIf="!stateService.isBrowser || isLoading">
<div class="spinner-border text-light"></div>

View File

@@ -60,3 +60,8 @@
height: 100%;
max-height: 270px;
}
.disabled {
pointer-events: none;
opacity: 0.5;
}

View File

@@ -136,12 +136,7 @@ export class BlockPreviewComponent implements OnInit, OnDestroy {
return of(transactions);
})
),
this.stateService.env.ACCELERATOR === true && block.height > 819500
? this.servicesApiService.getAccelerationHistory$({ blockHeight: block.height })
.pipe(catchError(() => {
return of([]);
}))
: of([])
this.stateService.env.ACCELERATOR === true && block.height > 819500 ? this.servicesApiService.getAccelerationHistory$({ blockHeight: block.height }) : of([])
]);
}
),

View File

@@ -1,53 +0,0 @@
<div #blockTxTitle id="block-tx-title" class="block-tx-title">
<h2 class="text-left">
<ng-container *ngTemplateOutlet="txCount === 1 ? transactionsSingular : transactionsPlural; context: {$implicit: txCount | number}"></ng-container>
<ng-template #transactionsSingular let-i i18n="shared.transaction-count.singular">{{ i }} transaction</ng-template>
<ng-template #transactionsPlural let-i i18n="shared.transaction-count.plural">{{ i }} transactions</ng-template>
</h2>
<ngb-pagination class="pagination-container float-right" [collectionSize]="txCount" [rotate]="true" [pageSize]="itemsPerPage" [(page)]="page" (pageChange)="pageChange(page, blockTxTitle)" [maxSize]="paginationMaxSize" [boundaryLinks]="true" [ellipses]="false"></ngb-pagination>
</div>
<div class="clearfix"></div>
<app-transactions-list *ngIf="transactions$ | async as transactions; else loading" [transactions]="transactions" [paginated]="true" [blockTime]="timestamp"></app-transactions-list>
<ng-template [ngIf]="transactionsError">
<br>
<app-http-error [error]="transactionsError">
<span i18n="error.general-loading-data">Error loading data.</span>
</app-http-error>
<br>
<br>
</ng-template>
<ng-template #loading>
<div class="text-center mb-4" class="tx-skeleton">
<ng-container *ngIf="(txsLoadingStatus$ | async) as txsLoadingStatus; else headerLoader">
<div class="header-bg box">
<div class="progress progress-dark" style="margin: 4px; height: 14px;">
<div class="progress-bar progress-light" role="progressbar" [ngStyle]="{'width': txsLoadingStatus + '%' }"></div>
</div>
</div>
</ng-container>
<div class="header-bg box">
<div class="row">
<div class="col-sm">
<span class="skeleton-loader"></span>
</div>
<div class="col-sm">
<span class="skeleton-loader"></span>
<span class="skeleton-loader"></span>
<span class="skeleton-loader"></span>
</div>
</div>
</div>
</div>
</ng-template>
<ng-template #headerLoader>
<div class="header-bg box">
<span class="skeleton-loader"></span>
</div>
</ng-template>
<ngb-pagination class="pagination-container float-right" [collectionSize]="txCount" [rotate]="true" [pageSize]="itemsPerPage" [(page)]="page" (pageChange)="pageChange(page, blockTxTitle)" [maxSize]="paginationMaxSize" [boundaryLinks]="true" [ellipses]="false"></ngb-pagination>

View File

@@ -1,37 +0,0 @@
.block-tx-title {
display: flex;
justify-content: space-between;
flex-direction: column;
margin-top: -15px;
position: relative;
@media (min-width: 550px) {
margin-top: 1rem;
flex-direction: row;
}
h2 {
line-height: 1;
margin: 0;
position: relative;
padding-bottom: 10px;
@media (min-width: 550px) {
padding-bottom: 0px;
align-self: end;
}
}
}
.tx-skeleton {
margin-top: 10px;
margin-bottom: 10px;
.header-bg {
&:first-child {
padding: 10px;
margin-bottom: 10px;
}
&:nth-child(2) {
.row {
height: 107px;
}
}
}
}

View File

@@ -1,74 +0,0 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { StateService } from '../../services/state.service';
import { Transaction, Vout } from '../../interfaces/electrs.interface';
import { Observable, Subscription, catchError, combineLatest, map, of, startWith, switchMap, tap } from 'rxjs';
import { ActivatedRoute, Router } from '@angular/router';
import { ElectrsApiService } from '../../services/electrs-api.service';
import { PreloadService } from '../../services/preload.service';
@Component({
selector: 'app-block-transactions',
templateUrl: './block-transactions.component.html',
styleUrl: './block-transactions.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BlockTransactionsComponent implements OnInit {
@Input() txCount: number;
@Input() timestamp: number;
@Input() blockHash: string;
@Input() previousBlockHash: string;
@Input() block$: Observable<any>;
@Input() paginationMaxSize: number;
@Output() blockReward = new EventEmitter<number>();
itemsPerPage = this.stateService.env.ITEMS_PER_PAGE;
page = 1;
transactions$: Observable<Transaction[]>;
isLoadingTransactions = true;
transactionsError: any = null;
transactionSubscription: Subscription;
txsLoadingStatus$: Observable<number>;
nextBlockTxListSubscription: Subscription;
constructor(
private stateService: StateService,
private route: ActivatedRoute,
private router: Router,
private electrsApiService: ElectrsApiService,
) { }
ngOnInit(): void {
this.transactions$ = combineLatest([this.block$, this.route.queryParams]).pipe(
tap(([_, queryParams]) => {
this.page = +queryParams['page'] || 1;
}),
switchMap(([block, _]) => this.electrsApiService.getBlockTransactions$(block.id, (this.page - 1) * this.itemsPerPage)
.pipe(
startWith(null),
catchError((err) => {
this.transactionsError = err;
return of([]);
}))
),
tap((transactions: Transaction[]) => {
// The block API doesn't contain the block rewards on Liquid
if (this.stateService.isLiquid() && transactions && transactions[0] && transactions[0].vin[0].is_coinbase) {
const blockReward = transactions[0].vout.reduce((acc: number, curr: Vout) => acc + curr.value, 0) / 100000000;
this.blockReward.emit(blockReward);
}
})
);
this.txsLoadingStatus$ = this.route.paramMap
.pipe(
switchMap(() => this.stateService.loadingIndicators$),
map((indicators) => indicators['blocktxs-' + this.blockHash] !== undefined ? indicators['blocktxs-' + this.blockHash] : 0)
);
}
pageChange(page: number, target: HTMLElement): void {
target.scrollIntoView(); // works for chrome
this.router.navigate([], { queryParams: { page: page }, queryParamsHandling: 'merge' });
}
}

View File

@@ -325,39 +325,53 @@
>Details</button>
</div>
@defer (on viewport) {
<app-block-transactions [paginationMaxSize]="paginationMaxSize" [block$]="block$" [txCount]="block.tx_count" [timestamp]="block.timestamp" [blockHash]="blockHash" [previousBlockHash]="block.previousblockhash" (blockReward)="updateBlockReward($event)"></app-block-transactions>
} @placeholder {
<div>
<div class="block-tx-title">
<h2 class="text-left">
<ng-container *ngTemplateOutlet="block.tx_count === 1 ? transactionsSingular : transactionsPlural; context: {$implicit: block.tx_count | number}"></ng-container>
<ng-template #transactionsSingular let-i i18n="shared.transaction-count.singular">{{ i }} transaction</ng-template>
<ng-template #transactionsPlural let-i i18n="shared.transaction-count.plural">{{ i }} transactions</ng-template>
</h2>
<ngb-pagination class="pagination-container float-right" [disabled]="true" [collectionSize]="block.tx_count" [rotate]="true" [pageSize]="stateService.env.ITEMS_PER_PAGE" [maxSize]="paginationMaxSize" [boundaryLinks]="true" [ellipses]="false"></ngb-pagination>
</div>
<div class="clearfix"></div>
<div class="text-center mb-4" class="tx-skeleton">
<div #blockTxTitle id="block-tx-title" class="block-tx-title">
<h2 class="text-left">
<ng-container *ngTemplateOutlet="block.tx_count === 1 ? transactionsSingular : transactionsPlural; context: {$implicit: block.tx_count | number}"></ng-container>
<ng-template #transactionsSingular let-i i18n="shared.transaction-count.singular">{{ i }} transaction</ng-template>
<ng-template #transactionsPlural let-i i18n="shared.transaction-count.plural">{{ i }} transactions</ng-template>
</h2>
<ngb-pagination class="pagination-container float-right" [collectionSize]="block.tx_count" [rotate]="true" [pageSize]="itemsPerPage" [(page)]="page" (pageChange)="pageChange(page, blockTxTitle)" [maxSize]="paginationMaxSize" [boundaryLinks]="true" [ellipses]="false"></ngb-pagination>
</div>
<div class="clearfix"></div>
<app-transactions-list [transactions]="transactions" [paginated]="true" [blockTime]="block.timestamp"></app-transactions-list>
<ng-template [ngIf]="transactionsError">
<br>
<app-http-error [error]="transactionsError">
<span i18n="error.general-loading-data">Error loading data.</span>
</app-http-error>
<br>
<br>
</ng-template>
<ng-template [ngIf]="isLoadingTransactions && !transactionsError">
<div class="text-center mb-4" class="tx-skeleton">
<ng-container *ngIf="(txsLoadingStatus$ | async) as txsLoadingStatus; else headerLoader">
<div class="header-bg box">
<span class="skeleton-loader"></span>
<div class="progress progress-dark" style="margin: 4px; height: 14px;">
<div class="progress-bar progress-light" role="progressbar" [ngStyle]="{'width': txsLoadingStatus + '%' }"></div>
</div>
</div>
<div class="header-bg box">
<div class="row">
<div class="col-sm">
<span class="skeleton-loader"></span>
</div>
<div class="col-sm">
<span class="skeleton-loader"></span>
<span class="skeleton-loader"></span>
<span class="skeleton-loader"></span>
</div>
</ng-container>
<div class="header-bg box">
<div class="row">
<div class="col-sm">
<span class="skeleton-loader"></span>
<span class="skeleton-loader"></span>
</div>
<div class="col-sm">
<span class="skeleton-loader"></span>
</div>
</div>
</div>
</div>
}
</ng-template>
<ngb-pagination class="pagination-container float-right" [collectionSize]="block.tx_count" [rotate]="true" [pageSize]="itemsPerPage" [(page)]="page" (pageChange)="pageChange(page, blockTxTitle)" [maxSize]="paginationMaxSize" [boundaryLinks]="true" [ellipses]="false"></ngb-pagination>
<div class="clearfix"></div>
<br>
@@ -368,6 +382,12 @@
</app-http-error>
</ng-template>
<ng-template #headerLoader>
<div class="header-bg box">
<span class="skeleton-loader"></span>
</div>
</ng-template>
</div>
<ng-template #emptyBlockInfo>

View File

@@ -21,6 +21,25 @@
}
}
.qr-wrapper {
background-color: var(--fg);
padding: 10px;
padding-bottom: 5px;
display: inline-block;
}
.qrcode-col {
text-align: center;
}
.qrcode-col > div {
margin: 20px auto 5px;
@media (min-width: 768px) {
text-align: center;
margin: auto;
}
}
.fiat {
display: block;
font-size: 13px;
@@ -81,7 +100,19 @@ h1 {
}
}
.row {
.address-link {
line-height: 26px;
margin-left: 0px;
top: 14px;
position: relative;
display: flex;
flex-direction: row;
@media (min-width: 768px) {
line-height: 38px;
}
}
.row{
flex-direction: column;
@media (min-width: 768px) {
flex-direction: row;
@@ -109,6 +140,28 @@ h1 {
margin-right: .5em;
}
.block-tx-title {
display: flex;
justify-content: space-between;
flex-direction: column;
margin-top: -15px;
position: relative;
@media (min-width: 550px) {
margin-top: 1rem;
flex-direction: row;
}
h2 {
line-height: 1;
margin: 0;
position: relative;
padding-bottom: 10px;
@media (min-width: 550px) {
padding-bottom: 0px;
align-self: end;
}
}
}
.grow {
flex-grow: 1;
}
@@ -151,6 +204,22 @@ h1 {
}
}
.tx-skeleton {
margin-top: 10px;
margin-bottom: 10px;
.header-bg {
&:first-child {
padding: 10px;
margin-bottom: 10px;
}
&:nth-child(2) {
.row {
height: 107px;
}
}
}
}
.chart-container{
margin: 20px auto;
@media (min-width: 768px) {
@@ -234,41 +303,3 @@ h1 {
.graph-col {
flex-grow: 1.11;
}
.block-tx-title {
display: flex;
justify-content: space-between;
flex-direction: column;
margin-top: -15px;
position: relative;
@media (min-width: 550px) {
margin-top: 1rem;
flex-direction: row;
}
h2 {
line-height: 1;
margin: 0;
position: relative;
padding-bottom: 10px;
@media (min-width: 550px) {
padding-bottom: 0px;
align-self: end;
}
}
}
.tx-skeleton {
margin-top: 10px;
margin-bottom: 10px;
.header-bg {
&:first-child {
padding: 10px;
margin-bottom: 10px;
}
&:nth-child(2) {
.row {
height: 107px;
}
}
}
}

View File

@@ -1,14 +1,15 @@
import { Component, OnInit, OnDestroy, ViewChildren, QueryList, ChangeDetectorRef } from '@angular/core';
import { Component, OnInit, OnDestroy, ViewChildren, QueryList, Inject, PLATFORM_ID, ChangeDetectorRef } from '@angular/core';
import { Location } from '@angular/common';
import { ActivatedRoute, ParamMap, Router } from '@angular/router';
import { ElectrsApiService } from '../../services/electrs-api.service';
import { switchMap, tap, throttleTime, catchError, map, shareReplay, startWith, filter } from 'rxjs/operators';
import { Transaction, Vout } from '../../interfaces/electrs.interface';
import { Observable, of, Subscription, asyncScheduler, EMPTY, combineLatest, forkJoin } from 'rxjs';
import { StateService } from '../../services/state.service';
import { SeoService } from '../../services/seo.service';
import { WebsocketService } from '../../services/websocket.service';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
import { BlockAudit, BlockExtended, TransactionStripped } from '../../interfaces/node-api.interface';
import { AccelerationInfo, BlockAudit, BlockExtended, TransactionStripped } from '../../interfaces/node-api.interface';
import { ApiService } from '../../services/api.service';
import { BlockOverviewGraphComponent } from '../../components/block-overview-graph/block-overview-graph.component';
import { detectWebGL } from '../../shared/graphs.utils';
@@ -16,7 +17,6 @@ import { seoDescriptionNetwork } from '../../shared/common.utils';
import { PriceService, Price } from '../../services/price.service';
import { CacheService } from '../../services/cache.service';
import { ServicesApiServices } from '../../services/services-api.service';
import { PreloadService } from '../../services/preload.service';
@Component({
selector: 'app-block',
@@ -42,17 +42,23 @@ export class BlockComponent implements OnInit, OnDestroy {
isLoadingBlock = true;
latestBlock: BlockExtended;
latestBlocks: BlockExtended[] = [];
transactions: Transaction[];
oobFees: number = 0;
isLoadingTransactions = true;
strippedTransactions: TransactionStripped[];
overviewTransitionDirection: string;
isLoadingOverview = true;
error: any;
blockSubsidy: number;
fees: number;
block$: Observable<any>;
paginationMaxSize: number;
page = 1;
itemsPerPage: number;
txsLoadingStatus$: Observable<number>;
showDetails = false;
showPreviousBlocklink = true;
showNextBlocklink = true;
transactionsError: any = null;
overviewError: any = null;
webGlEnabled = true;
auditParamEnabled: boolean = false;
@@ -63,16 +69,20 @@ export class BlockComponent implements OnInit, OnDestroy {
isMobile = window.innerWidth <= 767.98;
hoverTx: string;
numMissing: number = 0;
paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5;
numUnexpected: number = 0;
mode: 'projected' | 'actual' = 'projected';
transactionSubscription: Subscription;
overviewSubscription: Subscription;
auditSubscription: Subscription;
keyNavigationSubscription: Subscription;
blocksSubscription: Subscription;
cacheBlocksSubscription: Subscription;
networkChangedSubscription: Subscription;
queryParamsSubscription: Subscription;
nextBlockSubscription: Subscription = undefined;
nextBlockSummarySubscription: Subscription = undefined;
nextBlockTxListSubscription: Subscription = undefined;
timeLtrSubscription: Subscription;
timeLtr: boolean;
childChangeSubscription: Subscription;
@@ -99,14 +109,16 @@ export class BlockComponent implements OnInit, OnDestroy {
private cacheService: CacheService,
private servicesApiService: ServicesApiServices,
private cd: ChangeDetectorRef,
private preloadService: PreloadService,
@Inject(PLATFORM_ID) private platformId: Object,
) {
this.webGlEnabled = this.stateService.isBrowser && detectWebGL();
}
ngOnInit(): void {
ngOnInit() {
this.websocketService.want(['blocks', 'mempool-blocks']);
this.paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5;
this.network = this.stateService.network;
this.itemsPerPage = this.stateService.env.ITEMS_PER_PAGE;
this.timeLtrSubscription = this.stateService.timeLtr.subscribe((ltr) => {
this.timeLtr = !!ltr;
@@ -127,6 +139,12 @@ export class BlockComponent implements OnInit, OnDestroy {
});
}
this.txsLoadingStatus$ = this.route.paramMap
.pipe(
switchMap(() => this.stateService.loadingIndicators$),
map((indicators) => indicators['blocktxs-' + this.blockHash] !== undefined ? indicators['blocktxs-' + this.blockHash] : 0)
);
this.cacheBlocksSubscription = this.cacheService.loadedBlocks$.subscribe((block) => {
this.loadedCacheBlock(block);
});
@@ -154,10 +172,11 @@ export class BlockComponent implements OnInit, OnDestroy {
}
});
this.block$ = this.route.paramMap.pipe(
const block$ = this.route.paramMap.pipe(
switchMap((params: ParamMap) => {
const blockHash: string = params.get('id') || '';
this.block = undefined;
this.page = 1;
this.error = undefined;
this.fees = undefined;
this.oobFees = 0;
@@ -235,11 +254,16 @@ export class BlockComponent implements OnInit, OnDestroy {
}
}),
tap((block: BlockExtended) => {
if (block.previousblockhash) {
this.preloadService.block$.next(block.previousblockhash);
if (this.auditSupported) {
this.preloadService.blockAudit$.next(block.previousblockhash);
}
if (block.height > 0) {
// Preload previous block summary (execute the http query so the response will be cached)
this.unsubscribeNextBlockSubscriptions();
setTimeout(() => {
this.nextBlockSubscription = this.apiService.getBlock$(block.previousblockhash).subscribe();
this.nextBlockTxListSubscription = this.electrsApiService.getBlockTransactions$(block.previousblockhash).subscribe();
if (this.auditSupported) {
this.apiService.getBlockAudit$(block.previousblockhash);
}
}, 100);
}
this.updateAuditAvailableFromBlockHeight(block.height);
this.block = block;
@@ -264,6 +288,9 @@ export class BlockComponent implements OnInit, OnDestroy {
this.fees = block.extras.reward / 100000000 - this.blockSubsidy;
}
this.stateService.markBlock$.next({ blockHeight: this.blockHeight });
this.isLoadingTransactions = true;
this.transactions = null;
this.transactionsError = null;
this.isLoadingOverview = true;
this.overviewError = null;
@@ -277,8 +304,31 @@ export class BlockComponent implements OnInit, OnDestroy {
throttleTime(300, asyncScheduler, { leading: true, trailing: true }),
shareReplay(1)
);
this.transactionSubscription = combineLatest([block$, this.route.queryParams]).pipe(
tap(([_, queryParams]) => this.page = +queryParams['page'] || 1),
switchMap(([block, _]) => this.electrsApiService.getBlockTransactions$(block.id, (this.page - 1) * this.itemsPerPage)
.pipe(
catchError((err) => {
this.transactionsError = err;
return of([]);
}))
),
)
.subscribe((transactions: Transaction[]) => {
if (this.fees === undefined && transactions[0]) {
this.fees = transactions[0].vout.reduce((acc: number, curr: Vout) => acc + curr.value, 0) / 100000000 - this.blockSubsidy;
}
this.transactions = transactions;
this.isLoadingTransactions = false;
this.cd.markForCheck();
},
(error) => {
this.error = error;
this.isLoadingBlock = false;
this.isLoadingOverview = false;
});
this.overviewSubscription = this.block$.pipe(
this.overviewSubscription = block$.pipe(
switchMap((block) => {
return forkJoin([
this.apiService.getStrippedBlockTransactions$(block.id)
@@ -295,12 +345,7 @@ export class BlockComponent implements OnInit, OnDestroy {
return of(null);
})
),
this.stateService.env.ACCELERATOR === true && block.height > 819500
? this.servicesApiService.getAccelerationHistory$({ blockHeight: block.height })
.pipe(catchError(() => {
return of([]);
}))
: of([])
this.stateService.env.ACCELERATOR === true && block.height > 819500 ? this.servicesApiService.getAccelerationHistory$({ blockHeight: block.height }) : of([])
]);
})
)
@@ -448,14 +493,14 @@ export class BlockComponent implements OnInit, OnDestroy {
this.cd.markForCheck();
});
this.oobSubscription = this.block$.pipe(
this.oobSubscription = block$.pipe(
filter(() => this.stateService.env.PUBLIC_ACCELERATIONS === true && this.stateService.network === ''),
switchMap((block) => this.apiService.getAccelerationsByHeight$(block.height)
.pipe(
map(accelerations => {
return { block, accelerations };
}),
catchError(() => {
catchError((err) => {
return of({ block, accelerations: [] });
}))
),
@@ -510,7 +555,7 @@ export class BlockComponent implements OnInit, OnDestroy {
if (this.priceSubscription) {
this.priceSubscription.unsubscribe();
}
this.priceSubscription = combineLatest([this.stateService.fiatCurrency$, this.block$]).pipe(
this.priceSubscription = combineLatest([this.stateService.fiatCurrency$, block$]).pipe(
switchMap(([currency, block]) => {
return this.priceService.getBlockPrice$(block.timestamp, true, currency).pipe(
tap((price) => {
@@ -527,27 +572,52 @@ export class BlockComponent implements OnInit, OnDestroy {
});
}
ngOnDestroy(): void {
ngOnDestroy() {
this.stateService.markBlock$.next({});
this.transactionSubscription?.unsubscribe();
this.overviewSubscription?.unsubscribe();
this.auditSubscription?.unsubscribe();
this.keyNavigationSubscription?.unsubscribe();
this.blocksSubscription?.unsubscribe();
this.cacheBlocksSubscription?.unsubscribe();
this.networkChangedSubscription?.unsubscribe();
this.queryParamsSubscription?.unsubscribe();
this.timeLtrSubscription?.unsubscribe();
this.auditSubscription?.unsubscribe();
this.unsubscribeNextBlockSubscriptions();
this.childChangeSubscription?.unsubscribe();
this.priceSubscription?.unsubscribe();
this.oobSubscription?.unsubscribe();
}
unsubscribeNextBlockSubscriptions() {
if (this.nextBlockSubscription !== undefined) {
this.nextBlockSubscription.unsubscribe();
}
if (this.nextBlockSummarySubscription !== undefined) {
this.nextBlockSummarySubscription.unsubscribe();
}
if (this.nextBlockTxListSubscription !== undefined) {
this.nextBlockTxListSubscription.unsubscribe();
}
}
// TODO - Refactor this.fees/this.reward for liquid because it is not
// used anymore on Bitcoin networks (we use block.extras directly)
setBlockSubsidy(): void {
setBlockSubsidy() {
this.blockSubsidy = 0;
}
toggleShowDetails(): void {
pageChange(page: number, target: HTMLElement) {
const start = (page - 1) * this.itemsPerPage;
this.isLoadingTransactions = true;
this.transactions = null;
this.transactionsError = null;
target.scrollIntoView(); // works for chrome
this.router.navigate([], { queryParams: { page: page }, queryParamsHandling: 'merge' });
}
toggleShowDetails() {
if (this.showDetails) {
this.showDetails = false;
this.router.navigate([], {
@@ -579,7 +649,7 @@ export class BlockComponent implements OnInit, OnDestroy {
return this.block && this.block.height > 681393 && (new Date().getTime() / 1000) < 1628640000;
}
navigateToPreviousBlock(): void {
navigateToPreviousBlock() {
if (!this.block) {
return;
}
@@ -588,13 +658,13 @@ export class BlockComponent implements OnInit, OnDestroy {
block ? block.id : this.block.previousblockhash], { state: { data: { block, blockHeight: this.nextBlockHeight - 2 } } });
}
navigateToNextBlock(): void {
navigateToNextBlock() {
const block = this.latestBlocks.find((b) => b.height === this.nextBlockHeight);
this.router.navigate([this.relativeUrlPipe.transform('/block/'),
block ? block.id : this.nextBlockHeight], { state: { data: { block, blockHeight: this.nextBlockHeight } } });
}
setNextAndPreviousBlockLink(): void {
setNextAndPreviousBlockLink(){
if (this.latestBlock) {
if (!this.blockHeight){
this.showPreviousBlocklink = false;
@@ -626,12 +696,11 @@ export class BlockComponent implements OnInit, OnDestroy {
}
}
onResize(event: Event): void {
const target = event.target as Window;
const isMobile = target.innerWidth <= 767.98;
onResize(event: any): void {
const isMobile = event.target.innerWidth <= 767.98;
const changed = isMobile !== this.isMobile;
this.isMobile = isMobile;
this.paginationMaxSize = target.innerWidth < 670 ? 3 : 5;
this.paginationMaxSize = event.target.innerWidth < 670 ? 3 : 5;
if (changed) {
this.changeMode(this.mode);
@@ -673,11 +742,11 @@ export class BlockComponent implements OnInit, OnDestroy {
this.stateService.hideAudit.next(this.auditModeEnabled);
this.route.queryParams.subscribe(params => {
const queryParams = { ...params };
let queryParams = { ...params };
delete queryParams['audit'];
let newUrl = this.router.url.split('?')[0];
const queryString = new URLSearchParams(queryParams).toString();
let queryString = new URLSearchParams(queryParams).toString();
if (queryString) {
newUrl += '?' + queryString;
}
@@ -755,10 +824,4 @@ export class BlockComponent implements OnInit, OnDestroy {
this.block.canonical = block.id;
}
}
updateBlockReward(blockReward: number): void {
if (this.fees === undefined) {
this.fees = blockReward;
}
}
}

View File

@@ -2,7 +2,6 @@ import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Routes, RouterModule } from '@angular/router';
import { BlockComponent } from './block.component';
import { BlockTransactionsComponent } from './block-transactions.component';
import { SharedModule } from '../../shared/shared.module';
const routes: Routes = [
@@ -33,7 +32,6 @@ export class BlockRoutingModule { }
],
declarations: [
BlockComponent,
BlockTransactionsComponent,
]
})
export class BlockModule { }

View File

@@ -71,7 +71,7 @@
color: var(--fg);
font-size: 0.8rem;
position: absolute;
bottom: 16.1em;
bottom: 15.8em;
left: 1px;
transform: translateX(-50%) rotate(90deg);
background: none;

View File

@@ -1,90 +0,0 @@
<div class="container-xl">
<div class="title-block justify-content-center">
<h1 i18n="testnet4.faucet">Testnet4 Faucet</h1>
</div>
<div class="faucet-container text-center">
@if (txid) {
<div class="alert alert-success w-100 text-truncate">
<fa-icon [icon]="['fas', 'circle-check']"></fa-icon>
Sent!
<a class="text-primary" [href]="'/testnet4/tx/' + txid">{{ txid }}</a>
</div>
}
@if (loading) {
<p>Loading faucet...</p>
<div class="spinner-border text-light"></div>
} @else if (!user) {
<!-- User not logged in -->
<div class="alert alert-mempool d-block text-center w-100">
<div class="d-inline align-middle">
<span>To use the faucet, please&nbsp;</span>
<a routerLink="/login" [queryParams]="{'redirectTo': '/testnet4/faucet'}">login</a>
<span class="mr-2">&nbsp;or</span>
</div>
<app-twitter-login customClass="btn btn-sm" width="220px" redirectTo="/testnet4/faucet" buttonString="Sign up with Twitter"></app-twitter-login>
</div>
}
@else if (error === 'not_available') {
<!-- User logged in but not a paid user or did not link its Twitter account -->
<div class="alert alert-mempool d-block text-center w-100">
<div class="d-inline align-middle">
<span class="mb-2 mr-2">To use the faucet, please</span>
</div>
<app-twitter-login customClass="btn btn-sm" width="180px" redirectTo="/testnet4/faucet" buttonString="Link your Twitter"></app-twitter-login>
</div>
}
@else if (error) {
<!-- User can request -->
<app-mempool-error class="w-100" [error]="error"></app-mempool-error>
}
@if (!loading) {
<form [formGroup]="faucetForm" class="formGroup" (submit)="requestCoins()">
<div class="row">
<div class="col">
<div class="form-group mb-0">
<div class="input-group input-group-lg">
<div class="input-group-prepend">
<span class="input-group-text" i18n="amount-sats">Amount (sats)</span>
</div>
<input type="number" class="form-control" [class]="{invalid: invalidAmount}" formControlName="satoshis" id="satoshis">
<div class="button-group">
<button type="button" class="btn btn-secondary" (click)="setAmount(5000)">5k</button>
<button type="button" class="btn btn-secondary ml-2" (click)="setAmount(50000)">50k</button>
<button type="button" class="btn btn-secondary ml-2" (click)="setAmount(500000)">500k</button>
</div>
</div>
<div class="text-danger text-left" *ngIf="invalidAmount">
<div *ngIf="amount?.errors?.['required']">Amount is required</div>
<div *ngIf="amount?.errors?.['min']">Minimum is {{ amount?.errors?.['min'].min | number }} tSats</div>
<div *ngIf="amount?.errors?.['max']">Maximum is {{ amount?.errors?.['max'].max | number }} tSats</div>
</div>
<div class="input-group input-group-lg mt-2">
<div class="input-group-prepend">
<span class="input-group-text" i18n="address">Address</span>
</div>
<input type="text" class="form-control" [class]="{invalid: invalidAddress}" formControlName="address" id="address" placeholder="tb1q...">
<button type="submit" class="btn btn-primary submit-button" [disabled]="!faucetForm.valid || !faucetForm.get('address')?.dirty || isDisabled()" i18n="testnet4.request-coins">Request Testnet4 Coins</button>
</div>
<div class="text-danger text-left" *ngIf="invalidAddress">
<div *ngIf="address?.errors?.['required']">Address is required</div>
<div *ngIf="address?.errors?.['pattern']">Must be a valid testnet4 address</div>
<div *ngIf="address?.errors?.['forbiddenAddress']">You cannot use this address</div>
</div>
</div>
</div>
</div>
</form>
}
<!-- Send back coins -->
@if (status?.address) {
<div class="mt-4 alert alert-info w-100">If you no longer need your testnet4 coins, please consider <a class="text-primary" [routerLink]="['/address/' | relativeUrl, status.address]"><u>sending them back</u></a> to replenish the faucet.</div>
}
</div>
</div>

View File

@@ -1,52 +0,0 @@
.formGroup {
width: 100%;
}
.input-group {
display: flex;
flex-wrap: wrap;
align-items: stretch;
justify-content: flex-end;
row-gap: 0.5rem;
gap: 0.5rem;
.form-control {
min-width: 160px;
flex-grow: 100;
}
.button-group {
display: flex;
align-items: stretch;
}
.submit-button, .button-group, .button-group .btn {
flex-grow: 1;
}
.submit-button:disabled {
cursor: not-allowed;
}
#satoshis::after {
content: 'sats';
position: absolute;
right: 0.5em;
top: 0;
bottom: 0;
}
}
.faucet-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
width: 100%;
max-width: 800px;
margin: auto;
}
.invalid {
border-width: 1px;
border-color: var(--red);
}

View File

@@ -1,184 +0,0 @@
import { Component, OnDestroy, OnInit, ChangeDetectorRef } from "@angular/core";
import { FormBuilder, FormGroup, Validators, ValidatorFn, AbstractControl, ValidationErrors } from "@angular/forms";
import { Subscription } from "rxjs";
import { StorageService } from "../../services/storage.service";
import { ServicesApiServices } from "../../services/services-api.service";
import { getRegex } from "../../shared/regex.utils";
import { StateService } from "../../services/state.service";
import { WebsocketService } from "../../services/websocket.service";
import { AudioService } from "../../services/audio.service";
import { HttpErrorResponse } from "@angular/common/http";
@Component({
selector: 'app-faucet',
templateUrl: './faucet.component.html',
styleUrls: ['./faucet.component.scss']
})
export class FaucetComponent implements OnInit, OnDestroy {
loading = true;
error: string = '';
user: any = undefined;
txid: string = '';
faucetStatusSubscription: Subscription;
status: {
min: number; // minimum amount to request at once (in sats)
max: number; // maximum amount to request at once
address?: string; // faucet address
code: 'ok' | 'faucet_not_available' | 'faucet_maximum_reached' | 'faucet_too_soon';
} | null = null;
faucetForm: FormGroup;
mempoolPositionSubscription: Subscription;
confirmationSubscription: Subscription;
constructor(
private cd: ChangeDetectorRef,
private storageService: StorageService,
private servicesApiService: ServicesApiServices,
private formBuilder: FormBuilder,
private stateService: StateService,
private websocketService: WebsocketService,
private audioService: AudioService
) {
this.initForm(5000, 500_000, null);
}
ngOnDestroy() {
this.stateService.markBlock$.next({});
this.websocketService.stopTrackingTransaction();
if (this.mempoolPositionSubscription) {
this.mempoolPositionSubscription.unsubscribe();
}
if (this.confirmationSubscription) {
this.confirmationSubscription.unsubscribe();
}
}
ngOnInit() {
this.user = this.storageService.getAuth()?.user ?? null;
if (!this.user) {
this.loading = false;
return;
}
// Setup form
this.updateFaucetStatus();
// Track transaction
this.websocketService.want(['blocks', 'mempool-blocks']);
this.mempoolPositionSubscription = this.stateService.mempoolTxPosition$.subscribe(txPosition => {
if (txPosition && txPosition.txid === this.txid) {
this.stateService.markBlock$.next({
txid: txPosition.txid,
mempoolPosition: txPosition.position,
});
}
});
this.confirmationSubscription = this.stateService.txConfirmed$.subscribe(([txConfirmed, block]) => {
if (txConfirmed && txConfirmed === this.txid) {
this.stateService.markBlock$.next({ blockHeight: block.height });
}
});
}
updateFaucetStatus(): void {
this.servicesApiService.getFaucetStatus$().subscribe({
next: (status) => {
if (!status) {
this.error = 'internal_server_error';
return;
}
this.status = status;
if (this.status.code !== 'ok') {
this.error = this.status.code;
this.updateForm(this.status.min ?? 5000, this.status.max ?? 500_000, this.status.address);
return;
}
// update the form with the proper validation parameters
this.updateForm(this.status.min, this.status.max, this.status.address);
},
error: (response) => {
this.loading = false;
this.error = response.error;
this.cd.markForCheck();
}
});
}
requestCoins(): void {
if (this.isDisabled()) {
return;
}
this.error = null;
this.txid = '';
this.stateService.markBlock$.next({});
this.servicesApiService.requestTestnet4Coins$(this.faucetForm.get('address')?.value, parseInt(this.faucetForm.get('satoshis')?.value))
.subscribe({
next: ((response) => {
this.txid = response.txid;
this.websocketService.startTrackTransaction(this.txid);
this.audioService.playSound('cha-ching');
this.updateFaucetStatus();
this.cd.markForCheck();
}),
error: (response: HttpErrorResponse) => {
this.error = response.error;
},
});
}
isDisabled(): boolean {
return !(this.user && this.status?.code === 'ok' && !this.error);
}
getNotFaucetAddressValidator(faucetAddress: string): ValidatorFn {
return faucetAddress ? (control: AbstractControl): ValidationErrors | null => {
const forbidden = control.value === faucetAddress;
return forbidden ? { forbiddenAddress: { value: control.value } } : null;
}: () => null;
}
initForm(min: number, max: number, faucetAddress: string): void {
this.faucetForm = this.formBuilder.group({
'address': ['', [Validators.required, Validators.pattern(getRegex('address', 'testnet4')), this.getNotFaucetAddressValidator(faucetAddress)]],
'satoshis': [min, [Validators.required, Validators.min(min), Validators.max(max)]]
});
this.loading = false;
this.cd.markForCheck();
}
updateForm(min, max, faucetAddress: string): void {
if (!this.faucetForm) {
this.initForm(min, max, faucetAddress);
} else {
this.faucetForm.get('address').setValidators([Validators.required, Validators.pattern(getRegex('address', 'testnet4')), this.getNotFaucetAddressValidator(faucetAddress)]);
this.faucetForm.get('satoshis').setValidators([Validators.required, Validators.min(min), Validators.max(max)]);
this.faucetForm.get('satoshis').setValue(Math.max(min, this.faucetForm.get('satoshis').value));
this.faucetForm.get('satoshis').updateValueAndValidity();
this.faucetForm.get('satoshis').markAsDirty();
}
}
setAmount(value: number): void {
if (this.faucetForm) {
this.faucetForm.get('satoshis').setValue(value);
this.faucetForm.get('satoshis').updateValueAndValidity();
this.faucetForm.get('satoshis').markAsDirty();
}
}
get amount() { return this.faucetForm.get('satoshis')!; }
get invalidAmount() {
const amount = this.faucetForm.get('satoshis')!;
return amount?.invalid && (amount.dirty || amount.touched)
}
get address() { return this.faucetForm.get('address')!; }
get invalidAddress() {
const address = this.faucetForm.get('address')!;
return address?.invalid && (address.dirty || address.touched)
}
}

View File

@@ -1,9 +1,9 @@
<div *ngIf="stateService.env.MINING_DASHBOARD || stateService.env.LIGHTNING || stateService.env.ACCELERATOR" class="mb-3 d-flex menu" [style]="{'flex-wrap': flexWrap ? 'wrap' : ''}">
<div *ngIf="stateService.env.MINING_DASHBOARD || stateService.env.LIGHTNING" class="mb-3 d-flex menu">
<a routerLinkActive="active" class="btn btn-primary w-33"
<a routerLinkActive="active" class="btn btn-primary" [class]="padding"
[routerLink]="['/graphs/mempool' | relativeUrl]">Mempool</a>
<div ngbDropdown class="w-33" *ngIf="stateService.env.MINING_DASHBOARD">
<div ngbDropdown [class]="padding" *ngIf="stateService.env.MINING_DASHBOARD">
<button class="btn btn-primary w-100" id="dropdownBasic1" ngbDropdownToggle i18n="mining">Mining</button>
<div ngbDropdownMenu aria-labelledby="dropdownBasic1">
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/mining/pools' | relativeUrl]"
@@ -17,8 +17,6 @@
i18n="mining.block-fee-rates">Block Fee Rates</a>
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/mining/block-fees' | relativeUrl]"
i18n="mining.block-fees">Block Fees</a>
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/mining/block-fees-subsidy' | relativeUrl]"
i18n="mining.block-fees">Block Fees Vs Subsidy</a>
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/mining/block-rewards' | relativeUrl]"
i18n="mining.block-rewards">Block Rewards</a>
<a class="dropdown-item" routerLinkActive="active"
@@ -28,7 +26,7 @@
</div>
</div>
<div ngbDropdown class="w-33" *ngIf="stateService.networkSupportsLightning()">
<div ngbDropdown [class]="padding" *ngIf="stateService.env.LIGHTNING">
<button class="btn btn-primary w-100" id="dropdownBasic1" ngbDropdownToggle i18n="lightning">Lightning</button>
<div ngbDropdownMenu aria-labelledby="dropdownBasic1">
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/lightning/nodes-networks' | relativeUrl]"
@@ -45,14 +43,6 @@
i18n="lightning.nodes-channels-world-map">Lightning Nodes Channels World Map</a>
</div>
</div>
<div ngbDropdown class="w-33" *ngIf="stateService.env.ACCELERATOR">
<button class="btn btn-primary w-100" id="dropdownBasic1" ngbDropdownToggle i18n="accelerator.accelerations">Accelerations</button>
<div ngbDropdownMenu aria-labelledby="dropdownBasic1">
<a class="dropdown-item" routerLinkActive="active" [routerLink]="['/graphs/acceleration/fees' | relativeUrl]"
i18n="accelerator.acceleration-fees">Acceleration Fees</a>
</div>
</div>
</div>
<router-outlet></router-outlet>

View File

@@ -2,7 +2,7 @@
flex-grow: 1;
padding: 0 35px;
@media (min-width: 576px) {
max-width: 600px;
max-width: 400px;
}
& > * {
@@ -11,6 +11,5 @@
&.last-child {
margin-inline-end: 0;
}
margin-bottom: 5px;
}
}

View File

@@ -8,7 +8,7 @@ import { WebsocketService } from '../../services/websocket.service';
styleUrls: ['./graphs.component.scss'],
})
export class GraphsComponent implements OnInit {
flexWrap = false;
padding = 'w-50';
constructor(
public stateService: StateService,
@@ -18,8 +18,8 @@ export class GraphsComponent implements OnInit {
ngOnInit(): void {
this.websocketService.want(['blocks']);
if (this.stateService.env.ACCELERATOR === true && (this.stateService.env.MINING_DASHBOARD === true || this.stateService.env.LIGHTNING === true)) {
this.flexWrap = true;
if (this.stateService.env.MINING_DASHBOARD === true && this.stateService.env.LIGHTNING === true) {
this.padding = 'w-33';
}
}
}

View File

@@ -31,7 +31,7 @@
</div>
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(hashrateObservable$ | async) as stats">
<div class="btn-group btn-group-toggle" name="radioBasic">
<div class="btn-group btn-group-toggle" name="radioBasic" [class]="{'disabled': isLoading}">
<label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 12960" [class.active]="radioGroupForm.get('dateSpan').value === '3m'">
<input type="radio" [value]="'3m'" fragment="3m" [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" [attr.data-cy]="'3m'" formControlName="dateSpan"> 3M
</label>
@@ -54,7 +54,7 @@
</form>
</div>
<div [class]="!widget ? 'chart' : 'chart-widget'" *browserOnly [style]="{ height: widget ? ((height + 20) + 'px') : null, opacity: isLoading ? 0.5 : 1 }" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
<div [class]="!widget ? 'chart' : 'chart-widget'" *browserOnly [style]="{ height: widget ? ((height + 20) + 'px') : null}" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
(chartInit)="onChartInit($event)">
</div>
<div class="text-center loadingGraphs" *ngIf="!stateService.isBrowser || isLoading">

View File

@@ -113,3 +113,8 @@
max-width: 80px;
margin: 15px auto 3px;
}
.disabled {
pointer-events: none;
opacity: 0.5;
}

View File

@@ -349,9 +349,7 @@ export class HashrateChartComponent implements OnInit {
const selectedPowerOfTen: any = selectPowerOfTen(val);
const newVal = Math.round(val / selectedPowerOfTen.divider);
return `${newVal} ${selectedPowerOfTen.unit}H/s`;
},
showMinLabel: false,
showMaxLabel: false,
}
},
splitLine: {
lineStyle: {
@@ -383,9 +381,7 @@ export class HashrateChartComponent implements OnInit {
const selectedPowerOfTen: any = selectPowerOfTen(val);
const newVal = Math.round(val / selectedPowerOfTen.divider);
return `${newVal} ${selectedPowerOfTen.unit}`;
},
showMinLabel: false,
showMaxLabel: false,
}
},
splitLine: {
show: false,

View File

@@ -11,7 +11,7 @@
</div>
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(hashrateObservable$ | async) as stats">
<div class="btn-group btn-group-toggle" name="radioBasic">
<div class="btn-group btn-group-toggle" name="radioBasic" [class]="{'disabled': isLoading}">
<label class="btn btn-primary btn-sm" *ngIf="stats.blockCount >= 25920" [class.active]="radioGroupForm.get('dateSpan').value === '6m'">
<input type="radio" [value]="'6m'" fragment="6m" [routerLink]="['/graphs/mining/pools-dominance' | relativeUrl]" [attr.data-cy]="'6m'" formControlName="dateSpan"> 6M
</label>
@@ -32,7 +32,7 @@
</div>
<div class="chart" *browserOnly echarts [initOpts]="chartInitOptions" [options]="chartOptions"
(chartInit)="onChartInit($event)" [style]="{opacity: isLoading ? 0.5 : 1}">
(chartInit)="onChartInit($event)">
</div>
<div class="text-center loadingGraphs" *ngIf="!stateService.isBrowser || isLoading">
<div class="spinner-border text-light"></div>

View File

@@ -64,3 +64,8 @@
.loadingGraphs.widget {
top: 75%;
}
.disabled {
pointer-events: none;
opacity: 0.5;
}

View File

@@ -1,5 +1,5 @@
<div class="echarts" *browserOnly echarts [initOpts]="mempoolStatsChartInitOption" [options]="mempoolStatsChartOption" (chartRendered)="rendered()"
(chartInit)="onChartInit($event)" [style]="{opacity: isLoading ? 0.5 : 1}">
(chartInit)="onChartInit($event)">
</div>
<div class="text-center loadingGraphs" *ngIf="!stateService.isBrowser || isLoading">
<div class="spinner-border text-light"></div>

View File

@@ -32,8 +32,8 @@ export class IncomingTransactionsGraphComponent implements OnInit, OnChanges, On
@Input() template: ('widget' | 'advanced') = 'widget';
@Input() windowPreferenceOverride: string;
@Input() outlierCappingEnabled: boolean = false;
@Input() isLoading: boolean;
isLoading = true;
mempoolStatsChartOption: EChartsOption = {};
mempoolStatsChartInitOption = {
renderer: 'svg'
@@ -52,6 +52,8 @@ export class IncomingTransactionsGraphComponent implements OnInit, OnChanges, On
) { }
ngOnInit() {
this.isLoading = true;
this.rateUnitSub = this.stateService.rateUnits$.subscribe(rateUnits => {
this.weightMode = rateUnits === 'wu';
if (this.data) {
@@ -77,6 +79,7 @@ export class IncomingTransactionsGraphComponent implements OnInit, OnChanges, On
if (!this.data) {
return;
}
this.isLoading = false;
}
/**
@@ -223,7 +226,7 @@ export class IncomingTransactionsGraphComponent implements OnInit, OnChanges, On
itemFormatted += `<div class="item">
<div class="indicator-container">${colorSpan(bestItem.color)}</div>
<div class="grow"></div>
<div class="value">${formatNumber(bestItem.value[1], this.locale, '1.0-0')} <span class="symbol">vB/s</span></div>
<div class="value">${formatNumber(bestItem.value[1], this.locale, '1.0-0')}<span class="symbol">vB/s</span></div>
</div>`;
}
return `<div class="tx-wrapper-tooltip-chart ${(this.template === 'advanced') ? 'tx-wrapper-tooltip-chart-advanced' : ''}"

View File

@@ -5,7 +5,7 @@
<!-- Hamburger -->
<ng-container *ngIf="servicesEnabled">
<div *ngIf="user" class="profile_image_container" [class]="{'anon': !user.imageMd5}" (click)="hamburgerClick($event)">
<img *ngIf="user.imageMd5" [src]="'/api/v1/services/account/images/' + user.username + '/md5=' + user.imageMd5" class="profile_image">
<img *ngIf="user.imageMd5" [src]="'/api/v1/services/account/images/' + user.username + '?md5=' + user.imageMd5" class="profile_image">
<app-svg-images style="color: lightgrey; fill: lightgray" *ngIf="!user.imageMd5" name="anon"></app-svg-images>
</div>
<div *ngIf="false && user === null" class="profile_image_container" (click)="hamburgerClick($event)">
@@ -70,7 +70,7 @@
<a ngbDropdownItem class="mainnet" [routerLink]="networkPaths['mainnet'] || '/'"><app-svg-images name="bitcoin" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Mainnet</a>
<a ngbDropdownItem *ngIf="env.SIGNET_ENABLED" class="signet" [class.active]="network.val === 'signet'" [routerLink]="networkPaths['signet'] || '/signet'"><app-svg-images name="signet" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Signet</a>
<a ngbDropdownItem *ngIf="env.TESTNET_ENABLED" class="testnet" [class.active]="network.val === 'testnet'" [routerLink]="networkPaths['testnet'] || '/testnet'"><app-svg-images name="testnet" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Testnet3</a>
<a ngbDropdownItem *ngIf="env.TESTNET4_ENABLED" class="testnet4" [class.active]="network.val === 'testnet4'" [routerLink]="networkPaths['testnet4'] || '/testnet4'"><app-svg-images name="testnet4" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Testnet4 <span class="badge badge-pill badge-warning beta-network" i18n="beta">beta</span></a>
<a ngbDropdownItem *ngIf="env.TESTNET4_ENABLED" class="testnet" [class.active]="network.val === 'testnet4'" [routerLink]="networkPaths['testnet4'] || '/testnet4'"><app-svg-images name="testnet4" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Testnet4 <span class="badge badge-pill badge-warning beta-network" i18n="beta">beta</span></a>
<h6 *ngIf="env.LIQUID_ENABLED" class="dropdown-header" i18n="master-page.layer2-networks-header">Layer 2 Networks</h6>
<a [href]="env.LIQUID_WEBSITE_URL + urlLanguage + (networkPaths['liquid'] || '')" ngbDropdownItem *ngIf="env.LIQUID_ENABLED" class="liquid" [class.active]="network.val === 'liquid'"><app-svg-images name="liquid" width="22" height="22" viewBox="0 0 125 125" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Liquid</a>
<a [href]="env.LIQUID_WEBSITE_URL + urlLanguage + (networkPaths['liquidtestnet'] || '/testnet')" ngbDropdownItem *ngIf="env.LIQUID_TESTNET_ENABLED" class="liquidtestnet" [class.active]="network.val === 'liquid'"><app-svg-images name="liquidtestnet" width="22" height="22" viewBox="0 0 125 125" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Liquid Testnet</a>
@@ -92,7 +92,7 @@
<li class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" id="btn-pools" *ngIf="stateService.env.MINING_DASHBOARD">
<a class="nav-link" [routerLink]="['/mining' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'hammer']" [fixedWidth]="true" i18n-title="mining.mining-dashboard" title="Mining Dashboard"></fa-icon></a>
</li>
<li class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" id="btn-lightning" *ngIf="stateService.networkSupportsLightning()">
<li class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" id="btn-lightning" *ngIf="stateService.env.LIGHTNING && lightningNetworks.includes(stateService.network)">
<a class="nav-link" [routerLink]="['/lightning' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'bolt']" [fixedWidth]="true" i18n-title="master-page.lightning" title="Lightning Explorer"></fa-icon>
</a>
</li>
@@ -102,9 +102,6 @@
<li class="nav-item" routerLinkActive="active" id="btn-graphs">
<a class="nav-link" [routerLink]="['/graphs' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'chart-area']" [fixedWidth]="true" i18n-title="master-page.graphs" title="Graphs"></fa-icon></a>
</li>
<li class="nav-item" routerLinkActive="active" id="btn-faucet" *ngIf="stateService.isMempoolSpaceBuild && stateService.env.OFFICIAL_MEMPOOL_SPACE && stateService.network === 'testnet4'">
<a class="nav-link" [routerLink]="['/faucet' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'faucet-drip']" [fixedWidth]="true" i18n-title="master-page.faucet" title="Faucet"></fa-icon></a>
</li>
<li class="nav-item" routerLinkActive="active" id="btn-docs">
<a class="nav-link" [routerLink]="['/docs' | relativeUrl ]" (click)="collapse()"><fa-icon [icon]="['fas', 'book']" [fixedWidth]="true" i18n-title="documentation.title" title="Documentation"></fa-icon></a>
</li>

View File

@@ -27,6 +27,7 @@ export class MasterPageComponent implements OnInit, OnDestroy {
subdomain = '';
networkPaths: { [network: string]: string };
networkPaths$: Observable<Record<string, string>>;
lightningNetworks = ['', 'mainnet', 'bitcoin', 'testnet', 'signet'];
footerVisible = true;
user: any = undefined;
servicesEnabled = false;

View File

@@ -1,5 +1,4 @@
<div *browserOnly echarts class="echarts" (chartInit)="onChartReady($event)" (chartRendered)="rendered()" [initOpts]="mempoolVsizeFeesInitOptions"
[options]="mempoolVsizeFeesOptions" [style]="{opacity: isLoading ? 0.5 : 1}"></div>
<div *browserOnly echarts class="echarts" (chartInit)="onChartReady($event)" (chartRendered)="rendered()" [initOpts]="mempoolVsizeFeesInitOptions" [options]="mempoolVsizeFeesOptions"></div>
<div class="text-center loadingGraphs" *ngIf="!stateService.isBrowser || isLoading">
<div class="spinner-border text-light"></div>
</div>

View File

@@ -35,8 +35,8 @@ export class MempoolGraphComponent implements OnInit, OnChanges {
@Input() template: ('widget' | 'advanced') = 'widget';
@Input() showZoom = true;
@Input() windowPreferenceOverride: string;
@Input() isLoading: boolean;
isLoading = true;
mempoolVsizeFeesData: any;
mempoolVsizeFeesOptions: EChartsOption;
mempoolVsizeFeesInitOptions = {
@@ -65,6 +65,7 @@ export class MempoolGraphComponent implements OnInit, OnChanges {
) { }
ngOnInit(): void {
this.isLoading = true;
this.inverted = this.storageService.getValue('inverted-graph') === 'true';
this.isWidget = this.template === 'widget';
this.showCount = !this.isWidget && !this.hideCount;
@@ -85,6 +86,7 @@ export class MempoolGraphComponent implements OnInit, OnChanges {
if (!this.data) {
return;
}
this.isLoading = false;
}
onChartReady(myChart: any) {

View File

@@ -29,7 +29,7 @@
<div class="card">
<div class="card-body pl-2 pr-2">
<div class="mempool-graph">
<app-pool-ranking [height]="poolGraphHeight" [attr.data-cy]="'pool-distribution'" [widget]=true></app-pool-ranking>
<app-pool-ranking [height]="graphHeight" [attr.data-cy]="'pool-distribution'" [widget]=true></app-pool-ranking>
</div>
<div class="mt-1"><a [attr.data-cy]="'pool-distribution-view-more'" [routerLink]="['/graphs/mining/pools' | relativeUrl]" i18n="dashboard.view-more">View more &raquo;</a></div>
</div>
@@ -41,7 +41,7 @@
<div class="card">
<div class="card-body pl-lg-3 pr-lg-3 pl-2 pr-2">
<div class="fixed-mempool-graph">
<app-hashrate-chart [height]="hashrateGraphHeight" [attr.data-cy]="'hashrate-graph'" [widget]="true"></app-hashrate-chart>
<app-hashrate-chart [height]="graphHeight" [attr.data-cy]="'hashrate-graph'" [widget]="true"></app-hashrate-chart>
</div>
<div class="mt-1"><a [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" fragment="1y" i18n="dashboard.view-more">View more &raquo;</a></div>
</div>

View File

@@ -12,8 +12,7 @@ import { EventType, NavigationStart, Router } from '@angular/router';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MiningDashboardComponent implements OnInit, AfterViewInit {
hashrateGraphHeight = 335;
poolGraphHeight = 375;
graphHeight = 375;
constructor(
private seoService: SeoService,
@@ -45,14 +44,11 @@ export class MiningDashboardComponent implements OnInit, AfterViewInit {
@HostListener('window:resize', ['$event'])
onResize(): void {
if (window.innerWidth >= 992) {
this.hashrateGraphHeight = 335;
this.poolGraphHeight = 375;
this.graphHeight = 335;
} else if (window.innerWidth >= 768) {
this.hashrateGraphHeight = 245;
this.poolGraphHeight = 265;
this.graphHeight = 245;
} else {
this.hashrateGraphHeight = 240;
this.poolGraphHeight = 240;
this.graphHeight = 240;
}
}
}

View File

@@ -40,7 +40,7 @@
</div>
<form [formGroup]="radioGroupForm" class="formRadioGroup"
*ngIf="!widget && (miningStatsObservable$ | async) as stats">
<div class="btn-group btn-group-toggle" name="radioBasic">
<div class="btn-group btn-group-toggle" name="radioBasic" [class]="{'disabled': isLoading}">
<label class="btn btn-primary btn-sm" *ngIf="stats.totalBlockCount >= 144" [class.active]="radioGroupForm.get('dateSpan').value === '24h'">
<input type="radio" [value]="'24h'" fragment="24h" [routerLink]="['/graphs/mining/pools' | relativeUrl]" [attr.data-cy]="'24h'" formControlName="dateSpan"> 24h
</label>
@@ -76,7 +76,7 @@
</div>
<div [class]="!widget ? '' : 'pb-0'" class="container pb-lg-0">
<div [class]="widget ? 'chart-widget' : 'chart'" *browserOnly [style]="{ height: widget ? (height + 'px') : null, opacity: isLoading ? 0.5 : 1 }" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
<div [class]="widget ? 'chart-widget' : 'chart'" *browserOnly [style]="{ height: widget ? (height + 'px') : null}" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
(chartInit)="onChartInit($event)">
</div>
@@ -84,7 +84,7 @@
<div class="spinner-border text-light"></div>
</div>
<table *ngIf="widget === false" class="table table-borderless text-center pools-table" [style]="{opacity: isLoading ? 0.5 : 1}">
<table *ngIf="widget === false" class="table table-borderless text-center pools-table">
<thead>
<tr>
<th class="d-none d-md-table-cell" i18n="mining.rank">Rank</th>

View File

@@ -24,7 +24,6 @@
@media (max-width: 767.98px) {
max-height: 230px;
}
margin-bottom: 20px;
}
.chart-widget {
width: 100%;
@@ -122,6 +121,10 @@
margin: 15px auto 3px;
}
.disabled {
pointer-events: none;
opacity: 0.5;
}
td {
.difference {

View File

@@ -3,7 +3,7 @@ import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { EChartsOption, PieSeriesOption } from '../../graphs/echarts';
import { merge, Observable } from 'rxjs';
import { map, shareReplay, startWith, switchMap, tap } from 'rxjs/operators';
import { map, share, startWith, switchMap, tap } from 'rxjs/operators';
import { SeoService } from '../../services/seo.service';
import { StorageService } from '../..//services/storage.service';
import { MiningService, MiningStats } from '../../services/mining.service';
@@ -107,7 +107,7 @@ export class PoolRankingComponent implements OnInit {
this.isLoading = false;
this.prepareChartOptions(data);
}),
shareReplay(1)
share()
);
}

View File

@@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, Component, Inject, Input, LOCALE_ID, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { echarts, EChartsOption } from '../../graphs/echarts';
import { BehaviorSubject, Observable, Subscription, combineLatest, of } from 'rxjs';
import { BehaviorSubject, Observable, combineLatest, of, timer } from 'rxjs';
import { catchError, distinctUntilChanged, filter, map, share, switchMap, tap } from 'rxjs/operators';
import { BlockExtended, PoolStat } from '../../interfaces/node-api.interface';
import { ApiService } from '../../services/api.service';
@@ -28,7 +28,6 @@ export class PoolComponent implements OnInit {
gfg = true;
formatNumber = formatNumber;
slugSubscription: Subscription;
poolStats$: Observable<PoolStat>;
blocks$: Observable<BlockExtended[]>;
oobFees$: Observable<AccelerationTotal[]>;
@@ -57,24 +56,38 @@ export class PoolComponent implements OnInit {
}
ngOnInit(): void {
this.slugSubscription = this.route.params.pipe(map((params) => params.slug)).subscribe((slug) => {
this.isLoading = true;
this.blocks = [];
this.chartOptions = {};
this.slug = slug;
this.initializeObservables();
});
}
initializeObservables(): void {
this.poolStats$ = this.apiService.getPoolHashrate$(this.slug)
this.poolStats$ = this.route.params.pipe(map((params) => params.slug))
.pipe(
switchMap((data) => {
this.isLoading = false;
const hashrate = data.map(val => [val.timestamp * 1000, val.avgHashrate]);
const share = data.map(val => [val.timestamp * 1000, val.share * 100]);
this.prepareChartOptions(hashrate, share);
return this.apiService.getPoolStats$(this.slug);
switchMap((slug: any) => {
this.isLoading = true;
this.slug = slug;
return this.apiService.getPoolHashrate$(this.slug)
.pipe(
switchMap((data) => {
this.isLoading = false;
const hashrate = data.map(val => [val.timestamp * 1000, val.avgHashrate]);
const share = data.map(val => [val.timestamp * 1000, val.share * 100]);
this.prepareChartOptions(hashrate, share);
return [slug];
}),
catchError(() => {
this.isLoading = false;
this.seoService.logSoft404();
return of([slug]);
})
);
}),
switchMap((slug) => {
return this.apiService.getPoolStats$(slug).pipe(
catchError(() => {
this.isLoading = false;
this.seoService.logSoft404();
return of(null);
})
);
}),
tap(() => {
this.loadMoreSubject.next(this.blocks[0]?.height);
}),
map((poolStats) => {
this.seoService.setTitle(poolStats.pool.name);
@@ -88,12 +101,7 @@ export class PoolComponent implements OnInit {
return Object.assign({
logo: `/resources/mining-pools/` + poolStats.pool.slug + '.svg'
}, poolStats);
}),
catchError(() => {
this.isLoading = false;
this.seoService.logSoft404();
return of(null);
}),
})
);
this.blocks$ = this.loadMoreSubject
@@ -320,8 +328,4 @@ export class PoolComponent implements OnInit {
trackByBlock(index: number, block: BlockExtended) {
return block.height;
}
ngOnDestroy(): void {
this.slugSubscription.unsubscribe();
}
}

View File

@@ -5,8 +5,6 @@ import { StateService } from '../../services/state.service';
import { SeoService } from '../../services/seo.service';
import { OpenGraphService } from '../../services/opengraph.service';
import { seoDescriptionNetwork } from '../../shared/common.utils';
import { ActivatedRoute, Router } from '@angular/router';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
@Component({
selector: 'app-push-transaction',
@@ -25,9 +23,6 @@ export class PushTransactionComponent implements OnInit {
public stateService: StateService,
private seoService: SeoService,
private ogService: OpenGraphService,
private route: ActivatedRoute,
private router: Router,
private relativeUrlPipe: RelativeUrlPipe,
) { }
ngOnInit(): void {
@@ -38,100 +33,27 @@ export class PushTransactionComponent implements OnInit {
this.seoService.setTitle($localize`:@@meta.title.push-tx:Broadcast Transaction`);
this.seoService.setDescription($localize`:@@meta.description.push-tx:Broadcast a transaction to the ${this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet'?'Liquid':'Bitcoin'}${seoDescriptionNetwork(this.stateService.network)} network using the transaction's hash.`);
this.ogService.setManualOgImage('tx-push.jpg');
this.route.fragment.subscribe(async (fragment) => {
const fragmentParams = new URLSearchParams(fragment || '');
return this.handleColdcardPushTx(fragmentParams);
});
}
async postTx(hex?: string): Promise<string> {
postTx() {
this.isLoading = true;
this.error = '';
this.txId = '';
return new Promise((resolve, reject) => {
this.apiService.postTransaction$(hex || this.pushTxForm.get('txHash').value)
this.apiService.postTransaction$(this.pushTxForm.get('txHash').value)
.subscribe((result) => {
this.isLoading = false;
this.txId = result;
this.pushTxForm.reset();
resolve(this.txId);
},
(error) => {
if (typeof error.error === 'string') {
const matchText = error.error.match('"message":"(.*?)"');
this.error = 'Failed to broadcast transaction, reason: ' + (matchText && matchText[1] || error.error);
this.error = matchText && matchText[1] || error.error;
} else if (error.message) {
this.error = 'Failed to broadcast transaction, reason: ' + error.message;
this.error = error.message;
}
this.isLoading = false;
reject(this.error);
});
});
}
private async handleColdcardPushTx(fragmentParams: URLSearchParams): Promise<boolean> {
// maybe conforms to Coldcard nfc-pushtx spec
if (fragmentParams && fragmentParams.get('t')) {
try {
const pushNetwork = fragmentParams.get('n');
// Redirect to the appropriate network-specific URL
if (this.stateService.network !== '' && !pushNetwork) {
this.router.navigateByUrl(`/pushtx#${fragmentParams.toString()}`);
return false;
} else if (this.stateService.network !== 'testnet' && pushNetwork === 'XTN') {
this.router.navigateByUrl(`/testnet/pushtx#${fragmentParams.toString()}`);
return false;
} else if (pushNetwork === 'XRT') {
this.error = 'Regtest is not supported';
return false;
} else if (pushNetwork && !['XTN', 'XRT'].includes(pushNetwork)) {
this.error = 'Invalid network';
return false;
}
const rawTx = this.base64UrlToU8Array(fragmentParams.get('t'));
if (!fragmentParams.get('c')) {
this.error = 'Missing checksum, URL is probably truncated';
return false;
}
const rawCheck = this.base64UrlToU8Array(fragmentParams.get('c'));
// check checksum
const hashTx = await crypto.subtle.digest('SHA-256', rawTx);
if (this.u8ArrayToHex(new Uint8Array(hashTx.slice(24))) !== this.u8ArrayToHex(rawCheck)) {
this.error = 'Bad checksum, URL is probably truncated';
return false;
}
const hexTx = this.u8ArrayToHex(rawTx);
this.pushTxForm.get('txHash').setValue(hexTx);
try {
const txid = await this.postTx(hexTx);
this.router.navigate([this.relativeUrlPipe.transform('/tx'), txid]);
} catch (e) {
// error already handled
return false;
}
return true;
} catch (e) {
this.error = 'Failed to decode transaction';
return false;
}
}
}
private base64UrlToU8Array(base64Url: string): Uint8Array {
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/').padEnd(base64Url.length + (4 - base64Url.length % 4) % 4, '=');
const binaryString = atob(base64);
return new Uint8Array([...binaryString].map(char => char.charCodeAt(0)));
}
private u8ArrayToHex(arr: Uint8Array): string {
return Array.from(arr).map(byte => byte.toString(16).padStart(2, '0')).join('');
}
}

View File

@@ -22,7 +22,6 @@ export class SearchFormComponent implements OnInit {
env: Env;
network = '';
assets: object = {};
pools: object[] = [];
isSearching = false;
isTypeaheading$ = new BehaviorSubject<boolean>(false);
typeAhead$: Observable<any>;
@@ -119,16 +118,14 @@ export class SearchFormComponent implements OnInit {
if (!text.length) {
return of([
[],
{ nodes: [], channels: [] },
this.pools
{ nodes: [], channels: [] }
]);
}
this.isTypeaheading$.next(true);
if (!this.stateService.networkSupportsLightning()) {
if (!this.stateService.env.LIGHTNING) {
return zip(
this.electrsApiService.getAddressesByPrefix$(text).pipe(catchError(() => of([]))),
[{ nodes: [], channels: [] }],
this.getMiningPools()
);
}
return zip(
@@ -137,7 +134,6 @@ export class SearchFormComponent implements OnInit {
nodes: [],
channels: [],
}))),
this.getMiningPools()
);
}),
map((result: any[]) => {
@@ -157,14 +153,11 @@ export class SearchFormComponent implements OnInit {
{
nodes: [],
channels: [],
},
this.pools
}
]))
]
).pipe(
map((latestData) => {
this.pools = latestData[1][2] || [];
let searchText = latestData[0];
if (!searchText.length) {
return {
@@ -178,7 +171,6 @@ export class SearchFormComponent implements OnInit {
nodes: [],
channels: [],
liquidAsset: [],
pools: []
};
}
@@ -195,19 +187,13 @@ export class SearchFormComponent implements OnInit {
const matchesTxId = this.regexTransaction.test(searchText) && !this.regexBlockhash.test(searchText);
const matchesBlockHash = this.regexBlockhash.test(searchText);
const matchesAddress = !matchesTxId && this.regexAddress.test(searchText);
const publicKey = matchesAddress && searchText.startsWith('0');
const otherNetworks = findOtherNetworks(searchText, this.network as any || 'mainnet', this.env);
const liquidAsset = this.assets ? (this.assets[searchText] || []) : [];
const pools = this.pools.filter(pool => pool["name"].toLowerCase().includes(searchText.toLowerCase())).slice(0, 10);
if (matchesDateTime && searchText.indexOf('/') !== -1) {
searchText = searchText.replace(/\//g, '-');
}
if (publicKey) {
otherNetworks.length = 0;
}
return {
searchText: searchText,
hashQuickMatch: +(matchesBlockHeight || matchesBlockHash || matchesTxId || matchesAddress || matchesUnixTimestamp || matchesDateTime),
@@ -217,13 +203,11 @@ export class SearchFormComponent implements OnInit {
txId: matchesTxId,
blockHash: matchesBlockHash,
address: matchesAddress,
publicKey: publicKey,
addresses: matchesAddress && addressPrefixSearchResults.length === 1 && searchText === addressPrefixSearchResults[0] ? [] : addressPrefixSearchResults, // If there is only one address and it matches the search text, don't show it in the dropdown
otherNetworks: otherNetworks,
nodes: lightningResults.nodes,
channels: lightningResults.channels,
liquidAsset: liquidAsset,
pools: pools
};
})
);
@@ -255,8 +239,6 @@ export class SearchFormComponent implements OnInit {
});
this.isSearching = false;
}
} else if (result.slug) {
this.navigate('/mining/pool/', result.slug);
}
}
@@ -322,29 +304,4 @@ export class SearchFormComponent implements OnInit {
this.isSearching = false;
}
}
getMiningPools(): Observable<any> {
return this.pools.length ? of(this.pools) : combineLatest([
this.apiService.listPools$(undefined),
this.apiService.listPools$('1y')
]).pipe(
map(([poolsResponse, activePoolsResponse]) => {
const activePoolSlugs = new Set(activePoolsResponse.body.pools.map(pool => pool.slug));
return poolsResponse.body.map(pool => ({
name: pool.name,
slug: pool.slug,
active: activePoolSlugs.has(pool.slug)
}))
// Sort: active pools first, then alphabetically
.sort((a, b) => {
if (a.active && !b.active) return -1;
if (!a.active && b.active) return 1;
return a.slug < b.slug ? -1 : 1;
});
}),
catchError(() => of([]))
);
}
}

View File

@@ -1,4 +1,4 @@
<div class="dropdown-menu show" *ngIf="results" [hidden]="!results.hashQuickMatch && !results.otherNetworks.length && !results.addresses.length && !results.nodes.length && !results.channels.length && !results.liquidAsset.length && !results.pools.length">
<div class="dropdown-menu show" *ngIf="results" [hidden]="!results.hashQuickMatch && !results.otherNetworks.length && !results.addresses.length && !results.nodes.length && !results.channels.length && !results.liquidAsset.length">
<ng-template [ngIf]="results.blockHeight">
<div class="card-title" i18n="search.bitcoin-block-height">{{ networkName }} Block Height</div>
<button (click)="clickItem(0)" [class.active]="0 === activeIdx" type="button" role="option" class="dropdown-item">
@@ -23,7 +23,7 @@
<ng-container *ngTemplateOutlet="goTo; context: { $implicit: results.searchText | shortenString : 13 }"></ng-container>
</button>
</ng-template>
<ng-template [ngIf]="results.address && !results.publicKey">
<ng-template [ngIf]="results.address">
<div class="card-title" i18n="search.bitcoin-address">{{ networkName }} Address</div>
<button (click)="clickItem(0)" [class.active]="0 === activeIdx" type="button" role="option" class="dropdown-item">
<ng-container *ngTemplateOutlet="goTo; context: { $implicit: results.searchText | shortenString : isMobile ? 17 : 30 }"></ng-container>
@@ -35,26 +35,26 @@
<ng-container *ngTemplateOutlet="goTo; context: { $implicit: results.searchText | shortenString : 13 }"></ng-container>
</button>
</ng-template>
<ng-template [ngIf]="results.addresses.length">
<div class="card-title" i18n="search.bitcoin-addresses">{{ networkName }} Addresses</div>
<ng-template ngFor [ngForOf]="results.addresses" let-address let-i="index">
<button (click)="clickItem(results.hashQuickMatch + i)" [class.active]="(results.hashQuickMatch + i) === activeIdx" type="button" role="option" class="dropdown-item">
<ngb-highlight [result]="address | shortenString : isMobile ? 25 : 36" [term]="results.searchText"></ngb-highlight>
<ng-template [ngIf]="results.otherNetworks.length">
<div class="card-title danger" i18n="search.other-networks">Other Network Address</div>
<ng-template ngFor [ngForOf]="results.otherNetworks" let-otherNetwork let-i="index">
<button (click)="clickItem(results.hashQuickMatch + i)" [class.active]="(results.hashQuickMatch + i) === activeIdx" [class.inactive]="!otherNetwork.isNetworkAvailable" type="button" role="option" class="dropdown-item">
<ng-container *ngTemplateOutlet="goTo; context: { $implicit: otherNetwork.address| shortenString : isMobile ? 12 : 20 }"></ng-container>&nbsp;<b>({{ otherNetwork.network.charAt(0).toUpperCase() + otherNetwork.network.slice(1) }})</b>
</button>
</ng-template>
</ng-template>
<ng-template [ngIf]="results.pools.length">
<div class="card-title" i18n="search.mining-pools">Mining Pools</div>
<ng-template ngFor [ngForOf]="results.pools" let-pool let-i="index">
<button (click)="clickItem(results.hashQuickMatch + results.addresses.length + i)" [class.active]="results.hashQuickMatch + results.addresses.length + i === activeIdx" [class.inactive]="!pool.active" type="button" role="option" class="dropdown-item">
<ngb-highlight [result]="pool.name" [term]="results.searchText"></ngb-highlight>
<ng-template [ngIf]="results.addresses.length">
<div class="card-title" i18n="search.bitcoin-addresses">{{ networkName }} Addresses</div>
<ng-template ngFor [ngForOf]="results.addresses" let-address let-i="index">
<button (click)="clickItem(results.hashQuickMatch + results.otherNetworks.length + i)" [class.active]="(results.hashQuickMatch + results.otherNetworks.length + i) === activeIdx" type="button" role="option" class="dropdown-item">
<ngb-highlight [result]="address | shortenString : isMobile ? 25 : 36" [term]="results.searchText"></ngb-highlight>
</button>
</ng-template>
</ng-template>
<ng-template [ngIf]="results.nodes.length">
<div class="card-title" i18n="search.lightning-nodes">Lightning Nodes</div>
<ng-template ngFor [ngForOf]="results.nodes" let-node let-i="index">
<button (click)="clickItem(results.hashQuickMatch + results.addresses.length + results.pools.length + i)" [class.inactive]="node.status === 0" [class.active]="results.hashQuickMatch + results.addresses.length + results.pools.length + i === activeIdx" [routerLink]="['/lightning/node' | relativeUrl, node.public_key]" type="button" role="option" class="dropdown-item">
<button (click)="clickItem(results.hashQuickMatch + results.otherNetworks.length + results.addresses.length + i)" [class.inactive]="node.status === 0" [class.active]="results.hashQuickMatch + results.otherNetworks.length + results.addresses.length + i === activeIdx" [routerLink]="['/lightning/node' | relativeUrl, node.public_key]" type="button" role="option" class="dropdown-item">
<ngb-highlight [result]="node.alias" [term]="results.searchText"></ngb-highlight> &nbsp;<span class="symbol">{{ node.public_key | shortenString : 10 }}</span>
</button>
</ng-template>
@@ -62,25 +62,11 @@
<ng-template [ngIf]="results.channels.length">
<div class="card-title" i18n="search.lightning-channels">Lightning Channels</div>
<ng-template ngFor [ngForOf]="results.channels" let-channel let-i="index">
<button (click)="clickItem(results.hashQuickMatch + results.addresses.length + results.pools.length + results.nodes.length + i)" [class.inactive]="channel.status === 2" [class.active]="results.hashQuickMatch + results.addresses.length + results.pools.length + results.nodes.length + i === activeIdx" type="button" role="option" class="dropdown-item">
<button (click)="clickItem(results.hashQuickMatch + results.otherNetworks.length + results.addresses.length + results.nodes.length + i)" [class.inactive]="channel.status === 2" [class.active]="results.hashQuickMatch + results.otherNetworks.length + results.addresses.length + results.nodes.length + i === activeIdx" type="button" role="option" class="dropdown-item">
<ngb-highlight [result]="channel.short_id" [term]="results.searchText"></ngb-highlight> &nbsp;<span class="symbol">{{ channel.id }}</span>
</button>
</ng-template>
</ng-template>
<ng-template [ngIf]="results.otherNetworks.length">
<div class="card-title danger" i18n="search.other-networks">Other Network Address</div>
<ng-template ngFor [ngForOf]="results.otherNetworks" let-otherNetwork let-i="index">
<button (click)="clickItem(results.hashQuickMatch + results.addresses.length + results.pools.length + results.nodes.length + results.channels.length + i)" [class.active]="(results.hashQuickMatch + results.addresses.length + results.pools.length + results.nodes.length + results.channels.length + i) === activeIdx" [class.inactive]="!otherNetwork.isNetworkAvailable" type="button" role="option" class="dropdown-item">
<ng-container *ngTemplateOutlet="goTo; context: { $implicit: otherNetwork.address | shortenString : isMobile ? 12 : 20 }"></ng-container>&nbsp;<b>({{ otherNetwork.network.charAt(0).toUpperCase() + otherNetwork.network.slice(1) }})</b>
</button>
</ng-template>
</ng-template>
<ng-template [ngIf]="results.address && results.publicKey">
<div class="card-title" i18n="search.bitcoin-address">{{ networkName }} Address</div>
<button (click)="clickItem(0)" [class.active]="0 === activeIdx" type="button" role="option" class="dropdown-item">
<ng-container *ngTemplateOutlet="goTo; context: { $implicit: results.searchText | shortenString : isMobile ? 17 : 30 }"></ng-container>
</button>
</ng-template>
<ng-template [ngIf]="results.liquidAsset.length">
<div class="card-title" i18n="search.liquid-asset">Liquid Asset</div>
<button (click)="clickItem(0)" [class.active]="0 === activeIdx" type="button" role="option" class="dropdown-item">

View File

@@ -22,7 +22,3 @@
.inactive {
opacity: 0.2;
}
.active {
background-color: var(--active-bg);
}

View File

@@ -27,11 +27,7 @@ export class SearchResultsComponent implements OnChanges {
ngOnChanges() {
this.activeIdx = 0;
if (this.results) {
this.resultsFlattened = [...(this.results.hashQuickMatch ? [this.results.searchText] : []), ...this.results.addresses, ...this.results.pools, ...this.results.nodes, ...this.results.channels, ...this.results.otherNetworks];
// If searchText is a public key corresponding to a node, select it by default
if (this.results.publicKey && this.results.nodes.length > 0) {
this.activeIdx = 1;
}
this.resultsFlattened = [...(this.results.hashQuickMatch ? [this.results.searchText] : []), ...this.results.otherNetworks, ...this.results.addresses, ...this.results.nodes, ...this.results.channels];
}
}

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