Compare commits

...

123 Commits

Author SHA1 Message Date
hunicus
247c364ccd Update nostr icon to look less dorky 2023-07-05 18:09:48 -04:00
softsimon
7b01f54fc6 Merge pull request #3919 from joostjager/pool-fees
Add average fee delta to pool ranking
2023-07-05 15:30:15 +02:00
wiz
9f2e94d9cd Merge pull request #3863 from mempool/mononaut/scrollable-mempool
scroll to see all mempool blocks
2023-07-05 09:57:17 +09:00
wiz
824c6f97e4 ops: Use mempool/electrs for liquid instances 2023-07-05 09:33:33 +09:00
wiz
efcb58a4a6 Merge branch 'master' into mononaut/scrollable-mempool 2023-07-05 07:58:50 +09:00
Joost Jager
9e5d10b15f Add average fee delta to pool ranking
Co-authored-by: mononaut <83316221+mononaut@users.noreply.github.com>
2023-07-04 11:25:39 +00:00
softsimon
3d33233e51 Merge pull request #3891 from 0xFlicker/master
sign contributor agreement
2023-07-03 10:11:51 +02:00
softsimon
4cd7561af8 Merge pull request #3906 from mempool/simon/mempool-break-limit
Lowering mempool loop break limit
2023-07-03 10:08:17 +02:00
softsimon
52c813bcc7 Merge branch 'master' into simon/mempool-break-limit 2023-07-03 09:59:21 +02:00
softsimon
c20c7ae11f Merge pull request #3917 from mempool/mononaut/effective-rate-templates
Save effective rates to templates & summaries
2023-07-02 18:40:40 +02:00
softsimon
5f582195ad Merge branch 'master' into mononaut/effective-rate-templates 2023-07-02 18:11:28 +02:00
softsimon
a567a28c5c Update backend/src/api/mempool.ts
Co-authored-by: mononaut <83316221+mononaut@users.noreply.github.com>
2023-07-02 18:10:14 +02:00
softsimon
59713e2132 Update backend/src/api/mempool.ts
Co-authored-by: mononaut <83316221+mononaut@users.noreply.github.com>
2023-07-02 18:10:08 +02:00
softsimon
1e83a99bae Update backend/src/api/mempool.ts
Co-authored-by: mononaut <83316221+mononaut@users.noreply.github.com>
2023-07-02 18:10:02 +02:00
softsimon
b5fdb6d64f Merge pull request #3843 from mempool/mononaut/projected-fee-graph
Better projected fee graph
2023-07-02 17:44:12 +02:00
softsimon
1411b701f9 Merge pull request #3887 from mempool/mononaut/atomic-cpfp
Atomic CPFP database operations
2023-07-02 17:29:42 +02:00
softsimon
f80257c5ea Merge branch 'master' into mononaut/atomic-cpfp 2023-07-01 22:04:14 +02:00
softsimon
23cd063402 Merge pull request #3883 from mempool/hunicus/ronindojo-link
Update ronindojo link on about page
2023-07-01 22:03:43 +02:00
softsimon
2bda12e5f9 Merge pull request #3881 from mempool/mononaut/separate-audit-api
Separate summary and audit-summary API endpoints
2023-07-01 19:43:20 +02:00
softsimon
2e285c8d86 Merge pull request #3878 from mempool/mononaut/full-mempool-graph
Add 'all time' option for mempool graph
2023-07-01 17:23:03 +02:00
softsimon
70854de6ec Merge pull request #3855 from mempool/mononaut/websocket-responses
Fix inconsistent websocket responses
2023-07-01 17:19:22 +02:00
softsimon
f5cf5c7cc3 Merge pull request #3916 from mempool/mononaut/fix-transaction-eta
Fix transaction component ETA
2023-07-01 16:21:18 +02:00
nymkappa
d939391e62 ALL -> all (no caps needed, match the url parameters as well) 2023-06-30 19:52:06 -04:00
nymkappa
85e33e689d break graph toggles into another line earlier 2023-06-30 19:52:05 -04:00
Mononaut
ca1a1228a9 break graph toggles into two rows on small screens 2023-06-30 19:52:05 -04:00
Mononaut
d4ed238ae1 Add statistics/all to cache warmer, change query keyword 2023-06-30 19:52:05 -04:00
Mononaut
22baf4186e Add 'all time' option for mempool graph 2023-06-30 19:52:05 -04:00
Mononaut
58b8052530 don't reset blockchain position on every mempool update 2023-06-30 19:45:53 -04:00
Mononaut
9d606d0006 scroll selected mempool block into view 2023-06-30 19:45:53 -04:00
Mononaut
d848ab4bef scroll to see all mempool blocks 2023-06-30 19:45:53 -04:00
Mononaut
eaad63a082 frontend resync recent blocks when necessary 2023-06-30 19:43:04 -04:00
Mononaut
ca6ddd609d clean up backend websocket logic 2023-06-30 19:43:04 -04:00
Mononaut
eca40f94c9 use power-of-ten formatting for large fee rates 2023-06-30 19:41:12 -04:00
Mononaut
86f51e3902 fix fee graph for underfilled blocks 2023-06-30 19:27:31 -04:00
Mononaut
9f2b98b246 Handle stack-of-N-blocks in new fee graph 2023-06-30 19:27:31 -04:00
Mononaut
e4f3642082 Redesign mempool block fee distribution graph 2023-06-30 19:27:30 -04:00
Mononaut
48d62a1396 Save effective fee rates to block templates & summaries 2023-06-29 19:24:19 -04:00
Mononaut
d9ed02a033 Fix transaction component ETA 2023-06-29 11:39:13 -04:00
softsimon
31ae382f75 Lowering mempool loop break limit 2023-06-27 18:46:28 +02:00
softsimon
5f787db30d Merge pull request #3762 from knorrium/update_node_matrix
Update node CI test matrix
2023-06-26 18:08:53 +02:00
softsimon
da3c39c7d0 Merge pull request #3861 from mempool/hunicus/add-luminex-sponsor
Add luminex as enterprise sponsor
2023-06-26 12:31:03 +02:00
0xflick
dbf759fc76 sign contributor agreement 2023-06-24 16:30:29 -05:00
wiz
e88cf70719 Merge pull request #3886 from mempool/mononaut/hotfix-undefined-cpfp-cluster
Hotfix for undefined cpfp cluster bug
2023-06-23 19:08:27 +09:00
Mononaut
1f442b9ea6 Make cpfp db save operations atomic 2023-06-22 12:08:47 -04:00
Mononaut
9ff5ce0d37 Change order of cpfp db operations 2023-06-21 19:57:18 -04:00
Mononaut
329c635da5 Fix getCpfpInfo error handling 2023-06-21 19:57:09 -04:00
Mononaut
e18f3800be Hotfix for undefined cpfp cluster bug 2023-06-21 19:07:00 -04:00
hunicus
9bfc2c9413 Update ronindojo link on about page 2023-06-20 22:36:29 -04:00
Mononaut
4fbab08586 Separate summary and audit-summary API endpoints 2023-06-20 15:13:52 -04:00
softsimon
e5efc2957a Merge pull request #3871 from mempool/mononaut/rbf-removed-not-mined
Change RBF status badges
2023-06-17 15:05:12 +02:00
softsimon
20d7e56de2 Update i18n 2023-06-17 15:04:46 +02:00
softsimon
0586e04d67 Merge pull request #3873 from mempool/simon/sanitize-pubkey-search
Sanitize node pubkey search
2023-06-17 11:22:28 +02:00
wiz
39bde61538 ops: Set cache time for /api/v1 endpoints to 2 sec 2023-06-16 17:15:41 -07:00
softsimon
0fb92e6ebb Merge pull request #3872 from mempool/simon/mempool-loop-timeout
Reinstate the mempool loop time limit
2023-06-17 00:56:00 +02:00
softsimon
c8d3653ef3 Updating tests 2023-06-17 00:32:58 +02:00
softsimon
a5575c0876 Sanitize node pubkey search 2023-06-16 23:42:57 +02:00
Mononaut
1872e5d12f change "removed" and "replaced" badges to yellow 2023-06-16 17:35:07 -04:00
softsimon
176f5e1377 Reinstate the mempool loop time limit 2023-06-16 20:42:31 +02:00
Mononaut
c0e235c01a Mark RBF transactions as removed if earlier version is mined 2023-06-16 13:47:09 -04:00
softsimon
2faeb1071e Merge pull request #3864 from mempool/mononaut/blocks-api-calls
remove redundant audit score api calls from blocks list
2023-06-16 19:37:48 +02:00
Mononaut
618ba56c42 remove redundant audit score api calls from blocks list 2023-06-15 12:57:20 -04:00
softsimon
d955dbff55 Merge pull request #3842 from mempool/mononaut/consistent-fee-ranges
Fix fee range inconsistencies
2023-06-15 16:49:07 +02:00
Mononaut
ee39283241 precompute block fee spans 2023-06-15 09:54:08 -04:00
wiz
c42670259e ops: Fix crash while building electrs in install script 2023-06-15 02:37:30 -07:00
hunicus
816f410855 Make luminex css class unique 2023-06-14 22:17:05 -04:00
Mononaut
bb61ff97fa continue to skip first rate in simple fee ranges 2023-06-14 19:04:09 -04:00
hunicus
73d629d319 Add luminex as enterprise sponsor on about page 2023-06-14 18:58:49 -04:00
softsimon
c630d705df Merge pull request #3839 from mempool/mononaut/clean-up-legacy-cpfp
Clean up legacy CPFP calculations
2023-06-14 23:14:07 +02:00
softsimon
c5bf167e36 Merge pull request #3846 from mempool/mononaut/audit-details
Add expected vs actual audit details comparison table
2023-06-14 23:05:07 +02:00
softsimon
e8420853e2 Merge pull request #3838 from mempool/mononaut/dependent-rate-indexing
calculate & index ancestor-dependent effective rates
2023-06-14 23:02:25 +02:00
softsimon
a8a5b733e5 Merge pull request #3860 from mempool/simon/disable-liquid-tests
Disabling Liquid tests for now
2023-06-14 22:28:08 +02:00
Mononaut
30f8d5cf96 add missing markForChecks in blocks list 2023-06-14 16:23:57 -04:00
softsimon
f09939a201 Disabling Liquid tests for now 2023-06-14 22:20:18 +02:00
softsimon
a99515e94a Merge pull request #3834 from mempool/mononaut/fix-double-mined-rbf
Fix multiple mined RBF replacements of the same tx
2023-06-14 22:15:53 +02:00
Mononaut
c4f7b99978 add backfilled audit stats to cached blocks 2023-06-14 16:15:33 -04:00
softsimon
dcf73ec3f3 Merge pull request #3831 from mempool/mononaut/clock
Interactive clock
2023-06-14 22:14:26 +02:00
softsimon
bbe0579cdd Changing default clock to mempool 2023-06-14 21:53:51 +02:00
softsimon
4390ffe3b6 Merge pull request #3820 from mempool/nymkappa/reindexing-pools-update
Mining pool update / re-indexer improvment
2023-06-14 19:16:08 +02:00
Mononaut
6b93e61b56 minor audit details fixes 2023-06-14 11:28:39 -04:00
softsimon
c79d031c86 Merge pull request #3668 from mempool/simon/ignore-existing-mining-pools
Skip existing mining pool logos when syncing assets
2023-06-13 11:15:02 +02:00
Felipe Knorr Kuhn
048399574e Merge branch 'master' into update_node_matrix 2023-06-12 22:42:21 -07:00
Mononaut
ae5a0312be change audit detail labels 2023-06-12 12:40:19 -04:00
softsimon
0b2ffb3e91 Merge branch 'master' into nymkappa/reindexing-pools-update 2023-06-12 15:53:46 +02:00
softsimon
d009edbbf3 Merge pull request #3822 from mempool/nymkappa/fix-possible-crash
fix possible backend crash
2023-06-12 15:52:47 +02:00
softsimon
1fbdf97639 Merge branch 'master' into nymkappa/fix-possible-crash 2023-06-12 15:45:02 +02:00
softsimon
a36303e1fb Merge branch 'master' into simon/ignore-existing-mining-pools 2023-06-12 14:29:40 +02:00
softsimon
27a3a1575d Merge pull request #3849 from mempool/mononaut/icon-reset-scroll
Reset blockchain scroll on logo click
2023-06-11 18:43:44 +02:00
Mononaut
93d24d1cf7 Add expected fee % diff to blocks list page 2023-06-10 12:35:15 -04:00
Mononaut
bfb842d7ea Add % difference to weight and tx count in audit details 2023-06-10 12:35:13 -04:00
Mononaut
5b62966863 Add indexer task to backfill audit fee/weight stats 2023-06-10 12:34:16 -04:00
Mononaut
3013386ca5 Add expected weight to audit table 2023-06-10 12:32:53 -04:00
Mononaut
aedaf53137 Merge branch 'master' into merged-expected-block-fees 2023-06-10 12:15:29 -04:00
Joost Jager
7157efcf79 Add CLA for joostjager
Signed-off-by: Joost Jager <joost.jager@gmail.com>
2023-06-10 11:10:05 +02:00
Mononaut
57ac1486a0 Reset blockchain scroll on logo click 2023-06-09 19:03:47 -04:00
Mononaut
3c022ad755 Fix fee range inconsistencies 2023-06-07 11:59:31 -04:00
Mononaut
ca9b48283d calculate & index ancestor-dependent effective rates 2023-06-06 18:23:06 -04:00
Mononaut
c8fc416c88 Remove legacy mined block cpfp loop, reset stale ancestors 2023-06-06 14:19:30 -04:00
Mononaut
35d80eec1c Fix multiple mined RBF replacements of the same tx 2023-06-06 11:01:01 -04:00
Joost Jager
74b2014dff Show expected fees in blocks list 2023-06-06 08:52:29 +02:00
Mononaut
9883e59f12 fix graph page links layout 2023-06-05 14:09:02 -04:00
Mononaut
30396c5dca Add link to clock from graph page 2023-06-05 13:28:00 -04:00
Mononaut
ec0d5e0c23 Polish clocks, fix urls, make interactive 2023-06-05 13:27:17 -04:00
Joost Jager
3c0bb11208 Add expected total fees audit 2023-06-05 14:19:16 +02:00
nymkappa
0b74cf1d89 fix possible backend crash x2, remove dead code, improve log 2023-05-31 09:58:29 -07:00
nymkappa
c558c85f36 fix possible backend crash 2023-05-31 09:48:44 -07:00
nymkappa
ea51ab8d0b [indexer] show github sha when successufly updated pools json 2023-05-30 10:42:41 -07:00
nymkappa
62169cee3f [indexer] oldest known mining pool block per network 2023-05-30 10:25:41 -07:00
nymkappa
e7e7b30807 fix log 2023-05-30 10:16:56 -07:00
nymkappa
107bdbc209 [indexer] show indexer progress in /status component 2023-05-30 10:13:07 -07:00
nymkappa
0b4615cbf0 [indexer] reindex diff adjustments and hashrates upon mining pool update 2023-05-30 10:05:10 -07:00
softsimon
aa9fd845ef Path fix 2023-05-14 14:21:41 -05:00
softsimon
e41ce16bbb Merge branch 'master' into simon/ignore-existing-mining-pools 2023-05-14 13:19:22 -05:00
softsimon
f12403747d Adding video to sha1 check and chaining requests in promises. 2023-05-14 12:45:42 -05:00
Felipe Knorr Kuhn
68e9fb0882 Merge branch 'master' into update_node_matrix 2023-05-13 14:27:45 -07:00
softsimon
da3c3e8f5c Re-use variable fix 2023-05-13 13:24:44 -05:00
softsimon
a447887901 Adding missing slash 2023-05-13 13:23:27 -05:00
softsimon
d3bd434255 Use sha hashes to compare before downloading 2023-05-12 16:01:45 -05:00
softsimon
49e057e726 Fixing trailing slash issue 2023-05-12 11:05:20 -05:00
softsimon
7f3e4eb534 Skip existing mining pool logos when syncing assets 2023-05-12 11:05:19 -05:00
softsimon
50a96ce340 Merge branch 'master' into update_node_matrix 2023-05-12 10:37:23 -05:00
Felipe Knorr Kuhn
38162e1af2 Merge branch 'master' into update_node_matrix 2023-05-12 07:44:07 -07:00
Felipe Knorr Kuhn
b0478f6604 Remove node 19 and 20 due to Angular incompatibility 2023-05-11 22:15:29 -07:00
Felipe Knorr Kuhn
6cae3256a9 Update node CI test matrix 2023-05-11 21:42:19 -07:00
82 changed files with 1696 additions and 737 deletions

View File

@@ -9,7 +9,7 @@ jobs:
if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')"
strategy:
matrix:
node: ["16.16.0", "18.14.1"]
node: ["16", "17", "18"]
flavor: ["dev", "prod"]
fail-fast: false
runs-on: "ubuntu-latest"
@@ -55,7 +55,7 @@ jobs:
if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')"
strategy:
matrix:
node: ["16.16.0", "18.14.1"]
node: ["16", "17", "18"]
flavor: ["dev", "prod"]
fail-fast: false
runs-on: "ubuntu-latest"

View File

