From 73e6045549f930a76102c5b546b75d3fd69edee8 Mon Sep 17 00:00:00 2001 From: natsee Date: Tue, 30 Jan 2024 11:11:30 +0100 Subject: [PATCH] Liquid: Add support for peg outs display --- backend/src/api/liquid/elements-parser.ts | 33 ++++++++++++++++--- backend/src/api/liquid/liquid.routes.ts | 13 ++++++++ .../recent-pegs-list.component.ts | 25 +++++++++++++- .../reserves-audit-dashboard.component.html | 2 +- .../reserves-audit-dashboard.component.ts | 9 +++++ .../src/app/interfaces/node-api.interface.ts | 3 +- frontend/src/app/services/api.service.ts | 6 +++- 7 files changed, 83 insertions(+), 8 deletions(-) diff --git a/backend/src/api/liquid/elements-parser.ts b/backend/src/api/liquid/elements-parser.ts index 37006c6bf..753d33b0b 100644 --- a/backend/src/api/liquid/elements-parser.ts +++ b/backend/src/api/liquid/elements-parser.ts @@ -163,6 +163,10 @@ class ElementsParser { unspentAsTip = []; } + // Get the peg-out addresses that need to be scanned + const redeemAddresses = await this.$getRedeemAddressesToScan(); + if (redeemAddresses.length > 0) logger.debug(`Found ${redeemAddresses.length} peg-out addresses to scan`); + // The slow way: parse the block to look for the spending tx logger.debug(`${spentAsTip.length} / ${utxos.length} Federation UTXOs are spent as of tip`); @@ -170,7 +174,7 @@ class ElementsParser { const block: IBitcoinApi.Block = await bitcoinSecondClient.getBlock(blockHash, 2); const nbUtxos = spentAsTip.length; await DB.query('START TRANSACTION;'); - await this.$parseBitcoinBlock(block, spentAsTip, unspentAsTip, auditProgress.confirmedTip); + await this.$parseBitcoinBlock(block, spentAsTip, unspentAsTip, auditProgress.confirmedTip, redeemAddresses); await DB.query(`COMMIT;`); logger.debug(`Watched for spending of ${nbUtxos} Federation UTXOs in block ${auditProgress.lastBlockAudit} / ${auditProgress.confirmedTip}`); @@ -210,12 +214,14 @@ class ElementsParser { return {spentAsTip, unspentAsTip}; } - protected async $parseBitcoinBlock(block: IBitcoinApi.Block, spentAsTip: any[], unspentAsTip: any[], confirmedTip: number) { + protected async $parseBitcoinBlock(block: IBitcoinApi.Block, spentAsTip: any[], unspentAsTip: any[], confirmedTip: number, redeemAddresses: string[] = []) { + let mightRedeemInThisBlock = false; // If a Federation UTXO is spent in this block, we might find a peg-out address in the outputs... for (const tx of block.tx) { // 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) { + mightRedeemInThisBlock = 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); @@ -241,6 +247,13 @@ class ElementsParser { logger.debug(`Added new Federation UTXO ${tx.txid}:${output.n} of ${output.value * 100000000} sats belonging to ${output.scriptPubKey.address} (Federation change address).`); } } + if (mightRedeemInThisBlock && output.scriptPubKey.address && redeemAddresses.includes(output.scriptPubKey.address)) { + const query_add_redeem = `UPDATE elements_pegs SET bitcointxid = ?, bitcoinindex = ? WHERE bitcoinaddress = ?`; + const params_add_redeem: (string | number)[] = [tx.txid, output.n, output.scriptPubKey.address]; + await DB.query(query_add_redeem, params_add_redeem); + redeemAddresses.splice(redeemAddresses.indexOf(output.scriptPubKey.address), 1); + logger.debug(`Added redeem txid ${tx.txid}:${output.n} to peg-out address ${output.scriptPubKey.address}`); + } } } @@ -283,9 +296,15 @@ class ElementsParser { return rows[0]['number']; } - ///////////// DATA QUERY ////////////// + protected async $getRedeemAddressesToScan(): Promise { + const query = `SELECT bitcoinaddress FROM elements_pegs where amount < 0 AND bitcoinaddress != '' AND bitcointxid = '';`; + const [rows]: any[] = await DB.query(query); + return rows.map((row: any) => row.bitcoinaddress); + } - public async $getAuditStatus(): Promise { + ///////////// DATA QUERY ////////////// + + public async $getAuditStatus(): Promise { const lastBlockAudit = await this.$getLastBlockAudit(); const bitcoinBlocksToSync = await this.$getBitcoinBlockchainState(); return { @@ -382,6 +401,12 @@ class ElementsParser { return rows[0]; } + // Get the 300 most recent pegouts from the federation + public async $getRecentPegouts(): Promise { + const query = `SELECT txid, txindex, amount, bitcoinaddress, bitcointxid, bitcoinindex, datetime AS blocktime FROM elements_pegs WHERE amount < 0 ORDER BY blocktime DESC LIMIT 300;`; + const [rows] = await DB.query(query); + return rows; + } } export default new ElementsParser(); diff --git a/backend/src/api/liquid/liquid.routes.ts b/backend/src/api/liquid/liquid.routes.ts index 582b139af..64d631a05 100644 --- a/backend/src/api/liquid/liquid.routes.ts +++ b/backend/src/api/liquid/liquid.routes.ts @@ -19,6 +19,7 @@ class LiquidRoutes { .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) @@ -176,6 +177,18 @@ class LiquidRoutes { } } + 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(); diff --git a/frontend/src/app/components/liquid-reserves-audit/recent-pegs-list/recent-pegs-list.component.ts b/frontend/src/app/components/liquid-reserves-audit/recent-pegs-list/recent-pegs-list.component.ts index 0c42477a9..2ff103301 100644 --- a/frontend/src/app/components/liquid-reserves-audit/recent-pegs-list/recent-pegs-list.component.ts +++ b/frontend/src/app/components/liquid-reserves-audit/recent-pegs-list/recent-pegs-list.component.ts @@ -15,6 +15,7 @@ import { WebsocketService } from '../../../services/websocket.service'; export class RecentPegsListComponent implements OnInit { @Input() widget: boolean = false; @Input() recentPegIns$: Observable; + @Input() recentPegOuts$: Observable; env: Env; isLoading = true; @@ -103,6 +104,7 @@ export class RecentPegsListComponent implements OnInit { txid: utxo.pegtxid, txindex: utxo.pegindex, amount: utxo.amount, + bitcoinaddress: utxo.bitcoinaddress, bitcointxid: utxo.txid, bitcoinindex: utxo.txindex, blocktime: utxo.pegblocktime, @@ -110,9 +112,30 @@ export class RecentPegsListComponent implements OnInit { })), share() ); + + this.recentPegOuts$ = this.auditUpdated$.pipe( + filter(auditUpdated => auditUpdated === true), + throttleTime(40000), + switchMap(_ => this.apiService.recentPegOuts$()), + share() + ); + } - this.recentPegs$ = this.recentPegIns$; + this.recentPegs$ = combineLatest([ + this.recentPegIns$, + this.recentPegOuts$ + ]).pipe( + map(([recentPegIns, recentPegOuts]) => { + return [ + ...recentPegIns, + ...recentPegOuts + ].sort((a, b) => { + return b.blocktime - a.blocktime; + }); + }), + share() + ); } ngOnDestroy(): void { diff --git a/frontend/src/app/components/liquid-reserves-audit/reserves-audit-dashboard/reserves-audit-dashboard.component.html b/frontend/src/app/components/liquid-reserves-audit/reserves-audit-dashboard/reserves-audit-dashboard.component.html index f7858a713..e9f6b4ccd 100644 --- a/frontend/src/app/components/liquid-reserves-audit/reserves-audit-dashboard/reserves-audit-dashboard.component.html +++ b/frontend/src/app/components/liquid-reserves-audit/reserves-audit-dashboard/reserves-audit-dashboard.component.html @@ -26,7 +26,7 @@
- +
diff --git a/frontend/src/app/components/liquid-reserves-audit/reserves-audit-dashboard/reserves-audit-dashboard.component.ts b/frontend/src/app/components/liquid-reserves-audit/reserves-audit-dashboard/reserves-audit-dashboard.component.ts index faa00b3d0..9b23eb7cb 100644 --- a/frontend/src/app/components/liquid-reserves-audit/reserves-audit-dashboard/reserves-audit-dashboard.component.ts +++ b/frontend/src/app/components/liquid-reserves-audit/reserves-audit-dashboard/reserves-audit-dashboard.component.ts @@ -19,6 +19,7 @@ export class ReservesAuditDashboardComponent implements OnInit { currentReserves$: Observable; federationUtxos$: Observable; recentPegIns$: Observable; + recentPegOuts$: Observable; federationAddresses$: Observable; federationAddressesOneMonthAgo$: Observable; liquidPegsMonth$: Observable; @@ -110,6 +111,7 @@ export class ReservesAuditDashboardComponent implements OnInit { txid: utxo.pegtxid, txindex: utxo.pegindex, amount: utxo.amount, + bitcoinaddress: utxo.bitcoinaddress, bitcointxid: utxo.txid, bitcoinindex: utxo.txindex, blocktime: utxo.pegblocktime, @@ -118,6 +120,13 @@ export class ReservesAuditDashboardComponent implements OnInit { 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), diff --git a/frontend/src/app/interfaces/node-api.interface.ts b/frontend/src/app/interfaces/node-api.interface.ts index 6606cae4c..cebb23f27 100644 --- a/frontend/src/app/interfaces/node-api.interface.ts +++ b/frontend/src/app/interfaces/node-api.interface.ts @@ -101,8 +101,9 @@ export interface FederationUtxo { export interface RecentPeg { txid: string; - txindex: number; // input #0 for peg-ins + txindex: number; amount: number; + bitcoinaddress: string; bitcointxid: string; bitcoinindex: number; blocktime: number; diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index af4441eae..38060d47d 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators, - PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights, RbfTree, BlockAudit, Acceleration, AccelerationHistoryParams, CurrentPegs, AuditStatus, FederationAddress, FederationUtxo } 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 { StateService } from './state.service'; import { IBackendInfo, WebsocketResponse } from '../interfaces/websocket.interface'; @@ -206,6 +206,10 @@ export class ApiService { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/reserves/utxos'); } + recentPegOuts$(): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/pegouts'); + } + federationAddressesOneMonthAgo$(): Observable { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/v1/liquid/reserves/addresses/previous-month'); }