mirror of
https://github.com/mempool/mempool.git
synced 2025-01-19 05:34:03 +01:00
Merge branch 'master' into simon/load-more-mempool-txs
This commit is contained in:
commit
ede961a34a
@ -125,5 +125,16 @@
|
||||
"LIQUID_ONION": "http://liquidmom47f6s3m53ebfxn47p76a6tlnxib3wp6deux7wuzotdr6cyd.onion/api/v1",
|
||||
"BISQ_URL": "https://bisq.markets/api",
|
||||
"BISQ_ONION": "http://bisqmktse2cabavbr2xjq7xw3h6g5ottemo5rolfcwt6aly6tp5fdryd.onion/api"
|
||||
},
|
||||
"REPLICATION": {
|
||||
"ENABLED": false,
|
||||
"AUDIT": false,
|
||||
"AUDIT_START_HEIGHT": 774000,
|
||||
"SERVERS": [
|
||||
"list",
|
||||
"of",
|
||||
"trusted",
|
||||
"servers"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@ -121,5 +121,11 @@
|
||||
},
|
||||
"CLIGHTNING": {
|
||||
"SOCKET": "__CLIGHTNING_SOCKET__"
|
||||
},
|
||||
"REPLICATION": {
|
||||
"ENABLED": false,
|
||||
"AUDIT": false,
|
||||
"AUDIT_START_HEIGHT": 774000,
|
||||
"SERVERS": []
|
||||
}
|
||||
}
|
||||
|
@ -120,6 +120,13 @@ describe('Mempool Backend Config', () => {
|
||||
GEOLITE2_ASN: '/usr/local/share/GeoIP/GeoLite2-ASN.mmdb',
|
||||
GEOIP2_ISP: '/usr/local/share/GeoIP/GeoIP2-ISP.mmdb'
|
||||
});
|
||||
|
||||
expect(config.REPLICATION).toStrictEqual({
|
||||
ENABLED: false,
|
||||
AUDIT: false,
|
||||
AUDIT_START_HEIGHT: 774000,
|
||||
SERVERS: []
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -1,12 +1,12 @@
|
||||
import config from '../config';
|
||||
import logger from '../logger';
|
||||
import { TransactionExtended, MempoolBlockWithTransactions } from '../mempool.interfaces';
|
||||
import { MempoolTransactionExtended, MempoolBlockWithTransactions } from '../mempool.interfaces';
|
||||
import rbfCache from './rbf-cache';
|
||||
|
||||
const PROPAGATION_MARGIN = 180; // in seconds, time since a transaction is first seen after which it is assumed to have propagated to all miners
|
||||
|
||||
class Audit {
|
||||
auditBlock(transactions: TransactionExtended[], projectedBlocks: MempoolBlockWithTransactions[], mempool: { [txId: string]: TransactionExtended })
|
||||
auditBlock(transactions: MempoolTransactionExtended[], projectedBlocks: MempoolBlockWithTransactions[], mempool: { [txId: string]: MempoolTransactionExtended })
|
||||
: { censored: string[], added: string[], fresh: string[], sigop: string[], fullrbf: string[], score: number, similarity: number } {
|
||||
if (!projectedBlocks?.[0]?.transactionIds || !mempool) {
|
||||
return { censored: [], added: [], fresh: [], sigop: [], fullrbf: [], score: 0, similarity: 1 };
|
||||
@ -14,7 +14,7 @@ class Audit {
|
||||
|
||||
const matches: string[] = []; // present in both mined block and template
|
||||
const added: string[] = []; // present in mined block, not in template
|
||||
const fresh: string[] = []; // missing, but firstSeen within PROPAGATION_MARGIN
|
||||
const fresh: string[] = []; // missing, but firstSeen or lastBoosted within PROPAGATION_MARGIN
|
||||
const fullrbf: string[] = []; // either missing or present, and part of a fullrbf replacement
|
||||
const isCensored = {}; // missing, without excuse
|
||||
const isDisplaced = {};
|
||||
@ -36,10 +36,13 @@ class Audit {
|
||||
// look for transactions that were expected in the template, but missing from the mined block
|
||||
for (const txid of projectedBlocks[0].transactionIds) {
|
||||
if (!inBlock[txid]) {
|
||||
// tx is recent, may have reached the miner too late for inclusion
|
||||
if (rbfCache.isFullRbf(txid)) {
|
||||
fullrbf.push(txid);
|
||||
} else if (mempool[txid]?.firstSeen != null && (now - (mempool[txid]?.firstSeen || 0)) <= PROPAGATION_MARGIN) {
|
||||
// tx is recent, may have reached the miner too late for inclusion
|
||||
fresh.push(txid);
|
||||
} else if (mempool[txid]?.lastBoosted != null && (now - (mempool[txid]?.lastBoosted || 0)) <= PROPAGATION_MARGIN) {
|
||||
// tx was recently cpfp'd, miner may not have the latest effective rate
|
||||
fresh.push(txid);
|
||||
} else {
|
||||
isCensored[txid] = true;
|
||||
|
@ -457,6 +457,7 @@ class MempoolBlocks {
|
||||
};
|
||||
if (matched) {
|
||||
descendants.push(relative);
|
||||
mempoolTx.lastBoosted = Math.max(mempoolTx.lastBoosted || 0, mempool[txid].firstSeen || 0);
|
||||
} else {
|
||||
ancestors.push(relative);
|
||||
}
|
||||
|
@ -236,7 +236,7 @@ class WebsocketHandler {
|
||||
}
|
||||
|
||||
if (parsedMessage.action === 'init') {
|
||||
if (!this.socketData['blocks']?.length || !this.socketData['da']) {
|
||||
if (!this.socketData['blocks']?.length || !this.socketData['da'] || !this.socketData['backendInfo'] || !this.socketData['conversions']) {
|
||||
this.updateSocketData();
|
||||
}
|
||||
if (!this.socketData['blocks']?.length) {
|
||||
@ -419,7 +419,7 @@ class WebsocketHandler {
|
||||
memPool.addToSpendMap(newTransactions);
|
||||
const recommendedFees = feeApi.getRecommendedFee();
|
||||
|
||||
const latestTransactions = newTransactions.slice(0, 6).map((tx) => Common.stripTransaction(tx));
|
||||
const latestTransactions = memPool.getLatestTransactions();
|
||||
|
||||
// update init data
|
||||
const socketDataFields = {
|
||||
|
@ -132,6 +132,12 @@ interface IConfig {
|
||||
GEOLITE2_ASN: string;
|
||||
GEOIP2_ISP: string;
|
||||
},
|
||||
REPLICATION: {
|
||||
ENABLED: boolean;
|
||||
AUDIT: boolean;
|
||||
AUDIT_START_HEIGHT: number;
|
||||
SERVERS: string[];
|
||||
}
|
||||
}
|
||||
|
||||
const defaults: IConfig = {
|
||||
@ -264,6 +270,12 @@ const defaults: IConfig = {
|
||||
'GEOLITE2_ASN': '/usr/local/share/GeoIP/GeoLite2-ASN.mmdb',
|
||||
'GEOIP2_ISP': '/usr/local/share/GeoIP/GeoIP2-ISP.mmdb'
|
||||
},
|
||||
'REPLICATION': {
|
||||
'ENABLED': false,
|
||||
'AUDIT': false,
|
||||
'AUDIT_START_HEIGHT': 774000,
|
||||
'SERVERS': [],
|
||||
}
|
||||
};
|
||||
|
||||
class Config implements IConfig {
|
||||
@ -283,6 +295,7 @@ class Config implements IConfig {
|
||||
PRICE_DATA_SERVER: IConfig['PRICE_DATA_SERVER'];
|
||||
EXTERNAL_DATA_SERVER: IConfig['EXTERNAL_DATA_SERVER'];
|
||||
MAXMIND: IConfig['MAXMIND'];
|
||||
REPLICATION: IConfig['REPLICATION'];
|
||||
|
||||
constructor() {
|
||||
const configs = this.merge(configFromFile, defaults);
|
||||
@ -302,6 +315,7 @@ class Config implements IConfig {
|
||||
this.PRICE_DATA_SERVER = configs.PRICE_DATA_SERVER;
|
||||
this.EXTERNAL_DATA_SERVER = configs.EXTERNAL_DATA_SERVER;
|
||||
this.MAXMIND = configs.MAXMIND;
|
||||
this.REPLICATION = configs.REPLICATION;
|
||||
}
|
||||
|
||||
merge = (...objects: object[]): IConfig => {
|
||||
|
@ -7,6 +7,7 @@ import bitcoinClient from './api/bitcoin/bitcoin-client';
|
||||
import priceUpdater from './tasks/price-updater';
|
||||
import PricesRepository from './repositories/PricesRepository';
|
||||
import config from './config';
|
||||
import auditReplicator from './replication/AuditReplication';
|
||||
|
||||
export interface CoreIndex {
|
||||
name: string;
|
||||
@ -136,6 +137,7 @@ class Indexer {
|
||||
await blocks.$generateBlocksSummariesDatabase();
|
||||
await blocks.$generateCPFPDatabase();
|
||||
await blocks.$generateAuditStats();
|
||||
await auditReplicator.$sync();
|
||||
} catch (e) {
|
||||
this.indexerRunning = false;
|
||||
logger.err(`Indexer failed, trying again in 10 seconds. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
|
@ -100,6 +100,7 @@ export interface MempoolTransactionExtended extends TransactionExtended {
|
||||
adjustedVsize: number;
|
||||
adjustedFeePerVsize: number;
|
||||
inputs?: number[];
|
||||
lastBoosted?: number;
|
||||
}
|
||||
|
||||
export interface AuditTransaction {
|
||||
@ -236,6 +237,15 @@ export interface BlockSummary {
|
||||
transactions: TransactionStripped[];
|
||||
}
|
||||
|
||||
export interface AuditSummary extends BlockAudit {
|
||||
timestamp?: number,
|
||||
size?: number,
|
||||
weight?: number,
|
||||
tx_count?: number,
|
||||
transactions: TransactionStripped[];
|
||||
template?: TransactionStripped[];
|
||||
}
|
||||
|
||||
export interface BlockPrice {
|
||||
height: number;
|
||||
priceId: number;
|
||||
|
134
backend/src/replication/AuditReplication.ts
Normal file
134
backend/src/replication/AuditReplication.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import DB from '../database';
|
||||
import logger from '../logger';
|
||||
import { AuditSummary } from '../mempool.interfaces';
|
||||
import blocksAuditsRepository from '../repositories/BlocksAuditsRepository';
|
||||
import blocksSummariesRepository from '../repositories/BlocksSummariesRepository';
|
||||
import { $sync } from './replicator';
|
||||
import config from '../config';
|
||||
import { Common } from '../api/common';
|
||||
import blocks from '../api/blocks';
|
||||
|
||||
const BATCH_SIZE = 16;
|
||||
|
||||
/**
|
||||
* Syncs missing block template and audit data from trusted servers
|
||||
*/
|
||||
class AuditReplication {
|
||||
inProgress: boolean = false;
|
||||
skip: Set<string> = new Set();
|
||||
|
||||
public async $sync(): Promise<void> {
|
||||
if (!config.REPLICATION.ENABLED || !config.REPLICATION.AUDIT) {
|
||||
// replication not enabled
|
||||
return;
|
||||
}
|
||||
if (this.inProgress) {
|
||||
logger.info(`AuditReplication sync already in progress`, 'Replication');
|
||||
return;
|
||||
}
|
||||
this.inProgress = true;
|
||||
|
||||
const missingAudits = await this.$getMissingAuditBlocks();
|
||||
|
||||
logger.debug(`Fetching missing audit data for ${missingAudits.length} blocks from trusted servers`, 'Replication');
|
||||
|
||||
let totalSynced = 0;
|
||||
let totalMissed = 0;
|
||||
let loggerTimer = Date.now();
|
||||
// process missing audits in batches of
|
||||
for (let i = 0; i < missingAudits.length; i += BATCH_SIZE) {
|
||||
const slice = missingAudits.slice(i, i + BATCH_SIZE);
|
||||
const results = await Promise.all(slice.map(hash => this.$syncAudit(hash)));
|
||||
const synced = results.reduce((total, status) => status ? total + 1 : total, 0);
|
||||
totalSynced += synced;
|
||||
totalMissed += (slice.length - synced);
|
||||
if (Date.now() - loggerTimer > 10000) {
|
||||
loggerTimer = Date.now();
|
||||
logger.info(`Found ${totalSynced} / ${totalSynced + totalMissed} of ${missingAudits.length} missing audits`, 'Replication');
|
||||
}
|
||||
await Common.sleep$(1000);
|
||||
}
|
||||
|
||||
logger.debug(`Fetched ${totalSynced} audits, ${totalMissed} still missing`, 'Replication');
|
||||
|
||||
this.inProgress = false;
|
||||
}
|
||||
|
||||
private async $syncAudit(hash: string): Promise<boolean> {
|
||||
if (this.skip.has(hash)) {
|
||||
// we already know none of our trusted servers have this audit
|
||||
return false;
|
||||
}
|
||||
|
||||
let success = false;
|
||||
// start with a random server so load is uniformly spread
|
||||
const syncResult = await $sync(`/api/v1/block/${hash}/audit-summary`);
|
||||
if (syncResult) {
|
||||
if (syncResult.data?.template?.length) {
|
||||
await this.$saveAuditData(hash, syncResult.data);
|
||||
logger.info(`Imported audit data from ${syncResult.server} for block ${syncResult.data.height} (${hash})`);
|
||||
success = true;
|
||||
}
|
||||
if (!syncResult.data && !syncResult.exists) {
|
||||
this.skip.add(hash);
|
||||
}
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
private async $getMissingAuditBlocks(): Promise<string[]> {
|
||||
try {
|
||||
const startHeight = config.REPLICATION.AUDIT_START_HEIGHT || 0;
|
||||
const [rows]: any[] = await DB.query(`
|
||||
SELECT auditable.hash, auditable.height
|
||||
FROM (
|
||||
SELECT hash, height
|
||||
FROM blocks
|
||||
WHERE height >= ?
|
||||
) AS auditable
|
||||
LEFT JOIN blocks_audits ON auditable.hash = blocks_audits.hash
|
||||
WHERE blocks_audits.hash IS NULL
|
||||
ORDER BY auditable.height DESC
|
||||
`, [startHeight]);
|
||||
return rows.map(row => row.hash);
|
||||
} catch (e: any) {
|
||||
logger.err(`Cannot fetch missing audit blocks from db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private async $saveAuditData(blockHash: string, auditSummary: AuditSummary): Promise<void> {
|
||||
// save audit & template to DB
|
||||
await blocksSummariesRepository.$saveTemplate({
|
||||
height: auditSummary.height,
|
||||
template: {
|
||||
id: blockHash,
|
||||
transactions: auditSummary.template || []
|
||||
}
|
||||
});
|
||||
await blocksAuditsRepository.$saveAudit({
|
||||
hash: blockHash,
|
||||
height: auditSummary.height,
|
||||
time: auditSummary.timestamp || auditSummary.time,
|
||||
missingTxs: auditSummary.missingTxs || [],
|
||||
addedTxs: auditSummary.addedTxs || [],
|
||||
freshTxs: auditSummary.freshTxs || [],
|
||||
sigopTxs: auditSummary.sigopTxs || [],
|
||||
fullrbfTxs: auditSummary.fullrbfTxs || [],
|
||||
matchRate: auditSummary.matchRate,
|
||||
expectedFees: auditSummary.expectedFees,
|
||||
expectedWeight: auditSummary.expectedWeight,
|
||||
});
|
||||
// add missing data to cached blocks
|
||||
const cachedBlock = blocks.getBlocks().find(block => block.id === blockHash);
|
||||
if (cachedBlock) {
|
||||
cachedBlock.extras.matchRate = auditSummary.matchRate;
|
||||
cachedBlock.extras.expectedFees = auditSummary.expectedFees || null;
|
||||
cachedBlock.extras.expectedWeight = auditSummary.expectedWeight || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new AuditReplication();
|
||||
|
70
backend/src/replication/replicator.ts
Normal file
70
backend/src/replication/replicator.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import config from '../config';
|
||||
import backendInfo from '../api/backend-info';
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { SocksProxyAgent } from 'socks-proxy-agent';
|
||||
import * as https from 'https';
|
||||
|
||||
export async function $sync(path): Promise<{ data?: any, exists: boolean, server?: string }> {
|
||||
// start with a random server so load is uniformly spread
|
||||
let allMissing = true;
|
||||
const offset = Math.floor(Math.random() * config.REPLICATION.SERVERS.length);
|
||||
for (let i = 0; i < config.REPLICATION.SERVERS.length; i++) {
|
||||
const server = config.REPLICATION.SERVERS[(i + offset) % config.REPLICATION.SERVERS.length];
|
||||
// don't query ourself
|
||||
if (server === backendInfo.getBackendInfo().hostname) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await query(`https://${server}${path}`);
|
||||
if (result) {
|
||||
return { data: result, exists: true, server };
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.response?.status === 404) {
|
||||
// this server is also missing this data
|
||||
} else {
|
||||
// something else went wrong
|
||||
allMissing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { exists: !allMissing };
|
||||
}
|
||||
|
||||
export async function query(path): Promise<object> {
|
||||
type axiosOptions = {
|
||||
headers: {
|
||||
'User-Agent': string
|
||||
};
|
||||
timeout: number;
|
||||
httpsAgent?: https.Agent;
|
||||
};
|
||||
const axiosOptions: axiosOptions = {
|
||||
headers: {
|
||||
'User-Agent': (config.MEMPOOL.USER_AGENT === 'mempool') ? `mempool/v${backendInfo.getBackendInfo().version}` : `${config.MEMPOOL.USER_AGENT}`
|
||||
},
|
||||
timeout: config.SOCKS5PROXY.ENABLED ? 30000 : 10000
|
||||
};
|
||||
|
||||
if (config.SOCKS5PROXY.ENABLED) {
|
||||
const socksOptions = {
|
||||
agentOptions: {
|
||||
keepAlive: true,
|
||||
},
|
||||
hostname: config.SOCKS5PROXY.HOST,
|
||||
port: config.SOCKS5PROXY.PORT,
|
||||
username: config.SOCKS5PROXY.USERNAME || 'circuit0',
|
||||
password: config.SOCKS5PROXY.PASSWORD,
|
||||
};
|
||||
|
||||
axiosOptions.httpsAgent = new SocksProxyAgent(socksOptions);
|
||||
}
|
||||
|
||||
const data: AxiosResponse = await axios.get(path, axiosOptions);
|
||||
if (data.statusText === 'error' || !data.data) {
|
||||
throw new Error(`${data.status}`);
|
||||
}
|
||||
return data.data;
|
||||
}
|
@ -153,6 +153,7 @@ class PriceUpdater {
|
||||
try {
|
||||
const p = 60 * 60 * 1000; // milliseconds in an hour
|
||||
const nowRounded = new Date(Math.round(new Date().getTime() / p) * p); // https://stackoverflow.com/a/28037042
|
||||
this.latestPrices.time = nowRounded.getTime() / 1000;
|
||||
await PricesRepository.$savePrices(nowRounded.getTime() / 1000, this.latestPrices);
|
||||
} catch (e) {
|
||||
this.lastRun = previousRun + 5 * 60;
|
||||
|
@ -127,5 +127,11 @@
|
||||
"GEOLITE2_CITY": "__MAXMIND_GEOLITE2_CITY__",
|
||||
"GEOLITE2_ASN": "__MAXMIND_GEOLITE2_ASN__",
|
||||
"GEOIP2_ISP": "__MAXMIND_GEOIP2_ISP__"
|
||||
},
|
||||
"REPLICATION": {
|
||||
"ENABLED": __REPLICATION_ENABLED__,
|
||||
"AUDIT": __REPLICATION_AUDIT__,
|
||||
"AUDIT_START_HEIGHT": __REPLICATION_AUDIT_START_HEIGHT__,
|
||||
"SERVERS": __REPLICATION_SERVERS__
|
||||
}
|
||||
}
|
||||
|
@ -130,6 +130,12 @@ __MAXMIND_GEOLITE2_CITY__=${MAXMIND_GEOLITE2_CITY:="/backend/GeoIP/GeoLite2-City
|
||||
__MAXMIND_GEOLITE2_ASN__=${MAXMIND_GEOLITE2_ASN:="/backend/GeoIP/GeoLite2-ASN.mmdb"}
|
||||
__MAXMIND_GEOIP2_ISP__=${MAXMIND_GEOIP2_ISP:=""}
|
||||
|
||||
# REPLICATION
|
||||
__REPLICATION_ENABLED__=${REPLICATION_ENABLED:=true}
|
||||
__REPLICATION_AUDIT__=${REPLICATION_AUDIT:=true}
|
||||
__REPLICATION_AUDIT_START_HEIGHT__=${REPLICATION_AUDIT_START_HEIGHT:=774000}
|
||||
__REPLICATION_SERVERS__=${REPLICATION_SERVERS:=[]}
|
||||
|
||||
|
||||
mkdir -p "${__MEMPOOL_CACHE_DIR__}"
|
||||
|
||||
@ -250,5 +256,10 @@ sed -i "s!__MAXMIND_GEOLITE2_CITY__!${__MAXMIND_GEOLITE2_CITY__}!g" mempool-conf
|
||||
sed -i "s!__MAXMIND_GEOLITE2_ASN__!${__MAXMIND_GEOLITE2_ASN__}!g" mempool-config.json
|
||||
sed -i "s!__MAXMIND_GEOIP2_ISP__!${__MAXMIND_GEOIP2_ISP__}!g" mempool-config.json
|
||||
|
||||
# REPLICATION
|
||||
sed -i "s!__REPLICATION_ENABLED__!${__REPLICATION_ENABLED__}!g" mempool-config.json
|
||||
sed -i "s!__REPLICATION_AUDIT__!${__REPLICATION_AUDIT__}!g" mempool-config.json
|
||||
sed -i "s!__REPLICATION_AUDIT_START_HEIGHT__!${__REPLICATION_AUDIT_START_HEIGHT__}!g" mempool-config.json
|
||||
sed -i "s!__REPLICATION_SERVERS__!${__REPLICATION_SERVERS__}!g" mempool-config.json
|
||||
|
||||
node /backend/package/index.js
|
||||
|
@ -22,6 +22,7 @@ import { AssetsFeaturedComponent } from './components/assets/assets-featured/ass
|
||||
import { AssetsComponent } from './components/assets/assets.component';
|
||||
import { AssetComponent } from './components/asset/asset.component';
|
||||
import { AssetsNavComponent } from './components/assets/assets-nav/assets-nav.component';
|
||||
import { CalculatorComponent } from './components/calculator/calculator.component';
|
||||
|
||||
const browserWindow = window || {};
|
||||
// @ts-ignore
|
||||
@ -278,6 +279,10 @@ let routes: Routes = [
|
||||
path: 'rbf',
|
||||
component: RbfList,
|
||||
},
|
||||
{
|
||||
path: 'tools/calculator',
|
||||
component: CalculatorComponent
|
||||
},
|
||||
{
|
||||
path: 'terms-of-service',
|
||||
component: TermsOfServiceComponent
|
||||
|
@ -38,7 +38,7 @@ export default class TxView implements TransactionStripped {
|
||||
value: number;
|
||||
feerate: number;
|
||||
rate?: number;
|
||||
status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'added' | 'censored' | 'selected' | 'fullrbf';
|
||||
status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'freshcpfp' | 'added' | 'censored' | 'selected' | 'fullrbf';
|
||||
context?: 'projected' | 'actual';
|
||||
scene?: BlockScene;
|
||||
|
||||
@ -210,6 +210,7 @@ export default class TxView implements TransactionStripped {
|
||||
case 'fullrbf':
|
||||
return marginalFeeColors[feeLevelIndex] || marginalFeeColors[mempoolFeeColors.length - 1];
|
||||
case 'fresh':
|
||||
case 'freshcpfp':
|
||||
return auditColors.missing;
|
||||
case 'added':
|
||||
return auditColors.added;
|
||||
|
@ -50,6 +50,7 @@
|
||||
<td *ngSwitchCase="'missing'"><span class="badge badge-warning" i18n="transaction.audit.marginal">Marginal fee rate</span></td>
|
||||
<td *ngSwitchCase="'sigop'"><span class="badge badge-warning" i18n="transaction.audit.sigop">High sigop count</span></td>
|
||||
<td *ngSwitchCase="'fresh'"><span class="badge badge-warning" i18n="transaction.audit.recently-broadcasted">Recently broadcasted</span></td>
|
||||
<td *ngSwitchCase="'freshcpfp'"><span class="badge badge-warning" i18n="transaction.audit.recently-cpfped">Recently CPFP'd</span></td>
|
||||
<td *ngSwitchCase="'added'"><span class="badge badge-warning" i18n="transaction.audit.added">Added</span></td>
|
||||
<td *ngSwitchCase="'selected'"><span class="badge badge-warning" i18n="transaction.audit.marginal">Marginal fee rate</span></td>
|
||||
<td *ngSwitchCase="'fullrbf'"><span class="badge badge-warning" i18n="transaction.audit.fullrbf">Full RBF</span></td>
|
||||
|
@ -370,7 +370,11 @@ export class BlockComponent implements OnInit, OnDestroy {
|
||||
tx.status = 'found';
|
||||
} else {
|
||||
if (isFresh[tx.txid]) {
|
||||
tx.status = 'fresh';
|
||||
if (tx.rate - (tx.fee / tx.vsize) >= 0.1) {
|
||||
tx.status = 'freshcpfp';
|
||||
} else {
|
||||
tx.status = 'fresh';
|
||||
}
|
||||
} else if (isSigop[tx.txid]) {
|
||||
tx.status = 'sigop';
|
||||
} else if (isFullRbf[tx.txid]) {
|
||||
|
@ -0,0 +1,69 @@
|
||||
<div class="container-xl">
|
||||
<div class="text-center">
|
||||
<h2>Calculator</h2>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="price$ | async; else loading">
|
||||
|
||||
<div class="row justify-content-center">
|
||||
|
||||
<form [formGroup]="form">
|
||||
<div class="input-group input-group-lg mb-1">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">{{ currency$ | async }}</span>
|
||||
</div>
|
||||
<input type="text" class="form-control" formControlName="fiat" (input)="transformInput('fiat')" (click)="selectAll($event)">
|
||||
<app-clipboard [button]="true" [text]="form.get('fiat').value" [class]="'btn btn-lg btn-secondary ml-1'"></app-clipboard>
|
||||
</div>
|
||||
|
||||
<div class="input-group input-group-lg mb-1">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">BTC</span>
|
||||
</div>
|
||||
<input type="text" class="form-control" formControlName="bitcoin" (input)="transformInput('bitcoin')" (click)="selectAll($event)">
|
||||
<app-clipboard [button]="true" [text]="form.get('bitcoin').value" [class]="'btn btn-lg btn-secondary ml-1'"></app-clipboard>
|
||||
</div>
|
||||
|
||||
<div class="input-group input-group-lg mb-1">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">sats</span>
|
||||
</div>
|
||||
<input type="text" class="form-control" formControlName="satoshis" (input)="transformInput('satoshis')" (click)="selectAll($event)">
|
||||
<app-clipboard [button]="true" [text]="form.get('satoshis').value" [class]="'btn btn-lg btn-secondary ml-1'"></app-clipboard>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="bitcoin-satoshis-text">
|
||||
₿
|
||||
<span [innerHTML]="form.get('bitcoin').value | bitcoinsatoshis"></span>
|
||||
<span class="sats"> sats</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="fiat-text">
|
||||
<app-fiat [value]="form.get('satoshis').value" digitsInfo="1.0-0"></app-fiat>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row justify-content-center mt-3">
|
||||
<div class="symbol">
|
||||
Fiat price last updated <app-time kind="since" [time]="lastFiatPrice$ | async" [fastRender]="true"></app-time>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</ng-container>
|
||||
|
||||
<ng-template #loading>
|
||||
<div class="text-center">
|
||||
Waiting for price feed...
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
</div>
|
@ -0,0 +1,30 @@
|
||||
.input-group-text {
|
||||
width: 75px;
|
||||
}
|
||||
|
||||
.bitcoin-satoshis-text {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.fiat-text {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.symbol {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.bitcoin-satoshis-text {
|
||||
font-size: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.sats {
|
||||
font-size: 20px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.row {
|
||||
margin: auto;
|
||||
}
|
137
frontend/src/app/components/calculator/calculator.component.ts
Normal file
137
frontend/src/app/components/calculator/calculator.component.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
|
||||
import { FormBuilder, FormGroup } from '@angular/forms';
|
||||
import { combineLatest, Observable } from 'rxjs';
|
||||
import { map, switchMap } from 'rxjs/operators';
|
||||
import { StateService } from '../../services/state.service';
|
||||
import { WebsocketService } from '../../services/websocket.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-calculator',
|
||||
templateUrl: './calculator.component.html',
|
||||
styleUrls: ['./calculator.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class CalculatorComponent implements OnInit {
|
||||
satoshis = 10000;
|
||||
form: FormGroup;
|
||||
|
||||
currency$ = this.stateService.fiatCurrency$;
|
||||
price$: Observable<number>;
|
||||
lastFiatPrice$: Observable<number>;
|
||||
|
||||
constructor(
|
||||
private stateService: StateService,
|
||||
private formBuilder: FormBuilder,
|
||||
private websocketService: WebsocketService,
|
||||
) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.form = this.formBuilder.group({
|
||||
fiat: [0],
|
||||
bitcoin: [0],
|
||||
satoshis: [0],
|
||||
});
|
||||
|
||||
this.lastFiatPrice$ = this.stateService.conversions$.asObservable()
|
||||
.pipe(
|
||||
map((conversions) => conversions.time)
|
||||
);
|
||||
|
||||
let currency;
|
||||
this.price$ = this.currency$.pipe(
|
||||
switchMap((result) => {
|
||||
currency = result;
|
||||
return this.stateService.conversions$.asObservable();
|
||||
}),
|
||||
map((conversions) => {
|
||||
return conversions[currency];
|
||||
})
|
||||
);
|
||||
|
||||
combineLatest([
|
||||
this.price$,
|
||||
this.form.get('fiat').valueChanges
|
||||
]).subscribe(([price, value]) => {
|
||||
const rate = (value / price).toFixed(8);
|
||||
const satsRate = Math.round(value / price * 100_000_000);
|
||||
if (isNaN(value)) {
|
||||
return;
|
||||
}
|
||||
this.form.get('bitcoin').setValue(rate, { emitEvent: false });
|
||||
this.form.get('satoshis').setValue(satsRate, { emitEvent: false } );
|
||||
});
|
||||
|
||||
combineLatest([
|
||||
this.price$,
|
||||
this.form.get('bitcoin').valueChanges
|
||||
]).subscribe(([price, value]) => {
|
||||
const rate = parseFloat((value * price).toFixed(8));
|
||||
if (isNaN(value)) {
|
||||
return;
|
||||
}
|
||||
this.form.get('fiat').setValue(rate, { emitEvent: false } );
|
||||
this.form.get('satoshis').setValue(Math.round(value * 100_000_000), { emitEvent: false } );
|
||||
});
|
||||
|
||||
combineLatest([
|
||||
this.price$,
|
||||
this.form.get('satoshis').valueChanges
|
||||
]).subscribe(([price, value]) => {
|
||||
const rate = parseFloat((value / 100_000_000 * price).toFixed(8));
|
||||
const bitcoinRate = (value / 100_000_000).toFixed(8);
|
||||
if (isNaN(value)) {
|
||||
return;
|
||||
}
|
||||
this.form.get('fiat').setValue(rate, { emitEvent: false } );
|
||||
this.form.get('bitcoin').setValue(bitcoinRate, { emitEvent: false });
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
transformInput(name: string): void {
|
||||
const formControl = this.form.get(name);
|
||||
if (!formControl.value) {
|
||||
return formControl.setValue('', {emitEvent: false});
|
||||
}
|
||||
let value = formControl.value.replace(',', '.').replace(/[^0-9.]/g, '');
|
||||
if (value === '.') {
|
||||
value = '0';
|
||||
}
|
||||
let sanitizedValue = this.removeExtraDots(value);
|
||||
if (name === 'bitcoin' && this.countDecimals(sanitizedValue) > 8) {
|
||||
sanitizedValue = this.toFixedWithoutRounding(sanitizedValue, 8);
|
||||
}
|
||||
if (sanitizedValue === '') {
|
||||
sanitizedValue = '0';
|
||||
}
|
||||
if (name === 'satoshis') {
|
||||
sanitizedValue = parseFloat(sanitizedValue).toFixed(0);
|
||||
}
|
||||
formControl.setValue(sanitizedValue, {emitEvent: true});
|
||||
}
|
||||
|
||||
removeExtraDots(str: string): string {
|
||||
const [beforeDot, afterDot] = str.split('.', 2);
|
||||
if (afterDot === undefined) {
|
||||
return str;
|
||||
}
|
||||
const afterDotReplaced = afterDot.replace(/\./g, '');
|
||||
return `${beforeDot}.${afterDotReplaced}`;
|
||||
}
|
||||
|
||||
countDecimals(numberString: string): number {
|
||||
const decimalPos = numberString.indexOf('.');
|
||||
if (decimalPos === -1) return 0;
|
||||
return numberString.length - decimalPos - 1;
|
||||
}
|
||||
|
||||
toFixedWithoutRounding(numStr: string, fixed: number): string {
|
||||
const re = new RegExp(`^-?\\d+(?:.\\d{0,${(fixed || -1)}})?`);
|
||||
const result = numStr.match(re);
|
||||
return result ? result[0] : numStr;
|
||||
}
|
||||
|
||||
selectAll(event): void {
|
||||
event.target.select();
|
||||
}
|
||||
}
|
@ -173,7 +173,8 @@ export interface TransactionStripped {
|
||||
fee: number;
|
||||
vsize: number;
|
||||
value: number;
|
||||
status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'added' | 'censored' | 'selected' | 'fullrbf';
|
||||
rate?: number; // effective fee rate
|
||||
status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'freshcpfp' | 'added' | 'censored' | 'selected' | 'fullrbf';
|
||||
context?: 'projected' | 'actual';
|
||||
}
|
||||
|
||||
|
@ -89,7 +89,7 @@ export interface TransactionStripped {
|
||||
vsize: number;
|
||||
value: number;
|
||||
rate?: number; // effective fee rate
|
||||
status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'added' | 'censored' | 'selected' | 'fullrbf';
|
||||
status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'freshcpfp' | 'added' | 'censored' | 'selected' | 'fullrbf';
|
||||
context?: 'projected' | 'actual';
|
||||
}
|
||||
|
||||
|
28
frontend/src/app/shared/pipes/bitcoinsatoshis.pipe.ts
Normal file
28
frontend/src/app/shared/pipes/bitcoinsatoshis.pipe.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
|
||||
@Pipe({
|
||||
name: 'bitcoinsatoshis'
|
||||
})
|
||||
export class BitcoinsatoshisPipe implements PipeTransform {
|
||||
|
||||
constructor(private sanitizer: DomSanitizer) { }
|
||||
|
||||
transform(value: string): SafeHtml {
|
||||
const newValue = this.insertSpaces(parseFloat(value || '0').toFixed(8));
|
||||
const position = (newValue || '0').search(/[1-9]/);
|
||||
|
||||
const firstPart = newValue.slice(0, position);
|
||||
const secondPart = newValue.slice(position);
|
||||
|
||||
return this.sanitizer.bypassSecurityTrustHtml(
|
||||
`<span class="text-secondary">${firstPart}</span>${secondPart}`
|
||||
);
|
||||
}
|
||||
|
||||
insertSpaces(str: string): string {
|
||||
const length = str.length;
|
||||
return str.slice(0, length - 6) + ' ' + str.slice(length - 6, length - 3) + ' ' + str.slice(length - 3);
|
||||
}
|
||||
|
||||
}
|
@ -97,6 +97,8 @@ 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 { CalculatorComponent } from '../components/calculator/calculator.component';
|
||||
import { BitcoinsatoshisPipe } from '../shared/pipes/bitcoinsatoshis.pipe';
|
||||
|
||||
import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-directives/weight-directives';
|
||||
|
||||
@ -185,12 +187,12 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir
|
||||
GeolocationComponent,
|
||||
TestnetAlertComponent,
|
||||
GlobalFooterComponent,
|
||||
|
||||
CalculatorComponent,
|
||||
BitcoinsatoshisPipe,
|
||||
MempoolBlockOverviewComponent,
|
||||
ClockchainComponent,
|
||||
ClockComponent,
|
||||
ClockFaceComponent,
|
||||
|
||||
OnlyVsizeDirective,
|
||||
OnlyWeightDirective
|
||||
],
|
||||
|
@ -353,7 +353,7 @@ ELEMENTS_REPO_URL=https://github.com/ElementsProject/elements
|
||||
ELEMENTS_REPO_NAME=elements
|
||||
ELEMENTS_REPO_BRANCH=master
|
||||
#ELEMENTS_LATEST_RELEASE=$(curl -s https://api.github.com/repos/ElementsProject/elements/releases/latest|grep tag_name|head -1|cut -d '"' -f4)
|
||||
ELEMENTS_LATEST_RELEASE=elements-22.1
|
||||
ELEMENTS_LATEST_RELEASE=elements-22.1.1
|
||||
echo -n '.'
|
||||
|
||||
BITCOIN_ELECTRS_REPO_URL=https://github.com/mempool/electrs
|
||||
|
@ -48,5 +48,30 @@
|
||||
"STATISTICS": {
|
||||
"ENABLED": true,
|
||||
"TX_PER_SECOND_SAMPLE_PERIOD": 150
|
||||
},
|
||||
"REPLICATION": {
|
||||
"ENABLED": true,
|
||||
"AUDIT": true,
|
||||
"AUDIT_START_HEIGHT": 774000,
|
||||
"SERVERS": [
|
||||
"node201.fmt.mempool.space",
|
||||
"node202.fmt.mempool.space",
|
||||
"node203.fmt.mempool.space",
|
||||
"node204.fmt.mempool.space",
|
||||
"node205.fmt.mempool.space",
|
||||
"node206.fmt.mempool.space",
|
||||
"node201.fra.mempool.space",
|
||||
"node202.fra.mempool.space",
|
||||
"node203.fra.mempool.space",
|
||||
"node204.fra.mempool.space",
|
||||
"node205.fra.mempool.space",
|
||||
"node206.fra.mempool.space",
|
||||
"node201.tk7.mempool.space",
|
||||
"node202.tk7.mempool.space",
|
||||
"node203.tk7.mempool.space",
|
||||
"node204.tk7.mempool.space",
|
||||
"node205.tk7.mempool.space",
|
||||
"node206.tk7.mempool.space"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user