Merge branch 'master' into nymkappa/mega-branch

This commit is contained in:
nymkappa 2024-02-05 17:10:07 +01:00
commit f964888c47
No known key found for this signature in database
GPG Key ID: E155910B16E8BD04
67 changed files with 3243 additions and 120 deletions

6
backend/.gitignore vendored
View File

@ -7,6 +7,12 @@ mempool-config.json
pools.json pools.json
icons.json icons.json
# docker
Dockerfile
GeoIP
start.sh
wait-for-it.sh
# compiled output # compiled output
/dist /dist
/tmp /tmp

View File

@ -646,7 +646,7 @@ class BisqMarketsApi {
case 'year': case 'year':
return strtotime('midnight first day of january', ts); return strtotime('midnight first day of january', ts);
default: default:
throw new Error('Unsupported interval: ' + interval); throw new Error('Unsupported interval');
} }
} }

View File

@ -106,6 +106,7 @@ export namespace IBitcoinApi {
address?: string; // (string) bitcoin address address?: string; // (string) bitcoin address
addresses?: string[]; // (string) bitcoin addresses addresses?: string[]; // (string) bitcoin addresses
pegout_chain?: string; // (string) Elements peg-out chain pegout_chain?: string; // (string) Elements peg-out chain
pegout_address?: string; // (string) Elements peg-out address
pegout_addresses?: string[]; // (string) Elements peg-out addresses pegout_addresses?: string[]; // (string) Elements peg-out addresses
}; };
} }

View File

