Merge pull request #4723 from mempool/natsoni/federation-utxos-expiry

Liquid dashboard: Filter for expired UTXOs - Match layout with main dashboard
This commit is contained in:
softsimon 2024-03-09 10:20:08 +07:00 committed by GitHub
commit 020194d37b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 493 additions and 80 deletions

View file

@ -7,7 +7,7 @@ import cpfpRepository from '../repositories/CpfpRepository';
import { RowDataPacket } from 'mysql2';
class DatabaseMigration {
private static currentVersion = 70;
private static currentVersion = 71;
private queryTimeout = 3600_000;
private statisticsAddedIndexed = false;
private uniqueLogs: string[] = [];
@ -590,6 +590,22 @@ class DatabaseMigration {
await this.$executeQuery('ALTER TABLE accelerations MODIFY COLUMN added DATETIME;');
await this.updateToSchemaVersion(70);
}
if (databaseSchemaVersion < 71 && config.MEMPOOL.NETWORK === 'liquid') {
await this.$executeQuery('TRUNCATE TABLE elements_pegs');
await this.$executeQuery('TRUNCATE TABLE federation_txos');
await this.$executeQuery('SET FOREIGN_KEY_CHECKS = 0');
await this.$executeQuery('TRUNCATE TABLE federation_addresses');
await this.$executeQuery('SET FOREIGN_KEY_CHECKS = 1');
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
await this.$executeQuery(`UPDATE state SET number = 0 WHERE name = 'last_elements_block';`);
await this.$executeQuery(`UPDATE state SET number = 0 WHERE name = 'last_bitcoin_block_audit';`);
await this.$executeQuery('ALTER TABLE `federation_txos` ADD timelock INT NOT NULL DEFAULT 0');
await this.$executeQuery('ALTER TABLE `federation_txos` ADD expiredAt INT NOT NULL DEFAULT 0');
await this.$executeQuery('ALTER TABLE `federation_txos` ADD emergencyKey TINYINT NOT NULL DEFAULT 0');
await this.updateToSchemaVersion(71);
}
}
/**

View file

@ -92,8 +92,8 @@ class ElementsParser {
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];
const query_utxos = `INSERT IGNORE INTO federation_txos (txid, txindex, bitcoinaddress, amount, blocknumber, blocktime, unspent, lastblockupdate, lasttimeupdate, timelock, expiredAt, emergencyKey, pegtxid, pegindex, pegblocktime) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
const params_utxos: (string | number)[] = [bitcointxid, bitcoinindex, bitcoinaddress, amount, bitcoinblock, bitcoinBlockTime, 1, bitcoinblock - 1, 0, 4032, 0, 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']);
@ -206,7 +206,7 @@ class ElementsParser {
// 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 query = `SELECT txid, txindex, bitcoinaddress, amount, blocknumber, timelock, expiredAt FROM federation_txos WHERE lastblockupdate = ? AND unspent = 1`;
const [rows] = await DB.query(query, [height - 1]);
return rows as any[];
}
@ -227,16 +227,26 @@ class ElementsParser {
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...
let mightRedeemInThisTx = false;
// 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]);
mightRedeemInThisTx = true; // A Federation UTXO is spent in this block: we might find a peg-out address in the outputs
if (txo.expiredAt > 0 ) {
if (input.txinwitness?.length !== 13) { // Check if the witness data of the input contains the 11 signatures: if it doesn't, emergency keys are being used
await DB.query(`UPDATE federation_txos SET unspent = 0, lastblockupdate = ?, lasttimeupdate = ?, emergencyKey = 1 WHERE txid = ? AND txindex = ?`, [block.height, block.time, txo.txid, txo.txindex]);
logger.debug(`Expired Federation UTXO ${txo.txid}:${txo.txindex} (${txo.amount} sats) was spent in block ${block.height} using emergency keys!`);
} else {
await DB.query(`UPDATE federation_txos SET unspent = 0, lastblockupdate = ?, lasttimeupdate = ? WHERE txid = ? AND txindex = ?`, [block.height, block.time, txo.txid, txo.txindex]);
logger.debug(`Expired Federation UTXO ${txo.txid}:${txo.txindex} (${txo.amount} sats) was spent in block ${block.height} using regular 11-of-15 signatures`);
}
} else {
await DB.query(`UPDATE federation_txos SET unspent = 0, lastblockupdate = ?, lasttimeupdate = ? WHERE txid = ? AND txindex = ?`, [block.height, block.time, txo.txid, txo.txindex]);
logger.debug(`Federation UTXO ${txo.txid}:${txo.txindex} (${txo.amount} sats) was spent in block ${block.height}`);
}
// 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
@ -245,17 +255,21 @@ class ElementsParser {
// 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];
const timelock = output.scriptPubKey.address === federationChangeAddresses[0] ? 4032 : 2016; // P2WSH change address has a 4032 timelock, P2SH change address has a 2016 timelock
const query_utxos = `INSERT INTO federation_txos (txid, txindex, bitcoinaddress, amount, blocknumber, blocktime, unspent, lastblockupdate, lasttimeupdate, timelock, expiredAt, emergencyKey, 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, timelock, 0, 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
amount: output.value * 100000000,
blocknumber: block.height,
timelock: timelock,
expiredAt: 0,
});
logger.debug(`Added new Federation UTXO ${tx.txid}:${output.n} (${output.value * 100000000} sats), change address: ${output.scriptPubKey.address}`);
logger.debug(`Added new Federation UTXO ${tx.txid}:${output.n} (${Math.round(output.value * 100000000)} sats), change address: ${output.scriptPubKey.address}`);
}
}
if (mightRedeemInThisTx && output.scriptPubKey.address && redeemAddresses.includes(output.scriptPubKey.address)) {
@ -282,13 +296,22 @@ class ElementsParser {
}
}
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 spentAsTip) {
if (utxo.expiredAt === 0 && block.height >= utxo.blocknumber + utxo.timelock) { // The UTXO is expiring in this block
await DB.query(`UPDATE federation_txos SET lastblockupdate = ?, expiredAt = ? WHERE txid = ? AND txindex = ?`, [block.height, block.time, utxo.txid, utxo.txindex]);
} else {
await DB.query(`UPDATE federation_txos SET lastblockupdate = ? WHERE txid = ? AND txindex = ?`, [block.height, utxo.txid, utxo.txindex]);
}
}
for (const utxo of unspentAsTip) {
if (utxo.expiredAt === 0 && block.height >= utxo.blocknumber + utxo.timelock) { // The UTXO is expiring in this block
await DB.query(`UPDATE federation_txos SET unspent = 0, lastblockupdate = ?, expiredAt = ? WHERE txid = ? AND txindex = ?`, [confirmedTip, block.time, utxo.txid, utxo.txindex]);
} else if (utxo.expiredAt === 0 && confirmedTip >= utxo.blocknumber + utxo.timelock) { // The UTXO is expiring before the tip: we need to keep track of it
await DB.query(`UPDATE federation_txos SET lastblockupdate = ? WHERE txid = ? AND txindex = ?`, [utxo.blocknumber + utxo.timelock - 1, utxo.txid, utxo.txindex]);
} else {
await DB.query(`UPDATE federation_txos SET lastblockupdate = ? WHERE txid = ? AND txindex = ?`, [confirmedTip, utxo.txid, utxo.txindex]);
}
}
}
@ -328,6 +351,10 @@ class ElementsParser {
return rows;
}
protected isDust(amount: number, feeRate: number): boolean {
return amount <= (450 * feeRate); // A P2WSH 11-of-15 multisig input is around 450 bytes
}
///////////// DATA QUERY //////////////
public async $getAuditStatus(): Promise<any> {
@ -354,6 +381,8 @@ class ElementsParser {
(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)))
AND
(expiredAt = 0 OR expiredAt > UNIX_TIMESTAMP(LAST_DAY(FROM_UNIXTIME(blocktime)) + INTERVAL 1 DAY))
GROUP BY
date;`;
const [rows] = await DB.query(query);
@ -374,7 +403,7 @@ class ElementsParser {
// 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 [rows] = await DB.query(`SELECT SUM(amount) AS total_balance FROM federation_txos WHERE unspent = 1 AND expiredAt = 0;`);
const lastblockaudit = await this.$getLastBlockAudit();
const hash = await bitcoinSecondClient.getBlockHash(lastblockaudit);
return {
@ -386,28 +415,53 @@ class ElementsParser {
// 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 query = `SELECT bitcoinaddress, SUM(amount) AS balance FROM federation_txos WHERE unspent = 1 AND expiredAt = 0 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 query = `SELECT txid, txindex, bitcoinaddress, amount, blocknumber, blocktime, pegtxid, pegindex, pegblocktime, timelock, expiredAt FROM federation_txos WHERE unspent = 1 AND expiredAt = 0 ORDER BY blocktime DESC;`;
const [rows] = await DB.query(query);
return rows;
}
// Get expired UTXOs, most recent first
public async $getExpiredUtxos(): Promise<any> {
const query = `SELECT txid, txindex, bitcoinaddress, amount, blocknumber, blocktime, pegtxid, pegindex, pegblocktime, timelock, expiredAt FROM federation_txos WHERE unspent = 1 AND expiredAt > 0 ORDER BY blocktime DESC;`;
const [rows]: any[] = await DB.query(query);
const feeRate = Math.round((await bitcoinSecondClient.estimateSmartFee(1)).feerate * 100000000 / 1000);
for (const row of rows) {
row.isDust = this.isDust(row.amount, feeRate);
}
return rows;
}
// Get utxos that were spent using emergency keys
public async $getEmergencySpentUtxos(): Promise<any> {
const query = `SELECT txid, txindex, bitcoinaddress, amount, blocknumber, blocktime, pegtxid, pegindex, pegblocktime, timelock, expiredAt FROM federation_txos WHERE emergencyKey = 1 ORDER BY blocktime DESC;`;
const [rows] = await DB.query(query);
return rows;
}
// Get the total number of federation addresses
public async $getFederationAddressesNumber(): Promise<any> {
const query = `SELECT COUNT(DISTINCT bitcoinaddress) AS address_count FROM federation_txos WHERE unspent = 1;`;
const query = `SELECT COUNT(DISTINCT bitcoinaddress) AS address_count FROM federation_txos WHERE unspent = 1 AND expiredAt = 0;`;
const [rows] = await DB.query(query);
return rows[0];
}
// Get the total number of federation utxos
public async $getFederationUtxosNumber(): Promise<any> {
const query = `SELECT COUNT(*) AS utxo_count FROM federation_txos WHERE unspent = 1;`;
const query = `SELECT COUNT(*) AS utxo_count FROM federation_txos WHERE unspent = 1 AND expiredAt = 0;`;
const [rows] = await DB.query(query);
return rows[0];
}
// Get the total number of emergency spent utxos and their total amount
public async $getEmergencySpentUtxosStats(): Promise<any> {
const query = `SELECT COUNT(*) AS utxo_count, SUM(amount) AS total_amount FROM federation_txos WHERE emergencyKey = 1;`;
const [rows] = await DB.query(query);
return rows[0];
}

View file

@ -26,6 +26,9 @@ class LiquidRoutes {
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/addresses/total', this.$getFederationAddressesNumber)
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/utxos', this.$getFederationUtxos)
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/utxos/total', this.$getFederationUtxosNumber)
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/utxos/expired', this.$getExpiredUtxos)
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/utxos/emergency-spent', this.$getEmergencySpentUtxos)
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/utxos/emergency-spent/stats', this.$getEmergencySpentUtxosStats)
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/status', this.$getFederationAuditStatus)
;
}
@ -167,6 +170,18 @@ class LiquidRoutes {
}
}
private async $getExpiredUtxos(req: Request, res: Response) {
try {
const expiredUtxos = await elementsParser.$getExpiredUtxos();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(expiredUtxos);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getFederationUtxosNumber(req: Request, res: Response) {
try {
const federationUtxos = await elementsParser.$getFederationUtxosNumber();
@ -179,6 +194,30 @@ class LiquidRoutes {
}
}
private async $getEmergencySpentUtxos(req: Request, res: Response) {
try {
const emergencySpentUtxos = await elementsParser.$getEmergencySpentUtxos();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(emergencySpentUtxos);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getEmergencySpentUtxosStats(req: Request, res: Response) {
try {
const emergencySpentUtxos = await elementsParser.$getEmergencySpentUtxosStats();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(emergencySpentUtxos);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getPegsList(req: Request, res: Response) {
try {
const recentPegs = await elementsParser.$getPegsList(parseInt(req.params?.count));

View file

@ -11,6 +11,7 @@ module.exports = {
encryptWallet: 'encryptwallet',
estimateFee: 'estimatefee', // bitcoind v0.10.0x
estimatePriority: 'estimatepriority', // bitcoind v0.10.0+
estimateSmartFee: 'estimatesmartfee',
generate: 'generate', // bitcoind v0.11.0+
getAccount: 'getaccount',
getAccountAddress: 'getaccountaddress',

View file

@ -18,7 +18,7 @@ import { EChartsOption } from '../../graphs/echarts';
})
export class LbtcPegsGraphComponent implements OnInit, OnChanges {
@Input() data: any;
@Input() height: number | string = '320';
@Input() height: number | string = '360';
pegsChartOptions: EChartsOption;
right: number | string = '10';

View file

@ -0,0 +1,28 @@
<div class="fee-estimation-container">
<div class="item">
<h5 class="card-title" i18n="liquid.non-dust-expired">Non-Dust Expired</h5>
<div *ngIf="(stats$ | async) as expiredStats; else loadingData" class="card-text">
<div class="fee-text" i18n-ngbTooltip="liquid.expired-utxos-non-dust" ngbTooltip="Total amount of BTC held in non-dust Federation UTXOs that have expired timelocks" placement="top">{{ (+expiredStats.nonDust.total) / 100000000 | number: '1.5-5' }} <span style="color: #b86d12;">BTC</span></div>
<div class="fiat">{{ expiredStats.nonDust.count }} <span i18n="shared.utxos">UTXOs</span></div>
</div>
</div>
<div class="item">
<a class="title-link" [routerLink]="['/audit/wallet/utxos' | relativeUrl]" [fragment]="'expired'">
<h5 class="card-title"><ng-container i18n="liquid.total-expired">Total Expired</ng-container>&nbsp;<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="font-size: 13px; color: #4a68b9"></fa-icon></h5>
</a>
<div *ngIf="(stats$ | async) as expiredStats; else loadingData" class="card-text">
<div class="fee-text" i18n-ngbTooltip="liquid.expired-utxos" ngbTooltip="Total amount of BTC held in Federation UTXOs that have expired timelocks" placement="top">{{ (+expiredStats.all.total) / 100000000 | number: '1.5-5' }} <span style="color: #b86d12;">BTC</span></div>
<div class="fiat">{{ expiredStats.all.count }} <span i18n="shared.utxos">UTXOs</span></div>
</div>
</div>
</div>
<ng-template #loadingData>
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
</div>
</ng-template>

View file

@ -0,0 +1,82 @@
.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;
color: #ffffff;
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,35 @@
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
import { Observable, map, of } from 'rxjs';
import { FederationUtxo } from '../../../interfaces/node-api.interface';
@Component({
selector: 'app-expired-utxos-stats',
templateUrl: './expired-utxos-stats.component.html',
styleUrls: ['./expired-utxos-stats.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ExpiredUtxosStatsComponent implements OnInit {
@Input() expiredUtxos$: Observable<FederationUtxo[]>;
stats$: Observable<any>;
constructor() { }
ngOnInit(): void {
this.stats$ = this.expiredUtxos$?.pipe(
map((utxos: FederationUtxo[]) => {
const stats = { nonDust: { count: 0, total: 0 }, all: { count: 0, total: 0 } };
utxos.forEach((utxo: FederationUtxo) => {
stats.all.count++;
stats.all.total += utxo.amount;
if (!utxo.isDust) {
stats.nonDust.count++;
stats.nonDust.total += utxo.amount;
}
});
return stats;
}),
);
}
}

View file

@ -10,6 +10,9 @@
<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>
<th class="expires-in text-left" *ngIf="!widget && showExpiredUtxos === false" i18n="liquid.expires-in">Expires in</th>
<th class="expires-in text-left" *ngIf="!widget && showExpiredUtxos === true" i18n="liquid.expired-since">Expired since</th>
<th class="is-dust text-right" *ngIf="!widget && showExpiredUtxos === true" i18n="liquid.is-dust">Is Dust</th>
</thead>
<tbody *ngIf="federationUtxos$ | async as utxos; else skeleton" [style]="isLoading ? 'opacity: 0.75' : ''">
<ng-container *ngIf="widget; else regularRows">
@ -56,6 +59,13 @@
&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>
<td class="expires-in text-left" [ngStyle]="{ 'color': getGradientColor(utxo.blocknumber + utxo.timelock - lastReservesBlockUpdate) }">
{{ utxo.blocknumber + utxo.timelock - lastReservesBlockUpdate < 0 ? -(utxo.blocknumber + utxo.timelock - lastReservesBlockUpdate) : utxo.blocknumber + utxo.timelock - lastReservesBlockUpdate }} <span i18n="shared.blocks" class="symbol">blocks</span>
</td>
<td *ngIf="!widget && showExpiredUtxos === true" class="is-dust text-right" [ngStyle]="{ 'color': !utxo.isDust ? '#D81B60' : '' }">
<div i18n="shared.yes" *ngIf="utxo.isDust">Yes</div>
<div i18n="shared.no" *ngIf="!utxo.isDust">No</div>
</td>
</tr>
</ng-template>
</tbody>
@ -90,6 +100,9 @@
<td class="timestamp text-left">
<span class="skeleton-loader" style="max-width: 140px"></span>
</td>
<td class="expires-in text-left">
<span class="skeleton-loader" style="max-width: 140px"></span>
</td>
</tr>
</ng-template>
</ng-template>

View file

@ -72,7 +72,7 @@ tr, td, th {
@media (max-width: 800px) {
display: none;
}
@media (max-width: 1000px) {
@media (max-width: 1190px) {
.relative-time {
display: none;
}
@ -92,3 +92,15 @@ tr, td, th {
}
}
.expires-in {
@media (max-width: 987px) {
display: none;
}
}
.is-dust {
@media (max-width: 1090px) {
display: none;
}
}

View file

@ -1,5 +1,6 @@
import { Component, OnInit, ChangeDetectionStrategy, Input } from '@angular/core';
import { Observable, Subject, combineLatest, of, timer } from 'rxjs';
import { ActivatedRoute, Router } from '@angular/router';
import { BehaviorSubject, 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';
@ -24,6 +25,9 @@ export class FederationUtxosListComponent implements OnInit {
skeletonLines: number[] = [];
auditStatus$: Observable<AuditStatus>;
auditUpdated$: Observable<boolean>;
showExpiredUtxos: boolean = false;
showExpiredUtxosToggleSubject: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(this.showExpiredUtxos);
showExpiredUtxosToggle$: Observable<boolean> = this.showExpiredUtxosToggleSubject.asObservable();
lastReservesBlockUpdate: number = 0;
currentPeg$: Observable<CurrentPegs>;
lastPegBlockUpdate: number = 0;
@ -36,6 +40,8 @@ export class FederationUtxosListComponent implements OnInit {
private apiService: ApiService,
public stateService: StateService,
private websocketService: WebsocketService,
private route: ActivatedRoute,
private router: Router
) {
}
@ -45,7 +51,12 @@ export class FederationUtxosListComponent implements OnInit {
this.skeletonLines = this.widget === true ? [...Array(6).keys()] : [...Array(15).keys()];
if (!this.widget) {
this.route.fragment.subscribe((fragment) => {
this.showExpiredUtxosToggleSubject.next(['expired'].indexOf(fragment) > -1);
});
this.websocketService.want(['blocks']);
this.auditStatus$ = this.stateService.blocks$.pipe(
takeUntil(this.destroy$),
throttleTime(40000),
@ -70,27 +81,30 @@ export class FederationUtxosListComponent implements OnInit {
this.auditUpdated$ = combineLatest([
this.auditStatus$,
this.currentPeg$
this.currentPeg$,
this.showExpiredUtxosToggle$
]).pipe(
filter(([auditStatus, _]) => auditStatus.isAuditSynced === true),
map(([auditStatus, currentPeg]) => ({
filter(([auditStatus, _, __]) => auditStatus.isAuditSynced === true),
map(([auditStatus, currentPeg, showExpiredUtxos]) => ({
lastBlockAudit: auditStatus.lastBlockAudit,
currentPegAmount: currentPeg.amount
currentPegAmount: currentPeg.amount,
showExpiredUtxos: showExpiredUtxos
})),
switchMap(({ lastBlockAudit, currentPegAmount }) => {
switchMap(({ lastBlockAudit, currentPegAmount, showExpiredUtxos }) => {
const blockAuditCheck = lastBlockAudit > this.lastReservesBlockUpdate;
const amountCheck = currentPegAmount !== this.lastPegAmount;
const expiredCheck = showExpiredUtxos !== this.showExpiredUtxos;
this.lastReservesBlockUpdate = lastBlockAudit;
this.lastPegAmount = currentPegAmount;
return of(blockAuditCheck || amountCheck);
this.showExpiredUtxos = showExpiredUtxos;
return of(blockAuditCheck || amountCheck || expiredCheck);
}),
share()
);
this.federationUtxos$ = this.auditUpdated$.pipe(
filter(auditUpdated => auditUpdated === true),
throttleTime(40000),
switchMap(_ => this.apiService.federationUtxos$()),
switchMap(_ => this.showExpiredUtxos ? this.apiService.expiredUtxos$() : this.apiService.federationUtxos$()),
tap(_ => this.isLoading = false),
share()
);
@ -106,4 +120,32 @@ export class FederationUtxosListComponent implements OnInit {
this.page = page;
}
getGradientColor(value: number): string {
const distanceToGreen = Math.abs(4032 - value);
const green = '#7CB342';
const red = '#D81B60';
if (value < 0) {
return red;
} else if (value >= 4032) {
return green;
} else {
const scaleFactor = 1 - distanceToGreen / 4032;
const r = parseInt(red.slice(1, 3), 16);
const g = parseInt(green.slice(1, 3), 16);
const b = parseInt(red.slice(5, 7), 16);
const newR = Math.floor(r + (g - r) * scaleFactor);
const newG = Math.floor(g - (g - r) * scaleFactor);
const newB = b;
return '#' + this.componentToHex(newR) + this.componentToHex(newG) + this.componentToHex(newB);
}
}
componentToHex(c: number): string {
const hex = c.toString(16);
return hex.length == 1 ? '0' + hex : hex;
}
}

View file

@ -1,4 +1,4 @@
<div class="container-xl">
<div class="container-xl" style="max-width: 1400px;">
<div>
<h1 i18n="liquid.federation-wallet">Liquid Federation Wallet</h1>
</div>
@ -6,8 +6,10 @@
<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>
<a class="nav-link" [routerLink]="['/audit/wallet/utxos' | relativeUrl]" routerLinkActive="active" [routerLinkActiveOptions]="{matrixParams: 'ignored', queryParams: 'ignored', paths: 'exact', fragment: isExpiredFragment() ? 'exact' : 'ignored'}">UTXOs</a>
</li>
<li class="nav-item">
<a class="nav-link" [routerLink]="['/audit/wallet/utxos' | relativeUrl]" [fragment]="'expired'" routerLinkActive="active" [routerLinkActiveOptions]="{matrixParams: 'ignored', queryParams: 'ignored', paths: 'exact', fragment: 'exact'}"><ng-container i18n="liquid.timelock-expired-utxos">Timelock-Expired UTXOs</ng-container></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>

View file

@ -10,4 +10,11 @@ ul {
justify-content: center;
margin: auto;
}
}
@media (max-width: 436px) {
.nav-link {
padding: 0.8rem 0.8rem;
font-size: 0.8rem;
}
}

View file

@ -17,4 +17,8 @@ export class FederationWalletComponent implements OnInit {
ngOnInit(): void {
}
isExpiredFragment(): boolean {
return location.hash === '#expired';
}
}

View file

@ -1,42 +1,43 @@
<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 class="fee-estimation-container">
<div class="item">
<h5 class="card-title" i18n="liquid.unpeg">Unpeg</h5>
<div *ngIf="(unbackedMonths$ | async) as unbackedMonths; else loadingData" class="card-text">
<ng-container *ngIf="unbackedMonths.historyComplete; else loadingData">
<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>
</ng-container>
</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 * 100).toFixed(3) }} %
</div>
<div class="item avg-ratio">
<h5 class="card-title" i18n="liquid.avg-peg-ratio">Avg Peg Ratio</h5>
<div *ngIf="(unbackedMonths$ | async) as unbackedMonths; else loadingData" class="card-text">
<ng-container *ngIf="unbackedMonths.historyComplete; else loadingData">
<div class="fee-text" [ngClass]="{'danger' : unbackedMonths.avg < 1, 'correct': unbackedMonths.avg >= 1}">
{{ (unbackedMonths.avg * 100).toFixed(3) }} %
</div>
</div>
</ng-container>
</div>
</ng-container>
</div>
</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 class="item">
<!-- <a class="title-link" [routerLink]="['/audit/emergency-spends' | relativeUrl]">
<h5 class="card-title"><ng-container i18n="liquid.forfeited-utxos">Forfeited UTXOs</ng-container>&nbsp;<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="font-size: 13px; color: #4a68b9"></fa-icon></h5>
</a> -->
<h5 class="card-title" i18n="liquid.emergency-keys">Emergency Keys</h5>
<div *ngIf="(emergencyUtxosStats$ | async) as emergencyUtxosStats; else loadingData" class="card-text">
<div class="fee-text" [ngClass]="{'danger' : emergencyUtxosStats.utxo_count > 0, 'correct': emergencyUtxosStats.utxo_count === 0}">
{{ emergencyUtxosStats.utxo_count }} <span i18n="shared.usage">usage</span>
</div>
</div>
</div>
</div>
<ng-template #loadingData>
<div class="card-text">
<div class="skeleton-loader"></div>
</div>
</ng-template>

View file

@ -3,7 +3,15 @@
justify-content: space-between;
@media (min-width: 376px) {
flex-direction: row;
}
}
.avg-ratio {
display: none;
@media (min-width: 480px) and (max-width: 767px), (min-width: 915px) {
display: block;
}
}
.item {
max-width: 300px;
margin: 0;

View file

@ -9,6 +9,7 @@ import { Observable, map } from 'rxjs';
})
export class ReservesRatioStatsComponent implements OnInit {
@Input() fullHistory$: Observable<any>;
@Input() emergencyUtxosStats$: Observable<any>;
unbackedMonths$: Observable<any>
constructor() { }

View file

@ -159,12 +159,28 @@
<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 card-wrapper liquid">
<div class="main-title" i18n="liquid.federation-holdings">Liquid Federation Holdings</div>
<div class="card">
<div class="card-body liquid">
<app-reserves-supply-stats [currentPeg$]="currentPeg$" [currentReserves$]="currentReserves$"></app-reserves-supply-stats>
</div>
</div>
</div>
<div class="col card-wrapper liquid">
<div class="main-title" i18n="liquid.federation-expired-utxos">Federation Timelock-Expired UTXOs</div>
<div class="card">
<div class="card-body liquid">
<app-expired-utxos-stats [expiredUtxos$]="expiredUtxos$"></app-expired-utxos-stats>
</div>
</div>
</div>
<div class="col">
<div class="card-liquid card">
<div class="card-title card-title-liquid">
<app-reserves-supply-stats [currentPeg$]="currentPeg$" [currentReserves$]="currentReserves$"></app-reserves-supply-stats>
</div>
<h5 class="card-title" style="padding-top: 20px;" i18n="dashboard.lbtc-supply-against-btc-holdings">L-BTC Supply Against BTC Holdings</h5>
<div class="card-body pl-0" style="padding-top: 10px;">
<app-lbtc-pegs-graph [data]="fullHistory$ | async" [height]="lbtcPegGraphHeight"></app-lbtc-pegs-graph>
</div>
@ -174,7 +190,7 @@
<div class="col" style="margin-bottom: 1.47rem">
<div class="card-liquid card">
<div class="card-body">
<app-reserves-ratio-stats [fullHistory$]="fullHistory$"></app-reserves-ratio-stats>
<app-reserves-ratio-stats [fullHistory$]="fullHistory$" [emergencyUtxosStats$]="emergencySpentUtxosStats$"></app-reserves-ratio-stats>
<app-reserves-ratio [currentPeg]="currentPeg$ | async" [currentReserves]="currentReserves$ | async"></app-reserves-ratio>
</div>
</div>
@ -283,13 +299,29 @@
<div class="container-xl dashboard-container">
<div class="row row-cols-1 row-cols-md-2">
<div class="col card-wrapper liquid">
<div class="main-title" i18n="liquid.federation-holdings">Liquid Federation Holdings</div>
<div class="card">
<div class="card-body liquid">
<app-reserves-supply-stats></app-reserves-supply-stats>
</div>
</div>
</div>
<div class="col card-wrapper liquid">
<div class="main-title" i18n="liquid.federation-expired-utxos">Federation Timelock-Expired UTXOs</div>
<div class="card">
<div class="card-body liquid">
<app-expired-utxos-stats></app-expired-utxos-stats>
</div>
</div>
</div>
<div class="col">
<div class="card-liquid card">
<div class="card-title card-title-liquid">
<app-reserves-supply-stats></app-reserves-supply-stats>
</div>
<div class="card-body pl-0" style="padding-top: 10px;">
<h5 class="card-title" style="padding-top: 20px;" i18n="dashboard.lbtc-supply-against-btc-holdings">L-BTC Supply Against BTC Holdings</h5>
<div class="card-body pl-0" style="padding-top: 25px;">
<app-lbtc-pegs-graph [height]="lbtcPegGraphHeight"></app-lbtc-pegs-graph>
</div>
</div>

View file

@ -321,6 +321,9 @@
flex-direction: column;
justify-content: space-around;
padding: 22px 20px;
&.liquid {
height: 124.5px;
}
}
.less-padding {
padding: 20px 20px;

View file

@ -1,7 +1,7 @@
import { AfterViewInit, ChangeDetectionStrategy, Component, HostListener, OnDestroy, OnInit } from '@angular/core';
import { combineLatest, EMPTY, fromEvent, interval, merge, Observable, of, Subject, Subscription, timer } from 'rxjs';
import { catchError, delayWhen, distinctUntilChanged, filter, map, scan, share, shareReplay, startWith, switchMap, takeUntil, tap, throttleTime } from 'rxjs/operators';
import { AuditStatus, BlockExtended, CurrentPegs, FederationAddress, OptimizedMempoolStats, PegsVolume, RecentPeg } from '../interfaces/node-api.interface';
import { AuditStatus, BlockExtended, CurrentPegs, FederationAddress, FederationUtxo, OptimizedMempoolStats, PegsVolume, RecentPeg } from '../interfaces/node-api.interface';
import { MempoolInfo, TransactionStripped, ReplacementInfo } from '../interfaces/websocket.interface';
import { ApiService } from '../services/api.service';
import { StateService } from '../services/state.service';
@ -57,6 +57,8 @@ export class DashboardComponent implements OnInit, OnDestroy, AfterViewInit {
federationAddresses$: Observable<FederationAddress[]>;
federationAddressesNumber$: Observable<number>;
federationUtxosNumber$: Observable<number>;
expiredUtxos$: Observable<FederationUtxo[]>;
emergencySpentUtxosStats$: Observable<any>;
fullHistory$: Observable<any>;
isLoad: boolean = true;
filterSubscription: Subscription;
@ -64,7 +66,7 @@ export class DashboardComponent implements OnInit, OnDestroy, AfterViewInit {
currencySubscription: Subscription;
currency: string;
incomingGraphHeight: number = 300;
lbtcPegGraphHeight: number = 320;
lbtcPegGraphHeight: number = 360;
private lastPegBlockUpdate: number = 0;
private lastPegAmount: string = '';
private lastReservesBlockUpdate: number = 0;
@ -342,6 +344,20 @@ export class DashboardComponent implements OnInit, OnDestroy, AfterViewInit {
map(count => count.utxo_count),
share()
);
this.expiredUtxos$ = this.auditUpdated$.pipe(
filter(auditUpdated => auditUpdated === true),
throttleTime(40000),
switchMap(_ => this.apiService.expiredUtxos$()),
share()
);
this.emergencySpentUtxosStats$ = this.auditUpdated$.pipe(
filter(auditUpdated => auditUpdated === true),
throttleTime(40000),
switchMap(_ => this.apiService.emergencySpentUtxosStats$()),
share()
);
this.liquidPegsMonth$ = interval(60 * 60 * 1000)
.pipe(
@ -432,15 +448,15 @@ export class DashboardComponent implements OnInit, OnDestroy, AfterViewInit {
if (window.innerWidth >= 992) {
this.incomingGraphHeight = 300;
this.goggleResolution = 82;
this.lbtcPegGraphHeight = 320;
this.lbtcPegGraphHeight = 360;
} else if (window.innerWidth >= 768) {
this.incomingGraphHeight = 215;
this.goggleResolution = 80;
this.lbtcPegGraphHeight = 230;
this.lbtcPegGraphHeight = 270;
} else {
this.incomingGraphHeight = 180;
this.goggleResolution = 86;
this.lbtcPegGraphHeight = 220;
this.lbtcPegGraphHeight = 270;
}
}
}

View file

@ -13,6 +13,7 @@ import { IncomingTransactionsGraphComponent } from '../components/incoming-trans
import { MempoolGraphComponent } from '../components/mempool-graph/mempool-graph.component';
import { LbtcPegsGraphComponent } from '../components/lbtc-pegs-graph/lbtc-pegs-graph.component';
import { ReservesSupplyStatsComponent } from '../components/liquid-reserves-audit/reserves-supply-stats/reserves-supply-stats.component';
import { ExpiredUtxosStatsComponent } from '../components/liquid-reserves-audit/expired-utxos-stats/expired-utxos-stats.component';
import { ReservesRatioStatsComponent } from '../components/liquid-reserves-audit/reserves-ratio-stats/reserves-ratio-stats.component';
import { ReservesRatioComponent } from '../components/liquid-reserves-audit/reserves-ratio/reserves-ratio.component';
import { RecentPegsStatsComponent } from '../components/liquid-reserves-audit/recent-pegs-stats/recent-pegs-stats.component';
@ -56,6 +57,7 @@ import { CommonModule } from '@angular/common';
MempoolGraphComponent,
LbtcPegsGraphComponent,
ReservesSupplyStatsComponent,
ExpiredUtxosStatsComponent,
ReservesRatioStatsComponent,
ReservesRatioComponent,
RecentPegsStatsComponent,

View file

@ -103,6 +103,9 @@ export interface FederationUtxo {
pegtxid: string;
pegindex: number;
pegblocktime: number;
timelock: number;
expiredAt: number;
isDust?: boolean;
}
export interface RecentPeg {

View file

@ -200,6 +200,14 @@ export class ApiService {
return this.httpClient.get<FederationUtxo[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/reserves/utxos');
}
expiredUtxos$(): Observable<FederationUtxo[]> {
return this.httpClient.get<FederationUtxo[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/reserves/utxos/expired');
}
emergencySpentUtxos$(): Observable<FederationUtxo[]> {
return this.httpClient.get<FederationUtxo[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/reserves/utxos/emergency-spent');
}
recentPegsList$(count: number = 0): Observable<RecentPeg[]> {
return this.httpClient.get<RecentPeg[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/pegs/list/' + count);
}
@ -216,6 +224,10 @@ export class ApiService {
return this.httpClient.get<any>(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/reserves/utxos/total');
}
emergencySpentUtxosStats$(): Observable<any> {
return this.httpClient.get<any>(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/reserves/utxos/emergency-spent/stats');
}
listFeaturedAssets$(): Observable<any[]> {
return this.httpClient.get<any[]>(this.apiBaseUrl + '/api/v1/assets/featured');
}