mirror of
https://github.com/mempool/mempool.git
synced 2025-02-23 22:46:54 +01:00
Merge pull request #4163 from mempool/mononaut/fast-forensics
Fast lightning forensics
This commit is contained in:
commit
28113d1141
10 changed files with 265 additions and 119 deletions
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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];
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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([]);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue