Closed channels forensics

This commit is contained in:
softsimon 2022-06-29 23:06:13 +02:00
parent f0ad38dec6
commit 4bb23cf0c8
No known key found for this signature in database
GPG Key ID: 488D7DCFB5A430D7
18 changed files with 1019 additions and 11 deletions

View File

@ -29,7 +29,7 @@
<td><app-time-since [dateString]="channel.updated_at"></app-time-since></td>
</tr>
<tr>
<td i18n="address.total-sent">Transaction ID</td>
<td i18n="address.total-sent">Opening transaction</td>
<td>
<a [routerLink]="['/tx' | relativeUrl, channel.transaction_id + ':' + channel.transaction_vout]" >
<span>{{ channel.transaction_id | shortenString : 10 }}</span>
@ -37,6 +37,23 @@
<app-clipboard [text]="channel.transaction_id"></app-clipboard>
</td>
</tr>
<ng-template [ngIf]="channel.closing_transaction_id">
<tr *ngIf="channel.closing_transaction_id">
<td i18n="address.total-sent">Closing transaction</td>
<td>
<a [routerLink]="['/tx' | relativeUrl, channel.closing_transaction_id]" >
<span>{{ channel.closing_transaction_id | shortenString : 10 }}</span>
</a>
<app-clipboard [text]="channel.closing_transaction_id"></app-clipboard>
</td>
</tr>
<tr>
<td i18n="address.total-sent">Closing type</td>
<td>
<app-closing-type [type]="channel.closing_reason"></app-closing-type>
</td>
</tr>
</ng-template>
</tbody>
</table>
</div>
@ -69,3 +86,11 @@
</div>
<br>
<ng-template [ngIf]="error">
<div class="text-center">
<span i18n="error.general-loading-data">Error loading data.</span>
<br><br>
<i>{{ error.status }}: {{ error.error }}</i>
</div>
</ng-template>

View File

@ -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<any>;
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);
})
);
})
);
}

View File

@ -0,0 +1 @@
<span class="badge badge-pill badge-{{ label.class }}" >{{ label.label }}</span>

View File

@ -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',
};
}
}
}

View File

@ -52,7 +52,12 @@
<td class="d-none d-md-table-cell">
<span class="badge rounded-pill badge-secondary" *ngIf="channel.status === 0">Inactive</span>
<span class="badge rounded-pill badge-success" *ngIf="channel.status === 1">Active</span>
<span class="badge rounded-pill badge-danger" *ngIf="channel.status === 2">Closed</span>
<ng-template [ngIf]="channel.status === 2">
<span class="badge rounded-pill badge-secondary" *ngIf="!channel.closing_reason; else closingReason">Closed</span>
<ng-template #closingReason>
<app-closing-type [type]="channel.closing_reason"></app-closing-type>
</ng-template>
</ng-template>
</td>
<td class="capacity text-left d-none d-md-table-cell">
{{ node.fee_rate }} <span class="symbol">ppm ({{ node.fee_rate / 10000 | number }}%)</span>

View File

@ -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,

View File

@ -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",

View File

@ -0,0 +1,25 @@
import { IEsploraApi } from './esplora-api.interface';
export interface AbstractBitcoinApi {
$getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]>;
$getRawTransaction(txId: string, skipConversion?: boolean, addPrevout?: boolean, lazyPrevouts?: boolean): Promise<IEsploraApi.Transaction>;
$getBlockHeightTip(): Promise<number>;
$getTxIdsForBlock(hash: string): Promise<string[]>;
$getBlockHash(height: number): Promise<string>;
$getBlockHeader(hash: string): Promise<string>;
$getBlock(hash: string): Promise<IEsploraApi.Block>;
$getAddress(address: string): Promise<IEsploraApi.Address>;
$getAddressTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]>;
$getAddressPrefix(prefix: string): string[];
$sendRawTransaction(rawTransaction: string): Promise<string>;
$getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend>;
$getOutspends(txId: string): Promise<IEsploraApi.Outspend[]>;
$getBatchedOutspends(txId: string[]): Promise<IEsploraApi.Outspend[][]>;
}
export interface BitcoinRpcCredentials {
host: string;
port: number;
user: string;
pass: string;
timeout: number;
}

View File

@ -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();

View File

@ -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
}
}
}

View File

