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,17 +99,10 @@ 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 { |  | ||||||
|                   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; |                 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]); | ||||||
|               lightningScriptReasons.push(lightningScript); |               lightningScriptReasons.push(lightningScript); | ||||||
| @ -124,17 +121,10 @@ 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 { |  | ||||||
|                 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; |               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); | ||||||
|             const locktimeHex: string = closingTx.locktime.toString(16); |             const locktimeHex: string = closingTx.locktime.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…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user