Compare commits
113 Commits
mononaut/u
...
natsoni/va
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab9ee3151e | ||
|
|
c933d975f3 | ||
|
|
53458da3bb | ||
|
|
46b5b26347 | ||
|
|
bd5abf6592 | ||
|
|
2c04896397 | ||
|
|
286fc8e9ad | ||
|
|
52fa6a0f78 | ||
|
|
a53b587395 | ||
|
|
2341b1d79e | ||
|
|
35215c7740 | ||
|
|
e2080e5548 | ||
|
|
60a07fb093 | ||
|
|
28477cc433 | ||
|
|
6bb2f06118 | ||
|
|
68bab5b7b8 | ||
|
|
6bc7eec4e3 | ||
|
|
4364b5fb06 | ||
|
|
86bc7b3f37 | ||
|
|
ea0a4d1d67 | ||
|
|
aa80fa550b | ||
|
|
bdbe833e2a | ||
|
|
c1f338774a | ||
|
|
e5ab8ab732 | ||
|
|
ce204d03c6 | ||
|
|
b0630de3cc | ||
|
|
2ff2b78d26 | ||
|
|
35ff2c7225 | ||
|
|
3c3ab96164 | ||
|
|
0c49c5313b | ||
|
|
c5f6509a4c | ||
|
|
8bac2db12c | ||
|
|
abad704fc6 | ||
|
|
d7459ba943 | ||
|
|
ec7da01186 | ||
|
|
2ce1cc24b9 | ||
|
|
cb95423178 | ||
|
|
a68c8d317c | ||
|
|
f58c2e6fe5 | ||
|
|
203e2904d6 | ||
|
|
38971d33a6 | ||
|
|
83f3d07538 | ||
|
|
9e844ffbbd | ||
|
|
0497c750be | ||
|
|
788caf05ce | ||
|
|
db34ca6a5f | ||
|
|
3bbdf6fb25 | ||
|
|
6b16b2d651 | ||
|
|
0279fe69d4 | ||
|
|
e1c47fddee | ||
|
|
6b2b3459f7 | ||
|
|
1d91e76ec2 | ||
|
|
e8dbd777d0 | ||
|
|
987def9baa | ||
|
|
d9197e28be | ||
|
|
72f4d811c1 | ||
|
|
f8144bd9a0 | ||
|
|
d6d56688ea | ||
|
|
97817e770f | ||
|
|
bbf8aed38f | ||
|
|
ef5c8ddcdf | ||
|
|
69e1d6150d | ||
|
|
86c3ef68e4 | ||
|
|
ec54ed6f94 | ||
|
|
c2a3ff4b67 | ||
|
|
cc9e4f2d43 | ||
|
|
4c62d54b6b | ||
|
|
8bbc27dae5 | ||
|
|
2ffaeb50df | ||
|
|
12d212b89f | ||
|
|
1cdb38af0b | ||
|
|
947f55dda2 | ||
|
|
c52bde140f | ||
|
|
1663637ed9 | ||
|
|
d24217282b | ||
|
|
b6a1547ce4 | ||
|
|
66f431d3d3 | ||
|
|
d02625eb0d | ||
|
|
a3ac9c31ed | ||
|
|
4729a87b39 | ||
|
|
16c154f39d | ||
|
|
fa98c73067 | ||
|
|
d32de4fa06 | ||
|
|
50ee7c07d1 | ||
|
|
2ac041dc6c | ||
|
|
4aac31332f | ||
|
|
9a6ac69983 | ||
|
|
c93aa4d82c | ||
|
|
16fe1f3cec | ||
|
|
42d591bf4c | ||
|
|
2133356047 | ||
|
|
d7b0923000 | ||
|
|
2fa33b749c | ||
|
|
90cc75479e | ||
|
|
e2eadd0433 | ||
|
|
1bd045582d | ||
|
|
c6e8885c94 | ||
|
|
5531d04f10 | ||
|
|
85ff8521f7 | ||
|
|
3794ddfd3a | ||
|
|
64d979d5f9 | ||
|
|
0ef76f3e64 | ||
|
|
53a493c233 | ||
|
|
f2a7cddbb0 | ||
|
|
f85f3a4eb5 | ||
|
|
1312dd45e6 | ||
|
|
f16afe20be | ||
|
|
d0cba30543 | ||
|
|
6d595dcdb6 | ||
|
|
1c3e0bdd6a | ||
|
|
453a2224cd | ||
|
|
1b25a71d9f | ||
|
|
bf541f0898 |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -257,7 +257,7 @@ jobs:
|
||||
spec: |
|
||||
cypress/e2e/mainnet/*.spec.ts
|
||||
cypress/e2e/signet/*.spec.ts
|
||||
cypress/e2e/testnet/*.spec.ts
|
||||
cypress/e2e/testnet4/*.spec.ts
|
||||
- module: "liquid"
|
||||
spec: |
|
||||
cypress/e2e/liquid/liquid.spec.ts
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@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,
|
||||
@@ -32,6 +33,7 @@
|
||||
"prefer-rest-params": 1,
|
||||
"quotes": [1, "single", { "allowTemplateLiterals": true }],
|
||||
"semi": 1,
|
||||
"curly": [1, "all"],
|
||||
"eqeqeq": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +139,8 @@
|
||||
"ENABLED": false,
|
||||
"AUDIT": false,
|
||||
"AUDIT_START_HEIGHT": 774000,
|
||||
"STATISTICS": false,
|
||||
"STATISTICS_START_TIME": 1481932800,
|
||||
"SERVERS": [
|
||||
"list",
|
||||
"of",
|
||||
|
||||
18
backend/package-lock.json
generated
18
backend/package-lock.json
generated
@@ -13,7 +13,7 @@
|
||||
"@babel/core": "^7.24.0",
|
||||
"@mempool/electrum-client": "1.1.9",
|
||||
"@types/node": "^18.15.3",
|
||||
"axios": "~1.6.1",
|
||||
"axios": "~1.7.2",
|
||||
"bitcoinjs-lib": "~6.1.3",
|
||||
"crypto-js": "~4.2.0",
|
||||
"express": "~4.19.2",
|
||||
@@ -2318,11 +2318,11 @@
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.6.1.tgz",
|
||||
"integrity": "sha512-vfBmhDpKafglh0EldBEbVuoe7DyAavGSLWhuSm5ZSEKQnHhBf0xAAwybbNH1IkrJNGnS/VG4I5yxig1pCEXE4g==",
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz",
|
||||
"integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.0",
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
@@ -9509,11 +9509,11 @@
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||
},
|
||||
"axios": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.6.1.tgz",
|
||||
"integrity": "sha512-vfBmhDpKafglh0EldBEbVuoe7DyAavGSLWhuSm5ZSEKQnHhBf0xAAwybbNH1IkrJNGnS/VG4I5yxig1pCEXE4g==",
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz",
|
||||
"integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==",
|
||||
"requires": {
|
||||
"follow-redirects": "^1.15.0",
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"@babel/core": "^7.24.0",
|
||||
"@mempool/electrum-client": "1.1.9",
|
||||
"@types/node": "^18.15.3",
|
||||
"axios": "~1.6.1",
|
||||
"axios": "~1.7.2",
|
||||
"bitcoinjs-lib": "~6.1.3",
|
||||
"crypto-js": "~4.2.0",
|
||||
"express": "~4.19.2",
|
||||
|
||||
@@ -131,6 +131,8 @@
|
||||
"ENABLED": false,
|
||||
"AUDIT": false,
|
||||
"AUDIT_START_HEIGHT": 774000,
|
||||
"STATISTICS": false,
|
||||
"STATISTICS_START_TIME": 1481932800,
|
||||
"SERVERS": []
|
||||
},
|
||||
"MEMPOOL_SERVICES": {
|
||||
|
||||
@@ -135,6 +135,8 @@ describe('Mempool Backend Config', () => {
|
||||
ENABLED: false,
|
||||
AUDIT: false,
|
||||
AUDIT_START_HEIGHT: 774000,
|
||||
STATISTICS: false,
|
||||
STATISTICS_START_TIME: 1481932800,
|
||||
SERVERS: []
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import cpfpRepository from '../repositories/CpfpRepository';
|
||||
import { RowDataPacket } from 'mysql2';
|
||||
|
||||
class DatabaseMigration {
|
||||
private static currentVersion = 78;
|
||||
private static currentVersion = 79;
|
||||
private queryTimeout = 3600_000;
|
||||
private statisticsAddedIndexed = false;
|
||||
private uniqueLogs: string[] = [];
|
||||
@@ -674,6 +674,18 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,6 +24,7 @@ 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)
|
||||
@@ -217,6 +218,26 @@ 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));
|
||||
const blockCount = await BlocksRepository.$blockCount(null, null);
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.header('X-total-count', blockCount.toString());
|
||||
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);
|
||||
|
||||
@@ -45,11 +45,22 @@ class Mining {
|
||||
*/
|
||||
public async $getHistoricalBlockFees(interval: string | null = null): Promise<any> {
|
||||
return await BlocksRepository.$getHistoricalBlockFees(
|
||||
this.getTimeRange(interval, 5),
|
||||
this.getTimeRange(interval),
|
||||
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
|
||||
*/
|
||||
@@ -646,6 +657,24 @@ 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 {
|
||||
|
||||
@@ -52,6 +52,28 @@ 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
|
||||
|
||||
@@ -64,7 +64,7 @@ class StatisticsApi {
|
||||
}
|
||||
}
|
||||
|
||||
public async $create(statistics: Statistic): Promise<number | undefined> {
|
||||
public async $create(statistics: Statistic, convertToDatetime = false): Promise<number | undefined> {
|
||||
try {
|
||||
const query = `INSERT INTO statistics(
|
||||
added,
|
||||
@@ -114,7 +114,7 @@ class StatisticsApi {
|
||||
vsize_1800,
|
||||
vsize_2000
|
||||
)
|
||||
VALUES (${statistics.added}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
VALUES (${convertToDatetime ? `FROM_UNIXTIME(${statistics.added})` : statistics.added}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
||||
|
||||
const params: (string | number)[] = [
|
||||
@@ -456,6 +456,59 @@ 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();
|
||||
|
||||
@@ -141,6 +141,8 @@ interface IConfig {
|
||||
ENABLED: boolean;
|
||||
AUDIT: boolean;
|
||||
AUDIT_START_HEIGHT: number;
|
||||
STATISTICS: boolean;
|
||||
STATISTICS_START_TIME: number | string;
|
||||
SERVERS: string[];
|
||||
},
|
||||
MEMPOOL_SERVICES: {
|
||||
@@ -298,6 +300,8 @@ const defaults: IConfig = {
|
||||
'ENABLED': false,
|
||||
'AUDIT': false,
|
||||
'AUDIT_START_HEIGHT': 774000,
|
||||
'STATISTICS': false,
|
||||
'STATISTICS_START_TIME': 1481932800,
|
||||
'SERVERS': [],
|
||||
},
|
||||
'MEMPOOL_SERVICES': {
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 {
|
||||
@@ -188,6 +189,7 @@ 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();
|
||||
|
||||
@@ -422,6 +422,7 @@ export interface Statistic {
|
||||
|
||||
export interface OptimizedStatistic {
|
||||
added: string;
|
||||
count: number;
|
||||
vbytes_per_second: number;
|
||||
total_fee: number;
|
||||
mempool_byte_weight: number;
|
||||
|
||||
228
backend/src/replication/StatisticsReplication.ts
Normal file
228
backend/src/replication/StatisticsReplication.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
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();
|
||||
|
||||
@@ -244,6 +244,8 @@ 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) {
|
||||
@@ -309,7 +311,7 @@ class AccelerationRepository {
|
||||
pools: acc.pools.map(pool => pool.pool_unique_id),
|
||||
}))
|
||||
for (const acc of accelerations) {
|
||||
if (blockTxs[acc.txid]) {
|
||||
if (blockTxs[acc.txid] && acc.pools.some(pool => pool.pool_unique_id === block.extras.pool.id)) {
|
||||
const tx = blockTxs[acc.txid];
|
||||
const accelerationInfo = accelerationCosts.getAccelerationInfo(tx, boostRate, transactions);
|
||||
accelerationInfo.cost = Math.max(0, Math.min(acc.feeDelta, accelerationInfo.cost));
|
||||
|
||||
@@ -663,7 +663,7 @@ class BlocksRepository {
|
||||
/**
|
||||
* Get the historical averaged block fees
|
||||
*/
|
||||
public async $getHistoricalBlockFees(div: number, interval: string | null): Promise<any> {
|
||||
public async $getHistoricalBlockFees(div: number, interval: string | null, timespan?: {from: number, to: number}): Promise<any> {
|
||||
try {
|
||||
let query = `SELECT
|
||||
CAST(AVG(blocks.height) as INT) as avgHeight,
|
||||
@@ -677,6 +677,8 @@ 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}`;
|
||||
|
||||
3
contributors/bitcoinmechanic.txt
Normal file
3
contributors/bitcoinmechanic.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
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
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20.12.0-buster-slim AS builder
|
||||
FROM node:20.13.1-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.12.0-buster-slim
|
||||
FROM node:20.13.1-buster-slim
|
||||
|
||||
WORKDIR /backend
|
||||
|
||||
|
||||
@@ -137,6 +137,8 @@
|
||||
"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": {
|
||||
|
||||
@@ -138,6 +138,8 @@ __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
|
||||
@@ -284,6 +286,8 @@ 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20.12.0-buster-slim AS builder
|
||||
FROM node:20.13.1-buster-slim AS builder
|
||||
|
||||
ARG commitHash
|
||||
ENV DOCKER_COMMIT_HASH=${commitHash}
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"prefer-rest-params": 1,
|
||||
"quotes": [1, "single", { "allowTemplateLiterals": true }],
|
||||
"semi": 1,
|
||||
"curly": [1, "all"],
|
||||
"eqeqeq": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"component": "twitter",
|
||||
"mobileOrder": 5,
|
||||
"props": {
|
||||
"handle": "bitcoinofficesv"
|
||||
"handle": "nayibbukele"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -45,6 +45,7 @@ describe('Liquid', () => {
|
||||
|
||||
it('loads a specific block page', () => {
|
||||
cy.visit(`${basePath}/block/7e1369a23a5ab861e7bdede2aadcccae4ea873ffd9caf11c7c5541eb5bcdff54`);
|
||||
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
|
||||
cy.waitForSkeletonGone();
|
||||
});
|
||||
|
||||
|
||||
@@ -46,7 +46,8 @@ describe('Liquid Testnet', () => {
|
||||
});
|
||||
|
||||
it('loads a specific block page', () => {
|
||||
cy.visit(`${basePath}/block/7e1369a23a5ab861e7bdede2aadcccae4ea873ffd9caf11c7c5541eb5bcdff54`);
|
||||
cy.visit(`${basePath}/block/fb4cbcbff3993ca4bf8caf657d55a23db5ed4ab1cfa33c489303c2e04e1c38e0`);
|
||||
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
|
||||
cy.waitForSkeletonGone();
|
||||
});
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ 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');
|
||||
@@ -111,9 +112,10 @@ 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('div > a > .badge').first().trigger('onmouseover');
|
||||
cy.get('div > a > .badge').first().trigger('mouseenter');
|
||||
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('.tooltip-inner').should('be.visible');
|
||||
});
|
||||
|
||||
@@ -283,6 +285,7 @@ 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();
|
||||
|
||||
@@ -295,6 +298,7 @@ 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();
|
||||
|
||||
@@ -323,6 +327,7 @@ 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');
|
||||
@@ -339,7 +344,7 @@ describe('Mainnet', () => {
|
||||
cy.visit('/');
|
||||
cy.waitForSkeletonGone();
|
||||
|
||||
cy.changeNetwork('testnet');
|
||||
cy.changeNetwork('testnet4');
|
||||
cy.changeNetwork('signet');
|
||||
cy.changeNetwork('mainnet');
|
||||
});
|
||||
@@ -439,6 +444,7 @@ 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');
|
||||
@@ -446,6 +452,7 @@ 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(() => {
|
||||
@@ -458,6 +465,7 @@ 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');
|
||||
@@ -467,6 +475,7 @@ 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(() => {
|
||||
@@ -482,6 +491,7 @@ 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();
|
||||
});
|
||||
@@ -493,6 +503,7 @@ 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();
|
||||
});
|
||||
|
||||
@@ -95,12 +95,14 @@ 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');
|
||||
@@ -113,6 +115,7 @@ 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);
|
||||
@@ -121,6 +124,7 @@ 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(() => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { emitMempoolInfo } from '../../support/websocket';
|
||||
|
||||
const baseModule = Cypress.env('BASE_MODULE');
|
||||
|
||||
describe('Testnet', () => {
|
||||
describe('Testnet4', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('/api/block-height/*').as('block-height');
|
||||
cy.intercept('/api/block/*').as('block');
|
||||
@@ -13,7 +13,7 @@ describe('Testnet', () => {
|
||||
if (baseModule === 'mempool') {
|
||||
|
||||
it('loads the dashboard', () => {
|
||||
cy.visit('/testnet');
|
||||
cy.visit('/testnet4');
|
||||
cy.waitForSkeletonGone();
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('Testnet', () => {
|
||||
|
||||
it.skip('loads the dashboard with the skeleton blocks', () => {
|
||||
cy.mockMempoolSocket();
|
||||
cy.visit('/testnet');
|
||||
cy.visit('/testnet4');
|
||||
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('Testnet', () => {
|
||||
});
|
||||
|
||||
it('loads the pools screen', () => {
|
||||
cy.visit('/testnet');
|
||||
cy.visit('/testnet4');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('#btn-pools').click().then(() => {
|
||||
cy.wait(1000);
|
||||
@@ -53,7 +53,7 @@ describe('Testnet', () => {
|
||||
});
|
||||
|
||||
it('loads the graphs screen', () => {
|
||||
cy.visit('/testnet');
|
||||
cy.visit('/testnet4');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('#btn-graphs').click().then(() => {
|
||||
cy.wait(1000);
|
||||
@@ -63,7 +63,7 @@ describe('Testnet', () => {
|
||||
describe('tv mode', () => {
|
||||
it('loads the tv screen - desktop', () => {
|
||||
cy.viewport('macbook-16');
|
||||
cy.visit('/testnet/graphs');
|
||||
cy.visit('/testnet4/graphs');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('#btn-tv').click().then(() => {
|
||||
cy.wait(1000);
|
||||
@@ -73,7 +73,7 @@ describe('Testnet', () => {
|
||||
});
|
||||
|
||||
it('loads the tv screen - mobile', () => {
|
||||
cy.visit('/testnet/graphs');
|
||||
cy.visit('/testnet4/graphs');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('#btn-tv').click().then(() => {
|
||||
cy.viewport('iphone-6');
|
||||
@@ -85,7 +85,7 @@ describe('Testnet', () => {
|
||||
|
||||
|
||||
it('loads the api screen', () => {
|
||||
cy.visit('/testnet');
|
||||
cy.visit('/testnet4');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('#btn-docs').click().then(() => {
|
||||
cy.wait(1000);
|
||||
@@ -94,13 +94,15 @@ describe('Testnet', () => {
|
||||
|
||||
describe('blocks', () => {
|
||||
it('shows empty blocks properly', () => {
|
||||
cy.visit('/testnet/block/0');
|
||||
cy.visit('/testnet4/block/0');
|
||||
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('/testnet/block/0');
|
||||
cy.visit('/testnet4/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');
|
||||
@@ -112,15 +114,17 @@ describe('Testnet', () => {
|
||||
});
|
||||
|
||||
it('shows blocks with no pagination', () => {
|
||||
cy.visit('/testnet/block/000000000000002f8ce27716e74ecc7ad9f7b5101fed12d09e28bb721b9460ea');
|
||||
cy.visit('/testnet4/block/000000000066e8b6cc78a93f8989587f5819624bae2eb1c05f535cadded19f99');
|
||||
cy.get('.pagination').scrollIntoView({ offset: { top: 200, left: 0 } });
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('h2').invoke('text').should('equal', '11 transactions');
|
||||
cy.get('h2').invoke('text').should('equal', '18 transactions');
|
||||
cy.get('ul.pagination').first().children().should('have.length', 5);
|
||||
});
|
||||
|
||||
it('supports pagination on the block screen', () => {
|
||||
// 48 txs
|
||||
cy.visit('/testnet/block/000000000000002ca3878ebd98b313a1c2d531f2e70a6575d232ca7564dea7a9');
|
||||
cy.visit('/testnet4/block/000000000000006982d53f8273bdff21dafc380c292eabc669b5ab6d732311c3');
|
||||
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(() => {
|
||||
@@ -72,7 +72,7 @@ Cypress.Commands.add('mockMempoolSocket', () => {
|
||||
mockWebSocket();
|
||||
});
|
||||
|
||||
Cypress.Commands.add('changeNetwork', (network: "testnet" | "signet" | "liquid" | "mainnet") => {
|
||||
Cypress.Commands.add('changeNetwork', (network: "testnet" | "testnet4" | "signet" | "liquid" | "mainnet") => {
|
||||
cy.get('.dropdown-toggle').click().then(() => {
|
||||
cy.get(`a.${network}`).click().then(() => {
|
||||
cy.waitForPageIdle();
|
||||
|
||||
2
frontend/cypress/support/index.d.ts
vendored
2
frontend/cypress/support/index.d.ts
vendored
@@ -5,6 +5,6 @@ declare namespace Cypress {
|
||||
waitForSkeletonGone(): Chainable<any>
|
||||
waitForPageIdle(): Chainable<any>
|
||||
mockMempoolSocket(): Chainable<any>
|
||||
changeNetwork(network: "testnet"|"signet"|"liquid"|"mainnet"): Chainable<any>
|
||||
changeNetwork(network: "testnet"|"testnet4"|"signet"|"liquid"|"mainnet"): Chainable<any>
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ let configContent = {};
|
||||
let gitCommitHash = '';
|
||||
let packetJsonVersion = '';
|
||||
let customConfig;
|
||||
let customConfigContent;
|
||||
|
||||
try {
|
||||
const rawConfig = fs.readFileSync(CONFIG_FILE_NAME);
|
||||
@@ -25,11 +26,16 @@ try {
|
||||
}
|
||||
|
||||
if (configContent && configContent.CUSTOMIZATION) {
|
||||
customConfig = readConfig(configContent.CUSTOMIZATION);
|
||||
try {
|
||||
customConfig = readConfig(configContent.CUSTOMIZATION);
|
||||
customConfigContent = JSON.parse(customConfig);
|
||||
} catch (e) {
|
||||
console.log(`failed to load customization config from ${configContent.CUSTOMIZATION}`);
|
||||
}
|
||||
}
|
||||
|
||||
const baseModuleName = configContent.BASE_MODULE || 'mempool';
|
||||
const customBuildName = (customConfig && configContent.enterprise) ? ('.' + configContent.enterprise) : '';
|
||||
const customBuildName = (customConfigContent && customConfigContent.enterprise) ? ('.' + customConfigContent.enterprise) : '';
|
||||
const indexFilePath = 'src/index.' + baseModuleName + customBuildName + '.html';
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"TESTNET_ENABLED": false,
|
||||
"TESTNET4_ENABLED": false,
|
||||
"SIGNET_ENABLED": false,
|
||||
"LIQUID_ENABLED": false,
|
||||
"LIQUID_TESTNET_ENABLED": false,
|
||||
|
||||
29
frontend/package-lock.json
generated
29
frontend/package-lock.json
generated
@@ -32,12 +32,11 @@
|
||||
"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.1.0",
|
||||
"ngx-echarts": "~17.2.0",
|
||||
"ngx-infinite-scroll": "^17.0.0",
|
||||
"qrcode": "1.5.1",
|
||||
"rxjs": "~7.8.1",
|
||||
@@ -63,7 +62,7 @@
|
||||
"optionalDependencies": {
|
||||
"@cypress/schematic": "^2.5.0",
|
||||
"@types/cypress": "^1.1.3",
|
||||
"cypress": "^13.9.0",
|
||||
"cypress": "^13.10.0",
|
||||
"cypress-fail-on-console-error": "~5.1.0",
|
||||
"cypress-wait-until": "^2.0.1",
|
||||
"mock-socket": "~9.3.1",
|
||||
@@ -8029,9 +8028,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cypress": {
|
||||
"version": "13.9.0",
|
||||
"resolved": "https://registry.npmjs.org/cypress/-/cypress-13.9.0.tgz",
|
||||
"integrity": "sha512-atNjmYfHsvTuCaxTxLZr9xGoHz53LLui3266WWxXJHY7+N6OdwJdg/feEa3T+buez9dmUXHT1izCOklqG82uCQ==",
|
||||
"version": "13.10.0",
|
||||
"resolved": "https://registry.npmjs.org/cypress/-/cypress-13.10.0.tgz",
|
||||
"integrity": "sha512-tOhwRlurVOQbMduX+KonoMeQILs2cwR3yHGGENoFvvSoLUBHmJ8b9/n21gFSDqjlOJ+SRVcwuh+fG/JDsHsT6Q==",
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -13290,9 +13289,9 @@
|
||||
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="
|
||||
},
|
||||
"node_modules/ngx-echarts": {
|
||||
"version": "17.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ngx-echarts/-/ngx-echarts-17.1.0.tgz",
|
||||
"integrity": "sha512-DSNF/aKmJSxJWb9UwPUgNtY8Ma9SmViDBRacvAwpakc/5mJerunxndDgoBQkYk5JFKAjXX6bp4ZWLRKL3/5AGA==",
|
||||
"version": "17.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ngx-echarts/-/ngx-echarts-17.2.0.tgz",
|
||||
"integrity": "sha512-i3XDE9d53zmJH4bp8RQ/271oPlhBkczO1M3VtWk8nCXdxQq9qx8UckjWEQ7oV1AbSDLGK5sRiFu5EaY5hvdWPA==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -24112,9 +24111,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"cypress": {
|
||||
"version": "13.9.0",
|
||||
"resolved": "https://registry.npmjs.org/cypress/-/cypress-13.9.0.tgz",
|
||||
"integrity": "sha512-atNjmYfHsvTuCaxTxLZr9xGoHz53LLui3266WWxXJHY7+N6OdwJdg/feEa3T+buez9dmUXHT1izCOklqG82uCQ==",
|
||||
"version": "13.10.0",
|
||||
"resolved": "https://registry.npmjs.org/cypress/-/cypress-13.10.0.tgz",
|
||||
"integrity": "sha512-tOhwRlurVOQbMduX+KonoMeQILs2cwR3yHGGENoFvvSoLUBHmJ8b9/n21gFSDqjlOJ+SRVcwuh+fG/JDsHsT6Q==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@cypress/request": "^3.0.0",
|
||||
@@ -28069,9 +28068,9 @@
|
||||
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="
|
||||
},
|
||||
"ngx-echarts": {
|
||||
"version": "17.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ngx-echarts/-/ngx-echarts-17.1.0.tgz",
|
||||
"integrity": "sha512-DSNF/aKmJSxJWb9UwPUgNtY8Ma9SmViDBRacvAwpakc/5mJerunxndDgoBQkYk5JFKAjXX6bp4ZWLRKL3/5AGA==",
|
||||
"version": "17.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ngx-echarts/-/ngx-echarts-17.2.0.tgz",
|
||||
"integrity": "sha512-i3XDE9d53zmJH4bp8RQ/271oPlhBkczO1M3VtWk8nCXdxQq9qx8UckjWEQ7oV1AbSDLGK5sRiFu5EaY5hvdWPA==",
|
||||
"requires": {
|
||||
"tslib": "^2.3.0"
|
||||
}
|
||||
|
||||
@@ -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 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",
|
||||
"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",
|
||||
"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 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"
|
||||
"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"
|
||||
},
|
||||
"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.1.0",
|
||||
"ngx-echarts": "~17.2.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.9.0",
|
||||
"cypress": "^13.10.0",
|
||||
"cypress-fail-on-console-error": "~5.1.0",
|
||||
"cypress-wait-until": "^2.0.1",
|
||||
"mock-socket": "~9.3.1",
|
||||
|
||||
@@ -24,7 +24,7 @@ PROXY_CONFIG = [
|
||||
'/api/**', '!/api/v1/ws',
|
||||
'!/liquid', '!/liquid/**', '!/liquid/',
|
||||
'!/liquidtestnet', '!/liquidtestnet/**', '!/liquidtestnet/',
|
||||
'/testnet/api/**', '/signet/api/**'
|
||||
'/testnet/api/**', '/signet/api/**', '/testnet4/api/**'
|
||||
],
|
||||
target: "https://mempool.space",
|
||||
ws: true,
|
||||
|
||||
@@ -12,6 +12,7 @@ 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';
|
||||
@@ -46,6 +47,7 @@ const providers = [
|
||||
CapAddressPipe,
|
||||
AppPreloadingStrategy,
|
||||
ServicesApiServices,
|
||||
PreloadService,
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: HttpCacheInterceptor, multi: true },
|
||||
{ provide: ZONE_SERVICE, useClass: ZoneService },
|
||||
];
|
||||
|
||||
@@ -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://bisq.network/" title="Bisq Network">
|
||||
<img class="bisq" src="/resources/profile/bisq.svg" />
|
||||
<a href="https://bitcoin.gob.sv" title="Oficina Nacional del Bitcoin">
|
||||
<img class="sv" src="/resources/profile/onbtc-full.svg" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -129,8 +129,9 @@
|
||||
position: relative;
|
||||
width: 300px;
|
||||
}
|
||||
.bisq {
|
||||
top: 3px;
|
||||
.sv {
|
||||
height: 85px;
|
||||
width: auto;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
|
||||
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="daysAvailable">
|
||||
<div class="btn-group btn-group-toggle" name="radioBasic" [class]="{'disabled': isLoading}">
|
||||
<div class="btn-group btn-group-toggle" name="radioBasic">
|
||||
<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)">
|
||||
(chartInit)="onChartInit($event)" [style]="{opacity: isLoading ? 0.5 : 1}">
|
||||
</div>
|
||||
<div class="text-center loadingGraphs" *ngIf="!stateService.isBrowser || isLoading">
|
||||
<div class="spinner-border text-light"></div>
|
||||
|
||||
@@ -65,8 +65,3 @@ h5 {
|
||||
font-size: 1rem;
|
||||
color: var(--title-fg);
|
||||
}
|
||||
|
||||
.disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -175,6 +175,9 @@ 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()) {
|
||||
|
||||
@@ -4,6 +4,8 @@ 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',
|
||||
@@ -12,12 +14,12 @@ import { NgbTooltipConfig } from '@ng-bootstrap/ng-bootstrap';
|
||||
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,
|
||||
@@ -52,11 +54,7 @@ export class AppComponent implements OnInit {
|
||||
ngOnInit() {
|
||||
this.router.events.subscribe((val) => {
|
||||
if (val instanceof NavigationEnd) {
|
||||
let domain = 'mempool.space';
|
||||
if (this.stateService.env.BASE_MODULE === 'liquid') {
|
||||
domain = 'liquid.network';
|
||||
}
|
||||
this.link.setAttribute('href', 'https://' + domain + this.location.path());
|
||||
this.seoService.updateCanonical(this.location.path());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -57,8 +57,9 @@ export class BalanceWidgetComponent implements OnInit, OnChanges {
|
||||
calculateStats(summary: AddressTxSummary[]): void {
|
||||
let weekTotal = 0;
|
||||
let monthTotal = 0;
|
||||
const weekAgo = (Date.now() / 1000) - (60 * 60 * 24 * 7);
|
||||
const monthAgo = (Date.now() / 1000) - (60 * 60 * 24 * 30);
|
||||
|
||||
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;
|
||||
for (let i = 0; i < summary.length && summary[i].time >= monthAgo; i++) {
|
||||
monthTotal += summary[i].value;
|
||||
if (summary[i].time >= weekAgo) {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
|
||||
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
|
||||
<div class="btn-group btn-group-toggle" name="radioBasic" [class]="{'disabled': isLoading}">
|
||||
<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-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)">
|
||||
(chartInit)="onChartInit($event)" [style]="{opacity: isLoading ? 0.5 : 1}">
|
||||
</div>
|
||||
<div class="text-center loadingGraphs" *ngIf="!stateService.isBrowser || isLoading">
|
||||
<div class="spinner-border text-light"></div>
|
||||
|
||||
@@ -139,8 +139,3 @@
|
||||
max-width: 80px;
|
||||
margin: 15px auto 3px;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
|
||||
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
|
||||
<div class="btn-group btn-group-toggle" name="radioBasic" [class]="{'disabled': isLoading}">
|
||||
<div class="btn-group btn-group-toggle" name="radioBasic">
|
||||
<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)">
|
||||
(chartInit)="onChartInit($event)" [style]="{opacity: isLoading ? 0.5 : 1}">
|
||||
</div>
|
||||
<div class="text-center loadingGraphs" *ngIf="!stateService.isBrowser || isLoading">
|
||||
<div class="spinner-border text-light"></div>
|
||||
|
||||
@@ -60,8 +60,3 @@
|
||||
height: 100%;
|
||||
max-height: 270px;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<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>
|
||||
@@ -0,0 +1,61 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
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;
|
||||
showFiat = false;
|
||||
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']),
|
||||
blockSubsidy: response.body.map(val => this.subsidies[Math.floor(Math.min(val.avgHeight / 210000, 33))] / 100_000_000),
|
||||
blockSubsidyFiat: response.body.filter(val => val['USD'] > 0).map(val => this.subsidies[Math.floor(Math.min(val.avgHeight / 210000, 33))] / 100_000_000 * val['USD']),
|
||||
};
|
||||
|
||||
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: 'color-mix(in srgb, var(--active-bg) 95%, transparent)',
|
||||
borderRadius: 4,
|
||||
shadowColor: 'color-mix(in srgb, var(--active-bg) 95%, transparent)',
|
||||
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];
|
||||
if (!this.showFiat) tooltip += `${tick.marker} ${tick.seriesName}: ${formatNumber(tick.data, this.locale, '1.0-3')} BTC<br>`;
|
||||
else tooltip += `${tick.marker} ${tick.seriesName}: ${this.fiatCurrencyPipe.transform(tick.data, null, 'USD') }<br>`;
|
||||
}
|
||||
if (!this.showFiat) tooltip += `<div style="margin-left: 2px">${formatNumber(data.reduce((acc, val) => acc + val.data, 0), this.locale, '1.0-3')} BTC</div>`;
|
||||
else 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',
|
||||
},
|
||||
],
|
||||
selected: {
|
||||
'Subsidy (USD)': this.showFiat,
|
||||
'Fees (USD)': this.showFiat,
|
||||
'Subsidy': !this.showFiat,
|
||||
'Fees': !this.showFiat,
|
||||
},
|
||||
},
|
||||
yAxis: this.data.blockFees.length === 0 ? undefined : [
|
||||
{
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
color: 'var(--grey)',
|
||||
formatter: (val) => {
|
||||
return `${val} BTC`;
|
||||
}
|
||||
},
|
||||
min: 0,
|
||||
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',
|
||||
stack: 'total',
|
||||
data: this.data.blockSubsidy,
|
||||
},
|
||||
{
|
||||
name: 'Fees',
|
||||
yAxisIndex: 0,
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
data: this.data.blockFees,
|
||||
},
|
||||
{
|
||||
name: 'Subsidy (USD)',
|
||||
yAxisIndex: 1,
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
data: this.data.blockSubsidyFiat,
|
||||
},
|
||||
{
|
||||
name: 'Fees (USD)',
|
||||
yAxisIndex: 1,
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
data: this.data.blockFeesFiat,
|
||||
},
|
||||
],
|
||||
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) => {
|
||||
const isFiat = params.name.includes('USD');
|
||||
if (isFiat === this.showFiat) return;
|
||||
|
||||
const isActivation = params.selected[params.name];
|
||||
if (isFiat === isActivation) {
|
||||
this.showFiat = true;
|
||||
this.chartInstance.dispatchAction({ type: 'legendUnSelect', name: 'Subsidy' });
|
||||
this.chartInstance.dispatchAction({ type: 'legendUnSelect', name: 'Fees' });
|
||||
this.chartInstance.dispatchAction({ type: 'legendSelect', name: 'Subsidy (USD)' });
|
||||
this.chartInstance.dispatchAction({ type: 'legendSelect', name: 'Fees (USD)' });
|
||||
} else {
|
||||
this.showFiat = false;
|
||||
this.chartInstance.dispatchAction({ type: 'legendSelect', name: 'Subsidy' });
|
||||
this.chartInstance.dispatchAction({ type: 'legendSelect', name: 'Fees' });
|
||||
this.chartInstance.dispatchAction({ type: 'legendUnSelect', name: 'Subsidy (USD)' });
|
||||
this.chartInstance.dispatchAction({ type: 'legendUnSelect', name: 'Fees (USD)' });
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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.blockSubsidy.splice(startIndex, endIndex - startIndex, ...response.body.map(val => this.subsidies[Math.floor(Math.min(val.avgHeight / 210000, 33))] / 100_000_000));
|
||||
this.data.blockSubsidyFiat.splice(startIndex, endIndex - startIndex, ...response.body.filter(val => val['USD'] > 0).map(val => this.subsidies[Math.floor(Math.min(val.avgHeight / 210000, 33))] / 100_000_000 * val['USD']));
|
||||
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.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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
|
||||
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
|
||||
<div class="btn-group btn-group-toggle" name="radioBasic" [class]="{'disabled': isLoading}">
|
||||
<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-health' | relativeUrl]" formControlName="dateSpan"> 24h
|
||||
</label>
|
||||
@@ -46,7 +46,7 @@
|
||||
</div>
|
||||
|
||||
<div class="chart" *browserOnly echarts [initOpts]="chartInitOptions" [options]="chartOptions"
|
||||
(chartInit)="onChartInit($event)">
|
||||
(chartInit)="onChartInit($event)" [style]="{opacity: isLoading ? 0.5 : 1}">
|
||||
</div>
|
||||
<div class="text-center loadingGraphs" *ngIf="!stateService.isBrowser || isLoading">
|
||||
<div class="spinner-border text-light"></div>
|
||||
|
||||
@@ -60,8 +60,3 @@
|
||||
height: 100%;
|
||||
max-height: 270px;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
</div>
|
||||
|
||||
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(statsObservable$ | async) as stats">
|
||||
<div class="btn-group btn-group-toggle" name="radioBasic" [class]="{'disabled': isLoading}">
|
||||
<div class="btn-group btn-group-toggle" name="radioBasic">
|
||||
<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)">
|
||||
(chartInit)="onChartInit($event)" [style]="{opacity: isLoading ? 0.5 : 1}">
|
||||
</div>
|
||||
<div class="text-center loadingGraphs" *ngIf="!stateService.isBrowser || isLoading">
|
||||
<div class="spinner-border text-light"></div>
|
||||
|
||||
@@ -60,8 +60,3 @@
|
||||
height: 100%;
|
||||
max-height: 270px;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
</div>
|
||||
|
||||
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(blockSizesWeightsObservable$ | async) as stats">
|
||||
<div class="btn-group btn-group-toggle" name="radioBasic" [class]="{'disabled': isLoading}">
|
||||
<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-sizes-weights' | relativeUrl]" formControlName="dateSpan"> 24h
|
||||
</label>
|
||||
@@ -45,7 +45,7 @@
|
||||
</div>
|
||||
|
||||
<div class="chart" *browserOnly echarts [initOpts]="chartInitOptions" [options]="chartOptions"
|
||||
(chartInit)="onChartInit($event)">
|
||||
(chartInit)="onChartInit($event)" [style]="{opacity: isLoading ? 0.5 : 1}">
|
||||
</div>
|
||||
<div class="text-center loadingGraphs" *ngIf="!stateService.isBrowser || isLoading">
|
||||
<div class="spinner-border text-light"></div>
|
||||
|
||||
@@ -60,8 +60,3 @@
|
||||
height: 100%;
|
||||
max-height: 270px;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -136,7 +136,12 @@ export class BlockPreviewComponent implements OnInit, OnDestroy {
|
||||
return of(transactions);
|
||||
})
|
||||
),
|
||||
this.stateService.env.ACCELERATOR === true && block.height > 819500 ? this.servicesApiService.getAccelerationHistory$({ blockHeight: block.height }) : of([])
|
||||
this.stateService.env.ACCELERATOR === true && block.height > 819500
|
||||
? this.servicesApiService.getAccelerationHistory$({ blockHeight: block.height })
|
||||
.pipe(catchError(() => {
|
||||
return of([]);
|
||||
}))
|
||||
: of([])
|
||||
]);
|
||||
}
|
||||
),
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<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>
|
||||
@@ -0,0 +1,37 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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' });
|
||||
}
|
||||
}
|
||||
@@ -325,53 +325,39 @@
|
||||
>Details</button>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
@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 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>
|
||||
<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 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>
|
||||
</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>
|
||||
@@ -382,12 +368,6 @@
|
||||
</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>
|
||||
|
||||
@@ -21,25 +21,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -100,19 +81,7 @@ h1 {
|
||||
}
|
||||
}
|
||||
|
||||
.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{
|
||||
.row {
|
||||
flex-direction: column;
|
||||
@media (min-width: 768px) {
|
||||
flex-direction: row;
|
||||
@@ -140,28 +109,6 @@ 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;
|
||||
}
|
||||
@@ -204,22 +151,6 @@ 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) {
|
||||
@@ -303,3 +234,41 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { Component, OnInit, OnDestroy, ViewChildren, QueryList, Inject, PLATFORM_ID, ChangeDetectorRef } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, ViewChildren, QueryList, 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 { AccelerationInfo, BlockAudit, BlockExtended, TransactionStripped } from '../../interfaces/node-api.interface';
|
||||
import { 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';
|
||||
@@ -17,6 +16,7 @@ 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,23 +42,17 @@ 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;
|
||||
paginationMaxSize: number;
|
||||
page = 1;
|
||||
itemsPerPage: number;
|
||||
txsLoadingStatus$: Observable<number>;
|
||||
block$: Observable<any>;
|
||||
showDetails = false;
|
||||
showPreviousBlocklink = true;
|
||||
showNextBlocklink = true;
|
||||
transactionsError: any = null;
|
||||
overviewError: any = null;
|
||||
webGlEnabled = true;
|
||||
auditParamEnabled: boolean = false;
|
||||
@@ -69,20 +63,16 @@ 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;
|
||||
@@ -109,16 +99,14 @@ export class BlockComponent implements OnInit, OnDestroy {
|
||||
private cacheService: CacheService,
|
||||
private servicesApiService: ServicesApiServices,
|
||||
private cd: ChangeDetectorRef,
|
||||
@Inject(PLATFORM_ID) private platformId: Object,
|
||||
private preloadService: PreloadService,
|
||||
) {
|
||||
this.webGlEnabled = this.stateService.isBrowser && detectWebGL();
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
ngOnInit(): void {
|
||||
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;
|
||||
@@ -139,12 +127,6 @@ 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);
|
||||
});
|
||||
@@ -172,11 +154,10 @@ export class BlockComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
});
|
||||
|
||||
const block$ = this.route.paramMap.pipe(
|
||||
this.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;
|
||||
@@ -254,16 +235,11 @@ export class BlockComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}),
|
||||
tap((block: BlockExtended) => {
|
||||
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);
|
||||
if (block.previousblockhash) {
|
||||
this.preloadService.block$.next(block.previousblockhash);
|
||||
if (this.auditSupported) {
|
||||
this.preloadService.blockAudit$.next(block.previousblockhash);
|
||||
}
|
||||
}
|
||||
this.updateAuditAvailableFromBlockHeight(block.height);
|
||||
this.block = block;
|
||||
@@ -288,9 +264,6 @@ 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;
|
||||
|
||||
@@ -304,31 +277,8 @@ 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 = block$.pipe(
|
||||
this.overviewSubscription = this.block$.pipe(
|
||||
switchMap((block) => {
|
||||
return forkJoin([
|
||||
this.apiService.getStrippedBlockTransactions$(block.id)
|
||||
@@ -345,7 +295,12 @@ export class BlockComponent implements OnInit, OnDestroy {
|
||||
return of(null);
|
||||
})
|
||||
),
|
||||
this.stateService.env.ACCELERATOR === true && block.height > 819500 ? this.servicesApiService.getAccelerationHistory$({ blockHeight: block.height }) : of([])
|
||||
this.stateService.env.ACCELERATOR === true && block.height > 819500
|
||||
? this.servicesApiService.getAccelerationHistory$({ blockHeight: block.height })
|
||||
.pipe(catchError(() => {
|
||||
return of([]);
|
||||
}))
|
||||
: of([])
|
||||
]);
|
||||
})
|
||||
)
|
||||
@@ -493,14 +448,14 @@ export class BlockComponent implements OnInit, OnDestroy {
|
||||
this.cd.markForCheck();
|
||||
});
|
||||
|
||||
this.oobSubscription = block$.pipe(
|
||||
this.oobSubscription = this.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((err) => {
|
||||
catchError(() => {
|
||||
return of({ block, accelerations: [] });
|
||||
}))
|
||||
),
|
||||
@@ -555,7 +510,7 @@ export class BlockComponent implements OnInit, OnDestroy {
|
||||
if (this.priceSubscription) {
|
||||
this.priceSubscription.unsubscribe();
|
||||
}
|
||||
this.priceSubscription = combineLatest([this.stateService.fiatCurrency$, block$]).pipe(
|
||||
this.priceSubscription = combineLatest([this.stateService.fiatCurrency$, this.block$]).pipe(
|
||||
switchMap(([currency, block]) => {
|
||||
return this.priceService.getBlockPrice$(block.timestamp, true, currency).pipe(
|
||||
tap((price) => {
|
||||
@@ -572,52 +527,27 @@ export class BlockComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
ngOnDestroy(): void {
|
||||
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() {
|
||||
setBlockSubsidy(): void {
|
||||
this.blockSubsidy = 0;
|
||||
}
|
||||
|
||||
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() {
|
||||
toggleShowDetails(): void {
|
||||
if (this.showDetails) {
|
||||
this.showDetails = false;
|
||||
this.router.navigate([], {
|
||||
@@ -649,7 +579,7 @@ export class BlockComponent implements OnInit, OnDestroy {
|
||||
return this.block && this.block.height > 681393 && (new Date().getTime() / 1000) < 1628640000;
|
||||
}
|
||||
|
||||
navigateToPreviousBlock() {
|
||||
navigateToPreviousBlock(): void {
|
||||
if (!this.block) {
|
||||
return;
|
||||
}
|
||||
@@ -658,13 +588,13 @@ export class BlockComponent implements OnInit, OnDestroy {
|
||||
block ? block.id : this.block.previousblockhash], { state: { data: { block, blockHeight: this.nextBlockHeight - 2 } } });
|
||||
}
|
||||
|
||||
navigateToNextBlock() {
|
||||
navigateToNextBlock(): void {
|
||||
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(){
|
||||
setNextAndPreviousBlockLink(): void {
|
||||
if (this.latestBlock) {
|
||||
if (!this.blockHeight){
|
||||
this.showPreviousBlocklink = false;
|
||||
@@ -696,11 +626,12 @@ export class BlockComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
onResize(event: any): void {
|
||||
const isMobile = event.target.innerWidth <= 767.98;
|
||||
onResize(event: Event): void {
|
||||
const target = event.target as Window;
|
||||
const isMobile = target.innerWidth <= 767.98;
|
||||
const changed = isMobile !== this.isMobile;
|
||||
this.isMobile = isMobile;
|
||||
this.paginationMaxSize = event.target.innerWidth < 670 ? 3 : 5;
|
||||
this.paginationMaxSize = target.innerWidth < 670 ? 3 : 5;
|
||||
|
||||
if (changed) {
|
||||
this.changeMode(this.mode);
|
||||
@@ -742,11 +673,11 @@ export class BlockComponent implements OnInit, OnDestroy {
|
||||
this.stateService.hideAudit.next(this.auditModeEnabled);
|
||||
|
||||
this.route.queryParams.subscribe(params => {
|
||||
let queryParams = { ...params };
|
||||
const queryParams = { ...params };
|
||||
delete queryParams['audit'];
|
||||
|
||||
let newUrl = this.router.url.split('?')[0];
|
||||
let queryString = new URLSearchParams(queryParams).toString();
|
||||
const queryString = new URLSearchParams(queryParams).toString();
|
||||
if (queryString) {
|
||||
newUrl += '?' + queryString;
|
||||
}
|
||||
@@ -824,4 +755,10 @@ export class BlockComponent implements OnInit, OnDestroy {
|
||||
this.block.canonical = block.id;
|
||||
}
|
||||
}
|
||||
|
||||
updateBlockReward(blockReward: number): void {
|
||||
if (this.fees === undefined) {
|
||||
this.fees = blockReward;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ 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 = [
|
||||
@@ -32,6 +33,7 @@ export class BlockRoutingModule { }
|
||||
],
|
||||
declarations: [
|
||||
BlockComponent,
|
||||
BlockTransactionsComponent,
|
||||
]
|
||||
})
|
||||
export class BlockModule { }
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
color: var(--fg);
|
||||
font-size: 0.8rem;
|
||||
position: absolute;
|
||||
bottom: 15.8em;
|
||||
bottom: 16.1em;
|
||||
left: 1px;
|
||||
transform: translateX(-50%) rotate(90deg);
|
||||
background: none;
|
||||
|
||||
@@ -68,11 +68,17 @@
|
||||
<ng-template #mempoolTable let-mempoolInfoData>
|
||||
<div class="mempool-info-data">
|
||||
<div class="item">
|
||||
<h5 *ngIf="!mempoolInfoData.value || mempoolInfoData.value.memPoolInfo.mempoolminfee === mempoolInfoData.value.memPoolInfo.minrelaytxfee || (stateService.env.BASE_MODULE === 'liquid' && mempoolInfoData.value.memPoolInfo.mempoolminfee === 0.000001) else purgingText" class="card-title" i18n="dashboard.minimum-fee|Minimum mempool fee">Minimum fee</h5>
|
||||
<ng-template #purgingText><h5 class="card-title" i18n="dashboard.purging|Purgin below fee">Purging</h5></ng-template>
|
||||
<p class="card-text" *ngIf="(isLoadingWebSocket$ | async) === false && mempoolInfoData.value; else loading">
|
||||
<ng-template [ngIf]="mempoolInfoData.value.memPoolInfo.mempoolminfee !== mempoolInfoData.value.memPoolInfo.minrelaytxfee">< </ng-template><app-fee-rate [fee]="mempoolInfoData.value.memPoolInfo.mempoolminfee * 100000"></app-fee-rate>
|
||||
</p>
|
||||
@if (!mempoolInfoData.value || mempoolInfoData.value.memPoolInfo.mempoolminfee === mempoolInfoData.value.memPoolInfo.minrelaytxfee || (stateService.env.BASE_MODULE === 'liquid' && mempoolInfoData.value.memPoolInfo.mempoolminfee === 0.000001)) {
|
||||
<h5 class="card-title" i18n="dashboard.minimum-fee|Minimum mempool fee">Minimum fee</h5>
|
||||
<p class="card-text" *ngIf="(isLoadingWebSocket$ | async) === false && mempoolInfoData.value; else loading">
|
||||
<app-fee-rate [fee]="mempoolInfoData.value.memPoolInfo.mempoolminfee * 100000"></app-fee-rate>
|
||||
</p>
|
||||
} @else {
|
||||
<h5 class="card-title" i18n="dashboard.purging|Purging below fee">Purging</h5>
|
||||
<p class="card-text" *ngIf="(isLoadingWebSocket$ | async) === false && mempoolInfoData.value; else loading" i18n-ngbTooltip="dashboard.purging-desc" ngbTooltip="Fee rate below which transactions are purged from default Bitcoin Core nodes" placement="bottom">
|
||||
< <app-fee-rate [fee]="mempoolInfoData.value.memPoolInfo.mempoolminfee * 100000"></app-fee-rate>
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="item">
|
||||
<h5 class="card-title" i18n="dashboard.unconfirmed|Unconfirmed count">Unconfirmed</h5>
|
||||
@@ -83,7 +89,7 @@
|
||||
<div class="item bar">
|
||||
<h5 class="card-title" i18n="dashboard.memory-usage|Memory usage">Memory Usage</h5>
|
||||
<div class="card-text" *ngIf="(isLoadingWebSocket$ | async) === false && mempoolInfoData.value; else loadingbig">
|
||||
<div class="progress">
|
||||
<div class="progress" i18n-ngbTooltip="dashboard.memory-usage-desc" ngbTooltip="Memory used by our mempool (may exceed default Bitcoin Core limit)" placement="bottom">
|
||||
<div class="progress-bar {{ mempoolInfoData.value.mempoolSizeProgress }}" role="progressbar" [ngStyle]="{'width': (mempoolInfoData.value.memPoolInfo.usage / mempoolInfoData.value.memPoolInfo.maxmempool * 100) + '%' }"> </div>
|
||||
<div class="progress-text">‎<span [innerHTML]="mempoolInfoData.value.memPoolInfo.usage | bytes : 2 : 'B' : null : false : 3"></span> / <span [innerHTML]="mempoolInfoData.value.memPoolInfo.maxmempool | bytes"></span></div>
|
||||
</div>
|
||||
|
||||
90
frontend/src/app/components/faucet/faucet.component.html
Normal file
90
frontend/src/app/components/faucet/faucet.component.html
Normal file
@@ -0,0 +1,90 @@
|
||||
<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 </span>
|
||||
<a routerLink="/login" [queryParams]="{'redirectTo': '/testnet4/faucet'}">login</a>
|
||||
<span class="mr-2"> 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>
|
||||
52
frontend/src/app/components/faucet/faucet.component.scss
Normal file
52
frontend/src/app/components/faucet/faucet.component.scss
Normal file
@@ -0,0 +1,52 @@
|
||||
.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);
|
||||
}
|
||||
184
frontend/src/app/components/faucet/faucet.component.ts
Normal file
184
frontend/src/app/components/faucet/faucet.component.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
<div *ngIf="stateService.env.MINING_DASHBOARD || stateService.env.LIGHTNING" class="mb-3 d-flex menu">
|
||||
<div *ngIf="stateService.env.MINING_DASHBOARD || stateService.env.LIGHTNING || stateService.env.ACCELERATOR" class="mb-3 d-flex menu" [style]="{'flex-wrap': flexWrap ? 'wrap' : ''}">
|
||||
|
||||
<a routerLinkActive="active" class="btn btn-primary" [class]="padding"
|
||||
<a routerLinkActive="active" class="btn btn-primary w-33"
|
||||
[routerLink]="['/graphs/mempool' | relativeUrl]">Mempool</a>
|
||||
|
||||
<div ngbDropdown [class]="padding" *ngIf="stateService.env.MINING_DASHBOARD">
|
||||
<div ngbDropdown class="w-33" *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,6 +17,8 @@
|
||||
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"
|
||||
@@ -26,7 +28,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ngbDropdown [class]="padding" *ngIf="stateService.env.LIGHTNING">
|
||||
<div ngbDropdown class="w-33" *ngIf="stateService.networkSupportsLightning()">
|
||||
<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]"
|
||||
@@ -43,6 +45,14 @@
|
||||
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>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
flex-grow: 1;
|
||||
padding: 0 35px;
|
||||
@media (min-width: 576px) {
|
||||
max-width: 400px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
& > * {
|
||||
@@ -11,5 +11,6 @@
|
||||
&.last-child {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { WebsocketService } from '../../services/websocket.service';
|
||||
styleUrls: ['./graphs.component.scss'],
|
||||
})
|
||||
export class GraphsComponent implements OnInit {
|
||||
padding = 'w-50';
|
||||
flexWrap = false;
|
||||
|
||||
constructor(
|
||||
public stateService: StateService,
|
||||
@@ -18,8 +18,8 @@ export class GraphsComponent implements OnInit {
|
||||
ngOnInit(): void {
|
||||
this.websocketService.want(['blocks']);
|
||||
|
||||
if (this.stateService.env.MINING_DASHBOARD === true && this.stateService.env.LIGHTNING === true) {
|
||||
this.padding = 'w-33';
|
||||
if (this.stateService.env.ACCELERATOR === true && (this.stateService.env.MINING_DASHBOARD === true || this.stateService.env.LIGHTNING === true)) {
|
||||
this.flexWrap = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
|
||||
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(hashrateObservable$ | async) as stats">
|
||||
<div class="btn-group btn-group-toggle" name="radioBasic" [class]="{'disabled': isLoading}">
|
||||
<div class="btn-group btn-group-toggle" name="radioBasic">
|
||||
<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}" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
|
||||
<div [class]="!widget ? 'chart' : 'chart-widget'" *browserOnly [style]="{ height: widget ? ((height + 20) + 'px') : null, opacity: isLoading ? 0.5 : 1 }" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
|
||||
(chartInit)="onChartInit($event)">
|
||||
</div>
|
||||
<div class="text-center loadingGraphs" *ngIf="!stateService.isBrowser || isLoading">
|
||||
|
||||
@@ -113,8 +113,3 @@
|
||||
max-width: 80px;
|
||||
margin: 15px auto 3px;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -349,7 +349,9 @@ 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: {
|
||||
@@ -381,7 +383,9 @@ 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,
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</div>
|
||||
|
||||
<form [formGroup]="radioGroupForm" class="formRadioGroup" *ngIf="(hashrateObservable$ | async) as stats">
|
||||
<div class="btn-group btn-group-toggle" name="radioBasic" [class]="{'disabled': isLoading}">
|
||||
<div class="btn-group btn-group-toggle" name="radioBasic">
|
||||
<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)">
|
||||
(chartInit)="onChartInit($event)" [style]="{opacity: isLoading ? 0.5 : 1}">
|
||||
</div>
|
||||
<div class="text-center loadingGraphs" *ngIf="!stateService.isBrowser || isLoading">
|
||||
<div class="spinner-border text-light"></div>
|
||||
|
||||
@@ -64,8 +64,3 @@
|
||||
.loadingGraphs.widget {
|
||||
top: 75%;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="echarts" *browserOnly echarts [initOpts]="mempoolStatsChartInitOption" [options]="mempoolStatsChartOption" (chartRendered)="rendered()"
|
||||
(chartInit)="onChartInit($event)">
|
||||
(chartInit)="onChartInit($event)" [style]="{opacity: isLoading ? 0.5 : 1}">
|
||||
</div>
|
||||
<div class="text-center loadingGraphs" *ngIf="!stateService.isBrowser || isLoading">
|
||||
<div class="spinner-border text-light"></div>
|
||||
|
||||
@@ -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,8 +52,6 @@ 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) {
|
||||
@@ -79,7 +77,6 @@ export class IncomingTransactionsGraphComponent implements OnInit, OnChanges, On
|
||||
if (!this.data) {
|
||||
return;
|
||||
}
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -226,7 +223,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' : ''}"
|
||||
|
||||
@@ -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="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>
|
||||
<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>
|
||||
<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.env.LIGHTNING && lightningNetworks.includes(stateService.network)">
|
||||
<li class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" id="btn-lightning" *ngIf="stateService.networkSupportsLightning()">
|
||||
<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,6 +102,9 @@
|
||||
<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>
|
||||
|
||||
@@ -27,7 +27,6 @@ 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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<div *browserOnly echarts class="echarts" (chartInit)="onChartReady($event)" (chartRendered)="rendered()" [initOpts]="mempoolVsizeFeesInitOptions" [options]="mempoolVsizeFeesOptions"></div>
|
||||
<div *browserOnly echarts class="echarts" (chartInit)="onChartReady($event)" (chartRendered)="rendered()" [initOpts]="mempoolVsizeFeesInitOptions"
|
||||
[options]="mempoolVsizeFeesOptions" [style]="{opacity: isLoading ? 0.5 : 1}"></div>
|
||||
<div class="text-center loadingGraphs" *ngIf="!stateService.isBrowser || isLoading">
|
||||
<div class="spinner-border text-light"></div>
|
||||
</div>
|
||||
@@ -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,7 +65,6 @@ 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;
|
||||
@@ -86,7 +85,6 @@ export class MempoolGraphComponent implements OnInit, OnChanges {
|
||||
if (!this.data) {
|
||||
return;
|
||||
}
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
onChartReady(myChart: any) {
|
||||
|
||||
@@ -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" [class]="{'disabled': isLoading}">
|
||||
<div class="btn-group btn-group-toggle" name="radioBasic">
|
||||
<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}" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
|
||||
<div [class]="widget ? 'chart-widget' : 'chart'" *browserOnly [style]="{ height: widget ? (height + 'px') : null, opacity: isLoading ? 0.5 : 1 }" 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">
|
||||
<table *ngIf="widget === false" class="table table-borderless text-center pools-table" [style]="{opacity: isLoading ? 0.5 : 1}">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="d-none d-md-table-cell" i18n="mining.rank">Rank</th>
|
||||
|
||||
@@ -121,10 +121,6 @@
|
||||
margin: 15px auto 3px;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
td {
|
||||
.difference {
|
||||
|
||||
@@ -122,7 +122,7 @@ export class SearchFormComponent implements OnInit {
|
||||
]);
|
||||
}
|
||||
this.isTypeaheading$.next(true);
|
||||
if (!this.stateService.env.LIGHTNING) {
|
||||
if (!this.stateService.networkSupportsLightning()) {
|
||||
return zip(
|
||||
this.electrsApiService.getAddressesByPrefix$(text).pipe(catchError(() => of([]))),
|
||||
[{ nodes: [], channels: [] }],
|
||||
|
||||
@@ -94,13 +94,12 @@
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="spinner-border text-light bootstrap-spinner" *ngIf="spinnerLoading && mempoolStats.length"></div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="incoming-transactions-graph">
|
||||
<app-mempool-graph #mempoolgraph dir="ltr" [template]="'advanced'" [hideCount]="!showCount"
|
||||
[limitFilterFee]="filterFeeIndex" [height]="500" [left]="65" [right]="showCount ? 50 : 10"
|
||||
[data]="mempoolStats && mempoolStats.length ? mempoolStats : null"></app-mempool-graph>
|
||||
[data]="mempoolStats && mempoolStats.length ? mempoolStats : null" [isLoading]="isLoading"></app-mempool-graph>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,7 +127,7 @@
|
||||
<div class="card-body">
|
||||
<div class="incoming-transactions-graph">
|
||||
<app-incoming-transactions-graph #incominggraph [height]="500" [left]="65" [template]="'advanced'"
|
||||
[data]="mempoolTransactionsWeightPerSecondData" [outlierCappingEnabled]="outlierCappingEnabled"></app-incoming-transactions-graph>
|
||||
[data]="mempoolTransactionsWeightPerSecondData" [outlierCappingEnabled]="outlierCappingEnabled" [isLoading]="isLoading"></app-incoming-transactions-graph>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
}
|
||||
}
|
||||
.formRadioGroup.mining {
|
||||
@media (min-width: 1035px) {
|
||||
@media (min-width: 1200px) {
|
||||
position: relative;
|
||||
top: -100px;
|
||||
}
|
||||
@@ -231,4 +231,4 @@
|
||||
@media (max-width: 767px) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,7 @@ export class StatisticsComponent implements OnInit {
|
||||
|
||||
network = '';
|
||||
|
||||
loading = true;
|
||||
spinnerLoading = false;
|
||||
isLoading = true;
|
||||
feeLevels = feeLevels;
|
||||
chartColors = chartColors;
|
||||
filterSize = 100000;
|
||||
@@ -90,7 +89,7 @@ export class StatisticsComponent implements OnInit {
|
||||
.pipe(
|
||||
switchMap(() => {
|
||||
this.timespan = this.radioGroupForm.controls.dateSpan.value;
|
||||
this.spinnerLoading = true;
|
||||
this.isLoading = true;
|
||||
if (this.radioGroupForm.controls.dateSpan.value === '2h') {
|
||||
this.websocketService.want(['blocks', 'live-2h-chart']);
|
||||
return this.apiService.list2HStatistics$();
|
||||
@@ -131,8 +130,7 @@ export class StatisticsComponent implements OnInit {
|
||||
.subscribe((mempoolStats: any) => {
|
||||
this.mempoolStats = mempoolStats;
|
||||
this.handleNewMempoolData(this.mempoolStats.concat([]));
|
||||
this.loading = false;
|
||||
this.spinnerLoading = false;
|
||||
this.isLoading = false;
|
||||
});
|
||||
|
||||
this.stateService.live2Chart$
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<div class="text-left">
|
||||
|
||||
<p *ngIf="officialMempoolSpace">The <a href="https://mempool.space/">mempool.space</a> website, the <a href="https://liquid.network/">liquid.network</a> website, their associated API services, and related network and server infrastructure (collectively, the "Website") are operated by Mempool Space K.K. in Japan ("Mempool", "We", or "Us") and self-hosted from <a href="https://bgp.tools/as/142052#connectivity">AS142052</a>.</p>
|
||||
<p *ngIf="officialMempoolSpace">The <a href="https://mempool.space/">mempool.space</a> website, the <a href="https://liquid.network/">liquid.network</a> website, the <a href="https://bitcoin.gob.sv/">bitcoin.gob.sv</a> website, their associated API services, and related network and server infrastructure (collectively, the "Website") are operated by Mempool Space K.K. in Japan ("Mempool", "We", or "Us") and self-hosted from <a href="https://bgp.tools/as/142052#connectivity">AS142052</a>.</p>
|
||||
|
||||
<p *ngIf="!officialMempoolSpace">This website and its API service (collectively, the "Website") are operated by a member of the Bitcoin community ("We" or "Us"). Mempool Space K.K. in Japan ("Mempool") has no affiliation with the operator of this Website, and does not sponsor or endorse the information provided herein.</p>
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
|
||||
),
|
||||
this.refreshChannels$
|
||||
.pipe(
|
||||
filter(() => this.stateService.env.LIGHTNING),
|
||||
filter(() => this.stateService.networkSupportsLightning()),
|
||||
switchMap((txIds) => this.apiService.getChannelByTxIds$(txIds)),
|
||||
catchError((error) => {
|
||||
// handle 404
|
||||
@@ -248,7 +248,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
|
||||
if (txIds.length && !this.cached) {
|
||||
this.refreshOutspends$.next(txIds);
|
||||
}
|
||||
if (this.stateService.env.LIGHTNING) {
|
||||
if (this.stateService.networkSupportsLightning()) {
|
||||
const txIds = this.transactions.filter((tx) => !tx._channels).map((tx) => tx.txid);
|
||||
if (txIds.length) {
|
||||
this.refreshChannels$.next(txIds);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<a href="#" (click)="twitterLogin()"
|
||||
[class]="(disabled ? 'disabled': '') + (customClass ? customClass : 'w-100 btn mt-1 d-flex justify-content-center align-items-center')"
|
||||
style="background-color: #1DA1F2" [style]="width ? 'width: ' + width : ''">
|
||||
<img src="./resources/twitter.svg" height="25" style="padding: 2px" [alt]="buttonString + ' with Twitter'" />
|
||||
<span class="ml-2 text-light align-middle">{{ buttonString }}</span>
|
||||
</a>
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
@Component({
|
||||
selector: 'app-twitter-login',
|
||||
templateUrl: './twitter-login.component.html',
|
||||
})
|
||||
export class TwitterLogin {
|
||||
@Input() width: string | null = null;
|
||||
@Input() customClass: string | null = null;
|
||||
@Input() buttonString: string= 'unset';
|
||||
@Input() redirectTo: string | null = null;
|
||||
@Output() clicked = new EventEmitter<boolean>();
|
||||
@Input() disabled: boolean = false;
|
||||
|
||||
constructor() {}
|
||||
|
||||
twitterLogin() {
|
||||
this.clicked.emit(true);
|
||||
if (this.redirectTo) {
|
||||
location.replace(`/api/v1/services/auth/login/twitter?redirectTo=${encodeURIComponent(this.redirectTo)}`);
|
||||
} else {
|
||||
location.replace(`/api/v1/services/auth/login/twitter?redirectTo=${location.href}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, Input, ChangeDetectionStrategy, SecurityContext } from '@angular/core';
|
||||
import { Component, Input, ChangeDetectionStrategy, SecurityContext, SimpleChanges, OnChanges } from '@angular/core';
|
||||
import { LanguageService } from '../../services/language.service';
|
||||
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||
|
||||
@@ -8,7 +8,7 @@ import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||
styleUrls: ['./twitter-widget.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TwitterWidgetComponent {
|
||||
export class TwitterWidgetComponent implements OnChanges {
|
||||
@Input() handle: string;
|
||||
@Input() width = 300;
|
||||
@Input() height = 400;
|
||||
@@ -27,38 +27,44 @@ export class TwitterWidgetComponent {
|
||||
this.setIframeSrc();
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes.handle) {
|
||||
this.setIframeSrc();
|
||||
}
|
||||
}
|
||||
|
||||
setIframeSrc(): void {
|
||||
this.iframeSrc = this.sanitizer.bypassSecurityTrustResourceUrl(this.sanitizer.sanitize(SecurityContext.URL,
|
||||
'https://syndication.twitter.com/srv/timeline-profile/screen-name/bitcoinofficesv?creatorScreenName=mempool'
|
||||
+ '&dnt=true'
|
||||
+ '&embedId=twitter-widget-0'
|
||||
+ '&features=eyJ0ZndfdGltZWxpbmVfbGlzdCI6eyJidWNrZXQiOltdLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X2ZvbGxvd2VyX2NvdW50X3N1bnNldCI6eyJidWNrZXQiOnRydWUsInZlcnNpb24iOm51bGx9LCJ0ZndfdHdlZXRfZWRpdF9iYWNrZW5kIjp7ImJ1Y2tldCI6Im9uIiwidmVyc2lvbiI6bnVsbH0sInRmd19yZWZzcmNfc2Vzc2lvbiI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9LCJ0ZndfZm9zbnJfc29mdF9pbnRlcnZlbnRpb25zX2VuYWJsZWQiOnsiYnVja2V0Ijoib24iLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X21peGVkX21lZGlhXzE1ODk3Ijp7ImJ1Y2tldCI6InRyZWF0bWVudCIsInZlcnNpb24iOm51bGx9LCJ0ZndfZXhwZXJpbWVudHNfY29va2llX2V4cGlyYXRpb24iOnsiYnVja2V0IjoxMjA5NjAwLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X3Nob3dfYmlyZHdhdGNoX3Bpdm90c19lbmFibGVkIjp7ImJ1Y2tldCI6Im9uIiwidmVyc2lvbiI6bnVsbH0sInRmd19kdXBsaWNhdGVfc2NyaWJlc190b19zZXR0aW5ncyI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9LCJ0ZndfdXNlX3Byb2ZpbGVfaW1hZ2Vfc2hhcGVfZW5hYmxlZCI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9LCJ0ZndfdmlkZW9faGxzX2R5bmFtaWNfbWFuaWZlc3RzXzE1MDgyIjp7ImJ1Y2tldCI6InRydWVfYml0cmF0ZSIsInZlcnNpb24iOm51bGx9LCJ0ZndfbGVnYWN5X3RpbWVsaW5lX3N1bnNldCI6eyJidWNrZXQiOnRydWUsInZlcnNpb24iOm51bGx9LCJ0ZndfdHdlZXRfZWRpdF9mcm9udGVuZCI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9fQ%3D%3D'
|
||||
+ '&frame=false'
|
||||
+ '&hideBorder=true'
|
||||
+ '&hideFooter=false'
|
||||
+ '&hideHeader=true'
|
||||
+ '&hideScrollBar=false'
|
||||
+ `&lang=${this.lang}`
|
||||
+ '&maxHeight=500px'
|
||||
+ '&origin=https%3A%2F%2Fmempool.space%2F'
|
||||
// + '&sessionId=88f6d661d0dcca99c43c0a590f6a3e61c89226a9'
|
||||
+ '&showHeader=false'
|
||||
+ '&showReplies=false'
|
||||
+ '&siteScreenName=mempool'
|
||||
+ '&theme=dark'
|
||||
+ '&transparent=true'
|
||||
+ '&widgetsVersion=2615f7e52b7e0%3A1702314776716'
|
||||
));
|
||||
if (this.handle) {
|
||||
this.iframeSrc = this.sanitizer.bypassSecurityTrustResourceUrl(this.sanitizer.sanitize(SecurityContext.URL,
|
||||
`https://syndication.twitter.com/srv/timeline-profile/screen-name/${this.handle}?creatorScreenName=mempool`
|
||||
+ '&dnt=true'
|
||||
+ '&embedId=twitter-widget-0'
|
||||
+ '&features=eyJ0ZndfdGltZWxpbmVfgbGlzdCI6eyJidWNrZXQiOltdLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X2ZvbGxvd2VyX2NvdW50X3N1bnNldCI6eyJidWNrZXQiOnRydWUsInZlcnNpb24iOm51bGx9LCJ0ZndfdHdlZXRfZWRpdF9iYWNrZW5kIjp7ImJ1Y2tldCI6Im9uIiwidmVyc2lvbiI6bnVsbH0sInRmd19yZWZzcmNfc2Vzc2lvbiI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9LCJ0ZndfZm9zbnJfc29mdF9pbnRlcnZlbnRpb25zX2VuYWJsZWQiOnsiYnVja2V0Ijoib24iLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X21peGVkX21lZGlhXzE1ODk3Ijp7ImJ1Y2tldCI6InRyZWF0bWVudCIsInZlcnNpb24iOm51bGx9LCJ0ZndfZXhwZXJpbWVudHNfY29va2llX2V4cGlyYXRpb24iOnsiYnVja2V0IjoxMjA5NjAwLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X3Nob3dfYmlyZHdhdGNoX3Bpdm90c19lbmFibGVkIjp7ImJ1Y2tldCI6Im9uIiwidmVyc2lvbiI6bnVsbH0sInRmd19kdXBsaWNhdGVfc2NyaWJlc190b19zZXR0aW5ncyI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9LCJ0ZndfdXNlX3Byb2ZpbGVfaW1hZ2Vfc2hhcGVfZW5hYmxlZCI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9LCJ0ZndfdmlkZW9faGxzX2R5bmFtaWNfbWFuaWZlc3RzXzE1MDgyIjp7ImJ1Y2tldCI6InRydWVfYml0cmF0ZSIsInZlcnNpb24iOm51bGx9LCJ0ZndfbGVnYWN5X3RpbWVsaW5lX3N1bnNldCI6eyJidWNrZXQiOnRydWUsInZlcnNpb24iOm51bGx9LCJ0ZndfdHdlZXRfZWRpdF9mcm9udGVuZCI6eyJidWNrZXQiOiJvbiIsInZlcnNpb24iOm51bGx9fQ%3D%3D'
|
||||
+ '&frame=false'
|
||||
+ '&hideBorder=true'
|
||||
+ '&hideFooter=false'
|
||||
+ '&hideHeader=true'
|
||||
+ '&hideScrollBar=false'
|
||||
+ `&lang=${this.lang}`
|
||||
+ '&maxHeight=500px'
|
||||
+ '&origin=https%3A%2F%2Fmempool.space%2F'
|
||||
// + '&sessionId=88f6d661d0dcca99c43c0a590f6a3e61c89226a9'
|
||||
+ '&showHeader=false'
|
||||
+ '&showReplies=false'
|
||||
+ '&siteScreenName=mempool'
|
||||
+ '&theme=dark'
|
||||
+ '&transparent=true'
|
||||
+ '&widgetsVersion=2615f7e52b7e0%3A1702314776716'
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
onReady(): void {
|
||||
console.log('ready!');
|
||||
this.loading = false;
|
||||
this.error = false;
|
||||
}
|
||||
|
||||
onFailed(): void {
|
||||
console.log('failed!')
|
||||
this.loading = false;
|
||||
this.error = true;
|
||||
}
|
||||
|
||||
@@ -272,11 +272,17 @@
|
||||
<ng-template #mempoolTable let-mempoolInfoData>
|
||||
<div class="mempool-info-data">
|
||||
<div class="item">
|
||||
<h5 *ngIf="!mempoolInfoData.value || mempoolInfoData.value.memPoolInfo.mempoolminfee === mempoolInfoData.value.memPoolInfo.minrelaytxfee || (stateService.env.BASE_MODULE === 'liquid' && mempoolInfoData.value.memPoolInfo.mempoolminfee === 0.000001) else purgingText" class="card-title" i18n="dashboard.minimum-fee|Minimum mempool fee">Minimum fee</h5>
|
||||
<ng-template #purgingText><h5 class="card-title" i18n="dashboard.purging|Purgin below fee">Purging</h5></ng-template>
|
||||
<p class="card-text" *ngIf="(isLoadingWebSocket$ | async) === false && mempoolInfoData.value; else loading">
|
||||
<ng-template [ngIf]="mempoolInfoData.value.memPoolInfo.mempoolminfee !== mempoolInfoData.value.memPoolInfo.minrelaytxfee">< </ng-template><app-fee-rate [fee]="mempoolInfoData.value.memPoolInfo.mempoolminfee * 100000"></app-fee-rate>
|
||||
</p>
|
||||
@if (!mempoolInfoData.value || mempoolInfoData.value.memPoolInfo.mempoolminfee === mempoolInfoData.value.memPoolInfo.minrelaytxfee || (stateService.env.BASE_MODULE === 'liquid' && mempoolInfoData.value.memPoolInfo.mempoolminfee === 0.000001)) {
|
||||
<h5 class="card-title" i18n="dashboard.minimum-fee|Minimum mempool fee">Minimum fee</h5>
|
||||
<p class="card-text" *ngIf="(isLoadingWebSocket$ | async) === false && mempoolInfoData.value; else loading">
|
||||
<app-fee-rate [fee]="mempoolInfoData.value.memPoolInfo.mempoolminfee * 100000"></app-fee-rate>
|
||||
</p>
|
||||
} @else {
|
||||
<h5 class="card-title" i18n="dashboard.purging|Purging below fee">Purging</h5>
|
||||
<p class="card-text" *ngIf="(isLoadingWebSocket$ | async) === false && mempoolInfoData.value; else loading" i18n-ngbTooltip="dashboard.purging-desc" ngbTooltip="Fee rate below which transactions are purged from default Bitcoin Core nodes" placement="bottom">
|
||||
< <app-fee-rate [fee]="mempoolInfoData.value.memPoolInfo.mempoolminfee * 100000"></app-fee-rate>
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="item">
|
||||
<h5 class="card-title" i18n="dashboard.unconfirmed|Unconfirmed count">Unconfirmed</h5>
|
||||
@@ -287,7 +293,7 @@
|
||||
<div class="item bar">
|
||||
<h5 class="card-title" i18n="dashboard.memory-usage|Memory usage">Memory Usage</h5>
|
||||
<div class="card-text" *ngIf="(isLoadingWebSocket$ | async) === false && mempoolInfoData.value; else loadingbig">
|
||||
<div class="progress">
|
||||
<div class="progress" i18n-ngbTooltip="dashboard.memory-usage-desc" ngbTooltip="Memory used by our mempool (may exceed default Bitcoin Core limit)" placement="bottom">
|
||||
<div class="progress-bar {{ mempoolInfoData.value.mempoolSizeProgress }}" role="progressbar" [ngStyle]="{'width': (mempoolInfoData.value.memPoolInfo.usage / mempoolInfoData.value.memPoolInfo.maxmempool * 100) + '%' }"> </div>
|
||||
<div class="progress-text">‎<span [innerHTML]="mempoolInfoData.value.memPoolInfo.usage | bytes : 2 : 'B' : null : false : 3"></span> / <span [innerHTML]="mempoolInfoData.value.memPoolInfo.maxmempool | bytes"></span></div>
|
||||
</div>
|
||||
|
||||
@@ -238,7 +238,7 @@
|
||||
</ng-template>
|
||||
|
||||
<ng-template type="why-empty-blocks">
|
||||
<p>When a new block is found, mining pools send miners a block template with no transactions so they can start searching for the next block as soon as possible. They send a block template full of transactions right afterward, but a full block template is a bigger data transfer and takes slightly longer to reach miners.</p><p>In this intervening time, which is usually no more than 1-2 seconds, miners sometimes get lucky and find a new block using the empty block template.</p>
|
||||
<p>When a new block is found, mining pools often send miners new block templates prior to fully validating the new block, often before they've even received the new block. During this time, it is not possible to select transactions for the next block as a pool isn't sure which transactions conflict with transactions already mined.</p><p>While empty blocks do not add additional transactions to the blockchain, they do contribute to the overall security of transactions already in the chain.</p>
|
||||
</ng-template>
|
||||
|
||||
<ng-template type="why-block-timestamps-dont-always-increase">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SharedModule } from '../shared/shared.module';
|
||||
|
||||
import { AccelerationFeesGraphComponent } from '../components/acceleration/acceleration-fees-graph/acceleration-fees-graph.component';
|
||||
import { BlockFeesGraphComponent } from '../components/block-fees-graph/block-fees-graph.component';
|
||||
import { BlockFeesSubsidyGraphComponent } from '../components/block-fees-subsidy-graph/block-fees-subsidy-graph.component';
|
||||
import { BlockRewardsGraphComponent } from '../components/block-rewards-graph/block-rewards-graph.component';
|
||||
import { BlockFeeRatesGraphComponent } from '../components/block-fee-rates-graph/block-fee-rates-graph.component';
|
||||
import { BlockSizesWeightsGraphComponent } from '../components/block-sizes-weights-graph/block-sizes-weights-graph.component';
|
||||
@@ -54,6 +55,7 @@ import { CommonModule } from '@angular/common';
|
||||
GraphsComponent,
|
||||
AccelerationFeesGraphComponent,
|
||||
BlockFeesGraphComponent,
|
||||
BlockFeesSubsidyGraphComponent,
|
||||
BlockRewardsGraphComponent,
|
||||
BlockFeeRatesGraphComponent,
|
||||
BlockSizesWeightsGraphComponent,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { RouterModule, Routes } from '@angular/router';
|
||||
import { BlockHealthGraphComponent } from '../components/block-health-graph/block-health-graph.component';
|
||||
import { BlockFeeRatesGraphComponent } from '../components/block-fee-rates-graph/block-fee-rates-graph.component';
|
||||
import { BlockFeesGraphComponent } from '../components/block-fees-graph/block-fees-graph.component';
|
||||
import { BlockFeesSubsidyGraphComponent } from '../components/block-fees-subsidy-graph/block-fees-subsidy-graph.component';
|
||||
import { BlockRewardsGraphComponent } from '../components/block-rewards-graph/block-rewards-graph.component';
|
||||
import { BlockSizesWeightsGraphComponent } from '../components/block-sizes-weights-graph/block-sizes-weights-graph.component';
|
||||
import { GraphsComponent } from '../components/graphs/graphs.component';
|
||||
@@ -113,6 +114,11 @@ const routes: Routes = [
|
||||
data: { networks: ['bitcoin'] },
|
||||
component: BlockFeesGraphComponent,
|
||||
},
|
||||
{
|
||||
path: 'mining/block-fees-subsidy',
|
||||
data: { networks: ['bitcoin'] },
|
||||
component: BlockFeesSubsidyGraphComponent,
|
||||
},
|
||||
{
|
||||
path: 'mining/block-rewards',
|
||||
data: { networks: ['bitcoin'] },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user