Merge pull request #4163 from mempool/mononaut/fast-forensics

Fast lightning forensics
This commit is contained in:
wiz 2023-11-13 14:54:20 +09:00 committed by GitHub
commit 28113d1141
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 265 additions and 119 deletions

View file

@ -24,6 +24,8 @@ export interface AbstractBitcoinApi {
$getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend>; $getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend>;
$getOutspends(txId: string): Promise<IEsploraApi.Outspend[]>; $getOutspends(txId: string): Promise<IEsploraApi.Outspend[]>;
$getBatchedOutspends(txId: string[]): Promise<IEsploraApi.Outspend[][]>; $getBatchedOutspends(txId: string[]): Promise<IEsploraApi.Outspend[][]>;
$getBatchedOutspendsInternal(txId: string[]): Promise<IEsploraApi.Outspend[][]>;
$getOutSpendsByOutpoint(outpoints: { txid: string, vout: number }[]): Promise<IEsploraApi.Outspend[]>;
startHealthChecks(): void; startHealthChecks(): void;
} }

View file

@ -60,8 +60,17 @@ class BitcoinApi implements AbstractBitcoinApi {
}); });
} }
$getRawTransactions(txids: string[]): Promise<IEsploraApi.Transaction[]> { async $getRawTransactions(txids: string[]): Promise<IEsploraApi.Transaction[]> {
throw new Error('Method getRawTransactions not supported by the Bitcoin RPC API.'); const txs: IEsploraApi.Transaction[] = [];
for (const txid of txids) {
try {
const tx = await this.$getRawTransaction(txid, false, true);
txs.push(tx);
} catch (err) {
// skip failures
}
}
return txs;
} }
$getMempoolTransactions(txids: string[]): Promise<IEsploraApi.Transaction[]> { $getMempoolTransactions(txids: string[]): Promise<IEsploraApi.Transaction[]> {
@ -202,6 +211,19 @@ class BitcoinApi implements AbstractBitcoinApi {
return outspends; return outspends;
} }
async $getBatchedOutspendsInternal(txId: string[]): Promise<IEsploraApi.Outspend[][]> {
return this.$getBatchedOutspends(txId);
}
async $getOutSpendsByOutpoint(outpoints: { txid: string, vout: number }[]): Promise<IEsploraApi.Outspend[]> {
const outspends: IEsploraApi.Outspend[] = [];
for (const outpoint of outpoints) {
const outspend = await this.$getOutspend(outpoint.txid, outpoint.vout);
outspends.push(outspend);
}
return outspends;
}
$getEstimatedHashrate(blockHeight: number): Promise<number> { $getEstimatedHashrate(blockHeight: number): Promise<number> {
// 120 is the default block span in Core // 120 is the default block span in Core
return this.bitcoindClient.getNetworkHashPs(120, blockHeight); return this.bitcoindClient.getNetworkHashPs(120, blockHeight);

View file

@ -24,7 +24,6 @@ class BitcoinRoutes {
public initRoutes(app: Application) { public initRoutes(app: Application) {
app app
.get(config.MEMPOOL.API_URL_PREFIX + 'transaction-times', this.getTransactionTimes) .get(config.MEMPOOL.API_URL_PREFIX + 'transaction-times', this.getTransactionTimes)
.get(config.MEMPOOL.API_URL_PREFIX + 'outspends', this.$getBatchedOutspends)
.get(config.MEMPOOL.API_URL_PREFIX + 'cpfp/:txId', this.$getCpfpInfo) .get(config.MEMPOOL.API_URL_PREFIX + 'cpfp/:txId', this.$getCpfpInfo)
.get(config.MEMPOOL.API_URL_PREFIX + 'difficulty-adjustment', this.getDifficultyChange) .get(config.MEMPOOL.API_URL_PREFIX + 'difficulty-adjustment', this.getDifficultyChange)
.get(config.MEMPOOL.API_URL_PREFIX + 'fees/recommended', this.getRecommendedFees) .get(config.MEMPOOL.API_URL_PREFIX + 'fees/recommended', this.getRecommendedFees)
@ -112,6 +111,7 @@ class BitcoinRoutes {
.get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/hex', this.getRawTransaction) .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/hex', this.getRawTransaction)
.get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/status', this.getTransactionStatus) .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/status', this.getTransactionStatus)
.get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/outspends', this.getTransactionOutspends) .get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/outspends', this.getTransactionOutspends)
.get(config.MEMPOOL.API_URL_PREFIX + 'txs/outspends', this.$getBatchedOutspends)
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/header', this.getBlockHeader) .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/header', this.getBlockHeader)
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks/tip/hash', this.getBlockTipHash) .get(config.MEMPOOL.API_URL_PREFIX + 'blocks/tip/hash', this.getBlockTipHash)
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/raw', this.getRawBlock) .get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/raw', this.getRawBlock)
@ -174,24 +174,20 @@ class BitcoinRoutes {
res.json(times); res.json(times);
} }
private async $getBatchedOutspends(req: Request, res: Response) { private async $getBatchedOutspends(req: Request, res: Response): Promise<IEsploraApi.Outspend[][] | void> {
if (!Array.isArray(req.query.txId)) { const txids_csv = req.query.txids;
res.status(500).send('Not an array'); if (!txids_csv || typeof txids_csv !== 'string') {
res.status(500).send('Invalid txids format');
return; return;
} }
if (req.query.txId.length > 50) { const txids = txids_csv.split(',');
if (txids.length > 50) {
res.status(400).send('Too many txids requested'); res.status(400).send('Too many txids requested');
return; return;
} }
const txIds: string[] = [];
for (const _txId in req.query.txId) {
if (typeof req.query.txId[_txId] === 'string') {
txIds.push(req.query.txId[_txId].toString());
}
}
try { try {
const batchedOutspends = await bitcoinApi.$getBatchedOutspends(txIds); const batchedOutspends = await bitcoinApi.$getBatchedOutspends(txids);
res.json(batchedOutspends); res.json(batchedOutspends);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); res.status(500).send(e instanceof Error ? e.message : e);

View file

@ -174,6 +174,9 @@ class FailoverRouter {
axiosConfig = { timeout: config.ESPLORA.REQUEST_TIMEOUT, responseType }; axiosConfig = { timeout: config.ESPLORA.REQUEST_TIMEOUT, responseType };
url = host.host + path; url = host.host + path;
} }
if (data?.params) {
axiosConfig.params = data.params;
}
return (method === 'post' return (method === 'post'
? this.requestConnection.post<T>(url, data, axiosConfig) ? this.requestConnection.post<T>(url, data, axiosConfig)
: this.requestConnection.get<T>(url, axiosConfig) : this.requestConnection.get<T>(url, axiosConfig)
@ -194,8 +197,8 @@ class FailoverRouter {
}); });
} }
public async $get<T>(path, responseType = 'json'): Promise<T> { public async $get<T>(path, responseType = 'json', params: any = null): Promise<T> {
return this.$query<T>('get', path, null, responseType); return this.$query<T>('get', path, params ? { params } : null, responseType);
} }
public async $post<T>(path, data: any, responseType = 'json'): Promise<T> { public async $post<T>(path, data: any, responseType = 'json'): Promise<T> {
@ -295,13 +298,16 @@ class ElectrsApi implements AbstractBitcoinApi {
return this.failoverRouter.$get<IEsploraApi.Outspend[]>('/tx/' + txId + '/outspends'); return this.failoverRouter.$get<IEsploraApi.Outspend[]>('/tx/' + txId + '/outspends');
} }
async $getBatchedOutspends(txId: string[]): Promise<IEsploraApi.Outspend[][]> { async $getBatchedOutspends(txids: string[]): Promise<IEsploraApi.Outspend[][]> {
const outspends: IEsploraApi.Outspend[][] = []; throw new Error('Method not implemented.');
for (const tx of txId) { }
const outspend = await this.$getOutspends(tx);
outspends.push(outspend); async $getBatchedOutspendsInternal(txids: string[]): Promise<IEsploraApi.Outspend[][]> {
} return this.failoverRouter.$post<IEsploraApi.Outspend[][]>('/internal/txs/outspends/by-txid', txids, 'json');
return outspends; }
async $getOutSpendsByOutpoint(outpoints: { txid: string, vout: number }[]): Promise<IEsploraApi.Outspend[]> {
return this.failoverRouter.$post<IEsploraApi.Outspend[]>('/internal/txs/outspends/by-outpoint', outpoints.map(out => `${out.txid}:${out.vout}`), 'json');
} }
public startHealthChecks(): void { public startHealthChecks(): void {

View file

@ -15,8 +15,6 @@ class ForensicsService {
txCache: { [txid: string]: IEsploraApi.Transaction } = {}; txCache: { [txid: string]: IEsploraApi.Transaction } = {};
tempCached: string[] = []; tempCached: string[] = [];
constructor() {}
public async $startService(): Promise<void> { public async $startService(): Promise<void> {
logger.info('Starting lightning network forensics service'); logger.info('Starting lightning network forensics service');
@ -66,93 +64,138 @@ class ForensicsService {
*/ */
public async $runClosedChannelsForensics(onlyNewChannels: boolean = false): Promise<void> { public async $runClosedChannelsForensics(onlyNewChannels: boolean = false): Promise<void> {
// Only Esplora backend can retrieve spent transaction outputs
if (config.MEMPOOL.BACKEND !== 'esplora') { if (config.MEMPOOL.BACKEND !== 'esplora') {
return; return;
} }
let progress = 0;
try { try {
logger.debug(`Started running closed channel forensics...`); logger.debug(`Started running closed channel forensics...`);
let channels; let allChannels;
if (onlyNewChannels) { if (onlyNewChannels) {
channels = await channelsApi.$getClosedChannelsWithoutReason(); allChannels = await channelsApi.$getClosedChannelsWithoutReason();
} else { } else {
channels = await channelsApi.$getUnresolvedClosedChannels(); allChannels = await channelsApi.$getUnresolvedClosedChannels();
} }
for (const channel of channels) { let progress = 0;
let reason = 0; const sliceLength = 1000;
let resolvedForceClose = false; // process batches of 1000 channels
// Only Esplora backend can retrieve spent transaction outputs for (let i = 0; i < Math.ceil(allChannels.length / sliceLength); i++) {
const cached: string[] = []; const channels = allChannels.slice(i * sliceLength, (i + 1) * sliceLength);
let allOutspends: IEsploraApi.Outspend[][] = [];
const forceClosedChannels: { channel: any, cachedSpends: string[] }[] = [];
// fetch outspends in bulk
try { try {
let outspends: IEsploraApi.Outspend[] | undefined; const outspendTxids = channels.map(channel => channel.closing_transaction_id);
try { allOutspends = await bitcoinApi.$getBatchedOutspendsInternal(outspendTxids);
outspends = await bitcoinApi.$getOutspends(channel.closing_transaction_id); logger.info(`Fetched outspends for ${allOutspends.length} txs from esplora for LN forensics`);
await Common.sleep$(config.LIGHTNING.FORENSICS_RATE_LIMIT); await Common.sleep$(config.LIGHTNING.FORENSICS_RATE_LIMIT);
} catch (e) { } catch (e) {
logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + channel.closing_transaction_id + '/outspends'}. Reason ${e instanceof Error ? e.message : e}`); logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/internal/txs/outspends/by-txid'}. Reason ${e instanceof Error ? e.message : e}`);
continue; }
} // fetch spending transactions in bulk and load into txCache
const lightningScriptReasons: number[] = []; const newSpendingTxids: { [txid: string]: boolean } = {};
for (const outspends of allOutspends) {
for (const outspend of outspends) { for (const outspend of outspends) {
if (outspend.spent && outspend.txid) { if (outspend.spent && outspend.txid) {
let spendingTx = await this.fetchTransaction(outspend.txid); newSpendingTxids[outspend.txid] = true;
if (!spendingTx) {
continue;
}
cached.push(spendingTx.txid);
const lightningScript = this.findLightningScript(spendingTx.vin[outspend.vin || 0]);
lightningScriptReasons.push(lightningScript);
} }
} }
const filteredReasons = lightningScriptReasons.filter((r) => r !== 1); }
if (filteredReasons.length) { const allOutspendTxs = await this.fetchTransactions(
if (filteredReasons.some((r) => r === 2 || r === 4)) { allOutspends.flatMap(outspends =>
reason = 3; outspends
} else { .filter(outspend => outspend.spent && outspend.txid)
reason = 2; .map(outspend => outspend.txid)
resolvedForceClose = true; )
} );
} else { logger.info(`Fetched ${allOutspendTxs.length} out-spending txs from esplora for LN forensics`);
/*
We can detect a commitment transaction (force close) by reading Sequence and Locktime // process each outspend
https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction for (const [index, channel] of channels.entries()) {
*/ let reason = 0;
let closingTx = await this.fetchTransaction(channel.closing_transaction_id, true); const cached: string[] = [];
if (!closingTx) { try {
const outspends = allOutspends[index];
if (!outspends || !outspends.length) {
// outspends are missing
continue; continue;
} }
cached.push(closingTx.txid); const lightningScriptReasons: number[] = [];
const sequenceHex: string = closingTx.vin[0].sequence.toString(16); for (const outspend of outspends) {
const locktimeHex: string = closingTx.locktime.toString(16); if (outspend.spent && outspend.txid) {
if (sequenceHex.substring(0, 2) === '80' && locktimeHex.substring(0, 2) === '20') { const spendingTx = this.txCache[outspend.txid];
reason = 2; // Here we can't be sure if it's a penalty or not if (!spendingTx) {
} else { continue;
reason = 1; }
cached.push(spendingTx.txid);
const lightningScript = this.findLightningScript(spendingTx.vin[outspend.vin || 0]);
lightningScriptReasons.push(lightningScript);
}
} }
} const filteredReasons = lightningScriptReasons.filter((r) => r !== 1);
if (reason) { if (filteredReasons.length) {
logger.debug('Setting closing reason ' + reason + ' for channel: ' + channel.id + '.'); if (filteredReasons.some((r) => r === 2 || r === 4)) {
await DB.query(`UPDATE channels SET closing_reason = ? WHERE id = ?`, [reason, channel.id]); // Force closed with penalty
if (reason === 2 && resolvedForceClose) { reason = 3;
await DB.query(`UPDATE channels SET closing_resolved = ? WHERE id = ?`, [true, channel.id]); } else {
} // Force closed without penalty
if (reason !== 2 || resolvedForceClose) { reason = 2;
await DB.query(`UPDATE channels SET closing_resolved = ? WHERE id = ?`, [true, channel.id]);
}
await DB.query(`UPDATE channels SET closing_reason = ? WHERE id = ?`, [reason, channel.id]);
// clean up cached transactions
cached.forEach(txid => { cached.forEach(txid => {
delete this.txCache[txid]; delete this.txCache[txid];
}); });
} else {
forceClosedChannels.push({ channel, cachedSpends: cached });
} }
} catch (e) {
logger.err(`$runClosedChannelsForensics() failed for channel ${channel.short_id}. Reason: ${e instanceof Error ? e.message : e}`);
} }
} catch (e) {
logger.err(`$runClosedChannelsForensics() failed for channel ${channel.short_id}. Reason: ${e instanceof Error ? e.message : e}`);
} }
++progress; // fetch force-closing transactions in bulk
const closingTxs = await this.fetchTransactions(forceClosedChannels.map(x => x.channel.closing_transaction_id));
logger.info(`Fetched ${closingTxs.length} closing txs from esplora for LN forensics`);
// process channels with no lightning script reasons
for (const { channel, cachedSpends } of forceClosedChannels) {
const closingTx = this.txCache[channel.closing_transaction_id];
if (!closingTx) {
// no channel close transaction found yet
continue;
}
/*
We can detect a commitment transaction (force close) by reading Sequence and Locktime
https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction
*/
const sequenceHex: string = closingTx.vin[0].sequence.toString(16);
const locktimeHex: string = closingTx.locktime.toString(16);
let reason;
if (sequenceHex.substring(0, 2) === '80' && locktimeHex.substring(0, 2) === '20') {
// Force closed, but we can't be sure if it's a penalty or not
reason = 2;
} else {
// Mutually closed
reason = 1;
// clean up cached transactions
delete this.txCache[closingTx.txid];
for (const txid of cachedSpends) {
delete this.txCache[txid];
}
}
await DB.query(`UPDATE channels SET closing_reason = ? WHERE id = ?`, [reason, channel.id]);
}
progress += channels.length;
const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer); const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer);
if (elapsedSeconds > 10) { if (elapsedSeconds > 10) {
logger.debug(`Updating channel closed channel forensics ${progress}/${channels.length}`); logger.debug(`Updating channel closed channel forensics ${progress}/${allChannels.length}`);
this.loggerTimer = new Date().getTime() / 1000; this.loggerTimer = new Date().getTime() / 1000;
} }
} }
@ -220,8 +263,11 @@ class ForensicsService {
logger.debug(`Started running open channel forensics...`); logger.debug(`Started running open channel forensics...`);
const channels = await channelsApi.$getChannelsWithoutSourceChecked(); const channels = await channelsApi.$getChannelsWithoutSourceChecked();
// preload open channel transactions
await this.fetchTransactions(channels.map(channel => channel.transaction_id), true);
for (const openChannel of channels) { for (const openChannel of channels) {
let openTx = await this.fetchTransaction(openChannel.transaction_id, true); const openTx = this.txCache[openChannel.transaction_id];
if (!openTx) { if (!openTx) {
continue; continue;
} }
@ -276,7 +322,7 @@ class ForensicsService {
// Check if a channel open tx input spends the result of a swept channel close output // Check if a channel open tx input spends the result of a swept channel close output
private async $attributeSweptChannelCloses(openChannel: ILightningApi.Channel, input: IEsploraApi.Vin): Promise<void> { private async $attributeSweptChannelCloses(openChannel: ILightningApi.Channel, input: IEsploraApi.Vin): Promise<void> {
let sweepTx = await this.fetchTransaction(input.txid, true); const sweepTx = await this.fetchTransaction(input.txid, true);
if (!sweepTx) { if (!sweepTx) {
logger.err(`couldn't find input transaction for channel forensics ${openChannel.channel_id} ${input.txid}`); logger.err(`couldn't find input transaction for channel forensics ${openChannel.channel_id} ${input.txid}`);
return; return;
@ -335,7 +381,7 @@ class ForensicsService {
if (matched && !ambiguous) { if (matched && !ambiguous) {
// fetch closing channel transaction and perform forensics on the outputs // fetch closing channel transaction and perform forensics on the outputs
let prevChannelTx = await this.fetchTransaction(input.txid, true); const prevChannelTx = await this.fetchTransaction(input.txid, true);
let outspends: IEsploraApi.Outspend[] | undefined; let outspends: IEsploraApi.Outspend[] | undefined;
try { try {
outspends = await bitcoinApi.$getOutspends(input.txid); outspends = await bitcoinApi.$getOutspends(input.txid);
@ -355,17 +401,17 @@ class ForensicsService {
}; };
}); });
} }
// preload outspend transactions
await this.fetchTransactions(outspends.filter(o => o.spent && o.txid).map(o => o.txid), true);
for (let i = 0; i < outspends?.length; i++) { for (let i = 0; i < outspends?.length; i++) {
const outspend = outspends[i]; const outspend = outspends[i];
const output = prevChannel.outputs[i]; const output = prevChannel.outputs[i];
if (outspend.spent && outspend.txid) { if (outspend.spent && outspend.txid) {
try { const spendingTx = this.txCache[outspend.txid];
const spendingTx = await this.fetchTransaction(outspend.txid, true); if (spendingTx) {
if (spendingTx) { output.type = this.findLightningScript(spendingTx.vin[outspend.vin || 0]);
output.type = this.findLightningScript(spendingTx.vin[outspend.vin || 0]);
}
} catch (e) {
logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + outspend.txid}. Reason ${e instanceof Error ? e.message : e}`);
} }
} else { } else {
output.type = 0; output.type = 0;
@ -430,13 +476,36 @@ class ForensicsService {
} }
await Common.sleep$(config.LIGHTNING.FORENSICS_RATE_LIMIT); await Common.sleep$(config.LIGHTNING.FORENSICS_RATE_LIMIT);
} catch (e) { } catch (e) {
logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + txid + '/outspends'}. Reason ${e instanceof Error ? e.message : e}`); logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + txid}. Reason ${e instanceof Error ? e.message : e}`);
return null; return null;
} }
} }
return tx; return tx;
} }
// fetches a batch of transactions and adds them to the txCache
// the returned list of txs does *not* preserve ordering or number
async fetchTransactions(txids, temp: boolean = false): Promise<(IEsploraApi.Transaction | null)[]> {
// deduplicate txids
const uniqueTxids = [...new Set<string>(txids)];
// filter out any transactions we already have in the cache
const needToFetch: string[] = uniqueTxids.filter(txid => !this.txCache[txid]);
try {
const txs = await bitcoinApi.$getRawTransactions(needToFetch);
for (const tx of txs) {
this.txCache[tx.txid] = tx;
if (temp) {
this.tempCached.push(tx.txid);
}
}
await Common.sleep$(config.LIGHTNING.FORENSICS_RATE_LIMIT);
} catch (e) {
logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/txs'}. Reason ${e instanceof Error ? e.message : e}`);
return [];
}
return txids.map(txid => this.txCache[txid]);
}
clearTempCache(): void { clearTempCache(): void {
for (const txid of this.tempCached) { for (const txid of this.tempCached) {
delete this.txCache[txid]; delete this.txCache[txid];

View file

@ -288,22 +288,32 @@ class NetworkSyncService {
} }
logger.debug(`${log}`, logger.tags.ln); logger.debug(`${log}`, logger.tags.ln);
const channels = await channelsApi.$getChannelsByStatus([0, 1]); const allChannels = await channelsApi.$getChannelsByStatus([0, 1]);
for (const channel of channels) {
const spendingTx = await bitcoinApi.$getOutspend(channel.transaction_id, channel.transaction_vout); const sliceLength = 5000;
if (spendingTx.spent === true && spendingTx.status?.confirmed === true) { // process batches of 5000 channels
logger.debug(`Marking channel: ${channel.id} as closed.`, logger.tags.ln); for (let i = 0; i < Math.ceil(allChannels.length / sliceLength); i++) {
await DB.query(`UPDATE channels SET status = 2, closing_date = FROM_UNIXTIME(?) WHERE id = ?`, const channels = allChannels.slice(i * sliceLength, (i + 1) * sliceLength);
[spendingTx.status.block_time, channel.id]); const outspends = await bitcoinApi.$getOutSpendsByOutpoint(channels.map(channel => {
if (spendingTx.txid && !channel.closing_transaction_id) { return { txid: channel.transaction_id, vout: channel.transaction_vout };
await DB.query(`UPDATE channels SET closing_transaction_id = ? WHERE id = ?`, [spendingTx.txid, channel.id]); }));
for (const [index, channel] of channels.entries()) {
const spendingTx = outspends[index];
if (spendingTx.spent === true && spendingTx.status?.confirmed === true) {
// logger.debug(`Marking channel: ${channel.id} as closed.`, logger.tags.ln);
await DB.query(`UPDATE channels SET status = 2, closing_date = FROM_UNIXTIME(?) WHERE id = ?`,
[spendingTx.status.block_time, channel.id]);
if (spendingTx.txid && !channel.closing_transaction_id) {
await DB.query(`UPDATE channels SET closing_transaction_id = ? WHERE id = ?`, [spendingTx.txid, channel.id]);
}
} }
} }
++progress; progress += channels.length;
const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer); const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer);
if (elapsedSeconds > config.LIGHTNING.LOGGER_UPDATE_INTERVAL) { if (elapsedSeconds > config.LIGHTNING.LOGGER_UPDATE_INTERVAL) {
logger.debug(`Checking if channel has been closed ${progress}/${channels.length}`, logger.tags.ln); logger.debug(`Checking if channel has been closed ${progress}/${allChannels.length}`, logger.tags.ln);
this.loggerTimer = new Date().getTime() / 1000; this.loggerTimer = new Date().getTime() / 1000;
} }
} }

View file

@ -75,7 +75,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
for (let i = 0; i < txIds.length; i += 50) { for (let i = 0; i < txIds.length; i += 50) {
batches.push(txIds.slice(i, i + 50)); batches.push(txIds.slice(i, i + 50));
} }
return forkJoin(batches.map(batch => { return this.apiService.cachedRequest(this.apiService.getOutspendsBatched$, 250, batch); })); return forkJoin(batches.map(batch => { return this.electrsApiService.cachedRequest(this.electrsApiService.getOutspendsBatched$, 250, batch); }));
} else { } else {
return of([]); return of([]);
} }

View file

@ -8,6 +8,7 @@ import { ApiService } from '../../services/api.service';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
import { AssetsService } from '../../services/assets.service'; import { AssetsService } from '../../services/assets.service';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { ElectrsApiService } from '../../services/electrs-api.service';
interface SvgLine { interface SvgLine {
path: string; path: string;
@ -100,7 +101,7 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges {
private router: Router, private router: Router,
private relativeUrlPipe: RelativeUrlPipe, private relativeUrlPipe: RelativeUrlPipe,
private stateService: StateService, private stateService: StateService,
private apiService: ApiService, private electrsApiService: ElectrsApiService,
private assetsService: AssetsService, private assetsService: AssetsService,
@Inject(LOCALE_ID) private locale: string, @Inject(LOCALE_ID) private locale: string,
) { ) {
@ -123,7 +124,7 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges {
.pipe( .pipe(
switchMap((txid) => { switchMap((txid) => {
if (!this.cached) { if (!this.cached) {
return this.apiService.cachedRequest(this.apiService.getOutspendsBatched$, 250, [txid]); return this.electrsApiService.cachedRequest(this.electrsApiService.getOutspendsBatched$, 250, [txid]);
} else { } else {
return of(null); return of(null);
} }

View file

@ -138,14 +138,6 @@ export class ApiService {
return this.httpClient.get<number[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/transaction-times', { params }); return this.httpClient.get<number[]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/transaction-times', { params });
} }
getOutspendsBatched$(txIds: string[]): Observable<Outspend[][]> {
let params = new HttpParams();
txIds.forEach((txId: string) => {
params = params.append('txId[]', txId);
});
return this.httpClient.get<Outspend[][]>(this.apiBaseUrl + this.apiBasePath + '/api/v1/outspends', { params });
}
getAboutPageProfiles$(): Observable<any[]> { getAboutPageProfiles$(): Observable<any[]> {
return this.httpClient.get<any[]>(this.apiBaseUrl + '/api/v1/services/sponsors'); return this.httpClient.get<any[]>(this.apiBaseUrl + '/api/v1/services/sponsors');
} }

View file

@ -1,6 +1,6 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, from, of, switchMap } from 'rxjs'; import { BehaviorSubject, Observable, catchError, filter, from, of, shareReplay, switchMap, take, tap } from 'rxjs';
import { Transaction, Address, Outspend, Recent, Asset, ScriptHash } from '../interfaces/electrs.interface'; import { Transaction, Address, Outspend, Recent, Asset, ScriptHash } from '../interfaces/electrs.interface';
import { StateService } from './state.service'; import { StateService } from './state.service';
import { BlockExtended } from '../interfaces/node-api.interface'; import { BlockExtended } from '../interfaces/node-api.interface';
@ -13,6 +13,8 @@ export class ElectrsApiService {
private apiBaseUrl: string; // base URL is protocol, hostname, and port private apiBaseUrl: string; // base URL is protocol, hostname, and port
private apiBasePath: string; // network path is /testnet, etc. or '' for mainnet private apiBasePath: string; // network path is /testnet, etc. or '' for mainnet
private requestCache = new Map<string, { subject: BehaviorSubject<any>, expiry: number }>;
constructor( constructor(
private httpClient: HttpClient, private httpClient: HttpClient,
private stateService: StateService, private stateService: StateService,
@ -30,6 +32,46 @@ export class ElectrsApiService {
}); });
} }
private generateCacheKey(functionName: string, params: any[]): string {
return functionName + JSON.stringify(params);
}
// delete expired cache entries
private cleanExpiredCache(): void {
this.requestCache.forEach((value, key) => {
if (value.expiry < Date.now()) {
this.requestCache.delete(key);
}
});
}
cachedRequest<T, F extends (...args: any[]) => Observable<T>>(
apiFunction: F,
expireAfter: number, // in ms
...params: Parameters<F>
): Observable<T> {
this.cleanExpiredCache();
const cacheKey = this.generateCacheKey(apiFunction.name, params);
if (!this.requestCache.has(cacheKey)) {
const subject = new BehaviorSubject<T | null>(null);
this.requestCache.set(cacheKey, { subject, expiry: Date.now() + expireAfter });
apiFunction.bind(this)(...params).pipe(
tap(data => {
subject.next(data as T);
}),
catchError((error) => {
subject.error(error);
return of(null);
}),
shareReplay(1),
).subscribe();
}
return this.requestCache.get(cacheKey).subject.asObservable().pipe(filter(val => val !== null), take(1));
}
getBlock$(hash: string): Observable<BlockExtended> { getBlock$(hash: string): Observable<BlockExtended> {
return this.httpClient.get<BlockExtended>(this.apiBaseUrl + this.apiBasePath + '/api/block/' + hash); return this.httpClient.get<BlockExtended>(this.apiBaseUrl + this.apiBasePath + '/api/block/' + hash);
} }
@ -54,6 +96,12 @@ export class ElectrsApiService {
return this.httpClient.get<Outspend[]>(this.apiBaseUrl + this.apiBasePath + '/api/tx/' + hash + '/outspends'); return this.httpClient.get<Outspend[]>(this.apiBaseUrl + this.apiBasePath + '/api/tx/' + hash + '/outspends');
} }
getOutspendsBatched$(txids: string[]): Observable<Outspend[][]> {
let params = new HttpParams();
params = params.append('txids', txids.join(','));
return this.httpClient.get<Outspend[][]>(this.apiBaseUrl + this.apiBasePath + '/api/txs/outspends', { params });
}
getBlockTransactions$(hash: string, index: number = 0): Observable<Transaction[]> { getBlockTransactions$(hash: string, index: number = 0): Observable<Transaction[]> {
return this.httpClient.get<Transaction[]>(this.apiBaseUrl + this.apiBasePath + '/api/block/' + hash + '/txs/' + index); return this.httpClient.get<Transaction[]>(this.apiBaseUrl + this.apiBasePath + '/api/block/' + hash + '/txs/' + index);
} }