diff --git a/backend/src/api/database-migration.ts b/backend/src/api/database-migration.ts index 861830226..f493e4eb3 100644 --- a/backend/src/api/database-migration.ts +++ b/backend/src/api/database-migration.ts @@ -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); + } } /** diff --git a/backend/src/api/liquid/elements-parser.ts b/backend/src/api/liquid/elements-parser.ts index 101a03aca..727865b95 100644 --- a/backend/src/api/liquid/elements-parser.ts +++ b/backend/src/api/liquid/elements-parser.ts @@ -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 { @@ -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 { - 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 { - 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 { - 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 { + 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 { + 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 { - 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 { - 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 { + 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]; } diff --git a/backend/src/api/liquid/liquid.routes.ts b/backend/src/api/liquid/liquid.routes.ts index 01d91e3b0..9ea61ca31 100644 --- a/backend/src/api/liquid/liquid.routes.ts +++ b/backend/src/api/liquid/liquid.routes.ts @@ -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)); diff --git a/backend/src/rpc-api/commands.ts b/backend/src/rpc-api/commands.ts index ecfb2ed7c..85675230b 100644 --- a/backend/src/rpc-api/commands.ts +++ b/backend/src/rpc-api/commands.ts @@ -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', diff --git a/frontend/src/app/components/lbtc-pegs-graph/lbtc-pegs-graph.component.ts b/frontend/src/app/components/lbtc-pegs-graph/lbtc-pegs-graph.component.ts index 9931fb78a..25c061550 100644 --- a/frontend/src/app/components/lbtc-pegs-graph/lbtc-pegs-graph.component.ts +++ b/frontend/src/app/components/lbtc-pegs-graph/lbtc-pegs-graph.component.ts @@ -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'; diff --git a/frontend/src/app/components/liquid-reserves-audit/expired-utxos-stats/expired-utxos-stats.component.html b/frontend/src/app/components/liquid-reserves-audit/expired-utxos-stats/expired-utxos-stats.component.html new file mode 100644 index 000000000..44dfedd26 --- /dev/null +++ b/frontend/src/app/components/liquid-reserves-audit/expired-utxos-stats/expired-utxos-stats.component.html @@ -0,0 +1,28 @@ +
+
+
Non-Dust Expired
+
+
{{ (+expiredStats.nonDust.total) / 100000000 | number: '1.5-5' }} BTC
+
{{ expiredStats.nonDust.count }} UTXOs
+
+
+ +
+ +
Total Expired 
+
+
+
{{ (+expiredStats.all.total) / 100000000 | number: '1.5-5' }} BTC
+
{{ expiredStats.all.count }} UTXOs
+
+
+
+ + + +
+
+
+
+
+ diff --git a/frontend/src/app/components/liquid-reserves-audit/expired-utxos-stats/expired-utxos-stats.component.scss b/frontend/src/app/components/liquid-reserves-audit/expired-utxos-stats/expired-utxos-stats.component.scss new file mode 100644 index 000000000..d2044f6de --- /dev/null +++ b/frontend/src/app/components/liquid-reserves-audit/expired-utxos-stats/expired-utxos-stats.component.scss @@ -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; +} diff --git a/frontend/src/app/components/liquid-reserves-audit/expired-utxos-stats/expired-utxos-stats.component.ts b/frontend/src/app/components/liquid-reserves-audit/expired-utxos-stats/expired-utxos-stats.component.ts new file mode 100644 index 000000000..90a737275 --- /dev/null +++ b/frontend/src/app/components/liquid-reserves-audit/expired-utxos-stats/expired-utxos-stats.component.ts @@ -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; + + stats$: Observable; + + 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; + }), + ); + } + +} diff --git a/frontend/src/app/components/liquid-reserves-audit/federation-utxos-list/federation-utxos-list.component.html b/frontend/src/app/components/liquid-reserves-audit/federation-utxos-list/federation-utxos-list.component.html index ea52cd8d7..f386ed64b 100644 --- a/frontend/src/app/components/liquid-reserves-audit/federation-utxos-list/federation-utxos-list.component.html +++ b/frontend/src/app/components/liquid-reserves-audit/federation-utxos-list/federation-utxos-list.component.html @@ -10,6 +10,9 @@ Amount Related Peg-In Date + Expires in + Expired since + Is Dust @@ -56,6 +59,13 @@ ‎{{ utxo.blocktime * 1000 | date:'yyyy-MM-dd HH:mm' }}
()
+ + {{ utxo.blocknumber + utxo.timelock - lastReservesBlockUpdate < 0 ? -(utxo.blocknumber + utxo.timelock - lastReservesBlockUpdate) : utxo.blocknumber + utxo.timelock - lastReservesBlockUpdate }} blocks + + +
Yes
+
No
+ @@ -90,6 +100,9 @@ + + + diff --git a/frontend/src/app/components/liquid-reserves-audit/federation-utxos-list/federation-utxos-list.component.scss b/frontend/src/app/components/liquid-reserves-audit/federation-utxos-list/federation-utxos-list.component.scss index 617edc869..6a2353f0c 100644 --- a/frontend/src/app/components/liquid-reserves-audit/federation-utxos-list/federation-utxos-list.component.scss +++ b/frontend/src/app/components/liquid-reserves-audit/federation-utxos-list/federation-utxos-list.component.scss @@ -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; + } +} + diff --git a/frontend/src/app/components/liquid-reserves-audit/federation-utxos-list/federation-utxos-list.component.ts b/frontend/src/app/components/liquid-reserves-audit/federation-utxos-list/federation-utxos-list.component.ts index 30f401abf..78181375d 100644 --- a/frontend/src/app/components/liquid-reserves-audit/federation-utxos-list/federation-utxos-list.component.ts +++ b/frontend/src/app/components/liquid-reserves-audit/federation-utxos-list/federation-utxos-list.component.ts @@ -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; auditUpdated$: Observable; + showExpiredUtxos: boolean = false; + showExpiredUtxosToggleSubject: BehaviorSubject = new BehaviorSubject(this.showExpiredUtxos); + showExpiredUtxosToggle$: Observable = this.showExpiredUtxosToggleSubject.asObservable(); lastReservesBlockUpdate: number = 0; currentPeg$: Observable; 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; + } + } diff --git a/frontend/src/app/components/liquid-reserves-audit/federation-wallet/federation-wallet.component.html b/frontend/src/app/components/liquid-reserves-audit/federation-wallet/federation-wallet.component.html index 1bb397533..4f9fd1ff4 100644 --- a/frontend/src/app/components/liquid-reserves-audit/federation-wallet/federation-wallet.component.html +++ b/frontend/src/app/components/liquid-reserves-audit/federation-wallet/federation-wallet.component.html @@ -1,4 +1,4 @@ -
+

Liquid Federation Wallet

@@ -6,8 +6,10 @@