Merge branch 'master' into nymkappa/no-db-blocks-list

This commit is contained in:
nymkappa 2023-07-28 09:37:55 +09:00 committed by GitHub
commit 48e3be875d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 775 additions and 189 deletions

View File

@ -28,9 +28,7 @@ jobs:
registry-url: "https://registry.npmjs.org" registry-url: "https://registry.npmjs.org"
- name: Install 1.70.x Rust toolchain - name: Install 1.70.x Rust toolchain
uses: actions-rs/toolchain@v1 uses: dtolnay/rust-toolchain@1.70
with:
toolchain: 1.70
- name: Install - name: Install
if: ${{ matrix.flavor == 'dev'}} if: ${{ matrix.flavor == 'dev'}}

View File

@ -15,7 +15,7 @@ class Audit {
const matches: string[] = []; // present in both mined block and template const matches: string[] = []; // present in both mined block and template
const added: string[] = []; // present in mined block, not in template const added: string[] = []; // present in mined block, not in template
const fresh: string[] = []; // missing, but firstSeen or lastBoosted within PROPAGATION_MARGIN const fresh: string[] = []; // missing, but firstSeen or lastBoosted within PROPAGATION_MARGIN
const fullrbf: string[] = []; // either missing or present, and part of a fullrbf replacement const rbf: string[] = []; // either missing or present, and either part of a full-rbf replacement, or a conflict with the mined block
const isCensored = {}; // missing, without excuse const isCensored = {}; // missing, without excuse
const isDisplaced = {}; const isDisplaced = {};
let displacedWeight = 0; let displacedWeight = 0;
@ -36,8 +36,9 @@ class Audit {
// look for transactions that were expected in the template, but missing from the mined block // look for transactions that were expected in the template, but missing from the mined block
for (const txid of projectedBlocks[0].transactionIds) { for (const txid of projectedBlocks[0].transactionIds) {
if (!inBlock[txid]) { if (!inBlock[txid]) {
if (rbfCache.isFullRbf(txid)) { // allow missing transactions which either belong to a full rbf tree, or conflict with any transaction in the mined block
fullrbf.push(txid); if (rbfCache.has(txid) && (rbfCache.isFullRbf(txid) || rbfCache.anyInSameTree(txid, (tx) => inBlock[tx.txid]))) {
rbf.push(txid);
} else if (mempool[txid]?.firstSeen != null && (now - (mempool[txid]?.firstSeen || 0)) <= PROPAGATION_MARGIN) { } else if (mempool[txid]?.firstSeen != null && (now - (mempool[txid]?.firstSeen || 0)) <= PROPAGATION_MARGIN) {
// tx is recent, may have reached the miner too late for inclusion // tx is recent, may have reached the miner too late for inclusion
fresh.push(txid); fresh.push(txid);
@ -98,8 +99,8 @@ class Audit {
if (inTemplate[tx.txid]) { if (inTemplate[tx.txid]) {
matches.push(tx.txid); matches.push(tx.txid);
} else { } else {
if (rbfCache.isFullRbf(tx.txid)) { if (rbfCache.has(tx.txid)) {
fullrbf.push(tx.txid); rbf.push(tx.txid);
} else if (!isDisplaced[tx.txid]) { } else if (!isDisplaced[tx.txid]) {
added.push(tx.txid); added.push(tx.txid);
} }
@ -147,7 +148,7 @@ class Audit {
added, added,
fresh, fresh,
sigop: [], sigop: [],
fullrbf, fullrbf: rbf,
score, score,
similarity, similarity,
}; };

View File

@ -3,10 +3,12 @@ import { IEsploraApi } from './esplora-api.interface';
export interface AbstractBitcoinApi { export interface AbstractBitcoinApi {
$getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]>; $getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]>;
$getRawTransaction(txId: string, skipConversion?: boolean, addPrevout?: boolean, lazyPrevouts?: boolean): Promise<IEsploraApi.Transaction>; $getRawTransaction(txId: string, skipConversion?: boolean, addPrevout?: boolean, lazyPrevouts?: boolean): Promise<IEsploraApi.Transaction>;
$getMempoolTransactions(lastTxid: string);
$getTransactionHex(txId: string): Promise<string>; $getTransactionHex(txId: string): Promise<string>;
$getBlockHeightTip(): Promise<number>; $getBlockHeightTip(): Promise<number>;
$getBlockHashTip(): Promise<string>; $getBlockHashTip(): Promise<string>;
$getTxIdsForBlock(hash: string): Promise<string[]>; $getTxIdsForBlock(hash: string): Promise<string[]>;
$getTxsForBlock(hash: string): Promise<IEsploraApi.Transaction[]>;
$getBlockHash(height: number): Promise<string>; $getBlockHash(height: number): Promise<string>;
$getBlockHeader(hash: string): Promise<string>; $getBlockHeader(hash: string): Promise<string>;
$getBlock(hash: string): Promise<IEsploraApi.Block>; $getBlock(hash: string): Promise<IEsploraApi.Block>;
@ -14,6 +16,8 @@ export interface AbstractBitcoinApi {
$getAddress(address: string): Promise<IEsploraApi.Address>; $getAddress(address: string): Promise<IEsploraApi.Address>;
$getAddressTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]>; $getAddressTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]>;
$getAddressPrefix(prefix: string): string[]; $getAddressPrefix(prefix: string): string[];
$getScriptHash(scripthash: string): Promise<IEsploraApi.ScriptHash>;
$getScriptHashTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]>;
$sendRawTransaction(rawTransaction: string): Promise<string>; $sendRawTransaction(rawTransaction: string): Promise<string>;
$getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend>; $getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend>;
$getOutspends(txId: string): Promise<IEsploraApi.Outspend[]>; $getOutspends(txId: string): Promise<IEsploraApi.Outspend[]>;

View File

@ -59,6 +59,10 @@ class BitcoinApi implements AbstractBitcoinApi {
}); });
} }
$getMempoolTransactions(lastTxid: string): Promise<IEsploraApi.Transaction[]> {
return Promise.resolve([]);
}
$getTransactionHex(txId: string): Promise<string> { $getTransactionHex(txId: string): Promise<string> {
return this.$getRawTransaction(txId, true) return this.$getRawTransaction(txId, true)
.then((tx) => tx.hex || ''); .then((tx) => tx.hex || '');
@ -77,6 +81,10 @@ class BitcoinApi implements AbstractBitcoinApi {
.then((rpcBlock: IBitcoinApi.Block) => rpcBlock.tx); .then((rpcBlock: IBitcoinApi.Block) => rpcBlock.tx);
} }
$getTxsForBlock(hash: string): Promise<IEsploraApi.Transaction[]> {
throw new Error('Method getTxsForBlock not supported by the Bitcoin RPC API.');
}
$getRawBlock(hash: string): Promise<Buffer> { $getRawBlock(hash: string): Promise<Buffer> {
return this.bitcoindClient.getBlock(hash, 0) return this.bitcoindClient.getBlock(hash, 0)
.then((raw: string) => Buffer.from(raw, "hex")); .then((raw: string) => Buffer.from(raw, "hex"));
@ -108,6 +116,14 @@ class BitcoinApi implements AbstractBitcoinApi {
throw new Error('Method getAddressTransactions not supported by the Bitcoin RPC API.'); throw new Error('Method getAddressTransactions not supported by the Bitcoin RPC API.');
} }
$getScriptHash(scripthash: string): Promise<IEsploraApi.ScriptHash> {
throw new Error('Method getScriptHash not supported by the Bitcoin RPC API.');
}
$getScriptHashTransactions(scripthash: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]> {
throw new Error('Method getScriptHashTransactions not supported by the Bitcoin RPC API.');
}
$getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]> { $getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]> {
return this.bitcoindClient.getRawMemPool(); return this.bitcoindClient.getRawMemPool();
} }

View File

@ -121,6 +121,8 @@ class BitcoinRoutes {
.get(config.MEMPOOL.API_URL_PREFIX + 'block-height/:height', this.getBlockHeight) .get(config.MEMPOOL.API_URL_PREFIX + 'block-height/:height', this.getBlockHeight)
.get(config.MEMPOOL.API_URL_PREFIX + 'address/:address', this.getAddress) .get(config.MEMPOOL.API_URL_PREFIX + 'address/:address', this.getAddress)
.get(config.MEMPOOL.API_URL_PREFIX + 'address/:address/txs', this.getAddressTransactions) .get(config.MEMPOOL.API_URL_PREFIX + 'address/:address/txs', this.getAddressTransactions)
.get(config.MEMPOOL.API_URL_PREFIX + 'scripthash/:scripthash', this.getScriptHash)
.get(config.MEMPOOL.API_URL_PREFIX + 'scripthash/:scripthash/txs', this.getScriptHashTransactions)
.get(config.MEMPOOL.API_URL_PREFIX + 'address-prefix/:prefix', this.getAddressPrefix) .get(config.MEMPOOL.API_URL_PREFIX + 'address-prefix/:prefix', this.getAddressPrefix)
; ;
} }
@ -567,6 +569,45 @@ class BitcoinRoutes {
} }
} }
private async getScriptHash(req: Request, res: Response) {
if (config.MEMPOOL.BACKEND === 'none') {
res.status(405).send('Address lookups cannot be used with bitcoind as backend.');
return;
}
try {
const addressData = await bitcoinApi.$getScriptHash(req.params.address);
res.json(addressData);
} catch (e) {
if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) {
return res.status(413).send(e instanceof Error ? e.message : e);
}
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async getScriptHashTransactions(req: Request, res: Response): Promise<void> {
if (config.MEMPOOL.BACKEND === 'none') {
res.status(405).send('Address lookups cannot be used with bitcoind as backend.');
return;
}
try {
let lastTxId: string = '';
if (req.query.after_txid && typeof req.query.after_txid === 'string') {
lastTxId = req.query.after_txid;
}
const transactions = await bitcoinApi.$getScriptHashTransactions(req.params.address, lastTxId);
res.json(transactions);
} catch (e) {
if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) {
res.status(413).send(e instanceof Error ? e.message : e);
return;
}
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async getAddressPrefix(req: Request, res: Response) { private async getAddressPrefix(req: Request, res: Response) {
try { try {
const blockHash = await bitcoinApi.$getAddressPrefix(req.params.prefix); const blockHash = await bitcoinApi.$getAddressPrefix(req.params.prefix);

View File

@ -126,6 +126,77 @@ class BitcoindElectrsApi extends BitcoinApi implements AbstractBitcoinApi {
} }
} }
async $getScriptHash(scripthash: string): Promise<IEsploraApi.ScriptHash> {
try {
const balance = await this.electrumClient.blockchainScripthash_getBalance(scripthash);
let history = memoryCache.get<IElectrumApi.ScriptHashHistory[]>('Scripthash_getHistory', scripthash);
if (!history) {
history = await this.electrumClient.blockchainScripthash_getHistory(scripthash);
memoryCache.set('Scripthash_getHistory', scripthash, history, 2);
}
const unconfirmed = history ? history.filter((h) => h.fee).length : 0;
return {
'scripthash': scripthash,
'chain_stats': {
'funded_txo_count': 0,
'funded_txo_sum': balance.confirmed ? balance.confirmed : 0,
'spent_txo_count': 0,
'spent_txo_sum': balance.confirmed < 0 ? balance.confirmed : 0,
'tx_count': (history?.length || 0) - unconfirmed,
},
'mempool_stats': {
'funded_txo_count': 0,
'funded_txo_sum': balance.unconfirmed > 0 ? balance.unconfirmed : 0,
'spent_txo_count': 0,
'spent_txo_sum': balance.unconfirmed < 0 ? -balance.unconfirmed : 0,
'tx_count': unconfirmed,
},
'electrum': true,
};
} catch (e: any) {
throw new Error(typeof e === 'string' ? e : e && e.message || e);
}
}
async $getScriptHashTransactions(scripthash: string, lastSeenTxId?: string): Promise<IEsploraApi.Transaction[]> {
try {
loadingIndicators.setProgress('address-' + scripthash, 0);
const transactions: IEsploraApi.Transaction[] = [];
let history = memoryCache.get<IElectrumApi.ScriptHashHistory[]>('Scripthash_getHistory', scripthash);
if (!history) {
history = await this.electrumClient.blockchainScripthash_getHistory(scripthash);
memoryCache.set('Scripthash_getHistory', scripthash, history, 2);
}
if (!history) {
throw new Error('failed to get scripthash history');
}
history.sort((a, b) => (b.height || 9999999) - (a.height || 9999999));
let startingIndex = 0;
if (lastSeenTxId) {
const pos = history.findIndex((historicalTx) => historicalTx.tx_hash === lastSeenTxId);
if (pos) {
startingIndex = pos + 1;
}
}
const endIndex = Math.min(startingIndex + 10, history.length);
for (let i = startingIndex; i < endIndex; i++) {
const tx = await this.$getRawTransaction(history[i].tx_hash, false, true);
transactions.push(tx);
loadingIndicators.setProgress('address-' + scripthash, (i + 1) / endIndex * 100);
}
return transactions;
} catch (e: any) {
loadingIndicators.setProgress('address-' + scripthash, 100);
throw new Error(typeof e === 'string' ? e : e && e.message || e);
}
}
private $getScriptHashBalance(scriptHash: string): Promise<IElectrumApi.ScriptHashBalance> { private $getScriptHashBalance(scriptHash: string): Promise<IElectrumApi.ScriptHashBalance> {
return this.electrumClient.blockchainScripthash_getBalance(this.encodeScriptHash(scriptHash)); return this.electrumClient.blockchainScripthash_getBalance(this.encodeScriptHash(scriptHash));
} }

View File

@ -99,6 +99,13 @@ export namespace IEsploraApi {
electrum?: boolean; electrum?: boolean;
} }
export interface ScriptHash {
scripthash: string;
chain_stats: ChainStats;
mempool_stats: MempoolStats;
electrum?: boolean;
}
export interface ChainStats { export interface ChainStats {
funded_txo_count: number; funded_txo_count: number;
funded_txo_sum: number; funded_txo_sum: number;

View File

@ -69,6 +69,10 @@ class ElectrsApi implements AbstractBitcoinApi {
return this.$queryWrapper<IEsploraApi.Transaction>(config.ESPLORA.REST_API_URL + '/tx/' + txId); return this.$queryWrapper<IEsploraApi.Transaction>(config.ESPLORA.REST_API_URL + '/tx/' + txId);
} }
async $getMempoolTransactions(lastSeenTxid?: string): Promise<IEsploraApi.Transaction[]> {
return this.$queryWrapper<IEsploraApi.Transaction[]>(config.ESPLORA.REST_API_URL + '/mempool/txs' + (lastSeenTxid ? '/' + lastSeenTxid : ''));
}
$getTransactionHex(txId: string): Promise<string> { $getTransactionHex(txId: string): Promise<string> {
return this.$queryWrapper<string>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/hex'); return this.$queryWrapper<string>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/hex');
} }
@ -85,6 +89,10 @@ class ElectrsApi implements AbstractBitcoinApi {
return this.$queryWrapper<string[]>(config.ESPLORA.REST_API_URL + '/block/' + hash + '/txids'); return this.$queryWrapper<string[]>(config.ESPLORA.REST_API_URL + '/block/' + hash + '/txids');
} }
$getTxsForBlock(hash: string): Promise<IEsploraApi.Transaction[]> {
return this.$queryWrapper<IEsploraApi.Transaction[]>(config.ESPLORA.REST_API_URL + '/block/' + hash + '/txs');
}
$getBlockHash(height: number): Promise<string> { $getBlockHash(height: number): Promise<string> {
return this.$queryWrapper<string>(config.ESPLORA.REST_API_URL + '/block-height/' + height); return this.$queryWrapper<string>(config.ESPLORA.REST_API_URL + '/block-height/' + height);
} }
@ -110,6 +118,14 @@ class ElectrsApi implements AbstractBitcoinApi {
throw new Error('Method getAddressTransactions not implemented.'); throw new Error('Method getAddressTransactions not implemented.');
} }
$getScriptHash(scripthash: string): Promise<IEsploraApi.ScriptHash> {
throw new Error('Method getScriptHash not implemented.');
}
$getScriptHashTransactions(scripthash: string, txId?: string): Promise<IEsploraApi.Transaction[]> {
throw new Error('Method getScriptHashTransactions not implemented.');
}
$getAddressPrefix(prefix: string): string[] { $getAddressPrefix(prefix: string): string[] {
throw new Error('Method not implemented.'); throw new Error('Method not implemented.');
} }