@@ -224,7 +224,12 @@ class BitcoinRoutes {
} else {
let cpfpInfo;
if (config.DATABASE.ENABLED) {
cpfpInfo = await transactionRepository.$getCpfpInfo(req.params.txId);
try {
cpfpInfo = await transactionRepository.$getCpfpInfo(req.params.txId);
} catch (e) {
res.status(500).send('failed to get CPFP info');
return;
}
}
if (cpfpInfo) {
res.json(cpfpInfo);
@@ -394,9 +399,13 @@ class BitcoinRoutes {
private async getBlockAuditSummary(req: Request, res: Response) {
try {
const transactions = await blocks.$getBlockAuditSummary(req.params.hash);
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString());
res.json(transactions);
const auditSummary = await blocks.$getBlockAuditSummary(req.params.hash);
if (auditSummary) {
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString());
res.json(auditSummary);
} else {
return res.status(404).send(`audit not available`);
}
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}

View File

@@ -127,14 +127,6 @@ class Blocks {
}
}
if (addMempoolData) {
transactions.forEach((tx) => {
if (!tx.cpfpChecked) {
Common.setRelativesAndGetCpfpInfo(tx as MempoolTransactionExtended, mempool); // Child Pay For Parent
}
});
}
if (!quiet) {
logger.debug(`${transactionsFound} of ${txIds.length} found in mempool. ${transactionsFetched} fetched through backend service.`);
}
@@ -166,6 +158,13 @@ class Blocks {
};
}
public summarizeBlockTransactions(hash: string, transactions: TransactionExtended[]): BlockSummary {
return {
id: hash,
transactions: Common.stripTransactions(transactions),
};
}
private convertLiquidFees(block: IBitcoinApi.VerboseBlock): IBitcoinApi.VerboseBlock {
block.tx.forEach(tx => {
tx.fee = Object.values(tx.fee || {}).reduce((total, output) => total + output, 0);
@@ -282,10 +281,14 @@ class Blocks {
}
extras.matchRate = null;
extras.expectedFees = null;
extras.expectedWeight = null;
if (config.MEMPOOL.AUDIT) {
const auditScore = await BlocksAuditsRepository.$getBlockAuditScore(block.id);
if (auditScore != null) {
extras.matchRate = auditScore.matchRate;
extras.expectedFees = auditScore.expectedFees;
extras.expectedWeight = auditScore.expectedWeight;
}
}
}
@@ -455,6 +458,46 @@ class Blocks {
}
}
/**
* [INDEXING] Index expected fees & weight for all audited blocks
*/
public async $generateAuditStats(): Promise<void> {
const blockIds = await BlocksAuditsRepository.$getBlocksWithoutSummaries();
if (!blockIds?.length) {
return;
}
let timer = Date.now();
let indexedThisRun = 0;
let indexedTotal = 0;
logger.debug(`Indexing ${blockIds.length} block audit details`);
for (const hash of blockIds) {
const summary = await BlocksSummariesRepository.$getTemplate(hash);
let totalFees = 0;
let totalWeight = 0;
for (const tx of summary?.transactions || []) {
totalFees += tx.fee;
totalWeight += (tx.vsize * 4);
}
await BlocksAuditsRepository.$setSummary(hash, totalFees, totalWeight);
const cachedBlock = this.blocks.find(block => block.id === hash);
if (cachedBlock) {
cachedBlock.extras.expectedFees = totalFees;
cachedBlock.extras.expectedWeight = totalWeight;
}
indexedThisRun++;
indexedTotal++;
const elapsedSeconds = (Date.now() - timer) / 1000;
if (elapsedSeconds > 5) {
const blockPerSeconds = indexedThisRun / elapsedSeconds;
logger.debug(`Indexed ${indexedTotal} / ${blockIds.length} block audit details (${blockPerSeconds.toFixed(1)}/s)`);
timer = Date.now();
indexedThisRun = 0;
}
}
logger.debug(`Indexing block audit details completed`);
}
/**
* [INDEXING] Index all blocks metadata for the mining dashboard
*/
@@ -610,7 +653,7 @@ class Blocks {
}
const cpfpSummary: CpfpSummary = Common.calculateCpfp(block.height, transactions);
const blockExtended: BlockExtended = await this.$getBlockExtended(block, cpfpSummary.transactions);
const blockSummary: BlockSummary = this.summarizeBlock(verboseBlock);
const blockSummary: BlockSummary = this.summarizeBlockTransactions(block.id, cpfpSummary.transactions);
this.updateTimerProgress(timer, `got block data for ${this.currentBlockHeight}`);
// start async callbacks
@@ -632,12 +675,13 @@ class Blocks {
for (let i = 10; i >= 0; --i) {
const newBlock = await this.$indexBlock(lastBlock.height - i);
this.updateTimerProgress(timer, `reindexed block`);
await this.$getStrippedBlockTransactions(newBlock.id, true, true);
this.updateTimerProgress(timer, `reindexed block summary`);
let cpfpSummary;
if (config.MEMPOOL.CPFP_INDEXING) {
await this.$indexCPFP(newBlock.id, lastBlock.height - i);
cpfpSummary = await this.$indexCPFP(newBlock.id, lastBlock.height - i);
this.updateTimerProgress(timer, `reindexed block cpfp`);
}
await this.$getStrippedBlockTransactions(newBlock.id, true, true, cpfpSummary, newBlock.height);
this.updateTimerProgress(timer, `reindexed block summary`);
}
await mining.$indexDifficultyAdjustments();
await DifficultyAdjustmentsRepository.$deleteLastAdjustment();
@@ -645,9 +689,12 @@ class Blocks {
logger.info(`Re-indexed 10 blocks and summaries. Also re-indexed the last difficulty adjustments. Will re-index latest hashrates in a few seconds.`, logger.tags.mining);
indexer.reindex();
}
await blocksRepository.$saveBlockInDatabase(blockExtended);
this.updateTimerProgress(timer, `saved ${this.currentBlockHeight} to database`);
}
await blocksRepository.$saveBlockInDatabase(blockExtended);
this.updateTimerProgress(timer, `saved ${this.currentBlockHeight} to database`);
if (!fastForwarded) {
const lastestPriceId = await PricesRepository.$getLatestPriceId();
this.updateTimerProgress(timer, `got latest price id ${this.currentBlockHeight}`);
if (priceUpdater.historyInserted === true && lastestPriceId !== null) {
@@ -665,7 +712,7 @@ class Blocks {
// Save blocks summary for visualization if it's enabled
if (Common.blocksSummariesIndexingEnabled() === true) {
await this.$getStrippedBlockTransactions(blockExtended.id, true);
await this.$getStrippedBlockTransactions(blockExtended.id, true, false, cpfpSummary, blockExtended.height);
this.updateTimerProgress(timer, `saved block summary for ${this.currentBlockHeight}`);
}
if (config.MEMPOOL.CPFP_INDEXING) {
@@ -691,6 +738,11 @@ class Blocks {
this.currentDifficulty = block.difficulty;
}
// wait for pending async callbacks to finish
this.updateTimerProgress(timer, `waiting for async callbacks to complete for ${this.currentBlockHeight}`);
await Promise.all(callbackPromises);
this.updateTimerProgress(timer, `async callbacks completed for ${this.currentBlockHeight}`);
this.blocks.push(blockExtended);
if (this.blocks.length > config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4) {
this.blocks = this.blocks.slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4);
@@ -707,11 +759,6 @@ class Blocks {
diskCache.$saveCacheToDisk();
}
// wait for pending async callbacks to finish
this.updateTimerProgress(timer, `waiting for async callbacks to complete for ${this.currentBlockHeight}`);
await Promise.all(callbackPromises);
this.updateTimerProgress(timer, `async callbacks completed for ${this.currentBlockHeight}`);
handledBlocks++;
}
@@ -788,7 +835,7 @@ class Blocks {
}
public async $getStrippedBlockTransactions(hash: string, skipMemoryCache = false,
skipDBLookup = false): Promise<TransactionStripped[]>
skipDBLookup = false, cpfpSummary?: CpfpSummary, blockHeight?: number): Promise<TransactionStripped[]>
{
if (skipMemoryCache === false) {
// Check the memory cache
@@ -806,13 +853,35 @@ class Blocks {
}
}
// Call Core RPC
const block = await bitcoinClient.getBlock(hash, 2);
const summary = this.summarizeBlock(block);
let height = blockHeight;
let summary: BlockSummary;
if (cpfpSummary) {
summary = {
id: hash,
transactions: cpfpSummary.transactions.map(tx => {
return {
txid: tx.txid,
fee: tx.fee,
vsize: tx.vsize,
value: Math.round(tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0)),
rate: tx.effectiveFeePerVsize
};
}),
};
} else {
// Call Core RPC
const block = await bitcoinClient.getBlock(hash, 2);
summary = this.summarizeBlock(block);
height = block.height;
}
if (height == null) {
const block = await bitcoinApi.$getBlock(hash);
height = block.height;
}
// Index the response if needed
if (Common.blocksSummariesIndexingEnabled() === true) {
await BlocksSummariesRepository.$saveTransactions(block.height, block.hash, summary.transactions);
await BlocksSummariesRepository.$saveTransactions(height, hash, summary.transactions);
}
return summary.transactions;
@@ -968,19 +1037,11 @@ class Blocks {
}
public async $getBlockAuditSummary(hash: string): Promise<any> {
let summary;
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) {
summary = await BlocksAuditsRepository.$getBlockAudit(hash);
return BlocksAuditsRepository.$getBlockAudit(hash);
} else {
return null;
}
// fallback to non-audited transaction summary
if (!summary?.transactions?.length) {
const strippedTransactions = await this.$getStrippedBlockTransactions(hash);
summary = {
transactions: strippedTransactions
};
}
return summary;
}
public getLastDifficultyAdjustmentTime(): number {
@@ -1011,9 +1072,13 @@ class Blocks {
}
public async $saveCpfp(hash: string, height: number, cpfpSummary: CpfpSummary): Promise<void> {
const result = await cpfpRepository.$batchSaveClusters(cpfpSummary.clusters);
if (!result) {
await cpfpRepository.$insertProgressMarker(height);
try {
const result = await cpfpRepository.$batchSaveClusters(cpfpSummary.clusters);
if (!result) {
await cpfpRepository.$insertProgressMarker(height);
}
} catch (e) {
// not a fatal error, we'll try again next time the indexer runs
}
}
}

View File

@@ -1,4 +1,4 @@
import { Ancestor, CpfpInfo, CpfpSummary, EffectiveFeeStats, MempoolBlockWithTransactions, TransactionExtended, MempoolTransactionExtended, TransactionStripped, WorkingEffectiveFeeStats } from '../mempool.interfaces';
import { Ancestor, CpfpInfo, CpfpSummary, CpfpCluster, EffectiveFeeStats, MempoolBlockWithTransactions, TransactionExtended, MempoolTransactionExtended, TransactionStripped, WorkingEffectiveFeeStats } from '../mempool.interfaces';
import config from '../config';
import { NodeSocket } from '../repositories/NodesSocketsRepository';
import { isIP } from 'net';
@@ -91,6 +91,14 @@ export class Common {
if (replaced.size) {
matches[tx.txid] = { replaced: Array.from(replaced), replacedBy: tx };
}
// remove this tx from the spendMap
// prevents the same tx being replaced more than once
for (const vin of tx.vin) {
const key = `${vin.txid}:${vin.vout}`;
if (spendMap.get(key)?.txid === tx.txid) {
spendMap.delete(key);
}
}
}
return matches;
}
@@ -105,6 +113,10 @@ export class Common {
};
}
static stripTransactions(txs: TransactionExtended[]): TransactionStripped[] {
return txs.map(this.stripTransaction);
}
static sleep$(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(() => {
@@ -366,40 +378,80 @@ export class Common {
}
static calculateCpfp(height: number, transactions: TransactionExtended[]): CpfpSummary {
const clusters: { root: string, height: number, txs: Ancestor[], effectiveFeePerVsize: number }[] = [];
let cluster: TransactionExtended[] = [];
let ancestors: { [txid: string]: boolean } = {};
const clusters: CpfpCluster[] = []; // list of all cpfp clusters in this block
const clusterMap: { [txid: string]: CpfpCluster } = {}; // map transactions to their cpfp cluster
let clusterTxs: TransactionExtended[] = []; // working list of elements of the current cluster
let ancestors: { [txid: string]: boolean } = {}; // working set of ancestors of the current cluster root
const txMap = {};
// initialize the txMap
for (const tx of transactions) {
txMap[tx.txid] = tx;
}
// reverse pass to identify CPFP clusters
for (let i = transactions.length - 1; i >= 0; i--) {
const tx = transactions[i];
txMap[tx.txid] = tx;
if (!ancestors[tx.txid]) {
let totalFee = 0;
let totalVSize = 0;
cluster.forEach(tx => {
clusterTxs.forEach(tx => {
totalFee += tx?.fee || 0;
totalVSize += (tx.weight / 4);
});
const effectiveFeePerVsize = totalFee / totalVSize;
if (cluster.length > 1) {
clusters.push({
root: cluster[0].txid,
let cluster: CpfpCluster;
if (clusterTxs.length > 1) {
cluster = {
root: clusterTxs[0].txid,
height,
txs: cluster.map(tx => { return { txid: tx.txid, weight: tx.weight, fee: tx.fee || 0 }; }),
txs: clusterTxs.map(tx => { return { txid: tx.txid, weight: tx.weight, fee: tx.fee || 0 }; }),
effectiveFeePerVsize,
});
};
clusters.push(cluster);
}
cluster.forEach(tx => {
clusterTxs.forEach(tx => {
txMap[tx.txid].effectiveFeePerVsize = effectiveFeePerVsize;
if (cluster) {
clusterMap[tx.txid] = cluster;
}
});
cluster = [];
// reset working vars
clusterTxs = [];
ancestors = {};
}
cluster.push(tx);
clusterTxs.push(tx);
tx.vin.forEach(vin => {
ancestors[vin.txid] = true;
});
}
// forward pass to enforce ancestor rate caps
for (const tx of transactions) {
let minAncestorRate = tx.effectiveFeePerVsize;
for (const vin of tx.vin) {
if (txMap[vin.txid]?.effectiveFeePerVsize) {
minAncestorRate = Math.min(minAncestorRate, txMap[vin.txid].effectiveFeePerVsize);
}
}
// check rounded values to skip cases with almost identical fees
const roundedMinAncestorRate = Math.ceil(minAncestorRate);
const roundedEffectiveFeeRate = Math.floor(tx.effectiveFeePerVsize);
if (roundedMinAncestorRate < roundedEffectiveFeeRate) {
tx.effectiveFeePerVsize = minAncestorRate;
if (!clusterMap[tx.txid]) {
// add a single-tx cluster to record the dependent rate
const cluster = {
root: tx.txid,
height,
txs: [{ txid: tx.txid, weight: tx.weight, fee: tx.fee || 0 }],
effectiveFeePerVsize: minAncestorRate,
};
clusterMap[tx.txid] = cluster;
clusters.push(cluster);
} else {
// update the existing cluster with the dependent rate
clusterMap[tx.txid].effectiveFeePerVsize = minAncestorRate;
}
}
}
return {
transactions,
clusters,

View File

@@ -7,7 +7,7 @@ import cpfpRepository from '../repositories/CpfpRepository';
import { RowDataPacket } from 'mysql2';
class DatabaseMigration {
private static currentVersion = 61;
private static currentVersion = 62;
private queryTimeout = 3600_000;
private statisticsAddedIndexed = false;
private uniqueLogs: string[] = [];
@@ -533,6 +533,12 @@ class DatabaseMigration {
await this.updateToSchemaVersion(61);
}
if (databaseSchemaVersion < 62 && isBitcoin === true) {
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD expected_fees BIGINT UNSIGNED DEFAULT NULL');
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD expected_weight BIGINT UNSIGNED DEFAULT NULL');
await this.updateToSchemaVersion(62);
}
}
/**
@@ -1051,7 +1057,7 @@ class DatabaseMigration {
}
public async $blocksReindexingTruncate(): Promise<void> {
logger.warn(`Truncating pools, blocks and hashrates for re-indexing (using '--reindex-blocks'). You can cancel this command within 5 seconds`);
logger.warn(`Truncating pools, blocks, hashrates and difficulty_adjustments tables for re-indexing (using '--reindex-blocks'). You can cancel this command within 5 seconds`);
await Common.sleep$(5000);
await this.$executeQuery(`TRUNCATE blocks`);

View File

@@ -373,7 +373,7 @@ class NodesApi {
public async $searchNodeByPublicKeyOrAlias(search: string) {
try {
const publicKeySearch = search.replace('%', '') + '%';
const publicKeySearch = search.replace(/[^a-zA-Z0-9]/g, '') + '%';
const aliasSearch = search
.replace(/[-_.]/g, ' ') // Replace all -_. characters with empty space. Eg: "ln.nicehash" becomes "ln nicehash".
.replace(/[^a-zA-Z0-9 ]/g, '') // Remove all special characters and keep just A to Z, 0 to 9.

View File

@@ -143,7 +143,7 @@ class MempoolBlocks {
const stackWeight = transactionsSorted.slice(index).reduce((total, tx) => total + (tx.weight || 0), 0);
if (stackWeight > config.MEMPOOL.BLOCK_WEIGHT_UNITS) {
onlineStats = true;
feeStatsCalculator = new OnlineFeeStatsCalculator(stackWeight, 0.5);
feeStatsCalculator = new OnlineFeeStatsCalculator(stackWeight, 0.5, [10, 20, 30, 40, 50, 60, 70, 80, 90]);
feeStatsCalculator.processNext(tx);
}
}
@@ -334,7 +334,7 @@ class MempoolBlocks {
if (hasBlockStack) {
stackWeight = blocks[blocks.length - 1].reduce((total, tx) => total + (mempool[tx]?.weight || 0), 0);
hasBlockStack = stackWeight > config.MEMPOOL.BLOCK_WEIGHT_UNITS;
feeStatsCalculator = new OnlineFeeStatsCalculator(stackWeight, 0.5);
feeStatsCalculator = new OnlineFeeStatsCalculator(stackWeight, 0.5, [10, 20, 30, 40, 50, 60, 70, 80, 90]);
}
const readyBlocks: { transactionIds, transactions, totalSize, totalWeight, totalFees, feeStats }[] = [];
@@ -358,6 +358,9 @@ class MempoolBlocks {
block: blockIndex,
vsize: totalVsize + (mempoolTx.vsize / 2),
};
mempoolTx.ancestors = [];
mempoolTx.descendants = [];
mempoolTx.bestDescendant = null;
mempoolTx.cpfpChecked = true;
// online calculation of stack-of-blocks fee stats

View File

@@ -156,7 +156,7 @@ class Mempool {
}
};
let loggerTimer = new Date().getTime() / 1000;
let intervalTimer = Date.now();
for (const txid of transactions) {
if (!this.mempoolCache[txid]) {
try {
@@ -179,12 +179,20 @@ class Mempool {
logger.debug(`Error finding transaction '${txid}' in the mempool: ` + (e instanceof Error ? e.message : e));
}
}
const elapsedSeconds = Math.round((new Date().getTime() / 1000) - loggerTimer);
if (elapsedSeconds > 4) {
const progress = (currentMempoolSize + newTransactions.length) / transactions.length * 100;
logger.debug(`Mempool is synchronizing. Processed ${newTransactions.length}/${diff} txs (${Math.round(progress)}%)`);
loadingIndicators.setProgress('mempool', progress);
loggerTimer = new Date().getTime() / 1000;
if (Date.now() - intervalTimer > 5_000) {
if (this.inSync) {
// Break and restart mempool loop if we spend too much time processing
// new transactions that may lead to falling behind on block height
logger.debug('Breaking mempool loop because the 5s time limit exceeded.');
break;
} else {
const progress = (currentMempoolSize + newTransactions.length) / transactions.length * 100;
logger.debug(`Mempool is synchronizing. Processed ${newTransactions.length}/${diff} txs (${Math.round(progress)}%)`);
loadingIndicators.setProgress('mempool', progress);
intervalTimer = Date.now()
}
}
}

View File

@@ -13,11 +13,15 @@ import BlocksAuditsRepository from '../../repositories/BlocksAuditsRepository';
import PricesRepository from '../../repositories/PricesRepository';
import { bitcoinCoreApi } from '../bitcoin/bitcoin-api-factory';
import { IEsploraApi } from '../bitcoin/esplora-api.interface';
import database from '../../database';
class Mining {
private blocksPriceIndexingRunning = false;
public lastHashrateIndexingDate: number | null = null;
public lastWeeklyHashrateIndexingDate: number | null = null;
public reindexHashrateRequested = false;
public reindexDifficultyAdjustmentRequested = false;
/**
* Get historical blocks health
@@ -102,6 +106,7 @@ class Mining {
emptyBlocks: emptyBlocksCount.length > 0 ? emptyBlocksCount[0]['count'] : 0,
slug: poolInfo.slug,
avgMatchRate: poolInfo.avgMatchRate !== null ? Math.round(100 * poolInfo.avgMatchRate) / 100 : null,
avgFeeDelta: poolInfo.avgFeeDelta,
};
poolsStats.push(poolStat);
});
@@ -289,6 +294,14 @@ class Mining {
* Generate daily hashrate data
*/
public async $generateNetworkHashrateHistory(): Promise<void> {
// If a re-index was requested, truncate first
if (this.reindexHashrateRequested === true) {
logger.notice(`hashrates will now be re-indexed`);
await database.query(`TRUNCATE hashrates`);
this.lastHashrateIndexingDate = 0;
this.reindexHashrateRequested = false;
}
// We only run this once a day around midnight
const today = new Date().getUTCDate();
if (today === this.lastHashrateIndexingDate) {
@@ -394,6 +407,13 @@ class Mining {
* Index difficulty adjustments
*/
public async $indexDifficultyAdjustments(): Promise<void> {
// If a re-index was requested, truncate first
if (this.reindexDifficultyAdjustmentRequested === true) {
logger.notice(`difficulty_adjustments will now be re-indexed`);
await database.query(`TRUNCATE difficulty_adjustments`);
this.reindexDifficultyAdjustmentRequested = false;
}
const indexedHeightsArray = await DifficultyAdjustmentsRepository.$getAdjustmentsHeights();
const indexedHeights = {};
for (const height of indexedHeightsArray) {
@@ -472,11 +492,11 @@ class Mining {
}
this.blocksPriceIndexingRunning = true;
let totalInserted = 0;
try {
const prices: any[] = await PricesRepository.$getPricesTimesAndId();
const blocksWithoutPrices: any[] = await BlocksRepository.$getBlocksWithoutPrice();
let totalInserted = 0;
const blocksPrices: BlockPrice[] = [];
for (const block of blocksWithoutPrices) {
@@ -521,7 +541,13 @@ class Mining {
}
} catch (e) {
this.blocksPriceIndexingRunning = false;
throw e;
logger.err(`Cannot index block prices. ${e}`);
}
if (totalInserted > 0) {
logger.info(`Indexing blocks prices completed. Indexed ${totalInserted}`, logger.tags.mining);
} else {
logger.debug(`Indexing blocks prices completed. Indexed 0.`, logger.tags.mining);
}
this.blocksPriceIndexingRunning = false;

View File

@@ -4,6 +4,7 @@ import config from '../config';
import PoolsRepository from '../repositories/PoolsRepository';
import { PoolTag } from '../mempool.interfaces';
import diskCache from './disk-cache';
import mining from './mining/mining';
class PoolsParser {
miningPools: any[] = [];
@@ -73,14 +74,12 @@ class PoolsParser {
if (JSON.stringify(pool.addresses) !== poolDB.addresses ||
JSON.stringify(pool.regexes) !== poolDB.regexes) {
// Pool addresses changed or coinbase tags changed
logger.notice(`Updating addresses and/or coinbase tags for ${pool.name} mining pool. If 'AUTOMATIC_BLOCK_REINDEXING' is enabled, we will re-index its blocks and 'unknown' blocks`);
logger.notice(`Updating addresses and/or coinbase tags for ${pool.name} mining pool.`);
await PoolsRepository.$updateMiningPoolTags(poolDB.id, pool.addresses, pool.regexes);
await this.$deleteBlocksForPool(poolDB);
}
}
}
logger.info('Mining pools-v2.json import completed');
}
/**
@@ -128,7 +127,15 @@ class PoolsParser {
LIMIT 1`,
[pool.id]
);
const oldestBlockHeight = oldestPoolBlock.length ?? 0 > 0 ? oldestPoolBlock[0].height : 130635;
let firstKnownBlockPool = 130635; // https://mempool.space/block/0000000000000a067d94ff753eec72830f1205ad3a4c216a08a80c832e551a52
if (config.MEMPOOL.NETWORK === 'testnet') {
firstKnownBlockPool = 21106; // https://mempool.space/testnet/block/0000000070b701a5b6a1b965f6a38e0472e70b2bb31b973e4638dec400877581
} else if (config.MEMPOOL.NETWORK === 'signet') {
firstKnownBlockPool = 0;
}
const oldestBlockHeight = oldestPoolBlock.length ?? 0 > 0 ? oldestPoolBlock[0].height : firstKnownBlockPool;
const [unknownPool] = await DB.query(`SELECT id from pools where slug = "unknown"`);
this.uniqueLog(logger.notice, `Deleting blocks with unknown mining pool from height ${oldestBlockHeight} for re-indexing`);
await DB.query(`
@@ -142,16 +149,31 @@ class PoolsParser {
WHERE pool_id = ?`,
[pool.id]
);
// Re-index hashrates and difficulty adjustments later
mining.reindexHashrateRequested = true;
mining.reindexDifficultyAdjustmentRequested = true;
}
private async $deleteUnknownBlocks(): Promise<void> {
let firstKnownBlockPool = 130635; // https://mempool.space/block/0000000000000a067d94ff753eec72830f1205ad3a4c216a08a80c832e551a52
if (config.MEMPOOL.NETWORK === 'testnet') {
firstKnownBlockPool = 21106; // https://mempool.space/testnet/block/0000000070b701a5b6a1b965f6a38e0472e70b2bb31b973e4638dec400877581
} else if (config.MEMPOOL.NETWORK === 'signet') {
firstKnownBlockPool = 0;
}
const [unknownPool] = await DB.query(`SELECT id from pools where slug = "unknown"`);
this.uniqueLog(logger.notice, `Deleting blocks with unknown mining pool from height 130635 for re-indexing`);
this.uniqueLog(logger.notice, `Deleting blocks with unknown mining pool from height ${firstKnownBlockPool} for re-indexing`);
await DB.query(`
DELETE FROM blocks
WHERE pool_id = ? AND height >= 130635`,
WHERE pool_id = ? AND height >= ${firstKnownBlockPool}`,
[unknownPool[0].id]
);
// Re-index hashrates and difficulty adjustments later
mining.reindexHashrateRequested = true;
mining.reindexDifficultyAdjustmentRequested = true;
}
}

View File

@@ -211,7 +211,7 @@ class StatisticsApi {
CAST(avg(vsize_1800) as DOUBLE) as vsize_1800,
CAST(avg(vsize_2000) as DOUBLE) as vsize_2000 \
FROM statistics \
WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW() \
${interval === 'all' ? '' : `WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`} \
GROUP BY UNIX_TIMESTAMP(added) DIV ${div} \
ORDER BY statistics.added DESC;`;
}
@@ -259,7 +259,7 @@ class StatisticsApi {
vsize_1800,
vsize_2000 \
FROM statistics \
WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW() \
${interval === 'all' ? '' : `WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`} \
GROUP BY UNIX_TIMESTAMP(added) DIV ${div} \
ORDER BY statistics.added DESC;`;
}
@@ -386,6 +386,17 @@ class StatisticsApi {
}
}
public async $listAll(): Promise<OptimizedStatistic[]> {
try {
const query = this.getQueryForDays(43200, 'all'); // 12h interval
const [rows] = await DB.query({ sql: query, timeout: this.queryTimeout });
return this.mapStatisticToOptimizedStatistic(rows as Statistic[]);
} catch (e) {
logger.err('$listAll() error' + (e instanceof Error ? e.message : e));
return [];
}
}
private mapStatisticToOptimizedStatistic(statistic: Statistic[]): OptimizedStatistic[] {
return statistic.map((s) => {
return {

View File

@@ -15,10 +15,11 @@ class StatisticsRoutes {
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/2y', this.$getStatisticsByTime.bind(this, '2y'))
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/3y', this.$getStatisticsByTime.bind(this, '3y'))
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/4y', this.$getStatisticsByTime.bind(this, '4y'))
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/all', this.$getStatisticsByTime.bind(this, 'all'))
;
}
private async $getStatisticsByTime(time: '2h' | '24h' | '1w' | '1m' | '3m' | '6m' | '1y' | '2y' | '3y' | '4y', req: Request, res: Response) {
private async $getStatisticsByTime(time: '2h' | '24h' | '1w' | '1m' | '3m' | '6m' | '1y' | '2y' | '3y' | '4y' | 'all', req: Request, res: Response) {
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString());
@@ -26,10 +27,6 @@ class StatisticsRoutes {
try {
let result;
switch (time as string) {
case '2h':
result = await statisticsApi.$list2H();
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
break;
case '24h':
result = await statisticsApi.$list24H();
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
@@ -58,8 +55,13 @@ class StatisticsRoutes {
case '4y':
result = await statisticsApi.$list4Y();
break;
case 'all':
result = await statisticsApi.$listAll();
break;
default:
result = await statisticsApi.$list2H();
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
break;
}
res.json(result);
} catch (e) {

View File

@@ -22,6 +22,14 @@ import { deepClone } from '../utils/clone';
import priceUpdater from '../tasks/price-updater';
import { ApiPrice } from '../repositories/PricesRepository';
// valid 'want' subscriptions
const wantable = [
'blocks',
'mempool-blocks',
'live-2h-chart',
'stats',
];
class WebsocketHandler {
private wss: WebSocket.Server | undefined;
private extraInitProperties = {};
@@ -30,7 +38,7 @@ class WebsocketHandler {
private numConnected = 0;
private numDisconnected = 0;
private initData: { [key: string]: string } = {};
private socketData: { [key: string]: string } = {};
private serializedInitData: string = '{}';
constructor() { }
@@ -39,28 +47,28 @@ class WebsocketHandler {
this.wss = wss;
}
setExtraInitProperties(property: string, value: any) {
setExtraInitData(property: string, value: any) {
this.extraInitProperties[property] = value;
this.setInitDataFields(this.extraInitProperties);
this.updateSocketDataFields(this.extraInitProperties);
}
private setInitDataFields(data: { [property: string]: any }): void {
private updateSocketDataFields(data: { [property: string]: any }): void {
for (const property of Object.keys(data)) {
if (data[property] != null) {
this.initData[property] = JSON.stringify(data[property]);
this.socketData[property] = JSON.stringify(data[property]);
} else {
delete this.initData[property];
delete this.socketData[property];
}
}
this.serializedInitData = '{'
+ Object.keys(this.initData).map(key => `"${key}": ${this.initData[key]}`).join(', ')
+ '}';
+ Object.keys(this.socketData).map(key => `"${key}": ${this.socketData[key]}`).join(', ')
+ '}';
}
private updateInitData(): void {
private updateSocketData(): void {
const _blocks = blocks.getBlocks().slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT);
const da = difficultyAdjustment.getDifficultyAdjustment();
this.setInitDataFields({
this.updateSocketDataFields({
'mempoolInfo': memPool.getMempoolInfo(),
'vBytesPerSecond': memPool.getVBytesPerSecond(),
'blocks': _blocks,
@@ -94,11 +102,33 @@ class WebsocketHandler {
const parsedMessage: WebsocketResponse = JSON.parse(message);
const response = {};
if (parsedMessage.action === 'want') {
client['want-blocks'] = parsedMessage.data.indexOf('blocks') > -1;
client['want-mempool-blocks'] = parsedMessage.data.indexOf('mempool-blocks') > -1;
client['want-live-2h-chart'] = parsedMessage.data.indexOf('live-2h-chart') > -1;
client['want-stats'] = parsedMessage.data.indexOf('stats') > -1;
const wantNow = {};
if (parsedMessage && parsedMessage.action === 'want' && Array.isArray(parsedMessage.data)) {
for (const sub of wantable) {
const key = `want-${sub}`;
const wants = parsedMessage.data.includes(sub);
if (wants && client['wants'] && !client[key]) {
wantNow[key] = true;
}
client[key] = wants;
}
client['wants'] = true;
}
// send initial data when a client first starts a subscription
if (wantNow['want-blocks'] || (parsedMessage && parsedMessage['refresh-blocks'])) {
response['blocks'] = this.socketData['blocks'];
}
if (wantNow['want-mempool-blocks']) {
response['mempool-blocks'] = this.socketData['mempool-blocks'];
}
if (wantNow['want-stats']) {
response['mempoolInfo'] = this.socketData['mempoolInfo'];
response['vBytesPerSecond'] = this.socketData['vBytesPerSecond'];
response['fees'] = this.socketData['fees'];
response['da'] = this.socketData['da'];
}
if (parsedMessage && parsedMessage['track-tx']) {
@@ -109,21 +139,21 @@ class WebsocketHandler {
if (parsedMessage['watch-mempool']) {
const rbfCacheTxid = rbfCache.getReplacedBy(trackTxid);
if (rbfCacheTxid) {
response['txReplaced'] = {
response['txReplaced'] = JSON.stringify({
txid: rbfCacheTxid,
};
});
client['track-tx'] = null;
} else {
// It might have appeared before we had the time to start watching for it
const tx = memPool.getMempool()[trackTxid];
if (tx) {
if (config.MEMPOOL.BACKEND === 'esplora') {
response['tx'] = tx;
response['tx'] = JSON.stringify(tx);
} else {
// tx.prevout is missing from transactions when in bitcoind mode
try {
const fullTx = await transactionUtils.$getMempoolTransactionExtended(tx.txid, true);
response['tx'] = fullTx;
response['tx'] = JSON.stringify(fullTx);
} catch (e) {
logger.debug('Error finding transaction: ' + (e instanceof Error ? e.message : e));
}
@@ -131,7 +161,7 @@ class WebsocketHandler {
} else {
try {
const fullTx = await transactionUtils.$getMempoolTransactionExtended(client['track-tx'], true);
response['tx'] = fullTx;
response['tx'] = JSON.stringify(fullTx);
} catch (e) {
logger.debug('Error finding transaction. ' + (e instanceof Error ? e.message : e));
client['track-mempool-tx'] = parsedMessage['track-tx'];
@@ -141,10 +171,10 @@ class WebsocketHandler {
}
const tx = memPool.getMempool()[trackTxid];
if (tx && tx.position) {
response['txPosition'] = {
response['txPosition'] = JSON.stringify({
txid: trackTxid,
position: tx.position,
};
});
}
} else {
client['track-tx'] = null;
@@ -177,10 +207,10 @@ class WebsocketHandler {
const index = parsedMessage['track-mempool-block'];
client['track-mempool-block'] = index;
const mBlocksWithTransactions = mempoolBlocks.getMempoolBlocksWithTransactions();
response['projected-block-transactions'] = {
response['projected-block-transactions'] = JSON.stringify({
index: index,
blockTransactions: mBlocksWithTransactions[index]?.transactions || [],
};
});
} else {
client['track-mempool-block'] = null;
}
@@ -189,23 +219,24 @@ class WebsocketHandler {
if (parsedMessage && parsedMessage['track-rbf'] !== undefined) {
if (['all', 'fullRbf'].includes(parsedMessage['track-rbf'])) {
client['track-rbf'] = parsedMessage['track-rbf'];
response['rbfLatest'] = JSON.stringify(rbfCache.getRbfTrees(parsedMessage['track-rbf'] === 'fullRbf'));
} else {
client['track-rbf'] = false;
}
}
if (parsedMessage.action === 'init') {
if (!this.initData['blocks']?.length || !this.initData['da']) {
this.updateInitData();
if (!this.socketData['blocks']?.length || !this.socketData['da']) {
this.updateSocketData();
}
if (!this.initData['blocks']?.length) {
if (!this.socketData['blocks']?.length) {
return;
}
client.send(this.serializedInitData);
}
if (parsedMessage.action === 'ping') {
response['pong'] = true;
response['pong'] = JSON.stringify(true);
}
if (parsedMessage['track-donation'] && parsedMessage['track-donation'].length === 22) {
@@ -221,7 +252,8 @@ class WebsocketHandler {
}
if (Object.keys(response).length) {
client.send(JSON.stringify(response));
const serializedResponse = this.serializeResponse(response);
client.send(serializedResponse);
}
} catch (e) {
logger.debug('Error parsing websocket message: ' + (e instanceof Error ? e.message : e));
@@ -250,7 +282,7 @@ class WebsocketHandler {
throw new Error('WebSocket.Server is not set');
}
this.setInitDataFields({ 'loadingIndicators': indicators });
this.updateSocketDataFields({ 'loadingIndicators': indicators });
const response = JSON.stringify({ loadingIndicators: indicators });
this.wss.clients.forEach((client) => {
@@ -266,7 +298,7 @@ class WebsocketHandler {
throw new Error('WebSocket.Server is not set');
}
this.setInitDataFields({ 'conversions': conversionRates });
this.updateSocketDataFields({ 'conversions': conversionRates });
const response = JSON.stringify({ conversions: conversionRates });
this.wss.clients.forEach((client) => {
@@ -336,11 +368,21 @@ class WebsocketHandler {
memPool.addToSpendMap(newTransactions);
const recommendedFees = feeApi.getRecommendedFee();
const latestTransactions = newTransactions.slice(0, 6).map((tx) => Common.stripTransaction(tx));
// update init data
this.updateInitData();
this.updateSocketDataFields({
'mempoolInfo': mempoolInfo,
'vBytesPerSecond': vBytesPerSecond,
'mempool-blocks': mBlocks,
'transactions': latestTransactions,
'loadingIndicators': loadingIndicators.getLoadingIndicators(),
'da': da?.previousTime ? da : undefined,
'fees': recommendedFees,
});
// cache serialized objects to avoid stringify-ing the same thing for every client
const responseCache = { ...this.initData };
const responseCache = { ...this.socketData };
function getCachedResponse(key: string, data): string {
if (!responseCache[key]) {
responseCache[key] = JSON.stringify(data);
@@ -371,8 +413,6 @@ class WebsocketHandler {
}
}
const latestTransactions = newTransactions.slice(0, 6).map((tx) => Common.stripTransaction(tx));
this.wss.clients.forEach(async (client) => {
if (client.readyState !== WebSocket.OPEN) {
return;
@@ -490,7 +530,7 @@ class WebsocketHandler {
if (rbfReplacedBy) {
response['rbfTransaction'] = JSON.stringify({
txid: rbfReplacedBy,
})
});
}
const rbfChange = rbfChanges.map[client['track-tx']];
@@ -524,9 +564,7 @@ class WebsocketHandler {
}
if (Object.keys(response).length) {
const serializedResponse = '{'
+ Object.keys(response).map(key => `"${key}": ${response[key]}`).join(', ')
+ '}';
const serializedResponse = this.serializeResponse(response);
client.send(serializedResponse);
}
});
@@ -562,20 +600,20 @@ class WebsocketHandler {
const { censored, added, fresh, sigop, score, similarity } = Audit.auditBlock(transactions, projectedBlocks, auditMempool);
const matchRate = Math.round(score * 100 * 100) / 100;
const stripped = projectedBlocks[0]?.transactions ? projectedBlocks[0].transactions.map((tx) => {
return {
txid: tx.txid,
vsize: tx.vsize,
fee: tx.fee ? Math.round(tx.fee) : 0,
value: tx.value,
};
}) : [];
const stripped = projectedBlocks[0]?.transactions ? projectedBlocks[0].transactions : [];
let totalFees = 0;
let totalWeight = 0;
for (const tx of stripped) {
totalFees += tx.fee;
totalWeight += (tx.vsize * 4);
}
BlocksSummariesRepository.$saveTemplate({
height: block.height,
template: {
id: block.id,
transactions: stripped
transactions: stripped,
}
});
@@ -588,10 +626,14 @@ class WebsocketHandler {
freshTxs: fresh,
sigopTxs: sigop,
matchRate: matchRate,
expectedFees: totalFees,
expectedWeight: totalWeight,
});
if (block.extras) {
block.extras.matchRate = matchRate;
block.extras.expectedFees = totalFees;
block.extras.expectedWeight = totalWeight;
block.extras.similarity = similarity;
}
}
@@ -622,11 +664,19 @@ class WebsocketHandler {
const da = difficultyAdjustment.getDifficultyAdjustment();
const fees = feeApi.getRecommendedFee();
const mempoolInfo = memPool.getMempoolInfo();
// update init data
this.updateInitData();
this.updateSocketDataFields({
'mempoolInfo': mempoolInfo,
'blocks': [...blocks.getBlocks(), block].slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT),
'mempool-blocks': mBlocks,
'loadingIndicators': loadingIndicators.getLoadingIndicators(),
'da': da?.previousTime ? da : undefined,
'fees': fees,
});
const responseCache = { ...this.initData };
const responseCache = { ...this.socketData };
function getCachedResponse(key, data): string {
if (!responseCache[key]) {
responseCache[key] = JSON.stringify(data);
@@ -634,22 +684,26 @@ class WebsocketHandler {
return responseCache[key];
}
const mempoolInfo = memPool.getMempoolInfo();
this.wss.clients.forEach((client) => {
if (client.readyState !== WebSocket.OPEN) {
return;
}
if (!client['want-blocks']) {
return;
const response = {};
if (client['want-blocks']) {
response['block'] = getCachedResponse('block', block);
}
const response = {};
response['block'] = getCachedResponse('block', block);
response['mempoolInfo'] = getCachedResponse('mempoolInfo', mempoolInfo);
response['da'] = getCachedResponse('da', da?.previousTime ? da : undefined);
response['fees'] = getCachedResponse('fees', fees);
if (client['want-stats']) {
response['mempoolInfo'] = getCachedResponse('mempoolInfo', mempoolInfo);
response['vBytesPerSecond'] = getCachedResponse('vBytesPerSecond', memPool.getVBytesPerSecond());
response['fees'] = getCachedResponse('fees', fees);
if (da?.previousTime) {
response['da'] = getCachedResponse('da', da);
}
}
if (mBlocks && client['want-mempool-blocks']) {
response['mempool-blocks'] = getCachedResponse('mempool-blocks', mBlocks);
@@ -744,11 +798,19 @@ class WebsocketHandler {
}
}
const serializedResponse = '{'
if (Object.keys(response).length) {
const serializedResponse = this.serializeResponse(response);
client.send(serializedResponse);
}
});
}
// takes a dictionary of JSON serialized values
// and zips it together into a valid JSON object
private serializeResponse(response): string {
return '{'
+ Object.keys(response).map(key => `"${key}": ${response[key]}`).join(', ')
+ '}';
client.send(serializedResponse);
});
}
private printLogs(): void {

View File

@@ -30,7 +30,7 @@ import { FieldPacket, OkPacket, PoolOptions, ResultSetHeader, RowDataPacket } fr
}
public async query<T extends RowDataPacket[][] | RowDataPacket[] | OkPacket |
OkPacket[] | ResultSetHeader>(query, params?): Promise<[T, FieldPacket[]]>
OkPacket[] | ResultSetHeader>(query, params?, connection?: PoolConnection): Promise<[T, FieldPacket[]]>
{
this.checkDBFlag();
let hardTimeout;
@@ -45,7 +45,9 @@ import { FieldPacket, OkPacket, PoolOptions, ResultSetHeader, RowDataPacket } fr
reject(new Error(`DB query failed to return, reject or time out within ${hardTimeout / 1000}s - ${query?.sql?.slice(0, 160) || (typeof(query) === 'string' || query instanceof String ? query?.slice(0, 160) : 'unknown query')}`));
}, hardTimeout);
this.getPool().then(pool => {
// Use a specific connection if provided, otherwise delegate to the pool
const connectionPromise = connection ? Promise.resolve(connection) : this.getPool();
connectionPromise.then((pool: PoolConnection | Pool) => {
return pool.query(query, params) as Promise<[T, FieldPacket[]]>;
}).then(result => {
resolve(result);
@@ -61,6 +63,33 @@ import { FieldPacket, OkPacket, PoolOptions, ResultSetHeader, RowDataPacket } fr
}
}
public async $atomicQuery<T extends RowDataPacket[][] | RowDataPacket[] | OkPacket |
OkPacket[] | ResultSetHeader>(queries: { query, params }[]): Promise<[T, FieldPacket[]][]>
{
const pool = await this.getPool();
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const results: [T, FieldPacket[]][] = [];
for (const query of queries) {
const result = await this.query(query.query, query.params, connection) as [T, FieldPacket[]];
results.push(result);
}
await connection.commit();
return results;
} catch (e) {
logger.err('Could not complete db transaction, rolling back: ' + (e instanceof Error ? e.message : e));
connection.rollback();
connection.release();
throw e;
} finally {
connection.release();
}
}
public async checkDbConnection() {
this.checkDBFlag();
try {

View File

@@ -150,7 +150,7 @@ class Server {
if (config.BISQ.ENABLED) {
bisq.startBisqService();
bisq.setPriceCallbackFunction((price) => websocketHandler.setExtraInitProperties('bsq-price', price));
bisq.setPriceCallbackFunction((price) => websocketHandler.setExtraInitData('bsq-price', price));
blocks.setNewBlockCallback(bisq.handleNewBitcoinBlock.bind(bisq));
bisqMarkets.startBisqService();
}

View File

@@ -134,6 +134,7 @@ class Indexer {
await mining.$generatePoolHashrateHistory();
await blocks.$generateBlocksSummariesDatabase();
await blocks.$generateCPFPDatabase();
await blocks.$generateAuditStats();
} catch (e) {
this.indexerRunning = false;
logger.err(`Indexer failed, trying again in 10 seconds. Reason: ` + (e instanceof Error ? e.message : e));

View File

@@ -19,6 +19,7 @@ export interface PoolInfo {
blockCount: number;
slug: string;
avgMatchRate: number | null;
avgFeeDelta: number | null;
}
export interface PoolStats extends PoolInfo {
@@ -35,11 +36,15 @@ export interface BlockAudit {
sigopTxs: string[],
addedTxs: string[],
matchRate: number,
expectedFees?: number,
expectedWeight?: number,
}
export interface AuditScore {
hash: string,
matchRate?: number,
expectedFees?: number
expectedWeight?: number
}
export interface MempoolBlock {
@@ -182,6 +187,8 @@ export interface BlockExtension {
feeRange: number[]; // fee rate percentiles
reward: number;
matchRate: number | null;
expectedFees: number | null;
expectedWeight: number | null;
similarity?: number;
pool: {
id: number; // Note - This is the `unique_id`, not to mix with the auto increment `id`
@@ -253,9 +260,16 @@ export interface WorkingEffectiveFeeStats extends EffectiveFeeStats {
maxFee: number;
}
export interface CpfpCluster {
root: string,
height: number,
txs: Ancestor[],
effectiveFeePerVsize: number,
}
export interface CpfpSummary {
transactions: TransactionExtended[];
clusters: { root: string, height: number, txs: Ancestor[], effectiveFeePerVsize: number }[];
clusters: CpfpCluster[];
}
export interface Statistic {

View File

@@ -6,9 +6,9 @@ import { BlockAudit, AuditScore } from '../mempool.interfaces';
class BlocksAuditRepositories {
public async $saveAudit(audit: BlockAudit): Promise<void> {
try {
await DB.query(`INSERT INTO blocks_audits(time, height, hash, missing_txs, added_txs, fresh_txs, sigop_txs, match_rate)
VALUE (FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?, ?)`, [audit.time, audit.height, audit.hash, JSON.stringify(audit.missingTxs),
JSON.stringify(audit.addedTxs), JSON.stringify(audit.freshTxs), JSON.stringify(audit.sigopTxs), audit.matchRate]);
await DB.query(`INSERT INTO blocks_audits(time, height, hash, missing_txs, added_txs, fresh_txs, sigop_txs, match_rate, expected_fees, expected_weight)
VALUE (FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [audit.time, audit.height, audit.hash, JSON.stringify(audit.missingTxs),
JSON.stringify(audit.addedTxs), JSON.stringify(audit.freshTxs), JSON.stringify(audit.sigopTxs), audit.matchRate, audit.expectedFees, audit.expectedWeight]);
} catch (e: any) {
if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart
logger.debug(`Cannot save block audit for block ${audit.hash} because it has already been indexed, ignoring`);
@@ -18,6 +18,19 @@ class BlocksAuditRepositories {
}
}
public async $setSummary(hash: string, expectedFees: number, expectedWeight: number) {
try {
await DB.query(`
UPDATE blocks_audits SET
expected_fees = ?,
expected_weight = ?
WHERE hash = ?
`, [expectedFees, expectedWeight, hash]);
} catch (e: any) {
logger.err(`Cannot update block audit in db. Reason: ` + (e instanceof Error ? e.message : e));
}
}
public async $getBlocksHealthHistory(div: number, interval: string | null): Promise<any> {
try {
let query = `SELECT UNIX_TIMESTAMP(time) as time, height, match_rate FROM blocks_audits`;
@@ -51,11 +64,17 @@ class BlocksAuditRepositories {
const [rows]: any[] = await DB.query(
`SELECT blocks.height, blocks.hash as id, UNIX_TIMESTAMP(blocks.blockTimestamp) as timestamp, blocks.size,
blocks.weight, blocks.tx_count,
transactions, template, missing_txs as missingTxs, added_txs as addedTxs, fresh_txs as freshTxs, sigop_txs as sigopTxs, match_rate as matchRate
template,
missing_txs as missingTxs,
added_txs as addedTxs,
fresh_txs as freshTxs,
sigop_txs as sigopTxs,
match_rate as matchRate,
expected_fees as expectedFees,
expected_weight as expectedWeight
FROM blocks_audits
JOIN blocks ON blocks.hash = blocks_audits.hash
JOIN blocks_templates ON blocks_templates.id = blocks_audits.hash
JOIN blocks_summaries ON blocks_summaries.id = blocks_audits.hash
WHERE blocks_audits.hash = "${hash}"
`);
@@ -64,12 +83,9 @@ class BlocksAuditRepositories {
rows[0].addedTxs = JSON.parse(rows[0].addedTxs);
rows[0].freshTxs = JSON.parse(rows[0].freshTxs);
rows[0].sigopTxs = JSON.parse(rows[0].sigopTxs);
rows[0].transactions = JSON.parse(rows[0].transactions);
rows[0].template = JSON.parse(rows[0].template);
if (rows[0].transactions.length) {
return rows[0];
}
return rows[0];
}
return null;
} catch (e: any) {
@@ -81,7 +97,7 @@ class BlocksAuditRepositories {
public async $getBlockAuditScore(hash: string): Promise<AuditScore> {
try {
const [rows]: any[] = await DB.query(
`SELECT hash, match_rate as matchRate
`SELECT hash, match_rate as matchRate, expected_fees as expectedFees, expected_weight as expectedWeight
FROM blocks_audits
WHERE blocks_audits.hash = "${hash}"
`);
@@ -95,7 +111,7 @@ class BlocksAuditRepositories {
public async $getBlockAuditScores(maxHeight: number, minHeight: number): Promise<AuditScore[]> {
try {
const [rows]: any[] = await DB.query(
`SELECT hash, match_rate as matchRate
`SELECT hash, match_rate as matchRate, expected_fees as expectedFees, expected_weight as expectedWeight
FROM blocks_audits
WHERE blocks_audits.height BETWEEN ? AND ?
`, [minHeight, maxHeight]);
@@ -105,6 +121,32 @@ class BlocksAuditRepositories {
throw e;
}
}
public async $getBlocksWithoutSummaries(): Promise<string[]> {
try {
const [fromRows]: any[] = await DB.query(`
SELECT height
FROM blocks_audits
WHERE expected_fees IS NULL
ORDER BY height DESC
LIMIT 1
`);
if (!fromRows?.length) {
return [];
}
const fromHeight = fromRows[0].height;
const [idRows]: any[] = await DB.query(`
SELECT hash
FROM blocks_audits
WHERE height <= ?
ORDER BY height DESC
`, [fromHeight]);
return idRows.map(row => row.hash);
} catch (e: any) {
logger.err(`Cannot fetch block audit from db. Reason: ` + (e instanceof Error ? e.message : e));
throw e;
}
}
}
export default new BlocksAuditRepositories();

View File

@@ -577,19 +577,6 @@ class BlocksRepository {
}
}
/**
* Return blocks height
*/
public async $getBlocksHeightsAndTimestamp(): Promise<object[]> {
try {
const [rows]: any[] = await DB.query(`SELECT height, blockTimestamp as timestamp FROM blocks`);
return rows;
} catch (e) {
logger.err('Cannot get blocks height and timestamp from the db. Reason: ' + (e instanceof Error ? e.message : e));
throw e;
}
}
/**
* Get general block stats
*/
@@ -877,7 +864,7 @@ class BlocksRepository {
/**
* Get all blocks which have not be linked to a price yet
*/
public async $getBlocksWithoutPrice(): Promise<object[]> {
public async $getBlocksWithoutPrice(): Promise<object[]> {
try {
const [rows]: any[] = await DB.query(`
SELECT UNIX_TIMESTAMP(blocks.blockTimestamp) as timestamp, blocks.height
@@ -889,7 +876,7 @@ class BlocksRepository {
return rows;
} catch (e) {
logger.err('Cannot get blocks height and timestamp from the db. Reason: ' + (e instanceof Error ? e.message : e));
throw e;
return [];
}
}
@@ -909,7 +896,6 @@ class BlocksRepository {
logger.debug(`Cannot save blocks prices for blocks [${blockPrices[0].height} to ${blockPrices[blockPrices.length - 1].height}] because it has already been indexed, ignoring`);
} else {
logger.err(`Cannot save blocks prices for blocks [${blockPrices[0].height} to ${blockPrices[blockPrices.length - 1].height}] into db. Reason: ` + (e instanceof Error ? e.message : e));
throw e;
}
}
}
@@ -928,7 +914,7 @@ class BlocksRepository {
return blocks;
} catch (e) {
logger.err(`Cannot get blocks with missing coinstatsindex. Reason: ` + (e instanceof Error ? e.message : e));
throw e;
return [];
}
}
@@ -1032,10 +1018,14 @@ class BlocksRepository {
// Match rate is not part of the blocks table, but it is part of APIs so we must include it
extras.matchRate = null;
extras.expectedFees = null;
extras.expectedWeight = null;
if (config.MEMPOOL.AUDIT) {
const auditScore = await BlocksAuditsRepository.$getBlockAuditScore(dbBlk.id);
if (auditScore != null) {
extras.matchRate = auditScore.matchRate;
extras.expectedFees = auditScore.expectedFees;
extras.expectedWeight = auditScore.expectedWeight;
}
}

View File

@@ -50,6 +50,21 @@ class BlocksSummariesRepository {
}
}
public async $getTemplate(id: string): Promise<BlockSummary | undefined> {
try {
const [templates]: any[] = await DB.query(`SELECT * from blocks_templates WHERE id = ?`, [id]);
if (templates.length > 0) {
return {
id: templates[0].id,
transactions: JSON.parse(templates[0].template),
};
}
} catch (e) {
logger.err(`Cannot get block template for block id ${id}. Reason: ` + (e instanceof Error ? e.message : e));
}
return undefined;
}
public async $getIndexedSummariesId(): Promise<string[]> {
try {
const [rows]: any[] = await DB.query(`SELECT id from blocks_summaries`);

View File

@@ -1,62 +1,19 @@
import cluster, { Cluster } from 'cluster';
import { RowDataPacket } from 'mysql2';
import DB from '../database';
import logger from '../logger';
import { Ancestor } from '../mempool.interfaces';
import { Ancestor, CpfpCluster } from '../mempool.interfaces';
import transactionRepository from '../repositories/TransactionRepository';
class CpfpRepository {
public async $saveCluster(clusterRoot: string, height: number, txs: Ancestor[], effectiveFeePerVsize: number): Promise<boolean> {
if (!txs[0]) {
return false;
}
// skip clusters of transactions with the same fees
const roundedEffectiveFee = Math.round(effectiveFeePerVsize * 100) / 100;
const equalFee = txs.reduce((acc, tx) => {
return (acc && Math.round(((tx.fee || 0) / (tx.weight / 4)) * 100) / 100 === roundedEffectiveFee);
}, true);
if (equalFee) {
return false;
}
try {
const packedTxs = Buffer.from(this.pack(txs));
await DB.query(
`
INSERT INTO compact_cpfp_clusters(root, height, txs, fee_rate)
VALUE (UNHEX(?), ?, ?, ?)
ON DUPLICATE KEY UPDATE
height = ?,
txs = ?,
fee_rate = ?
`,
[clusterRoot, height, packedTxs, effectiveFeePerVsize, height, packedTxs, effectiveFeePerVsize]
);
const maxChunk = 10;
let chunkIndex = 0;
while (chunkIndex < txs.length) {
const chunk = txs.slice(chunkIndex, chunkIndex + maxChunk).map(tx => {
return { txid: tx.txid, cluster: clusterRoot };
});
await transactionRepository.$batchSetCluster(chunk);
chunkIndex += maxChunk;
}
return true;
} catch (e: any) {
logger.err(`Cannot save cpfp cluster into db. Reason: ` + (e instanceof Error ? e.message : e));
throw e;
}
}
public async $batchSaveClusters(clusters: { root: string, height: number, txs: Ancestor[], effectiveFeePerVsize: number }[]): Promise<boolean> {
try {
const clusterValues: any[] = [];
const txs: any[] = [];
const clusterValues: [string, number, Buffer, number][] = [];
const txs: { txid: string, cluster: string }[] = [];
for (const cluster of clusters) {
if (cluster.txs?.length > 1) {
if (cluster.txs?.length) {
const roundedEffectiveFee = Math.round(cluster.effectiveFeePerVsize * 100) / 100;
const equalFee = cluster.txs.reduce((acc, tx) => {
const equalFee = cluster.txs.length > 1 && cluster.txs.reduce((acc, tx) => {
return (acc && Math.round(((tx.fee || 0) / (tx.weight / 4)) * 100) / 100 === roundedEffectiveFee);
}, true);
if (!equalFee) {
@@ -77,16 +34,10 @@ class CpfpRepository {
return false;
}
const queries: { query, params }[] = [];
const maxChunk = 100;
let chunkIndex = 0;
// insert transactions in batches of up to 100 rows
while (chunkIndex < txs.length) {
const chunk = txs.slice(chunkIndex, chunkIndex + maxChunk);
await transactionRepository.$batchSetCluster(chunk);
chunkIndex += maxChunk;
}
chunkIndex = 0;
// insert clusters in batches of up to 100 rows
while (chunkIndex < clusterValues.length) {
const chunk = clusterValues.slice(chunkIndex, chunkIndex + maxChunk);
@@ -98,12 +49,23 @@ class CpfpRepository {
return (' (UNHEX(?), ?, ?, ?)');
}) + ';';
const values = chunk.flat();
await DB.query(
queries.push({
query,
values
);
params: values,
});
chunkIndex += maxChunk;
}
chunkIndex = 0;
// insert transactions in batches of up to 100 rows
while (chunkIndex < txs.length) {
const chunk = txs.slice(chunkIndex, chunkIndex + maxChunk);
queries.push(transactionRepository.buildBatchSetQuery(chunk));
chunkIndex += maxChunk;
}
await DB.$atomicQuery(queries);
return true;
} catch (e: any) {
logger.err(`Cannot save cpfp clusters into db. Reason: ` + (e instanceof Error ? e.message : e));
@@ -111,7 +73,7 @@ class CpfpRepository {
}
}
public async $getCluster(clusterRoot: string): Promise<Cluster | void> {
public async $getCluster(clusterRoot: string): Promise<CpfpCluster | void> {
const [clusterRows]: any = await DB.query(
`
SELECT *
@@ -122,6 +84,7 @@ class CpfpRepository {
);
const cluster = clusterRows[0];
if (cluster?.txs) {
cluster.effectiveFeePerVsize = cluster.fee_rate;
cluster.txs = this.unpack(cluster.txs);
return cluster;
}

View File

@@ -39,7 +39,8 @@ class PoolsRepository {
pools.name AS name,
pools.link AS link,
slug,
AVG(blocks_audits.match_rate) AS avgMatchRate
AVG(blocks_audits.match_rate) AS avgMatchRate,
AVG((CAST(blocks.fees as SIGNED) - CAST(blocks_audits.expected_fees as SIGNED)) / NULLIF(CAST(blocks_audits.expected_fees as SIGNED), 0)) AS avgFeeDelta
FROM blocks
JOIN pools on pools.id = pool_id
LEFT JOIN blocks_audits ON blocks_audits.height = blocks.height

View File

@@ -25,9 +25,8 @@ class TransactionRepository {
}
}
public async $batchSetCluster(txs): Promise<void> {
try {
let query = `
public buildBatchSetQuery(txs: { txid: string, cluster: string }[]): { query, params } {
let query = `
INSERT IGNORE INTO compact_transactions
(
txid,
@@ -35,13 +34,22 @@ class TransactionRepository {
)
VALUES
`;
query += txs.map(tx => {
return (' (UNHEX(?), UNHEX(?))');
}) + ';';
const values = txs.map(tx => [tx.txid, tx.cluster]).flat();
query += txs.map(tx => {
return (' (UNHEX(?), UNHEX(?))');
}) + ';';
const values = txs.map(tx => [tx.txid, tx.cluster]).flat();
return {
query,
params: values,
};
}
public async $batchSetCluster(txs): Promise<void> {
try {
const query = this.buildBatchSetQuery(txs);
await DB.query(
query,
values
query.query,
query.params,
);
} catch (e: any) {
logger.err(`Cannot save cpfp transactions into db. Reason: ` + (e instanceof Error ? e.message : e));
@@ -105,6 +113,7 @@ class TransactionRepository {
return {
descendants,
ancestors,
effectiveFeePerVsize: cluster.effectiveFeePerVsize,
};
}
}

View File

@@ -71,7 +71,7 @@ class PoolsUpdater {
poolsParser.setMiningPools(poolsJson);
if (config.DATABASE.ENABLED === false) { // Don't run db operations
logger.info('Mining pools-v2.json import completed (no database)');
logger.info(`Mining pools-v2.json (${githubSha}) import completed (no database)`);
return;
}
@@ -84,7 +84,7 @@ class PoolsUpdater {
logger.err(`Could not migrate mining pools, rolling back. Exception: ${JSON.stringify(e)}`, logger.tags.mining);
await DB.query('ROLLBACK;');
}
logger.info('PoolsUpdater completed');
logger.info(`Mining pools-v2.json (${githubSha}) import completed`);
} catch (e) {
this.lastRun = now - (oneWeek - oneDay); // Try again in 24h instead of waiting next week

View 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 June 24, 2023.
Signed: 0xflicker

View 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: joostjager

View File

@@ -1,4 +1,4 @@
describe('Liquid', () => {
describe.skip('Liquid', () => {
const baseModule = Cypress.env('BASE_MODULE');
const basePath = '';

View File

@@ -1,4 +1,4 @@
describe('Liquid Testnet', () => {
describe.skip('Liquid Testnet', () => {
const baseModule = Cypress.env('BASE_MODULE');
const basePath = '/testnet';

View File

@@ -537,7 +537,7 @@ describe('Mainnet', () => {
cy.get('.container-xl > :nth-child(3)').invoke('css', 'width').should('equal', alertWidth);
});
cy.get('.btn-danger').then(getRectangle).then((rectA) => {
cy.get('.btn-warning').then(getRectangle).then((rectA) => {
cy.get('.alert').then(getRectangle).then((rectB) => {
expect(areOverlapping(rectA, rectB), 'Confirmations box and RBF alert are overlapping').to.be.false;
});
@@ -582,7 +582,7 @@ describe('Mainnet', () => {
cy.get(alertLocator).invoke('css', 'width').should('equal', firstWidth);
});
cy.get('.btn-danger').then(getRectangle).then((rectA) => {
cy.get('.btn-warning').then(getRectangle).then((rectA) => {
cy.get('.alert').then(getRectangle).then((rectB) => {
expect(areOverlapping(rectA, rectB), 'Confirmations box and RBF alert are overlapping').to.be.false;
});

View File

@@ -35,7 +35,7 @@
"start:local-staging": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c local-staging",
"start:mixed": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c mixed",
"build": "npm run generate-config && npm run ng -- build --configuration production --localize && npm run sync-assets && npm run build-mempool.js",
"sync-assets": "rsync -av ./src/resources ./dist/mempool/browser && node sync-assets.js 'dist/mempool/browser/resources/'",
"sync-assets": "rsync -av ./src/resources ./dist/mempool/browser && node sync-assets.js 'dist/mempool/browser/resources'",
"sync-assets-dev": "node sync-assets.js 'src/resources/'",
"generate-config": "node generate-config.js",
"build-mempool.js": "npm run build-mempool-js && npm run build-mempool-liquid-js && npm run build-mempool-bisq-js",

View File

@@ -4,8 +4,7 @@ import { AppPreloadingStrategy } from './app.preloading-strategy'
import { StartComponent } from './components/start/start.component';
import { TransactionComponent } from './components/transaction/transaction.component';
import { BlockComponent } from './components/block/block.component';
import { ClockMinedComponent as ClockMinedComponent } from './components/clock/clock-mined.component';
import { ClockMempoolComponent as ClockMempoolComponent } from './components/clock/clock-mempool.component';
import { ClockComponent } from './components/clock/clock.component';
import { AddressComponent } from './components/address/address.component';
import { MasterPageComponent } from './components/master-page/master-page.component';
import { AboutComponent } from './components/about/about.component';
@@ -358,12 +357,16 @@ let routes: Routes = [
],
},
{
path: 'clock-mined',
component: ClockMinedComponent,
path: 'clock',
redirectTo: 'clock/mempool/0'
},
{
path: 'clock-mempool',
component: ClockMempoolComponent,
path: 'clock/:mode',
redirectTo: 'clock/:mode/0'
},
{
path: 'clock/:mode/:index',
component: ClockComponent,
},
{
path: 'status',

View File

@@ -173,6 +173,21 @@
</svg>
<span>Exodus</span>
</a>
<a href="https://www.luminex.io" target="_blank" title="Luminex">
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="66.95" height="80" viewBox="0 0 300.43 385" style="padding-top: 10px;">
<defs>
<style>
.lum-cls-1 {
fill: #f2ea25;
}
</style>
</defs>
<path class="lum-cls-1" d="m309.02,90.04c0,49.65-38.73,90.04-95.34,90.04s-95.34-40.39-95.34-90.04S153.77,0,213.69,0c56.28,0,95.34,40.39,95.34,90.04Zm-63.56,0c0-20.52-14.23-37.07-31.78-37.07s-31.78,16.55-31.78,37.07,14.23,37.07,31.78,37.07,31.78-16.55,31.78-37.07Z"/>
<path class="lum-cls-1" d="m311.87,372.67h-66.34l-31.84-47.76-31.84,47.76h-66.34l58.38-90.22-53.07-79.61h66.34l26.54,42.46,26.53-42.46h66.34l-53.07,79.61,58.38,90.22Z"/>
<rect class="lum-cls-1" width="60.69" height="372.67"/>
</svg>
<span>Luminex</span>
</a>
</div>
</div>
@@ -205,7 +220,7 @@
<img class="image" src="/resources/profile/mynodebtc.png" />
<span>myNode</span>
</a>
<a href="https://github.com/RoninDojo/RoninDojo" target="_blank" title="RoninDojo">
<a href="https://code.samourai.io/ronindojo/RoninDojo" target="_blank" title="RoninDojo">
<img class="image" src="/resources/profile/ronindojo.png" />
<span>RoninDojo</span>
</a>

View File

@@ -63,7 +63,7 @@
*ngIf="blockAudit?.matchRate != null; else nullHealth"
>{{ blockAudit?.matchRate }}%</span>
<ng-template #nullHealth>
<ng-container *ngIf="!isLoadingAudit; else loadingHealth">
<ng-container *ngIf="!isLoadingOverview; else loadingHealth">
<span class="health-badge badge badge-secondary" i18n="unknown">Unknown</span>
</ng-container>
</ng-template>
@@ -121,7 +121,7 @@
<ng-container *ngIf="!isLoadingBlock; else loadingRest">
<tr *ngIf="network !== 'liquid' && network !== 'liquidtestnet'">
<td i18n="mempool-block.fee-span">Fee span</td>
<td><span>{{ block.extras.feeRange[1] | number:'1.0-0' }} - {{ block.extras.feeRange[block.extras.feeRange.length - 1] | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></span></td>
<td><span>{{ block?.extras?.minFee | number:'1.0-0' }} - {{ block?.extras?.maxFee | number:'1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></span></td>
</tr>
<tr *ngIf="block?.extras?.medianFee != undefined">
<td class="td-width" i18n="block.median-fee">Median fee</td>
@@ -226,6 +226,9 @@
(txClickEvent)="onTxClick($event)" (txHoverEvent)="onTxHover($event)" [unavailable]="!isMobile && !showAudit"></app-block-overview-graph>
<ng-container *ngIf="!isMobile || mode !== 'actual'; else emptyBlockInfo"></ng-container>
</div>
<ng-container *ngIf="network !== 'liquid'">
<ng-container *ngTemplateOutlet="isMobile && mode === 'actual' ? actualDetails : expectedDetails"></ng-container>
</ng-container>
</div>
<div class="col-sm" *ngIf="!isMobile">
<h3 class="block-subtitle actual" *ngIf="!isMobile"><ng-container i18n="block.actual-block">Actual Block</ng-container> <a class="info-link" [routerLink]="['/docs/faq' | relativeUrl ]" fragment="how-do-block-audits-work"><fa-icon [icon]="['fas', 'info-circle']" [fixedWidth]="true"></fa-icon></a></h3>
@@ -235,6 +238,9 @@
(txClickEvent)="onTxClick($event)" (txHoverEvent)="onTxHover($event)" [unavailable]="isMobile && !showAudit"></app-block-overview-graph>
<ng-container *ngTemplateOutlet="emptyBlockInfo"></ng-container>
</div>
<ng-container *ngIf="network !== 'liquid'">
<ng-container *ngTemplateOutlet="actualDetails"></ng-container>
</ng-container>
</div>
</div>
</div>
@@ -385,5 +391,60 @@
</a>
</ng-template>
<ng-template #expectedDetails>
<table *ngIf="block && blockAudit && blockAudit.expectedFees != null" class="table table-borderless table-striped audit-details-table">
<tbody>
<tr>
<td i18n="block.total-fees|Total fees in a block">Total fees</td>
<td>
<app-amount [satoshis]="blockAudit.expectedFees" digitsInfo="1.2-3" [noFiat]="true"></app-amount>
</td>
</tr>
<tr>
<td i18n="block.weight">Weight</td>
<td [innerHTML]="'&lrm;' + (blockAudit.expectedWeight | wuBytes: 2)"></td>
</tr>
<tr>
<td i18n="mempool-block.transactions">Transactions</td>
<td>{{ blockAudit.template?.length || 0 }}</td>
</tr>
</tbody>
</table>
</ng-template>
<ng-template #actualDetails>
<table *ngIf="block && blockAudit && blockAudit.expectedFees != null" class="table table-borderless table-striped audit-details-table">
<tbody>
<tr>
<td i18n="block.total-fees|Total fees in a block">Total fees</td>
<td>
<app-amount [satoshis]="block.extras.totalFees" digitsInfo="1.2-3" [noFiat]="true"></app-amount>
<span *ngIf="blockAudit.feeDelta" class="difference" [class.positive]="blockAudit.feeDelta <= 0" [class.negative]="blockAudit.feeDelta > 0">
{{ blockAudit.feeDelta < 0 ? '+' : '' }}{{ (-blockAudit.feeDelta * 100) | amountShortener: 2 }}%
</span>
</td>
</tr>
<tr>
<td i18n="block.weight">Weight</td>
<td [innerHTML]>
<span [innerHTML]="'&lrm;' + (block.weight | wuBytes: 2)"></span>
<span *ngIf="blockAudit.weightDelta" class="difference" [class.positive]="blockAudit.weightDelta <= 0" [class.negative]="blockAudit.weightDelta > 0">
{{ blockAudit.weightDelta < 0 ? '+' : '' }}{{ (-blockAudit.weightDelta * 100) | amountShortener: 2 }}%
</span>
</td>
</tr>
<tr>
<td i18n="mempool-block.transactions">Transactions</td>
<td>
{{ block.tx_count }}
<span *ngIf="blockAudit.txDelta" class="difference" [class.positive]="blockAudit.txDelta <= 0" [class.negative]="blockAudit.txDelta > 0">
{{ blockAudit.txDelta < 0 ? '+' : '' }}{{ (-blockAudit.txDelta * 100) | amountShortener: 2 }}%
</span>
</td>
</tr>
</tbody>
</table>
</ng-template>
<br>
<br>

View File

@@ -38,6 +38,17 @@
color: rgba(255, 255, 255, 0.4);
margin-left: 5px;
}
.difference {
margin-left: 0.5em;
&.positive {
color: rgb(66, 183, 71);
}
&.negative {
color: rgb(183, 66, 66);
}
}
}
}
@@ -252,3 +263,10 @@ h1 {
top: 11px;
margin-left: 10px;
}
.audit-details-table {
margin-top: 1.25rem;
@media (max-width: 767.98px) {
margin-top: 0.75rem;
}
}

View File

@@ -2,9 +2,9 @@ import { Component, OnInit, OnDestroy, ViewChildren, QueryList } from '@angular/
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, pairwise, filter } from 'rxjs/operators';
import { switchMap, tap, throttleTime, catchError, map, shareReplay, startWith } from 'rxjs/operators';
import { Transaction, Vout } from '../../interfaces/electrs.interface';
import { Observable, of, Subscription, asyncScheduler, EMPTY, combineLatest } from 'rxjs';
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';
@@ -44,7 +44,6 @@ export class BlockComponent implements OnInit, OnDestroy {
strippedTransactions: TransactionStripped[];
overviewTransitionDirection: string;
isLoadingOverview = true;
isLoadingAudit = true;
error: any;
blockSubsidy: number;
fees: number;
@@ -138,6 +137,8 @@ export class BlockComponent implements OnInit, OnDestroy {
if (block.id === this.blockHash) {
this.block = block;
block.extras.minFee = this.getMinBlockFee(block);
block.extras.maxFee = this.getMaxBlockFee(block);
if (block?.extras?.reward != undefined) {
this.fees = block.extras.reward / 100000000 - this.blockSubsidy;
}
@@ -234,6 +235,8 @@ export class BlockComponent implements OnInit, OnDestroy {
}
this.updateAuditAvailableFromBlockHeight(block.height);
this.block = block;
block.extras.minFee = this.getMinBlockFee(block);
block.extras.maxFee = this.getMaxBlockFee(block);
this.blockHeight = block.height;
this.lastBlockHeight = this.blockHeight;
this.nextBlockHeight = block.height + 1;
@@ -277,138 +280,111 @@ export class BlockComponent implements OnInit, OnDestroy {
this.isLoadingOverview = false;
});
if (!this.auditSupported) {
this.overviewSubscription = block$.pipe(
startWith(null),
pairwise(),
switchMap(([prevBlock, block]) => this.apiService.getStrippedBlockTransactions$(block.id)
.pipe(
catchError((err) => {
this.overviewError = err;
return of([]);
}),
switchMap((transactions) => {
if (prevBlock) {
return of({ transactions, direction: (prevBlock.height < block.height) ? 'right' : 'left' });
} else {
return of({ transactions, direction: 'down' });
}
})
)
),
)
.subscribe(({transactions, direction}: {transactions: TransactionStripped[], direction: string}) => {
this.strippedTransactions = transactions;
this.isLoadingOverview = false;
this.setupBlockGraphs();
},
(error) => {
this.error = error;
this.isLoadingOverview = false;
});
}
if (this.auditSupported) {
this.auditSubscription = block$.pipe(
startWith(null),
pairwise(),
switchMap(([prevBlock, block]) => {
this.isLoadingAudit = true;
this.blockAudit = null;
return this.apiService.getBlockAudit$(block.id)
this.overviewSubscription = block$.pipe(
switchMap((block) => {
return forkJoin([
this.apiService.getStrippedBlockTransactions$(block.id)
.pipe(
catchError((err) => {
this.overviewError = err;
this.isLoadingAudit = false;
return of([]);
return of(null);
})
);
}
),
filter((response) => response != null),
map((response) => {
const blockAudit = response.body;
const inTemplate = {};
const inBlock = {};
const isAdded = {};
const isCensored = {};
const isMissing = {};
const isSelected = {};
const isFresh = {};
const isSigop = {};
this.numMissing = 0;
this.numUnexpected = 0;
),
!this.isAuditAvailableFromBlockHeight(block.height) ? of(null) : this.apiService.getBlockAudit$(block.id)
.pipe(
catchError((err) => {
this.overviewError = err;
return of(null);
})
)
]);
})
)
.subscribe(([transactions, blockAudit]) => {
if (transactions) {
this.strippedTransactions = transactions;
} else {
this.strippedTransactions = [];
}
if (blockAudit?.template) {
for (const tx of blockAudit.template) {
inTemplate[tx.txid] = true;
}
for (const tx of blockAudit.transactions) {
inBlock[tx.txid] = true;
}
for (const txid of blockAudit.addedTxs) {
isAdded[txid] = true;
}
for (const txid of blockAudit.missingTxs) {
isCensored[txid] = true;
}
for (const txid of blockAudit.freshTxs || []) {
isFresh[txid] = true;
}
for (const txid of blockAudit.sigopTxs || []) {
isSigop[txid] = true;
}
// set transaction statuses
for (const tx of blockAudit.template) {
tx.context = 'projected';
if (isCensored[tx.txid]) {
tx.status = 'censored';
} else if (inBlock[tx.txid]) {
tx.status = 'found';
} else {
tx.status = isFresh[tx.txid] ? 'fresh' : (isSigop[tx.txid] ? 'sigop' : 'missing');
isMissing[tx.txid] = true;
this.numMissing++;
}
}
for (const [index, tx] of blockAudit.transactions.entries()) {
tx.context = 'actual';
if (index === 0) {
tx.status = null;
} else if (isAdded[tx.txid]) {
tx.status = 'added';
} else if (inTemplate[tx.txid]) {
tx.status = 'found';
} else {
tx.status = 'selected';
isSelected[tx.txid] = true;
this.numUnexpected++;
}
}
for (const tx of blockAudit.transactions) {
inBlock[tx.txid] = true;
}
this.setAuditAvailable(true);
} else {
this.setAuditAvailable(false);
this.blockAudit = null;
if (transactions && blockAudit) {
const inTemplate = {};
const inBlock = {};
const isAdded = {};
const isCensored = {};
const isMissing = {};
const isSelected = {};
const isFresh = {};
const isSigop = {};
this.numMissing = 0;
this.numUnexpected = 0;
if (blockAudit?.template) {
for (const tx of blockAudit.template) {
inTemplate[tx.txid] = true;
}
return blockAudit;
}),
catchError((err) => {
console.log(err);
this.error = err;
this.isLoadingOverview = false;
this.isLoadingAudit = false;
for (const tx of transactions) {
inBlock[tx.txid] = true;
}
for (const txid of blockAudit.addedTxs) {
isAdded[txid] = true;
}
for (const txid of blockAudit.missingTxs) {
isCensored[txid] = true;
}
for (const txid of blockAudit.freshTxs || []) {
isFresh[txid] = true;
}
for (const txid of blockAudit.sigopTxs || []) {
isSigop[txid] = true;
}
// set transaction statuses
for (const tx of blockAudit.template) {
tx.context = 'projected';
if (isCensored[tx.txid]) {
tx.status = 'censored';
} else if (inBlock[tx.txid]) {
tx.status = 'found';
} else {
tx.status = isFresh[tx.txid] ? 'fresh' : (isSigop[tx.txid] ? 'sigop' : 'missing');
isMissing[tx.txid] = true;
this.numMissing++;
}
}
for (const [index, tx] of transactions.entries()) {
tx.context = 'actual';
if (index === 0) {
tx.status = null;
} else if (isAdded[tx.txid]) {
tx.status = 'added';
} else if (inTemplate[tx.txid]) {
tx.status = 'found';
} else {
tx.status = 'selected';
isSelected[tx.txid] = true;
this.numUnexpected++;
}
}
for (const tx of transactions) {
inBlock[tx.txid] = true;
}
blockAudit.feeDelta = blockAudit.expectedFees > 0 ? (blockAudit.expectedFees - this.block.extras.totalFees) / blockAudit.expectedFees : 0;
blockAudit.weightDelta = blockAudit.expectedWeight > 0 ? (blockAudit.expectedWeight - this.block.weight) / blockAudit.expectedWeight : 0;
blockAudit.txDelta = blockAudit.template.length > 0 ? (blockAudit.template.length - this.block.tx_count) / blockAudit.template.length : 0;
this.blockAudit = blockAudit;
this.setAuditAvailable(true);
} else {
this.setAuditAvailable(false);
return of(null);
}),
).subscribe((blockAudit) => {
this.blockAudit = blockAudit;
this.setupBlockGraphs();
this.isLoadingOverview = false;
this.isLoadingAudit = false;
});
}
}
} else {
this.setAuditAvailable(false);
}
this.isLoadingOverview = false;
this.setupBlockGraphs();
});
this.networkChangedSubscription = this.stateService.networkChanged$
.subscribe((network) => this.network = network);
@@ -643,24 +619,50 @@ export class BlockComponent implements OnInit, OnDestroy {
}
updateAuditAvailableFromBlockHeight(blockHeight: number): void {
if (!this.auditSupported) {
if (!this.isAuditAvailableFromBlockHeight(blockHeight)) {
this.setAuditAvailable(false);
}
}
isAuditAvailableFromBlockHeight(blockHeight: number): boolean {
if (!this.auditSupported) {
return false;
}
switch (this.stateService.network) {
case 'testnet':
if (blockHeight < this.stateService.env.TESTNET_BLOCK_AUDIT_START_HEIGHT) {
this.setAuditAvailable(false);
return false;
}
break;
case 'signet':
if (blockHeight < this.stateService.env.SIGNET_BLOCK_AUDIT_START_HEIGHT) {
this.setAuditAvailable(false);
return false;
}
break;
default:
if (blockHeight < this.stateService.env.MAINNET_BLOCK_AUDIT_START_HEIGHT) {
this.setAuditAvailable(false);
return false;
}
}
return true;
}
getMinBlockFee(block: BlockExtended): number {
if (block?.extras?.feeRange) {
// heuristic to check if feeRange is adjusted for effective rates
if (block.extras.medianFee === block.extras.feeRange[3]) {
return block.extras.feeRange[1];
} else {
return block.extras.feeRange[0];
}
}
return 0;
}
getMaxBlockFee(block: BlockExtended): number {
if (block?.extras?.feeRange) {
return block.extras.feeRange[block.extras.feeRange.length - 1];
}
return 0;
}
}

View File

@@ -13,10 +13,10 @@
[class.offscreen]="!static && count && i >= count"
id="bitcoin-block-{{ block.height }}" [ngStyle]="blockStyles[i]"
[class.blink-bg]="isSpecial(block.height)">
<a draggable="false" [routerLink]="['/block/' | relativeUrl, block.id]" [state]="{ data: { block: block } }"
<a draggable="false" [routerLink]="[getHref(i, block) | relativeUrl]" [state]="{ data: { block: block } }"
class="blockLink" [ngClass]="{'disabled': (this.stateService.blockScrolling$ | async)}">&nbsp;</a>
<div *ngIf="!minimal" [attr.data-cy]="'bitcoin-block-' + i + '-height'" class="block-height">
<a [routerLink]="['/block/' | relativeUrl, block.id]" [state]="{ data: { block: block } }">{{ block.height
<a [routerLink]="[getHref(i, block) | relativeUrl]" [state]="{ data: { block: block } }">{{ block.height
}}</a>
</div>
<div class="block-body">
@@ -31,9 +31,8 @@
</div>
</ng-template>
<div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-fee-span'" class="fee-span"
*ngIf="block?.extras?.feeRange; else emptyfeespan">
{{ block?.extras?.feeRange?.[0] | number:feeRounding }} - {{
block?.extras?.feeRange[block?.extras?.feeRange?.length - 1] | number:feeRounding }} <ng-container
*ngIf="block?.extras?.minFee != null && block?.extras?.maxFee != null; else emptyfeespan">
{{ block.extras.minFee | number:feeRounding }} - {{ block.extras.maxFee | number:feeRounding }} <ng-container
i18n="shared.sat-vbyte|sat/vB">sat/vB</ng-container>
</div>
<ng-template #emptyfeespan>

View File

@@ -27,6 +27,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
@Input() minimal: boolean = false;
@Input() blockWidth: number = 125;
@Input() spotlight: number = 0;
@Input() getHref?: (index, block) => string = (index, block) => `/block/${block.id}`;
specialBlocks = specialBlocks;
network = '';
@@ -113,6 +114,9 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
this.blocksFilled = false;
}
block.extras.minFee = this.getMinBlockFee(block);
block.extras.maxFee = this.getMaxBlockFee(block);
this.blocks.unshift(block);
this.blocks = this.blocks.slice(0, this.dynamicBlocksAmount);
@@ -239,6 +243,10 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
if (height >= 0) {
this.cacheService.loadBlock(height);
block = this.cacheService.getCachedBlock(height) || null;
if (block) {
block.extras.minFee = this.getMinBlockFee(block);
block.extras.maxFee = this.getMaxBlockFee(block);
}
}
this.blocks.push(block || {
placeholder: height < 0,
@@ -277,6 +285,8 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
onBlockLoaded(block: BlockExtended) {
const blockIndex = this.height - block.height;
if (blockIndex >= 0 && blockIndex < this.blocks.length) {
block.extras.minFee = this.getMinBlockFee(block);
block.extras.maxFee = this.getMaxBlockFee(block);
this.blocks[blockIndex] = block;
this.blockStyles[blockIndex] = this.getStyleForBlock(block, blockIndex);
}
@@ -365,4 +375,23 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy {
}
return emptyBlocks;
}
getMinBlockFee(block: BlockExtended): number {
if (block?.extras?.feeRange) {
// heuristic to check if feeRange is adjusted for effective rates
if (block.extras.medianFee === block.extras.feeRange[3]) {
return block.extras.feeRange[1];
} else {
return block.extras.feeRange[0];
}
}
return 0;
}
getMaxBlockFee(block: BlockExtended): number {
if (block?.extras?.feeRange) {
return block.extras.feeRange[block.extras.feeRange.length - 1];
}
return 0;
}
}

View File

@@ -1,9 +1,9 @@
<div class="text-center" class="blockchain-wrapper" [class.time-ltr]="timeLtr" [class.ltr-transition]="ltrTransitionEnabled" #container>
<div class="position-container" [ngClass]="network ? network : ''">
<div class="position-container" [ngClass]="network ? network : ''" [style.--divider-offset]="dividerOffset + 'px'" [style.--mempool-offset]="mempoolOffset + 'px'">
<span>
<div class="blocks-wrapper">
<div class="scroll-spacer" *ngIf="minScrollWidth" [style.left]="minScrollWidth + 'px'"></div>
<app-mempool-blocks [hidden]="pageIndex > 0"></app-mempool-blocks>
<app-mempool-blocks [hidden]="pageIndex > 0" [allBlocks]="scrollableMempool" (widthChange)="onMempoolWidthChange($event)"></app-mempool-blocks>
<app-blockchain-blocks [hidden]="pageIndex > 0"></app-blockchain-blocks>
<ng-container *ngFor="let page of pages; trackBy: trackByPageFn">
<app-blockchain-blocks [static]="true" [offset]="page.offset" [height]="page.height" [count]="blocksPerPage" [loadingTip]="loadingTip" [connected]="connected"></app-blockchain-blocks>

View File

@@ -26,43 +26,14 @@
position: absolute;
left: 0;
top: 75px;
transform: translateX(50vw);
--divider-offset: 50vw;
--mempool-offset: 0px;
transform: translateX(calc(var(--divider-offset) + var(--mempool-offset)));
}
.position-container.liquid, .position-container.liquidtestnet {
transform: translateX(420px);
}
@media (min-width: 768px) {
.blockchain-wrapper.time-ltr {
.position-container.liquid, .position-container.liquidtestnet {
transform: translateX(calc(100vw - 420px));
}
}
}
@media (max-width: 767.98px) {
.blockchain-wrapper {
.position-container {
transform: translateX(95vw);
}
.position-container.liquid, .position-container.liquidtestnet {
transform: translateX(50vw);
}
.position-container.loading {
transform: translateX(50vw);
}
}
.blockchain-wrapper.time-ltr {
.position-container {
transform: translateX(5vw);
}
.position-container.liquid, .position-container.liquidtestnet {
transform: translateX(50vw);
}
.position-container.loading {
transform: translateX(50vw);
}
.blockchain-wrapper.time-ltr {
.position-container {
transform: translateX(calc(100vw - var(--divider-offset) - var(--mempool-offset)));
}
}

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, Input, OnChanges, SimpleChanges } from '@angular/core';
import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, Input, Output, EventEmitter, HostListener, ChangeDetectorRef } from '@angular/core';
import { firstValueFrom, Subscription } from 'rxjs';
import { StateService } from '../../services/state.service';
@@ -13,43 +13,95 @@ export class BlockchainComponent implements OnInit, OnDestroy {
@Input() pageIndex: number;
@Input() blocksPerPage: number = 8;
@Input() minScrollWidth: number = 0;
@Input() scrollableMempool: boolean = false;
@Output() mempoolOffsetChange: EventEmitter<number> = new EventEmitter();
network: string;
timeLtrSubscription: Subscription;
timeLtr: boolean = this.stateService.timeLtr.value;
ltrTransitionEnabled = false;
flipping = false;
connectionStateSubscription: Subscription;
loadingTip: boolean = true;
connected: boolean = true;
dividerOffset: number = 0;
mempoolOffset: number = 0;
constructor(
public stateService: StateService,
private cd: ChangeDetectorRef,
) {}
ngOnInit() {
ngOnInit(): void {
this.onResize();
this.network = this.stateService.network;
this.timeLtrSubscription = this.stateService.timeLtr.subscribe((ltr) => {
this.timeLtr = !!ltr;
});
this.connectionStateSubscription = this.stateService.connectionState$.subscribe(state => {
this.connected = (state === 2);
})
firstValueFrom(this.stateService.chainTip$).then(tip => {
});
firstValueFrom(this.stateService.chainTip$).then(() => {
this.loadingTip = false;
});
}
ngOnDestroy() {
ngOnDestroy(): void {
this.timeLtrSubscription.unsubscribe();
this.connectionStateSubscription.unsubscribe();
}
trackByPageFn(index: number, item: { index: number }) {
trackByPageFn(index: number, item: { index: number }): number {
return item.index;
}
toggleTimeDirection() {
this.ltrTransitionEnabled = true;
this.stateService.timeLtr.next(!this.timeLtr);
toggleTimeDirection(): void {
this.ltrTransitionEnabled = false;
const prevOffset = this.mempoolOffset;
this.mempoolOffset = 0;
this.mempoolOffsetChange.emit(0);
setTimeout(() => {
this.ltrTransitionEnabled = true;
this.flipping = true;
this.stateService.timeLtr.next(!this.timeLtr);
setTimeout(() => {
this.ltrTransitionEnabled = false;
this.flipping = false;
this.mempoolOffset = prevOffset;
this.mempoolOffsetChange.emit(this.mempoolOffset);
}, 1000);
}, 0);
this.cd.markForCheck();
}
onMempoolWidthChange(width): void {
if (this.flipping) {
return;
}
this.mempoolOffset = Math.max(0, width - this.dividerOffset);
this.cd.markForCheck();
setTimeout(() => {
this.mempoolOffsetChange.emit(this.mempoolOffset);
}, 0);
}
@HostListener('window:resize', ['$event'])
onResize(): void {
if (window.innerWidth >= 768) {
if (this.stateService.isLiquid()) {
this.dividerOffset = 420;
} else {
this.dividerOffset = window.innerWidth * 0.5;
}
} else {
if (this.stateService.isLiquid()) {
this.dividerOffset = window.innerWidth * 0.5;
} else {
this.dividerOffset = window.innerWidth * 0.95;
}
}
this.cd.markForCheck();
}
}

View File

@@ -13,12 +13,12 @@
<th *ngIf="indexingAvailable" class="pool text-left" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}" i18n="mining.pool-name"
i18n-ngbTooltip="mining.pool-name" ngbTooltip="Pool" placement="bottom" #miningpool [disableTooltip]="!isEllipsisActive(miningpool)">Pool</th>
<th class="timestamp" i18n="latest-blocks.timestamp" *ngIf="!widget" [class]="indexingAvailable ? '' : 'legacy'">Timestamp</th>
<th class="mined" i18n="latest-blocks.mined" *ngIf="!widget" [class]="indexingAvailable ? '' : 'legacy'">Mined</th>
<th *ngIf="auditAvailable" class="health text-right" i18n="latest-blocks.health" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}"
i18n-ngbTooltip="latest-blocks.health" ngbTooltip="Health" placement="bottom" #health [disableTooltip]="!isEllipsisActive(health)">Health</th>
<th *ngIf="indexingAvailable" class="reward text-right" i18n="latest-blocks.reward" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}"
i18n-ngbTooltip="latest-blocks.reward" ngbTooltip="Reward" placement="bottom" #reward [disableTooltip]="!isEllipsisActive(reward)">Reward</th>
<th *ngIf="indexingAvailable && !widget" class="fees text-right" i18n="latest-blocks.fees" [class]="indexingAvailable ? '' : 'legacy'">Fees</th>
<th *ngIf="auditAvailable && !widget" class="fee-delta" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}"></th>
<th *ngIf="indexingAvailable" class="txs text-right" i18n="dashboard.txs" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}"
i18n-ngbTooltip="dashboard.txs" ngbTooltip="TXs" placement="bottom" #txs [disableTooltip]="!isEllipsisActive(txs)">TXs</th>
<th *ngIf="!indexingAvailable" class="txs text-right" i18n="dashboard.txs" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}">Transactions</th>
@@ -42,26 +42,18 @@
<td class="timestamp" *ngIf="!widget" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}">
&lrm;{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm' }}
</td>
<td class="mined" *ngIf="!widget" [class]="indexingAvailable ? '' : 'legacy'">
<app-time kind="since" [time]="block.timestamp" [fastRender]="true"></app-time>
</td>
<td *ngIf="auditAvailable" class="health text-right" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}">
<a
*ngIf="block?.extras?.matchRate != null; else nullHealth"
class="health-badge badge"
[class.badge-success]="auditScores[block.id] >= 99"
[class.badge-warning]="auditScores[block.id] >= 75 && auditScores[block.id] < 99"
[class.badge-danger]="auditScores[block.id] < 75"
[routerLink]="auditScores[block.id] != null ? ['/block/' | relativeUrl, block.id] : null"
[class.badge-success]="block.extras.matchRate >= 99"
[class.badge-warning]="block.extras.matchRate >= 75 && block.extras.matchRate < 99"
[class.badge-danger]="block.extras.matchRate < 75"
[routerLink]="block.extras.matchRate != null ? ['/block/' | relativeUrl, block.id] : null"
[state]="{ data: { block: block } }"
*ngIf="auditScores[block.id] != null; else nullHealth"
>{{ auditScores[block.id] }}%</a>
>{{ block.extras.matchRate }}%</a>
<ng-template #nullHealth>
<ng-container *ngIf="!loadingScores; else loadingHealth">
<span class="health-badge badge badge-secondary" i18n="unknown">Unknown</span>
</ng-container>
</ng-template>
<ng-template #loadingHealth>
<span class="skeleton-loader" style="max-width: 60px"></span>
<span class="health-badge badge badge-secondary" i18n="unknown">Unknown</span>
</ng-template>
</td>
<td *ngIf="indexingAvailable" class="reward text-right" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}">
@@ -70,6 +62,11 @@
<td *ngIf="indexingAvailable && !widget" class="fees text-right" [class]="indexingAvailable ? '' : 'legacy'">
<app-amount [satoshis]="block.extras.totalFees" [noFiat]="true" digitsInfo="1.2-2"></app-amount>
</td>
<td *ngIf="auditAvailable" class="fee-delta" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}">
<span *ngIf="block.extras.feeDelta" class="difference" [class.positive]="block.extras.feeDelta >= 0" [class.negative]="block.extras.feeDelta < 0">
{{ block.extras.feeDelta > 0 ? '+' : '' }}{{ (block.extras.feeDelta * 100) | amountShortener: 2 }}%
</span>
</td>
<td class="txs text-right" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}">
{{ block.tx_count | number }}
</td>
@@ -106,6 +103,9 @@
<td *ngIf="indexingAvailable && !widget" class="fees text-right" [class]="indexingAvailable ? '' : 'legacy'">
<span class="skeleton-loader" style="max-width: 75px"></span>
</td>
<td *ngIf="auditAvailable && !widget" class="fee-delta" [class]="indexingAvailable ? '' : 'legacy'">
<span class="skeleton-loader" style="max-width: 75px"></span>
</td>
<td class="txs text-right" [ngClass]="{'widget': widget, 'legacy': !indexingAvailable}">
<span class="skeleton-loader" style="max-width: 75px"></span>
</td>

View File

@@ -23,6 +23,17 @@ tr, td, th {
border: 0px;
padding-top: 0.65rem !important;
padding-bottom: 0.7rem !important;
.difference {
margin-left: 0.5em;
&.positive {
color: rgb(66, 183, 71);
}
&.negative {
color: rgb(183, 66, 66);
}
}
}
.clear-link {
@@ -90,7 +101,7 @@ tr, td, th {
}
.timestamp {
width: 18%;
width: 10%;
@media (max-width: 1100px) {
display: none;
}
@@ -123,8 +134,8 @@ tr, td, th {
}
.txs {
padding-right: 40px;
width: 8%;
padding-right: 20px;
width: 6%;
@media (max-width: 1100px) {
padding-right: 10px;
}
@@ -160,6 +171,16 @@ tr, td, th {
.fees.widget {
width: 20%;
}
.fee-delta {
width: 6%;
padding-left: 0;
@media (max-width: 991px) {
display: none;
}
}
.fee-delta.widget {
display: none;
}
.reward {
width: 8%;
@@ -214,7 +235,7 @@ tr, td, th {
.health {
width: 10%;
@media (max-width: 1105px) {
@media (max-width: 1100px) {
width: 13%;
}
@media (max-width: 560px) {

View File

@@ -1,6 +1,6 @@
import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, Input } from '@angular/core';
import { BehaviorSubject, combineLatest, concat, Observable, timer, EMPTY, Subscription, of } from 'rxjs';
import { catchError, delayWhen, map, retryWhen, scan, skip, switchMap, tap } from 'rxjs/operators';
import { Component, OnInit, ChangeDetectionStrategy, Input, ChangeDetectorRef } from '@angular/core';
import { BehaviorSubject, combineLatest, Observable, timer, of } from 'rxjs';
import { delayWhen, map, retryWhen, scan, switchMap, tap } from 'rxjs/operators';
import { BlockExtended } from '../../interfaces/node-api.interface';
import { ApiService } from '../../services/api.service';
import { StateService } from '../../services/state.service';
@@ -12,19 +12,14 @@ import { WebsocketService } from '../../services/websocket.service';
styleUrls: ['./blocks-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BlocksList implements OnInit, OnDestroy {
export class BlocksList implements OnInit {
@Input() widget: boolean = false;
blocks$: Observable<BlockExtended[]> = undefined;
auditScores: { [hash: string]: number | void } = {};
auditScoreSubscription: Subscription;
latestScoreSubscription: Subscription;
indexingAvailable = false;
auditAvailable = false;
isLoading = true;
loadingScores = true;
fromBlockHeight = undefined;
paginationMaxSize: number;
page = 1;
@@ -39,6 +34,7 @@ export class BlocksList implements OnInit, OnDestroy {
private apiService: ApiService,
private websocketService: WebsocketService,
public stateService: StateService,
private cd: ChangeDetectorRef,
) {
}
@@ -65,7 +61,7 @@ export class BlocksList implements OnInit, OnDestroy {
this.blocksCount = blocks[0].height + 1;
}
this.isLoading = false;
this.lastBlockHeight = Math.max(...blocks.map(o => o.height))
this.lastBlockHeight = Math.max(...blocks.map(o => o.height));
}),
map(blocks => {
if (this.indexingAvailable) {
@@ -81,7 +77,7 @@ export class BlocksList implements OnInit, OnDestroy {
return blocks;
}),
retryWhen(errors => errors.pipe(delayWhen(() => timer(10000))))
)
);
})
),
this.stateService.blocks$
@@ -112,68 +108,25 @@ export class BlocksList implements OnInit, OnDestroy {
acc = acc.slice(0, this.widget ? 6 : 15);
}
return acc;
}, [])
);
if (this.indexingAvailable && this.auditAvailable) {
this.auditScoreSubscription = this.fromHeightSubject.pipe(
switchMap((fromBlockHeight) => {
this.loadingScores = true;
return this.apiService.getBlockAuditScores$(this.page === 1 ? undefined : fromBlockHeight)
.pipe(
catchError(() => {
return EMPTY;
})
);
}, []),
switchMap((blocks) => {
blocks.forEach(block => {
block.extras.feeDelta = block.extras.expectedFees ? (block.extras.totalFees - block.extras.expectedFees) / block.extras.expectedFees : 0;
});
return of(blocks);
})
).subscribe((scores) => {
Object.values(scores).forEach(score => {
this.auditScores[score.hash] = score?.matchRate != null ? score.matchRate : null;
});
this.loadingScores = false;
});
this.latestScoreSubscription = this.stateService.blocks$.pipe(
switchMap((block) => {
if (block[0]?.extras?.matchRate != null) {
return of({
hash: block[0].id,
matchRate: block[0]?.extras?.matchRate,
});
}
else if (block[0]?.id && this.auditScores[block[0].id] === undefined) {
return this.apiService.getBlockAuditScore$(block[0].id)
.pipe(
catchError(() => {
return EMPTY;
})
);
} else {
return EMPTY;
}
}),
).subscribe((score) => {
if (score && score.hash) {
this.auditScores[score.hash] = score?.matchRate != null ? score.matchRate : null;
}
});
}
);
}
ngOnDestroy(): void {
this.auditScoreSubscription?.unsubscribe();
this.latestScoreSubscription?.unsubscribe();
}
pageChange(page: number) {
pageChange(page: number): void {
this.fromHeightSubject.next((this.blocksCount - 1) - (page - 1) * 15);
}
trackByBlock(index: number, block: BlockExtended) {
trackByBlock(index: number, block: BlockExtended): number {
return block.height;
}
isEllipsisActive(e) {
isEllipsisActive(e): boolean {
return (e.offsetWidth < e.scrollWidth);
}
}

View File

@@ -1 +0,0 @@
<app-clock mode="mempool"></app-clock>

View File

@@ -1,7 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-clock-mempool',
templateUrl: './clock-mempool.component.html',
})
export class ClockMempoolComponent {}

View File

@@ -1 +0,0 @@
<app-clock mode="block"></app-clock>

View File

@@ -1,7 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-clock-mined',
templateUrl: './clock-mined.component.html',
})
export class ClockMinedComponent {}

View File

@@ -1,14 +1,19 @@
<div class="clock-wrapper" [style]="wrapperStyle">
<div class="clockchain-bar" [style.height]="chainHeight + 'px'">
<div class="clockchain">
<app-clockchain [width]="chainWidth" [height]="chainHeight" [mode]="mode"></app-clockchain>
<app-clockchain
[width]="chainWidth"
[height]="chainHeight"
[mode]="mode"
[index]="blockIndex"
></app-clockchain>
</div>
</div>
<div class="clock-face">
<app-clock-face [size]="clockSize">
<div class="block-wrapper">
<ng-container *ngIf="block && block.height >= 0">
<ng-container *ngIf="mode === 'block'; else mempoolMode;">
<ng-container *ngIf="blocks && blocks.length">
<ng-container *ngIf="mode === 'mined'; else mempoolMode;">
<div class="block-cube">
<div class="side top"></div>
<div class="side bottom"></div>
@@ -20,12 +25,12 @@
</ng-container>
<ng-template #mempoolMode>
<div class="block-sizer" [style]="blockSizerStyle">
<app-mempool-block-overview [index]="0" [pixelAlign]="true"></app-mempool-block-overview>
<app-mempool-block-overview [index]="blockIndex" [pixelAlign]="true"></app-mempool-block-overview>
</div>
</ng-template>
<div class="fader"></div>
<div class="title-wrapper">
<h1 class="block-height">{{ block.height }}</h1>
<h1 class="block-height">{{ blocks[mode === 'mempool' ? 0 : blockIndex].height }}</h1>
</div>
</ng-container>
</div>
@@ -42,13 +47,13 @@
<p class="label" i18n="clock.priority-rate|priority fee rate">priority rate</p>
<p *ngIf="recommendedFees$ | async as recommendedFees;" i18n="shared.sat-vbyte|sat/vB">{{ recommendedFees.fastestFee }} sat/vB</p>
</div>
<div *ngIf="mode !== 'mempool' && block" class="stats bottom left">
<p [innerHTML]="block.size | bytes: 2"></p>
<div *ngIf="mode !== 'mempool' && blocks?.length" class="stats bottom left">
<p [innerHTML]="blocks[blockIndex].size | bytes: 2"></p>
<p class="label" i18n="clock.block-size">block size</p>
</div>
<div *ngIf="mode !== 'mempool' && block" class="stats bottom right">
<div *ngIf="mode !== 'mempool' && blocks?.length" class="stats bottom right">
<p class="force-wrap">
<ng-container *ngTemplateOutlet="block.tx_count === 1 ? transactionsSingular : transactionsPlural; context: {$implicit: block.tx_count | number}"></ng-container>
<ng-container *ngTemplateOutlet="blocks[blockIndex].tx_count === 1 ? transactionsSingular : transactionsPlural; context: {$implicit: blocks[blockIndex].tx_count | number}"></ng-container>
<ng-template #transactionsSingular let-i i18n="shared.transaction-count.singular">{{ i }} <span class="label">transaction</span></ng-template>
<ng-template #transactionsPlural let-i i18n="shared.transaction-count.plural">{{ i }} <span class="label">transactions</span></ng-template>
</p>

View File

@@ -1,10 +1,11 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostListener, Input, OnInit } from '@angular/core';
import { Observable, Subscription } from 'rxjs';
import { Observable, Subscription, of, switchMap, tap } from 'rxjs';
import { StateService } from '../../services/state.service';
import { BlockExtended } from '../../interfaces/node-api.interface';
import { WebsocketService } from '../../services/websocket.service';
import { MempoolInfo, Recommendedfees } from '../../interfaces/websocket.interface';
import { ActivatedRoute } from '@angular/router';
import { ActivatedRoute, ParamMap, Router } from '@angular/router';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
@Component({
selector: 'app-clock',
@@ -13,12 +14,14 @@ import { ActivatedRoute } from '@angular/router';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ClockComponent implements OnInit {
@Input() mode: 'block' | 'mempool' = 'block';
hideStats: boolean = false;
mode: 'mempool' | 'mined' = 'mined';
blockIndex: number;
pageSubscription: Subscription;
blocksSubscription: Subscription;
recommendedFees$: Observable<Recommendedfees>;
mempoolInfo$: Observable<MempoolInfo>;
block: BlockExtended;
blocks: BlockExtended[] = [];
clockSize: number = 300;
chainWidth: number = 384;
chainHeight: number = 60;
@@ -41,6 +44,8 @@ export class ClockComponent implements OnInit {
public stateService: StateService,
private websocketService: WebsocketService,
private route: ActivatedRoute,
private router: Router,
private relativeUrlPipe: RelativeUrlPipe,
private cd: ChangeDetectorRef,
) {
this.route.queryParams.subscribe((params) => {
@@ -57,14 +62,40 @@ export class ClockComponent implements OnInit {
this.blocksSubscription = this.stateService.blocks$
.subscribe(([block]) => {
if (block) {
this.block = block;
this.blockStyle = this.getStyleForBlock(this.block);
this.cd.markForCheck();
this.blocks.unshift(block);
this.blocks = this.blocks.slice(0, 16);
if (this.blocks[this.blockIndex]) {
this.blockStyle = this.getStyleForBlock(this.blocks[this.blockIndex]);
this.cd.markForCheck();
}
}
});
this.recommendedFees$ = this.stateService.recommendedFees$;
this.mempoolInfo$ = this.stateService.mempoolInfo$;
this.pageSubscription = this.route.paramMap.pipe(
switchMap((params: ParamMap) => {
const rawMode: string = params.get('mode');
const mode = rawMode === 'mempool' ? 'mempool' : 'mined';
const index: number = Number.parseInt(params.get('index'));
if (mode !== rawMode || index < 0 || isNaN(index)) {
this.router.navigate([this.relativeUrlPipe.transform('/clock'), mode, index || 0]);
}
return of({
mode,
index,
});
}),
tap((page: { mode: 'mempool' | 'mined', index: number }) => {
this.mode = page.mode;
this.blockIndex = page.index || 0;
if (this.blocks[this.blockIndex]) {
this.blockStyle = this.getStyleForBlock(this.blocks[this.blockIndex]);
this.cd.markForCheck();
}
})
).subscribe();
}
getStyleForBlock(block: BlockExtended) {

View File

@@ -5,8 +5,20 @@
<div class="position-container" [ngClass]="network ? network : ''" [style.top]="(height / 3) + 'px'">
<span>
<div class="blocks-wrapper">
<app-mempool-blocks [minimal]="true" [count]="mempoolBlocks" [blockWidth]="blockWidth" [spotlight]="mode === 'mempool' ? 1 : 0"></app-mempool-blocks>
<app-blockchain-blocks [minimal]="true" [count]="blockchainBlocks" [blockWidth]="blockWidth" [spotlight]="mode === 'block' ? -1 : 0"></app-blockchain-blocks>
<app-mempool-blocks
[minimal]="true"
[count]="mempoolBlocks"
[blockWidth]="blockWidth"
[spotlight]="mode === 'mempool' ? index + 1 : 0"
[getHref]="getMempoolUrl"
></app-mempool-blocks>
<app-blockchain-blocks
[minimal]="true"
[count]="blockchainBlocks"
[blockWidth]="blockWidth"
[spotlight]="mode === 'mined' ? -index - 1 : 0"
[getHref]="getMinedUrl"
></app-blockchain-blocks>
</div>
<div class="divider" [style.top]="-(height / 6) + 'px'">
<svg

View File

@@ -11,7 +11,8 @@ import { StateService } from '../../services/state.service';
export class ClockchainComponent implements OnInit, OnChanges, OnDestroy {
@Input() width: number = 300;
@Input() height: number = 60;
@Input() mode: 'mempool' | 'block';
@Input() mode: 'mempool' | 'mined';
@Input() index: number = 0;
mempoolBlocks: number = 3;
blockchainBlocks: number = 6;
@@ -70,4 +71,12 @@ export class ClockchainComponent implements OnInit, OnChanges, OnDestroy {
this.ltrTransitionEnabled = true;
this.stateService.timeLtr.next(!this.timeLtr);
}
getMempoolUrl(index): string {
return `/clock/mempool/${index}`;
}
getMinedUrl(index): string {
return `/clock/block/${index}`;
}
}

View File

@@ -1,5 +1,9 @@
import { OnChanges } from '@angular/core';
import { Component, Input, OnInit, ChangeDetectionStrategy } from '@angular/core';
import { TransactionStripped } from '../../interfaces/websocket.interface';
import { StateService } from '../../services/state.service';
import { VbytesPipe } from '../../shared/pipes/bytes-pipe/vbytes.pipe';
import { selectPowerOfTen } from '../../bitcoin.utils';
@Component({
selector: 'app-fee-distribution-graph',
@@ -7,47 +11,121 @@ import { Component, Input, OnInit, ChangeDetectionStrategy } from '@angular/core
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FeeDistributionGraphComponent implements OnInit, OnChanges {
@Input() data: any;
@Input() feeRange: number[];
@Input() vsize: number;
@Input() transactions: TransactionStripped[];
@Input() height: number | string = 210;
@Input() top: number | string = 20;
@Input() right: number | string = 22;
@Input() left: number | string = 30;
@Input() numSamples: number = 200;
@Input() numLabels: number = 10;
simple: boolean = false;
data: number[][];
labelInterval: number = 50;
mempoolVsizeFeesOptions: any;
mempoolVsizeFeesInitOptions = {
renderer: 'svg'
};
constructor() { }
constructor(
private stateService: StateService,
private vbytesPipe: VbytesPipe,
) { }
ngOnInit() {
ngOnInit(): void {
this.mountChart();
}
ngOnChanges() {
ngOnChanges(): void {
this.simple = !!this.feeRange?.length;
this.prepareChart();
this.mountChart();
}
mountChart() {
prepareChart(): void {
if (this.simple) {
this.data = this.feeRange.map((rate, index) => [index * 10, rate]);
this.labelInterval = 1;
return;
}
this.data = [];
if (!this.transactions?.length) {
return;
}
const samples = [];
const txs = this.transactions.map(tx => { return { vsize: tx.vsize, rate: tx.rate || (tx.fee / tx.vsize) }; }).sort((a, b) => { return b.rate - a.rate; });
const maxBlockVSize = this.stateService.env.BLOCK_WEIGHT_UNITS / 4;
const sampleInterval = maxBlockVSize / this.numSamples;
let cumVSize = 0;
let sampleIndex = 0;
let nextSample = 0;
let txIndex = 0;
this.labelInterval = this.numSamples / this.numLabels;
while (nextSample <= maxBlockVSize) {
if (txIndex >= txs.length) {
samples.push([(1 - (sampleIndex / this.numSamples)) * 100, 0]);
nextSample += sampleInterval;
sampleIndex++;
continue;
}
while (txs[txIndex] && nextSample < cumVSize + txs[txIndex].vsize) {
samples.push([(1 - (sampleIndex / this.numSamples)) * 100, txs[txIndex].rate]);
nextSample += sampleInterval;
sampleIndex++;
}
cumVSize += txs[txIndex].vsize;
txIndex++;
}
this.data = samples.reverse();
}
mountChart(): void {
this.mempoolVsizeFeesOptions = {
grid: {
height: '210',
right: '20',
top: '22',
left: '30',
left: '40',
},
xAxis: {
type: 'category',
boundaryGap: false,
name: '% Weight',
nameLocation: 'middle',
nameGap: 0,
nameTextStyle: {
verticalAlign: 'top',
padding: [30, 0, 0, 0],
},
axisLabel: {
interval: (index: number): boolean => { return index && (index % this.labelInterval === 0); },
formatter: (value: number): string => { return Number(value).toFixed(0); },
},
axisTick: {
interval: (index:number): boolean => { return (index % this.labelInterval === 0); },
},
},
yAxis: {
type: 'value',
// name: 'Effective Fee Rate s/vb',
// nameLocation: 'middle',
splitLine: {
lineStyle: {
type: 'dotted',
color: '#ffffff66',
opacity: 0.25,
}
},
axisLabel: {
formatter: (value: number): string => {
const selectedPowerOfTen = selectPowerOfTen(value);
const newVal = Math.round(value / selectedPowerOfTen.divider);
return `${newVal}${selectedPowerOfTen.unit}`;
},
}
},
series: [{
@@ -58,14 +136,18 @@ export class FeeDistributionGraphComponent implements OnInit, OnChanges {
position: 'top',
color: '#ffffff',
textShadowBlur: 0,
formatter: (label: any) => {
return Math.floor(label.data);
formatter: (label: { data: number[] }): string => {
const value = label.data[1];
const selectedPowerOfTen = selectPowerOfTen(value);
const newVal = Math.round(value / selectedPowerOfTen.divider);
return `${newVal}${selectedPowerOfTen.unit}`;
},
},
showAllSymbol: false,
smooth: true,
lineStyle: {
color: '#D81B60',
width: 4,
width: 1,
},
itemStyle: {
color: '#b71c1c',

View File

@@ -1,7 +1,7 @@
<ng-container *ngIf="{ val: network$ | async } as network">
<header>
<nav class="navbar navbar-expand-md navbar-dark bg-dark">
<a class="navbar-brand" [ngClass]="{'dual-logos': subdomain}" [routerLink]="['/' | relativeUrl]">
<a class="navbar-brand" [ngClass]="{'dual-logos': subdomain}" [routerLink]="['/' | relativeUrl]" (click)="brandClick($event)">
<ng-template [ngIf]="subdomain">
<div class="subdomain_container">
<img [src]="'/api/v1/enterprise/images/' + subdomain + '/logo'" class="subdomain_logo">

View File

@@ -53,4 +53,8 @@ export class MasterPageComponent implements OnInit {
onResize(): void {
this.isMobile = window.innerWidth <= 767.98;
}
brandClick(e): void {
this.stateService.resetScroll$.next(true);
}
}

View File

@@ -39,11 +39,11 @@
</tr>
</tbody>
</table>
<app-fee-distribution-graph *ngIf="webGlEnabled" [data]="mempoolBlock.feeRange" ></app-fee-distribution-graph>
<app-fee-distribution-graph *ngIf="webGlEnabled" [transactions]="mempoolBlockTransactions$ | async" [feeRange]="mempoolBlock.isStack ? mempoolBlock.feeRange : []" [vsize]="mempoolBlock.blockVSize" ></app-fee-distribution-graph>
</div>
<div class="col-md chart-container">
<app-mempool-block-overview *ngIf="webGlEnabled" [index]="mempoolBlockIndex" (txPreviewEvent)="setTxPreview($event)"></app-mempool-block-overview>
<app-fee-distribution-graph *ngIf="!webGlEnabled" [data]="mempoolBlock.feeRange" ></app-fee-distribution-graph>
<app-fee-distribution-graph *ngIf="!webGlEnabled" [transactions]="mempoolBlockTransactions$ | async" [feeRange]="mempoolBlock.isStack ? mempoolBlock.feeRange : []" [vsize]="mempoolBlock.blockVSize" ></app-fee-distribution-graph>
</div>
</div>
</div>

View File

@@ -17,6 +17,7 @@ export class MempoolBlockComponent implements OnInit, OnDestroy {
network$: Observable<string>;
mempoolBlockIndex: number;
mempoolBlock$: Observable<MempoolBlock>;
mempoolBlockTransactions$: Observable<TransactionStripped[]>;
ordinal$: BehaviorSubject<string> = new BehaviorSubject('');
previewTx: TransactionStripped | void;
webGlEnabled: boolean;
@@ -53,6 +54,7 @@ export class MempoolBlockComponent implements OnInit, OnDestroy {
const ordinal = this.getOrdinal(mempoolBlocks[this.mempoolBlockIndex]);
this.ordinal$.next(ordinal);
this.seoService.setTitle(ordinal);
mempoolBlocks[this.mempoolBlockIndex].isStack = mempoolBlocks[this.mempoolBlockIndex].blockVSize > this.stateService.blockVSize;
return mempoolBlocks[this.mempoolBlockIndex];
})
);
@@ -62,6 +64,8 @@ export class MempoolBlockComponent implements OnInit, OnDestroy {
})
);
this.mempoolBlockTransactions$ = this.stateService.liveMempoolBlockTransactions$.pipe(map(txMap => Object.values(txMap)));
this.network$ = this.stateService.networkChanged$;
}

View File

@@ -8,7 +8,7 @@
[style.right]="mempoolBlockStyles[i].right"
></div>
<div @blockEntryTrigger [@.disabled]="i > 0 || !animateEntry" [attr.data-cy]="'mempool-block-' + i" class="bitcoin-block text-center mempool-block" [class.hide-block]="count && i >= count" id="mempool-block-{{ i }}" [ngStyle]="mempoolBlockStyles[i]" [class.blink-bg]="projectedBlock.blink">
<a draggable="false" [routerLink]="['/mempool-block/' | relativeUrl, i]"
<a draggable="false" [routerLink]="[getHref(i) | relativeUrl]"
class="blockLink" [ngClass]="{'disabled': (this.stateService.blockScrolling$ | async)}">&nbsp;</a>
<div class="block-body">
<ng-container *ngIf="!minimal">

View File

@@ -1,9 +1,9 @@
import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef, HostListener, Input, OnChanges, SimpleChanges } from '@angular/core';
import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef, HostListener, Input, OnChanges, SimpleChanges, Output, EventEmitter } from '@angular/core';
import { Subscription, Observable, fromEvent, merge, of, combineLatest } from 'rxjs';
import { MempoolBlock } from '../../interfaces/websocket.interface';
import { StateService } from '../../services/state.service';
import { Router } from '@angular/router';
import { take, map, switchMap } from 'rxjs/operators';
import { take, map, switchMap, tap } from 'rxjs/operators';
import { feeLevels, mempoolFeeColors } from '../../app.constants';
import { specialBlocks } from '../../app.constants';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
@@ -28,6 +28,10 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy {
@Input() blockWidth: number = 125;
@Input() count: number = null;
@Input() spotlight: number = 0;
@Input() getHref?: (index) => string = (index) => `/mempool-block/${index}`;
@Input() allBlocks: boolean = false;
@Output() widthChange: EventEmitter<number> = new EventEmitter();
specialBlocks = specialBlocks;
mempoolBlocks: MempoolBlock[] = [];
@@ -142,16 +146,24 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy {
this.mempoolBlocksFull = JSON.parse(stringifiedBlocks);
this.mempoolBlocks = this.reduceMempoolBlocksToFitScreen(JSON.parse(stringifiedBlocks));
this.now = Date.now();
this.updateMempoolBlockStyles();
this.calculateTransactionPosition();
return this.mempoolBlocks;
}),
tap(() => {
this.cd.markForCheck();
this.widthChange.emit(this.containerOffset + this.mempoolBlocks.length * this.blockOffset);
})
);
this.difficultyAdjustments$ = this.stateService.difficultyAdjustment$
.pipe(
map((da) => {
this.now = new Date().getTime();
this.now = Date.now();
this.cd.markForCheck();
return da;
})
);
@@ -253,7 +265,10 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy {
reduceEmptyBlocksToFitScreen(blocks: MempoolBlock[]): MempoolBlock[] {
const innerWidth = this.stateService.env.BASE_MODULE !== 'liquid' && window.innerWidth <= 767.98 ? window.innerWidth : window.innerWidth / 2;
const blocksAmount = Math.min(this.stateService.env.MEMPOOL_BLOCKS_AMOUNT, Math.floor(innerWidth / (this.blockWidth + this.blockPadding)));
let blocksAmount = this.stateService.env.MEMPOOL_BLOCKS_AMOUNT;
if (!this.allBlocks) {
blocksAmount = Math.min(this.stateService.env.MEMPOOL_BLOCKS_AMOUNT, Math.floor(innerWidth / (this.blockWidth + this.blockPadding)));
}
while (blocks.length < blocksAmount) {
blocks.push({
blockSize: 0,
@@ -273,10 +288,10 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy {
reduceMempoolBlocksToFitScreen(blocks: MempoolBlock[]): MempoolBlock[] {
const innerWidth = this.stateService.env.BASE_MODULE !== 'liquid' && window.innerWidth <= 767.98 ? window.innerWidth : window.innerWidth / 2;
let blocksAmount;
let blocksAmount = this.stateService.env.MEMPOOL_BLOCKS_AMOUNT;
if (this.count) {
blocksAmount = 8;
} else {
} else if (!this.allBlocks) {
blocksAmount = Math.min(this.stateService.env.MEMPOOL_BLOCKS_AMOUNT, Math.floor(innerWidth / (this.blockWidth + this.blockPadding)));
}
while (blocks.length > blocksAmount) {

View File

@@ -94,7 +94,8 @@
<th class="" i18n="master-page.blocks">Blocks</th>
<th *ngIf="auditAvailable" class="health text-right widget" i18n="latest-blocks.avg_health"
i18n-ngbTooltip="latest-blocks.avg_health" ngbTooltip="Avg Health" placement="bottom" #health [disableTooltip]="!isEllipsisActive(health)">Avg Health</th>
<th class="d-none d-md-table-cell" i18n="mining.empty-blocks">Empty blocks</th>
<th *ngIf="auditAvailable" class="d-none d-sm-table-cell" i18n="mining.fees-per-block">Avg Block Fees</th>
<th class="d-none d-lg-table-cell" i18n="mining.empty-blocks">Empty blocks</th>
</tr>
</thead>
<tbody [attr.data-cy]="'pools-table'" *ngIf="(miningStatsObservable$ | async) as miningStats">
@@ -121,7 +122,15 @@
<span class="health-badge badge badge-secondary" i18n="unknown">Unknown</span>
</ng-template>
</td>
<td class="d-none d-md-table-cell">{{ pool.emptyBlocks }} ({{ pool.emptyBlockRatio }}%)</td>
<td *ngIf="auditAvailable" class="d-none d-sm-table-cell">
<span *ngIf="pool.avgFeeDelta != null; else nullFeeDelta" class="difference" [class.positive]="pool.avgFeeDelta >= 0" [class.negative]="pool.avgFeeDelta < 0">
{{ pool.avgFeeDelta > 0 ? '+' : '' }}{{ (pool.avgFeeDelta * 100) | amountShortener: 2 }}%
</span>
<ng-template #nullFeeDelta>
-
</ng-template>
</td>
<td class="d-none d-lg-table-cell">{{ pool.emptyBlocks }} ({{ pool.emptyBlockRatio }}%)</td>
</tr>
<tr style="border-top: 1px solid #555">
<td class="d-none d-md-table-cell"></td>

View File

@@ -110,4 +110,15 @@
.disabled {
pointer-events: none;
opacity: 0.5;
}
}
td {
.difference {
&.positive {
color: rgb(66, 183, 71);
}
&.negative {
color: rgb(183, 66, 66);
}
}
}

View File

@@ -1,3 +1,5 @@
<app-indexing-progress *ngIf="showLoadingIndicator"></app-indexing-progress>
<ng-container *ngIf="specialEvent">
<div class="pyro">
<div class="before"></div>
@@ -16,7 +18,7 @@
(dragstart)="onDragStart($event)"
(scroll)="onScroll($event)"
>
<app-blockchain [pageIndex]="pageIndex" [pages]="pages" [blocksPerPage]="blocksPerPage" [minScrollWidth]="minScrollWidth"></app-blockchain>
<app-blockchain [pageIndex]="pageIndex" [pages]="pages" [blocksPerPage]="blocksPerPage" [minScrollWidth]="minScrollWidth" [scrollableMempool]="true" (mempoolOffsetChange)="onMempoolOffsetChange($event)"></app-blockchain>
</div>
<div class="reset-scroll" [class.hidden]="pageIndex === 0" (click)="resetScroll()">
<fa-icon [icon]="['fas', 'circle-left']" [fixedWidth]="true"></fa-icon>

View File

@@ -1,6 +1,6 @@
import { Component, ElementRef, HostListener, OnInit, OnDestroy, ViewChild } from '@angular/core';
import { Component, ElementRef, HostListener, OnInit, OnDestroy, ViewChild, Input } from '@angular/core';
import { Subscription } from 'rxjs';
import { StateService } from '../../services/state.service';
import { MarkBlockState, StateService } from '../../services/state.service';
import { specialBlocks } from '../../app.constants';
@Component({
@@ -9,6 +9,8 @@ import { specialBlocks } from '../../app.constants';
styleUrls: ['./start.component.scss'],
})
export class StartComponent implements OnInit, OnDestroy {
@Input() showLoadingIndicator = false;
interval = 60;
colors = ['#5E35B1', '#ffffff'];
@@ -22,9 +24,11 @@ export class StartComponent implements OnInit, OnDestroy {
chainTipSubscription: Subscription;
chainTip: number = -1;
tipIsSet: boolean = false;
lastMark: MarkBlockState;
markBlockSubscription: Subscription;
blockCounterSubscription: Subscription;
@ViewChild('blockchainContainer') blockchainContainer: ElementRef;
resetScrollSubscription: Subscription;
isMobile: boolean = false;
isiOS: boolean = false;
@@ -37,10 +41,11 @@ export class StartComponent implements OnInit, OnDestroy {
minScrollWidth: number;
pageIndex: number = 0;
pages: any[] = [];
pendingMark: number | void = null;
pendingMark: number | null = null;
lastUpdate: number = 0;
lastMouseX: number;
velocity: number = 0;
mempoolOffset: number = 0;
constructor(
private stateService: StateService,
@@ -67,19 +72,40 @@ export class StartComponent implements OnInit, OnDestroy {
this.chainTip = height;
this.tipIsSet = true;
this.updatePages();
if (this.pendingMark != null) {
this.scrollToBlock(this.pendingMark);
this.pendingMark = null;
}
this.applyPendingMarkArrow();
});
this.markBlockSubscription = this.stateService.markBlock$.subscribe((mark) => {
let blockHeight;
let newMark = true;
if (mark?.blockHeight != null) {
if (this.lastMark?.blockHeight === mark.blockHeight) {
newMark = false;
}
blockHeight = mark.blockHeight;
} else if (mark?.mempoolBlockIndex != null) {
if (this.lastMark?.mempoolBlockIndex === mark.mempoolBlockIndex || (mark.txid && this.lastMark?.txid === mark.txid)) {
newMark = false;
}
blockHeight = -1 - mark.mempoolBlockIndex;
} else if (mark?.mempoolPosition?.block != null) {
if (this.lastMark?.txid === mark.txid) {
newMark = false;
}
blockHeight = -1 - mark.mempoolPosition.block;
}
this.lastMark = mark;
if (blockHeight != null) {
if (this.tipIsSet) {
if (!this.blockInViewport(mark.blockHeight)) {
this.scrollToBlock(mark.blockHeight);
let scrollToHeight = blockHeight;
if (blockHeight < 0) {
scrollToHeight = this.chainTip - blockHeight;
}
} else {
this.pendingMark = mark.blockHeight;
if (newMark && !this.blockInViewport(scrollToHeight)) {
this.scrollToBlock(scrollToHeight);
}
}
if (!this.tipIsSet || (blockHeight < 0 && !this.mempoolOffset)) {
this.pendingMark = blockHeight;
}
}
});
@@ -106,6 +132,30 @@ export class StartComponent implements OnInit, OnDestroy {
}, 60 * 60 * 1000);
}
});
this.resetScrollSubscription = this.stateService.resetScroll$.subscribe(reset => {
if (reset) {
this.resetScroll();
this.stateService.resetScroll$.next(false);
}
});
}
onMempoolOffsetChange(offset): void {
const delta = offset - this.mempoolOffset;
this.addConvertedScrollOffset(delta);
this.mempoolOffset = offset;
this.applyPendingMarkArrow();
}
applyPendingMarkArrow(): void {
if (this.pendingMark != null) {
if (this.pendingMark < 0) {
this.scrollToBlock(this.chainTip - this.pendingMark);
} else {
this.scrollToBlock(this.pendingMark);
}
this.pendingMark = null;
}
}
@HostListener('window:resize', ['$event'])
@@ -341,7 +391,7 @@ export class StartComponent implements OnInit, OnDestroy {
resetScroll(): void {
this.scrollToBlock(this.chainTip);
this.blockchainContainer.nativeElement.scrollLeft = 0;
this.setScrollLeft(0);
}
getPageIndexOf(height: number): number {
@@ -359,9 +409,17 @@ export class StartComponent implements OnInit, OnDestroy {
getConvertedScrollOffset(): number {
if (this.timeLtr) {
return -this.blockchainContainer?.nativeElement?.scrollLeft || 0;
return -(this.blockchainContainer?.nativeElement?.scrollLeft || 0) - this.mempoolOffset;
} else {
return this.blockchainContainer?.nativeElement?.scrollLeft || 0;
return (this.blockchainContainer?.nativeElement?.scrollLeft || 0) - this.mempoolOffset;
}
}
setScrollLeft(offset: number): void {
if (this.timeLtr) {
this.blockchainContainer.nativeElement.scrollLeft = offset - this.mempoolOffset;
} else {
this.blockchainContainer.nativeElement.scrollLeft = offset + this.mempoolOffset;
}
}
@@ -379,11 +437,12 @@ export class StartComponent implements OnInit, OnDestroy {
ngOnDestroy() {
if (this.blockchainContainer?.nativeElement) {
// clean up scroll position to prevent caching wrong scroll in Firefox
this.blockchainContainer.nativeElement.scrollLeft = 0;
this.setScrollLeft(0);
}
this.timeLtrSubscription.unsubscribe();
this.chainTipSubscription.unsubscribe();
this.markBlockSubscription.unsubscribe();
this.blockCounterSubscription.unsubscribe();
this.resetScrollSubscription.unsubscribe();
}
}

View File

@@ -12,46 +12,54 @@
<form [formGroup]="radioGroupForm" class="formRadioGroup"
[class]="(stateService.env.MINING_DASHBOARD || stateService.env.LIGHTNING) ? 'mining' : 'no-menu'" (click)="saveGraphPreference()">
<div *ngIf="!isMobile()" class="btn-group btn-group-toggle">
<label class="btn btn-primary btn-sm mr-2">
<a [routerLink]="['/tv' | relativeUrl]" style="color: white" id="btn-tv">
<fa-icon [icon]="['fas', 'tv']" [fixedWidth]="true" i18n-title="master-page.tvview" title="TV view"></fa-icon>
</a>
</label>
<div class="small-buttons">
<a class="btn btn-primary btn-sm mb-0" [routerLink]="['/clock/mempool/0' | relativeUrl]" style="color: white" id="btn-clock">
<fa-icon [icon]="['fas', 'clock']" [fixedWidth]="true" i18n-title="master-page.clockview" title="Clock view"></fa-icon>
</a>
<a *ngIf="!isMobile()" class="btn btn-primary btn-sm mb-0" [routerLink]="['/tv' | relativeUrl]" style="color: white" id="btn-tv">
<fa-icon [icon]="['fas', 'tv']" [fixedWidth]="true" i18n-title="master-page.tvview" title="TV view"></fa-icon>
</a>
</div>
<div class="btn-group btn-group-toggle" name="radioBasic">
<label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '2h'">
<input type="radio" [value]="'2h'" [routerLink]="['/graphs' | relativeUrl]" fragment="2h" formControlName="dateSpan"> 2H
(LIVE)
</label>
<label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '24h'">
<input type="radio" [value]="'24h'" [routerLink]="['/graphs' | relativeUrl]" fragment="24h" formControlName="dateSpan">
24H
</label>
<label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '1w'">
<input type="radio" [value]="'1w'" [routerLink]="['/graphs' | relativeUrl]" fragment="1w" formControlName="dateSpan"> 1W
</label>
<label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '1m'">
<input type="radio" [value]="'1m'" [routerLink]="['/graphs' | relativeUrl]" fragment="1m" formControlName="dateSpan"> 1M
</label>
<label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '3m'">
<input type="radio" [value]="'3m'" [routerLink]="['/graphs' | relativeUrl]" fragment="3m" formControlName="dateSpan"> 3M
</label>
<label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '6m'">
<input type="radio" [value]="'6m'" [routerLink]="['/graphs' | relativeUrl]" fragment="6m" formControlName="dateSpan"> 6M
</label>
<label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '1y'">
<input type="radio" [value]="'1y'" [routerLink]="['/graphs' | relativeUrl]" fragment="1y" formControlName="dateSpan"> 1Y
</label>
<label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '2y'">
<input type="radio" [value]="'2y'" [routerLink]="['/graphs' | relativeUrl]" fragment="2y" formControlName="dateSpan"> 2Y
</label>
<label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '3y'">
<input type="radio" [value]="'3y'" [routerLink]="['/graphs' | relativeUrl]" fragment="3y" formControlName="dateSpan"> 3Y
</label>
<label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '4y'">
<input type="radio" [value]="'4y'" [routerLink]="['/graphs' | relativeUrl]" fragment="4y" formControlName="dateSpan"> 4Y
</label>
<div class="btn-toggle-rows" name="radioBasic">
<div class="btn-group btn-group-toggle">
<label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '2h'">
<input type="radio" [value]="'2h'" [routerLink]="['/graphs' | relativeUrl]" fragment="2h" formControlName="dateSpan"> 2H
(LIVE)
</label>
<label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '24h'">
<input type="radio" [value]="'24h'" [routerLink]="['/graphs' | relativeUrl]" fragment="24h" formControlName="dateSpan">
24H
</label>
<label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '1w'">
<input type="radio" [value]="'1w'" [routerLink]="['/graphs' | relativeUrl]" fragment="1w" formControlName="dateSpan"> 1W
</label>
<label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '1m'">
<input type="radio" [value]="'1m'" [routerLink]="['/graphs' | relativeUrl]" fragment="1m" formControlName="dateSpan"> 1M
</label>
<label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '3m'">
<input type="radio" [value]="'3m'" [routerLink]="['/graphs' | relativeUrl]" fragment="3m" formControlName="dateSpan"> 3M
</label>
</div>
<div class="btn-group btn-group-toggle">
<label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '6m'">
<input type="radio" [value]="'6m'" [routerLink]="['/graphs' | relativeUrl]" fragment="6m" formControlName="dateSpan"> 6M
</label>
<label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '1y'">
<input type="radio" [value]="'1y'" [routerLink]="['/graphs' | relativeUrl]" fragment="1y" formControlName="dateSpan"> 1Y
</label>
<label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '2y'">
<input type="radio" [value]="'2y'" [routerLink]="['/graphs' | relativeUrl]" fragment="2y" formControlName="dateSpan"> 2Y
</label>
<label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '3y'">
<input type="radio" [value]="'3y'" [routerLink]="['/graphs' | relativeUrl]" fragment="3y" formControlName="dateSpan"> 3Y
</label>
<label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === '4y'">
<input type="radio" [value]="'4y'" [routerLink]="['/graphs' | relativeUrl]" fragment="4y" formControlName="dateSpan"> 4Y
</label>
<label class="btn btn-primary btn-sm" [class.active]="radioGroupForm.get('dateSpan').value === 'all'">
<input type="radio" [value]="'all'" [routerLink]="['/graphs' | relativeUrl]" fragment="all" formControlName="dateSpan"><span i18n="all">All</span>
</label>
</div>
</div>
<div class="small-buttons">
<div ngbDropdown #myDrop="ngbDropdown">

View File

@@ -53,17 +53,17 @@
}
}
.formRadioGroup.mining {
@media (min-width: 991px) {
@media (min-width: 1035px) {
position: relative;
top: -100px;
}
@media (min-width: 830px) and (max-width: 991px) {
@media (min-width: 830px) and (max-width: 1035px) {
position: relative;
top: 0px;
}
}
.formRadioGroup.no-menu {
@media (min-width: 991px) {
@media (min-width: 1035px) {
position: relative;
top: -33px;
}
@@ -150,17 +150,30 @@
margin: 0px 0px;
}
.btn {
width: 49.25%;
width: 50%;
flex-grow: 1;
flex-shrink: 1;
margin: 1px 0;
margin-right: 0.5rem;
@media (min-width: 830px) {
width: auto;
}
&:first-child {
margin-right: 0;
@media (min-width: 830px) {
margin-right: 0.5rem;
}
}
}
.dropdown {
width: 49.25%;
display: flex;
width: 50%;
flex-grow: 1;
flex-shrink: 1;
@media (min-width: 830px) {
width: auto;
margin: 0px 5px;
margin-left: 0.5rem;
}
}
#dropdownFees {
@@ -170,3 +183,43 @@
}
}
}
.btn-toggle-rows {
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: stretch;
.btn-group {
flex-grow: 1;
flex-shrink: 1;
}
@media (min-width: 500px) {
.btn-group:first-child > .btn:last-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.btn-group:last-child > .btn:first-child {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
@media (max-width: 499px) {
flex-direction: column;
.btn-group:first-child > .btn:first-child {
border-bottom-left-radius: 0;
}
.btn-group:first-child > .btn:last-child {
border-bottom-right-radius: 0;
}
.btn-group:last-child > .btn:first-child {
border-top-left-radius: 0;
}
.btn-group:last-child > .btn:last-child {
border-top-right-radius: 0;
}
}
}

View File

@@ -72,8 +72,10 @@ export class StatisticsComponent implements OnInit {
this.route
.fragment
.subscribe((fragment) => {
if (['2h', '24h', '1w', '1m', '3m', '6m', '1y', '2y', '3y', '4y'].indexOf(fragment) > -1) {
if (['2h', '24h', '1w', '1m', '3m', '6m', '1y', '2y', '3y', '4y', 'all'].indexOf(fragment) > -1) {
this.radioGroupForm.controls.dateSpan.setValue(fragment, { emitEvent: false });
} else {
this.radioGroupForm.controls.dateSpan.setValue('2h', { emitEvent: false });
}
});
@@ -114,7 +116,12 @@ export class StatisticsComponent implements OnInit {
if (this.radioGroupForm.controls.dateSpan.value === '3y') {
return this.apiService.list3YStatistics$();
}
return this.apiService.list4YStatistics$();
if (this.radioGroupForm.controls.dateSpan.value === '4y') {
return this.apiService.list4YStatistics$();
}
if (this.radioGroupForm.controls.dateSpan.value === 'all') {
return this.apiService.listAllTimeStatistics$();
}
})
)
.subscribe((mempoolStats: any) => {

View File

@@ -1,2 +1,2 @@
<app-start></app-start>
<app-start [showLoadingIndicator]="true"></app-start>
<app-footer></app-footer>

View File

@@ -18,7 +18,12 @@
</span>
<div class="container-buttons">
<app-confirmations [chainTip]="latestBlock?.height" [height]="tx?.status?.block_height" [replaced]="replaced"></app-confirmations>
<app-confirmations
[chainTip]="latestBlock?.height"
[height]="tx?.status?.block_height"
[replaced]="replaced"
[removed]="this.rbfInfo?.mined && !this.tx?.status?.confirmed"
></app-confirmations>
</div>
</ng-container>
</div>
@@ -100,7 +105,7 @@
<app-time kind="until" [time]="(60 * 1000 * this.mempoolPosition.block) + now" [fastRender]="false" [fixedRender]="true"></app-time>
</ng-template>
<ng-template #timeEstimateDefault>
<app-time kind="until" *ngIf="(timeAvg$ | async) as timeAvg;" [time]="(timeAvg * this.mempoolPosition.block) + now + timeAvg" [fastRender]="false" [fixedRender]="true"></app-time>
<app-time kind="until" *ngIf="(da$ | async) as da;" [time]="da.timeAvg * (this.mempoolPosition.block + 1) + now + da.timeOffset" [fastRender]="false" [fixedRender]="true"></app-time>
</ng-template>
</ng-template>
</ng-template>

View File

@@ -19,7 +19,7 @@ import { WebsocketService } from '../../services/websocket.service';
import { AudioService } from '../../services/audio.service';
import { ApiService } from '../../services/api.service';
import { SeoService } from '../../services/seo.service';
import { BlockExtended, CpfpInfo, RbfTree, MempoolPosition } from '../../interfaces/node-api.interface';
import { BlockExtended, CpfpInfo, RbfTree, MempoolPosition, DifficultyAdjustment } from '../../interfaces/node-api.interface';
import { LiquidUnblinding } from './liquid-ublinding';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
import { Price, PriceService } from '../../services/price.service';
@@ -65,7 +65,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
fetchCachedTx$ = new Subject<string>();
isCached: boolean = false;
now = Date.now();
timeAvg$: Observable<number>;
da$: Observable<DifficultyAdjustment>;
liquidUnblinding = new LiquidUnblinding();
inputIndex: number;
outputIndex: number;
@@ -117,11 +117,11 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.setFlowEnabled();
});
this.timeAvg$ = timer(0, 1000)
.pipe(
switchMap(() => this.stateService.difficultyAdjustment$),
map((da) => da.timeAvg)
);
this.da$ = this.stateService.difficultyAdjustment$.pipe(
tap(() => {
this.now = Date.now();
})
);
this.urlFragmentSubscription = this.route.fragment.subscribe((fragment) => {
this.fragmentParams = new URLSearchParams(fragment || '');
@@ -236,10 +236,12 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
});
this.mempoolPositionSubscription = this.stateService.mempoolTxPosition$.subscribe(txPosition => {
this.now = Date.now();
if (txPosition && txPosition.txid === this.txId && txPosition.position) {
this.mempoolPosition = txPosition.position;
if (this.tx && !this.tx.status.confirmed) {
this.stateService.markBlock$.next({
txid: txPosition.txid,
mempoolPosition: this.mempoolPosition
});
this.txInBlockIndex = this.mempoolPosition.block;
@@ -359,6 +361,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
} else {
if (tx.cpfpChecked) {
this.stateService.markBlock$.next({
txid: tx.txid,
txFeePerVSize: tx.effectiveFeePerVsize,
mempoolPosition: this.mempoolPosition,
});
@@ -434,12 +437,12 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
});
this.mempoolBlocksSubscription = this.stateService.mempoolBlocks$.subscribe((mempoolBlocks) => {
this.now = Date.now();
if (!this.tx || this.mempoolPosition) {
return;
}
this.now = Date.now();
const txFeePerVSize =
this.tx.effectiveFeePerVsize || this.tx.fee / (this.tx.weight / 4);

View File

@@ -91,6 +91,7 @@ export interface SinglePoolStats {
logo: string;
slug: string;
avgMatchRate: number;
avgFeeDelta: number;
}
export interface PoolsStats {
blockCount: number;
@@ -129,10 +130,15 @@ export interface PoolStat {
export interface BlockExtension {
totalFees?: number;
medianFee?: number;
minFee?: number;
maxFee?: number;
feeRange?: number[];
reward?: number;
coinbaseRaw?: string;
matchRate?: number;
expectedFees?: number;
expectedWeight?: number;
feeDelta?: number;
similarity?: number;
pool?: {
id: number;
@@ -148,7 +154,14 @@ export interface BlockExtended extends Block {
export interface BlockAudit extends BlockExtended {
missingTxs: string[],
addedTxs: string[],
freshTxs: string[],
sigopTxs: string[],
matchRate: number,
expectedFees: number,
expectedWeight: number,
feeDelta?: number,
weightDelta?: number,
txDelta?: number,
template: TransactionStripped[],
transactions: TransactionStripped[],
}
@@ -159,6 +172,7 @@ export interface TransactionStripped {
vsize: number;
value: number;
status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'added' | 'censored' | 'selected';
context?: 'projected' | 'actual';
}
interface RbfTransaction extends TransactionStripped {

View File

@@ -31,6 +31,7 @@ export interface WebsocketResponse {
'track-rbf'?: string;
'watch-mempool'?: boolean;
'track-bisq-market'?: string;
'refresh-blocks'?: boolean;
}
export interface ReplacedTransaction extends Transaction {

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators,
PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights, RbfTree } from '../interfaces/node-api.interface';
PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights, RbfTree, BlockAudit } from '../interfaces/node-api.interface';
import { Observable } from 'rxjs';
import { StateService } from './state.service';
import { WebsocketResponse } from '../interfaces/websocket.interface';
@@ -72,6 +72,10 @@ export class ApiService {
return this.httpClient.get<OptimizedMempoolStats[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/statistics/4y');
}
listAllTimeStatistics$(): Observable<OptimizedMempoolStats[]> {
return this.httpClient.get<OptimizedMempoolStats[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/statistics/all');
}
getTransactionTimes$(txIds: string[]): Observable<number[]> {
let params = new HttpParams();
txIds.forEach((txId: string) => {
@@ -245,9 +249,9 @@ export class ApiService {
);
}
getBlockAudit$(hash: string) : Observable<any> {
return this.httpClient.get<any>(
this.apiBaseUrl + this.apiBasePath + `/api/v1/block/${hash}/audit-summary`, { observe: 'response' }
getBlockAudit$(hash: string) : Observable<BlockAudit> {
return this.httpClient.get<BlockAudit>(
this.apiBaseUrl + this.apiBasePath + `/api/v1/block/${hash}/audit-summary`
);
}

View File

@@ -1,15 +1,16 @@
import { Inject, Injectable, PLATFORM_ID, LOCALE_ID } from '@angular/core';
import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable } from 'rxjs';
import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable, merge } from 'rxjs';
import { Transaction } from '../interfaces/electrs.interface';
import { IBackendInfo, MempoolBlock, MempoolBlockWithTransactions, MempoolBlockDelta, MempoolInfo, Recommendedfees, ReplacedTransaction, TransactionStripped } from '../interfaces/websocket.interface';
import { BlockExtended, DifficultyAdjustment, MempoolPosition, OptimizedMempoolStats, RbfTree } from '../interfaces/node-api.interface';
import { Router, NavigationStart } from '@angular/router';
import { isPlatformBrowser } from '@angular/common';
import { map, shareReplay } from 'rxjs/operators';
import { map, scan, shareReplay, tap } from 'rxjs/operators';
import { StorageService } from './storage.service';
interface MarkBlockState {
export interface MarkBlockState {
blockHeight?: number;
txid?: string;
mempoolBlockIndex?: number;
txFeePerVSize?: number;
mempoolPosition?: MempoolPosition;
@@ -100,6 +101,7 @@ export class StateService {
mempoolBlocks$ = new ReplaySubject<MempoolBlock[]>(1);
mempoolBlockTransactions$ = new Subject<TransactionStripped[]>();
mempoolBlockDelta$ = new Subject<MempoolBlockDelta>();
liveMempoolBlockTransactions$: Observable<{ [txid: string]: TransactionStripped}>;
txReplaced$ = new Subject<ReplacedTransaction>();
txRbfInfo$ = new Subject<RbfTree>();
rbfLatest$ = new Subject<RbfTree[]>();
@@ -126,6 +128,7 @@ export class StateService {
keyNavigation$ = new Subject<KeyboardEvent>();
blockScrolling$: Subject<boolean> = new Subject<boolean>();
resetScroll$: Subject<boolean> = new Subject<boolean>();
timeLtr: BehaviorSubject<boolean>;
hideFlow: BehaviorSubject<boolean>;
hideAudit: BehaviorSubject<boolean>;
@@ -165,6 +168,30 @@ export class StateService {
this.blocks$ = new ReplaySubject<[BlockExtended, string]>(this.env.KEEP_BLOCKS_AMOUNT);
this.liveMempoolBlockTransactions$ = merge(
this.mempoolBlockTransactions$.pipe(map(transactions => { return { transactions }; })),
this.mempoolBlockDelta$.pipe(map(delta => { return { delta }; })),
).pipe(scan((transactions: { [txid: string]: TransactionStripped }, change: any): { [txid: string]: TransactionStripped } => {
if (change.transactions) {
const txMap = {}
change.transactions.forEach(tx => {
txMap[tx.txid] = tx;
})
return txMap;
} else {
change.delta.changed.forEach(tx => {
transactions[tx.txid].rate = tx.rate;
})
change.delta.removed.forEach(txid => {
delete transactions[txid];
});
change.delta.added.forEach(tx => {
transactions[tx.txid] = tx;
});
return transactions;
}
}, {}));
if (this.env.BASE_MODULE === 'bisq') {
this.network = this.env.BASE_MODULE;
this.networkChanged$.next(this.env.BASE_MODULE);

View File

@@ -235,6 +235,8 @@ export class WebsocketService {
}
handleResponse(response: WebsocketResponse) {
let reinitBlocks = false;
if (response.blocks && response.blocks.length) {
const blocks = response.blocks;
let maxHeight = 0;
@@ -256,9 +258,11 @@ export class WebsocketService {
}
if (response.block) {
if (response.block.height > this.stateService.latestBlockHeight) {
if (response.block.height === this.stateService.latestBlockHeight + 1) {
this.stateService.updateChainTip(response.block.height);
this.stateService.blocks$.next([response.block, response.txConfirmed || '']);
} else if (response.block.height > this.stateService.latestBlockHeight + 1) {
reinitBlocks = true;
}
if (response.txConfirmed) {
@@ -369,5 +373,9 @@ export class WebsocketService {
if (response['git-commit']) {
this.stateService.backendInfo$.next(response['git-commit']);
}
if (reinitBlocks) {
this.websocketSubject.next({'refresh-blocks': true});
}
}
}

View File

@@ -6,8 +6,11 @@
</button>
</ng-template>
<ng-template [ngIf]="!hideUnconfirmed && !confirmations && replaced">
<button type="button" class="btn btn-sm btn-danger {{buttonClass}}" i18n="transaction.unconfirmed|Transaction unconfirmed state">Replaced</button>
<button type="button" class="btn btn-sm btn-warning {{buttonClass}}" i18n="transaction.replaced|Transaction replaced state">Replaced</button>
</ng-template>
<ng-template [ngIf]="!hideUnconfirmed && !confirmations && !replaced">
<ng-template [ngIf]="!hideUnconfirmed && !confirmations && !replaced && removed">
<button type="button" class="btn btn-sm btn-warning {{buttonClass}}" i18n="transaction.audit.removed|Transaction removed state">Removed</button>
</ng-template>
<ng-template [ngIf]="!hideUnconfirmed && !confirmations && !replaced && !removed">
<button type="button" class="btn btn-sm btn-danger {{buttonClass}}" i18n="transaction.unconfirmed|Transaction unconfirmed state">Unconfirmed</button>
</ng-template>

View File

@@ -11,6 +11,7 @@ export class ConfirmationsComponent implements OnChanges {
@Input() chainTip: number;
@Input() height: number;
@Input() replaced: boolean = false;
@Input() removed: boolean = false;
@Input() hideUnconfirmed: boolean = false;
@Input() buttonClass: string = '';

View File

@@ -67,7 +67,15 @@
<div class="col-sm-12">
<a href="https://github.com/mempool" target="_blank"><svg fill="#fff" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub</title><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg></a>
<a href="https://twitter.com/mempool" target="_blank"><svg fill="#fff" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Twitter</title><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/></svg></a>
<a href="nostr:npub18d4r6wanxkyrdfjdrjqzj2ukua5cas669ew2g5w7lf4a8te7awzqey6lt3" target="_blank"><svg fill="#fff" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 875 875"><path d="M684.72 485.57c.22 12.59-11.93 51.47-38.67 81.3-26.74 29.83-56.02 20.85-58.42 20.16s-3.09-4.46-7.89-3.77-9.6 6.17-18.86 7.2-17.49 1.71-26.06-1.37c-4.46.69-5.14.71-7.2 2.24s-17.83 10.79-21.6 11.47c0 7.2-1.37 44.57 0 55.89s3.77 25.71 7.54 36 2.74 10.63 7.54 9.94 13.37.34 15.77 4.11c2.4 3.77 1.37 6.51 5.49 8.23s60.69 17.14 99.43 19.2c26.74.69 42.86 2.74 52.12 19.54 1.37 7.89 7.54 13.03 11.31 14.06s8.23 2.06 12 5.83 1.03 8.23 5.49 11.66c4.46 3.43 14.74 8.57 25.37 13.71 10.63 5.14 15.09 13.37 15.77 16.11s1.71 10.97 1.71 10.97-8.91 0-10.97-2.06-2.74-5.83-2.74-5.83-6.17 1.03-7.54 3.43.69 2.74-7.89.69-11.66-3.77-18.17-8.57c-6.51-4.8-16.46-17.14-25.03-16.8 4.11 8.23 5.83 8.23 10.63 10.97s8.23 5.83 8.23 5.83l-7.2 4.46s-4.46 2.06-14.74-.69-11.66-4.46-12.69-10.63 0-9.26-2.74-14.4-4.11-15.77-22.29-21.26c-18.17-5.49-66.52-21.26-100.12-24.69s-22.63-2.74-28.11-1.37-15.77 4.46-26.4-1.37c-10.63-5.83-16.8-13.71-17.49-20.23s-1.71-10.97 0-19.2 3.43-19.89 1.71-26.74-14.06-55.89-19.89-64.12c-13.03 1.03-50.74-.69-50.74-.69s-2.4-.69-17.49 5.83-36.48 13.76-46.77 19.93-14.4 9.7-16.12 13.13c.12 3-1.23 7.72-2.79 9.06s-12.48 2.42-12.48 2.42-5.85 5.86-8.25 9.97c-6.86 9.6-55.2 125.14-66.52 149.83-13.54 32.57-9.77 27.43-37.71 27.43s-8.06.3-8.06.3-12.34 5.88-16.8 5.88-18.86-2.4-26.4 0-16.46 9.26-23.31 10.29-4.95-1.34-8.38-3.74c-4-.21-14.27-.12-14.27-.12s1.74-6.51 7.91-10.88c8.23-5.83 25.37-16.11 34.63-21.26s17.49-7.89 23.31-9.26 18.51-6.17 30.51-9.94 19.54-8.23 29.83-31.54 50.4-111.43 51.43-116.23c.63-2.96 3.73-6.48 4.8-15.09.66-5.35-2.49-13.04 1.71-22.63 10.97-25.03 21.6-20.23 26.4-20.23s17.14.34 26.4-1.37 15.43-2.74 24.69-7.89 11.31-8.91 11.31-8.91l-19.89-3.43s-18.51.69-25.03-4.46-15.43-15.77-15.43-15.77l-7.54-7.2 1.03 8.57s-5.14-8.91-6.51-10.29-8.57-6.51-11.31-11.31-7.54-25.03-7.54-25.03l-6.17 13.03-1.71-18.86-5.14 7.2-2.74-16.11-4.8 8.23-3.43-14.4-5.83 4.46-2.4-10.29-5.83-3.43s-14.06-9.26-16.46-9.6-4.46 3.43-4.46 3.43l1.37 12-12.2-6.27-7-11.9s2.36 4.01-9.62 7.53c-20.55 0-21.89-2.28-24.93-3.94-1.31-6.56-5.57-10.11-5.57-10.11h-20.57l-.34-6.86-7.89 3.09.69-10.29h-14.06l1.03-11.31h-8.91s3.09-9.26 25.71-22.97 25.03-16.46 46.29-17.14c21.26-.69 32.91 2.74 46.29 8.23s38.74 13.71 43.89 17.49c11.31-9.94 28.46-19.89 34.29-19.89 1.03-2.4 6.19-12.33 17.96-17.6 35.31-15.81 108.13-34 131.53-35.54 31.2-2.06 7.89-1.37 39.09 2.06 31.2 3.43 54.17 7.54 69.6 12.69 12.58 4.19 25.03 9.6 34.29 2.06 4.33-1.81 11.81-1.34 17.83-5.14 30.69-25.09 34.72-32.35 43.63-41.95s20.14-24.91 22.54-45.14 4.46-58.29-10.63-88.12-28.8-45.26-34.63-69.26c-5.83-24-8.23-61.03-6.17-73.03 2.06-12 5.14-22.29 6.86-30.51s9.94-14.74 19.89-16.46c9.94-1.71 17.83 1.37 22.29 4.8 4.46 3.43 11.65 6.28 13.37 10.29.34 1.71-1.37 6.51 8.23 8.23 9.6 1.71 16.05 4.16 16.05 4.16s15.64 4.29 3.11 7.73c-12.69 2.06-20.52-.71-24.29 1.69s-7.21 10.08-9.61 11.1-7.2.34-12 4.11-9.6 6.86-12.69 14.4-5.49 15.77-3.43 26.74 8.57 31.54 14.4 43.2c5.83 11.66 20.23 40.8 24.34 47.66s15.77 29.49 16.8 53.83 1.03 44.23 0 54.86-10.84 51.65-35.53 85.94c-8.16 14.14-23.21 31.9-24.67 35.03-1.45 3.13-3.02 4.88-1.61 7.65 4.62 9.05 12.87 22.13 14.71 29.22 2.29 6.64 6.99 16.13 7.22 28.72Z" style="stroke:#000;stroke-miterlimit:10;stroke-width:6px"/></svg></a>
<a href="nostr:npub18d4r6wanxkyrdfjdrjqzj2ukua5cas669ew2g5w7lf4a8te7awzqey6lt3" target="_blank">
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 36" height="20" width="auto">
<path d="M10.1763 4.02459C11.0916 4.0629 11.8843 4.65378 12.4402 5.55882C12.8446 5.12801 13.3938 4.83598 14.1209 4.73381C14.1711 4.727 14.2784 4.72444 14.3661 4.72359C12.871 3.08634 10.7263 2.05359 8.3356 2.05359C7.89798 2.05359 7.47143 2.09786 7.05254 2.16427C6.96569 2.16853 6.85586 2.18385 6.71197 2.21621C6.70601 2.21748 6.70005 2.21855 6.6941 2.21961C6.68814 2.22068 6.68217 2.22174 6.67622 2.22302C6.67134 2.22406 6.66747 2.22468 6.66371 2.22528C6.65828 2.22614 6.65311 2.22696 6.64557 2.22898C4.99469 2.63339 3.75334 2.30475 3.04753 1.34692C2.97346 1.2456 2.67036 0.791806 2.5767 0C2.05649 0.537236 1.71678 1.4261 2.00881 2.3635C2.2421 3.11018 2.66865 3.51715 3.13863 3.74703C2.4243 3.79045 1.82832 3.65678 1.23233 3.20639C0.880705 2.93905 0.665298 2.65127 0.299194 1.89352C-0.0558415 2.41033 -0.0243404 3.00376 0.0412177 3.31622C0.128912 3.72234 0.342616 4.18295 0.612512 4.44518C1.03311 4.85726 1.62824 4.94326 2.10672 4.9407C1.40602 5.76486 0.862823 6.7278 0.531627 7.7861C2.08031 7.4366 4.04739 7.30373 5.49059 7.20625C5.80877 7.18475 6.10149 7.16498 6.35864 7.14499C6.80393 5.32639 8.0623 3.93604 10.1763 4.02459Z" fill="#fff"/>
<path d="M14.3867 11.1813C15.2124 11.1813 15.8817 10.0168 15.8817 8.58029C15.8817 7.14377 15.2124 5.97925 14.3867 5.97925C13.561 5.97925 12.8916 7.14377 12.8916 8.58029C12.8916 10.0168 13.561 11.1813 14.3867 11.1813Z" fill="#fff"/>
<path d="M10.177 11.9852C11.2373 11.9852 12.0969 10.5165 12.0969 8.70478C12.0969 6.89303 11.2373 5.42432 10.177 5.42432C9.11665 5.42432 8.25708 6.89303 8.25708 8.70478C8.25708 10.5165 9.11665 11.9852 10.177 11.9852Z" fill="#fff"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.97102 35.7932V28.701C6.97102 28.701 6.81437 24.6849 8.33413 21.6863C8.86711 20.6348 10.0863 19.1908 10.9956 18.4032C11.4843 17.9792 11.9654 17.6183 12.4422 17.2871C12.6848 17.1457 12.9181 16.9925 13.1437 16.8273C17.1224 14.385 20.639 14.8946 23.5546 15.3171L23.6535 15.3314C23.6535 15.3314 24.355 13.2522 21.4764 12.1837C19.9132 11.6031 18.0614 11.1416 16.5221 10.813C16.3016 11.2148 16.0206 11.552 15.6843 11.8117C15.4493 11.993 14.855 12.4494 13.7755 12.3208C13.2331 12.2561 12.8151 12.0339 12.4839 11.7121C11.9254 12.6375 11.1148 13.2471 10.174 13.3263C7.57382 13.5468 6.26606 11.3715 6.18092 8.95776C4.23461 9.14848 1.57567 10.3456 0.158935 11.0446V33.6816C2.25987 34.7341 4.55274 35.4602 6.97102 35.7932Z" fill="#fff"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.158935 33.6816C2.25987 34.7341 4.55274 35.4602 6.97102 35.7932V28.701C6.97102 28.701 6.81437 24.6849 8.33413 21.6863C8.86711 20.6348 10.0863 19.1908 10.9956 18.4032C11.4843 17.9792 11.9654 17.6183 12.4422 17.2871C12.6848 17.1457 12.9181 16.9925 13.1437 16.8273C17.1224 14.385 20.639 14.8946 23.5546 15.3171L23.6535 15.3314C23.6535 15.3314 24.355 13.2522 21.4764 12.1837C19.9132 11.6031 18.0614 11.1416 16.5221 10.813C16.3016 11.2148 16.0206 11.552 15.6843 11.8117C15.4493 11.993 14.855 12.4494 13.7755 12.3208C13.2331 12.2561 12.8151 12.0339 12.4839 11.7121C11.9254 12.6375 11.1148 13.2471 10.174 13.3263C7.57382 13.5468 6.26606 11.3715 6.18092 8.95776C4.23461 9.14848 1.57567 10.3456 0.158935 11.0446V33.6816ZM10.1763 4.02459C11.0916 4.0629 11.8843 4.65378 12.4402 5.55882C12.8446 5.12801 13.3938 4.83598 14.1209 4.73381C14.1711 4.727 14.2784 4.72444 14.3661 4.72359C12.871 3.08634 10.7263 2.05359 8.3356 2.05359C7.89798 2.05359 7.47143 2.09786 7.05254 2.16427C6.96569 2.16853 6.85586 2.18385 6.71197 2.21621C6.70601 2.21748 6.70005 2.21855 6.6941 2.21961C6.68814 2.22068 6.68217 2.22174 6.67622 2.22302C6.67134 2.22406 6.66747 2.22468 6.66371 2.22528C6.65828 2.22614 6.65311 2.22696 6.64557 2.22898C4.99469 2.63339 3.75334 2.30475 3.04753 1.34692C2.97346 1.2456 2.67036 0.791806 2.5767 0C2.05649 0.537236 1.71678 1.4261 2.00881 2.3635C2.2421 3.11018 2.66865 3.51715 3.13863 3.74703C2.4243 3.79045 1.82832 3.65678 1.23233 3.20639C0.880705 2.93905 0.665298 2.65127 0.299194 1.89352C-0.0558415 2.41033 -0.0243404 3.00376 0.0412177 3.31622C0.128912 3.72234 0.342616 4.18295 0.612512 4.44518C1.03311 4.85726 1.62824 4.94326 2.10672 4.9407C1.40602 5.76486 0.862823 6.7278 0.531627 7.7861C2.08031 7.4366 4.04739 7.30373 5.49059 7.20625C5.80877 7.18475 6.10149 7.16498 6.35864 7.14499C6.80393 5.32639 8.0623 3.93604 10.1763 4.02459ZM15.8817 8.58029C15.8817 10.0168 15.2124 11.1813 14.3867 11.1813C13.561 11.1813 12.8916 10.0168 12.8916 8.58029C12.8916 7.14377 13.561 5.97925 14.3867 5.97925C15.2124 5.97925 15.8817 7.14377 15.8817 8.58029ZM12.0969 8.70478C12.0969 10.5165 11.2373 11.9852 10.177 11.9852C9.11665 11.9852 8.25708 10.5165 8.25708 8.70478C8.25708 6.89303 9.11665 5.42432 10.177 5.42432C11.2373 5.42432 12.0969 6.89303 12.0969 8.70478Z" fill="#fff"/>
</svg>
</a>
<a href="https://youtube.com/@mempool" target="_blank"><svg fill="#fff" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>YouTube</title><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg></a>
<a href="https://bitcointv.com/c/mempool/videos" target="_blank"><svg _ngcontent-serverApp-c90="" xmlns="http://www.w3.org/2000/svg" focusable="false" viewBox="0 0 440 440"><path _ngcontent-serverApp-c90="" d="M225.57,2.08l-.69-.45a4.22,4.22,0,0,0-5.72,1.23L182.33,46.09a4,4,0,0,0,.88,5.81l9.38,6.38L173.48,97.49a4.22,4.22,0,0,0,2.45,4.19s3.55.7,4.53-1l41.92-40.56a3.62,3.62,0,0,0-1.51-5.1l-10.55-6.12L227.44,6.79A4.26,4.26,0,0,0,225.57,2.08Z" fill="#fff"></path><path _ngcontent-serverApp-c90="" d="M118.52,401.83c-62.51,0-113.37-51-113.37-113.67V214.68C5.15,152,56,101,118.52,101H342.08a24.82,24.82,0,0,1,24.76,24.83V377a24.81,24.81,0,0,1-24.76,24.82Z"></path><path _ngcontent-serverApp-c90="" d="M342.08,105.18a20.65,20.65,0,0,1,20.61,20.66V377a20.66,20.66,0,0,1-20.61,20.66H118.52C58.3,397.67,9.31,348.55,9.31,288.16V214.68c0-60.38,49-109.5,109.21-109.5H342.08m0-8.34H118.52C53.62,96.84,1,149.6,1,214.68v73.48C1,353.24,53.62,406,118.52,406H342.08A29,29,0,0,0,371,377V125.84a29,29,0,0,0-28.92-29Z" fill="#fff"></path><path _ngcontent-serverApp-c90="" fill="#fff" d="M344.69,346.23A25.84,25.84,0,1,0,335,369.87l-10.22-10.2a11.69,11.69,0,1,1,4.77-5.12l10.31,10.28A25.84,25.84,0,0,0,344.69,346.23Z"></path><path _ngcontent-serverApp-c90="" fill="#fff" d="M315.82,257.61a25.67,25.67,0,0,0-12.53,5.22L315,274.49a9.58,9.58,0,0,1,2.11-.73A9.72,9.72,0,1,1,309.4,283a9.4,9.4,0,0,1,.75-3.41L298.4,267.84a25.77,25.77,0,1,0,17.42-10.23Z"></path><path _ngcontent-serverApp-c90="" fill="#fff" d="M313,214a7.76,7.76,0,1,1,1.41,10.91,7.62,7.62,0,0,1-2.19-2.69l-18.67-.14a25.94,25.94,0,1,0,.05-7l18.64.14A7.4,7.4,0,0,1,313,214Z"></path><path _ngcontent-serverApp-c90="" fill="#fff" d="M341.2,144.08h-6.32c-1.67,0-3.61,1.87-3.61,4.29s1.94,4.29,3.61,4.29h6.32c1.67,0,3.61-1.87,3.61-4.29S342.87,144.08,341.2,144.08Z"></path><path _ngcontent-serverApp-c90="" fill="#fff" d="M301.75,144.08h-6.44c-1.67,0-3.61,1.87-3.61,4.29s1.94,4.29,3.61,4.29h6.44c1.67,0,3.61-1.87,3.61-4.29S303.42,144.08,301.75,144.08Z"></path><path _ngcontent-serverApp-c90="" fill="#fff" d="M321.77,144.08h-7c-1.67,0-3.62,1.87-3.62,4.29s1.95,4.29,3.62,4.29h7c1.67,0,3.62-1.87,3.62-4.29S323.44,144.08,321.77,144.08Z"></path><ellipse _ngcontent-serverApp-c90="" fill="#fff" cx="295.97" cy="127.61" rx="4.27" ry="4.29"></ellipse><path _ngcontent-serverApp-c90="" fill="#fff" d="M340.54,131.9a4.29,4.29,0,1,0-4.27-4.29A4.28,4.28,0,0,0,340.54,131.9Z"></path><path _ngcontent-serverApp-c90="" fill="#fff" d="M318.26,131.9a4.29,4.29,0,1,0-4.27-4.29A4.29,4.29,0,0,0,318.26,131.9Z"></path><ellipse _ngcontent-serverApp-c90="" fill="#fff" cx="295.97" cy="169.13" rx="4.27" ry="4.29"></ellipse><path _ngcontent-serverApp-c90="" fill="#fff" d="M340.54,164.84a4.3,4.3,0,1,0,4.27,4.29A4.29,4.29,0,0,0,340.54,164.84Z"></path><path _ngcontent-serverApp-c90="" fill="#fff" d="M318.26,164.84a4.3,4.3,0,1,0,4.28,4.29A4.29,4.29,0,0,0,318.26,164.84Z"></path><path _ngcontent-serverApp-c90="" d="M108.62,256.87c8.36-1,7.68-7.76,3.14-17-3.64-7.4-9.74-16.39-15.75-25.36-14.23-21.23-27.69-42.23-5.35-41.07,19.55,1,42.9,18.63,68.22,36.74,31.1,22.24,65.16,45.21,98.81,39.11a151.19,151.19,0,0,1,20-2.37V221a92,92,0,0,0-91.91-92.16H124.33A92,92,0,0,0,32.42,221v17.59c17.71,3.81,31,9.94,43.8,14.15C86.6,256.16,96.69,258.31,108.62,256.87Z"></path><path _ngcontent-serverApp-c90="" d="M273.37,310.79c-35-15.26-76.67-32.1-104-23.59-3.15,1-5,2.3-6,3.85-3.35,5.31,4.67,13.57,14.89,22.17,7.17,6,15.36,12.21,21.44,17.64,11.47,10.26,15.35,17.84-9.89,16.62-29.75-1.44-49.18-13.75-71.18-24l-.29-.14a165.84,165.84,0,0,0-22.93-8.91c-15.74-4.67-34.22-6.79-58.51-3.28A91.93,91.93,0,0,0,124.33,375h61.45A92,92,0,0,0,273.37,310.79Z"></path><path _ngcontent-serverApp-c90="" fill="#fff" d="M257.69,249.31C224,255.41,190,232.44,158.88,210.2c-25.32-18.11-48.67-35.72-68.22-36.74C68.32,172.3,81.78,193.3,96,214.53c6,9,12.11,18,15.75,25.36,4.54,9.22,5.22,16-3.14,17-11.93,1.44-22-.71-32.4-4.13-12.8-4.21-26.09-10.34-43.8-14.15v44.26c0,1.26.14,2.48.19,3.72a91.8,91.8,0,0,0,2.9,19.62c.43,1.67.84,3.34,1.37,5,24.29-3.51,42.77-1.39,58.51,3.28a165.84,165.84,0,0,1,22.93,8.91c.39-.12.76-.26,1.14-.39l-.85.53c22,10.25,41.43,22.56,71.18,24,25.24,1.22,21.36-6.36,9.89-16.62-6.08-5.43-14.27-11.61-21.44-17.64-10.22-8.6-18.24-16.86-14.89-22.17,1-1.55,2.87-2.87,6-3.85,27.33-8.51,69,8.33,104,23.59.32-1,.56-2.05.84-3.07a92.33,92.33,0,0,0,3.48-24.87V246.94A151.19,151.19,0,0,0,257.69,249.31Z"></path><path _ngcontent-serverApp-c90="" fill="#fff" d="M192,137a78,78,0,0,1,77.78,78v73.91a78,78,0,0,1-77.78,78H118.51a78,78,0,0,1-77.78-78V215a78,78,0,0,1,77.78-78H192m0-8.33H118.51A86.21,86.21,0,0,0,32.42,215v73.91a86.21,86.21,0,0,0,86.09,86.33H192a86.21,86.21,0,0,0,86.09-86.33V215A86.21,86.21,0,0,0,192,128.64Z"></path></svg></a>
<a href="https://mempool.chat" target="_blank"><svg fill="#fff" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Matrix</title><path d="M.632.55v22.9H2.28V24H0V0h2.28v.55zm7.043 7.26v1.157h.033c.309-.443.683-.784 1.117-1.024.433-.245.936-.365 1.5-.365.54 0 1.033.107 1.481.314.448.208.785.582 1.02 1.108.254-.374.6-.706 1.034-.992.434-.287.95-.43 1.546-.43.453 0 .872.056 1.26.167.388.11.716.286.993.53.276.245.489.559.646.951.152.392.23.863.23 1.417v5.728h-2.349V11.52c0-.286-.01-.559-.032-.812a1.755 1.755 0 0 0-.18-.66 1.106 1.106 0 0 0-.438-.448c-.194-.11-.457-.166-.785-.166-.332 0-.6.064-.803.189a1.38 1.38 0 0 0-.48.499 1.946 1.946 0 0 0-.231.696 5.56 5.56 0 0 0-.06.785v4.768h-2.35v-4.8c0-.254-.004-.503-.018-.752a2.074 2.074 0 0 0-.143-.688 1.052 1.052 0 0 0-.415-.503c-.194-.125-.476-.19-.854-.19-.111 0-.259.024-.439.074-.18.051-.36.143-.53.282-.171.138-.319.337-.439.595-.12.259-.18.6-.18 1.02v4.966H5.46V7.81zm15.693 15.64V.55H21.72V0H24v24h-2.28v-.55z"/></svg></a>

View File

@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
import { NgbCollapseModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
import { FontAwesomeModule, FaIconLibrary } from '@fortawesome/angular-fontawesome';
import { faFilter, faAngleDown, faAngleUp, faAngleRight, faAngleLeft, faBolt, faChartArea, faCogs, faCubes, faHammer, faDatabase, faExchangeAlt, faInfoCircle,
faLink, faList, faSearch, faCaretUp, faCaretDown, faTachometerAlt, faThList, faTint, faTv, faAngleDoubleDown, faSortUp, faAngleDoubleUp, faChevronDown,
faLink, faList, faSearch, faCaretUp, faCaretDown, faTachometerAlt, faThList, faTint, faTv, faClock, faAngleDoubleDown, faSortUp, faAngleDoubleUp, faChevronDown,
faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload, faQrcode, faArrowRightArrowLeft, faArrowsRotate, faCircleLeft } from '@fortawesome/free-solid-svg-icons';
import { InfiniteScrollModule } from 'ngx-infinite-scroll';
import { MasterPageComponent } from '../components/master-page/master-page.component';
@@ -95,8 +95,6 @@ import { MempoolBlockOverviewComponent } from '../components/mempool-block-overv
import { ClockchainComponent } from '../components/clockchain/clockchain.component';
import { ClockFaceComponent } from '../components/clock-face/clock-face.component';
import { ClockComponent } from '../components/clock/clock.component';
import { ClockMinedComponent } from '../components/clock/clock-mined.component';
import { ClockMempoolComponent } from '../components/clock/clock-mempool.component';
@NgModule({
declarations: [
@@ -185,8 +183,6 @@ import { ClockMempoolComponent } from '../components/clock/clock-mempool.compone
MempoolBlockOverviewComponent,
ClockchainComponent,
ClockComponent,
ClockMinedComponent,
ClockMempoolComponent,
ClockFaceComponent,
],
imports: [
@@ -300,8 +296,6 @@ import { ClockMempoolComponent } from '../components/clock/clock-mempool.compone
MempoolBlockOverviewComponent,
ClockchainComponent,
ClockComponent,
ClockMinedComponent,
ClockMempoolComponent,
ClockFaceComponent,
]
})
@@ -310,6 +304,7 @@ export class SharedModule {
library.addIcons(faInfoCircle);
library.addIcons(faChartArea);
library.addIcons(faTv);
library.addIcons(faClock);
library.addIcons(faTachometerAlt);
library.addIcons(faCubes);
library.addIcons(faHammer);

View File

@@ -500,7 +500,7 @@ html:lang(ru) .card-title {
}
.fee-distribution-chart {
height: 250px;
height: 265px;
}
.fees-wrapper-tooltip-chart {

View File

@@ -1,5 +1,6 @@
var https = require('https');
var fs = require('fs');
var crypto = require('crypto');
const CONFIG_FILE_NAME = 'mempool-frontend-config.json';
let configContent = {};
@@ -37,39 +38,180 @@ function download(filename, url) {
});
}
function downloadMiningPoolLogos() {
const options = {
host: 'api.github.com',
path: '/repos/mempool/mining-pool-logos/contents/',
method: 'GET',
headers: {'user-agent': 'node.js'}
};
https.get(options, (response) => {
let chunks_of_data = [];
response.on('data', (fragments) => {
chunks_of_data.push(fragments);
});
response.on('end', () => {
let response_body = Buffer.concat(chunks_of_data);
try {
const poolLogos = JSON.parse(response_body.toString());
for (const poolLogo of poolLogos) {
download(`${PATH}/mining-pools/${poolLogo.name}`, poolLogo.download_url);
}
} catch (e) {
console.error(`Unable to download mining pool logos. Trying again at next restart. Reason: ${e instanceof Error ? e.message : e}`);
}
});
response.on('error', (error) => {
throw new Error(error);
});
})
function getLocalHash(filePath) {
const size = fs.statSync(filePath);
const buffer = fs.readFileSync(filePath);
const bufferWithHeader = Buffer.concat([Buffer.from('blob '), Buffer.from(`${size.size}`), Buffer.from('\0'), buffer]);
return crypto.createHash('sha1').update(bufferWithHeader).digest('hex');
}
function downloadMiningPoolLogos$() {
return new Promise((resolve, reject) => {
console.log('Checking if mining pool logos needs downloading or updating...');
const options = {
host: 'api.github.com',
path: '/repos/mempool/mining-pool-logos/contents/',
method: 'GET',
headers: {'user-agent': 'node.js'}
};
https.get(options, (response) => {
const chunks_of_data = [];
response.on('data', (fragments) => {
chunks_of_data.push(fragments);
});
response.on('end', () => {
const response_body = Buffer.concat(chunks_of_data);
try {
const poolLogos = JSON.parse(response_body.toString());
if (poolLogos.message) {
reject(poolLogos.message);
}
let downloadedCount = 0;
for (const poolLogo of poolLogos) {
const filePath = `${PATH}/mining-pools/${poolLogo.name}`;
if (fs.existsSync(filePath)) {
const localHash = getLocalHash(filePath);
if (localHash !== poolLogo.sha) {
console.log(`${poolLogo.name} is different on the remote, downloading...`);
download(filePath, poolLogo.download_url);
downloadedCount++;
}
} else {
console.log(`${poolLogo.name} is missing, downloading...`);
download(filePath, poolLogo.download_url);
downloadedCount++;
}
}
console.log(`Downloaded ${downloadedCount} and skipped ${poolLogos.length - downloadedCount} existing mining pool logos`);
resolve();
} catch (e) {
reject(`Unable to download mining pool logos. Trying again at next restart. Reason: ${e instanceof Error ? e.message : e}`);
}
});
response.on('error', (error) => {
reject(error);
});
});
});
}
function downloadPromoVideoSubtiles$() {
return new Promise((resolve, reject) => {
console.log('Checking if promo video subtitles needs downloading or updating...');
const options = {
host: 'api.github.com',
path: '/repos/mempool/mempool-promo/contents/subtitles',
method: 'GET',
headers: {'user-agent': 'node.js'}
};
https.get(options, (response) => {
const chunks_of_data = [];
response.on('data', (fragments) => {
chunks_of_data.push(fragments);
});
response.on('end', () => {
const response_body = Buffer.concat(chunks_of_data);
try {
const videoLanguages = JSON.parse(response_body.toString());
if (videoLanguages.message) {
reject(videoLanguages.message);
}
let downloadedCount = 0;
for (const language of videoLanguages) {
const filePath = `${PATH}/promo-video/${language.name}`;
if (fs.existsSync(filePath)) {
const localHash = getLocalHash(filePath);
if (localHash !== language.sha) {
console.log(`${language.name} is different on the remote, updating`);
download(filePath, language.download_url);
downloadedCount++;
}
} else {
console.log(`${language.name} is missing, downloading`);
download(filePath, language.download_url);
downloadedCount++;
}
}
console.log(`Downloaded ${downloadedCount} and skipped ${videoLanguages.length - downloadedCount} existing video subtitles`);
resolve();
} catch (e) {
reject(`Unable to download video subtitles. Trying again at next restart. Reason: ${e instanceof Error ? e.message : e}`);
}
});
response.on('error', (error) => {
reject(error);
});
});
});
}
function downloadPromoVideo$() {
return new Promise((resolve, reject) => {
console.log('Checking if promo video needs downloading or updating...');
const options = {
host: 'api.github.com',
path: '/repos/mempool/mempool-promo/contents',
method: 'GET',
headers: {'user-agent': 'node.js'}
};
https.get(options, (response) => {
const chunks_of_data = [];
response.on('data', (fragments) => {
chunks_of_data.push(fragments);
});
response.on('end', () => {
const response_body = Buffer.concat(chunks_of_data);
try {
const contents = JSON.parse(response_body.toString());
if (contents.message) {
reject(contents.message);
}
for (const item of contents) {
if (item.name !== 'promo.mp4') {
continue;
}
const filePath = `${PATH}/promo-video/mempool-promo.mp4`;
if (fs.existsSync(filePath)) {
const localHash = getLocalHash(filePath);
if (localHash !== item.sha) {
console.log(`mempool-promo.mp4 is different on the remote, updating`);
download(filePath, item.download_url);
console.log('mempool-promo.mp4 downloaded.');
} else {
console.log(`mempool-promo.mp4 is already up to date. Skipping.`);
}
} else {
console.log(`mempool-promo.mp4 is missing, downloading`);
download(filePath, item.download_url);
}
}
resolve();
} catch (e) {
reject(`Unable to download video. Trying again at next restart. Reason: ${e instanceof Error ? e.message : e}`);
}
});
response.on('error', (error) => {
reject(error);
});
});
});
}
let assetsJsonUrl = 'https://raw.githubusercontent.com/mempool/asset_registry_db/master/index.json';
let assetsMinimalJsonUrl = 'https://raw.githubusercontent.com/mempool/asset_registry_db/master/index.minimal.json';
@@ -81,11 +223,6 @@ if (configContent.BASE_MODULE && configContent.BASE_MODULE === 'liquid') {
const testnetAssetsJsonUrl = 'https://raw.githubusercontent.com/Blockstream/asset_registry_testnet_db/master/index.json';
const testnetAssetsMinimalJsonUrl = 'https://raw.githubusercontent.com/Blockstream/asset_registry_testnet_db/master/index.minimal.json';
const promoPrefix = PATH + 'promo-video/';
const promoVideoFile = promoPrefix + 'mempool-promo.mp4';
const promoVideoUrl = 'https://raw.githubusercontent.com/mempool/mempool-promo/master/promo.mp4';
const promoVideoLanguages = ['en','sv','ja','zh','cs','fi','fr','de','it','lt','nb','fa','pl','ro','pt'];
console.log('Downloading assets');
download(PATH + 'assets.json', assetsJsonUrl);
console.log('Downloading assets minimal');
@@ -94,13 +231,10 @@ console.log('Downloading testnet assets');
download(PATH + 'assets-testnet.json', testnetAssetsJsonUrl);
console.log('Downloading testnet assets minimal');
download(PATH + 'assets-testnet.minimal.json', testnetAssetsMinimalJsonUrl);
if (!fs.existsSync(promoVideoFile)) {
console.log('Downloading promo video');
download(promoVideoFile, promoVideoUrl);
}
console.log('Downloading promo video subtitles');
for( const l of promoVideoLanguages ) {
download(promoPrefix + l + ".vtt", "https://raw.githubusercontent.com/mempool/mempool-promo/master/subtitles/" + l + ".vtt");
}
console.log('Downloading mining pool logos');
downloadMiningPoolLogos();
downloadMiningPoolLogos$()
.then(() => downloadPromoVideoSubtiles$())
.then(() => downloadPromoVideo$())
.catch((error) => {
throw new Error(error);
});

View File

@@ -361,10 +361,10 @@ BITCOIN_ELECTRS_REPO_NAME=electrs
BITCOIN_ELECTRS_REPO_BRANCH=mempool
BITCOIN_ELECTRS_LATEST_RELEASE=mempool
ELEMENTS_ELECTRS_REPO_URL=https://github.com/blockstream/electrs
ELEMENTS_ELECTRS_REPO_URL=https://github.com/mempool/electrs
ELEMENTS_ELECTRS_REPO_NAME=electrs
ELEMENTS_ELECTRS_REPO_BRANCH=new-index
ELEMENTS_ELECTRS_LATEST_RELEASE=new-index
ELEMENTS_ELECTRS_REPO_BRANCH=mempool
ELEMENTS_ELECTRS_LATEST_RELEASE=mempool
LIQUID_ASSET_REGISTRY_DB_URL=https://github.com/blockstream/asset_registry_db
LIQUID_ASSET_REGISTRY_DB_NAME=asset_registry_db
@@ -1239,7 +1239,7 @@ if [ "${BITCOIN_ELECTRS_INSTALL}" = ON ];then
case $OS in
FreeBSD)
echo "[*] Patching Bitcoin Electrs code for FreeBSD"
osSudo "${BITCOIN_USER}" sh -c "cd \"${BITCOIN_HOME}/.cargo/registry/src/github.com-1ecc6299db9ec823/sysconf-0.3.4\" && patch -p1 < \"${MEMPOOL_HOME}/${MEMPOOL_REPO_NAME}/production/freebsd/sysconf.patch\""
osSudo "${BITCOIN_USER}" sh -c "cd \"${BITCOIN_HOME}/.cargo/registry/src/index.crates.io-6f17d22bba15001f/sysconf-0.3.4\" && patch -p1 < \"${MEMPOOL_HOME}/${MEMPOOL_REPO_NAME}/production/freebsd/sysconf.patch\""
osSudo "${BITCOIN_USER}" sh -c "cd \"${BITCOIN_ELECTRS_HOME}/src/new_index/\" && sed -i.bak -e s/Snappy/None/ db.rs && rm db.rs.bak"
osSudo "${BITCOIN_USER}" sh -c "cd \"${BITCOIN_ELECTRS_HOME}/src/bin/\" && sed -i.bak -e 's/from_secs(5)/from_secs(1)/' electrs.rs && rm electrs.rs.bak"
;;
@@ -1289,7 +1289,7 @@ if [ "${ELEMENTS_ELECTRS_INSTALL}" = ON ];then
case $OS in
FreeBSD)
echo "[*] Patching Liquid Electrs code for FreeBSD"
osSudo "${ELEMENTS_USER}" sh -c "cd \"${ELEMENTS_HOME}/.cargo/registry/src/github.com-1ecc6299db9ec823/sysconf-0.3.4\" && patch -p1 < \"${MEMPOOL_HOME}/${MEMPOOL_REPO_NAME}/production/freebsd/sysconf.patch\""
osSudo "${ELEMENTS_USER}" sh -c "cd \"${ELEMENTS_HOME}/.cargo/registry/src/index.crates.io-6f17d22bba15001f/sysconf-0.3.4\" && patch -p1 < \"${MEMPOOL_HOME}/${MEMPOOL_REPO_NAME}/production/freebsd/sysconf.patch\""
;;
Debian)
;;

View File

@@ -21,6 +21,7 @@ do for url in / \
'/api/v1/statistics/2y' \
'/api/v1/statistics/3y' \
'/api/v1/statistics/4y' \
'/api/v1/statistics/all' \
'/api/v1/mining/pools/24h' \
'/api/v1/mining/pools/3d' \
'/api/v1/mining/pools/1w' \

View File

@@ -97,10 +97,10 @@ location @mempool-api-v1-cache-normal {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache api;
proxy_cache_valid 200 10s;
proxy_cache_valid 200 2s;
proxy_redirect off;
expires 10s;
expires 2s;
}
location @mempool-api-v1-cache-disabled {