@ -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<IEsploraApi.Block> {
throw new Error('Method not implemented.');
}
$getRawTransaction(txId: string, skipConversion = false, addPrevout = false, lazyPrevouts = false): Promise<IEsploraApi.Transaction> {
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<number> {
return this.bitcoindClient.getChainTips()
.then((result: IBitcoinApi.ChainTips[]) => {
return result.find(tip => tip.status === 'active')!.height;
});
}
$getTxIdsForBlock(hash: string): Promise<string[]> {
return this.bitcoindClient.getBlock(hash, 1)
.then((rpcBlock: IBitcoinApi.Block) => rpcBlock.tx);
}
$getRawBlock(hash: string): Promise<string> {
return this.bitcoindClient.getBlock(hash, 0);
}
$getBlockHash(height: number): Promise<string> {
return this.bitcoindClient.getBlockHash(height);
}
$getBlockHeader(hash: string): Promise<string> {
return this.bitcoindClient.getBlockHeader(hash, false);
}
$getAddress(address: string): Promise<IEsploraApi.Address> {
throw new Error('Method getAddress not supported by the Bitcoin RPC API.');
}
$getAddressTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]> {
throw new Error('Method getAddressTransactions not supported by the Bitcoin RPC API.');
}
$getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]> {
return this.bitcoindClient.getRawMemPool();
}
$sendRawTransaction(rawTransaction: string): Promise<string> {
return this.bitcoindClient.sendRawTransaction(rawTransaction);
}
async $getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend> {
const txOut = await this.bitcoindClient.getTxOut(txId, vout, false);
return {
spent: txOut === null,
status: {
confirmed: true,
}
};
}
async $getOutspends(txId: string): Promise<IEsploraApi.Outspend[]> {
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<IEsploraApi.Outspend[][]> {
const outspends: IEsploraApi.Outspend[][] = [];
for (const tx of txId) {
const outspend = await this.$getOutspends(tx);
outspends.push(outspend);
}
return outspends;
}
$getEstimatedHashrate(blockHeight: number): Promise<number> {
// 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<IEsploraApi.Transaction> {
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<IEsploraApi.Transaction> {
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;

View File

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

View File

@ -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<IEsploraApi.Transaction['txid'][]> {
return axios.get<IEsploraApi.Transaction['txid'][]>(config.ESPLORA.REST_API_URL + '/mempool/txids', this.axiosConfig)
.then((response) => response.data);
}
$getRawTransaction(txId: string): Promise<IEsploraApi.Transaction> {
return axios.get<IEsploraApi.Transaction>(config.ESPLORA.REST_API_URL + '/tx/' + txId, this.axiosConfig)
.then((response) => response.data);
}
$getBlockHeightTip(): Promise<number> {
return axios.get<number>(config.ESPLORA.REST_API_URL + '/blocks/tip/height', this.axiosConfig)
.then((response) => response.data);
}
$getTxIdsForBlock(hash: string): Promise<string[]> {
return axios.get<string[]>(config.ESPLORA.REST_API_URL + '/block/' + hash + '/txids', this.axiosConfig)
.then((response) => response.data);
}
$getBlockHash(height: number): Promise<string> {
return axios.get<string>(config.ESPLORA.REST_API_URL + '/block-height/' + height, this.axiosConfig)
.then((response) => response.data);
}
$getBlockHeader(hash: string): Promise<string> {
return axios.get<string>(config.ESPLORA.REST_API_URL + '/block/' + hash + '/header', this.axiosConfig)
.then((response) => response.data);
}
$getBlock(hash: string): Promise<IEsploraApi.Block> {
return axios.get<IEsploraApi.Block>(config.ESPLORA.REST_API_URL + '/block/' + hash, this.axiosConfig)
.then((response) => response.data);
}
$getAddress(address: string): Promise<IEsploraApi.Address> {
throw new Error('Method getAddress not implemented.');
}
$getAddressTransactions(address: string, txId?: string): Promise<IEsploraApi.Transaction[]> {
throw new Error('Method getAddressTransactions not implemented.');
}
$getAddressPrefix(prefix: string): string[] {
throw new Error('Method not implemented.');
}
$sendRawTransaction(rawTransaction: string): Promise<string> {
throw new Error('Method not implemented.');
}
$getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend> {
return axios.get<IEsploraApi.Outspend>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspend/' + vout, this.axiosConfig)
.then((response) => response.data);
}
$getOutspends(txId: string): Promise<IEsploraApi.Outspend[]> {
return axios.get<IEsploraApi.Outspend[]>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspends', this.axiosConfig)
.then((response) => response.data);
}
async $getBatchedOutspends(txId: string[]): Promise<IEsploraApi.Outspend[][]> {
const outspends: IEsploraApi.Outspend[][] = [];
for (const tx of txId) {
const outspend = await this.$getOutspends(tx);
outspends.push(outspend);
}
return outspends;
}
}
export default ElectrsApi;

View File

@ -36,6 +36,17 @@ class ChannelsApi {
}
}
public async $getClosedChannelsWithoutReason(): Promise<any[]> {
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<any[]> {
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,

View File

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

View File

@ -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,

View File

@ -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<void> {
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<void> {
const fromChannel = chanNumber({ channel: channel.id }).number;