diff --git a/frontend/src/app/lightning/channel/channel.component.html b/frontend/src/app/lightning/channel/channel.component.html index 47b5b334c..43dafd24e 100644 --- a/frontend/src/app/lightning/channel/channel.component.html +++ b/frontend/src/app/lightning/channel/channel.component.html @@ -29,7 +29,7 @@ - Transaction ID + Opening transaction {{ channel.transaction_id | shortenString : 10 }} @@ -37,6 +37,23 @@ + + + Closing transaction + + + {{ channel.closing_transaction_id | shortenString : 10 }} + + + + + + Closing type + + + + + @@ -69,3 +86,11 @@
+ + +
+ Error loading data. +

+ {{ error.status }}: {{ error.error }} +
+
\ No newline at end of file diff --git a/frontend/src/app/lightning/channel/channel.component.ts b/frontend/src/app/lightning/channel/channel.component.ts index 029b83e08..bc66f7180 100644 --- a/frontend/src/app/lightning/channel/channel.component.ts +++ b/frontend/src/app/lightning/channel/channel.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ActivatedRoute, ParamMap } from '@angular/router'; -import { Observable } from 'rxjs'; -import { switchMap } from 'rxjs/operators'; +import { Observable, of } from 'rxjs'; +import { catchError, switchMap } from 'rxjs/operators'; import { SeoService } from 'src/app/services/seo.service'; import { LightningApiService } from '../lightning-api.service'; @@ -13,6 +13,7 @@ import { LightningApiService } from '../lightning-api.service'; }) export class ChannelComponent implements OnInit { channel$: Observable; + error: any = null; constructor( private lightningApiService: LightningApiService, @@ -24,8 +25,16 @@ export class ChannelComponent implements OnInit { this.channel$ = this.activatedRoute.paramMap .pipe( switchMap((params: ParamMap) => { + this.error = null; this.seoService.setTitle(`Channel: ${params.get('short_id')}`); - return this.lightningApiService.getChannel$(params.get('short_id')); + return this.lightningApiService.getChannel$(params.get('short_id')) + .pipe( + catchError((err) => { + this.error = err; + console.log(this.error); + return of(null); + }) + ); }) ); } diff --git a/frontend/src/app/lightning/channel/closing-type/closing-type.component.html b/frontend/src/app/lightning/channel/closing-type/closing-type.component.html new file mode 100644 index 000000000..60461d1c0 --- /dev/null +++ b/frontend/src/app/lightning/channel/closing-type/closing-type.component.html @@ -0,0 +1 @@ +{{ label.label }} \ No newline at end of file diff --git a/frontend/src/app/lightning/channel/closing-type/closing-type.component.scss b/frontend/src/app/lightning/channel/closing-type/closing-type.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/app/lightning/channel/closing-type/closing-type.component.ts b/frontend/src/app/lightning/channel/closing-type/closing-type.component.ts new file mode 100644 index 000000000..5aa6158d3 --- /dev/null +++ b/frontend/src/app/lightning/channel/closing-type/closing-type.component.ts @@ -0,0 +1,37 @@ +import { ChangeDetectionStrategy, Component, Input, OnChanges, OnInit } from '@angular/core'; + +@Component({ + selector: 'app-closing-type', + templateUrl: './closing-type.component.html', + styleUrls: ['./closing-type.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ClosingTypeComponent implements OnChanges { + @Input() type = 0; + label: { label: string; class: string }; + + ngOnChanges() { + this.label = this.getLabelFromType(this.type); + } + + getLabelFromType(type: number): { label: string; class: string } { + switch (type) { + case 1: return { + label: 'Mutually closed', + class: 'success', + }; + case 2: return { + label: 'Force closed', + class: 'warning', + }; + case 3: return { + label: 'Force closed with penalty', + class: 'danger', + }; + default: return { + label: 'Unknown', + class: 'secondary', + }; + } + } +} diff --git a/frontend/src/app/lightning/channels-list/channels-list.component.html b/frontend/src/app/lightning/channels-list/channels-list.component.html index aec19de5f..a6d553ef1 100644 --- a/frontend/src/app/lightning/channels-list/channels-list.component.html +++ b/frontend/src/app/lightning/channels-list/channels-list.component.html @@ -52,7 +52,12 @@ Inactive Active - Closed + + Closed + + + + {{ node.fee_rate }} ppm ({{ node.fee_rate / 10000 | number }}%) diff --git a/frontend/src/app/lightning/lightning.module.ts b/frontend/src/app/lightning/lightning.module.ts index 1ab421108..a8cad3dc9 100644 --- a/frontend/src/app/lightning/lightning.module.ts +++ b/frontend/src/app/lightning/lightning.module.ts @@ -12,6 +12,7 @@ import { ChannelsListComponent } from './channels-list/channels-list.component'; import { ChannelComponent } from './channel/channel.component'; import { LightningWrapperComponent } from './lightning-wrapper/lightning-wrapper.component'; import { ChannelBoxComponent } from './channel/channel-box/channel-box.component'; +import { ClosingTypeComponent } from './channel/closing-type/closing-type.component'; @NgModule({ declarations: [ LightningDashboardComponent, @@ -22,6 +23,7 @@ import { ChannelBoxComponent } from './channel/channel-box/channel-box.component ChannelComponent, LightningWrapperComponent, ChannelBoxComponent, + ClosingTypeComponent, ], imports: [ CommonModule, diff --git a/lightning-backend/mempool-config.sample.json b/lightning-backend/mempool-config.sample.json index eac34ddf4..9402ff2c2 100644 --- a/lightning-backend/mempool-config.sample.json +++ b/lightning-backend/mempool-config.sample.json @@ -6,6 +6,9 @@ "API_URL_PREFIX": "/api/v1/", "STDOUT_LOG_MIN_PRIORITY": "debug" }, + "ESPLORA": { + "REST_API_URL": "" + }, "SYSLOG": { "ENABLED": false, "HOST": "127.0.0.1", diff --git a/lightning-backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts b/lightning-backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts new file mode 100644 index 000000000..cd60843f3 --- /dev/null +++ b/lightning-backend/src/api/bitcoin/bitcoin-api-abstract-factory.ts @@ -0,0 +1,25 @@ +import { IEsploraApi } from './esplora-api.interface'; + +export interface AbstractBitcoinApi { + $getRawMempool(): Promise; + $getRawTransaction(txId: string, skipConversion?: boolean, addPrevout?: boolean, lazyPrevouts?: boolean): Promise; + $getBlockHeightTip(): Promise; + $getTxIdsForBlock(hash: string): Promise; + $getBlockHash(height: number): Promise; + $getBlockHeader(hash: string): Promise; + $getBlock(hash: string): Promise; + $getAddress(address: string): Promise; + $getAddressTransactions(address: string, lastSeenTxId: string): Promise; + $getAddressPrefix(prefix: string): string[]; + $sendRawTransaction(rawTransaction: string): Promise; + $getOutspend(txId: string, vout: number): Promise; + $getOutspends(txId: string): Promise; + $getBatchedOutspends(txId: string[]): Promise; +} +export interface BitcoinRpcCredentials { + host: string; + port: number; + user: string; + pass: string; + timeout: number; +} diff --git a/lightning-backend/src/api/bitcoin/bitcoin-api-factory.ts b/lightning-backend/src/api/bitcoin/bitcoin-api-factory.ts new file mode 100644 index 000000000..3ae598ac2 --- /dev/null +++ b/lightning-backend/src/api/bitcoin/bitcoin-api-factory.ts @@ -0,0 +1,15 @@ +import config from '../../config'; +import { AbstractBitcoinApi } from './bitcoin-api-abstract-factory'; +import EsploraApi from './esplora-api'; +import BitcoinApi from './bitcoin-api'; +import bitcoinClient from './bitcoin-client'; + +function bitcoinApiFactory(): AbstractBitcoinApi { + if (config.ESPLORA.REST_API_URL) { + return new EsploraApi(); + } else { + return new BitcoinApi(bitcoinClient); + } +} + +export default bitcoinApiFactory(); diff --git a/lightning-backend/src/api/bitcoin/bitcoin-api.interface.ts b/lightning-backend/src/api/bitcoin/bitcoin-api.interface.ts new file mode 100644 index 000000000..54d666794 --- /dev/null +++ b/lightning-backend/src/api/bitcoin/bitcoin-api.interface.ts @@ -0,0 +1,175 @@ +export namespace IBitcoinApi { + export interface MempoolInfo { + loaded: boolean; // (boolean) True if the mempool is fully loaded + size: number; // (numeric) Current tx count + bytes: number; // (numeric) Sum of all virtual transaction sizes as defined in BIP 141. + usage: number; // (numeric) Total memory usage for the mempool + total_fee: number; // (numeric) Total fees of transactions in the mempool + maxmempool: number; // (numeric) Maximum memory usage for the mempool + mempoolminfee: number; // (numeric) Minimum fee rate in BTC/kB for tx to be accepted. + minrelaytxfee: number; // (numeric) Current minimum relay fee for transactions + } + + export interface RawMempool { [txId: string]: MempoolEntry; } + + export interface MempoolEntry { + vsize: number; // (numeric) virtual transaction size as defined in BIP 141. + weight: number; // (numeric) transaction weight as defined in BIP 141. + time: number; // (numeric) local time transaction entered pool in seconds since 1 Jan 1970 GMT + height: number; // (numeric) block height when transaction entered pool + descendantcount: number; // (numeric) number of in-mempool descendant transactions (including this one) + descendantsize: number; // (numeric) virtual transaction size of in-mempool descendants (including this one) + ancestorcount: number; // (numeric) number of in-mempool ancestor transactions (including this one) + ancestorsize: number; // (numeric) virtual transaction size of in-mempool ancestors (including this one) + wtxid: string; // (string) hash of serialized transactionumber; including witness data + fees: { + base: number; // (numeric) transaction fee in BTC + modified: number; // (numeric) transaction fee with fee deltas used for mining priority in BTC + ancestor: number; // (numeric) modified fees (see above) of in-mempool ancestors (including this one) in BTC + descendant: number; // (numeric) modified fees (see above) of in-mempool descendants (including this one) in BTC + }; + depends: string[]; // (string) parent transaction id + spentby: string[]; // (array) unconfirmed transactions spending outputs from this transaction + 'bip125-replaceable': boolean; // (boolean) Whether this transaction could be replaced due to BIP125 (replace-by-fee) + } + + export interface Block { + hash: string; // (string) the block hash (same as provided) + confirmations: number; // (numeric) The number of confirmations, or -1 if the block is not on the main chain + size: number; // (numeric) The block size + strippedsize: number; // (numeric) The block size excluding witness data + weight: number; // (numeric) The block weight as defined in BIP 141 + height: number; // (numeric) The block height or index + version: number; // (numeric) The block version + versionHex: string; // (string) The block version formatted in hexadecimal + merkleroot: string; // (string) The merkle root + tx: Transaction[]; + time: number; // (numeric) The block time expressed in UNIX epoch time + mediantime: number; // (numeric) The median block time expressed in UNIX epoch time + nonce: number; // (numeric) The nonce + bits: string; // (string) The bits + difficulty: number; // (numeric) The difficulty + chainwork: string; // (string) Expected number of hashes required to produce the chain up to this block (in hex) + nTx: number; // (numeric) The number of transactions in the block + previousblockhash: string; // (string) The hash of the previous block + nextblockhash: string; // (string) The hash of the next block + } + + export interface Transaction { + in_active_chain: boolean; // (boolean) Whether specified block is in the active chain or not + hex: string; // (string) The serialized, hex-encoded data for 'txid' + txid: string; // (string) The transaction id (same as provided) + hash: string; // (string) The transaction hash (differs from txid for witness transactions) + size: number; // (numeric) The serialized transaction size + vsize: number; // (numeric) The virtual transaction size (differs from size for witness transactions) + weight: number; // (numeric) The transaction's weight (between vsize*4-3 and vsize*4) + version: number; // (numeric) The version + locktime: number; // (numeric) The lock time + vin: Vin[]; + vout: Vout[]; + blockhash: string; // (string) the block hash + confirmations: number; // (numeric) The confirmations + blocktime: number; // (numeric) The block time expressed in UNIX epoch time + time: number; // (numeric) Same as blocktime + } + + export interface VerboseBlock extends Block { + tx: VerboseTransaction[]; // The transactions in the format of the getrawtransaction RPC. Different from verbosity = 1 "tx" result + } + + export interface VerboseTransaction extends Transaction { + fee?: number; // (numeric) The transaction fee in BTC, omitted if block undo data is not available + } + + export interface Vin { + txid?: string; // (string) The transaction id + vout?: number; // (string) + scriptSig?: { // (json object) The script + asm: string; // (string) asm + hex: string; // (string) hex + }; + sequence: number; // (numeric) The script sequence number + txinwitness?: string[]; // (string) hex-encoded witness data + coinbase?: string; + is_pegin?: boolean; // (boolean) Elements peg-in + } + + export interface Vout { + value: number; // (numeric) The value in BTC + n: number; // (numeric) index + asset?: string; // (string) Elements asset id + scriptPubKey: { // (json object) + asm: string; // (string) the asm + hex: string; // (string) the hex + reqSigs?: number; // (numeric) The required sigs + type: string; // (string) The type, eg 'pubkeyhash' + address?: string; // (string) bitcoin address + addresses?: string[]; // (string) bitcoin addresses + pegout_chain?: string; // (string) Elements peg-out chain + pegout_addresses?: string[]; // (string) Elements peg-out addresses + }; + } + + export interface AddressInformation { + isvalid: boolean; // (boolean) If the address is valid or not. If not, this is the only property returned. + isvalid_parent?: boolean; // (boolean) Elements only + address: string; // (string) The bitcoin address validated + scriptPubKey: string; // (string) The hex-encoded scriptPubKey generated by the address + isscript: boolean; // (boolean) If the key is a script + iswitness: boolean; // (boolean) If the address is a witness + witness_version?: number; // (numeric, optional) The version number of the witness program + witness_program: string; // (string, optional) The hex value of the witness program + confidential_key?: string; // (string) Elements only + unconfidential?: string; // (string) Elements only + } + + export interface ChainTips { + height: number; // (numeric) height of the chain tip + hash: string; // (string) block hash of the tip + branchlen: number; // (numeric) zero for main chain, otherwise length of branch connecting the tip to the main chain + status: 'invalid' | 'headers-only' | 'valid-headers' | 'valid-fork' | 'active'; + } + + export interface BlockchainInfo { + chain: number; // (string) current network name as defined in BIP70 (main, test, regtest) + blocks: number; // (numeric) the current number of blocks processed in the server + headers: number; // (numeric) the current number of headers we have validated + bestblockhash: string, // (string) the hash of the currently best block + difficulty: number; // (numeric) the current difficulty + mediantime: number; // (numeric) median time for the current best block + verificationprogress: number; // (numeric) estimate of verification progress [0..1] + initialblockdownload: boolean; // (bool) (debug information) estimate of whether this node is in Initial Block Download mode. + chainwork: string // (string) total amount of work in active chain, in hexadecimal + size_on_disk: number; // (numeric) the estimated size of the block and undo files on disk + pruned: number; // (boolean) if the blocks are subject to pruning + pruneheight: number; // (numeric) lowest-height complete block stored (only present if pruning is enabled) + automatic_pruning: number; // (boolean) whether automatic pruning is enabled (only present if pruning is enabled) + prune_target_size: number; // (numeric) the target size used by pruning (only present if automatic pruning is enabled) + softforks: SoftFork[]; // (array) status of softforks in progress + bip9_softforks: { [name: string]: Bip9SoftForks[] } // (object) status of BIP9 softforks in progress + warnings: string; // (string) any network and blockchain warnings. + } + + interface SoftFork { + id: string; // (string) name of softfork + version: number; // (numeric) block version + reject: { // (object) progress toward rejecting pre-softfork blocks + status: boolean; // (boolean) true if threshold reached + }, + } + interface Bip9SoftForks { + status: number; // (string) one of defined, started, locked_in, active, failed + bit: number; // (numeric) the bit (0-28) in the block version field used to signal this softfork (only for started status) + startTime: number; // (numeric) the minimum median time past of a block at which the bit gains its meaning + timeout: number; // (numeric) the median time past of a block at which the deployment is considered failed if not yet locked in + since: number; // (numeric) height of the first block to which the status applies + statistics: { // (object) numeric statistics about BIP9 signalling for a softfork (only for started status) + period: number; // (numeric) the length in blocks of the BIP9 signalling period + threshold: number; // (numeric) the number of blocks with the version bit set required to activate the feature + elapsed: number; // (numeric) the number of blocks elapsed since the beginning of the current period + count: number; // (numeric) the number of blocks with the version bit set in the current period + possible: boolean; // (boolean) returns false if there are not enough blocks left in this period to pass activation threshold + } + } + +} diff --git a/lightning-backend/src/api/bitcoin/bitcoin-api.ts b/lightning-backend/src/api/bitcoin/bitcoin-api.ts new file mode 100644 index 000000000..d8fa07e80 --- /dev/null +++ b/lightning-backend/src/api/bitcoin/bitcoin-api.ts @@ -0,0 +1,313 @@ +import * as bitcoinjs from 'bitcoinjs-lib'; +import { AbstractBitcoinApi } from './bitcoin-api-abstract-factory'; +import { IBitcoinApi } from './bitcoin-api.interface'; +import { IEsploraApi } from './esplora-api.interface'; + +class BitcoinApi implements AbstractBitcoinApi { + protected bitcoindClient: any; + + constructor(bitcoinClient: any) { + this.bitcoindClient = bitcoinClient; + } + + $getAddressPrefix(prefix: string): string[] { + throw new Error('Method not implemented.'); + } + + $getBlock(hash: string): Promise { + throw new Error('Method not implemented.'); + } + + $getRawTransaction(txId: string, skipConversion = false, addPrevout = false, lazyPrevouts = false): Promise { + return this.bitcoindClient.getRawTransaction(txId, true) + .then((transaction: IBitcoinApi.Transaction) => { + if (skipConversion) { + transaction.vout.forEach((vout) => { + vout.value = Math.round(vout.value * 100000000); + }); + return transaction; + } + return this.$convertTransaction(transaction, addPrevout, lazyPrevouts); + }) + .catch((e: Error) => { + throw e; + }); + } + + $getBlockHeightTip(): Promise { + return this.bitcoindClient.getChainTips() + .then((result: IBitcoinApi.ChainTips[]) => { + return result.find(tip => tip.status === 'active')!.height; + }); + } + + $getTxIdsForBlock(hash: string): Promise { + return this.bitcoindClient.getBlock(hash, 1) + .then((rpcBlock: IBitcoinApi.Block) => rpcBlock.tx); + } + + $getRawBlock(hash: string): Promise { + return this.bitcoindClient.getBlock(hash, 0); + } + + $getBlockHash(height: number): Promise { + return this.bitcoindClient.getBlockHash(height); + } + + $getBlockHeader(hash: string): Promise { + return this.bitcoindClient.getBlockHeader(hash, false); + } + + $getAddress(address: string): Promise { + throw new Error('Method getAddress not supported by the Bitcoin RPC API.'); + } + + $getAddressTransactions(address: string, lastSeenTxId: string): Promise { + throw new Error('Method getAddressTransactions not supported by the Bitcoin RPC API.'); + } + + $getRawMempool(): Promise { + return this.bitcoindClient.getRawMemPool(); + } + + $sendRawTransaction(rawTransaction: string): Promise { + return this.bitcoindClient.sendRawTransaction(rawTransaction); + } + + async $getOutspend(txId: string, vout: number): Promise { + const txOut = await this.bitcoindClient.getTxOut(txId, vout, false); + return { + spent: txOut === null, + status: { + confirmed: true, + } + }; + } + + async $getOutspends(txId: string): Promise { + const outSpends: IEsploraApi.Outspend[] = []; + const tx = await this.$getRawTransaction(txId, true, false); + for (let i = 0; i < tx.vout.length; i++) { + if (tx.status && tx.status.block_height === 0) { + outSpends.push({ + spent: false + }); + } else { + const txOut = await this.bitcoindClient.getTxOut(txId, i); + outSpends.push({ + spent: txOut === null, + }); + } + } + return outSpends; + } + + async $getBatchedOutspends(txId: string[]): Promise { + const outspends: IEsploraApi.Outspend[][] = []; + for (const tx of txId) { + const outspend = await this.$getOutspends(tx); + outspends.push(outspend); + } + return outspends; + } + + $getEstimatedHashrate(blockHeight: number): Promise { + // 120 is the default block span in Core + return this.bitcoindClient.getNetworkHashPs(120, blockHeight); + } + + protected async $convertTransaction(transaction: IBitcoinApi.Transaction, addPrevout: boolean, lazyPrevouts = false): Promise { + let esploraTransaction: IEsploraApi.Transaction = { + txid: transaction.txid, + version: transaction.version, + locktime: transaction.locktime, + size: transaction.size, + weight: transaction.weight, + fee: 0, + vin: [], + vout: [], + status: { confirmed: false }, + }; + + esploraTransaction.vout = transaction.vout.map((vout) => { + return { + value: Math.round(vout.value * 100000000), + scriptpubkey: vout.scriptPubKey.hex, + scriptpubkey_address: vout.scriptPubKey && vout.scriptPubKey.address ? vout.scriptPubKey.address + : vout.scriptPubKey.addresses ? vout.scriptPubKey.addresses[0] : '', + scriptpubkey_asm: vout.scriptPubKey.asm ? this.convertScriptSigAsm(vout.scriptPubKey.hex) : '', + scriptpubkey_type: this.translateScriptPubKeyType(vout.scriptPubKey.type), + }; + }); + + // @ts-ignore + esploraTransaction.vin = transaction.vin.map((vin) => { + return { + is_coinbase: !!vin.coinbase, + prevout: null, + scriptsig: vin.scriptSig && vin.scriptSig.hex || vin.coinbase || '', + scriptsig_asm: vin.scriptSig && this.convertScriptSigAsm(vin.scriptSig.hex) || '', + sequence: vin.sequence, + txid: vin.txid || '', + vout: vin.vout || 0, + witness: vin.txinwitness, + }; + }); + + if (transaction.confirmations) { + esploraTransaction.status = { + confirmed: true, + block_height: -1, + block_hash: transaction.blockhash, + block_time: transaction.blocktime, + }; + } + + if (addPrevout) { + esploraTransaction = await this.$calculateFeeFromInputs(esploraTransaction, false, lazyPrevouts); + } else if (!transaction.confirmations) { + // esploraTransaction = await this.$appendMempoolFeeData(esploraTransaction); + } + + return esploraTransaction; + } + + private translateScriptPubKeyType(outputType: string): string { + const map = { + 'pubkey': 'p2pk', + 'pubkeyhash': 'p2pkh', + 'scripthash': 'p2sh', + 'witness_v0_keyhash': 'v0_p2wpkh', + 'witness_v0_scripthash': 'v0_p2wsh', + 'witness_v1_taproot': 'v1_p2tr', + 'nonstandard': 'nonstandard', + 'multisig': 'multisig', + 'nulldata': 'op_return' + }; + + if (map[outputType]) { + return map[outputType]; + } else { + return 'unknown'; + } + } + + private async $calculateFeeFromInputs(transaction: IEsploraApi.Transaction, addPrevout: boolean, lazyPrevouts: boolean): Promise { + if (transaction.vin[0].is_coinbase) { + transaction.fee = 0; + return transaction; + } + let totalIn = 0; + + for (let i = 0; i < transaction.vin.length; i++) { + if (lazyPrevouts && i > 12) { + transaction.vin[i].lazy = true; + continue; + } + const innerTx = await this.$getRawTransaction(transaction.vin[i].txid, false, false); + transaction.vin[i].prevout = innerTx.vout[transaction.vin[i].vout]; + this.addInnerScriptsToVin(transaction.vin[i]); + totalIn += innerTx.vout[transaction.vin[i].vout].value; + } + if (lazyPrevouts && transaction.vin.length > 12) { + transaction.fee = -1; + } else { + const totalOut = transaction.vout.reduce((p, output) => p + output.value, 0); + transaction.fee = parseFloat((totalIn - totalOut).toFixed(8)); + } + return transaction; + } + + private convertScriptSigAsm(hex: string): string { + const buf = Buffer.from(hex, 'hex'); + + const b: string[] = []; + + let i = 0; + while (i < buf.length) { + const op = buf[i]; + if (op >= 0x01 && op <= 0x4e) { + i++; + let push: number; + if (op === 0x4c) { + push = buf.readUInt8(i); + b.push('OP_PUSHDATA1'); + i += 1; + } else if (op === 0x4d) { + push = buf.readUInt16LE(i); + b.push('OP_PUSHDATA2'); + i += 2; + } else if (op === 0x4e) { + push = buf.readUInt32LE(i); + b.push('OP_PUSHDATA4'); + i += 4; + } else { + push = op; + b.push('OP_PUSHBYTES_' + push); + } + + const data = buf.slice(i, i + push); + if (data.length !== push) { + break; + } + + b.push(data.toString('hex')); + i += data.length; + } else { + if (op === 0x00) { + b.push('OP_0'); + } else if (op === 0x4f) { + b.push('OP_PUSHNUM_NEG1'); + } else if (op === 0xb1) { + b.push('OP_CLTV'); + } else if (op === 0xb2) { + b.push('OP_CSV'); + } else if (op === 0xba) { + b.push('OP_CHECKSIGADD'); + } else { + const opcode = bitcoinjs.script.toASM([ op ]); + if (opcode && op < 0xfd) { + if (/^OP_(\d+)$/.test(opcode)) { + b.push(opcode.replace(/^OP_(\d+)$/, 'OP_PUSHNUM_$1')); + } else { + b.push(opcode); + } + } else { + b.push('OP_RETURN_' + op); + } + } + i += 1; + } + } + + return b.join(' '); + } + + private addInnerScriptsToVin(vin: IEsploraApi.Vin): void { + if (!vin.prevout) { + return; + } + + if (vin.prevout.scriptpubkey_type === 'p2sh') { + const redeemScript = vin.scriptsig_asm.split(' ').reverse()[0]; + vin.inner_redeemscript_asm = this.convertScriptSigAsm(redeemScript); + if (vin.witness && vin.witness.length > 2) { + const witnessScript = vin.witness[vin.witness.length - 1]; + vin.inner_witnessscript_asm = this.convertScriptSigAsm(witnessScript); + } + } + + if (vin.prevout.scriptpubkey_type === 'v0_p2wsh' && vin.witness) { + const witnessScript = vin.witness[vin.witness.length - 1]; + vin.inner_witnessscript_asm = this.convertScriptSigAsm(witnessScript); + } + + if (vin.prevout.scriptpubkey_type === 'v1_p2tr' && vin.witness && vin.witness.length > 1) { + const witnessScript = vin.witness[vin.witness.length - 2]; + vin.inner_witnessscript_asm = this.convertScriptSigAsm(witnessScript); + } + } + +} + +export default BitcoinApi; diff --git a/lightning-backend/src/api/bitcoin/esplora-api.interface.ts b/lightning-backend/src/api/bitcoin/esplora-api.interface.ts new file mode 100644 index 000000000..39f8cfd6f --- /dev/null +++ b/lightning-backend/src/api/bitcoin/esplora-api.interface.ts @@ -0,0 +1,172 @@ +export namespace IEsploraApi { + export interface Transaction { + txid: string; + version: number; + locktime: number; + size: number; + weight: number; + fee: number; + vin: Vin[]; + vout: Vout[]; + status: Status; + hex?: string; + } + + export interface Recent { + txid: string; + fee: number; + vsize: number; + value: number; + } + + export interface Vin { + txid: string; + vout: number; + is_coinbase: boolean; + scriptsig: string; + scriptsig_asm: string; + inner_redeemscript_asm: string; + inner_witnessscript_asm: string; + sequence: any; + witness: string[]; + prevout: Vout | null; + // Elements + is_pegin?: boolean; + issuance?: Issuance; + // Custom + lazy?: boolean; + } + + interface Issuance { + asset_id: string; + is_reissuance: string; + asset_blinding_nonce: string; + asset_entropy: string; + contract_hash: string; + assetamount?: number; + assetamountcommitment?: string; + tokenamount?: number; + tokenamountcommitment?: string; + } + + export interface Vout { + scriptpubkey: string; + scriptpubkey_asm: string; + scriptpubkey_type: string; + scriptpubkey_address: string; + value: number; + // Elements + valuecommitment?: number; + asset?: string; + pegout?: Pegout; + } + + interface Pegout { + genesis_hash: string; + scriptpubkey: string; + scriptpubkey_asm: string; + scriptpubkey_address: string; + } + + export interface Status { + confirmed: boolean; + block_height?: number; + block_hash?: string; + block_time?: number; + } + + export interface Block { + id: string; + height: number; + version: number; + timestamp: number; + bits: number; + nonce: number; + difficulty: number; + merkle_root: string; + tx_count: number; + size: number; + weight: number; + previousblockhash: string; + } + + export interface Address { + address: string; + chain_stats: ChainStats; + mempool_stats: MempoolStats; + electrum?: boolean; + } + + export interface ChainStats { + funded_txo_count: number; + funded_txo_sum: number; + spent_txo_count: number; + spent_txo_sum: number; + tx_count: number; + } + + export interface MempoolStats { + funded_txo_count: number; + funded_txo_sum: number; + spent_txo_count: number; + spent_txo_sum: number; + tx_count: number; + } + + export interface Outspend { + spent: boolean; + txid?: string; + vin?: number; + status?: Status; + } + + export interface Asset { + asset_id: string; + issuance_txin: IssuanceTxin; + issuance_prevout: IssuancePrevout; + reissuance_token: string; + contract_hash: string; + status: Status; + chain_stats: AssetStats; + mempool_stats: AssetStats; + } + + export interface AssetExtended extends Asset { + name: string; + ticker: string; + precision: number; + entity: Entity; + version: number; + issuer_pubkey: string; + } + + export interface Entity { + domain: string; + } + + interface IssuanceTxin { + txid: string; + vin: number; + } + + interface IssuancePrevout { + txid: string; + vout: number; + } + + interface AssetStats { + tx_count: number; + issuance_count: number; + issued_amount: number; + burned_amount: number; + has_blinded_issuances: boolean; + reissuance_tokens: number; + burned_reissuance_tokens: number; + peg_in_count: number; + peg_in_amount: number; + peg_out_count: number; + peg_out_amount: number; + burn_count: number; + } + +} diff --git a/lightning-backend/src/api/bitcoin/esplora-api.ts b/lightning-backend/src/api/bitcoin/esplora-api.ts new file mode 100644 index 000000000..6ed48a0f8 --- /dev/null +++ b/lightning-backend/src/api/bitcoin/esplora-api.ts @@ -0,0 +1,84 @@ +import config from '../../config'; +import axios, { AxiosRequestConfig } from 'axios'; +import { AbstractBitcoinApi } from './bitcoin-api-abstract-factory'; +import { IEsploraApi } from './esplora-api.interface'; + +class ElectrsApi implements AbstractBitcoinApi { + axiosConfig: AxiosRequestConfig = { + timeout: 10000, + }; + + constructor() { } + + $getRawMempool(): Promise { + return axios.get(config.ESPLORA.REST_API_URL + '/mempool/txids', this.axiosConfig) + .then((response) => response.data); + } + + $getRawTransaction(txId: string): Promise { + return axios.get(config.ESPLORA.REST_API_URL + '/tx/' + txId, this.axiosConfig) + .then((response) => response.data); + } + + $getBlockHeightTip(): Promise { + return axios.get(config.ESPLORA.REST_API_URL + '/blocks/tip/height', this.axiosConfig) + .then((response) => response.data); + } + + $getTxIdsForBlock(hash: string): Promise { + return axios.get(config.ESPLORA.REST_API_URL + '/block/' + hash + '/txids', this.axiosConfig) + .then((response) => response.data); + } + + $getBlockHash(height: number): Promise { + return axios.get(config.ESPLORA.REST_API_URL + '/block-height/' + height, this.axiosConfig) + .then((response) => response.data); + } + + $getBlockHeader(hash: string): Promise { + return axios.get(config.ESPLORA.REST_API_URL + '/block/' + hash + '/header', this.axiosConfig) + .then((response) => response.data); + } + + $getBlock(hash: string): Promise { + return axios.get(config.ESPLORA.REST_API_URL + '/block/' + hash, this.axiosConfig) + .then((response) => response.data); + } + + $getAddress(address: string): Promise { + throw new Error('Method getAddress not implemented.'); + } + + $getAddressTransactions(address: string, txId?: string): Promise { + throw new Error('Method getAddressTransactions not implemented.'); + } + + $getAddressPrefix(prefix: string): string[] { + throw new Error('Method not implemented.'); + } + + $sendRawTransaction(rawTransaction: string): Promise { + throw new Error('Method not implemented.'); + } + + $getOutspend(txId: string, vout: number): Promise { + return axios.get(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspend/' + vout, this.axiosConfig) + .then((response) => response.data); + } + + $getOutspends(txId: string): Promise { + return axios.get(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspends', this.axiosConfig) + .then((response) => response.data); + } + + async $getBatchedOutspends(txId: string[]): Promise { + const outspends: IEsploraApi.Outspend[][] = []; + for (const tx of txId) { + const outspend = await this.$getOutspends(tx); + outspends.push(outspend); + } + return outspends; + } +} + +export default ElectrsApi; diff --git a/lightning-backend/src/api/explorer/channels.api.ts b/lightning-backend/src/api/explorer/channels.api.ts index 64f6569bf..d2dd930c3 100644 --- a/lightning-backend/src/api/explorer/channels.api.ts +++ b/lightning-backend/src/api/explorer/channels.api.ts @@ -36,6 +36,17 @@ class ChannelsApi { } } + public async $getClosedChannelsWithoutReason(): Promise { + try { + const query = `SELECT * FROM channels WHERE status = 2 AND closing_reason IS NULL`; + const [rows]: any = await DB.query(query); + return rows; + } catch (e) { + logger.err('$getClosedChannelsWithoutReason error: ' + (e instanceof Error ? e.message : e)); + throw e; + } + } + public async $getChannelsWithoutCreatedDate(): Promise { try { const query = `SELECT * FROM channels WHERE created IS NULL`; @@ -115,6 +126,8 @@ class ChannelsApi { 'capacity': channel.capacity, 'transaction_id': channel.transaction_id, 'transaction_vout': channel.transaction_vout, + 'closing_transaction_id': channel.closing_transaction_id, + 'closing_reason': channel.closing_reason, 'updated_at': channel.updated_at, 'created': channel.created, 'status': channel.status, diff --git a/lightning-backend/src/config.ts b/lightning-backend/src/config.ts index 9b71dd977..d2edad180 100644 --- a/lightning-backend/src/config.ts +++ b/lightning-backend/src/config.ts @@ -8,6 +8,9 @@ interface IConfig { API_URL_PREFIX: string; STDOUT_LOG_MIN_PRIORITY: 'emerg' | 'alert' | 'crit' | 'err' | 'warn' | 'notice' | 'info' | 'debug'; }; + ESPLORA: { + REST_API_URL: string; + }; SYSLOG: { ENABLED: boolean; HOST: string; @@ -43,6 +46,9 @@ const defaults: IConfig = { 'API_URL_PREFIX': '/api/v1/', 'STDOUT_LOG_MIN_PRIORITY': 'debug', }, + 'ESPLORA': { + 'REST_API_URL': 'http://127.0.0.1:3000', + }, 'SYSLOG': { 'ENABLED': true, 'HOST': '127.0.0.1', @@ -72,6 +78,7 @@ const defaults: IConfig = { class Config implements IConfig { MEMPOOL: IConfig['MEMPOOL']; + ESPLORA: IConfig['ESPLORA']; SYSLOG: IConfig['SYSLOG']; LN_NODE_AUTH: IConfig['LN_NODE_AUTH']; CORE_RPC: IConfig['CORE_RPC']; @@ -80,6 +87,7 @@ class Config implements IConfig { constructor() { const configs = this.merge(configFile, defaults); this.MEMPOOL = configs.MEMPOOL; + this.ESPLORA = configs.ESPLORA; this.SYSLOG = configs.SYSLOG; this.LN_NODE_AUTH = configs.LN_NODE_AUTH; this.CORE_RPC = configs.CORE_RPC; diff --git a/lightning-backend/src/database-migration.ts b/lightning-backend/src/database-migration.ts index 37b5301cf..6717154aa 100644 --- a/lightning-backend/src/database-migration.ts +++ b/lightning-backend/src/database-migration.ts @@ -213,6 +213,8 @@ class DatabaseMigration { updated_at datetime DEFAULT NULL, created datetime DEFAULT NULL, status int(11) NOT NULL DEFAULT 0, + closing_transaction_id varchar(64) DEFAULT NULL, + closing_reason int(11) DEFAULT NULL, node1_public_key varchar(66) NOT NULL, node1_base_fee_mtokens bigint(20) unsigned DEFAULT NULL, node1_cltv_delta int(11) DEFAULT NULL, diff --git a/lightning-backend/src/tasks/node-sync.service.ts b/lightning-backend/src/tasks/node-sync.service.ts index 65ecec8c2..3e6bfc96c 100644 --- a/lightning-backend/src/tasks/node-sync.service.ts +++ b/lightning-backend/src/tasks/node-sync.service.ts @@ -5,6 +5,10 @@ import lightningApi from '../api/lightning/lightning-api-factory'; import { ILightningApi } from '../api/lightning/lightning-api.interface'; import channelsApi from '../api/explorer/channels.api'; import bitcoinClient from '../api/bitcoin/bitcoin-client'; +import bitcoinApi from '../api/bitcoin/bitcoin-api-factory'; +import config from '../config'; +import { IEsploraApi } from '../api/bitcoin/esplora-api.interface'; +import e from 'express'; class NodeSyncService { constructor() {} @@ -38,15 +42,18 @@ class NodeSyncService { await this.$findInactiveNodesAndChannels(); logger.debug(`Inactive channels scan complete`); - await this.$scanForClosedChannels(); - logger.debug(`Closed channels scan complete`); - await this.$lookUpCreationDateFromChain(); logger.debug(`Channel creation dates scan complete`); await this.$updateNodeFirstSeen(); logger.debug(`Node first seen dates scan complete`); + await this.$scanForClosedChannels(); + logger.debug(`Closed channels scan complete`); + + await this.$runClosedChannelsForensics(); + logger.debug(`Closed channels forensics scan complete`); + } catch (e) { logger.err('$updateNodes() error: ' + (e instanceof Error ? e.message : e)); } @@ -109,17 +116,129 @@ class NodeSyncService { try { const channels = await channelsApi.$getChannelsByStatus(0); for (const channel of channels) { - const outspends = await bitcoinClient.getTxOut(channel.transaction_id, channel.transaction_vout); - if (outspends === null) { + const spendingTx = await bitcoinApi.$getOutspend(channel.transaction_id, channel.transaction_vout); + if (spendingTx.spent === true && spendingTx.status?.confirmed === true) { logger.debug('Marking channel: ' + channel.id + ' as closed.'); await DB.query(`UPDATE channels SET status = 2 WHERE id = ?`, [channel.id]); + if (spendingTx.txid && !channel.closing_transaction_id) { + await DB.query(`UPDATE channels SET closing_transaction_id = ? WHERE id = ?`, [spendingTx.txid, channel.id]); + } } } } catch (e) { - logger.err('$updateNodes() error: ' + (e instanceof Error ? e.message : e)); + logger.err('$scanForClosedChannels() error: ' + (e instanceof Error ? e.message : e)); } } + /* + 1. Mutually closed + 2. Forced closed + 3. Forced closed with penalty + */ + + private async $runClosedChannelsForensics(): Promise { + if (!config.ESPLORA.REST_API_URL) { + return; + } + + try { + const channels = await channelsApi.$getClosedChannelsWithoutReason(); + for (const channel of channels) { + let reason = 0; + // Only Esplora backend can retrieve spent transaction outputs + const outspends = await bitcoinApi.$getOutspends(channel.closing_transaction_id); + const lightningScriptReasons: number[] = []; + for (const outspend of outspends) { + if (outspend.spent && outspend.txid) { + const spendingTx = await bitcoinApi.$getRawTransaction(outspend.txid); + const lightningScript = this.findLightningScript(spendingTx.vin[outspend.vin || 0]); + lightningScriptReasons.push(lightningScript); + } + } + if (lightningScriptReasons.length === outspends.length + && lightningScriptReasons.filter((r) => r === 1).length === outspends.length) { + reason = 1; + } else { + const filteredReasons = lightningScriptReasons.filter((r) => r !== 1); + if (filteredReasons.length) { + if (filteredReasons.some((r) => r === 2 || r === 4)) { + reason = 3; + } else { + reason = 2; + } + } else { + /* + 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 closingTx = await bitcoinApi.$getRawTransaction(channel.closing_transaction_id); + const sequenceHex: string = closingTx.vin[0].sequence.toString(16); + const locktimeHex: string = closingTx.locktime.toString(16); + if (sequenceHex.substring(0, 2) === '80' && locktimeHex.substring(0, 2) === '20') { + reason = 2; // Here we can't be sure if it's a penalty or not + } else { + reason = 1; + } + } + } + if (reason) { + logger.debug('Setting closing reason ' + reason + ' for channel: ' + channel.id + '.'); + await DB.query(`UPDATE channels SET closing_reason = ? WHERE id = ?`, [reason, channel.id]); + } + } + } catch (e) { + logger.err('$runClosedChannelsForensics() error: ' + (e instanceof Error ? e.message : e)); + } + } + + private findLightningScript(vin: IEsploraApi.Vin): number { + const topElement = 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; + } + private async $saveChannel(channel: ILightningApi.Channel): Promise { const fromChannel = chanNumber({ channel: channel.id }).number;