@ -2,7 +2,7 @@ import config from '../config';
import bitcoinApi, { bitcoinCoreApi } from './bitcoin/bitcoin-api-factory'; import bitcoinApi, { bitcoinCoreApi } from './bitcoin/bitcoin-api-factory';
import logger from '../logger'; import logger from '../logger';
import memPool from './mempool'; import memPool from './mempool';
import { BlockExtended, BlockExtension, BlockSummary, PoolTag, TransactionExtended, TransactionMinerInfo, CpfpSummary, MempoolTransactionExtended, TransactionClassified } from '../mempool.interfaces'; import { BlockExtended, BlockExtension, BlockSummary, PoolTag, TransactionExtended, TransactionMinerInfo, CpfpSummary, MempoolTransactionExtended, TransactionClassified, BlockAudit } from '../mempool.interfaces';
import { Common } from './common'; import { Common } from './common';
import diskCache from './disk-cache'; import diskCache from './disk-cache';
import transactionUtils from './transaction-utils'; import transactionUtils from './transaction-utils';
@ -451,7 +451,9 @@ class Blocks {
if (config.MEMPOOL.BACKEND === 'esplora') { if (config.MEMPOOL.BACKEND === 'esplora') {
const txs = (await bitcoinApi.$getTxsForBlock(block.hash)).map(tx => transactionUtils.extendTransaction(tx)); const txs = (await bitcoinApi.$getTxsForBlock(block.hash)).map(tx => transactionUtils.extendTransaction(tx));
const cpfpSummary = await this.$indexCPFP(block.hash, block.height, txs); const cpfpSummary = await this.$indexCPFP(block.hash, block.height, txs);
await this.$getStrippedBlockTransactions(block.hash, true, true, cpfpSummary, block.height); // This will index the block summary if (cpfpSummary) {
await this.$getStrippedBlockTransactions(block.hash, true, true, cpfpSummary, block.height); // This will index the block summary
}
} else { } else {
await this.$getStrippedBlockTransactions(block.hash, true, true); // This will index the block summary await this.$getStrippedBlockTransactions(block.hash, true, true); // This will index the block summary
} }
@ -995,11 +997,11 @@ class Blocks {
return state; return state;
} }
private updateTimerProgress(state, msg) { private updateTimerProgress(state, msg): void {
state.progress = msg; state.progress = msg;
} }
private clearTimer(state) { private clearTimer(state): void {
if (state.timer) { if (state.timer) {
clearTimeout(state.timer); clearTimeout(state.timer);
} }
@ -1088,13 +1090,19 @@ class Blocks {
summary = { summary = {
id: hash, id: hash,
transactions: cpfpSummary.transactions.map(tx => { transactions: cpfpSummary.transactions.map(tx => {
let flags: number = 0;
try {
flags = tx.flags || Common.getTransactionFlags(tx);
} catch (e) {
logger.warn('Failed to classify transaction: ' + (e instanceof Error ? e.message : e));
}
return { return {
txid: tx.txid, txid: tx.txid,
fee: tx.fee || 0, fee: tx.fee || 0,
vsize: tx.vsize, vsize: tx.vsize,
value: Math.round(tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0)), value: Math.round(tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0)),
rate: tx.effectiveFeePerVsize, rate: tx.effectiveFeePerVsize,
flags: tx.flags || Common.getTransactionFlags(tx), flags: flags,
}; };
}), }),
}; };
@ -1284,7 +1292,7 @@ class Blocks {
return blocks; return blocks;
} }
public async $getBlockAuditSummary(hash: string): Promise<any> { public async $getBlockAuditSummary(hash: string): Promise<BlockAudit | null> {
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) { if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) {
return BlocksAuditsRepository.$getBlockAudit(hash); return BlocksAuditsRepository.$getBlockAudit(hash);
} else { } else {
@ -1304,7 +1312,7 @@ class Blocks {
return this.currentBlockHeight; return this.currentBlockHeight;
} }
public async $indexCPFP(hash: string, height: number, txs?: TransactionExtended[]): Promise<CpfpSummary> { public async $indexCPFP(hash: string, height: number, txs?: TransactionExtended[]): Promise<CpfpSummary | null> {
let transactions = txs; let transactions = txs;
if (!transactions) { if (!transactions) {
if (config.MEMPOOL.BACKEND === 'esplora') { if (config.MEMPOOL.BACKEND === 'esplora') {
@ -1319,14 +1327,19 @@ class Blocks {
} }
} }
const summary = Common.calculateCpfp(height, transactions as TransactionExtended[]); if (transactions?.length != null) {
const summary = Common.calculateCpfp(height, transactions as TransactionExtended[]);
await this.$saveCpfp(hash, height, summary); await this.$saveCpfp(hash, height, summary);
const effectiveFeeStats = Common.calcEffectiveFeeStatistics(summary.transactions); const effectiveFeeStats = Common.calcEffectiveFeeStatistics(summary.transactions);
await blocksRepository.$saveEffectiveFeeStats(hash, effectiveFeeStats); await blocksRepository.$saveEffectiveFeeStats(hash, effectiveFeeStats);
return summary; return summary;
} else {
logger.err(`Cannot index CPFP for block ${height} - missing transaction data`);
return null;
}
} }
public async $saveCpfp(hash: string, height: number, cpfpSummary: CpfpSummary): Promise<void> { public async $saveCpfp(hash: string, height: number, cpfpSummary: CpfpSummary): Promise<void> {

View File

@ -6,6 +6,7 @@ import { NodeSocket } from '../repositories/NodesSocketsRepository';
import { isIP } from 'net'; import { isIP } from 'net';
import transactionUtils from './transaction-utils'; import transactionUtils from './transaction-utils';
import { isPoint } from '../utils/secp256k1'; import { isPoint } from '../utils/secp256k1';
import logger from '../logger';
export class Common { export class Common {
static nativeAssetId = config.MEMPOOL.NETWORK === 'liquidtestnet' ? static nativeAssetId = config.MEMPOOL.NETWORK === 'liquidtestnet' ?
'144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49' '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49'
@ -245,7 +246,8 @@ export class Common {
} else if (tx.version === 2) { } else if (tx.version === 2) {
flags |= TransactionFlags.v2; flags |= TransactionFlags.v2;
} }
const reusedAddresses: { [address: string ]: number } = {}; const reusedInputAddresses: { [address: string ]: number } = {};
const reusedOutputAddresses: { [address: string ]: number } = {};
const inValues = {}; const inValues = {};
const outValues = {}; const outValues = {};
let rbf = false; let rbf = false;
@ -261,6 +263,9 @@ export class Common {
case 'v0_p2wpkh': flags |= TransactionFlags.p2wpkh; break; case 'v0_p2wpkh': flags |= TransactionFlags.p2wpkh; break;
case 'v0_p2wsh': flags |= TransactionFlags.p2wsh; break; case 'v0_p2wsh': flags |= TransactionFlags.p2wsh; break;
case 'v1_p2tr': { case 'v1_p2tr': {
if (!vin.witness?.length) {
throw new Error('Taproot input missing witness data');
}
flags |= TransactionFlags.p2tr; flags |= TransactionFlags.p2tr;
// in taproot, if the last witness item begins with 0x50, it's an annex // in taproot, if the last witness item begins with 0x50, it's an annex
const hasAnnex = vin.witness?.[vin.witness.length - 1].startsWith('50'); const hasAnnex = vin.witness?.[vin.witness.length - 1].startsWith('50');
@ -286,7 +291,7 @@ export class Common {
} }
if (vin.prevout?.scriptpubkey_address) { if (vin.prevout?.scriptpubkey_address) {
reusedAddresses[vin.prevout?.scriptpubkey_address] = (reusedAddresses[vin.prevout?.scriptpubkey_address] || 0) + 1; reusedInputAddresses[vin.prevout?.scriptpubkey_address] = (reusedInputAddresses[vin.prevout?.scriptpubkey_address] || 0) + 1;
} }
inValues[vin.prevout?.value || Math.random()] = (inValues[vin.prevout?.value || Math.random()] || 0) + 1; inValues[vin.prevout?.value || Math.random()] = (inValues[vin.prevout?.value || Math.random()] || 0) + 1;
} }
@ -301,7 +306,7 @@ export class Common {
case 'p2pk': { case 'p2pk': {
flags |= TransactionFlags.p2pk; flags |= TransactionFlags.p2pk;
// detect fake pubkey (i.e. not a valid DER point on the secp256k1 curve) // detect fake pubkey (i.e. not a valid DER point on the secp256k1 curve)
hasFakePubkey = hasFakePubkey || !isPoint(vout.scriptpubkey.slice(2, -2)); hasFakePubkey = hasFakePubkey || !isPoint(vout.scriptpubkey?.slice(2, -2));
} break; } break;
case 'multisig': { case 'multisig': {
flags |= TransactionFlags.p2ms; flags |= TransactionFlags.p2ms;
@ -321,7 +326,7 @@ export class Common {
case 'op_return': flags |= TransactionFlags.op_return; break; case 'op_return': flags |= TransactionFlags.op_return; break;
} }
if (vout.scriptpubkey_address) { if (vout.scriptpubkey_address) {
reusedAddresses[vout.scriptpubkey_address] = (reusedAddresses[vout.scriptpubkey_address] || 0) + 1; reusedOutputAddresses[vout.scriptpubkey_address] = (reusedOutputAddresses[vout.scriptpubkey_address] || 0) + 1;
} }
outValues[vout.value || Math.random()] = (outValues[vout.value || Math.random()] || 0) + 1; outValues[vout.value || Math.random()] = (outValues[vout.value || Math.random()] || 0) + 1;
} }
@ -331,7 +336,7 @@ export class Common {
// fast but bad heuristic to detect possible coinjoins // fast but bad heuristic to detect possible coinjoins
// (at least 5 inputs and 5 outputs, less than half of which are unique amounts, with no address reuse) // (at least 5 inputs and 5 outputs, less than half of which are unique amounts, with no address reuse)
const addressReuse = Object.values(reusedAddresses).reduce((acc, count) => Math.max(acc, count), 0) > 1; const addressReuse = Object.keys(reusedOutputAddresses).reduce((acc, key) => Math.max(acc, (reusedInputAddresses[key] || 0) + (reusedOutputAddresses[key] || 0)), 0) > 1;
if (!addressReuse && tx.vin.length >= 5 && tx.vout.length >= 5 && (Object.keys(inValues).length + Object.keys(outValues).length) <= (tx.vin.length + tx.vout.length) / 2 ) { if (!addressReuse && tx.vin.length >= 5 && tx.vout.length >= 5 && (Object.keys(inValues).length + Object.keys(outValues).length) <= (tx.vin.length + tx.vout.length) / 2 ) {
flags |= TransactionFlags.coinjoin; flags |= TransactionFlags.coinjoin;
} }
@ -348,7 +353,12 @@ export class Common {
} }
static classifyTransaction(tx: TransactionExtended): TransactionClassified { static classifyTransaction(tx: TransactionExtended): TransactionClassified {
const flags = Common.getTransactionFlags(tx); let flags = 0;
try {
flags = Common.getTransactionFlags(tx);
} catch (e) {
logger.warn('Failed to add classification flags to transaction: ' + (e instanceof Error ? e.message : e));
}
tx.flags = flags; tx.flags = flags;
return { return {
...Common.stripTransaction(tx), ...Common.stripTransaction(tx),

View File

@ -7,7 +7,7 @@ import cpfpRepository from '../repositories/CpfpRepository';
import { RowDataPacket } from 'mysql2'; import { RowDataPacket } from 'mysql2';
class DatabaseMigration { class DatabaseMigration {
private static currentVersion = 67; private static currentVersion = 68;
private queryTimeout = 3600_000; private queryTimeout = 3600_000;
private statisticsAddedIndexed = false; private statisticsAddedIndexed = false;
private uniqueLogs: string[] = []; private uniqueLogs: string[] = [];
@ -566,6 +566,20 @@ class DatabaseMigration {
await this.$executeQuery('ALTER TABLE `blocks_templates` ADD INDEX `version` (`version`)'); await this.$executeQuery('ALTER TABLE `blocks_templates` ADD INDEX `version` (`version`)');
await this.updateToSchemaVersion(67); await this.updateToSchemaVersion(67);
} }
if (databaseSchemaVersion < 68 && config.MEMPOOL.NETWORK === "liquid") {
await this.$executeQuery('TRUNCATE TABLE elements_pegs');
await this.$executeQuery('ALTER TABLE elements_pegs ADD PRIMARY KEY (txid, txindex);');
await this.$executeQuery(`UPDATE state SET number = 0 WHERE name = 'last_elements_block';`);
// Create the federation_addresses table and add the two Liquid Federation change addresses in
await this.$executeQuery(this.getCreateFederationAddressesTableQuery(), await this.$checkIfTableExists('federation_addresses'));
await this.$executeQuery(`INSERT INTO federation_addresses (bitcoinaddress) VALUES ('bc1qxvay4an52gcghxq5lavact7r6qe9l4laedsazz8fj2ee2cy47tlqff4aj4')`); // Federation change address
await this.$executeQuery(`INSERT INTO federation_addresses (bitcoinaddress) VALUES ('3EiAcrzq1cELXScc98KeCswGWZaPGceT1d')`); // Federation change address
// Create the federation_txos table that uses the federation_addresses table as a foreign key
await this.$executeQuery(this.getCreateFederationTxosTableQuery(), await this.$checkIfTableExists('federation_txos'));
await this.$executeQuery(`INSERT INTO state VALUES('last_bitcoin_block_audit', 0, NULL);`);
await this.updateToSchemaVersion(68);
}
} }
/** /**
@ -813,6 +827,32 @@ class DatabaseMigration {
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`; ) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
} }
private getCreateFederationAddressesTableQuery(): string {
return `CREATE TABLE IF NOT EXISTS federation_addresses (
bitcoinaddress varchar(100) NOT NULL,
PRIMARY KEY (bitcoinaddress)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
}
private getCreateFederationTxosTableQuery(): string {
return `CREATE TABLE IF NOT EXISTS federation_txos (
txid varchar(65) NOT NULL,
txindex int(11) NOT NULL,
bitcoinaddress varchar(100) NOT NULL,
amount bigint(20) unsigned NOT NULL,
blocknumber int(11) unsigned NOT NULL,
blocktime int(11) unsigned NOT NULL,
unspent tinyint(1) NOT NULL,
lastblockupdate int(11) unsigned NOT NULL,
lasttimeupdate int(11) unsigned NOT NULL,
pegtxid varchar(65) NOT NULL,
pegindex int(11) NOT NULL,
pegblocktime int(11) unsigned NOT NULL,
PRIMARY KEY (txid, txindex),
FOREIGN KEY (bitcoinaddress) REFERENCES federation_addresses (bitcoinaddress)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
}
private getCreatePoolsTableQuery(): string { private getCreatePoolsTableQuery(): string {
return `CREATE TABLE IF NOT EXISTS pools ( return `CREATE TABLE IF NOT EXISTS pools (
id int(11) NOT NULL AUTO_INCREMENT, id int(11) NOT NULL AUTO_INCREMENT,

View File

@ -5,8 +5,12 @@ import { Common } from '../common';
import DB from '../../database'; import DB from '../../database';
import logger from '../../logger'; import logger from '../../logger';
const federationChangeAddresses = ['bc1qxvay4an52gcghxq5lavact7r6qe9l4laedsazz8fj2ee2cy47tlqff4aj4', '3EiAcrzq1cELXScc98KeCswGWZaPGceT1d'];
const auditBlockOffsetWithTip = 1; // Wait for 1 block confirmation before processing the block in the audit process to reduce the risk of reorgs
class ElementsParser { class ElementsParser {
private isRunning = false; private isRunning = false;
private isUtxosUpdatingRunning = false;
constructor() { } constructor() { }
@ -32,12 +36,6 @@ class ElementsParser {
} }
} }
public async $getPegDataByMonth(): Promise<any> {
const query = `SELECT SUM(amount) AS amount, DATE_FORMAT(FROM_UNIXTIME(datetime), '%Y-%m-01') AS date FROM elements_pegs GROUP BY DATE_FORMAT(FROM_UNIXTIME(datetime), '%Y%m')`;
const [rows] = await DB.query(query);
return rows;
}
protected async $parseBlock(block: IBitcoinApi.Block) { protected async $parseBlock(block: IBitcoinApi.Block) {
for (const tx of block.tx) { for (const tx of block.tx) {
await this.$parseInputs(tx, block); await this.$parseInputs(tx, block);
@ -55,29 +53,30 @@ class ElementsParser {
protected async $parsePegIn(input: IBitcoinApi.Vin, vindex: number, txid: string, block: IBitcoinApi.Block) { protected async $parsePegIn(input: IBitcoinApi.Vin, vindex: number, txid: string, block: IBitcoinApi.Block) {
const bitcoinTx: IBitcoinApi.Transaction = await bitcoinSecondClient.getRawTransaction(input.txid, true); const bitcoinTx: IBitcoinApi.Transaction = await bitcoinSecondClient.getRawTransaction(input.txid, true);
const bitcoinBlock: IBitcoinApi.Block = await bitcoinSecondClient.getBlock(bitcoinTx.blockhash);
const prevout = bitcoinTx.vout[input.vout || 0]; const prevout = bitcoinTx.vout[input.vout || 0];
const outputAddress = prevout.scriptPubKey.address || (prevout.scriptPubKey.addresses && prevout.scriptPubKey.addresses[0]) || ''; const outputAddress = prevout.scriptPubKey.address || (prevout.scriptPubKey.addresses && prevout.scriptPubKey.addresses[0]) || '';
await this.$savePegToDatabase(block.height, block.time, prevout.value * 100000000, txid, vindex, await this.$savePegToDatabase(block.height, block.time, prevout.value * 100000000, txid, vindex,
outputAddress, bitcoinTx.txid, prevout.n, 1); outputAddress, bitcoinTx.txid, prevout.n, bitcoinBlock.height, bitcoinBlock.time, 1);
} }
protected async $parseOutputs(tx: IBitcoinApi.Transaction, block: IBitcoinApi.Block) { protected async $parseOutputs(tx: IBitcoinApi.Transaction, block: IBitcoinApi.Block) {
for (const output of tx.vout) { for (const output of tx.vout) {
if (output.scriptPubKey.pegout_chain) { if (output.scriptPubKey.pegout_chain) {
await this.$savePegToDatabase(block.height, block.time, 0 - output.value * 100000000, tx.txid, output.n, await this.$savePegToDatabase(block.height, block.time, 0 - output.value * 100000000, tx.txid, output.n,
(output.scriptPubKey.pegout_addresses && output.scriptPubKey.pegout_addresses[0] || ''), '', 0, 0); (output.scriptPubKey.pegout_address || ''), '', 0, 0, 0, 0);
} }
if (!output.scriptPubKey.pegout_chain && output.scriptPubKey.type === 'nulldata' if (!output.scriptPubKey.pegout_chain && output.scriptPubKey.type === 'nulldata'
&& output.value && output.value > 0 && output.asset && output.asset === Common.nativeAssetId) { && output.value && output.value > 0 && output.asset && output.asset === Common.nativeAssetId) {
await this.$savePegToDatabase(block.height, block.time, 0 - output.value * 100000000, tx.txid, output.n, await this.$savePegToDatabase(block.height, block.time, 0 - output.value * 100000000, tx.txid, output.n,
(output.scriptPubKey.pegout_addresses && output.scriptPubKey.pegout_addresses[0] || ''), '', 0, 1); (output.scriptPubKey.pegout_address || ''), '', 0, 0, 0, 1);
} }
} }
} }
protected async $savePegToDatabase(height: number, blockTime: number, amount: number, txid: string, protected async $savePegToDatabase(height: number, blockTime: number, amount: number, txid: string,
txindex: number, bitcoinaddress: string, bitcointxid: string, bitcoinindex: number, final_tx: number): Promise<void> { txindex: number, bitcoinaddress: string, bitcointxid: string, bitcoinindex: number, bitcoinblock: number, bitcoinBlockTime: number, final_tx: number): Promise<void> {
const query = `INSERT INTO elements_pegs( const query = `INSERT IGNORE INTO elements_pegs(
block, datetime, amount, txid, txindex, bitcoinaddress, bitcointxid, bitcoinindex, final_tx block, datetime, amount, txid, txindex, bitcoinaddress, bitcointxid, bitcoinindex, final_tx
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`; ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`;
@ -85,7 +84,22 @@ class ElementsParser {
height, blockTime, amount, txid, txindex, bitcoinaddress, bitcointxid, bitcoinindex, final_tx height, blockTime, amount, txid, txindex, bitcoinaddress, bitcointxid, bitcoinindex, final_tx
]; ];
await DB.query(query, params); await DB.query(query, params);
logger.debug(`Saved L-BTC peg from block height #${height} with TXID ${txid}.`); logger.debug(`Saved L-BTC peg from Liquid block height #${height} with TXID ${txid}.`);
if (amount > 0) { // Peg-in
// Add the address to the federation addresses table
await DB.query(`INSERT IGNORE INTO federation_addresses (bitcoinaddress) VALUES (?)`, [bitcoinaddress]);
// Add the UTXO to the federation txos table
const query_utxos = `INSERT IGNORE INTO federation_txos (txid, txindex, bitcoinaddress, amount, blocknumber, blocktime, unspent, lastblockupdate, lasttimeupdate, pegtxid, pegindex, pegblocktime) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
const params_utxos: (string | number)[] = [bitcointxid, bitcoinindex, bitcoinaddress, amount, bitcoinblock, bitcoinBlockTime, 1, bitcoinblock - 1, 0, txid, txindex, blockTime];
await DB.query(query_utxos, params_utxos);
const [minBlockUpdate] = await DB.query(`SELECT MIN(lastblockupdate) AS lastblockupdate FROM federation_txos WHERE unspent = 1`)
await this.$saveLastBlockAuditToDatabase(minBlockUpdate[0]['lastblockupdate']);
logger.debug(`Saved new Federation UTXO ${bitcointxid}:${bitcoinindex} belonging to ${bitcoinaddress} to federation txos`);
}
} }
protected async $getLatestBlockHeightFromDatabase(): Promise<number> { protected async $getLatestBlockHeightFromDatabase(): Promise<number> {
@ -98,6 +112,327 @@ class ElementsParser {
const query = `UPDATE state SET number = ? WHERE name = 'last_elements_block'`; const query = `UPDATE state SET number = ? WHERE name = 'last_elements_block'`;
await DB.query(query, [blockHeight]); await DB.query(query, [blockHeight]);
} }
///////////// FEDERATION AUDIT //////////////
public async $updateFederationUtxos() {
if (this.isUtxosUpdatingRunning) {
return;
}
this.isUtxosUpdatingRunning = true;
try {
let auditProgress = await this.$getAuditProgress();
// If no peg in transaction was found in the database, return
if (!auditProgress.lastBlockAudit) {
logger.debug(`No Federation UTXOs found in the database. Waiting for some to be confirmed before starting the Federation UTXOs audit`);
this.isUtxosUpdatingRunning = false;
return;
}
const bitcoinBlocksToSync = await this.$getBitcoinBlockchainState();
// If the bitcoin blockchain is not synced yet, return
if (bitcoinBlocksToSync.bitcoinHeaders > bitcoinBlocksToSync.bitcoinBlocks + 1) {
logger.debug(`Bitcoin client is not synced yet. ${bitcoinBlocksToSync.bitcoinHeaders - bitcoinBlocksToSync.bitcoinBlocks} blocks remaining to sync before the Federation audit process can start`);
this.isUtxosUpdatingRunning = false;
return;
}
auditProgress.lastBlockAudit++;
// Logging
let indexedThisRun = 0;
let timer = Date.now() / 1000;
const startedAt = Date.now() / 1000;
const indexingSpeeds: number[] = [];
while (auditProgress.lastBlockAudit <= auditProgress.confirmedTip) {
// First, get the current UTXOs that need to be scanned in the block
const utxos = await this.$getFederationUtxosToScan(auditProgress.lastBlockAudit);
// Get the peg-out addresses that need to be scanned
const redeemAddresses = await this.$getRedeemAddressesToScan();
// The fast way: check if these UTXOs are still unspent as of the current block with gettxout
let spentAsTip: any[];
let unspentAsTip: any[];
if (auditProgress.confirmedTip - auditProgress.lastBlockAudit <= 150) { // If the audit status is not too far in the past, we can use gettxout (fast way)
const utxosToParse = await this.$getFederationUtxosToParse(utxos);
spentAsTip = utxosToParse.spentAsTip;
unspentAsTip = utxosToParse.unspentAsTip;
logger.debug(`Found ${utxos.length} Federation UTXOs and ${redeemAddresses.length} Peg-Out Addresses to scan in Bitcoin block height #${auditProgress.lastBlockAudit} / #${auditProgress.confirmedTip}`);
logger.debug(`${unspentAsTip.length} / ${utxos.length} Federation UTXOs are unspent as of tip`);
} else { // If the audit status is too far in the past, it is useless and wasteful to look for still unspent txos since they will all be spent as of the tip
spentAsTip = utxos;
unspentAsTip = [];
// Logging
const elapsedSeconds = (Date.now() / 1000) - timer;
if (elapsedSeconds > 5) {
const runningFor = (Date.now() / 1000) - startedAt;
const blockPerSeconds = indexedThisRun / elapsedSeconds;
indexingSpeeds.push(blockPerSeconds);
if (indexingSpeeds.length > 100) indexingSpeeds.shift(); // Keep the length of the up to 100 last indexing speeds
const meanIndexingSpeed = indexingSpeeds.reduce((a, b) => a + b, 0) / indexingSpeeds.length;
const eta = (auditProgress.confirmedTip - auditProgress.lastBlockAudit) / meanIndexingSpeed;
logger.debug(`Scanning ${utxos.length} Federation UTXOs and ${redeemAddresses.length} Peg-Out Addresses at Bitcoin block height #${auditProgress.lastBlockAudit} / #${auditProgress.confirmedTip} | ~${meanIndexingSpeed.toFixed(2)} blocks/sec | elapsed: ${(runningFor / 60).toFixed(0)} minutes | ETA: ${(eta / 60).toFixed(0)} minutes`);
timer = Date.now() / 1000;
indexedThisRun = 0;
}
}
// The slow way: parse the block to look for the spending tx
const blockHash: IBitcoinApi.ChainTips = await bitcoinSecondClient.getBlockHash(auditProgress.lastBlockAudit);
const block: IBitcoinApi.Block = await bitcoinSecondClient.getBlock(blockHash, 2);
await this.$parseBitcoinBlock(block, spentAsTip, unspentAsTip, auditProgress.confirmedTip, redeemAddresses);
// Finally, update the lastblockupdate of the remaining UTXOs and save to the database
const [minBlockUpdate] = await DB.query(`SELECT MIN(lastblockupdate) AS lastblockupdate FROM federation_txos WHERE unspent = 1`)
await this.$saveLastBlockAuditToDatabase(minBlockUpdate[0]['lastblockupdate']);
auditProgress = await this.$getAuditProgress();
auditProgress.lastBlockAudit++;
indexedThisRun++;
}
this.isUtxosUpdatingRunning = false;
} catch (e) {
this.isUtxosUpdatingRunning = false;
throw new Error(e instanceof Error ? e.message : 'Error');
}
}
// Get the UTXOs that need to be scanned in block height (UTXOs that were last updated in the block height - 1)
protected async $getFederationUtxosToScan(height: number) {
const query = `SELECT txid, txindex, bitcoinaddress, amount FROM federation_txos WHERE lastblockupdate = ? AND unspent = 1`;
const [rows] = await DB.query(query, [height - 1]);
return rows as any[];
}
// Returns the UTXOs that are spent as of tip and need to be scanned
protected async $getFederationUtxosToParse(utxos: any[]): Promise<any> {
const spentAsTip: any[] = [];
const unspentAsTip: any[] = [];
for (const utxo of utxos) {
const result = await bitcoinSecondClient.getTxOut(utxo.txid, utxo.txindex, false);
result ? unspentAsTip.push(utxo) : spentAsTip.push(utxo);
}
return {spentAsTip, unspentAsTip};
}
protected async $parseBitcoinBlock(block: IBitcoinApi.Block, spentAsTip: any[], unspentAsTip: any[], confirmedTip: number, redeemAddressesData: any[] = []) {
const redeemAddresses: string[] = redeemAddressesData.map(redeemAddress => redeemAddress.bitcoinaddress);
for (const tx of block.tx) {
let mightRedeemInThisTx = false; // If a Federation UTXO is spent in this block, we might find a peg-out address in the outputs...
// Check if the Federation UTXOs that was spent as of tip are spent in this block
for (const input of tx.vin) {
const txo = spentAsTip.find(txo => txo.txid === input.txid && txo.txindex === input.vout);
if (txo) {
mightRedeemInThisTx = true;
await DB.query(`UPDATE federation_txos SET unspent = 0, lastblockupdate = ?, lasttimeupdate = ? WHERE txid = ? AND txindex = ?`, [block.height, block.time, txo.txid, txo.txindex]);
// Remove the TXO from the utxo array
spentAsTip.splice(spentAsTip.indexOf(txo), 1);
logger.debug(`Federation UTXO ${txo.txid}:${txo.txindex} (${txo.amount} sats) was spent in block ${block.height}`);
}
}
// Check if an output is sent to a change address of the federation
for (const output of tx.vout) {
if (output.scriptPubKey.address && federationChangeAddresses.includes(output.scriptPubKey.address)) {
// Check that the UTXO was not already added in the DB by previous scans
const [rows_check] = await DB.query(`SELECT txid FROM federation_txos WHERE txid = ? AND txindex = ?`, [tx.txid, output.n]) as any[];
if (rows_check.length === 0) {
const query_utxos = `INSERT INTO federation_txos (txid, txindex, bitcoinaddress, amount, blocknumber, blocktime, unspent, lastblockupdate, lasttimeupdate, pegtxid, pegindex, pegblocktime) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
const params_utxos: (string | number)[] = [tx.txid, output.n, output.scriptPubKey.address, output.value * 100000000, block.height, block.time, 1, block.height, 0, '', 0, 0];
await DB.query(query_utxos, params_utxos);
// Add the UTXO to the utxo array
spentAsTip.push({
txid: tx.txid,
txindex: output.n,
bitcoinaddress: output.scriptPubKey.address,
amount: output.value * 100000000
});
logger.debug(`Added new Federation UTXO ${tx.txid}:${output.n} (${output.value * 100000000} sats), change address: ${output.scriptPubKey.address}`);
}
}
if (mightRedeemInThisTx && output.scriptPubKey.address && redeemAddresses.includes(output.scriptPubKey.address)) {
// Find the number of times output.scriptPubKey.address appears in redeemAddresses. There can be address reuse for peg-outs...
const matchingAddress: any[] = redeemAddressesData.filter(redeemAddress => redeemAddress.bitcoinaddress === output.scriptPubKey.address && -redeemAddress.amount === Math.round(output.value * 100000000));
if (matchingAddress.length > 0) {
if (matchingAddress.length > 1) {
// If there are more than one peg out address with the same amount, we can't know which one redeemed the UTXO: we take the oldest one
matchingAddress.sort((a, b) => a.datetime - b.datetime);
logger.debug(`Found redeem txid ${tx.txid}:${output.n} to peg-out address ${matchingAddress[0].bitcoinaddress}, amount ${matchingAddress[0].amount}, datetime ${matchingAddress[0].datetime}`);
} else {
logger.debug(`Found redeem txid ${tx.txid}:${output.n} to peg-out address ${matchingAddress[0].bitcoinaddress}, amount ${matchingAddress[0].amount}`);
}
const query_add_redeem = `UPDATE elements_pegs SET bitcointxid = ?, bitcoinindex = ? WHERE bitcoinaddress = ? AND amount = ? AND datetime = ?`;
const params_add_redeem: (string | number)[] = [tx.txid, output.n, matchingAddress[0].bitcoinaddress, matchingAddress[0].amount, matchingAddress[0].datetime];
await DB.query(query_add_redeem, params_add_redeem);
const index = redeemAddressesData.indexOf(matchingAddress[0]);
redeemAddressesData.splice(index, 1);
redeemAddresses.splice(index, 1);
} else { // The output amount does not match the peg-out amount... log it
logger.debug(`Found redeem txid ${tx.txid}:${output.n} to peg-out address ${output.scriptPubKey.address} but output amount ${Math.round(output.value * 100000000)} does not match the peg-out amount!`);
}
}
}
}
for (const utxo of spentAsTip) {
await DB.query(`UPDATE federation_txos SET lastblockupdate = ? WHERE txid = ? AND txindex = ?`, [block.height, utxo.txid, utxo.txindex]);
}
for (const utxo of unspentAsTip) {
await DB.query(`UPDATE federation_txos SET lastblockupdate = ? WHERE txid = ? AND txindex = ?`, [confirmedTip, utxo.txid, utxo.txindex]);
}
}
protected async $saveLastBlockAuditToDatabase(blockHeight: number) {
const query = `UPDATE state SET number = ? WHERE name = 'last_bitcoin_block_audit'`;
await DB.query(query, [blockHeight]);
}
// Get the bitcoin block where the audit process was last updated
protected async $getAuditProgress(): Promise<any> {
const lastblockaudit = await this.$getLastBlockAudit();
const bitcoinBlocksToSync = await this.$getBitcoinBlockchainState();
return {
lastBlockAudit: lastblockaudit,
confirmedTip: bitcoinBlocksToSync.bitcoinBlocks - auditBlockOffsetWithTip,
};
}
// Get the bitcoin blocks remaining to be synced
protected async $getBitcoinBlockchainState(): Promise<any> {
const result = await bitcoinSecondClient.getBlockchainInfo();
return {
bitcoinBlocks: result.blocks,
bitcoinHeaders: result.headers,
}
}
protected async $getLastBlockAudit(): Promise<number> {
const query = `SELECT number FROM state WHERE name = 'last_bitcoin_block_audit'`;
const [rows] = await DB.query(query);
return rows[0]['number'];
}
protected async $getRedeemAddressesToScan(): Promise<any[]> {
const query = `SELECT datetime, amount, bitcoinaddress FROM elements_pegs where amount < 0 AND bitcoinaddress != '' AND bitcointxid = '';`;
const [rows]: any[] = await DB.query(query);
return rows;
}
///////////// DATA QUERY //////////////
public async $getAuditStatus(): Promise<any> {
const lastBlockAudit = await this.$getLastBlockAudit();
const bitcoinBlocksToSync = await this.$getBitcoinBlockchainState();
return {
bitcoinBlocks: bitcoinBlocksToSync.bitcoinBlocks,
bitcoinHeaders: bitcoinBlocksToSync.bitcoinHeaders,
lastBlockAudit: lastBlockAudit,
isAuditSynced: bitcoinBlocksToSync.bitcoinHeaders - bitcoinBlocksToSync.bitcoinBlocks <= 2 && bitcoinBlocksToSync.bitcoinBlocks - lastBlockAudit <= 3,
};
}
public async $getPegDataByMonth(): Promise<any> {
const query = `SELECT SUM(amount) AS amount, DATE_FORMAT(FROM_UNIXTIME(datetime), '%Y-%m-01') AS date FROM elements_pegs GROUP BY DATE_FORMAT(FROM_UNIXTIME(datetime), '%Y%m')`;
const [rows] = await DB.query(query);
return rows;
}
public async $getFederationReservesByMonth(): Promise<any> {
const query = `
SELECT SUM(amount) AS amount, DATE_FORMAT(FROM_UNIXTIME(blocktime), '%Y-%m-01') AS date FROM federation_txos
WHERE
(blocktime > UNIX_TIMESTAMP(LAST_DAY(FROM_UNIXTIME(blocktime) - INTERVAL 1 MONTH) + INTERVAL 1 DAY))
AND
((unspent = 1) OR (unspent = 0 AND lasttimeupdate > UNIX_TIMESTAMP(LAST_DAY(FROM_UNIXTIME(blocktime)) + INTERVAL 1 DAY)))
GROUP BY
date;`;
const [rows] = await DB.query(query);
return rows;
}
// Get the current L-BTC pegs and the last Liquid block it was updated
public async $getCurrentLbtcSupply(): Promise<any> {
const [rows] = await DB.query(`SELECT SUM(amount) AS LBTC_supply FROM elements_pegs;`);
const lastblockupdate = await this.$getLatestBlockHeightFromDatabase();
const hash = await bitcoinClient.getBlockHash(lastblockupdate);
return {
amount: rows[0]['LBTC_supply'],
lastBlockUpdate: lastblockupdate,
hash: hash
};
}
// Get the current reserves of the federation and the last Bitcoin block it was updated
public async $getCurrentFederationReserves(): Promise<any> {
const [rows] = await DB.query(`SELECT SUM(amount) AS total_balance FROM federation_txos WHERE unspent = 1;`);
const lastblockaudit = await this.$getLastBlockAudit();
const hash = await bitcoinSecondClient.getBlockHash(lastblockaudit);
return {
amount: rows[0]['total_balance'],
lastBlockUpdate: lastblockaudit,
hash: hash
};
}
// Get all of the federation addresses, most balances first
public async $getFederationAddresses(): Promise<any> {
const query = `SELECT bitcoinaddress, SUM(amount) AS balance FROM federation_txos WHERE unspent = 1 GROUP BY bitcoinaddress ORDER BY balance DESC;`;
const [rows] = await DB.query(query);
return rows;
}
// Get all of the UTXOs held by the federation, most recent first
public async $getFederationUtxos(): Promise<any> {
const query = `SELECT txid, txindex, bitcoinaddress, amount, blocknumber, blocktime, pegtxid, pegindex, pegblocktime FROM federation_txos WHERE unspent = 1 ORDER BY blocktime DESC;`;
const [rows] = await DB.query(query);
return rows;
}
// Get all of the federation addresses one month ago, most balances first
public async $getFederationAddressesOneMonthAgo(): Promise<any> {
const query = `
SELECT COUNT(*) AS addresses_count_one_month FROM (
SELECT bitcoinaddress, SUM(amount) AS balance
FROM federation_txos
WHERE
(blocktime < UNIX_TIMESTAMP(TIMESTAMPADD(DAY, -30, CURRENT_TIMESTAMP())))
AND
((unspent = 1) OR (unspent = 0 AND lasttimeupdate > UNIX_TIMESTAMP(TIMESTAMPADD(DAY, -30, CURRENT_TIMESTAMP()))))
GROUP BY bitcoinaddress
) AS result;`;
const [rows] = await DB.query(query);
return rows[0];
}
// Get all of the UTXOs held by the federation one month ago, most recent first
public async $getFederationUtxosOneMonthAgo(): Promise<any> {
const query = `
SELECT COUNT(*) AS utxos_count_one_month FROM federation_txos
WHERE
(blocktime < UNIX_TIMESTAMP(TIMESTAMPADD(DAY, -30, CURRENT_TIMESTAMP())))
AND
((unspent = 1) OR (unspent = 0 AND lasttimeupdate > UNIX_TIMESTAMP(TIMESTAMPADD(DAY, -30, CURRENT_TIMESTAMP()))))
ORDER BY blocktime DESC;`;
const [rows] = await DB.query(query);
return rows[0];
}
// Get recent pegouts from the federation (3 months old)
public async $getRecentPegouts(): Promise<any> {
const query = `SELECT txid, txindex, amount, bitcoinaddress, bitcointxid, bitcoinindex, datetime AS blocktime FROM elements_pegs WHERE amount < 0 AND datetime > UNIX_TIMESTAMP(TIMESTAMPADD(DAY, -90, CURRENT_TIMESTAMP())) ORDER BY blocktime;`;
const [rows] = await DB.query(query);
return rows;
}
} }
export default new ElementsParser(); export default new ElementsParser();

View File

@ -15,7 +15,16 @@ class LiquidRoutes {
if (config.DATABASE.ENABLED) { if (config.DATABASE.ENABLED) {
app app
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/pegs', this.$getElementsPegs)
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/pegs/month', this.$getElementsPegsByMonth) .get(config.MEMPOOL.API_URL_PREFIX + 'liquid/pegs/month', this.$getElementsPegsByMonth)
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves', this.$getFederationReserves)
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/month', this.$getFederationReservesByMonth)
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/pegouts', this.$getPegOuts)
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/addresses', this.$getFederationAddresses)
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/addresses/previous-month', this.$getFederationAddressesOneMonthAgo)
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/utxos', this.$getFederationUtxos)
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/utxos/previous-month', this.$getFederationUtxosOneMonthAgo)
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/status', this.$getFederationAuditStatus)
; ;
} }
} }
@ -63,11 +72,123 @@ class LiquidRoutes {
private async $getElementsPegsByMonth(req: Request, res: Response) { private async $getElementsPegsByMonth(req: Request, res: Response) {
try { try {
const pegs = await elementsParser.$getPegDataByMonth(); const pegs = await elementsParser.$getPegDataByMonth();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 60 * 60).toUTCString());
res.json(pegs); res.json(pegs);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); res.status(500).send(e instanceof Error ? e.message : e);
} }
} }
private async $getFederationReservesByMonth(req: Request, res: Response) {
try {
const reserves = await elementsParser.$getFederationReservesByMonth();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 60 * 60).toUTCString());
res.json(reserves);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getElementsPegs(req: Request, res: Response) {
try {
const currentSupply = await elementsParser.$getCurrentLbtcSupply();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(currentSupply);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getFederationReserves(req: Request, res: Response) {
try {
const currentReserves = await elementsParser.$getCurrentFederationReserves();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(currentReserves);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getFederationAuditStatus(req: Request, res: Response) {
try {
const auditStatus = await elementsParser.$getAuditStatus();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(auditStatus);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getFederationAddresses(req: Request, res: Response) {
try {
const federationAddresses = await elementsParser.$getFederationAddresses();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(federationAddresses);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getFederationAddressesOneMonthAgo(req: Request, res: Response) {
try {
const federationAddresses = await elementsParser.$getFederationAddressesOneMonthAgo();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 60 * 60 * 24).toUTCString());
res.json(federationAddresses);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getFederationUtxos(req: Request, res: Response) {
try {
const federationUtxos = await elementsParser.$getFederationUtxos();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(federationUtxos);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getFederationUtxosOneMonthAgo(req: Request, res: Response) {
try {
const federationUtxos = await elementsParser.$getFederationUtxosOneMonthAgo();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 60 * 60 * 24).toUTCString());
res.json(federationUtxos);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getPegOuts(req: Request, res: Response) {
try {
const recentPegOuts = await elementsParser.$getRecentPegouts();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(recentPegOuts);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
} }
export default new LiquidRoutes(); export default new LiquidRoutes();

View File

@ -142,7 +142,7 @@ class Mining {
public async $getPoolStat(slug: string): Promise<object> { public async $getPoolStat(slug: string): Promise<object> {
const pool = await PoolsRepository.$getPool(slug); const pool = await PoolsRepository.$getPool(slug);
if (!pool) { if (!pool) {
throw new Error('This mining pool does not exist ' + escape(slug)); throw new Error('This mining pool does not exist');
} }
const blockCount: number = await BlocksRepository.$blockCount(pool.id); const blockCount: number = await BlocksRepository.$blockCount(pool.id);

View File

@ -266,6 +266,7 @@ class Server {
blocks.setNewBlockCallback(async () => { blocks.setNewBlockCallback(async () => {
try { try {
await elementsParser.$parse(); await elementsParser.$parse();
await elementsParser.$updateFederationUtxos();
} catch (e) { } catch (e) {
logger.warn('Elements parsing error: ' + (e instanceof Error ? e.message : e)); logger.warn('Elements parsing error: ' + (e instanceof Error ? e.message : e));
} }

View File

@ -59,7 +59,7 @@ class BlocksAuditRepositories {
} }
} }
public async $getBlockAudit(hash: string): Promise<any> { public async $getBlockAudit(hash: string): Promise<BlockAudit | null> {
try { try {
const [rows]: any[] = await DB.query( const [rows]: any[] = await DB.query(
`SELECT blocks_audits.height, blocks_audits.hash as id, UNIX_TIMESTAMP(blocks_audits.time) as timestamp, `SELECT blocks_audits.height, blocks_audits.hash as id, UNIX_TIMESTAMP(blocks_audits.time) as timestamp,
@ -75,8 +75,8 @@ class BlocksAuditRepositories {
expected_weight as expectedWeight expected_weight as expectedWeight
FROM blocks_audits FROM blocks_audits
JOIN blocks_templates ON blocks_templates.id = blocks_audits.hash JOIN blocks_templates ON blocks_templates.id = blocks_audits.hash
WHERE blocks_audits.hash = "${hash}" WHERE blocks_audits.hash = ?
`); `, [hash]);
if (rows.length) { if (rows.length) {
rows[0].missingTxs = JSON.parse(rows[0].missingTxs); rows[0].missingTxs = JSON.parse(rows[0].missingTxs);
@ -101,8 +101,8 @@ class BlocksAuditRepositories {
const [rows]: any[] = await DB.query( const [rows]: any[] = await DB.query(
`SELECT hash, match_rate as matchRate, expected_fees as expectedFees, expected_weight as expectedWeight `SELECT hash, match_rate as matchRate, expected_fees as expectedFees, expected_weight as expectedWeight
FROM blocks_audits FROM blocks_audits
WHERE blocks_audits.hash = "${hash}" WHERE blocks_audits.hash = ?
`); `, [hash]);
return rows[0]; return rows[0];
} catch (e: any) { } catch (e: any) {
logger.err(`Cannot fetch block audit from db. Reason: ` + (e instanceof Error ? e.message : e)); logger.err(`Cannot fetch block audit from db. Reason: ` + (e instanceof Error ? e.message : e));

View File

@ -5,7 +5,7 @@ import logger from '../logger';
import { Common } from '../api/common'; import { Common } from '../api/common';
import PoolsRepository from './PoolsRepository'; import PoolsRepository from './PoolsRepository';
import HashratesRepository from './HashratesRepository'; import HashratesRepository from './HashratesRepository';
import { escape } from 'mysql2'; import { RowDataPacket, escape } from 'mysql2';
import BlocksSummariesRepository from './BlocksSummariesRepository'; import BlocksSummariesRepository from './BlocksSummariesRepository';
import DifficultyAdjustmentsRepository from './DifficultyAdjustmentsRepository'; import DifficultyAdjustmentsRepository from './DifficultyAdjustmentsRepository';
import bitcoinClient from '../api/bitcoin/bitcoin-client'; import bitcoinClient from '../api/bitcoin/bitcoin-client';
@ -478,7 +478,7 @@ class BlocksRepository {
public async $getBlocksByPool(slug: string, startHeight?: number): Promise<BlockExtended[]> { public async $getBlocksByPool(slug: string, startHeight?: number): Promise<BlockExtended[]> {
const pool = await PoolsRepository.$getPool(slug); const pool = await PoolsRepository.$getPool(slug);
if (!pool) { if (!pool) {
throw new Error('This mining pool does not exist ' + escape(slug)); throw new Error('This mining pool does not exist');
} }
const params: any[] = []; const params: any[] = [];
@ -802,10 +802,10 @@ class BlocksRepository {
/** /**
* Get a list of blocks that have been indexed * Get a list of blocks that have been indexed
*/ */
public async $getIndexedBlocks(): Promise<any[]> { public async $getIndexedBlocks(): Promise<{ height: number, hash: string }[]> {
try { try {
const [rows]: any = await DB.query(`SELECT height, hash FROM blocks ORDER BY height DESC`); const [rows] = await DB.query(`SELECT height, hash FROM blocks ORDER BY height DESC`) as RowDataPacket[][];
return rows; return rows as { height: number, hash: string }[];
} catch (e) { } catch (e) {
logger.err('Cannot generate block size and weight history. Reason: ' + (e instanceof Error ? e.message : e)); logger.err('Cannot generate block size and weight history. Reason: ' + (e instanceof Error ? e.message : e));
throw e; throw e;
@ -815,7 +815,7 @@ class BlocksRepository {
/** /**
* Get a list of blocks that have not had CPFP data indexed * Get a list of blocks that have not had CPFP data indexed
*/ */
public async $getCPFPUnindexedBlocks(): Promise<any[]> { public async $getCPFPUnindexedBlocks(): Promise<number[]> {
try { try {
const blockchainInfo = await bitcoinClient.getBlockchainInfo(); const blockchainInfo = await bitcoinClient.getBlockchainInfo();
const currentBlockHeight = blockchainInfo.blocks; const currentBlockHeight = blockchainInfo.blocks;
@ -825,13 +825,13 @@ class BlocksRepository {
} }
const minHeight = Math.max(0, currentBlockHeight - indexingBlockAmount + 1); const minHeight = Math.max(0, currentBlockHeight - indexingBlockAmount + 1);
const [rows]: any[] = await DB.query(` const [rows] = await DB.query(`
SELECT height SELECT height
FROM compact_cpfp_clusters FROM compact_cpfp_clusters
WHERE height <= ? AND height >= ? WHERE height <= ? AND height >= ?
GROUP BY height GROUP BY height
ORDER BY height DESC; ORDER BY height DESC;
`, [currentBlockHeight, minHeight]); `, [currentBlockHeight, minHeight]) as RowDataPacket[][];
const indexedHeights = {}; const indexedHeights = {};
rows.forEach((row) => { indexedHeights[row.height] = true; }); rows.forEach((row) => { indexedHeights[row.height] = true; });

View File

@ -1,3 +1,4 @@
import { RowDataPacket } from 'mysql2';
import DB from '../database'; import DB from '../database';
import logger from '../logger'; import logger from '../logger';
import { BlockSummary, TransactionClassified } from '../mempool.interfaces'; import { BlockSummary, TransactionClassified } from '../mempool.interfaces';
@ -69,7 +70,7 @@ class BlocksSummariesRepository {
public async $getIndexedSummariesId(): Promise<string[]> { public async $getIndexedSummariesId(): Promise<string[]> {
try { try {
const [rows]: any[] = await DB.query(`SELECT id from blocks_summaries`); const [rows] = await DB.query(`SELECT id from blocks_summaries`) as RowDataPacket[][];
return rows.map(row => row.id); return rows.map(row => row.id);
} catch (e) { } catch (e) {
logger.err(`Cannot get block summaries id list. Reason: ` + (e instanceof Error ? e.message : e)); logger.err(`Cannot get block summaries id list. Reason: ` + (e instanceof Error ? e.message : e));

View File

@ -139,7 +139,7 @@ class HashratesRepository {
public async $getPoolWeeklyHashrate(slug: string): Promise<any[]> { public async $getPoolWeeklyHashrate(slug: string): Promise<any[]> {
const pool = await PoolsRepository.$getPool(slug); const pool = await PoolsRepository.$getPool(slug);
if (!pool) { if (!pool) {
throw new Error('This mining pool does not exist ' + escape(slug)); throw new Error('This mining pool does not exist');
} }
// Find hashrate boundaries // Find hashrate boundaries

View File

@ -31,6 +31,9 @@ const curveP = BigInt(`0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
* @returns {boolean} true if the point is on the SECP256K1 curve * @returns {boolean} true if the point is on the SECP256K1 curve
*/ */
export function isPoint(pointHex: string): boolean { export function isPoint(pointHex: string): boolean {
if (!pointHex?.length) {
return false;
}
if ( if (
!( !(
// is uncompressed // is uncompressed

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 December 20, 2023.
Signed: jamesblacklock

View File

@ -1,3 +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 November 16, 2023. I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of November 16, 2023.
Signed: natsee Signed: natsoni

View File

@ -55,7 +55,7 @@ __ELECTRUM_TLS_ENABLED__=${ELECTRUM_TLS_ENABLED:=false}
# ESPLORA # ESPLORA
__ESPLORA_REST_API_URL__=${ESPLORA_REST_API_URL:=http://127.0.0.1:3000} __ESPLORA_REST_API_URL__=${ESPLORA_REST_API_URL:=http://127.0.0.1:3000}
__ESPLORA_UNIX_SOCKET_PATH__=${ESPLORA_UNIX_SOCKET_PATH:="null"} __ESPLORA_UNIX_SOCKET_PATH__=${ESPLORA_UNIX_SOCKET_PATH:=""}
__ESPLORA_BATCH_QUERY_BASE_SIZE__=${ESPLORA_BATCH_QUERY_BASE_SIZE:=1000} __ESPLORA_BATCH_QUERY_BASE_SIZE__=${ESPLORA_BATCH_QUERY_BASE_SIZE:=1000}
__ESPLORA_RETRY_UNIX_SOCKET_AFTER__=${ESPLORA_RETRY_UNIX_SOCKET_AFTER:=30000} __ESPLORA_RETRY_UNIX_SOCKET_AFTER__=${ESPLORA_RETRY_UNIX_SOCKET_AFTER:=30000}
__ESPLORA_REQUEST_TIMEOUT__=${ESPLORA_REQUEST_TIMEOUT:=5000} __ESPLORA_REQUEST_TIMEOUT__=${ESPLORA_REQUEST_TIMEOUT:=5000}

7
frontend/.gitignore vendored
View File

@ -6,6 +6,13 @@
/out-tsc /out-tsc
server.run.js server.run.js
# docker
Dockerfile
entrypoint.sh
nginx-mempool.conf
nginx.conf
wait-for
# Only exists if Bazel was run # Only exists if Bazel was run
/bazel-out /bazel-out

View File

@ -45,28 +45,30 @@ export class AcceleratorDashboardComponent implements OnInit {
this.pendingAccelerations$ = interval(30000).pipe( this.pendingAccelerations$ = interval(30000).pipe(
startWith(true), startWith(true),
switchMap(() => { switchMap(() => {
return this.serviceApiServices.getAccelerations$(); return this.serviceApiServices.getAccelerations$().pipe(
}), catchError(() => {
catchError((e) => { return of([]);
return of([]); }),
);
}), }),
share(), share(),
); );
this.accelerations$ = this.stateService.chainTip$.pipe( this.accelerations$ = this.stateService.chainTip$.pipe(
distinctUntilChanged(), distinctUntilChanged(),
switchMap((chainTip) => { switchMap(() => {
return this.serviceApiServices.getAccelerationHistory$({ timeframe: '1m' }); return this.serviceApiServices.getAccelerationHistory$({ timeframe: '1m' }).pipe(
}), catchError(() => {
catchError((e) => { return of([]);
return of([]); }),
);
}), }),
share(), share(),
); );
this.minedAccelerations$ = this.accelerations$.pipe( this.minedAccelerations$ = this.accelerations$.pipe(
map(accelerations => { map(accelerations => {
return accelerations.filter(acc => ['mined', 'completed', 'failed'].includes(acc.status)) return accelerations.filter(acc => ['mined', 'completed', 'failed'].includes(acc.status));
}) })
); );

View File

@ -19,7 +19,7 @@
</ng-template> </ng-template>
<ng-template #default> <ng-template #default>
&lrm;{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis / 100000000 | number : digitsInfo }} &lrm;{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis / 100000000 | number : digitsInfo }}
<span class="symbol"><ng-template [ngIf]="network === 'liquid'">L-</ng-template> <span class="symbol"><ng-template [ngIf]="network === 'liquid' && !forceBtc">L-</ng-template>
<ng-template [ngIf]="network === 'liquidtestnet'">tL-</ng-template> <ng-template [ngIf]="network === 'liquidtestnet'">tL-</ng-template>
<ng-template [ngIf]="network === 'testnet'">t</ng-template> <ng-template [ngIf]="network === 'testnet'">t</ng-template>
<ng-template [ngIf]="network === 'signet'">s</ng-template>BTC</span> <ng-template [ngIf]="network === 'signet'">s</ng-template>BTC</span>

View File

@ -23,6 +23,7 @@ export class AmountComponent implements OnInit, OnDestroy {
@Input() noFiat = false; @Input() noFiat = false;
@Input() addPlus = false; @Input() addPlus = false;
@Input() blockConversion: Price; @Input() blockConversion: Price;
@Input() forceBtc: boolean = false;
constructor( constructor(
private stateService: StateService, private stateService: StateService,

View File

@ -27,7 +27,6 @@ export class LbtcPegsGraphComponent implements OnInit, OnChanges {
template: ('widget' | 'advanced') = 'widget'; template: ('widget' | 'advanced') = 'widget';
isLoading = true; isLoading = true;
pegsChartOption: EChartsOption = {};
pegsChartInitOption = { pegsChartInitOption = {
renderer: 'svg' renderer: 'svg'
}; };
@ -41,20 +40,24 @@ export class LbtcPegsGraphComponent implements OnInit, OnChanges {
} }
ngOnChanges() { ngOnChanges() {
if (!this.data) { if (!this.data?.liquidPegs) {
return; return;
} }
this.pegsChartOptions = this.createChartOptions(this.data.series, this.data.labels); if (!this.data.liquidReserves) {
this.pegsChartOptions = this.createChartOptions(this.data.liquidPegs.series, this.data.liquidPegs.labels);
} else {
this.pegsChartOptions = this.createChartOptions(this.data.liquidPegs.series, this.data.liquidPegs.labels, this.data.liquidReserves.series);
}
} }
rendered() { rendered() {
if (!this.data) { if (!this.data.liquidPegs) {
return; return;
} }
this.isLoading = false; this.isLoading = false;
} }
createChartOptions(series: number[], labels: string[]): EChartsOption { createChartOptions(pegSeries: number[], labels: string[], reservesSeries?: number[],): EChartsOption {
return { return {
grid: { grid: {
height: this.height, height: this.height,
@ -99,17 +102,18 @@ export class LbtcPegsGraphComponent implements OnInit, OnChanges {
type: 'line', type: 'line',
}, },
formatter: (params: any) => { formatter: (params: any) => {
const colorSpan = (color: string) => `<span class="indicator" style="background-color: #116761;"></span>`; const colorSpan = (color: string) => `<span class="indicator" style="background-color: ${color};"></span>`;
let itemFormatted = '<div class="title">' + params[0].axisValue + '</div>'; let itemFormatted = '<div class="title">' + params[0].axisValue + '</div>';
params.map((item: any, index: number) => { for (let index = params.length - 1; index >= 0; index--) {
const item = params[index];
if (index < 26) { if (index < 26) {
itemFormatted += `<div class="item"> itemFormatted += `<div class="item">
<div class="indicator-container">${colorSpan(item.color)}</div> <div class="indicator-container">${colorSpan(item.color)}</div>
<div class="grow"></div> <div style="margin-right: 5px"></div>
<div class="value">${formatNumber(item.value, this.locale, '1.2-2')} <span class="symbol">L-BTC</span></div> <div class="value">${formatNumber(item.value, this.locale, '1.2-2')} <span class="symbol">${item.seriesName}</span></div>
</div>`; </div>`;
} }
}); }
return `<div class="tx-wrapper-tooltip-chart ${(this.template === 'advanced') ? 'tx-wrapper-tooltip-chart-advanced' : ''}">${itemFormatted}</div>`; return `<div class="tx-wrapper-tooltip-chart ${(this.template === 'advanced') ? 'tx-wrapper-tooltip-chart-advanced' : ''}">${itemFormatted}</div>`;
} }
}, },
@ -138,20 +142,34 @@ export class LbtcPegsGraphComponent implements OnInit, OnChanges {
}, },
series: [ series: [
{ {
data: series, data: pegSeries,
name: 'L-BTC',
color: '#116761',
type: 'line', type: 'line',
stack: 'total', stack: 'total',
smooth: false, smooth: true,
showSymbol: false, showSymbol: false,
areaStyle: { areaStyle: {
opacity: 0.2, opacity: 0.2,
color: '#116761', color: '#116761',
}, },
lineStyle: { lineStyle: {
width: 3, width: 2,
color: '#116761', color: '#116761',
}, },
}, },
{
data: reservesSeries,
name: 'BTC',
color: '#EA983B',
type: 'line',
smooth: true,
showSymbol: false,
lineStyle: {
width: 2,
color: '#EA983B',
},
},
], ],
}; };
} }

View File

@ -78,6 +78,9 @@
<li class="nav-item" routerLinkActive="active" id="btn-assets"> <li class="nav-item" routerLinkActive="active" id="btn-assets">
<a class="nav-link" [routerLink]="['/assets' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'database']" [fixedWidth]="true" i18n-title="master-page.assets" title="Assets"></fa-icon></a> <a class="nav-link" [routerLink]="['/assets' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'database']" [fixedWidth]="true" i18n-title="master-page.assets" title="Assets"></fa-icon></a>
</li> </li>
<li class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" id="btn-audit">
<a class="nav-link" [routerLink]="['/audit']" (click)="collapse()"><fa-icon [icon]="['fas', 'scale-balanced']" [fixedWidth]="true" i18n-title="master-page.btc-reserves-audit" title="BTC Reserves Audit"></fa-icon></a>
</li>
<li [hidden]="isMobile" class="nav-item mr-2" routerLinkActive="active" id="btn-docs"> <li [hidden]="isMobile" class="nav-item mr-2" routerLinkActive="active" id="btn-docs">
<a class="nav-link" [routerLink]="['/docs' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'book']" [fixedWidth]="true" i18n-title="master-page.docs" title="Docs"></fa-icon></a> <a class="nav-link" [routerLink]="['/docs' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'book']" [fixedWidth]="true" i18n-title="master-page.docs" title="Docs"></fa-icon></a>
</li> </li>

View File

@ -0,0 +1,72 @@
<div [ngClass]="{'widget': widget}">
<div class="clearfix"></div>
<div style="min-height: 295px">
<table class="table table-borderless">
<thead style="vertical-align: middle;">
<th class="address text-left" [ngClass]="{'widget': widget}" i18n="shared.address">Address</th>
<th class="amount text-right" [ngClass]="{'widget': widget}" i18n="address.balance">Balance</th>
</thead>
<tbody *ngIf="federationAddresses$ | async as addresses; else skeleton" [style]="isLoading ? 'opacity: 0.75' : ''">
<ng-container *ngIf="widget; else regularRows">
<tr *ngFor="let address of addresses | slice:0:5">
<td class="address text-left widget">
<a href="{{ env.MEMPOOL_WEBSITE_URL + '/address/' + address.bitcoinaddress }}" target="_blank" style="color:#b86d12">
<app-truncate [text]="address.bitcoinaddress" [lastChars]="6"></app-truncate>
</a>
</td>
<td class="amount text-right widget">
<app-amount [satoshis]="+address.balance" [noFiat]="true" [forceBtc]="true"></app-amount>
</td>
</tr>
</ng-container>
<ng-template #regularRows>
<tr *ngFor="let address of addresses | slice:(page - 1) * pageSize:page * pageSize">
<td class="address text-left">
<a href="{{ env.MEMPOOL_WEBSITE_URL + '/address/' + address.bitcoinaddress }}" target="_blank" style="color:#b86d12">
<app-truncate [text]="address.bitcoinaddress" [lastChars]="6"></app-truncate>
</a>
</td>
<td class="amount text-right">
<app-amount [satoshis]="+address.balance" [noFiat]="true" [forceBtc]="true"></app-amount>
</td>
</tr>
</ng-template>
</tbody>
<ng-template #skeleton>
<tbody *ngIf="widget; else regularRowsSkeleton">
<tr *ngFor="let item of skeletonLines">
<td class="address text-left widget">
<span class="skeleton-loader" style="max-width: 400px"></span>
</td>
<td class="amount text-right widget">
<span class="skeleton-loader" style="max-width: 350px"></span>
</td>
</tr>
</tbody>
<ng-template #regularRowsSkeleton>
<tr *ngFor="let item of skeletonLines">
<td class="address text-left">
<span class="skeleton-loader" style="max-width: 600px"></span>
</td>
<td class="amount text-right">
<span class="skeleton-loader" style="max-width: 400px"></span>
</td>
</tr>
</ng-template>
</ng-template>
</table>
<ngb-pagination *ngIf="!widget && federationAddresses$ | async as addresses" class="pagination-container float-right mt-2" [class]="isLoading ? 'disabled' : ''"
[collectionSize]="addresses.length" [rotate]="true" [maxSize]="maxSize" [pageSize]="15" [(page)]="page"
(pageChange)="pageChange(page)" [boundaryLinks]="true" [ellipses]="false">
</ngb-pagination>
<ng-template [ngIf]="!widget">
<div class="clearfix"></div>
<br>
</ng-template>
</div>
</div>

View File

@ -0,0 +1,45 @@
.spinner-border {
height: 25px;
width: 25px;
margin-top: 13px;
}
tr, td, th {
border: 0px;
padding-top: 0.65rem !important;
padding-bottom: 0.6rem !important;
padding-right: 2rem !important;
.widget {
padding-right: 1rem !important;
}
}
.clear-link {
color: white;
}
.disabled {
pointer-events: none;
opacity: 0.5;
}
.progress {
background-color: #2d3348;
}
.address {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 160px;
}
.address.widget {
width: 60%;
}
.amount {
width: 25%;
}
.amount.widget {
width: 40%;
}

View File

@ -0,0 +1,109 @@
import { Component, OnInit, ChangeDetectionStrategy, Input } from '@angular/core';
import { Observable, Subject, combineLatest, of, timer } from 'rxjs';
import { delayWhen, filter, map, share, shareReplay, switchMap, takeUntil, tap, throttleTime } from 'rxjs/operators';
import { ApiService } from '../../../services/api.service';
import { Env, StateService } from '../../../services/state.service';
import { AuditStatus, CurrentPegs, FederationAddress } from '../../../interfaces/node-api.interface';
import { WebsocketService } from '../../../services/websocket.service';
@Component({
selector: 'app-federation-addresses-list',
templateUrl: './federation-addresses-list.component.html',
styleUrls: ['./federation-addresses-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FederationAddressesListComponent implements OnInit {
@Input() widget: boolean = false;
@Input() federationAddresses$: Observable<FederationAddress[]>;
env: Env;
isLoading = true;
page = 1;
pageSize = 15;
maxSize = window.innerWidth <= 767.98 ? 3 : 5;
skeletonLines: number[] = [];
auditStatus$: Observable<AuditStatus>;
auditUpdated$: Observable<boolean>;
lastReservesBlockUpdate: number = 0;
currentPeg$: Observable<CurrentPegs>;
lastPegBlockUpdate: number = 0;
lastPegAmount: string = '';
isLoad: boolean = true;
private destroy$ = new Subject();
constructor(
private apiService: ApiService,
public stateService: StateService,
private websocketService: WebsocketService
) {
}
ngOnInit(): void {
this.isLoading = !this.widget;
this.env = this.stateService.env;
this.skeletonLines = this.widget === true ? [...Array(5).keys()] : [...Array(15).keys()];
if (!this.widget) {
this.websocketService.want(['blocks']);
this.auditStatus$ = this.stateService.blocks$.pipe(
takeUntil(this.destroy$),
throttleTime(40000),
delayWhen(_ => this.isLoad ? timer(0) : timer(2000)),
tap(() => this.isLoad = false),
switchMap(() => this.apiService.federationAuditSynced$()),
shareReplay(1)
);
this.currentPeg$ = this.auditStatus$.pipe(
filter(auditStatus => auditStatus.isAuditSynced === true),
switchMap(_ =>
this.apiService.liquidPegs$().pipe(
filter((currentPegs) => currentPegs.lastBlockUpdate >= this.lastPegBlockUpdate),
tap((currentPegs) => {
this.lastPegBlockUpdate = currentPegs.lastBlockUpdate;
})
)
),
share()
);
this.auditUpdated$ = combineLatest([
this.auditStatus$,
this.currentPeg$
]).pipe(
filter(([auditStatus, _]) => auditStatus.isAuditSynced === true),
map(([auditStatus, currentPeg]) => ({
lastBlockAudit: auditStatus.lastBlockAudit,
currentPegAmount: currentPeg.amount
})),
switchMap(({ lastBlockAudit, currentPegAmount }) => {
const blockAuditCheck = lastBlockAudit > this.lastReservesBlockUpdate;
const amountCheck = currentPegAmount !== this.lastPegAmount;
this.lastReservesBlockUpdate = lastBlockAudit;
this.lastPegAmount = currentPegAmount;
return of(blockAuditCheck || amountCheck);
}),
share()
);
this.federationAddresses$ = this.auditUpdated$.pipe(
filter(auditUpdated => auditUpdated === true),
throttleTime(40000),
switchMap(_ => this.apiService.federationAddresses$()),
tap(_ => this.isLoading = false),
share()
);
}
}
ngOnDestroy(): void {
this.destroy$.next(1);
this.destroy$.complete();
}
pageChange(page: number): void {
this.page = page;
}
}

View File

@ -0,0 +1,34 @@
<div *ngIf="(federationAddresses$ | async) as federationAddresses; else loadingData">
<div class="fee-estimation-container">
<div class="item">
<a class="title-link" [routerLink]="['/audit/wallet/addresses' | relativeUrl]">
<h5 class="card-title"><ng-container i18n="liquid.federation-wallet">Liquid Federation Wallet</ng-container>&nbsp;<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="font-size: 13px; color: #4a68b9"></fa-icon></h5>
</a>
<div class="card-text">
<div class="fee-text">{{ federationAddresses.length }} <span i18n="shared.addresses">addresses</span></div>
<span class="fiat" *ngIf="(federationAddressesOneMonthAgo$ | async) as federationAddressesOneMonthAgo; else loadingSkeleton" i18n-ngbTooltip="liquid.percentage-change-last-month" ngbTooltip="Percentage change past month" placement="bottom">
<app-change [current]="federationAddresses.length" [previous]="federationAddressesOneMonthAgo.addresses_count_one_month"></app-change>
</span>
</div>
</div>
</div>
</div>
<ng-template #loadingData>
<div class="fee-estimation-container loading-container">
<div class="item">
<a class="title-link" [routerLink]="['/audit/wallet/addresses' | relativeUrl]">
<h5 class="card-title"><ng-container i18n="liquid.federation-wallet">Liquid Federation Wallet</ng-container>&nbsp;<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="font-size: 13px; color: #4a68b9"></fa-icon></h5>
</a>
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
</div>
</div>
</div>
</ng-template>
<ng-template #loadingSkeleton>
<div class="skeleton-loader skeleton-loader-transactions" style="margin-top: 2px; margin-bottom: 5px;"></div>
</ng-template>

View File

@ -0,0 +1,75 @@
.fee-estimation-container {
display: flex;
justify-content: space-between;
@media (min-width: 376px) {
flex-direction: row;
}
.item {
max-width: 300px;
margin: 0;
width: -webkit-fill-available;
@media (min-width: 376px) {
margin: 0 auto 0px;
}
.card-title {
margin: 0;
color: #4a68b9;
font-size: 10px;
font-size: 1rem;
white-space: nowrap;
}
.card-text {
font-size: 22px;
span {
font-size: 11px;
position: relative;
top: -2px;
}
}
.card-text span {
color: #ffffff66;
font-size: 12px;
top: 0px;
}
.fee-text{
border-bottom: 1px solid #ffffff1c;
width: fit-content;
margin: auto;
line-height: 1.45;
padding: 0px 2px;
}
.fiat {
display: block;
font-size: 14px !important;
}
}
}
.loading-container{
min-height: 76px;
}
.card-text {
.skeleton-loader {
width: 100%;
display: block;
&:first-child {
max-width: 90px;
margin: 15px auto 3px;
}
&:last-child {
margin: 10px auto 3px;
max-width: 55px;
}
}
}
.title-link, .title-link:hover, .title-link:focus, .title-link:active {
display: block;
margin-bottom: 4px;
text-decoration: none;
color: inherit;
}

View File

@ -0,0 +1,20 @@
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { FederationAddress } from '../../../interfaces/node-api.interface';
@Component({
selector: 'app-federation-addresses-stats',
templateUrl: './federation-addresses-stats.component.html',
styleUrls: ['./federation-addresses-stats.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FederationAddressesStatsComponent implements OnInit {
@Input() federationAddresses$: Observable<FederationAddress[]>;
@Input() federationAddressesOneMonthAgo$: Observable<any>;
constructor() { }
ngOnInit(): void {
}
}

View File

@ -0,0 +1,109 @@
<div [ngClass]="{'widget': widget}">
<div class="clearfix"></div>
<div style="min-height: 295px">
<table class="table table-borderless">
<thead style="vertical-align: middle;">
<th class="txid text-left" [ngClass]="{'widget': widget}" i18n="transaction.output">Output</th>
<th class="address text-left" *ngIf="!widget" i18n="shared.address">Address</th>
<th class="amount text-right" [ngClass]="{'widget': widget}" i18n="shared.amount">Amount</th>
<th class="pegin text-left" *ngIf="!widget" i18n="liquid.related-peg-in">Related Peg-In</th>
<th class="timestamp text-left" i18n="shared.date" [ngClass]="{'widget': widget}">Date</th>
</thead>
<tbody *ngIf="federationUtxos$ | async as utxos; else skeleton" [style]="isLoading ? 'opacity: 0.75' : ''">
<ng-container *ngIf="widget; else regularRows">
<tr *ngFor="let utxo of utxos | slice:0:6">
<td class="txid text-left widget">
<a href="{{ env.MEMPOOL_WEBSITE_URL + '/tx/' + utxo.txid + ':' + utxo.txindex }}" target="_blank" style="color:#b86d12">
<app-truncate [text]="utxo.txid + ':' + utxo.txindex" [lastChars]="6"></app-truncate>
</a>
</td>
<td class="amount text-right widget">
<app-amount [satoshis]="utxo.amount" [noFiat]="true" [forceBtc]="true"></app-amount>
</td>
<td class="timestamp text-left widget">
<app-time kind="since" [time]="utxo.blocktime"></app-time>
</td>
</tr>
</ng-container>
<ng-template #regularRows>
<tr *ngFor="let utxo of utxos | slice:(page - 1) * pageSize:page * pageSize">
<td class="txid text-left">
<a href="{{ env.MEMPOOL_WEBSITE_URL + '/tx/' + utxo.txid + ':' + utxo.txindex }}" target="_blank" style="color:#b86d12">
<app-truncate [text]="utxo.txid + ':' + utxo.txindex" [lastChars]="6"></app-truncate>
</a>
</td>
<td class="address text-left">
<a href="{{ env.MEMPOOL_WEBSITE_URL + '/address/' + utxo.bitcoinaddress }}" target="_blank" style="color:#b86d12">
<app-truncate [text]="utxo.bitcoinaddress" [lastChars]="6"></app-truncate>
</a>
</td>
<td class="amount text-right">
<app-amount [satoshis]="utxo.amount" [noFiat]="true" [forceBtc]="true"></app-amount>
</td>
<td class="pegin text-left">
<ng-container *ngIf="utxo.pegtxid; else noPeginMessage">
<a [routerLink]="['/tx' | relativeUrl, utxo.pegtxid]" [fragment]="'vin=' + utxo.pegindex">
<app-truncate [text]="utxo.pegtxid + ':' + utxo.pegindex" [lastChars]="6"></app-truncate>
</a>
</ng-container>
<ng-template #noPeginMessage>
<i><span class="text-muted" i18n="liquid.change-output">Change output</span></i>
</ng-template>
</td>
<td class="timestamp text-left">
&lrm;{{ utxo.blocktime * 1000 | date:'yyyy-MM-dd HH:mm' }}
<div class="symbol lg-inline relative-time"><i>(<app-time kind="since" [time]="utxo.blocktime"></app-time>)</i></div>
</td>
</tr>
</ng-template>
</tbody>
<ng-template #skeleton>
<tbody *ngIf="widget; else regularRowsSkeleton">
<tr *ngFor="let item of skeletonLines">
<td class="txid text-left widget">
<span class="skeleton-loader" style="max-width: 400px"></span>
</td>
<td class="amount text-right widget">
<span class="skeleton-loader" style="max-width: 300px"></span>
</td>
<td class="timestamp text-left widget">
<span class="skeleton-loader" style="max-width: 300px"></span>
</td>
</tr>
</tbody>
<ng-template #regularRowsSkeleton>
<tr *ngFor="let item of skeletonLines">
<td class="txid text-left">
<span class="skeleton-loader" style="max-width: 300px"></span>
</td>
<td class="address text-left">
<span class="skeleton-loader" style="max-width: 300px"></span>
</td>
<td class="amount text-right">
<span class="skeleton-loader" style="max-width: 140px"></span>
</td>
<td class="pegin text-left">
<span class="skeleton-loader" style="max-width: 300px"></span>
</td>
<td class="timestamp text-left">
<span class="skeleton-loader" style="max-width: 140px"></span>
</td>
</tr>
</ng-template>
</ng-template>
</table>
<ngb-pagination *ngIf="!widget && federationUtxos$ | async as utxos" class="pagination-container float-right mt-2" [class]="isLoading ? 'disabled' : ''"
[collectionSize]="utxos.length" [rotate]="true" [maxSize]="maxSize" [pageSize]="15" [(page)]="page"
(pageChange)="pageChange(page)" [boundaryLinks]="true" [ellipses]="false">
</ngb-pagination>
<ng-template [ngIf]="!widget">
<div class="clearfix"></div>
<br>
</ng-template>
</div>
</div>

View File

@ -0,0 +1,94 @@
.spinner-border {
height: 25px;
width: 25px;
margin-top: 13px;
}
tr, td, th {
border: 0px;
padding-top: 0.65rem !important;
padding-bottom: 0.6rem !important;
padding-right: 2rem !important;
.widget {
padding-right: 1rem !important;
}
}
.clear-link {
color: white;
}
.disabled {
pointer-events: none;
opacity: 0.5;
}
.progress {
background-color: #2d3348;
}
.txid {
width: 25%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 160px;
}
.txid.widget {
width: 40%;
}
.address {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 160px;
@media (max-width: 527px) {
display: none;
}
}
.amount {
width: 12%;
}
.amount.widget {
width: 30%;
}
.pegin {
width: 25%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 160px;
@media (max-width: 872px) {
display: none;
}
}
.timestamp {
width: 18%;
@media (max-width: 800px) {
display: none;
}
@media (max-width: 1000px) {
.relative-time {
display: none;
}
}
}
.timestamp.widget {
width: 100%;
@media (min-width: 768px) AND (max-width: 1050px) {
display: none;
}
@media (max-width: 767px) {
display: block;
}
@media (max-width: 500px) {
display: none;
}
}

View File

@ -0,0 +1,109 @@
import { Component, OnInit, ChangeDetectionStrategy, Input } from '@angular/core';
import { Observable, Subject, combineLatest, of, timer } from 'rxjs';
import { delayWhen, filter, map, share, shareReplay, switchMap, takeUntil, tap, throttleTime } from 'rxjs/operators';
import { ApiService } from '../../../services/api.service';
import { Env, StateService } from '../../../services/state.service';
import { AuditStatus, CurrentPegs, FederationUtxo } from '../../../interfaces/node-api.interface';
import { WebsocketService } from '../../../services/websocket.service';
@Component({
selector: 'app-federation-utxos-list',
templateUrl: './federation-utxos-list.component.html',
styleUrls: ['./federation-utxos-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FederationUtxosListComponent implements OnInit {
@Input() widget: boolean = false;
@Input() federationUtxos$: Observable<FederationUtxo[]>;
env: Env;
isLoading = true;
page = 1;
pageSize = 15;
maxSize = window.innerWidth <= 767.98 ? 3 : 5;
skeletonLines: number[] = [];
auditStatus$: Observable<AuditStatus>;
auditUpdated$: Observable<boolean>;
lastReservesBlockUpdate: number = 0;
currentPeg$: Observable<CurrentPegs>;
lastPegBlockUpdate: number = 0;
lastPegAmount: string = '';
isLoad: boolean = true;
private destroy$ = new Subject();
constructor(
private apiService: ApiService,
public stateService: StateService,
private websocketService: WebsocketService,
) {
}
ngOnInit(): void {
this.isLoading = !this.widget;
this.env = this.stateService.env;
this.skeletonLines = this.widget === true ? [...Array(6).keys()] : [...Array(15).keys()];
if (!this.widget) {
this.websocketService.want(['blocks']);
this.auditStatus$ = this.stateService.blocks$.pipe(
takeUntil(this.destroy$),
throttleTime(40000),
delayWhen(_ => this.isLoad ? timer(0) : timer(2000)),
tap(() => this.isLoad = false),
switchMap(() => this.apiService.federationAuditSynced$()),
shareReplay(1)
);
this.currentPeg$ = this.auditStatus$.pipe(
filter(auditStatus => auditStatus.isAuditSynced === true),
switchMap(_ =>
this.apiService.liquidPegs$().pipe(
filter((currentPegs) => currentPegs.lastBlockUpdate >= this.lastPegBlockUpdate),
tap((currentPegs) => {
this.lastPegBlockUpdate = currentPegs.lastBlockUpdate;
})
)
),
share()
);
this.auditUpdated$ = combineLatest([
this.auditStatus$,
this.currentPeg$
]).pipe(
filter(([auditStatus, _]) => auditStatus.isAuditSynced === true),
map(([auditStatus, currentPeg]) => ({
lastBlockAudit: auditStatus.lastBlockAudit,
currentPegAmount: currentPeg.amount
})),
switchMap(({ lastBlockAudit, currentPegAmount }) => {
const blockAuditCheck = lastBlockAudit > this.lastReservesBlockUpdate;
const amountCheck = currentPegAmount !== this.lastPegAmount;
this.lastReservesBlockUpdate = lastBlockAudit;
this.lastPegAmount = currentPegAmount;
return of(blockAuditCheck || amountCheck);
}),
share()
);
this.federationUtxos$ = this.auditUpdated$.pipe(
filter(auditUpdated => auditUpdated === true),
throttleTime(40000),
switchMap(_ => this.apiService.federationUtxos$()),
tap(_ => this.isLoading = false),
share()
);
}
}
ngOnDestroy(): void {
this.destroy$.next(1);
this.destroy$.complete();
}
pageChange(page: number): void {
this.page = page;
}
}

View File

@ -0,0 +1,24 @@
<div class="container-xl">
<div>
<h1 i18n="liquid.federation-wallet">Liquid Federation Wallet</h1>
</div>
<div class="nav-container">
<ul class="nav nav-pills">
<li class="nav-item">
<a class="nav-link" [routerLink]="['/audit/wallet/utxos' | relativeUrl]" routerLinkActive="active">UTXOs</a>
</li>
<li class="nav-item">
<a class="nav-link" [routerLink]="['/audit/wallet/addresses' | relativeUrl]" routerLinkActive="active"><ng-container i18n="mining.addresses">Addresses</ng-container></a>
</li>
</ul>
</div>
<div class="clearfix"></div>
<router-outlet></router-outlet>
</div>
<br>

View File

@ -0,0 +1,13 @@
ul {
margin-bottom: 20px;
}
@media (max-width: 767.98px) {
.nav-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: auto;
}
}

View File

@ -0,0 +1,20 @@
import { Component, OnInit } from '@angular/core';
import { SeoService } from '../../../services/seo.service';
@Component({
selector: 'app-federation-wallet',
templateUrl: './federation-wallet.component.html',
styleUrls: ['./federation-wallet.component.scss']
})
export class FederationWalletComponent implements OnInit {
constructor(
private seoService: SeoService
) {
this.seoService.setTitle($localize`:@@993e5bc509c26db81d93018e24a6afe6e50cae52:Liquid Federation Wallet`);
}
ngOnInit(): void {
}
}

View File

@ -0,0 +1,139 @@
<div class="container-xl">
<div [ngClass]="{'widget': widget}">
<div *ngIf="!widget">
<h1 i18n="liquid.recent-pegs">Recent Peg-In / Out's</h1>
</div>
<div class="clearfix"></div>
<div style="min-height: 295px">
<table class="table table-borderless">
<thead style="vertical-align: middle;">
<th class="transaction text-left" [ngClass]="{'widget': widget}" i18n="shared.transaction">Transaction</th>
<th class="timestamp text-left" i18n="shared.date" [ngClass]="{'widget': widget}">Date</th>
<th class="amount text-right" [ngClass]="{'widget': widget}" i18n="shared.amount">Amount</th>
<th class="output text-left" *ngIf="!widget" i18n="liquid.fund-redemption-tx">Fund / Redemption Tx</th>
<th class="address text-left" *ngIf="!widget" i18n="liquid.bitcoin-address">BTC Address</th>
</thead>
<tbody *ngIf="recentPegs$ | async as pegs; else skeleton" [style]="isLoading ? 'opacity: 0.75' : ''">
<ng-container *ngIf="widget; else regularRows">
<tr *ngFor="let peg of pegs | slice:0:6">
<td class="transaction text-left widget">
<ng-container *ngIf="peg.amount > 0">
<a [routerLink]="['/tx' | relativeUrl, peg.txid]" [fragment]="'vin=' + peg.txindex">
<app-truncate [text]="peg.txid"></app-truncate>
</a>
</ng-container>
<ng-container *ngIf="peg.amount < 0">
<a [routerLink]="['/tx' | relativeUrl, peg.txid]" [fragment]="'vout=' + peg.txindex">
<app-truncate [text]="peg.txid"></app-truncate>
</a>
</ng-container>
</td>
<td class="timestamp text-left widget">
<app-time kind="since" [time]="peg.blocktime"></app-time>
</td>
<td class="amount text-right widget" [ngClass]="{'credit': peg.amount > 0, 'debit': peg.amount < 0}">
<app-amount [satoshis]="peg.amount" [noFiat]="true" [forceBtc]="true" [addPlus]="true"></app-amount>
</td>
</tr>
</ng-container>
<ng-template #regularRows>
<tr *ngFor="let peg of pegs | slice:(page - 1) * pageSize:page * pageSize">
<td class="transaction text-left">
<ng-container *ngIf="peg.amount > 0">
<a [routerLink]="['/tx' | relativeUrl, peg.txid]" [fragment]="'vin=' + peg.txindex">
<app-truncate [text]="peg.txid" [lastChars]="6"></app-truncate>
</a>
</ng-container>
<ng-container *ngIf="peg.amount < 0">
<a [routerLink]="['/tx' | relativeUrl, peg.txid]" [fragment]="'vout=' + peg.txindex">
<app-truncate [text]="peg.txid" [lastChars]="6"></app-truncate>
</a>
</ng-container>
</td>
<td class="timestamp text-left">
&lrm;{{ peg.blocktime * 1000 | date:'yyyy-MM-dd HH:mm' }}
<div class="symbol lg-inline relative-time"><i>(<app-time kind="since" [time]="peg.blocktime"></app-time>)</i></div>
</td>
<td class="amount text-right" [ngClass]="{'credit': peg.amount > 0, 'debit': peg.amount < 0}">
<app-amount [satoshis]="peg.amount" [noFiat]="true" [forceBtc]="true" [addPlus]="true"></app-amount>
</td>
<td class="output text-left">
<ng-container *ngIf="peg.bitcointxid; else redeemInProgress">
<a href="{{ env.MEMPOOL_WEBSITE_URL + '/tx/' + peg.bitcointxid + ':' + peg.bitcoinindex }}" target="_blank" style="color:#b86d12">
<app-truncate [text]="peg.bitcointxid + ':' + peg.bitcoinindex" [lastChars]="6"></app-truncate>
</a>
</ng-container>
<ng-template #redeemInProgress>
<ng-container *ngIf="peg.bitcoinaddress; else noRedeem">
<i><span class="text-muted" i18n="liquid.redemption-in-progress">Peg out in progress...</span></i>
</ng-container>
</ng-template>
</td>
<td class="address text-left">
<ng-container *ngIf="peg.bitcoinaddress; else noRedeem">
<a href="{{ env.MEMPOOL_WEBSITE_URL + '/address/' + peg.bitcoinaddress }}" target="_blank" style="color:#b86d12">
<app-truncate [text]="peg.bitcoinaddress" [lastChars]="6"></app-truncate>
</a>
</ng-container>
</td>
</tr>
</ng-template>
</tbody>
<ng-template #skeleton>
<tbody *ngIf="widget; else regularRowsSkeleton">
<tr *ngFor="let item of skeletonLines">
<td class="transaction text-left widget">
<span class="skeleton-loader" style="max-width: 400px"></span>
</td>
<td class="timestamp text-left widget">
<span class="skeleton-loader" style="max-width: 300px"></span>
</td>
<td class="amount text-right widget">
<span class="skeleton-loader" style="max-width: 300px"></span>
</td>
</tr>
</tbody>
<ng-template #regularRowsSkeleton>
<tr *ngFor="let item of skeletonLines">
<td class="transaction text-left">
<span class="skeleton-loader" style="max-width: 300px"></span>
</td>
<td class="timestamp text-left">
<span class="skeleton-loader" style="max-width: 140px"></span>
</td>
<td class="amount text-right">
<span class="skeleton-loader" style="max-width: 140px"></span>
</td>
<td class="output text-left">
<span class="skeleton-loader" style="max-width: 300px"></span>
</td>
<td class="address text-left">
<span class="skeleton-loader" style="max-width: 140px"></span>
</td>
</tr>
</ng-template>
</ng-template>
</table>
<ngb-pagination *ngIf="!widget && recentPegs$ | async as pegs" class="pagination-container float-right mt-2" [class]="isLoading ? 'disabled' : ''"
[collectionSize]="pegs.length" [rotate]="true" [maxSize]="maxSize" [pageSize]="15" [(page)]="page"
(pageChange)="pageChange(page)" [boundaryLinks]="true" [ellipses]="false">
</ngb-pagination>
<ng-template [ngIf]="!widget">
<div class="clearfix"></div>
<br>
</ng-template>
</div>
</div>
</div>
<br>
<ng-template #noRedeem>
<span class="text-muted">-</span>
</ng-template>

View File

@ -0,0 +1,107 @@
.spinner-border {
height: 25px;
width: 25px;
margin-top: 13px;
}
tr, td, th {
border: 0px;
padding-top: 0.65rem !important;
padding-bottom: 0.6rem !important;
padding-right: 2rem !important;
.widget {
padding-right: 1rem !important;
}
}
.clear-link {
color: white;
}
.disabled {
pointer-events: none;
opacity: 0.5;
}
.progress {
background-color: #2d3348;
}
.transaction {
width: 20%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 120px;
}
.transaction.widget {
width: 100%;
}
.address {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 160px;
@media (max-width: 527px) {
display: none;
}
}
.amount {
width: 0%;
}
.output {
width: 20%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 160px;
@media (max-width: 800px) {
display: none;
}
}
.address {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 160px;
@media (max-width: 960px) {
display: none;
}
}
.timestamp {
width: 0%;
@media (max-width: 650px) {
display: none;
}
@media (max-width: 1000px) {
.relative-time {
display: none;
}
}
}
.timestamp.widget {
@media (min-width: 768px) AND (max-width: 1050px) {
display: none;
}
@media (max-width: 767px) {
display: block;
}
@media (max-width: 500px) {
display: none;
}
}
.credit {
color: #7CB342;
}
.debit {
color: #D81B60;
}

View File

@ -0,0 +1,154 @@
import { Component, OnInit, ChangeDetectionStrategy, Input } from '@angular/core';
import { Observable, Subject, combineLatest, of, timer } from 'rxjs';
import { delayWhen, filter, map, share, shareReplay, switchMap, takeUntil, tap, throttleTime } from 'rxjs/operators';
import { ApiService } from '../../../services/api.service';
import { Env, StateService } from '../../../services/state.service';
import { AuditStatus, CurrentPegs, FederationUtxo, RecentPeg } from '../../../interfaces/node-api.interface';
import { WebsocketService } from '../../../services/websocket.service';
import { SeoService } from '../../../services/seo.service';
@Component({
selector: 'app-recent-pegs-list',
templateUrl: './recent-pegs-list.component.html',
styleUrls: ['./recent-pegs-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RecentPegsListComponent implements OnInit {
@Input() widget: boolean = false;
@Input() recentPegIns$: Observable<RecentPeg[]> = of([]);
@Input() recentPegOuts$: Observable<RecentPeg[]> = of([]);
env: Env;
isLoading = true;
page = 1;
pageSize = 15;
maxSize = window.innerWidth <= 767.98 ? 3 : 5;
skeletonLines: number[] = [];
auditStatus$: Observable<AuditStatus>;
auditUpdated$: Observable<boolean>;
federationUtxos$: Observable<FederationUtxo[]>;
recentPegs$: Observable<RecentPeg[]>;
lastReservesBlockUpdate: number = 0;
currentPeg$: Observable<CurrentPegs>;
lastPegBlockUpdate: number = 0;
lastPegAmount: string = '';
isLoad: boolean = true;
private destroy$ = new Subject();
constructor(
private apiService: ApiService,
public stateService: StateService,
private websocketService: WebsocketService,
private seoService: SeoService
) {
}
ngOnInit(): void {
this.isLoading = !this.widget;
this.env = this.stateService.env;
this.skeletonLines = this.widget === true ? [...Array(6).keys()] : [...Array(15).keys()];
if (!this.widget) {
this.seoService.setTitle($localize`:@@a8b0889ea1b41888f1e247f2731cc9322198ca04:Recent Peg-In / Out's`);
this.websocketService.want(['blocks']);
this.auditStatus$ = this.stateService.blocks$.pipe(
takeUntil(this.destroy$),
throttleTime(40000),
delayWhen(_ => this.isLoad ? timer(0) : timer(2000)),
tap(() => this.isLoad = false),
switchMap(() => this.apiService.federationAuditSynced$()),
shareReplay(1)
);
this.currentPeg$ = this.auditStatus$.pipe(
filter(auditStatus => auditStatus.isAuditSynced === true),
switchMap(_ =>
this.apiService.liquidPegs$().pipe(
filter((currentPegs) => currentPegs.lastBlockUpdate >= this.lastPegBlockUpdate),
tap((currentPegs) => {
this.lastPegBlockUpdate = currentPegs.lastBlockUpdate;
})
)
),
share()
);
this.auditUpdated$ = combineLatest([
this.auditStatus$,
this.currentPeg$
]).pipe(
filter(([auditStatus, _]) => auditStatus.isAuditSynced === true),
map(([auditStatus, currentPeg]) => ({
lastBlockAudit: auditStatus.lastBlockAudit,
currentPegAmount: currentPeg.amount
})),
switchMap(({ lastBlockAudit, currentPegAmount }) => {
const blockAuditCheck = lastBlockAudit > this.lastReservesBlockUpdate;
const amountCheck = currentPegAmount !== this.lastPegAmount;
this.lastReservesBlockUpdate = lastBlockAudit;
this.lastPegAmount = currentPegAmount;
return of(blockAuditCheck || amountCheck);
}),
share()
);
this.federationUtxos$ = this.auditUpdated$.pipe(
filter(auditUpdated => auditUpdated === true),
throttleTime(40000),
switchMap(_ => this.apiService.federationUtxos$()),
share()
);
this.recentPegIns$ = this.federationUtxos$.pipe(
map(federationUtxos => federationUtxos.filter(utxo => utxo.pegtxid).map(utxo => {
return {
txid: utxo.pegtxid,
txindex: utxo.pegindex,
amount: utxo.amount,
bitcoinaddress: utxo.bitcoinaddress,
bitcointxid: utxo.txid,
bitcoinindex: utxo.txindex,
blocktime: utxo.pegblocktime,
}
})),
share()
);
this.recentPegOuts$ = this.auditUpdated$.pipe(
filter(auditUpdated => auditUpdated === true),
throttleTime(40000),
switchMap(_ => this.apiService.recentPegOuts$()),
share()
);
}
this.recentPegs$ = combineLatest([
this.recentPegIns$,
this.recentPegOuts$
]).pipe(
map(([recentPegIns, recentPegOuts]) => {
return [
...recentPegIns,
...recentPegOuts
].sort((a, b) => {
return b.blocktime - a.blocktime;
});
}),
filter(recentPegs => recentPegs.length > 0),
tap(_ => this.isLoading = false),
share()
);
}
ngOnDestroy(): void {
this.destroy$.next(1);
this.destroy$.complete();
}
pageChange(page: number): void {
this.page = page;
}
}

View File

@ -0,0 +1,7 @@
<div class="fee-estimation-container">
<div class="item">
<a class="title-link" [routerLink]="['/audit/pegs' | relativeUrl]">
<h5 class="card-title"><ng-container i18n="liquid.recent-pegs">Recent Peg-In / Out's</ng-container>&nbsp;<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="font-size: 13px; color: #4a68b9"></fa-icon></h5>
</a>
</div>
</div>

View File

@ -0,0 +1,71 @@
.fee-estimation-container {
display: flex;
justify-content: space-between;
padding-bottom: 1rem;
@media (min-width: 376px) {
flex-direction: row;
}
.item {
max-width: 300px;
margin: 0;
width: -webkit-fill-available;
@media (min-width: 376px) {
margin: 0 auto 0px;
}
.card-title {
margin: 0;
color: #4a68b9;
font-size: 10px;
font-size: 1rem;
white-space: nowrap;
}
.card-text {
font-size: 22px;
span {
font-size: 11px;
position: relative;
top: -2px;
}
}
.card-text span {
color: #ffffff66;
font-size: 12px;
top: 0px;
}
.fee-text{
width: fit-content;
margin: auto;
line-height: 1.45;
padding: 0px 2px;
}
.fiat {
display: block;
font-size: 14px !important;
}
}
}
.card-text {
.skeleton-loader {
width: 100%;
display: block;
&:first-child {
max-width: 90px;
margin: 15px auto 3px;
}
&:last-child {
margin: 10px auto 3px;
max-width: 55px;
}
}
}
.title-link, .title-link:hover, .title-link:focus, .title-link:active {
display: block;
margin-bottom: 4px;
text-decoration: none;
color: inherit;
}

View File

@ -0,0 +1,15 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
@Component({
selector: 'app-recent-pegs-stats',
templateUrl: './recent-pegs-stats.component.html',
styleUrls: ['./recent-pegs-stats.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RecentPegsStatsComponent implements OnInit {
constructor() { }
ngOnInit(): void {
}
}

View File

@ -0,0 +1,98 @@
<div class="container-xl dashboard-container" *ngIf="(auditStatus$ | async)?.isAuditSynced; else auditInProgress">
<div class="row row-cols-1 row-cols-md-2">
<div class="col">
<div class="card">
<div class="card-body">
<app-reserves-supply-stats [currentPeg$]="currentPeg$" [currentReserves$]="currentReserves$"></app-reserves-supply-stats>
<app-reserves-ratio [currentPeg]="currentPeg$ | async" [currentReserves]="currentReserves$ | async"></app-reserves-ratio>
</div>
</div>
</div>
<div class="col" style="margin-bottom: 1.47rem">
<div class="card">
<div class="card-title">
<app-reserves-ratio-stats [fullHistory$]="fullHistory$"></app-reserves-ratio-stats>
</div>
<div class="card-body pl-0" style="padding-top: 10px;">
<app-reserves-ratio-graph [data]="fullHistory$ | async"></app-reserves-ratio-graph>
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-body">
<app-recent-pegs-stats></app-recent-pegs-stats>
<app-recent-pegs-list [recentPegIns$]="recentPegIns$" [recentPegOuts$]="recentPegOuts$"[widget]="true"></app-recent-pegs-list>
</div>
</div>
</div>
<div class="col" style="margin-bottom: 1.47rem">
<div class="card">
<div class="card-body">
<app-federation-addresses-stats [federationAddresses$]="federationAddresses$" [federationAddressesOneMonthAgo$]="federationAddressesOneMonthAgo$"></app-federation-addresses-stats>
<app-federation-addresses-list [federationAddresses$]="federationAddresses$" [widget]="true"></app-federation-addresses-list>
</div>
</div>
</div>
</div>
</div>
<ng-template #loadingSkeleton>
<div class="container-xl dashboard-container">
<div class="row row-cols-1 row-cols-md-2">
<div class="col">
<div class="card">
<div class="card-body">
<app-reserves-supply-stats></app-reserves-supply-stats>
<app-reserves-ratio></app-reserves-ratio>
</div>
</div>
</div>
<div class="col" style="margin-bottom: 1.47rem">
<div class="card">
<div class="card-title">
<app-reserves-ratio-stats></app-reserves-ratio-stats>
</div>
<div class="card-body pl-0" style="padding-top: 10px;">
<app-reserves-ratio-graph></app-reserves-ratio-graph>
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-body">
<app-recent-pegs-stats></app-recent-pegs-stats>
<app-recent-pegs-list [widget]="true"></app-recent-pegs-list>
</div>
</div>
</div>
<div class="col" style="margin-bottom: 1.47rem">
<div class="card">
<div class="card-body">
<app-federation-addresses-stats></app-federation-addresses-stats>
<app-federation-addresses-list [widget]="true"></app-federation-addresses-list>
</div>
</div>
</div>
</div>
</div>
</ng-template>
<ng-template #auditInProgress>
<ng-container *ngIf="(auditStatus$ | async) as auditStatus; else loadingSkeleton">
<div class="in-progress-message" *ngIf="auditStatus.lastBlockAudit && auditStatus.bitcoinHeaders; else loadingSkeleton">
<span i18n="liquid.audit-in-progress">Audit in progress: Bitcoin block height #{{ auditStatus.lastBlockAudit }} / #{{ auditStatus.bitcoinHeaders }}</span>
</div>
</ng-container>
</ng-template>

View File

@ -0,0 +1,138 @@
.dashboard-container {
text-align: center;
margin-top: 0.5rem;
.col {
margin-bottom: 1.5rem;
}
}
.card {
background-color: #1d1f31;
}
.card-title {
padding-top: 20px;
}
.card-body.pool-ranking {
padding: 1.25rem 0.25rem 0.75rem 0.25rem;
}
.card-text {
font-size: 22px;
}
#blockchain-container {
position: relative;
overflow-x: scroll;
overflow-y: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
}
#blockchain-container::-webkit-scrollbar {
display: none;
}
.fade-border {
-webkit-mask-image: linear-gradient(to right, transparent 0%, black 10%, black 80%, transparent 100%)
}
.in-progress-message {
position: relative;
color: #ffffff91;
margin-top: 20px;
text-align: center;
padding-bottom: 3px;
font-weight: 500;
}
.more-padding {
padding: 24px 20px !important;
}
.card-wrapper {
.card {
height: auto !important;
}
.card-body {
display: flex;
flex: inherit;
text-align: center;
flex-direction: column;
justify-content: space-around;
padding: 22px 20px;
}
}
.skeleton-loader {
width: 100%;
display: block;
&:first-child {
max-width: 90px;
margin: 15px auto 3px;
}
&:last-child {
margin: 10px auto 3px;
max-width: 55px;
}
}
.card-text {
font-size: 22px;
}
.title-link, .title-link:hover, .title-link:focus, .title-link:active {
display: block;
margin-bottom: 10px;
text-decoration: none;
color: inherit;
}
.lastest-blocks-table {
width: 100%;
text-align: left;
tr, td, th {
border: 0px;
padding-top: 0.65rem !important;
padding-bottom: 0.8rem !important;
}
.table-cell-height {
width: 25%;
}
.table-cell-fee {
width: 25%;
text-align: right;
}
.table-cell-pool {
text-align: left;
width: 30%;
@media (max-width: 875px) {
display: none;
}
.pool-name {
margin-left: 1em;
}
}
.table-cell-acceleration-count {
text-align: right;
width: 20%;
}
}
.card {
height: 385px;
}
.list-card {
height: 410px;
@media (max-width: 767px) {
height: auto;
}
}
.mempool-block-wrapper {
max-height: 380px;
max-width: 380px;
margin: auto;
}

View File

@ -0,0 +1,204 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { SeoService } from '../../../services/seo.service';
import { WebsocketService } from '../../../services/websocket.service';
import { StateService } from '../../../services/state.service';
import { Observable, Subject, combineLatest, delayWhen, filter, interval, map, of, share, shareReplay, startWith, switchMap, takeUntil, tap, throttleTime, timer } from 'rxjs';
import { ApiService } from '../../../services/api.service';
import { AuditStatus, CurrentPegs, FederationAddress, FederationUtxo, RecentPeg } from '../../../interfaces/node-api.interface';
@Component({
selector: 'app-reserves-audit-dashboard',
templateUrl: './reserves-audit-dashboard.component.html',
styleUrls: ['./reserves-audit-dashboard.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ReservesAuditDashboardComponent implements OnInit {
auditStatus$: Observable<AuditStatus>;
auditUpdated$: Observable<boolean>;
currentPeg$: Observable<CurrentPegs>;
currentReserves$: Observable<CurrentPegs>;
federationUtxos$: Observable<FederationUtxo[]>;
recentPegIns$: Observable<RecentPeg[]>;
recentPegOuts$: Observable<RecentPeg[]>;
federationAddresses$: Observable<FederationAddress[]>;
federationAddressesOneMonthAgo$: Observable<any>;
liquidPegsMonth$: Observable<any>;
liquidReservesMonth$: Observable<any>;
fullHistory$: Observable<any>;
isLoad: boolean = true;
private lastPegBlockUpdate: number = 0;
private lastPegAmount: string = '';
private lastReservesBlockUpdate: number = 0;
private destroy$ = new Subject();
constructor(
private seoService: SeoService,
private websocketService: WebsocketService,
private apiService: ApiService,
private stateService: StateService,
) {
this.seoService.setTitle($localize`:@@liquid.reserves-audit:Reserves Audit Dashboard`);
}
ngOnInit(): void {
this.websocketService.want(['blocks', 'mempool-blocks']);
this.auditStatus$ = this.stateService.blocks$.pipe(
takeUntil(this.destroy$),
throttleTime(40000),
delayWhen(_ => this.isLoad ? timer(0) : timer(2000)),
tap(() => this.isLoad = false),
switchMap(() => this.apiService.federationAuditSynced$()),
shareReplay(1),
);
this.currentPeg$ = this.auditStatus$.pipe(
filter(auditStatus => auditStatus.isAuditSynced === true),
switchMap(_ =>
this.apiService.liquidPegs$().pipe(
filter((currentPegs) => currentPegs.lastBlockUpdate >= this.lastPegBlockUpdate),
tap((currentPegs) => {
this.lastPegBlockUpdate = currentPegs.lastBlockUpdate;
})
)
),
share()
);
this.auditUpdated$ = combineLatest([
this.auditStatus$,
this.currentPeg$
]).pipe(
filter(([auditStatus, _]) => auditStatus.isAuditSynced === true),
map(([auditStatus, currentPeg]) => ({
lastBlockAudit: auditStatus.lastBlockAudit,
currentPegAmount: currentPeg.amount
})),
switchMap(({ lastBlockAudit, currentPegAmount }) => {
const blockAuditCheck = lastBlockAudit > this.lastReservesBlockUpdate;
const amountCheck = currentPegAmount !== this.lastPegAmount;
this.lastPegAmount = currentPegAmount;
return of(blockAuditCheck || amountCheck);
}),
share()
);
this.currentReserves$ = this.auditUpdated$.pipe(
filter(auditUpdated => auditUpdated === true),
throttleTime(40000),
switchMap(_ =>
this.apiService.liquidReserves$().pipe(
filter((currentReserves) => currentReserves.lastBlockUpdate >= this.lastReservesBlockUpdate),
tap((currentReserves) => {
this.lastReservesBlockUpdate = currentReserves.lastBlockUpdate;
})
)
),
share()
);
this.federationUtxos$ = this.auditUpdated$.pipe(
filter(auditUpdated => auditUpdated === true),
throttleTime(40000),
switchMap(_ => this.apiService.federationUtxos$()),
share()
);
this.recentPegIns$ = this.federationUtxos$.pipe(
map(federationUtxos => federationUtxos.filter(utxo => utxo.pegtxid).map(utxo => {
return {
txid: utxo.pegtxid,
txindex: utxo.pegindex,
amount: utxo.amount,
bitcoinaddress: utxo.bitcoinaddress,
bitcointxid: utxo.txid,
bitcoinindex: utxo.txindex,
blocktime: utxo.pegblocktime,
}
})),
share()
);
this.recentPegOuts$ = this.auditUpdated$.pipe(
filter(auditUpdated => auditUpdated === true),
throttleTime(40000),
switchMap(_ => this.apiService.recentPegOuts$()),
share()
);
this.federationAddresses$ = this.auditUpdated$.pipe(
filter(auditUpdated => auditUpdated === true),
throttleTime(40000),
switchMap(_ => this.apiService.federationAddresses$()),
share()
);
this.federationAddressesOneMonthAgo$ = interval(60 * 60 * 1000)
.pipe(
startWith(0),
switchMap(() => this.apiService.federationAddressesOneMonthAgo$())
);
this.liquidPegsMonth$ = interval(60 * 60 * 1000)
.pipe(
startWith(0),
switchMap(() => this.apiService.listLiquidPegsMonth$()),
map((pegs) => {
const labels = pegs.map(stats => stats.date);
const series = pegs.map(stats => parseFloat(stats.amount) / 100000000);
series.reduce((prev, curr, i) => series[i] = prev + curr, 0);
return {
series,
labels
};
}),
share(),
);
this.liquidReservesMonth$ = interval(60 * 60 * 1000).pipe(
startWith(0),
switchMap(() => this.apiService.listLiquidReservesMonth$()),
map(reserves => {
const labels = reserves.map(stats => stats.date);
const series = reserves.map(stats => parseFloat(stats.amount) / 100000000);
return {
series,
labels
};
}),
share()
);
this.fullHistory$ = combineLatest([this.liquidPegsMonth$, this.currentPeg$, this.liquidReservesMonth$, this.currentReserves$])
.pipe(
map(([liquidPegs, currentPeg, liquidReserves, currentReserves]) => {
liquidPegs.series[liquidPegs.series.length - 1] = parseFloat(currentPeg.amount) / 100000000;
if (liquidPegs.series.length === liquidReserves?.series.length) {
liquidReserves.series[liquidReserves.series.length - 1] = parseFloat(currentReserves?.amount) / 100000000;
} else if (liquidPegs.series.length === liquidReserves?.series.length + 1) {
liquidReserves.series.push(parseFloat(currentReserves?.amount) / 100000000);
liquidReserves.labels.push(liquidPegs.labels[liquidPegs.labels.length - 1]);
} else {
liquidReserves = {
series: [],
labels: []
};
}
return {
liquidPegs,
liquidReserves
};
}),
share()
);
}
ngOnDestroy(): void {
this.destroy$.next(1);
this.destroy$.complete();
}
}

View File

@ -0,0 +1,42 @@
<div *ngIf="(unbackedMonths$ | async) as unbackedMonths; else loadingData">
<ng-container *ngIf="unbackedMonths.historyComplete; else loadingData">
<div class="fee-estimation-container">
<div class="item">
<h5 class="card-title" i18n="liquid.unpeg">Unpeg</h5>
<div class="card-text">
<div class="fee-text" [ngClass]="{'danger' : unbackedMonths.total > 0, 'correct': unbackedMonths.total === 0}">
{{ unbackedMonths.total }} <span i18n="liquid.unpeg-event">Unpeg Event</span>
</div>
</div>
</div>
<div class="item">
<h5 class="card-title" i18n="liquid.avg-peg-ratio">Avg Peg Ratio</h5>
<div class="card-text">
<div class="fee-text" [ngClass]="{'danger' : unbackedMonths.avg < 1, 'correct': unbackedMonths.avg >= 1}">
{{ unbackedMonths.avg.toFixed(5) }}
</div>
</div>
</div>
</div>
</ng-container>
</div>
<ng-template #loadingData>
<div class="fee-estimation-container loading-container">
<div class="item">
<h5 class="card-title" i18n="liquid.unpeg">Unpeg</h5>
<div class="card-text">
<div class="skeleton-loader"></div>
</div>
</div>
<div class="item">
<h5 class="card-title" i18n="liquid.avg-peg-ratio">Avg Peg Ratio</h5>
<div class="card-text">
<div class="skeleton-loader"></div>
</div>
</div>
</div>
</ng-template>

View File

@ -0,0 +1,63 @@
.fee-estimation-container {
display: flex;
justify-content: space-between;
@media (min-width: 376px) {
flex-direction: row;
}
.item {
max-width: 300px;
margin: 0;
width: -webkit-fill-available;
@media (min-width: 376px) {
margin: 0 auto 0px;
}
.card-title {
margin-bottom: 4px;
color: #4a68b9;
font-size: 10px;
font-size: 1rem;
white-space: nowrap;
}
.card-text {
font-size: 22px;
span {
font-size: 11px;
position: relative;
top: -2px;
}
.danger {
color: #D81B60;
}
.correct {
color: #7CB342;
}
}
.card-text span {
color: #ffffff66;
font-size: 12px;
top: 0px;
}
.fee-text{
width: fit-content;
margin: auto;
line-height: 1.45;
padding: 0px 2px;
}
}
}
.loading-container{
min-height: 76px;
}
.card-text {
.skeleton-loader {
width: 100%;
display: block;
max-width: 90px;
margin: 15px auto 3px;
}
}

View File

@ -0,0 +1,51 @@
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
import { Observable, map } from 'rxjs';
@Component({
selector: 'app-reserves-ratio-stats',
templateUrl: './reserves-ratio-stats.component.html',
styleUrls: ['./reserves-ratio-stats.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ReservesRatioStatsComponent implements OnInit {
@Input() fullHistory$: Observable<any>;
unbackedMonths$: Observable<any>
constructor() { }
ngOnInit(): void {
if (!this.fullHistory$) {
return;
}
this.unbackedMonths$ = this.fullHistory$
.pipe(
map((fullHistory) => {
if (fullHistory.liquidPegs.series.length !== fullHistory.liquidReserves.series.length) {
return {
historyComplete: false,
total: null
};
}
// Only check the last 3 years
let ratioSeries = fullHistory.liquidReserves.series.map((value: number, index: number) => value / fullHistory.liquidPegs.series[index]);
ratioSeries = ratioSeries.slice(Math.max(ratioSeries.length - 36, 0));
let total = 0;
let avg = 0;
for (let i = 0; i < ratioSeries.length; i++) {
avg += ratioSeries[i];
if (ratioSeries[i] < 1) {
total++;
}
}
avg = avg / ratioSeries.length;
return {
historyComplete: true,
total: total,
avg: avg,
};
})
);
}
}

View File

@ -0,0 +1,4 @@
<div class="echarts" echarts [initOpts]="ratioHistoryChartInitOptions" [options]="ratioHistoryChartOptions" (chartRendered)="rendered()"></div>
<div class="text-center loadingGraphs" *ngIf="isLoading">
<div class="spinner-border text-light"></div>
</div>

View File

@ -0,0 +1,6 @@
.loadingGraphs {
position: absolute;
top: 50%;
left: calc(50% - 16px);
z-index: 100;
}

View File

@ -0,0 +1,195 @@
import { Component, Inject, LOCALE_ID, ChangeDetectionStrategy, Input, OnChanges, OnInit } from '@angular/core';
import { formatDate, formatNumber } from '@angular/common';
import { EChartsOption } from '../../../graphs/echarts';
@Component({
selector: 'app-reserves-ratio-graph',
templateUrl: './reserves-ratio-graph.component.html',
styleUrls: ['./reserves-ratio-graph.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ReservesRatioGraphComponent implements OnInit, OnChanges {
@Input() data: any;
ratioHistoryChartOptions: EChartsOption;
ratioSeries: number[] = [];
height: number | string = '200';
right: number | string = '10';
top: number | string = '20';
left: number | string = '50';
template: ('widget' | 'advanced') = 'widget';
isLoading = true;
ratioHistoryChartInitOptions = {
renderer: 'svg'
};
constructor(
@Inject(LOCALE_ID) private locale: string,
) { }
ngOnInit() {
this.isLoading = true;
}
ngOnChanges() {
if (!this.data) {
return;
}
// Compute the ratio series: the ratio of the reserves to the pegs
this.ratioSeries = this.data.liquidReserves.series.map((value: number, index: number) => value / this.data.liquidPegs.series[index]);
// Truncate the ratio series and labels series to last 3 years
this.ratioSeries = this.ratioSeries.slice(Math.max(this.ratioSeries.length - 36, 0));
this.data.liquidPegs.labels = this.data.liquidPegs.labels.slice(Math.max(this.data.liquidPegs.labels.length - 36, 0));
// Cut the values that are too high or too low
this.ratioSeries = this.ratioSeries.map((value: number) => Math.min(Math.max(value, 0.995), 1.005));
this.ratioHistoryChartOptions = this.createChartOptions(this.ratioSeries, this.data.liquidPegs.labels);
}
rendered() {
if (!this.data) {
return;
}
this.isLoading = false;
}
createChartOptions(ratioSeries: number[], labels: string[]): EChartsOption {
return {
grid: {
height: this.height,
right: this.right,
top: this.top,
left: this.left,
},
animation: false,
dataZoom: [{
type: 'inside',
realtime: true,
zoomOnMouseWheel: (this.template === 'advanced') ? true : false,
maxSpan: 100,
minSpan: 10,
}, {
show: (this.template === 'advanced') ? true : false,
type: 'slider',
brushSelect: false,
realtime: true,
selectedDataBackground: {
lineStyle: {
color: '#fff',
opacity: 0.45,
},
areaStyle: {
opacity: 0,
}
}
}],
tooltip: {
trigger: 'axis',
position: (pos, params, el, elRect, size) => {
const obj = { top: -20 };
obj[['left', 'right'][+(pos[0] < size.viewSize[0] / 2)]] = 80;
return obj;
},
extraCssText: `width: ${(this.template === 'widget') ? '125px' : '135px'};
background: transparent;
border: none;
box-shadow: none;`,
axisPointer: {
type: 'line',
},
formatter: (params: any) => {
const colorSpan = (color: string) => `<span class="indicator" style="background-color: ${color};"></span>`;
let itemFormatted = '<div class="title">' + params[0].axisValue + '</div>';
const item = params[0];
const formattedValue = formatNumber(item.value, this.locale, '1.5-5');
const symbol = (item.value === 1.005) ? '≥ ' : (item.value === 0.995) ? '≤ ' : '';
itemFormatted += `<div class="item">
<div class="indicator-container">${colorSpan(item.color)}</div>
<div style="margin-right: 5px"></div>
<div class="value">${symbol}${formattedValue}</div>
</div>`;
return `<div class="tx-wrapper-tooltip-chart ${(this.template === 'advanced') ? 'tx-wrapper-tooltip-chart-advanced' : ''}">${itemFormatted}</div>`;
}
},
xAxis: {
type: 'category',
axisLabel: {
align: 'center',
fontSize: 11,
lineHeight: 12
},
boundaryGap: false,
data: labels.map((value: any) => `${formatDate(value, 'MMM\ny', this.locale)}`),
},
yAxis: {
type: 'value',
axisLabel: {
fontSize: 11,
},
splitLine: {
lineStyle: {
type: 'dotted',
color: '#ffffff66',
opacity: 0.25,
}
},
min: 0.995,
max: 1.005,
},
series: [
{
data: ratioSeries,
name: '',
type: 'line',
smooth: true,
showSymbol: false,
lineStyle: {
width: 3,
},
markLine: {
silent: true,
symbol: 'none',
lineStyle: {
color: '#fff',
opacity: 1,
width: 1,
},
data: [{
yAxis: 1,
label: {
show: false,
color: '#ffffff',
}
}],
},
},
],
visualMap: {
show: false,
top: 50,
right: 10,
pieces: [{
gt: 0,
lte: 0.999,
color: '#D81B60'
},
{
gt: 0.999,
lte: 1.001,
color: '#FDD835'
},
{
gt: 1.001,
lte: 2,
color: '#7CB342'
}
],
outOfRange: {
color: '#999'
}
},
};
}
}

View File

@ -0,0 +1,4 @@
<div class="echarts" echarts [initOpts]="ratioChartInitOptions" [options]="ratioChartOptions" (chartRendered)="rendered()"></div>
<div class="text-center loadingGraphs" *ngIf="isLoading">
<div class="spinner-border text-light"></div>
</div>

View File

@ -0,0 +1,6 @@
.loadingGraphs {
position: absolute;
top: 50%;
left: calc(50% - 16px);
z-index: 100;
}

View File

@ -0,0 +1,126 @@
import { Component, ChangeDetectionStrategy, Input, OnChanges, OnInit } from '@angular/core';
import { EChartsOption } from '../../../graphs/echarts';
import { CurrentPegs } from '../../../interfaces/node-api.interface';
@Component({
selector: 'app-reserves-ratio',
templateUrl: './reserves-ratio.component.html',
styleUrls: ['./reserves-ratio.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ReservesRatioComponent implements OnInit, OnChanges {
@Input() currentPeg: CurrentPegs;
@Input() currentReserves: CurrentPegs;
ratioChartOptions: EChartsOption;
height: number | string = '200';
right: number | string = '10';
top: number | string = '20';
left: number | string = '50';
template: ('widget' | 'advanced') = 'widget';
isLoading = true;
ratioChartInitOptions = {
renderer: 'svg'
};
constructor() { }
ngOnInit() {
this.isLoading = true;
}
ngOnChanges() {
if (!this.currentPeg || !this.currentReserves || this.currentPeg.amount === '0') {
return;
}
this.ratioChartOptions = this.createChartOptions(this.currentPeg, this.currentReserves);
}
rendered() {
if (!this.currentPeg || !this.currentReserves) {
return;
}
this.isLoading = false;
}
createChartOptions(currentPeg: CurrentPegs, currentReserves: CurrentPegs): EChartsOption {
return {
series: [
{
type: 'gauge',
startAngle: 180,
endAngle: 0,
center: ['50%', '70%'],
radius: '100%',
min: 0.999,
max: 1.001,
splitNumber: 2,
axisLine: {
lineStyle: {
width: 6,
color: [
[0.49, '#D81B60'],
[1, '#7CB342']
]
}
},
axisLabel: {
color: 'inherit',
fontFamily: 'inherit',
},
pointer: {
icon: 'path://M2090.36389,615.30999 L2090.36389,615.30999 C2091.48372,615.30999 2092.40383,616.194028 2092.44859,617.312956 L2096.90698,728.755929 C2097.05155,732.369577 2094.2393,735.416212 2090.62566,735.56078 C2090.53845,735.564269 2090.45117,735.566014 2090.36389,735.566014 L2090.36389,735.566014 C2086.74736,735.566014 2083.81557,732.63423 2083.81557,729.017692 C2083.81557,728.930412 2083.81732,728.84314 2083.82081,728.755929 L2088.2792,617.312956 C2088.32396,616.194028 2089.24407,615.30999 2090.36389,615.30999 Z',
length: '50%',
width: 16,
offsetCenter: [0, '-27%'],
itemStyle: {
color: 'auto'
}
},
axisTick: {
length: 12,
lineStyle: {
color: 'auto',
width: 2
}
},
splitLine: {
length: 20,
lineStyle: {
color: 'auto',
width: 5
}
},
title: {
show: true,
offsetCenter: [0, '-117.5%'],
fontSize: 18,
color: '#4a68b9',
fontFamily: 'inherit',
fontWeight: 500,
},
detail: {
fontSize: 25,
offsetCenter: [0, '-0%'],
valueAnimation: true,
fontFamily: 'inherit',
fontWeight: 500,
formatter: function (value) {
return (value).toFixed(5);
},
color: 'inherit'
},
data: [
{
value: parseFloat(currentReserves.amount) / parseFloat(currentPeg.amount),
name: 'Peg-O-Meter'
}
]
}
]
};
}
}

View File

@ -0,0 +1,44 @@
<div *ngIf="(currentPeg$ | async) as currentPeg; else loadingData">
<div *ngIf="(currentReserves$ | async) as currentReserves; else loadingData">
<div class="fee-estimation-container">
<div class="item">
<h5 class="card-title" i18n="dashboard.lbtc-pegs-in-circulation">L-BTC in circulation</h5>
<div class="card-text">
<div class="fee-text">{{ (+currentPeg.amount) / 100000000 | number: '1.2-2' }} <span>L-BTC</span></div>
<span class="fiat">
<span>As of block&nbsp;<a [routerLink]="['/block', currentPeg.hash]">{{ currentPeg.lastBlockUpdate }}</a></span>
</span>
</div>
</div>
<div class="item">
<h5 class="card-title" i18n="dashboard.btc-reserves">BTC Reserves</h5>
<div class="card-text">
<div class="fee-text">{{ (+currentReserves.amount) / 100000000 | number: '1.2-2' }} <span style="color: #b86d12;">BTC</span></div>
<span class="fiat">
<span>As of block&nbsp;<a href="{{ env.MEMPOOL_WEBSITE_URL + '/block/' + currentReserves.hash }}" target="_blank" style="color:#b86d12">{{ currentReserves.lastBlockUpdate }}</a></span>
</span>
</div>
</div>
</div>
</div>
</div>
<ng-template #loadingData>
<div class="fee-estimation-container loading-container">
<div class="item">
<h5 class="card-title" i18n="dashboard.lbtc-pegs-in-circulation">L-BTC in circulation</h5>
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
</div>
</div>
<div class="item">
<h5 class="card-title" i18n="dashboard.btc-reserves">BTC Reserves</h5>
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
</div>
</div>
</div>
</ng-template>

View File

@ -0,0 +1,73 @@
.fee-estimation-container {
display: flex;
justify-content: space-between;
@media (min-width: 376px) {
flex-direction: row;
}
.item {
max-width: 150px;
margin: 0;
width: -webkit-fill-available;
@media (min-width: 376px) {
margin: 0 auto 0px;
}
.card-title {
color: #4a68b9;
font-size: 10px;
margin-bottom: 4px;
font-size: 1rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-text {
font-size: 22px;
span {
font-size: 11px;
position: relative;
top: -2px;
}
}
&:last-child {
margin-bottom: 0;
}
.card-text span {
color: #ffffff66;
font-size: 12px;
top: 0px;
}
.fee-text{
border-bottom: 1px solid #ffffff1c;
width: fit-content;
margin: auto;
line-height: 1.45;
padding: 0px 2px;
}
.fiat {
display: block;
font-size: 14px !important;
}
}
}
.loading-container{
min-height: 76px;
}
.card-text {
.skeleton-loader {
width: 100%;
display: block;
&:first-child {
max-width: 90px;
margin: 15px auto 3px;
}
&:last-child {
margin: 10px auto 3px;
max-width: 55px;
}
}
}

View File

@ -0,0 +1,24 @@
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { Env, StateService } from '../../../services/state.service';
import { CurrentPegs } from '../../../interfaces/node-api.interface';
@Component({
selector: 'app-reserves-supply-stats',
templateUrl: './reserves-supply-stats.component.html',
styleUrls: ['./reserves-supply-stats.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ReservesSupplyStatsComponent implements OnInit {
@Input() currentReserves$: Observable<CurrentPegs>;
@Input() currentPeg$: Observable<CurrentPegs>;
env: Env;
constructor(private stateService: StateService) { }
ngOnInit(): void {
this.env = this.stateService.env;
}
}

View File

@ -540,7 +540,7 @@
</ng-container> </ng-container>
</ng-template> </ng-template>
</div> </div>
<button *ngIf="cpfpInfo.bestDescendant || cpfpInfo.descendants?.length || cpfpInfo.ancestors?.length" type="button" class="btn btn-outline-info btn-sm btn-small-height float-right" (click)="showCpfpDetails = !showCpfpDetails">CPFP <fa-icon [icon]="['fas', 'info-circle']" [fixedWidth]="true"></fa-icon></button> <button *ngIf="cpfpInfo?.bestDescendant || cpfpInfo?.descendants?.length || cpfpInfo?.ancestors?.length" type="button" class="btn btn-outline-info btn-sm btn-small-height float-right" (click)="showCpfpDetails = !showCpfpDetails">CPFP <fa-icon [icon]="['fas', 'info-circle']" [fixedWidth]="true"></fa-icon></button>
</td> </td>
</tr> </tr>
</tbody> </tbody>

View File

@ -508,7 +508,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
} }
} }
} }
if (!found && txFeePerVSize < mempoolBlocks[mempoolBlocks.length - 1].feeRange[0]) { if (!found && mempoolBlocks.length && txFeePerVSize < mempoolBlocks[mempoolBlocks.length - 1].feeRange[0]) {
this.txInBlockIndex = 7; this.txInBlockIndex = 7;
} }
}); });

View File

@ -33,7 +33,7 @@
</ng-container> </ng-container>
</ng-template> </ng-template>
<ng-template #liquidPegs> <ng-template #liquidPegs>
<app-lbtc-pegs-graph [data]="liquidPegsMonth$ | async"></app-lbtc-pegs-graph> <app-lbtc-pegs-graph [data]="fullHistory$ | async"></app-lbtc-pegs-graph>
</ng-template> </ng-template>
</div> </div>
</div> </div>
@ -270,8 +270,16 @@
<div class="mempool-info-data"> <div class="mempool-info-data">
<div class="item"> <div class="item">
<h5 class="card-title" i18n="dashboard.lbtc-pegs-in-circulation">L-BTC in circulation</h5> <h5 class="card-title" i18n="dashboard.lbtc-pegs-in-circulation">L-BTC in circulation</h5>
<ng-container *ngIf="(liquidPegsMonth$ | async) as liquidPegsMonth; else loadingTransactions"> <ng-container *ngIf="(currentPeg$ | async) as currentPeg; else loadingTransactions">
<p class="card-text">{{ liquidPegsMonth.series.slice(-1)[0] | number: '1.2-2' }} <span>L-BTC</span></p> <p i18n-ngbTooltip="liquid.last-elements-audit-block" [ngbTooltip]="'L-BTC supply last updated at Liquid block ' + (currentPeg.lastBlockUpdate)" placement="top" class="card-text">{{ (+currentPeg.amount) / 100000000 | number: '1.2-2' }} <span>L-BTC</span></p>
</ng-container>
</div>
<div class="item">
<a class="title-link" [routerLink]="['/audit' | relativeUrl]">
<h5 class="card-title"><ng-container i18n="dashboard.btc-reserves">BTC Reserves</ng-container>&nbsp;<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="font-size: 13px; color: #4a68b9"></fa-icon></h5>
</a>
<ng-container *ngIf="(currentReserves$ | async) as currentReserves; else loadingTransactions">
<p i18n-ngbTooltip="liquid.last-bitcoin-audit-block" [ngbTooltip]="'BTC reserves last updated at Bitcoin block ' + (currentReserves.lastBlockUpdate)" placement="top" class="card-text">{{ +(currentReserves.amount) / 100000000 | number: '1.2-2' }} <span class="bitcoin-color">BTC</span></p>
</ng-container> </ng-container>
</div> </div>
</div> </div>

View File

@ -97,6 +97,9 @@
color: #ffffff66; color: #ffffff66;
font-size: 12px; font-size: 12px;
} }
.bitcoin-color {
color: #b86d12;
}
} }
.progress { .progress {
width: 90%; width: 90%;

View File

@ -1,7 +1,7 @@
import { AfterViewInit, ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; import { AfterViewInit, ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
import { combineLatest, merge, Observable, of, Subscription } from 'rxjs'; import { combineLatest, EMPTY, merge, Observable, of, Subject, Subscription, timer } from 'rxjs';
import { catchError, filter, map, scan, share, switchMap, tap } from 'rxjs/operators'; import { catchError, delayWhen, filter, map, scan, share, shareReplay, startWith, switchMap, takeUntil, tap, throttleTime } from 'rxjs/operators';
import { BlockExtended, OptimizedMempoolStats } from '../interfaces/node-api.interface'; import { AuditStatus, BlockExtended, CurrentPegs, OptimizedMempoolStats } from '../interfaces/node-api.interface';
import { MempoolInfo, TransactionStripped, ReplacementInfo } from '../interfaces/websocket.interface'; import { MempoolInfo, TransactionStripped, ReplacementInfo } from '../interfaces/websocket.interface';
import { ApiService } from '../services/api.service'; import { ApiService } from '../services/api.service';
import { StateService } from '../services/state.service'; import { StateService } from '../services/state.service';
@ -47,8 +47,20 @@ export class DashboardComponent implements OnInit, OnDestroy, AfterViewInit {
transactionsWeightPerSecondOptions: any; transactionsWeightPerSecondOptions: any;
isLoadingWebSocket$: Observable<boolean>; isLoadingWebSocket$: Observable<boolean>;
liquidPegsMonth$: Observable<any>; liquidPegsMonth$: Observable<any>;
currentPeg$: Observable<CurrentPegs>;
auditStatus$: Observable<AuditStatus>;
auditUpdated$: Observable<boolean>;
liquidReservesMonth$: Observable<any>;
currentReserves$: Observable<CurrentPegs>;
fullHistory$: Observable<any>;
isLoad: boolean = true;
currencySubscription: Subscription; currencySubscription: Subscription;
currency: string; currency: string;
private lastPegBlockUpdate: number = 0;
private lastPegAmount: string = '';
private lastReservesBlockUpdate: number = 0;
private destroy$ = new Subject();
constructor( constructor(
public stateService: StateService, public stateService: StateService,
@ -64,6 +76,8 @@ export class DashboardComponent implements OnInit, OnDestroy, AfterViewInit {
ngOnDestroy(): void { ngOnDestroy(): void {
this.currencySubscription.unsubscribe(); this.currencySubscription.unsubscribe();
this.websocketService.stopTrackRbfSummary(); this.websocketService.stopTrackRbfSummary();
this.destroy$.next(1);
this.destroy$.complete();
} }
ngOnInit(): void { ngOnInit(): void {
@ -82,35 +96,35 @@ export class DashboardComponent implements OnInit, OnDestroy, AfterViewInit {
this.stateService.mempoolInfo$, this.stateService.mempoolInfo$,
this.stateService.vbytesPerSecond$ this.stateService.vbytesPerSecond$
]) ])
.pipe( .pipe(
map(([mempoolInfo, vbytesPerSecond]) => { map(([mempoolInfo, vbytesPerSecond]) => {
const percent = Math.round((Math.min(vbytesPerSecond, this.vBytesPerSecondLimit) / this.vBytesPerSecondLimit) * 100); const percent = Math.round((Math.min(vbytesPerSecond, this.vBytesPerSecondLimit) / this.vBytesPerSecondLimit) * 100);
let progressColor = 'bg-success'; let progressColor = 'bg-success';
if (vbytesPerSecond > 1667) { if (vbytesPerSecond > 1667) {
progressColor = 'bg-warning'; progressColor = 'bg-warning';
} }
if (vbytesPerSecond > 3000) { if (vbytesPerSecond > 3000) {
progressColor = 'bg-danger'; progressColor = 'bg-danger';
} }
const mempoolSizePercentage = (mempoolInfo.usage / mempoolInfo.maxmempool * 100); const mempoolSizePercentage = (mempoolInfo.usage / mempoolInfo.maxmempool * 100);
let mempoolSizeProgress = 'bg-danger'; let mempoolSizeProgress = 'bg-danger';
if (mempoolSizePercentage <= 50) { if (mempoolSizePercentage <= 50) {
mempoolSizeProgress = 'bg-success'; mempoolSizeProgress = 'bg-success';
} else if (mempoolSizePercentage <= 75) { } else if (mempoolSizePercentage <= 75) {
mempoolSizeProgress = 'bg-warning'; mempoolSizeProgress = 'bg-warning';
} }
return { return {
memPoolInfo: mempoolInfo, memPoolInfo: mempoolInfo,
vBytesPerSecond: vbytesPerSecond, vBytesPerSecond: vbytesPerSecond,
progressWidth: percent + '%', progressWidth: percent + '%',
progressColor: progressColor, progressColor: progressColor,
mempoolSizeProgress: mempoolSizeProgress, mempoolSizeProgress: mempoolSizeProgress,
}; };
}) })
); );
this.mempoolBlocksData$ = this.stateService.mempoolBlocks$ this.mempoolBlocksData$ = this.stateService.mempoolBlocks$
.pipe( .pipe(
@ -204,18 +218,114 @@ export class DashboardComponent implements OnInit, OnDestroy, AfterViewInit {
); );
if (this.stateService.network === 'liquid' || this.stateService.network === 'liquidtestnet') { if (this.stateService.network === 'liquid' || this.stateService.network === 'liquidtestnet') {
this.liquidPegsMonth$ = this.apiService.listLiquidPegsMonth$() this.auditStatus$ = this.stateService.blocks$.pipe(
takeUntil(this.destroy$),
throttleTime(40000),
delayWhen(_ => this.isLoad ? timer(0) : timer(2000)),
tap(() => this.isLoad = false),
switchMap(() => this.apiService.federationAuditSynced$()),
shareReplay(1)
);
////////// Pegs historical data //////////
this.liquidPegsMonth$ = this.auditStatus$.pipe(
throttleTime(60 * 60 * 1000),
switchMap(() => this.apiService.listLiquidPegsMonth$()),
map((pegs) => {
const labels = pegs.map(stats => stats.date);
const series = pegs.map(stats => parseFloat(stats.amount) / 100000000);
series.reduce((prev, curr, i) => series[i] = prev + curr, 0);
return {
series,
labels
};
}),
share(),
);
this.currentPeg$ = this.auditStatus$.pipe(
switchMap(_ =>
this.apiService.liquidPegs$().pipe(
filter((currentPegs) => currentPegs.lastBlockUpdate >= this.lastPegBlockUpdate),
tap((currentPegs) => {
this.lastPegBlockUpdate = currentPegs.lastBlockUpdate;
})
)
),
share()
);
////////// BTC Reserves historical data //////////
this.auditUpdated$ = combineLatest([
this.auditStatus$,
this.currentPeg$
]).pipe(
filter(([auditStatus, _]) => auditStatus.isAuditSynced === true),
map(([auditStatus, currentPeg]) => ({
lastBlockAudit: auditStatus.lastBlockAudit,
currentPegAmount: currentPeg.amount
})),
switchMap(({ lastBlockAudit, currentPegAmount }) => {
const blockAuditCheck = lastBlockAudit > this.lastReservesBlockUpdate;
const amountCheck = currentPegAmount !== this.lastPegAmount;
this.lastPegAmount = currentPegAmount;
return of(blockAuditCheck || amountCheck);
})
);
this.liquidReservesMonth$ = this.auditStatus$.pipe(
throttleTime(60 * 60 * 1000),
switchMap((auditStatus) => {
return auditStatus.isAuditSynced ? this.apiService.listLiquidReservesMonth$() : EMPTY;
}),
map(reserves => {
const labels = reserves.map(stats => stats.date);
const series = reserves.map(stats => parseFloat(stats.amount) / 100000000);
return {
series,
labels
};
}),
share()
);
this.currentReserves$ = this.auditUpdated$.pipe(
filter(auditUpdated => auditUpdated === true),
throttleTime(40000),
switchMap(_ =>
this.apiService.liquidReserves$().pipe(
filter((currentReserves) => currentReserves.lastBlockUpdate >= this.lastReservesBlockUpdate),
tap((currentReserves) => {
this.lastReservesBlockUpdate = currentReserves.lastBlockUpdate;
})
)
),
share()
);
this.fullHistory$ = combineLatest([this.liquidPegsMonth$, this.currentPeg$, this.liquidReservesMonth$.pipe(startWith(null)), this.currentReserves$.pipe(startWith(null))])
.pipe( .pipe(
map((pegs) => { map(([liquidPegs, currentPeg, liquidReserves, currentReserves]) => {
const labels = pegs.map(stats => stats.date); liquidPegs.series[liquidPegs.series.length - 1] = parseFloat(currentPeg.amount) / 100000000;
const series = pegs.map(stats => parseFloat(stats.amount) / 100000000);
series.reduce((prev, curr, i) => series[i] = prev + curr, 0); if (liquidPegs.series.length === liquidReserves?.series.length) {
liquidReserves.series[liquidReserves.series.length - 1] = parseFloat(currentReserves?.amount) / 100000000;
} else if (liquidPegs.series.length === liquidReserves?.series.length + 1) {
liquidReserves.series.push(parseFloat(currentReserves?.amount) / 100000000);
liquidReserves.labels.push(liquidPegs.labels[liquidPegs.labels.length - 1]);
} else {
liquidReserves = {
series: [],
labels: []
};
}
return { return {
series, liquidPegs,
labels liquidReserves
}; };
}), }),
share(), share()
); );
} }

View File

@ -1,6 +1,6 @@
// Import tree-shakeable echarts // Import tree-shakeable echarts
import * as echarts from 'echarts/core'; import * as echarts from 'echarts/core';
import { LineChart, LinesChart, BarChart, TreemapChart, PieChart, ScatterChart } from 'echarts/charts'; import { LineChart, LinesChart, BarChart, TreemapChart, PieChart, ScatterChart, GaugeChart } from 'echarts/charts';
import { TitleComponent, TooltipComponent, GridComponent, LegendComponent, GeoComponent, DataZoomComponent, VisualMapComponent, MarkLineComponent } from 'echarts/components'; import { TitleComponent, TooltipComponent, GridComponent, LegendComponent, GeoComponent, DataZoomComponent, VisualMapComponent, MarkLineComponent } from 'echarts/components';
import { SVGRenderer, CanvasRenderer } from 'echarts/renderers'; import { SVGRenderer, CanvasRenderer } from 'echarts/renderers';
// Typescript interfaces // Typescript interfaces
@ -12,6 +12,6 @@ echarts.use([
TitleComponent, TooltipComponent, GridComponent, TitleComponent, TooltipComponent, GridComponent,
LegendComponent, GeoComponent, DataZoomComponent, LegendComponent, GeoComponent, DataZoomComponent,
VisualMapComponent, MarkLineComponent, VisualMapComponent, MarkLineComponent,
LineChart, LinesChart, BarChart, TreemapChart, PieChart, ScatterChart LineChart, LinesChart, BarChart, TreemapChart, PieChart, ScatterChart, GaugeChart
]); ]);
export { echarts, EChartsOption, TreemapSeriesOption, LineSeriesOption, PieSeriesOption }; export { echarts, EChartsOption, TreemapSeriesOption, LineSeriesOption, PieSeriesOption };

View File

@ -76,6 +76,46 @@ export interface LiquidPegs {
date: string; date: string;
} }
export interface CurrentPegs {
amount: string;
lastBlockUpdate: number;
hash: string;
}
export interface FederationAddress {
bitcoinaddress: string;
balance: string;
}
export interface FederationUtxo {
txid: string;
txindex: number;
bitcoinaddress: string;
amount: number;
blocknumber: number;
blocktime: number;
pegtxid: string;
pegindex: number;
pegblocktime: number;
}
export interface RecentPeg {
txid: string;
txindex: number;
amount: number;
bitcoinaddress: string;
bitcointxid: string;
bitcoinindex: number;
blocktime: number;
}
export interface AuditStatus {
bitcoinBlocks: number;
bitcoinHeaders: number;
lastBlockAudit: number;
isAuditSynced: boolean;
}
export interface ITranslators { [language: string]: string; } export interface ITranslators { [language: string]: string; }
/** /**

View File

@ -2,8 +2,10 @@ import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Routes, RouterModule } from '@angular/router'; import { Routes, RouterModule } from '@angular/router';
import { SharedModule } from '../shared/shared.module'; import { SharedModule } from '../shared/shared.module';
import { NgxEchartsModule } from 'ngx-echarts';
import { LiquidMasterPageComponent } from '../components/liquid-master-page/liquid-master-page.component'; import { LiquidMasterPageComponent } from '../components/liquid-master-page/liquid-master-page.component';
import { StartComponent } from '../components/start/start.component'; import { StartComponent } from '../components/start/start.component';
import { AddressComponent } from '../components/address/address.component'; import { AddressComponent } from '../components/address/address.component';
import { PushTransactionComponent } from '../components/push-transaction/push-transaction.component'; import { PushTransactionComponent } from '../components/push-transaction/push-transaction.component';
@ -13,6 +15,17 @@ import { AssetsComponent } from '../components/assets/assets.component';
import { AssetsFeaturedComponent } from '../components/assets/assets-featured/assets-featured.component' import { AssetsFeaturedComponent } from '../components/assets/assets-featured/assets-featured.component'
import { AssetComponent } from '../components/asset/asset.component'; import { AssetComponent } from '../components/asset/asset.component';
import { AssetsNavComponent } from '../components/assets/assets-nav/assets-nav.component'; import { AssetsNavComponent } from '../components/assets/assets-nav/assets-nav.component';
import { ReservesAuditDashboardComponent } from '../components/liquid-reserves-audit/reserves-audit-dashboard/reserves-audit-dashboard.component';
import { ReservesSupplyStatsComponent } from '../components/liquid-reserves-audit/reserves-supply-stats/reserves-supply-stats.component';
import { RecentPegsStatsComponent } from '../components/liquid-reserves-audit/recent-pegs-stats/recent-pegs-stats.component';
import { RecentPegsListComponent } from '../components/liquid-reserves-audit/recent-pegs-list/recent-pegs-list.component';
import { FederationWalletComponent } from '../components/liquid-reserves-audit/federation-wallet/federation-wallet.component';
import { FederationUtxosListComponent } from '../components/liquid-reserves-audit/federation-utxos-list/federation-utxos-list.component';
import { FederationAddressesStatsComponent } from '../components/liquid-reserves-audit/federation-addresses-stats/federation-addresses-stats.component';
import { FederationAddressesListComponent } from '../components/liquid-reserves-audit/federation-addresses-list/federation-addresses-list.component';
import { ReservesRatioComponent } from '../components/liquid-reserves-audit/reserves-ratio/reserves-ratio.component';
import { ReservesRatioStatsComponent } from '../components/liquid-reserves-audit/reserves-ratio-stats/reserves-ratio-stats.component';
import { ReservesRatioGraphComponent } from '../components/liquid-reserves-audit/reserves-ratio/reserves-ratio-graph.component';
const routes: Routes = [ const routes: Routes = [
{ {
@ -64,6 +77,44 @@ const routes: Routes = [
data: { preload: true, networkSpecific: true }, data: { preload: true, networkSpecific: true },
loadChildren: () => import('../components/block/block.module').then(m => m.BlockModule), loadChildren: () => import('../components/block/block.module').then(m => m.BlockModule),
}, },
{
path: 'audit',
data: { networks: ['liquid'] },
component: StartComponent,
children: [
{
path: '',
data: { networks: ['liquid'] },
component: ReservesAuditDashboardComponent,
}
]
},
{
path: 'audit/wallet',
data: { networks: ['liquid'] },
component: FederationWalletComponent,
children: [
{
path: 'utxos',
data: { networks: ['liquid'] },
component: FederationUtxosListComponent,
},
{
path: 'addresses',
data: { networks: ['liquid'] },
component: FederationAddressesListComponent,
},
{
path: '**',
redirectTo: 'utxos'
}
]
},
{
path: 'audit/pegs',
data: { networks: ['liquid'] },
component: RecentPegsListComponent,
},
{ {
path: 'assets', path: 'assets',
data: { networks: ['liquid'] }, data: { networks: ['liquid'] },
@ -123,9 +174,23 @@ export class LiquidRoutingModule { }
CommonModule, CommonModule,
LiquidRoutingModule, LiquidRoutingModule,
SharedModule, SharedModule,
NgxEchartsModule.forRoot({
echarts: () => import('../graphs/echarts').then(m => m.echarts),
})
], ],
declarations: [ declarations: [
LiquidMasterPageComponent, LiquidMasterPageComponent,
ReservesAuditDashboardComponent,
ReservesSupplyStatsComponent,
RecentPegsStatsComponent,
RecentPegsListComponent,
FederationWalletComponent,
FederationUtxosListComponent,
FederationAddressesStatsComponent,
FederationAddressesListComponent,
ReservesRatioComponent,
ReservesRatioStatsComponent,
ReservesRatioGraphComponent,
] ]
}) })
export class LiquidMasterPageModule { } export class LiquidMasterPageModule { }

View File

@ -1,7 +1,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators, import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators,
PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights, RbfTree, BlockAudit } from '../interfaces/node-api.interface'; PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights, RbfTree, BlockAudit, Acceleration, AccelerationHistoryParams, CurrentPegs, AuditStatus, FederationAddress, FederationUtxo, RecentPeg } from '../interfaces/node-api.interface';
import { BehaviorSubject, Observable, catchError, filter, of, shareReplay, take, tap } from 'rxjs'; import { BehaviorSubject, Observable, catchError, filter, of, shareReplay, take, tap } from 'rxjs';
import { StateService } from './state.service'; import { StateService } from './state.service';
import { Transaction } from '../interfaces/electrs.interface'; import { Transaction } from '../interfaces/electrs.interface';
@ -168,10 +168,46 @@ export class ApiService {
return this.httpClient.get<RbfTree[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/' + (fullRbf ? 'fullrbf/' : '') + 'replacements/' + (after || '')); return this.httpClient.get<RbfTree[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/' + (fullRbf ? 'fullrbf/' : '') + 'replacements/' + (after || ''));
} }
liquidPegs$(): Observable<CurrentPegs> {
return this.httpClient.get<CurrentPegs>(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/pegs');
}
listLiquidPegsMonth$(): Observable<LiquidPegs[]> { listLiquidPegsMonth$(): Observable<LiquidPegs[]> {
return this.httpClient.get<LiquidPegs[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/pegs/month'); return this.httpClient.get<LiquidPegs[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/pegs/month');
} }
liquidReserves$(): Observable<CurrentPegs> {
return this.httpClient.get<CurrentPegs>(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/reserves');
}
listLiquidReservesMonth$(): Observable<LiquidPegs[]> {
return this.httpClient.get<LiquidPegs[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/reserves/month');
}
federationAuditSynced$(): Observable<AuditStatus> {
return this.httpClient.get<AuditStatus>(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/reserves/status');
}
federationAddresses$(): Observable<FederationAddress[]> {
return this.httpClient.get<FederationAddress[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/reserves/addresses');
}
federationUtxos$(): Observable<FederationUtxo[]> {
return this.httpClient.get<FederationUtxo[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/reserves/utxos');
}
recentPegOuts$(): Observable<RecentPeg[]> {
return this.httpClient.get<RecentPeg[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/pegouts');
}
federationAddressesOneMonthAgo$(): Observable<any> {
return this.httpClient.get<FederationAddress[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/reserves/addresses/previous-month');
}
federationUtxosOneMonthAgo$(): Observable<any> {
return this.httpClient.get<FederationUtxo[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/reserves/utxos/previous-month');
}
listFeaturedAssets$(): Observable<any[]> { listFeaturedAssets$(): Observable<any[]> {
return this.httpClient.get<any[]>(this.apiBaseUrl + '/api/v1/assets/featured'); return this.httpClient.get<any[]>(this.apiBaseUrl + '/api/v1/assets/featured');
} }

View File

@ -4,7 +4,7 @@ import { NgbCollapseModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstra
import { FontAwesomeModule, FaIconLibrary } from '@fortawesome/angular-fontawesome'; import { FontAwesomeModule, FaIconLibrary } from '@fortawesome/angular-fontawesome';
import { faFilter, faAngleDown, faAngleUp, faAngleRight, faAngleLeft, faBolt, faChartArea, faCogs, faCubes, faHammer, faDatabase, faExchangeAlt, faInfoCircle, import { faFilter, faAngleDown, faAngleUp, faAngleRight, faAngleLeft, faBolt, faChartArea, faCogs, faCubes, faHammer, faDatabase, faExchangeAlt, faInfoCircle,
faLink, faList, faSearch, faCaretUp, faCaretDown, faTachometerAlt, faThList, faTint, faTv, faClock, 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, faFastForward, faWallet, faUserClock, faWrench, faUserFriends, faQuestionCircle, faHistory, faSignOutAlt, faKey, faSuitcase, faIdCardAlt, faNetworkWired, faUserCheck, faCircleCheck, faUserCircle, faCheck, faRocket } from '@fortawesome/free-solid-svg-icons'; faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faBook, faListUl, faDownload, faQrcode, faArrowRightArrowLeft, faArrowsRotate, faCircleLeft, faFastForward, faWallet, faUserClock, faWrench, faUserFriends, faQuestionCircle, faHistory, faSignOutAlt, faKey, faSuitcase, faIdCardAlt, faNetworkWired, faUserCheck, faCircleCheck, faUserCircle, faCheck, faRocket, faScaleBalanced } from '@fortawesome/free-solid-svg-icons';
import { InfiniteScrollModule } from 'ngx-infinite-scroll'; import { InfiniteScrollModule } from 'ngx-infinite-scroll';
import { MenuComponent } from '../components/menu/menu.component'; import { MenuComponent } from '../components/menu/menu.component';
import { PreviewTitleComponent } from '../components/master-page-preview/preview-title.component'; import { PreviewTitleComponent } from '../components/master-page-preview/preview-title.component';
@ -385,5 +385,6 @@ export class SharedModule {
library.addIcons(faUserCircle); library.addIcons(faUserCircle);
library.addIcons(faCheck); library.addIcons(faCheck);
library.addIcons(faRocket); library.addIcons(faRocket);
library.addIcons(faScaleBalanced);
} }
} }