View File

@ -70,6 +70,9 @@ class Blocks {
* @param blockHash * @param blockHash
* @param blockHeight * @param blockHeight
* @param onlyCoinbase - Set to true if you only need the coinbase transaction * @param onlyCoinbase - Set to true if you only need the coinbase transaction
* @param txIds - optional ordered list of transaction ids if already known
* @param quiet - don't print non-essential logs
* @param addMempoolData - calculate sigops etc
* @returns Promise<TransactionExtended[]> * @returns Promise<TransactionExtended[]>
*/ */
private async $getTransactionsExtended( private async $getTransactionsExtended(
@ -80,62 +83,77 @@ class Blocks {
quiet: boolean = false, quiet: boolean = false,
addMempoolData: boolean = false, addMempoolData: boolean = false,
): Promise<TransactionExtended[]> { ): Promise<TransactionExtended[]> {
const transactions: TransactionExtended[] = []; const isEsplora = config.MEMPOOL.BACKEND === 'esplora';
const transactionMap: { [txid: string]: TransactionExtended } = {};
if (!txIds) { if (!txIds) {
txIds = await bitcoinApi.$getTxIdsForBlock(blockHash); txIds = await bitcoinApi.$getTxIdsForBlock(blockHash);
} }
const mempool = memPool.getMempool(); const mempool = memPool.getMempool();
let transactionsFound = 0; let foundInMempool = 0;
let transactionsFetched = 0; let totalFound = 0;
for (let i = 0; i < txIds.length; i++) { // Copy existing transactions from the mempool
if (mempool[txIds[i]]) { if (!onlyCoinbase) {
// We update blocks before the mempool (index.ts), therefore we can for (const txid of txIds) {
// optimize here by directly fetching txs in the "outdated" mempool if (mempool[txid]) {
transactions.push(mempool[txIds[i]]); transactionMap[txid] = mempool[txid];
transactionsFound++; foundInMempool++;
} else if (config.MEMPOOL.BACKEND === 'esplora' || !memPool.hasPriority() || i === 0) { totalFound++;
// Otherwise we fetch the tx data through backend services (esplora, electrum, core rpc...)
if (!quiet && (i % (Math.round((txIds.length) / 10)) === 0 || i + 1 === txIds.length)) { // Avoid log spam
logger.debug(`Indexing tx ${i + 1} of ${txIds.length} in block #${blockHeight}`);
}
try {
const tx = await transactionUtils.$getTransactionExtended(txIds[i], false, false, false, addMempoolData);
transactions.push(tx);
transactionsFetched++;
} catch (e) {
try {
if (config.MEMPOOL.BACKEND === 'esplora') {
// Try again with core
const tx = await transactionUtils.$getTransactionExtended(txIds[i], false, false, true, addMempoolData);
transactions.push(tx);
transactionsFetched++;
} else {
throw e;
}
} catch (e) {
if (i === 0) {
const msg = `Cannot fetch coinbase tx ${txIds[i]}. Reason: ` + (e instanceof Error ? e.message : e);
logger.err(msg);
throw new Error(msg);
} else {
logger.err(`Cannot fetch tx ${txIds[i]}. Reason: ` + (e instanceof Error ? e.message : e));
}
}
} }
} }
}
if (onlyCoinbase === true) { // Skip expensive lookups while mempool has priority
break; // Fetch the first transaction and exit if (onlyCoinbase) {
try {
const coinbase = await transactionUtils.$getTransactionExtended(txIds[0], false, false, false, addMempoolData);
return [coinbase];
} catch (e) {
const msg = `Cannot fetch coinbase tx ${txIds[0]}. Reason: ` + (e instanceof Error ? e.message : e);
logger.err(msg);
throw new Error(msg);
}
}
// Fetch remaining txs in bulk
if (isEsplora && (txIds.length - totalFound > 500)) {
try {
const rawTransactions = await bitcoinApi.$getTxsForBlock(blockHash);
for (const tx of rawTransactions) {
if (!transactionMap[tx.txid]) {
transactionMap[tx.txid] = addMempoolData ? transactionUtils.extendMempoolTransaction(tx) : transactionUtils.extendTransaction(tx);
totalFound++;
}
}
} catch (e) {
logger.err(`Cannot fetch bulk txs for block ${blockHash}. Reason: ` + (e instanceof Error ? e.message : e));
}
}
// Fetch remaining txs individually
for (const txid of txIds.filter(txid => !transactionMap[txid])) {
if (!transactionMap[txid]) {
if (!quiet && (totalFound % (Math.round((txIds.length) / 10)) === 0 || totalFound + 1 === txIds.length)) { // Avoid log spam
logger.debug(`Indexing tx ${totalFound + 1} of ${txIds.length} in block #${blockHeight}`);
}
try {
const tx = await transactionUtils.$getTransactionExtended(txid, false, false, false, addMempoolData);
transactionMap[txid] = tx;
totalFound++;
} catch (e) {
logger.err(`Cannot fetch tx ${txid}. Reason: ` + (e instanceof Error ? e.message : e));
}
} }
} }
if (!quiet) { if (!quiet) {
logger.debug(`${transactionsFound} of ${txIds.length} found in mempool. ${transactionsFetched} fetched through backend service.`); logger.debug(`${foundInMempool} of ${txIds.length} found in mempool. ${totalFound - foundInMempool} fetched through backend service.`);
} }
return transactions; // Return list of transactions, preserving block order
return txIds.map(txid => transactionMap[txid]).filter(tx => tx != null);
} }
/** /**

View File

@ -80,7 +80,7 @@ class ChannelsApi {
public async $searchChannelsById(search: string): Promise<any[]> { public async $searchChannelsById(search: string): Promise<any[]> {
try { try {
const searchStripped = search.replace('%', '') + '%'; const searchStripped = search.replace(/[^0-9x]/g, '') + '%';
const query = `SELECT id, short_id, capacity, status FROM channels WHERE id LIKE ? OR short_id LIKE ? LIMIT 10`; const query = `SELECT id, short_id, capacity, status FROM channels WHERE id LIKE ? OR short_id LIKE ? LIMIT 10`;
const [rows]: any = await DB.query(query, [searchStripped, searchStripped]); const [rows]: any = await DB.query(query, [searchStripped, searchStripped]);
return rows; return rows;

View File

@ -217,7 +217,7 @@ async function buildFullChannel(clChannelA: any, clChannelB: any): Promise<ILigh
return { return {
channel_id: Common.channelShortIdToIntegerId(clChannelA.short_channel_id), channel_id: Common.channelShortIdToIntegerId(clChannelA.short_channel_id),
capacity: clChannelA.satoshis, capacity: (clChannelA.amount_msat / 1000).toString(),
last_update: lastUpdate, last_update: lastUpdate,
node1_policy: convertPolicy(clChannelA), node1_policy: convertPolicy(clChannelA),
node2_policy: convertPolicy(clChannelB), node2_policy: convertPolicy(clChannelB),
@ -241,7 +241,7 @@ async function buildIncompleteChannel(clChannel: any): Promise<ILightningApi.Cha
return { return {
channel_id: Common.channelShortIdToIntegerId(clChannel.short_channel_id), channel_id: Common.channelShortIdToIntegerId(clChannel.short_channel_id),
capacity: clChannel.satoshis, capacity: (clChannel.amount_msat / 1000).toString(),
last_update: clChannel.last_update ?? 0, last_update: clChannel.last_update ?? 0,
node1_policy: convertPolicy(clChannel), node1_policy: convertPolicy(clChannel),
node2_policy: null, node2_policy: null,
@ -257,8 +257,8 @@ async function buildIncompleteChannel(clChannel: any): Promise<ILightningApi.Cha
function convertPolicy(clChannel: any): ILightningApi.RoutingPolicy { function convertPolicy(clChannel: any): ILightningApi.RoutingPolicy {
return { return {
time_lock_delta: clChannel.delay, time_lock_delta: clChannel.delay,
min_htlc: clChannel.htlc_minimum_msat.slice(0, -4), min_htlc: clChannel.htlc_minimum_msat.toString(),
max_htlc_msat: clChannel.htlc_maximum_msat.slice(0, -4), max_htlc_msat: clChannel.htlc_maximum_msat.toString(),
fee_base_msat: clChannel.base_fee_millisatoshi, fee_base_msat: clChannel.base_fee_millisatoshi,
fee_rate_milli_msat: clChannel.fee_per_millionth, fee_rate_milli_msat: clChannel.fee_per_millionth,
disabled: !clChannel.active, disabled: !clChannel.active,

View File

@ -1,5 +1,5 @@
import config from '../config'; import config from '../config';
import bitcoinApi from './bitcoin/bitcoin-api-factory'; import bitcoinApi, { bitcoinCoreApi } from './bitcoin/bitcoin-api-factory';
import { MempoolTransactionExtended, TransactionExtended, VbytesPerSecond } from '../mempool.interfaces'; import { MempoolTransactionExtended, TransactionExtended, VbytesPerSecond } from '../mempool.interfaces';
import logger from '../logger'; import logger from '../logger';
import { Common } from './common'; import { Common } from './common';
@ -9,6 +9,7 @@ import loadingIndicators from './loading-indicators';
import bitcoinClient from './bitcoin/bitcoin-client'; import bitcoinClient from './bitcoin/bitcoin-client';
import bitcoinSecondClient from './bitcoin/bitcoin-second-client'; import bitcoinSecondClient from './bitcoin/bitcoin-second-client';
import rbfCache from './rbf-cache'; import rbfCache from './rbf-cache';
import { IEsploraApi } from './bitcoin/esplora-api.interface';
class Mempool { class Mempool {
private inSync: boolean = false; private inSync: boolean = false;
@ -86,7 +87,7 @@ class Mempool {
this.mempoolCache = mempoolData; this.mempoolCache = mempoolData;
let count = 0; let count = 0;
for (const txid of Object.keys(this.mempoolCache)) { for (const txid of Object.keys(this.mempoolCache)) {
if (this.mempoolCache[txid].sigops == null || this.mempoolCache[txid].effectiveFeePerVsize == null) { if (!this.mempoolCache[txid].sigops || this.mempoolCache[txid].effectiveFeePerVsize == null) {
this.mempoolCache[txid] = transactionUtils.extendMempoolTransaction(this.mempoolCache[txid]); this.mempoolCache[txid] = transactionUtils.extendMempoolTransaction(this.mempoolCache[txid]);
} }
if (this.mempoolCache[txid].order == null) { if (this.mempoolCache[txid].order == null) {
@ -103,6 +104,44 @@ class Mempool {
this.addToSpendMap(Object.values(this.mempoolCache)); this.addToSpendMap(Object.values(this.mempoolCache));
} }
public async $reloadMempool(expectedCount: number): Promise<MempoolTransactionExtended[]> {
let count = 0;
let done = false;
let last_txid;
const newTransactions: MempoolTransactionExtended[] = [];
loadingIndicators.setProgress('mempool', count / expectedCount * 100);
while (!done) {
try {
const result = await bitcoinApi.$getMempoolTransactions(last_txid);
if (result) {
for (const tx of result) {
const extendedTransaction = transactionUtils.extendMempoolTransaction(tx);
if (!this.mempoolCache[extendedTransaction.txid]) {
newTransactions.push(extendedTransaction);
this.mempoolCache[extendedTransaction.txid] = extendedTransaction;
}
count++;
}
logger.info(`Fetched ${count} of ${expectedCount} mempool transactions from esplora`);
if (result.length > 0) {
last_txid = result[result.length - 1].txid;
} else {
done = true;
}
if (Math.floor((count / expectedCount) * 100) < 100) {
loadingIndicators.setProgress('mempool', count / expectedCount * 100);
}
} else {
done = true;
}
} catch(err) {
logger.err('failed to fetch bulk mempool transactions from esplora');
}
}
return newTransactions;
logger.info(`Done inserting loaded mempool transactions into local cache`);
}
public async $updateMemPoolInfo() { public async $updateMemPoolInfo() {
this.mempoolInfo = await this.$getMempoolInfo(); this.mempoolInfo = await this.$getMempoolInfo();
} }
@ -143,7 +182,7 @@ class Mempool {
const currentMempoolSize = Object.keys(this.mempoolCache).length; const currentMempoolSize = Object.keys(this.mempoolCache).length;
this.updateTimerProgress(timer, 'got raw mempool'); this.updateTimerProgress(timer, 'got raw mempool');
const diff = transactions.length - currentMempoolSize; const diff = transactions.length - currentMempoolSize;
const newTransactions: MempoolTransactionExtended[] = []; let newTransactions: MempoolTransactionExtended[] = [];
this.mempoolCacheDelta = Math.abs(diff); this.mempoolCacheDelta = Math.abs(diff);
@ -162,41 +201,57 @@ class Mempool {
}; };
let intervalTimer = Date.now(); let intervalTimer = Date.now();
for (const txid of transactions) {
if (!this.mempoolCache[txid]) {
try {
const transaction = await transactionUtils.$getMempoolTransactionExtended(txid, false, false, false);
this.updateTimerProgress(timer, 'fetched new transaction');
this.mempoolCache[txid] = transaction;
if (this.inSync) {
this.txPerSecondArray.push(new Date().getTime());
this.vBytesPerSecondArray.push({
unixTime: new Date().getTime(),
vSize: transaction.vsize,
});
}
hasChange = true;
newTransactions.push(transaction);
} catch (e: any) {
if (config.MEMPOOL.BACKEND === 'esplora' && e.response?.status === 404) {
this.missingTxCount++;
}
logger.debug(`Error finding transaction '${txid}' in the mempool: ` + (e instanceof Error ? e.message : e));
}
}
if (Date.now() - intervalTimer > 5_000) { let loaded = false;
if (config.MEMPOOL.BACKEND === 'esplora' && currentMempoolSize < transactions.length * 0.5 && transactions.length > 20_000) {
if (this.inSync) { this.inSync = false;
// Break and restart mempool loop if we spend too much time processing logger.info(`Missing ${transactions.length - currentMempoolSize} mempool transactions, attempting to reload in bulk from esplora`);
// new transactions that may lead to falling behind on block height try {
logger.debug('Breaking mempool loop because the 5s time limit exceeded.'); newTransactions = await this.$reloadMempool(transactions.length);
break; loaded = true;
} else { } catch (e) {
const progress = (currentMempoolSize + newTransactions.length) / transactions.length * 100; logger.err('failed to load mempool in bulk from esplora, falling back to fetching individual transactions');
logger.debug(`Mempool is synchronizing. Processed ${newTransactions.length}/${diff} txs (${Math.round(progress)}%)`); }
loadingIndicators.setProgress('mempool', progress); }
intervalTimer = Date.now()
if (!loaded) {
for (const txid of transactions) {
if (!this.mempoolCache[txid]) {
try {
const transaction = await transactionUtils.$getMempoolTransactionExtended(txid, false, false, false);
this.updateTimerProgress(timer, 'fetched new transaction');
this.mempoolCache[txid] = transaction;
if (this.inSync) {
this.txPerSecondArray.push(new Date().getTime());
this.vBytesPerSecondArray.push({
unixTime: new Date().getTime(),
vSize: transaction.vsize,
});
}
hasChange = true;
newTransactions.push(transaction);
} catch (e: any) {
if (config.MEMPOOL.BACKEND === 'esplora' && e.response?.status === 404) {
this.missingTxCount++;
}
logger.debug(`Error finding transaction '${txid}' in the mempool: ` + (e instanceof Error ? e.message : e));
}
}
if (Date.now() - intervalTimer > 5_000) {
if (this.inSync) {
// Break and restart mempool loop if we spend too much time processing
// new transactions that may lead to falling behind on block height
logger.debug('Breaking mempool loop because the 5s time limit exceeded.');
break;
} else {
const progress = (currentMempoolSize + newTransactions.length) / transactions.length * 100;
logger.debug(`Mempool is synchronizing. Processed ${newTransactions.length}/${diff} txs (${Math.round(progress)}%)`);
if (Math.floor(progress) < 100) {
loadingIndicators.setProgress('mempool', progress);
}
intervalTimer = Date.now()
}
} }
} }
} }
@ -246,12 +301,6 @@ class Mempool {
const newTransactionsStripped = newTransactions.map((tx) => Common.stripTransaction(tx)); const newTransactionsStripped = newTransactions.map((tx) => Common.stripTransaction(tx));
this.latestTransactions = newTransactionsStripped.concat(this.latestTransactions).slice(0, 6); this.latestTransactions = newTransactionsStripped.concat(this.latestTransactions).slice(0, 6);
if (!this.inSync && transactions.length === newMempoolSize) {
this.inSync = true;
logger.notice('The mempool is now in sync!');
loadingIndicators.setProgress('mempool', 100);
}
this.mempoolCacheDelta = Math.abs(transactions.length - newMempoolSize); this.mempoolCacheDelta = Math.abs(transactions.length - newMempoolSize);
if (this.mempoolChangedCallback && (hasChange || deletedTransactions.length)) { if (this.mempoolChangedCallback && (hasChange || deletedTransactions.length)) {
@ -263,6 +312,12 @@ class Mempool {
this.updateTimerProgress(timer, 'completed async mempool callback'); this.updateTimerProgress(timer, 'completed async mempool callback');
} }
if (!this.inSync && transactions.length === newMempoolSize) {
this.inSync = true;
logger.notice('The mempool is now in sync!');
loadingIndicators.setProgress('mempool', 100);
}
const end = new Date().getTime(); const end = new Date().getTime();
const time = end - start; const time = end - start;
logger.debug(`Mempool updated in ${time / 1000} seconds. New size: ${Object.keys(this.mempoolCache).length} (${diff > 0 ? '+' + diff : diff})`); logger.debug(`Mempool updated in ${time / 1000} seconds. New size: ${Object.keys(this.mempoolCache).length} (${diff > 0 ? '+' + diff : diff})`);

View File

@ -100,6 +100,24 @@ class RbfCache {
this.dirtyTrees.add(treeId); this.dirtyTrees.add(treeId);
} }
public has(txId: string): boolean {
return this.txs.has(txId);
}
public anyInSameTree(txId: string, predicate: (tx: RbfTransaction) => boolean): boolean {
const tree = this.getRbfTree(txId);
if (!tree) {
return false;
}
const txs = this.getTransactionsInTree(tree);
for (const tx of txs) {
if (predicate(tx)) {
return true;
}
}
return false;
}
public getReplacedBy(txId: string): string | undefined { public getReplacedBy(txId: string): string | undefined {
return this.replacedBy.get(txId); return this.replacedBy.get(txId);
} }

View File

@ -53,7 +53,7 @@ class TransactionUtils {
return (await this.$getTransactionExtended(txId, addPrevouts, lazyPrevouts, forceCore, true)) as MempoolTransactionExtended; return (await this.$getTransactionExtended(txId, addPrevouts, lazyPrevouts, forceCore, true)) as MempoolTransactionExtended;
} }
private extendTransaction(transaction: IEsploraApi.Transaction): TransactionExtended { public extendTransaction(transaction: IEsploraApi.Transaction): TransactionExtended {
// @ts-ignore // @ts-ignore
if (transaction.vsize) { if (transaction.vsize) {
// @ts-ignore // @ts-ignore
@ -74,7 +74,7 @@ class TransactionUtils {
public extendMempoolTransaction(transaction: IEsploraApi.Transaction): MempoolTransactionExtended { public extendMempoolTransaction(transaction: IEsploraApi.Transaction): MempoolTransactionExtended {
const vsize = Math.ceil(transaction.weight / 4); const vsize = Math.ceil(transaction.weight / 4);
const fractionalVsize = (transaction.weight / 4); const fractionalVsize = (transaction.weight / 4);
const sigops = Common.isLiquid() ? this.countSigops(transaction) : 0; const sigops = !Common.isLiquid() ? this.countSigops(transaction) : 0;
// https://github.com/bitcoin/bitcoin/blob/e9262ea32a6e1d364fb7974844fadc36f931f8c6/src/policy/policy.cpp#L295-L298 // https://github.com/bitcoin/bitcoin/blob/e9262ea32a6e1d364fb7974844fadc36f931f8c6/src/policy/policy.cpp#L295-L298
const adjustedVsize = Math.max(fractionalVsize, sigops * 5); // adjusted vsize = Max(weight, sigops * bytes_per_sigop) / witness_scale_factor const adjustedVsize = Math.max(fractionalVsize, sigops * 5); // adjusted vsize = Max(weight, sigops * bytes_per_sigop) / witness_scale_factor
const feePerVbytes = (transaction.fee || 0) / fractionalVsize; const feePerVbytes = (transaction.fee || 0) / fractionalVsize;

View File

@ -604,7 +604,7 @@ class WebsocketHandler {
} }
} }
if (client['track-mempool-block'] >= 0) { if (client['track-mempool-block'] >= 0 && memPool.isInSync()) {
const index = client['track-mempool-block']; const index = client['track-mempool-block'];
if (mBlockDeltas[index]) { if (mBlockDeltas[index]) {
response['projected-block-transactions'] = getCachedResponse(`projected-block-transactions-${index}`, { response['projected-block-transactions'] = getCachedResponse(`projected-block-transactions-${index}`, {
@ -644,7 +644,7 @@ class WebsocketHandler {
memPool.handleMinedRbfTransactions(rbfTransactions); memPool.handleMinedRbfTransactions(rbfTransactions);
memPool.removeFromSpendMap(transactions); memPool.removeFromSpendMap(transactions);
if (config.MEMPOOL.AUDIT) { if (config.MEMPOOL.AUDIT && memPool.isInSync()) {
let projectedBlocks; let projectedBlocks;
let auditMempool = _memPool; let auditMempool = _memPool;
// template calculation functions have mempool side effects, so calculate audits using // template calculation functions have mempool side effects, so calculate audits using
@ -665,7 +665,7 @@ class WebsocketHandler {
projectedBlocks = mempoolBlocks.getMempoolBlocksWithTransactions(); projectedBlocks = mempoolBlocks.getMempoolBlocksWithTransactions();
} }
if (Common.indexingEnabled() && memPool.isInSync()) { if (Common.indexingEnabled()) {
const { censored, added, fresh, sigop, fullrbf, score, similarity } = Audit.auditBlock(transactions, projectedBlocks, auditMempool); const { censored, added, fresh, sigop, fullrbf, score, similarity } = Audit.auditBlock(transactions, projectedBlocks, auditMempool);
const matchRate = Math.round(score * 100 * 100) / 100; const matchRate = Math.round(score * 100 * 100) / 100;
@ -858,7 +858,7 @@ class WebsocketHandler {
} }
} }
if (client['track-mempool-block'] >= 0) { if (client['track-mempool-block'] >= 0 && memPool.isInSync()) {
const index = client['track-mempool-block']; const index = client['track-mempool-block'];
if (mBlockDeltas && mBlockDeltas[index]) { if (mBlockDeltas && mBlockDeltas[index]) {
response['projected-block-transactions'] = getCachedResponse(`projected-block-transactions-${index}`, { response['projected-block-transactions'] = getCachedResponse(`projected-block-transactions-${index}`, {

View File

@ -2,11 +2,15 @@
# For additional information regarding the format and rule options, please see: # For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries # https://github.com/browserslist/browserslist#queries
# For the full list of supported browsers by the Angular framework, please see:
# https://angular.io/guide/browser-support
# You can see what browsers were selected by your queries by running: # You can see what browsers were selected by your queries by running:
# npx browserslist # npx browserslist
> 0.5% last 2 Chrome versions
last 2 versions last 1 Firefox version
last 2 Edge major versions
last 2 Safari major versions
last 2 iOS major versions
Firefox ESR Firefox ESR
not dead
not IE 9-11 # For IE 9-11 support, remove 'not'.

View File

@ -281,3 +281,15 @@ export function isFeatureActive(network: string, height: number, feature: 'rbf'
return false; return false;
} }
} }
export async function calcScriptHash$(script: string): Promise<string> {
if (!/^[0-9a-fA-F]*$/.test(script) || script.length % 2 !== 0) {
throw new Error('script is not a valid hex string');
}
const buf = Uint8Array.from(script.match(/.{2}/g).map((byte) => parseInt(byte, 16)));
const hashBuffer = await crypto.subtle.digest('SHA-256', buf);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray
.map((bytes) => bytes.toString(16).padStart(2, '0'))
.join('');
}

View File

@ -64,13 +64,15 @@ export class AddressPreviewComponent implements OnInit, OnDestroy {
this.address = null; this.address = null;
this.addressInfo = null; this.addressInfo = null;
this.addressString = params.get('id') || ''; this.addressString = params.get('id') || '';
if (/^[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100}$/.test(this.addressString)) { if (/^[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100}|[A-F0-9]{130}$/.test(this.addressString)) {
this.addressString = this.addressString.toLowerCase(); this.addressString = this.addressString.toLowerCase();
} }
this.seoService.setTitle($localize`:@@address.component.browser-title:Address: ${this.addressString}:INTERPOLATION:`); this.seoService.setTitle($localize`:@@address.component.browser-title:Address: ${this.addressString}:INTERPOLATION:`);
return this.electrsApiService.getAddress$(this.addressString) return (this.addressString.match(/[a-f0-9]{130}/)
.pipe( ? this.electrsApiService.getPubKeyAddress$(this.addressString)
: this.electrsApiService.getAddress$(this.addressString)
).pipe(
catchError((err) => { catchError((err) => {
this.isLoadingAddress = false; this.isLoadingAddress = false;
this.error = err; this.error = err;

View File

@ -81,6 +81,7 @@ h1 {
top: 11px; top: 11px;
} }
@media (min-width: 768px) { @media (min-width: 768px) {
max-width: calc(100% - 180px);
top: 17px; top: 17px;
} }
} }

View File

@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
import { ActivatedRoute, ParamMap } from '@angular/router'; import { ActivatedRoute, ParamMap } from '@angular/router';
import { ElectrsApiService } from '../../services/electrs-api.service'; import { ElectrsApiService } from '../../services/electrs-api.service';
import { switchMap, filter, catchError, map, tap } from 'rxjs/operators'; import { switchMap, filter, catchError, map, tap } from 'rxjs/operators';
import { Address, Transaction } from '../../interfaces/electrs.interface'; import { Address, ScriptHash, Transaction } from '../../interfaces/electrs.interface';
import { WebsocketService } from '../../services/websocket.service'; import { WebsocketService } from '../../services/websocket.service';
import { StateService } from '../../services/state.service'; import { StateService } from '../../services/state.service';
import { AudioService } from '../../services/audio.service'; import { AudioService } from '../../services/audio.service';
@ -72,7 +72,7 @@ export class AddressComponent implements OnInit, OnDestroy {
this.addressInfo = null; this.addressInfo = null;
document.body.scrollTo(0, 0); document.body.scrollTo(0, 0);
this.addressString = params.get('id') || ''; this.addressString = params.get('id') || '';
if (/^[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100}$/.test(this.addressString)) { if (/^[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100}|[A-F0-9]{130}$/.test(this.addressString)) {
this.addressString = this.addressString.toLowerCase(); this.addressString = this.addressString.toLowerCase();
} }
this.seoService.setTitle($localize`:@@address.component.browser-title:Address: ${this.addressString}:INTERPOLATION:`); this.seoService.setTitle($localize`:@@address.component.browser-title:Address: ${this.addressString}:INTERPOLATION:`);
@ -83,8 +83,11 @@ export class AddressComponent implements OnInit, OnDestroy {
.pipe(filter((state) => state === 2 && this.transactions && this.transactions.length > 0)) .pipe(filter((state) => state === 2 && this.transactions && this.transactions.length > 0))
) )
.pipe( .pipe(
switchMap(() => this.electrsApiService.getAddress$(this.addressString) switchMap(() => (
.pipe( this.addressString.match(/[a-f0-9]{130}/)
? this.electrsApiService.getPubKeyAddress$(this.addressString)
: this.electrsApiService.getAddress$(this.addressString)
).pipe(
catchError((err) => { catchError((err) => {
this.isLoadingAddress = false; this.isLoadingAddress = false;
this.error = err; this.error = err;
@ -114,7 +117,9 @@ export class AddressComponent implements OnInit, OnDestroy {
this.updateChainStats(); this.updateChainStats();
this.isLoadingAddress = false; this.isLoadingAddress = false;
this.isLoadingTransactions = true; this.isLoadingTransactions = true;
return this.electrsApiService.getAddressTransactions$(address.address); return address.is_pubkey
? this.electrsApiService.getScriptHashTransactions$('41' + address.address + 'ac')
: this.electrsApiService.getAddressTransactions$(address.address);
}), }),
switchMap((transactions) => { switchMap((transactions) => {
this.tempTransactions = transactions; this.tempTransactions = transactions;

View File

@ -38,7 +38,7 @@ export default class TxView implements TransactionStripped {
value: number; value: number;
feerate: number; feerate: number;
rate?: number; rate?: number;
status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'freshcpfp' | 'added' | 'censored' | 'selected' | 'fullrbf'; status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'freshcpfp' | 'added' | 'censored' | 'selected' | 'rbf';
context?: 'projected' | 'actual'; context?: 'projected' | 'actual';
scene?: BlockScene; scene?: BlockScene;
@ -207,7 +207,7 @@ export default class TxView implements TransactionStripped {
return auditColors.censored; return auditColors.censored;
case 'missing': case 'missing':
case 'sigop': case 'sigop':
case 'fullrbf': case 'rbf':
return marginalFeeColors[feeLevelIndex] || marginalFeeColors[mempoolFeeColors.length - 1]; return marginalFeeColors[feeLevelIndex] || marginalFeeColors[mempoolFeeColors.length - 1];
case 'fresh': case 'fresh':
case 'freshcpfp': case 'freshcpfp':

View File

@ -53,7 +53,7 @@
<td *ngSwitchCase="'freshcpfp'"><span class="badge badge-warning" i18n="transaction.audit.recently-cpfped">Recently CPFP'd</span></td> <td *ngSwitchCase="'freshcpfp'"><span class="badge badge-warning" i18n="transaction.audit.recently-cpfped">Recently CPFP'd</span></td>
<td *ngSwitchCase="'added'"><span class="badge badge-warning" i18n="transaction.audit.added">Added</span></td> <td *ngSwitchCase="'added'"><span class="badge badge-warning" i18n="transaction.audit.added">Added</span></td>
<td *ngSwitchCase="'selected'"><span class="badge badge-warning" i18n="transaction.audit.marginal">Marginal fee rate</span></td> <td *ngSwitchCase="'selected'"><span class="badge badge-warning" i18n="transaction.audit.marginal">Marginal fee rate</span></td>
<td *ngSwitchCase="'fullrbf'"><span class="badge badge-warning" i18n="transaction.audit.fullrbf">Full RBF</span></td> <td *ngSwitchCase="'rbf'"><span class="badge badge-warning" i18n="transaction.audit.conflicting">Conflicting</span></td>
</ng-container> </ng-container>
</tr> </tr>
</tbody> </tbody>

View File

@ -339,7 +339,7 @@ export class BlockComponent implements OnInit, OnDestroy {
const isSelected = {}; const isSelected = {};
const isFresh = {}; const isFresh = {};
const isSigop = {}; const isSigop = {};
const isFullRbf = {}; const isRbf = {};
this.numMissing = 0; this.numMissing = 0;
this.numUnexpected = 0; this.numUnexpected = 0;
@ -363,7 +363,7 @@ export class BlockComponent implements OnInit, OnDestroy {
isSigop[txid] = true; isSigop[txid] = true;
} }
for (const txid of blockAudit.fullrbfTxs || []) { for (const txid of blockAudit.fullrbfTxs || []) {
isFullRbf[txid] = true; isRbf[txid] = true;
} }
// set transaction statuses // set transaction statuses
for (const tx of blockAudit.template) { for (const tx of blockAudit.template) {
@ -381,8 +381,8 @@ export class BlockComponent implements OnInit, OnDestroy {
} }
} else if (isSigop[tx.txid]) { } else if (isSigop[tx.txid]) {
tx.status = 'sigop'; tx.status = 'sigop';
} else if (isFullRbf[tx.txid]) { } else if (isRbf[tx.txid]) {
tx.status = 'fullrbf'; tx.status = 'rbf';
} else { } else {
tx.status = 'missing'; tx.status = 'missing';
} }
@ -398,8 +398,8 @@ export class BlockComponent implements OnInit, OnDestroy {
tx.status = 'added'; tx.status = 'added';
} else if (inTemplate[tx.txid]) { } else if (inTemplate[tx.txid]) {
tx.status = 'found'; tx.status = 'found';
} else if (isFullRbf[tx.txid]) { } else if (isRbf[tx.txid]) {
tx.status = 'fullrbf'; tx.status = 'rbf';
} else { } else {
tx.status = 'selected'; tx.status = 'selected';
isSelected[tx.txid] = true; isSelected[tx.txid] = true;

View File

@ -50,6 +50,8 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy {
blockSubscription: Subscription; blockSubscription: Subscription;
networkSubscription: Subscription; networkSubscription: Subscription;
chainTipSubscription: Subscription; chainTipSubscription: Subscription;
keySubscription: Subscription;
isTabHiddenSubscription: Subscription;
network = ''; network = '';
now = new Date().getTime(); now = new Date().getTime();
timeOffset = 0; timeOffset = 0;
@ -116,8 +118,15 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy {
this.calculateTransactionPosition(); this.calculateTransactionPosition();
}); });
this.reduceMempoolBlocksToFitScreen(this.mempoolBlocks); this.reduceMempoolBlocksToFitScreen(this.mempoolBlocks);
this.stateService.isTabHidden$.subscribe((tabHidden) => this.tabHidden = tabHidden); this.isTabHiddenSubscription = this.stateService.isTabHidden$.subscribe((tabHidden) => this.tabHidden = tabHidden);
this.loadingBlocks$ = this.stateService.isLoadingWebSocket$; this.loadingBlocks$ = combineLatest([
this.stateService.isLoadingWebSocket$,
this.stateService.isLoadingMempool$
]).pipe(
switchMap(([loadingBlocks, loadingMempool]) => {
return of(loadingBlocks || loadingMempool);
})
);
this.mempoolBlocks$ = merge( this.mempoolBlocks$ = merge(
of(true), of(true),
@ -217,7 +226,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy {
this.networkSubscription = this.stateService.networkChanged$ this.networkSubscription = this.stateService.networkChanged$
.subscribe((network) => this.network = network); .subscribe((network) => this.network = network);
this.stateService.keyNavigation$.subscribe((event) => { this.keySubscription = this.stateService.keyNavigation$.subscribe((event) => {
if (this.markIndex === undefined) { if (this.markIndex === undefined) {
return; return;
} }
@ -228,13 +237,12 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy {
if (this.mempoolBlocks[this.markIndex - 1]) { if (this.mempoolBlocks[this.markIndex - 1]) {
this.router.navigate([this.relativeUrlPipe.transform('mempool-block/'), this.markIndex - 1]); this.router.navigate([this.relativeUrlPipe.transform('mempool-block/'), this.markIndex - 1]);
} else { } else {
this.stateService.blocks$ const blocks = this.stateService.blocksSubject$.getValue();
.pipe(map((blocks) => blocks[0])) for (const block of (blocks || [])) {
.subscribe((block) => { if (this.stateService.latestBlockHeight === block.height) {
if (this.stateService.latestBlockHeight === block.height) { this.router.navigate([this.relativeUrlPipe.transform('/block/'), block.id], { state: { data: { block } }});
this.router.navigate([this.relativeUrlPipe.transform('/block/'), block.id], { state: { data: { block } }}); }
} }
});
} }
} else if (event.key === nextKey) { } else if (event.key === nextKey) {
if (this.mempoolBlocks[this.markIndex + 1]) { if (this.mempoolBlocks[this.markIndex + 1]) {
@ -258,6 +266,8 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy {
this.networkSubscription.unsubscribe(); this.networkSubscription.unsubscribe();
this.timeLtrSubscription.unsubscribe(); this.timeLtrSubscription.unsubscribe();
this.chainTipSubscription.unsubscribe(); this.chainTipSubscription.unsubscribe();
this.keySubscription.unsubscribe();
this.isTabHiddenSubscription.unsubscribe();
clearTimeout(this.resetTransitionTimeout); clearTimeout(this.resetTransitionTimeout);
} }

View File

@ -1,6 +1,8 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { AfterViewInit, ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { SeoService } from '../../services/seo.service'; import { SeoService } from '../../services/seo.service';
import { WebsocketService } from '../../services/websocket.service'; import { WebsocketService } from '../../services/websocket.service';
import { StateService } from '../../services/state.service';
import { EventType, NavigationStart, Router } from '@angular/router';
@Component({ @Component({
selector: 'app-mining-dashboard', selector: 'app-mining-dashboard',
@ -8,10 +10,12 @@ import { WebsocketService } from '../../services/websocket.service';
styleUrls: ['./mining-dashboard.component.scss'], styleUrls: ['./mining-dashboard.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class MiningDashboardComponent implements OnInit { export class MiningDashboardComponent implements OnInit, AfterViewInit {
constructor( constructor(
private seoService: SeoService, private seoService: SeoService,
private websocketService: WebsocketService, private websocketService: WebsocketService,
private stateService: StateService,
private router: Router
) { ) {
this.seoService.setTitle($localize`:@@a681a4e2011bb28157689dbaa387de0dd0aa0c11:Mining Dashboard`); this.seoService.setTitle($localize`:@@a681a4e2011bb28157689dbaa387de0dd0aa0c11:Mining Dashboard`);
} }
@ -19,4 +23,15 @@ export class MiningDashboardComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.websocketService.want(['blocks', 'mempool-blocks', 'stats']); this.websocketService.want(['blocks', 'mempool-blocks', 'stats']);
} }
ngAfterViewInit(): void {
this.stateService.focusSearchInputDesktop();
this.router.events.subscribe((e: NavigationStart) => {
if (e.type === EventType.NavigationStart) {
if (e.url.indexOf('graphs') === -1) { // The mining dashboard and the graph component are part of the same module so we can't use ngAfterViewInit in graphs.component.ts to blur the input
this.stateService.focusSearchInputDesktop();
}
}
});
}
} }

View File

@ -1,7 +1,7 @@
<form [formGroup]="searchForm" (submit)="searchForm.valid && search()" novalidate> <form [formGroup]="searchForm" (submit)="searchForm.valid && search()" novalidate>
<div class="d-flex"> <div class="d-flex">
<div class="search-box-container mr-2"> <div class="search-box-container mr-2">
<input autofocus (focus)="focus$.next($any($event).target.value)" (click)="click$.next($any($event).target.value)" formControlName="searchText" type="text" class="form-control" i18n-placeholder="search-form.searchbar-placeholder" placeholder="Explore the full Bitcoin ecosystem"> <input #searchInput (focus)="focus$.next($any($event).target.value)" (click)="click$.next($any($event).target.value)" formControlName="searchText" type="text" class="form-control" i18n-placeholder="search-form.searchbar-placeholder" placeholder="Explore the full Bitcoin ecosystem">
<app-search-results #searchResults [hidden]="dropdownHidden" [results]="typeAhead$ | async" (selectedResult)="selectedResult($event)"></app-search-results> <app-search-results #searchResults [hidden]="dropdownHidden" [results]="typeAhead$ | async" (selectedResult)="selectedResult($event)"></app-search-results>
</div> </div>
<div> <div>

View File

@ -1,6 +1,6 @@
import { Component, OnInit, ChangeDetectionStrategy, EventEmitter, Output, ViewChild, HostListener, ElementRef } from '@angular/core'; import { Component, OnInit, ChangeDetectionStrategy, EventEmitter, Output, ViewChild, HostListener, ElementRef } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router'; import { EventType, NavigationStart, Router } from '@angular/router';
import { AssetsService } from '../../services/assets.service'; import { AssetsService } from '../../services/assets.service';
import { StateService } from '../../services/state.service'; import { StateService } from '../../services/state.service';
import { Observable, of, Subject, zip, BehaviorSubject, combineLatest } from 'rxjs'; import { Observable, of, Subject, zip, BehaviorSubject, combineLatest } from 'rxjs';
@ -34,7 +34,7 @@ export class SearchFormComponent implements OnInit {
} }
} }
regexAddress = /^([a-km-zA-HJ-NP-Z1-9]{26,35}|[a-km-zA-HJ-NP-Z1-9]{80}|[A-z]{2,5}1[a-zA-HJ-NP-Z0-9]{39,59})$/; regexAddress = /^([a-km-zA-HJ-NP-Z1-9]{26,35}|[a-km-zA-HJ-NP-Z1-9]{80}|[A-z]{2,5}1[a-zA-HJ-NP-Z0-9]{39,59}|[0-9a-fA-F]{130})$/;
regexBlockhash = /^[0]{8}[a-fA-F0-9]{56}$/; regexBlockhash = /^[0]{8}[a-fA-F0-9]{56}$/;
regexTransaction = /^([a-fA-F0-9]{64})(:\d+)?$/; regexTransaction = /^([a-fA-F0-9]{64})(:\d+)?$/;
regexBlockheight = /^[0-9]{1,9}$/; regexBlockheight = /^[0-9]{1,9}$/;
@ -47,6 +47,8 @@ export class SearchFormComponent implements OnInit {
this.handleKeyDown($event); this.handleKeyDown($event);
} }
@ViewChild('searchInput') searchInput: ElementRef;
constructor( constructor(
private formBuilder: UntypedFormBuilder, private formBuilder: UntypedFormBuilder,
private router: Router, private router: Router,
@ -55,11 +57,26 @@ export class SearchFormComponent implements OnInit {
private electrsApiService: ElectrsApiService, private electrsApiService: ElectrsApiService,
private apiService: ApiService, private apiService: ApiService,
private relativeUrlPipe: RelativeUrlPipe, private relativeUrlPipe: RelativeUrlPipe,
private elementRef: ElementRef, private elementRef: ElementRef
) { } ) {
}
ngOnInit(): void { ngOnInit(): void {
this.stateService.networkChanged$.subscribe((network) => this.network = network); this.stateService.networkChanged$.subscribe((network) => this.network = network);
this.router.events.subscribe((e: NavigationStart) => { // Reset search focus when changing page
if (this.searchInput && e.type === EventType.NavigationStart) {
this.searchInput.nativeElement.blur();
}
});
this.stateService.searchFocus$.subscribe(() => {
if (!this.searchInput) { // Try again a bit later once the view is properly initialized
setTimeout(() => this.searchInput.nativeElement.focus(), 100);
} else if (this.searchInput) {
this.searchInput.nativeElement.focus();
}
});
this.searchForm = this.formBuilder.group({ this.searchForm = this.formBuilder.group({
searchText: ['', Validators.required], searchText: ['', Validators.required],

View File

@ -23,7 +23,7 @@
<ng-template ngFor let-vin let-vindex="index" [ngForOf]="tx.vin.slice(0, getVinLimit(tx))" [ngForTrackBy]="trackByIndexFn"> <ng-template ngFor let-vin let-vindex="index" [ngForOf]="tx.vin.slice(0, getVinLimit(tx))" [ngForTrackBy]="trackByIndexFn">
<tr [ngClass]="{ <tr [ngClass]="{
'assetBox': (assetsMinimal && vin.prevout && assetsMinimal[vin.prevout.asset] && !vin.is_coinbase && vin.prevout.scriptpubkey_address && tx._unblinded) || inputIndex === vindex, 'assetBox': (assetsMinimal && vin.prevout && assetsMinimal[vin.prevout.asset] && !vin.is_coinbase && vin.prevout.scriptpubkey_address && tx._unblinded) || inputIndex === vindex,
'highlight': vin.prevout?.scriptpubkey_address === this.address && this.address !== '' 'highlight': this.address !== '' && (vin.prevout?.scriptpubkey_address === this.address || (vin.prevout?.scriptpubkey_type === 'p2pk' && vin.prevout?.scriptpubkey.slice(2, 132) === this.address))
}"> }">
<td class="arrow-td"> <td class="arrow-td">
<ng-template [ngIf]="vin.prevout === null && !vin.is_pegin" [ngIfElse]="hasPrevout"> <ng-template [ngIf]="vin.prevout === null && !vin.is_pegin" [ngIfElse]="hasPrevout">
@ -56,7 +56,9 @@
<span i18n="transactions-list.peg-in">Peg-in</span> <span i18n="transactions-list.peg-in">Peg-in</span>
</ng-container> </ng-container>
<ng-container *ngSwitchCase="vin.prevout && vin.prevout.scriptpubkey_type === 'p2pk'"> <ng-container *ngSwitchCase="vin.prevout && vin.prevout.scriptpubkey_type === 'p2pk'">
<span>P2PK</span> <span>P2PK <a class="address p2pk-address" [routerLink]="['/address/' | relativeUrl, vin.prevout.scriptpubkey.slice(2, 132)]" title="{{ vin.prevout.scriptpubkey.slice(2, 132) }}">
<app-truncate [text]="vin.prevout.scriptpubkey.slice(2, 132)" [lastChars]="8"></app-truncate>
</a></span>
</ng-container> </ng-container>
<ng-container *ngSwitchDefault> <ng-container *ngSwitchDefault>
<ng-template [ngIf]="!vin.prevout" [ngIfElse]="defaultAddress"> <ng-template [ngIf]="!vin.prevout" [ngIfElse]="defaultAddress">
@ -182,12 +184,19 @@
<ng-template ngFor let-vout let-vindex="index" [ngForOf]="tx.vout.slice(0, getVoutLimit(tx))" [ngForTrackBy]="trackByIndexFn"> <ng-template ngFor let-vout let-vindex="index" [ngForOf]="tx.vout.slice(0, getVoutLimit(tx))" [ngForTrackBy]="trackByIndexFn">
<tr [ngClass]="{ <tr [ngClass]="{
'assetBox': assetsMinimal && assetsMinimal[vout.asset] && vout.scriptpubkey_address && tx.vin && !tx.vin[0].is_coinbase && tx._unblinded || outputIndex === vindex, 'assetBox': assetsMinimal && assetsMinimal[vout.asset] && vout.scriptpubkey_address && tx.vin && !tx.vin[0].is_coinbase && tx._unblinded || outputIndex === vindex,
'highlight': vout.scriptpubkey_address === this.address && this.address !== '' 'highlight': this.address !== '' && (vout.scriptpubkey_address === this.address || (vout.scriptpubkey_type === 'p2pk' && vout.scriptpubkey.slice(2, 132) === this.address))
}"> }">
<td class="address-cell"> <td class="address-cell">
<a class="address" *ngIf="vout.scriptpubkey_address; else scriptpubkey_type" [routerLink]="['/address/' | relativeUrl, vout.scriptpubkey_address]" title="{{ vout.scriptpubkey_address }}"> <a class="address" *ngIf="vout.scriptpubkey_address; else pubkey_type" [routerLink]="['/address/' | relativeUrl, vout.scriptpubkey_address]" title="{{ vout.scriptpubkey_address }}">
<app-truncate [text]="vout.scriptpubkey_address" [lastChars]="8"></app-truncate> <app-truncate [text]="vout.scriptpubkey_address" [lastChars]="8"></app-truncate>
</a> </a>
<ng-template #pubkey_type>
<ng-container *ngIf="vout.scriptpubkey_type === 'p2pk'; else scriptpubkey_type">
P2PK <a class="address p2pk-address" [routerLink]="['/address/' | relativeUrl, vout.scriptpubkey.slice(2, 132)]" title="{{ vout.scriptpubkey.slice(2, 132) }}">
<app-truncate [text]="vout.scriptpubkey.slice(2, 132)" [lastChars]="8"></app-truncate>
</a>
</ng-container>
</ng-template>
<div> <div>
<app-address-labels [vout]="vout" [channel]="tx._channels && tx._channels.outputs[vindex] ? tx._channels.outputs[vindex] : null"></app-address-labels> <app-address-labels [vout]="vout" [channel]="tx._channels && tx._channels.outputs[vindex] ? tx._channels.outputs[vindex] : null"></app-address-labels>
</div> </div>

View File

@ -140,6 +140,15 @@ h2 {
font-family: monospace; font-family: monospace;
} }
.p2pk-address {
display: inline-block;
margin-left: 1em;
max-width: 100px;
@media (min-width: 576px) {
max-width: 200px
}
}
.grey-info-text { .grey-info-text {
color:#6c757d; color:#6c757d;
font-style: italic; font-style: italic;

View File

@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; import { AfterViewInit, ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
import { combineLatest, merge, Observable, of, Subscription } from 'rxjs'; import { combineLatest, merge, Observable, of, Subscription } from 'rxjs';
import { filter, map, scan, share, switchMap, tap } from 'rxjs/operators'; import { filter, map, scan, share, switchMap, tap } from 'rxjs/operators';
import { BlockExtended, OptimizedMempoolStats, RbfTree } from '../interfaces/node-api.interface'; import { BlockExtended, OptimizedMempoolStats } from '../interfaces/node-api.interface';
import { MempoolInfo, TransactionStripped, ReplacementInfo } from '../interfaces/websocket.interface'; import { MempoolInfo, TransactionStripped, ReplacementInfo } from '../interfaces/websocket.interface';
import { ApiService } from '../services/api.service'; import { ApiService } from '../services/api.service';
import { StateService } from '../services/state.service'; import { StateService } from '../services/state.service';
@ -31,7 +31,7 @@ interface MempoolStatsData {
styleUrls: ['./dashboard.component.scss'], styleUrls: ['./dashboard.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class DashboardComponent implements OnInit, OnDestroy { export class DashboardComponent implements OnInit, OnDestroy, AfterViewInit {
featuredAssets$: Observable<any>; featuredAssets$: Observable<any>;
network$: Observable<string>; network$: Observable<string>;
mempoolBlocksData$: Observable<MempoolBlocksData>; mempoolBlocksData$: Observable<MempoolBlocksData>;
@ -57,6 +57,10 @@ export class DashboardComponent implements OnInit, OnDestroy {
private seoService: SeoService private seoService: SeoService
) { } ) { }
ngAfterViewInit(): void {
this.stateService.focusSearchInputDesktop();
}
ngOnDestroy(): void { ngOnDestroy(): void {
this.currencySubscription.unsubscribe(); this.currencySubscription.unsubscribe();
this.websocketService.stopTrackRbfSummary(); this.websocketService.stopTrackRbfSummary();

View File

@ -129,6 +129,22 @@ export interface Address {
address: string; address: string;
chain_stats: ChainStats; chain_stats: ChainStats;
mempool_stats: MempoolStats; mempool_stats: MempoolStats;
is_pubkey?: boolean;
}
export interface ScriptHash {
electrum?: boolean;
scripthash: string;
chain_stats: ChainStats;
mempool_stats: MempoolStats;
}
export interface AddressOrScriptHash {
electrum?: boolean;
address?: string;
scripthash?: string;
chain_stats: ChainStats;
mempool_stats: MempoolStats;
} }
export interface ChainStats { export interface ChainStats {

View File

@ -174,7 +174,7 @@ export interface TransactionStripped {
vsize: number; vsize: number;
value: number; value: number;
rate?: number; // effective fee rate rate?: number; // effective fee rate
status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'freshcpfp' | 'added' | 'censored' | 'selected' | 'fullrbf'; status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'freshcpfp' | 'added' | 'censored' | 'selected' | 'rbf';
context?: 'projected' | 'actual'; context?: 'projected' | 'actual';
} }

View File

@ -89,7 +89,7 @@ export interface TransactionStripped {
vsize: number; vsize: number;
value: number; value: number;
rate?: number; // effective fee rate rate?: number; // effective fee rate
status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'freshcpfp' | 'added' | 'censored' | 'selected' | 'fullrbf'; status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'freshcpfp' | 'added' | 'censored' | 'selected' | 'rbf';
context?: 'projected' | 'actual'; context?: 'projected' | 'actual';
} }

View File

@ -1,19 +1,43 @@
<div class="box"> <div class="box">
<table class="table table-borderless table-striped"> <div class="starting-balance" *ngIf="showStartingBalance">
<tbody> <h5 i18n="lightning.starting-balance|Channel starting balance">Starting balance</h5>
<tr></tr> <div class="nodes">
<tr> <h5 class="alias">{{ left.alias }}</h5>
<td i18n="lightning.starting-balance|Channel starting balance">Starting balance</td> <h5 class="alias">{{ right.alias }}</h5>
<td *ngIf="showStartingBalance && minStartingBalance === maxStartingBalance"><app-sats [satoshis]="minStartingBalance"></app-sats></td> </div>
<td *ngIf="showStartingBalance && minStartingBalance !== maxStartingBalance">{{ minStartingBalance | number : '1.0-0' }} - {{ maxStartingBalance | number : '1.0-0' }}<app-sats [valueOverride]=" "></app-sats></td> <div class="balances">
<td *ngIf="!showStartingBalance">?</td> <div class="balance left">
</tr> <span class="value" *ngIf="minStartingBalance !== maxStartingBalance">{{ minStartingBalance | number : '1.0-0' }} - {{ maxStartingBalance | number : '1.0-0' }}<app-sats [valueOverride]=" "></app-sats></span>
<tr *ngIf="channel.status === 2"> <span class="value" *ngIf="minStartingBalance === maxStartingBalance">{{ minStartingBalance | number : '1.0-0' }}<app-sats [valueOverride]=" "></app-sats></span>
<td i18n="lightning.closing-balance|Channel closing balance">Closing balance</td> </div>
<td *ngIf="showClosingBalance && minClosingBalance === maxClosingBalance"><app-sats [satoshis]="minClosingBalance"></app-sats></td> <div class="balance right">
<td *ngIf="showClosingBalance && minClosingBalance !== maxClosingBalance">{{ minClosingBalance | number : '1.0-0' }} - {{ maxClosingBalance | number : '1.0-0' }}<app-sats [valueOverride]=" "></app-sats></td> <span class="value" *ngIf="minStartingBalance !== maxStartingBalance">{{ channel.capacity - maxStartingBalance | number : '1.0-0' }} - {{ channel.capacity - minStartingBalance | number : '1.0-0' }}<app-sats [valueOverride]=" "></app-sats></span>
<td *ngIf="!showClosingBalance">?</td> <span class="value" *ngIf="minStartingBalance === maxStartingBalance">{{ channel.capacity - maxStartingBalance | number : '1.0-0' }}<app-sats [valueOverride]=" "></app-sats></span>
</tr> </div>
</tbody> </div>
</table> <div class="balance-bar">
<div class="bar left" [class.hide-value]="hideStartingLeft" [style]="startingBalanceStyle.left"></div>
<div class="bar center" [style]="startingBalanceStyle.center"></div>
<div class="bar right" [class.hide-value]="hideStartingRight" [style]="startingBalanceStyle.right"></div>
</div>
</div>
<br>
<div class="closing-balance" *ngIf="showClosingBalance">
<h5 i18n="lightning.closing-balance|Channel closing balance">Closing balance</h5>
<div class="balances">
<div class="balance left">
<span class="value" *ngIf="minClosingBalance !== maxClosingBalance">{{ minClosingBalance | number : '1.0-0' }} - {{ maxClosingBalance | number : '1.0-0' }}<app-sats [valueOverride]=" "></app-sats></span>
<span class="value" *ngIf="minClosingBalance === maxClosingBalance">{{ minClosingBalance | number : '1.0-0' }}<app-sats [valueOverride]=" "></app-sats></span>
</div>
<div class="balance right">
<span class="value" *ngIf="minClosingBalance !== maxClosingBalance">{{ channel.capacity - maxClosingBalance | number : '1.0-0' }} - {{ channel.capacity - minClosingBalance | number : '1.0-0' }}<app-sats [valueOverride]=" "></app-sats></span>
<span class="value" *ngIf="minClosingBalance === maxClosingBalance">{{ channel.capacity - maxClosingBalance | number : '1.0-0' }}<app-sats [valueOverride]=" "></app-sats></span>
</div>
</div>
<div class="balance-bar">
<div class="bar left" [class.hide-value]="hideClosingLeft" [style]="closingBalanceStyle.left"></div>
<div class="bar center" [style]="closingBalanceStyle.center"></div>
<div class="bar right" [class.hide-value]="hideClosingRight" [style]="closingBalanceStyle.right"></div>
</div>
</div>
</div> </div>

View File

@ -6,4 +6,98 @@
.box { .box {
margin-bottom: 20px; margin-bottom: 20px;
} }
}
.starting-balance, .closing-balance {
width: 100%;
h5 {
text-align: center;
}
}
.nodes {
display: none;
flex-direction: row;
align-items: baseline;
justify-content: space-between;
@media (max-width: 768px) {
display: flex;
}
}
.balances {
display: flex;
flex-direction: row;
align-items: baseline;
justify-content: space-between;
margin-bottom: 8px;
.balance {
&.left {
text-align: start;
}
&.right {
text-align: end;
}
}
}
.balance-bar {
width: 100%;
height: 2em;
position: relative;
.bar {
position: absolute;
top: 0;
bottom: 0;
height: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
&.left {
background: #105fb0;
}
&.center {
background: repeating-linear-gradient(
60deg,
#105fb0 0,
#105fb0 12px,
#1a9436 12px,
#1a9436 24px
);
}
&.right {
background: #1a9436;
}
.value {
flex: 0;
white-space: nowrap;
}
&.hide-value {
.value {
display: none;
}
}
}
@media (max-width: 768px) {
height: 1em;
.bar.center {
background: repeating-linear-gradient(
60deg,
#105fb0 0,
#105fb0 8px,
#1a9436 8px,
#1a9436 16px
)
}
}
} }

View File

@ -8,8 +8,8 @@ import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } f
}) })
export class ChannelCloseBoxComponent implements OnChanges { export class ChannelCloseBoxComponent implements OnChanges {
@Input() channel: any; @Input() channel: any;
@Input() local: any; @Input() left: any;
@Input() remote: any; @Input() right: any;
showStartingBalance: boolean = false; showStartingBalance: boolean = false;
showClosingBalance: boolean = false; showClosingBalance: boolean = false;
@ -18,29 +18,55 @@ export class ChannelCloseBoxComponent implements OnChanges {
minClosingBalance: number; minClosingBalance: number;
maxClosingBalance: number; maxClosingBalance: number;
startingBalanceStyle: {
left: string,
center: string,
right: string,
} = {
left: '',
center: '',
right: '',
};
closingBalanceStyle: {
left: string,
center: string,
right: string,
} = {
left: '',
center: '',
right: '',
};
hideStartingLeft: boolean = false;
hideStartingRight: boolean = false;
hideClosingLeft: boolean = false;
hideClosingRight: boolean = false;
constructor() { } constructor() { }
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
if (this.channel && this.local && this.remote) { let closingCapacity;
this.showStartingBalance = (this.local.funding_balance || this.remote.funding_balance) && this.channel.funding_ratio; if (this.channel && this.left && this.right) {
this.showClosingBalance = this.local.closing_balance || this.remote.closing_balance; this.showStartingBalance = (this.left.funding_balance || this.right.funding_balance) && this.channel.funding_ratio;
this.showClosingBalance = this.left.closing_balance || this.right.closing_balance;
if (this.channel.single_funded) { if (this.channel.single_funded) {
if (this.local.funding_balance) { if (this.left.funding_balance) {
this.minStartingBalance = this.channel.capacity; this.minStartingBalance = this.channel.capacity;
this.maxStartingBalance = this.channel.capacity; this.maxStartingBalance = this.channel.capacity;
} else if (this.remote.funding_balance) { } else if (this.right.funding_balance) {
this.minStartingBalance = 0; this.minStartingBalance = 0;
this.maxStartingBalance = 0; this.maxStartingBalance = 0;
} }
} else { } else {
this.minStartingBalance = clampRound(0, this.channel.capacity, this.local.funding_balance * this.channel.funding_ratio); this.minStartingBalance = clampRound(0, this.channel.capacity, this.left.funding_balance * this.channel.funding_ratio);
this.maxStartingBalance = clampRound(0, this.channel.capacity, this.channel.capacity - (this.remote.funding_balance * this.channel.funding_ratio)); this.maxStartingBalance = clampRound(0, this.channel.capacity, this.channel.capacity - (this.right.funding_balance * this.channel.funding_ratio));
} }
const closingCapacity = this.channel.capacity - this.channel.closing_fee; closingCapacity = this.channel.capacity - this.channel.closing_fee;
this.minClosingBalance = clampRound(0, closingCapacity, this.local.closing_balance); this.minClosingBalance = clampRound(0, closingCapacity, this.left.closing_balance);
this.maxClosingBalance = clampRound(0, closingCapacity, closingCapacity - this.remote.closing_balance); this.maxClosingBalance = clampRound(0, closingCapacity, closingCapacity - this.right.closing_balance);
// margin of error to account for 2 x 330 sat anchor outputs // margin of error to account for 2 x 330 sat anchor outputs
if (Math.abs(this.minClosingBalance - this.maxClosingBalance) <= 660) { if (Math.abs(this.minClosingBalance - this.maxClosingBalance) <= 660) {
@ -50,6 +76,26 @@ export class ChannelCloseBoxComponent implements OnChanges {
this.showStartingBalance = false; this.showStartingBalance = false;
this.showClosingBalance = false; this.showClosingBalance = false;
} }
const startingMinPc = (this.minStartingBalance / this.channel.capacity) * 100;
const startingMaxPc = (this.maxStartingBalance / this.channel.capacity) * 100;
this.startingBalanceStyle = {
left: `left: 0%; right: ${100 - startingMinPc}%;`,
center: `left: ${startingMinPc}%; right: ${100 -startingMaxPc}%;`,
right: `left: ${startingMaxPc}%; right: 0%;`,
};
this.hideStartingLeft = startingMinPc < 15;
this.hideStartingRight = startingMaxPc > 85;
const closingMinPc = (this.minClosingBalance / closingCapacity) * 100;
const closingMaxPc = (this.maxClosingBalance / closingCapacity) * 100;
this.closingBalanceStyle = {
left: `left: 0%; right: ${100 - closingMinPc}%;`,
center: `left: ${closingMinPc}%; right: ${100 - closingMaxPc}%;`,
right: `left: ${closingMaxPc}%; right: 0%;`,
};
this.hideClosingLeft = closingMinPc < 15;
this.hideClosingRight = closingMaxPc > 85;
} }
} }

View File

@ -75,14 +75,14 @@
<div class="row row-cols-1 row-cols-md-2" *ngIf="!error"> <div class="row row-cols-1 row-cols-md-2" *ngIf="!error">
<div class="col"> <div class="col">
<app-channel-box [channel]="channel.node_left"></app-channel-box> <app-channel-box [channel]="channel.node_left"></app-channel-box>
<app-channel-close-box *ngIf="showCloseBoxes(channel)" [channel]="channel" [local]="channel.node_left" [remote]="channel.node_right"></app-channel-close-box>
</div> </div>
<div class="col"> <div class="col">
<app-channel-box [channel]="channel.node_right"></app-channel-box> <app-channel-box [channel]="channel.node_right"></app-channel-box>
<app-channel-close-box *ngIf="showCloseBoxes(channel)" [channel]="channel" [local]="channel.node_right" [remote]="channel.node_left"></app-channel-close-box>
</div> </div>
</div> </div>
<app-channel-close-box *ngIf="showCloseBoxes(channel)" [channel]="channel" [left]="channel.node_left" [right]="channel.node_right"></app-channel-close-box>
<br> <br>
<ng-container *ngIf="transactions$ | async as transactions"> <ng-container *ngIf="transactions$ | async as transactions">

View File

@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { AfterViewInit, ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { share } from 'rxjs/operators'; import { share } from 'rxjs/operators';
import { INodesRanking } from '../../interfaces/node-api.interface'; import { INodesRanking } from '../../interfaces/node-api.interface';
@ -12,7 +12,7 @@ import { LightningApiService } from '../lightning-api.service';
styleUrls: ['./lightning-dashboard.component.scss'], styleUrls: ['./lightning-dashboard.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class LightningDashboardComponent implements OnInit { export class LightningDashboardComponent implements OnInit, AfterViewInit {
statistics$: Observable<any>; statistics$: Observable<any>;
nodesRanking$: Observable<INodesRanking>; nodesRanking$: Observable<INodesRanking>;
officialMempoolSpace = this.stateService.env.OFFICIAL_MEMPOOL_SPACE; officialMempoolSpace = this.stateService.env.OFFICIAL_MEMPOOL_SPACE;
@ -30,4 +30,7 @@ export class LightningDashboardComponent implements OnInit {
this.statistics$ = this.lightningApiService.getLatestStatistics$().pipe(share()); this.statistics$ = this.lightningApiService.getLatestStatistics$().pipe(share());
} }
ngAfterViewInit(): void {
this.stateService.focusSearchInputDesktop();
}
} }

View File

@ -1,9 +1,10 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable, from, of, switchMap } from 'rxjs';
import { Transaction, Address, Outspend, Recent, Asset } from '../interfaces/electrs.interface'; import { Transaction, Address, Outspend, Recent, Asset, ScriptHash } from '../interfaces/electrs.interface';
import { StateService } from './state.service'; import { StateService } from './state.service';
import { BlockExtended } from '../interfaces/node-api.interface'; import { BlockExtended } from '../interfaces/node-api.interface';
import { calcScriptHash$ } from '../bitcoin.utils';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -65,6 +66,24 @@ export class ElectrsApiService {
return this.httpClient.get<Address>(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address); return this.httpClient.get<Address>(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address);
} }
getPubKeyAddress$(pubkey: string): Observable<Address> {
return this.getScriptHash$('41' + pubkey + 'ac').pipe(
switchMap((scripthash: ScriptHash) => {
return of({
...scripthash,
address: pubkey,
is_pubkey: true,
});
})
);
}
getScriptHash$(script: string): Observable<ScriptHash> {
return from(calcScriptHash$(script)).pipe(
switchMap(scriptHash => this.httpClient.get<ScriptHash>(this.apiBaseUrl + this.apiBasePath + '/api/scripthash/' + scriptHash))
);
}
getAddressTransactions$(address: string, txid?: string): Observable<Transaction[]> { getAddressTransactions$(address: string, txid?: string): Observable<Transaction[]> {
let params = new HttpParams(); let params = new HttpParams();
if (txid) { if (txid) {
@ -73,6 +92,16 @@ export class ElectrsApiService {
return this.httpClient.get<Transaction[]>(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address + '/txs', { params }); return this.httpClient.get<Transaction[]>(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address + '/txs', { params });
} }
getScriptHashTransactions$(script: string, txid?: string): Observable<Transaction[]> {
let params = new HttpParams();
if (txid) {
params = params.append('after_txid', txid);
}
return from(calcScriptHash$(script)).pipe(
switchMap(scriptHash => this.httpClient.get<Transaction[]>(this.apiBaseUrl + this.apiBasePath + '/api/scripthash/' + scriptHash + '/txs', { params })),
);
}
getAsset$(assetId: string): Observable<Asset> { getAsset$(assetId: string): Observable<Asset> {
return this.httpClient.get<Asset>(this.apiBaseUrl + this.apiBasePath + '/api/asset/' + assetId); return this.httpClient.get<Asset>(this.apiBaseUrl + this.apiBasePath + '/api/asset/' + assetId);
} }

View File

@ -7,6 +7,7 @@ import { Router, NavigationStart } from '@angular/router';
import { isPlatformBrowser } from '@angular/common'; import { isPlatformBrowser } from '@angular/common';
import { filter, map, scan, shareReplay } from 'rxjs/operators'; import { filter, map, scan, shareReplay } from 'rxjs/operators';
import { StorageService } from './storage.service'; import { StorageService } from './storage.service';
import { hasTouchScreen } from '../shared/pipes/bytes-pipe/utils';
export interface MarkBlockState { export interface MarkBlockState {
blockHeight?: number; blockHeight?: number;
@ -113,6 +114,7 @@ export class StateService {
mempoolTxPosition$ = new Subject<{ txid: string, position: MempoolPosition}>(); mempoolTxPosition$ = new Subject<{ txid: string, position: MempoolPosition}>();
blockTransactions$ = new Subject<Transaction>(); blockTransactions$ = new Subject<Transaction>();
isLoadingWebSocket$ = new ReplaySubject<boolean>(1); isLoadingWebSocket$ = new ReplaySubject<boolean>(1);
isLoadingMempool$ = new BehaviorSubject<boolean>(true);
vbytesPerSecond$ = new ReplaySubject<number>(1); vbytesPerSecond$ = new ReplaySubject<number>(1);
previousRetarget$ = new ReplaySubject<number>(1); previousRetarget$ = new ReplaySubject<number>(1);
backendInfo$ = new ReplaySubject<IBackendInfo>(1); backendInfo$ = new ReplaySubject<IBackendInfo>(1);
@ -138,6 +140,8 @@ export class StateService {
fiatCurrency$: BehaviorSubject<string>; fiatCurrency$: BehaviorSubject<string>;
rateUnits$: BehaviorSubject<string>; rateUnits$: BehaviorSubject<string>;
searchFocus$: Subject<boolean> = new Subject<boolean>();
constructor( constructor(
@Inject(PLATFORM_ID) private platformId: any, @Inject(PLATFORM_ID) private platformId: any,
@Inject(LOCALE_ID) private locale: string, @Inject(LOCALE_ID) private locale: string,
@ -355,4 +359,10 @@ export class StateService {
this.blocks = this.blocks.slice(0, this.env.KEEP_BLOCKS_AMOUNT); this.blocks = this.blocks.slice(0, this.env.KEEP_BLOCKS_AMOUNT);
this.blocksSubject$.next(this.blocks); this.blocksSubject$.next(this.blocks);
} }
focusSearchInputDesktop() {
if (!hasTouchScreen()) {
this.searchFocus$.next(true);
}
}
} }

View File

@ -368,6 +368,11 @@ export class WebsocketService {
if (response.loadingIndicators) { if (response.loadingIndicators) {
this.stateService.loadingIndicators$.next(response.loadingIndicators); this.stateService.loadingIndicators$.next(response.loadingIndicators);
if (response.loadingIndicators.mempool != null && response.loadingIndicators.mempool < 100) {
this.stateService.isLoadingMempool$.next(true);
} else {
this.stateService.isLoadingMempool$.next(false);
}
} }
if (response.mempoolInfo) { if (response.mempoolInfo) {

View File

@ -309,3 +309,28 @@ export function takeWhile(input: any[], predicate: CollectionPredicate) {
return takeUntil(input, (item: any, index: number | undefined, collection: any[] | undefined) => return takeUntil(input, (item: any, index: number | undefined, collection: any[] | undefined) =>
!predicate(item, index, collection)); !predicate(item, index, collection));
} }
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent
export function hasTouchScreen(): boolean {
let hasTouchScreen = false;
if ('maxTouchPoints' in navigator) {
hasTouchScreen = navigator.maxTouchPoints > 0;
} else if ('msMaxTouchPoints' in navigator) {
// @ts-ignore
hasTouchScreen = navigator.msMaxTouchPoints > 0;
} else {
const mQ = matchMedia?.('(pointer:coarse)');
if (mQ?.media === '(pointer:coarse)') {
hasTouchScreen = !!mQ.matches;
} else if ('orientation' in window) {
hasTouchScreen = true; // deprecated, but good fallback
} else {
// @ts-ignore - Only as a last resort, fall back to user agent sniffing
const UA = navigator.userAgent;
hasTouchScreen =
/\b(BlackBerry|webOS|iPhone|IEMobile)\b/i.test(UA) ||
/\b(Android|Windows Phone|iPad|iPod)\b/i.test(UA);
}
}
return hasTouchScreen;
}

View File

@ -7,15 +7,15 @@
"declaration": false, "declaration": false,
"downlevelIteration": true, "downlevelIteration": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"module": "es2020", "module": "ES2020",
"moduleResolution": "node", "moduleResolution": "node",
"importHelpers": true, "importHelpers": true,
"target": "es2020", "target": "ES2022",
"typeRoots": [ "typeRoots": [
"node_modules/@types" "node_modules/@types"
], ],
"lib": [ "lib": [
"es2018", "ES2018",
"dom", "dom",
"dom.iterable" "dom.iterable"
] ]
@ -24,5 +24,6 @@
"fullTemplateTypeCheck": true, "fullTemplateTypeCheck": true,
"strictInjectionParameters": true, "strictInjectionParameters": true,
"strictTemplates": true, "strictTemplates": true,
"useDefineForClassFields": false
} }
} }