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 @@
;
+ 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;
|