mirror of
https://github.com/mempool/mempool.git
synced 2024-11-20 02:11:49 +01:00
move linked channel scan into forensics task, add backend throttling
This commit is contained in:
parent
5e1f54e862
commit
609f68eb24
@ -5,13 +5,16 @@ import bitcoinApi from '../../api/bitcoin/bitcoin-api-factory';
|
|||||||
import config from '../../config';
|
import config from '../../config';
|
||||||
import { IEsploraApi } from '../../api/bitcoin/esplora-api.interface';
|
import { IEsploraApi } from '../../api/bitcoin/esplora-api.interface';
|
||||||
import { Common } from '../../api/common';
|
import { Common } from '../../api/common';
|
||||||
|
import { ILightningApi } from '../../api/lightning/lightning-api.interface';
|
||||||
|
|
||||||
const throttleDelay = 20; //ms
|
const throttleDelay = 20; //ms
|
||||||
|
const tempCacheSize = 10000;
|
||||||
|
|
||||||
class ForensicsService {
|
class ForensicsService {
|
||||||
loggerTimer = 0;
|
loggerTimer = 0;
|
||||||
closedChannelsScanBlock = 0;
|
closedChannelsScanBlock = 0;
|
||||||
txCache: { [txid: string]: IEsploraApi.Transaction } = {};
|
txCache: { [txid: string]: IEsploraApi.Transaction } = {};
|
||||||
|
tempCached: string[] = [];
|
||||||
|
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
||||||
@ -29,6 +32,7 @@ class ForensicsService {
|
|||||||
|
|
||||||
if (config.MEMPOOL.BACKEND === 'esplora') {
|
if (config.MEMPOOL.BACKEND === 'esplora') {
|
||||||
await this.$runClosedChannelsForensics(false);
|
await this.$runClosedChannelsForensics(false);
|
||||||
|
await this.$runOpenedChannelsForensics();
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -95,16 +99,9 @@ class ForensicsService {
|
|||||||
const lightningScriptReasons: number[] = [];
|
const lightningScriptReasons: number[] = [];
|
||||||
for (const outspend of outspends) {
|
for (const outspend of outspends) {
|
||||||
if (outspend.spent && outspend.txid) {
|
if (outspend.spent && outspend.txid) {
|
||||||
let spendingTx: IEsploraApi.Transaction | undefined = this.txCache[outspend.txid];
|
let spendingTx = await this.fetchTransaction(outspend.txid);
|
||||||
if (!spendingTx) {
|
if (!spendingTx) {
|
||||||
try {
|
continue;
|
||||||
spendingTx = await bitcoinApi.$getRawTransaction(outspend.txid);
|
|
||||||
await Common.sleep$(throttleDelay);
|
|
||||||
this.txCache[outspend.txid] = spendingTx;
|
|
||||||
} catch (e) {
|
|
||||||
logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + outspend.txid}. Reason ${e instanceof Error ? e.message : e}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
cached.push(spendingTx.txid);
|
cached.push(spendingTx.txid);
|
||||||
const lightningScript = this.findLightningScript(spendingTx.vin[outspend.vin || 0]);
|
const lightningScript = this.findLightningScript(spendingTx.vin[outspend.vin || 0]);
|
||||||
@ -124,16 +121,9 @@ class ForensicsService {
|
|||||||
We can detect a commitment transaction (force close) by reading Sequence and Locktime
|
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
|
https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction
|
||||||
*/
|
*/
|
||||||
let closingTx: IEsploraApi.Transaction | undefined = this.txCache[channel.closing_transaction_id];
|
let closingTx = await this.fetchTransaction(channel.closing_transaction_id, true);
|
||||||
if (!closingTx) {
|
if (!closingTx) {
|
||||||
try {
|
continue;
|
||||||
closingTx = await bitcoinApi.$getRawTransaction(channel.closing_transaction_id);
|
|
||||||
await Common.sleep$(throttleDelay);
|
|
||||||
this.txCache[channel.closing_transaction_id] = closingTx;
|
|
||||||
} catch (e) {
|
|
||||||
logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + channel.closing_transaction_id}. Reason ${e instanceof Error ? e.message : e}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
cached.push(closingTx.txid);
|
cached.push(closingTx.txid);
|
||||||
const sequenceHex: string = closingTx.vin[0].sequence.toString(16);
|
const sequenceHex: string = closingTx.vin[0].sequence.toString(16);
|
||||||
@ -174,7 +164,7 @@ class ForensicsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private findLightningScript(vin: IEsploraApi.Vin): number {
|
private findLightningScript(vin: IEsploraApi.Vin): number {
|
||||||
const topElement = vin.witness[vin.witness.length - 2];
|
const topElement = vin.witness?.length > 2 ? vin.witness[vin.witness.length - 2] : null;
|
||||||
if (/^OP_IF OP_PUSHBYTES_33 \w{66} OP_ELSE OP_PUSH(NUM_\d+|BYTES_(1 \w{2}|2 \w{4})) OP_CSV OP_DROP OP_PUSHBYTES_33 \w{66} OP_ENDIF OP_CHECKSIG$/.test(vin.inner_witnessscript_asm)) {
|
if (/^OP_IF OP_PUSHBYTES_33 \w{66} OP_ELSE OP_PUSH(NUM_\d+|BYTES_(1 \w{2}|2 \w{4})) OP_CSV OP_DROP OP_PUSHBYTES_33 \w{66} OP_ENDIF OP_CHECKSIG$/.test(vin.inner_witnessscript_asm)) {
|
||||||
// https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction-outputs
|
// https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction-outputs
|
||||||
if (topElement === '01') {
|
if (topElement === '01') {
|
||||||
@ -193,7 +183,7 @@ class ForensicsService {
|
|||||||
) {
|
) {
|
||||||
// https://github.com/lightning/bolts/blob/master/03-transactions.md#offered-htlc-outputs
|
// https://github.com/lightning/bolts/blob/master/03-transactions.md#offered-htlc-outputs
|
||||||
// https://github.com/lightning/bolts/blob/master/03-transactions.md#received-htlc-outputs
|
// https://github.com/lightning/bolts/blob/master/03-transactions.md#received-htlc-outputs
|
||||||
if (topElement.length === 66) {
|
if (topElement?.length === 66) {
|
||||||
// top element is a public key
|
// top element is a public key
|
||||||
// 'Revoked Lightning HTLC'; Penalty force closed
|
// 'Revoked Lightning HTLC'; Penalty force closed
|
||||||
return 4;
|
return 4;
|
||||||
@ -220,6 +210,248 @@ class ForensicsService {
|
|||||||
}
|
}
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If a channel open tx spends funds from a another channel transaction,
|
||||||
|
// we can attribute that output to a specific counterparty
|
||||||
|
private async $runOpenedChannelsForensics(): Promise<void> {
|
||||||
|
const runTimer = Date.now();
|
||||||
|
let progress = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info(`Started running open channel forensics...`);
|
||||||
|
const channels = await channelsApi.$getChannelsWithoutSourceChecked();
|
||||||
|
|
||||||
|
for (const openChannel of channels) {
|
||||||
|
let openTx = await this.fetchTransaction(openChannel.transaction_id, true);
|
||||||
|
if (!openTx) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const input of openTx.vin) {
|
||||||
|
const closeChannel = await channelsApi.$getChannelForensicsByClosingId(input.txid);
|
||||||
|
if (closeChannel) {
|
||||||
|
// this input directly spends a channel close output
|
||||||
|
await this.$attributeChannelBalances(closeChannel, openChannel, input);
|
||||||
|
} else {
|
||||||
|
const prevOpenChannels = await channelsApi.$getChannelForensicsByOpeningId(input.txid);
|
||||||
|
if (prevOpenChannels?.length) {
|
||||||
|
// this input spends a channel open change output
|
||||||
|
for (const prevOpenChannel of prevOpenChannels) {
|
||||||
|
await this.$attributeChannelBalances(prevOpenChannel, openChannel, input, null, null, true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// check if this input spends any swept channel close outputs
|
||||||
|
await this.$attributeSweptChannelCloses(openChannel, input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// calculate how much of the total input value is attributable to the channel open output
|
||||||
|
openChannel.funding_ratio = openTx.vout[openChannel.transaction_vout].value / ((openTx.vout.reduce((sum, v) => sum + v.value, 0) || 1) + openTx.fee);
|
||||||
|
// save changes to the opening channel, and mark it as checked
|
||||||
|
if (openTx?.vin?.length === 1) {
|
||||||
|
openChannel.single_funded = true;
|
||||||
|
}
|
||||||
|
if (openChannel.node1_funding_balance || openChannel.node2_funding_balance || openChannel.node1_closing_balance || openChannel.node2_closing_balance || openChannel.closed_by) {
|
||||||
|
await channelsApi.$updateOpeningInfo(openChannel);
|
||||||
|
}
|
||||||
|
await channelsApi.$markChannelSourceChecked(openChannel.id);
|
||||||
|
|
||||||
|
++progress;
|
||||||
|
const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer);
|
||||||
|
if (elapsedSeconds > 10) {
|
||||||
|
logger.info(`Updating opened channel forensics ${progress}/${channels?.length}`);
|
||||||
|
this.loggerTimer = new Date().getTime() / 1000;
|
||||||
|
this.truncateTempCache();
|
||||||
|
}
|
||||||
|
if (Date.now() - runTimer > (config.LIGHTNING.FORENSICS_INTERVAL * 1000)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Open channels forensics scan complete.`);
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$runOpenedChannelsForensics() error: ' + (e instanceof Error ? e.message : e));
|
||||||
|
} finally {
|
||||||
|
this.clearTempCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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> {
|
||||||
|
let sweepTx = await this.fetchTransaction(input.txid, true);
|
||||||
|
if (!sweepTx) {
|
||||||
|
logger.err(`couldn't find input transaction for channel forensics ${openChannel.channel_id} ${input.txid}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const openContribution = sweepTx.vout[input.vout].value;
|
||||||
|
for (const sweepInput of sweepTx.vin) {
|
||||||
|
const lnScriptType = this.findLightningScript(sweepInput);
|
||||||
|
if (lnScriptType > 1) {
|
||||||
|
const closeChannel = await channelsApi.$getChannelForensicsByClosingId(sweepInput.txid);
|
||||||
|
if (closeChannel) {
|
||||||
|
const initiator = (lnScriptType === 2 || lnScriptType === 4) ? 'remote' : (lnScriptType === 3 ? 'local' : null);
|
||||||
|
await this.$attributeChannelBalances(closeChannel, openChannel, sweepInput, openContribution, initiator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async $attributeChannelBalances(
|
||||||
|
prevChannel, openChannel, input: IEsploraApi.Vin, openContribution: number | null = null,
|
||||||
|
initiator: 'remote' | 'local' | null = null, linkedOpenings: boolean = false
|
||||||
|
): Promise<void> {
|
||||||
|
// figure out which node controls the input/output
|
||||||
|
let openSide;
|
||||||
|
let prevLocal;
|
||||||
|
let prevRemote;
|
||||||
|
let matched = false;
|
||||||
|
let ambiguous = false; // if counterparties are the same in both channels, we can't tell them apart
|
||||||
|
if (openChannel.node1_public_key === prevChannel.node1_public_key) {
|
||||||
|
openSide = 1;
|
||||||
|
prevLocal = 1;
|
||||||
|
prevRemote = 2;
|
||||||
|
matched = true;
|
||||||
|
} else if (openChannel.node1_public_key === prevChannel.node2_public_key) {
|
||||||
|
openSide = 1;
|
||||||
|
prevLocal = 2;
|
||||||
|
prevRemote = 1;
|
||||||
|
matched = true;
|
||||||
|
}
|
||||||
|
if (openChannel.node2_public_key === prevChannel.node1_public_key) {
|
||||||
|
openSide = 2;
|
||||||
|
prevLocal = 1;
|
||||||
|
prevRemote = 2;
|
||||||
|
if (matched) {
|
||||||
|
ambiguous = true;
|
||||||
|
}
|
||||||
|
matched = true;
|
||||||
|
} else if (openChannel.node2_public_key === prevChannel.node2_public_key) {
|
||||||
|
openSide = 2;
|
||||||
|
prevLocal = 2;
|
||||||
|
prevRemote = 1;
|
||||||
|
if (matched) {
|
||||||
|
ambiguous = true;
|
||||||
|
}
|
||||||
|
matched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matched && !ambiguous) {
|
||||||
|
// fetch closing channel transaction and perform forensics on the outputs
|
||||||
|
let prevChannelTx = await this.fetchTransaction(input.txid, true);
|
||||||
|
let outspends: IEsploraApi.Outspend[] | undefined;
|
||||||
|
try {
|
||||||
|
outspends = await bitcoinApi.$getOutspends(input.txid);
|
||||||
|
await Common.sleep$(throttleDelay);
|
||||||
|
} catch (e) {
|
||||||
|
logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + input.txid + '/outspends'}. Reason ${e instanceof Error ? e.message : e}`);
|
||||||
|
}
|
||||||
|
if (!outspends || !prevChannelTx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!linkedOpenings) {
|
||||||
|
if (!prevChannel.outputs) {
|
||||||
|
prevChannel.outputs = prevChannel.outputs || prevChannelTx.vout.map(vout => {
|
||||||
|
return {
|
||||||
|
type: 0,
|
||||||
|
value: vout.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (let i = 0; i < outspends?.length; i++) {
|
||||||
|
const outspend = outspends[i];
|
||||||
|
const output = prevChannel.outputs[i];
|
||||||
|
if (outspend.spent && outspend.txid) {
|
||||||
|
try {
|
||||||
|
const spendingTx = await this.fetchTransaction(outspend.txid, true);
|
||||||
|
if (spendingTx) {
|
||||||
|
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 {
|
||||||
|
output.type = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// attribute outputs to each counterparty, and sum up total known balances
|
||||||
|
prevChannel.outputs[input.vout].node = prevLocal;
|
||||||
|
const isPenalty = prevChannel.outputs.filter((out) => out.type === 2 || out.type === 4)?.length > 0;
|
||||||
|
const normalOutput = [1,3].includes(prevChannel.outputs[input.vout].type);
|
||||||
|
let localClosingBalance = 0;
|
||||||
|
let remoteClosingBalance = 0;
|
||||||
|
for (const output of prevChannel.outputs) {
|
||||||
|
if (isPenalty) {
|
||||||
|
// penalty close, so local node takes everything
|
||||||
|
localClosingBalance += output.value;
|
||||||
|
} else if (output.node) {
|
||||||
|
// this output determinstically linked to one of the counterparties
|
||||||
|
if (output.node === prevLocal) {
|
||||||
|
localClosingBalance += output.value;
|
||||||
|
} else {
|
||||||
|
remoteClosingBalance += output.value;
|
||||||
|
}
|
||||||
|
} else if (normalOutput && (output.type === 1 || output.type === 3)) {
|
||||||
|
// local node had one main output, therefore remote node takes the other
|
||||||
|
remoteClosingBalance += output.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevChannel[`node${prevLocal}_closing_balance`] = localClosingBalance;
|
||||||
|
prevChannel[`node${prevRemote}_closing_balance`] = remoteClosingBalance;
|
||||||
|
prevChannel.closing_fee = prevChannelTx.fee;
|
||||||
|
|
||||||
|
if (initiator && !linkedOpenings) {
|
||||||
|
const initiatorSide = initiator === 'remote' ? prevRemote : prevLocal;
|
||||||
|
prevChannel.closed_by = prevChannel[`node${initiatorSide}_public_key`];
|
||||||
|
}
|
||||||
|
|
||||||
|
// save changes to the closing channel
|
||||||
|
await channelsApi.$updateClosingInfo(prevChannel);
|
||||||
|
} else {
|
||||||
|
if (prevChannelTx.vin.length <= 1) {
|
||||||
|
prevChannel[`node${prevLocal}_funding_balance`] = prevChannel.capacity;
|
||||||
|
prevChannel.single_funded = true;
|
||||||
|
prevChannel.funding_ratio = 1;
|
||||||
|
// save changes to the closing channel
|
||||||
|
await channelsApi.$updateOpeningInfo(prevChannel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
openChannel[`node${openSide}_funding_balance`] = openChannel[`node${openSide}_funding_balance`] + (openContribution || prevChannelTx?.vout[input.vout]?.value || 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchTransaction(txid: string, temp: boolean = false): Promise<IEsploraApi.Transaction | null> {
|
||||||
|
let tx = this.txCache[txid];
|
||||||
|
if (!tx) {
|
||||||
|
try {
|
||||||
|
tx = await bitcoinApi.$getRawTransaction(txid);
|
||||||
|
this.txCache[txid] = tx;
|
||||||
|
if (temp) {
|
||||||
|
this.tempCached.push(txid);
|
||||||
|
}
|
||||||
|
await Common.sleep$(throttleDelay);
|
||||||
|
} catch (e) {
|
||||||
|
logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + txid + '/outspends'}. Reason ${e instanceof Error ? e.message : e}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTempCache(): void {
|
||||||
|
for (const txid of this.tempCached) {
|
||||||
|
delete this.txCache[txid];
|
||||||
|
}
|
||||||
|
this.tempCached = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
truncateTempCache(): void {
|
||||||
|
if (this.tempCached.length > tempCacheSize) {
|
||||||
|
const removed = this.tempCached.splice(0, this.tempCached.length - tempCacheSize);
|
||||||
|
for (const txid of removed) {
|
||||||
|
delete this.txCache[txid];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new ForensicsService();
|
export default new ForensicsService();
|
||||||
|
@ -305,256 +305,6 @@ class NetworkSyncService {
|
|||||||
logger.err('$scanForClosedChannels() error: ' + (e instanceof Error ? e.message : e));
|
logger.err('$scanForClosedChannels() error: ' + (e instanceof Error ? e.message : e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private findLightningScript(vin: IEsploraApi.Vin): number {
|
|
||||||
const topElement = vin.witness ? vin.witness[vin.witness.length - 2] : '';
|
|
||||||
if (/^OP_IF OP_PUSHBYTES_33 \w{66} OP_ELSE OP_PUSH(NUM_\d+|BYTES_(1 \w{2}|2 \w{4})) OP_CSV OP_DROP OP_PUSHBYTES_33 \w{66} OP_ENDIF OP_CHECKSIG$/.test(vin.inner_witnessscript_asm)) {
|
|
||||||
// https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction-outputs
|
|
||||||
if (topElement === '01') {
|
|
||||||
// top element is '01' to get in the revocation path
|
|
||||||
// 'Revoked Lightning Force Close';
|
|
||||||
// Penalty force closed
|
|
||||||
return 2;
|
|
||||||
} else {
|
|
||||||
// top element is '', this is a delayed to_local output
|
|
||||||
// 'Lightning Force Close';
|
|
||||||
return 3;
|
|
||||||
}
|
|
||||||
} else if (
|
|
||||||
/^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_NOTIF OP_DROP OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(vin.inner_witnessscript_asm) ||
|
|
||||||
/^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_IF OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_DROP OP_PUSHBYTES_3 \w{6} OP_CLTV OP_DROP OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(vin.inner_witnessscript_asm)
|
|
||||||
) {
|
|
||||||
// https://github.com/lightning/bolts/blob/master/03-transactions.md#offered-htlc-outputs
|
|
||||||
// https://github.com/lightning/bolts/blob/master/03-transactions.md#received-htlc-outputs
|
|
||||||
if (topElement?.length === 66) {
|
|
||||||
// top element is a public key
|
|
||||||
// 'Revoked Lightning HTLC'; Penalty force closed
|
|
||||||
return 4;
|
|
||||||
} else if (topElement) {
|
|
||||||
// top element is a preimage
|
|
||||||
// 'Lightning HTLC';
|
|
||||||
return 5;
|
|
||||||
} else {
|
|
||||||
// top element is '' to get in the expiry of the script
|
|
||||||
// 'Expired Lightning HTLC';
|
|
||||||
return 6;
|
|
||||||
}
|
|
||||||
} else if (/^OP_PUSHBYTES_33 \w{66} OP_CHECKSIG OP_IFDUP OP_NOTIF OP_PUSHNUM_16 OP_CSV OP_ENDIF$/.test(vin.inner_witnessscript_asm)) {
|
|
||||||
// https://github.com/lightning/bolts/blob/master/03-transactions.md#to_local_anchor-and-to_remote_anchor-output-option_anchors
|
|
||||||
if (topElement) {
|
|
||||||
// top element is a signature
|
|
||||||
// 'Lightning Anchor';
|
|
||||||
return 7;
|
|
||||||
} else {
|
|
||||||
// top element is '', it has been swept after 16 blocks
|
|
||||||
// 'Swept Lightning Anchor';
|
|
||||||
return 8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If a channel open tx spends funds from a another channel transaction,
|
|
||||||
// we can attribute that output to a specific counterparty
|
|
||||||
private async $runOpenedChannelsForensics(): Promise<void> {
|
|
||||||
const runTimer = Date.now();
|
|
||||||
let progress = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
logger.info(`Started running open channel forensics...`);
|
|
||||||
const channels = await channelsApi.$getChannelsWithoutSourceChecked();
|
|
||||||
|
|
||||||
for (const openChannel of channels) {
|
|
||||||
const openTx = await bitcoinApi.$getRawTransaction(openChannel.transaction_id);
|
|
||||||
for (const input of openTx.vin) {
|
|
||||||
const closeChannel = await channelsApi.$getChannelForensicsByClosingId(input.txid);
|
|
||||||
if (closeChannel) {
|
|
||||||
// this input directly spends a channel close output
|
|
||||||
await this.$attributeChannelBalances(closeChannel, openChannel, input);
|
|
||||||
} else {
|
|
||||||
const prevOpenChannels = await channelsApi.$getChannelForensicsByOpeningId(input.txid);
|
|
||||||
if (prevOpenChannels?.length) {
|
|
||||||
// this input spends a channel open change output
|
|
||||||
for (const prevOpenChannel of prevOpenChannels) {
|
|
||||||
await this.$attributeChannelBalances(prevOpenChannel, openChannel, input, null, null, true);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// check if this input spends any swept channel close outputs
|
|
||||||
await this.$attributeSweptChannelCloses(openChannel, input);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// calculate how much of the total input value is attributable to the channel open output
|
|
||||||
openChannel.funding_ratio = openTx.vout[openChannel.transaction_vout].value / ((openTx.vout.reduce((sum, v) => sum + v.value, 0) || 1) + openTx.fee);
|
|
||||||
// save changes to the opening channel, and mark it as checked
|
|
||||||
if (openTx?.vin?.length === 1) {
|
|
||||||
openChannel.single_funded = true;
|
|
||||||
}
|
|
||||||
if (openChannel.node1_funding_balance || openChannel.node2_funding_balance || openChannel.node1_closing_balance || openChannel.node2_closing_balance || openChannel.closed_by) {
|
|
||||||
await channelsApi.$updateOpeningInfo(openChannel);
|
|
||||||
}
|
|
||||||
await channelsApi.$markChannelSourceChecked(openChannel.id);
|
|
||||||
|
|
||||||
++progress;
|
|
||||||
const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer);
|
|
||||||
if (elapsedSeconds > 10) {
|
|
||||||
logger.info(`Updating opened channel forensics ${progress}/${channels?.length}`);
|
|
||||||
this.loggerTimer = new Date().getTime() / 1000;
|
|
||||||
}
|
|
||||||
if (Date.now() - runTimer > (config.LIGHTNING.GRAPH_REFRESH_INTERVAL * 1000)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Open channels forensics scan complete.`);
|
|
||||||
} catch (e) {
|
|
||||||
logger.err('$runOpenedChannelsForensics() error: ' + (e instanceof Error ? e.message : e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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> {
|
|
||||||
const sweepTx = await bitcoinApi.$getRawTransaction(input.txid);
|
|
||||||
if (!sweepTx) {
|
|
||||||
logger.err(`couldn't find input transaction for channel forensics ${openChannel.channel_id} ${input.txid}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const openContribution = sweepTx.vout[input.vout].value;
|
|
||||||
for (const sweepInput of sweepTx.vin) {
|
|
||||||
const lnScriptType = this.findLightningScript(sweepInput);
|
|
||||||
if (lnScriptType > 1) {
|
|
||||||
const closeChannel = await channelsApi.$getChannelForensicsByClosingId(sweepInput.txid);
|
|
||||||
if (closeChannel) {
|
|
||||||
const initiator = (lnScriptType === 2 || lnScriptType === 4) ? 'remote' : (lnScriptType === 3 ? 'local' : null);
|
|
||||||
await this.$attributeChannelBalances(closeChannel, openChannel, sweepInput, openContribution, initiator);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async $attributeChannelBalances(
|
|
||||||
prevChannel, openChannel, input: IEsploraApi.Vin, openContribution: number | null = null,
|
|
||||||
initiator: 'remote' | 'local' | null = null, linkedOpenings: boolean = false
|
|
||||||
): Promise<void> {
|
|
||||||
// figure out which node controls the input/output
|
|
||||||
let openSide;
|
|
||||||
let prevLocal;
|
|
||||||
let prevRemote;
|
|
||||||
let matched = false;
|
|
||||||
let ambiguous = false; // if counterparties are the same in both channels, we can't tell them apart
|
|
||||||
if (openChannel.node1_public_key === prevChannel.node1_public_key) {
|
|
||||||
openSide = 1;
|
|
||||||
prevLocal = 1;
|
|
||||||
prevRemote = 2;
|
|
||||||
matched = true;
|
|
||||||
} else if (openChannel.node1_public_key === prevChannel.node2_public_key) {
|
|
||||||
openSide = 1;
|
|
||||||
prevLocal = 2;
|
|
||||||
prevRemote = 1;
|
|
||||||
matched = true;
|
|
||||||
}
|
|
||||||
if (openChannel.node2_public_key === prevChannel.node1_public_key) {
|
|
||||||
openSide = 2;
|
|
||||||
prevLocal = 1;
|
|
||||||
prevRemote = 2;
|
|
||||||
if (matched) {
|
|
||||||
ambiguous = true;
|
|
||||||
}
|
|
||||||
matched = true;
|
|
||||||
} else if (openChannel.node2_public_key === prevChannel.node2_public_key) {
|
|
||||||
openSide = 2;
|
|
||||||
prevLocal = 2;
|
|
||||||
prevRemote = 1;
|
|
||||||
if (matched) {
|
|
||||||
ambiguous = true;
|
|
||||||
}
|
|
||||||
matched = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matched && !ambiguous) {
|
|
||||||
// fetch closing channel transaction and perform forensics on the outputs
|
|
||||||
let prevChannelTx: IEsploraApi.Transaction | undefined;
|
|
||||||
let outspends: IEsploraApi.Outspend[] | undefined;
|
|
||||||
try {
|
|
||||||
prevChannelTx = await bitcoinApi.$getRawTransaction(input.txid);
|
|
||||||
outspends = await bitcoinApi.$getOutspends(input.txid);
|
|
||||||
} catch (e) {
|
|
||||||
logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + input.txid + '/outspends'}. Reason ${e instanceof Error ? e.message : e}`);
|
|
||||||
}
|
|
||||||
if (!outspends || !prevChannelTx) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!linkedOpenings) {
|
|
||||||
if (!prevChannel.outputs) {
|
|
||||||
prevChannel.outputs = prevChannel.outputs || prevChannelTx.vout.map(vout => {
|
|
||||||
return {
|
|
||||||
type: 0,
|
|
||||||
value: vout.value,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
for (let i = 0; i < outspends?.length; i++) {
|
|
||||||
const outspend = outspends[i];
|
|
||||||
const output = prevChannel.outputs[i];
|
|
||||||
if (outspend.spent && outspend.txid) {
|
|
||||||
try {
|
|
||||||
const spendingTx = await bitcoinApi.$getRawTransaction(outspend.txid);
|
|
||||||
if (spendingTx) {
|
|
||||||
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 {
|
|
||||||
output.type = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// attribute outputs to each counterparty, and sum up total known balances
|
|
||||||
prevChannel.outputs[input.vout].node = prevLocal;
|
|
||||||
const isPenalty = prevChannel.outputs.filter((out) => out.type === 2 || out.type === 4)?.length > 0;
|
|
||||||
const normalOutput = [1,3].includes(prevChannel.outputs[input.vout].type);
|
|
||||||
let localClosingBalance = 0;
|
|
||||||
let remoteClosingBalance = 0;
|
|
||||||
for (const output of prevChannel.outputs) {
|
|
||||||
if (isPenalty) {
|
|
||||||
// penalty close, so local node takes everything
|
|
||||||
localClosingBalance += output.value;
|
|
||||||
} else if (output.node) {
|
|
||||||
// this output determinstically linked to one of the counterparties
|
|
||||||
if (output.node === prevLocal) {
|
|
||||||
localClosingBalance += output.value;
|
|
||||||
} else {
|
|
||||||
remoteClosingBalance += output.value;
|
|
||||||
}
|
|
||||||
} else if (normalOutput && (output.type === 1 || output.type === 3)) {
|
|
||||||
// local node had one main output, therefore remote node takes the other
|
|
||||||
remoteClosingBalance += output.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prevChannel[`node${prevLocal}_closing_balance`] = localClosingBalance;
|
|
||||||
prevChannel[`node${prevRemote}_closing_balance`] = remoteClosingBalance;
|
|
||||||
prevChannel.closing_fee = prevChannelTx.fee;
|
|
||||||
|
|
||||||
if (initiator && !linkedOpenings) {
|
|
||||||
const initiatorSide = initiator === 'remote' ? prevRemote : prevLocal;
|
|
||||||
prevChannel.closed_by = prevChannel[`node${initiatorSide}_public_key`];
|
|
||||||
}
|
|
||||||
|
|
||||||
// save changes to the closing channel
|
|
||||||
await channelsApi.$updateClosingInfo(prevChannel);
|
|
||||||
} else {
|
|
||||||
if (prevChannelTx.vin.length <= 1) {
|
|
||||||
prevChannel[`node${prevLocal}_funding_balance`] = prevChannel.capacity;
|
|
||||||
prevChannel.single_funded = true;
|
|
||||||
prevChannel.funding_ratio = 1;
|
|
||||||
// save changes to the closing channel
|
|
||||||
await channelsApi.$updateOpeningInfo(prevChannel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
openChannel[`node${openSide}_funding_balance`] = openChannel[`node${openSide}_funding_balance`] + (openContribution || prevChannelTx?.vout[input.vout]?.value || 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new NetworkSyncService();
|
export default new NetworkSyncService();
|
||||||
|
Loading…
Reference in New Issue
Block a user