Compare commits
1 Commits
mononaut/m
...
simon/e2e-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79fce5a269 |
@@ -27,9 +27,8 @@
|
||||
"AUTOMATIC_POOLS_UPDATE": false,
|
||||
"POOLS_JSON_URL": "https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json",
|
||||
"POOLS_JSON_TREE_URL": "https://api.github.com/repos/mempool/mining-pools/git/trees/master",
|
||||
"POOLS_UPDATE_DELAY": 604800,
|
||||
"AUDIT": false,
|
||||
"RUST_GBT": true,
|
||||
"RUST_GBT": false,
|
||||
"LIMIT_GBT": false,
|
||||
"CPFP_INDEXING": false,
|
||||
"DISK_CACHE_BLOCK_INTERVAL": 6,
|
||||
@@ -46,8 +45,7 @@
|
||||
"PASSWORD": "mempool",
|
||||
"TIMEOUT": 60000,
|
||||
"COOKIE": false,
|
||||
"COOKIE_PATH": "/path/to/bitcoin/.cookie",
|
||||
"DEBUG_LOG_PATH": "/path/to/bitcoin/debug.log"
|
||||
"COOKIE_PATH": "/path/to/bitcoin/.cookie"
|
||||
},
|
||||
"ELECTRUM": {
|
||||
"HOST": "127.0.0.1",
|
||||
|
||||
30
backend/package-lock.json
generated
30
backend/package-lock.json
generated
@@ -16,7 +16,7 @@
|
||||
"axios": "1.7.2",
|
||||
"bitcoinjs-lib": "~6.1.3",
|
||||
"crypto-js": "~4.2.0",
|
||||
"express": "~4.21.1",
|
||||
"express": "~4.21.0",
|
||||
"maxmind": "~4.3.11",
|
||||
"mysql2": "~3.11.0",
|
||||
"redis": "^4.7.0",
|
||||
@@ -2827,9 +2827,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -3461,16 +3461,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.21.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz",
|
||||
"integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==",
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz",
|
||||
"integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "1.20.3",
|
||||
"content-disposition": "0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "0.7.1",
|
||||
"cookie": "0.6.0",
|
||||
"cookie-signature": "1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
@@ -9865,9 +9865,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"cookie": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="
|
||||
},
|
||||
"cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
@@ -10319,16 +10319,16 @@
|
||||
}
|
||||
},
|
||||
"express": {
|
||||
"version": "4.21.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz",
|
||||
"integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==",
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz",
|
||||
"integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==",
|
||||
"requires": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "1.20.3",
|
||||
"content-disposition": "0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "0.7.1",
|
||||
"cookie": "0.6.0",
|
||||
"cookie-signature": "1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"axios": "1.7.2",
|
||||
"bitcoinjs-lib": "~6.1.3",
|
||||
"crypto-js": "~4.2.0",
|
||||
"express": "~4.21.1",
|
||||
"express": "~4.21.0",
|
||||
"maxmind": "~4.3.11",
|
||||
"mysql2": "~3.11.0",
|
||||
"rust-gbt": "file:./rust-gbt",
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
"INDEXING_BLOCKS_AMOUNT": 14,
|
||||
"POOLS_JSON_TREE_URL": "__MEMPOOL_POOLS_JSON_TREE_URL__",
|
||||
"POOLS_JSON_URL": "__MEMPOOL_POOLS_JSON_URL__",
|
||||
"POOLS_UPDATE_DELAY": 604800,
|
||||
"AUDIT": true,
|
||||
"RUST_GBT": false,
|
||||
"LIMIT_GBT": false,
|
||||
@@ -47,8 +46,7 @@
|
||||
"PASSWORD": "__CORE_RPC_PASSWORD__",
|
||||
"TIMEOUT": 1000,
|
||||
"COOKIE": false,
|
||||
"COOKIE_PATH": "__CORE_RPC_COOKIE_PATH__",
|
||||
"DEBUG_LOG_PATH": "__CORE_RPC_DEBUG_LOG_PATH__"
|
||||
"COOKIE_PATH": "__CORE_RPC_COOKIE_PATH__"
|
||||
},
|
||||
"ELECTRUM": {
|
||||
"HOST": "__ELECTRUM_HOST__",
|
||||
|
||||
@@ -41,9 +41,8 @@ describe('Mempool Backend Config', () => {
|
||||
STDOUT_LOG_MIN_PRIORITY: 'debug',
|
||||
POOLS_JSON_TREE_URL: 'https://api.github.com/repos/mempool/mining-pools/git/trees/master',
|
||||
POOLS_JSON_URL: 'https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json',
|
||||
POOLS_UPDATE_DELAY: 604800,
|
||||
AUDIT: false,
|
||||
RUST_GBT: true,
|
||||
RUST_GBT: false,
|
||||
LIMIT_GBT: false,
|
||||
CPFP_INDEXING: false,
|
||||
MAX_BLOCKS_BULK_QUERY: 0,
|
||||
@@ -74,8 +73,7 @@ describe('Mempool Backend Config', () => {
|
||||
PASSWORD: 'mempool',
|
||||
TIMEOUT: 60000,
|
||||
COOKIE: false,
|
||||
COOKIE_PATH: '/bitcoin/.cookie',
|
||||
DEBUG_LOG_PATH: '',
|
||||
COOKIE_PATH: '/bitcoin/.cookie'
|
||||
});
|
||||
|
||||
expect(config.SECOND_CORE_RPC).toStrictEqual({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IBitcoinApi, SubmitPackageResult, TestMempoolAcceptResult } from './bitcoin-api.interface';
|
||||
import { IBitcoinApi, TestMempoolAcceptResult } from './bitcoin-api.interface';
|
||||
import { IEsploraApi } from './esplora-api.interface';
|
||||
|
||||
export interface AbstractBitcoinApi {
|
||||
@@ -23,7 +23,6 @@ export interface AbstractBitcoinApi {
|
||||
$getScriptHashTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]>;
|
||||
$sendRawTransaction(rawTransaction: string): Promise<string>;
|
||||
$testMempoolAccept(rawTransactions: string[], maxfeerate?: number): Promise<TestMempoolAcceptResult[]>;
|
||||
$submitPackage(rawTransactions: string[], maxfeerate?: number, maxburnamount?: number): Promise<SubmitPackageResult>;
|
||||
$getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend>;
|
||||
$getOutspends(txId: string): Promise<IEsploraApi.Outspend[]>;
|
||||
$getBatchedOutspends(txId: string[]): Promise<IEsploraApi.Outspend[][]>;
|
||||
|
||||
@@ -218,21 +218,3 @@ export interface TestMempoolAcceptResult {
|
||||
},
|
||||
['reject-reason']?: string,
|
||||
}
|
||||
|
||||
export interface SubmitPackageResult {
|
||||
package_msg: string;
|
||||
"tx-results": { [wtxid: string]: TxResult };
|
||||
"replaced-transactions"?: string[];
|
||||
}
|
||||
|
||||
export interface TxResult {
|
||||
txid: string;
|
||||
"other-wtxid"?: string;
|
||||
vsize?: number;
|
||||
fees?: {
|
||||
base: number;
|
||||
"effective-feerate"?: number;
|
||||
"effective-includes"?: string[];
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as bitcoinjs from 'bitcoinjs-lib';
|
||||
import { AbstractBitcoinApi, HealthCheckHost } from './bitcoin-api-abstract-factory';
|
||||
import { IBitcoinApi, SubmitPackageResult, TestMempoolAcceptResult } from './bitcoin-api.interface';
|
||||
import { IBitcoinApi, TestMempoolAcceptResult } from './bitcoin-api.interface';
|
||||
import { IEsploraApi } from './esplora-api.interface';
|
||||
import blocks from '../blocks';
|
||||
import mempool from '../mempool';
|
||||
@@ -196,10 +196,6 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
}
|
||||
}
|
||||
|
||||
$submitPackage(rawTransactions: string[], maxfeerate?: number, maxburnamount?: number): Promise<SubmitPackageResult> {
|
||||
return this.bitcoindClient.submitPackage(rawTransactions, maxfeerate ?? undefined, maxburnamount ?? undefined);
|
||||
}
|
||||
|
||||
async $getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend> {
|
||||
const txOut = await this.bitcoindClient.getTxOut(txId, vout, false);
|
||||
return {
|
||||
|
||||
@@ -48,8 +48,6 @@ class BitcoinRoutes {
|
||||
.post(config.MEMPOOL.API_URL_PREFIX + 'psbt/addparents', this.postPsbtCompletion)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from', this.getBlocksByBulk.bind(this))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from/:to', this.getBlocksByBulk.bind(this))
|
||||
// Temporarily add txs/package endpoint for all backends until esplora supports it
|
||||
.post(config.MEMPOOL.API_URL_PREFIX + 'txs/package', this.$submitPackage)
|
||||
;
|
||||
|
||||
if (config.MEMPOOL.BACKEND !== 'esplora') {
|
||||
@@ -796,19 +794,6 @@ class BitcoinRoutes {
|
||||
}
|
||||
}
|
||||
|
||||
private async $submitPackage(req: Request, res: Response) {
|
||||
try {
|
||||
const rawTxs = Common.getTransactionsFromRequest(req);
|
||||
const maxfeerate = parseFloat(req.query.maxfeerate as string);
|
||||
const maxburnamount = parseFloat(req.query.maxburnamount as string);
|
||||
const result = await bitcoinClient.submitPackage(rawTxs, maxfeerate ?? undefined, maxburnamount ?? undefined);
|
||||
res.send(result);
|
||||
} catch (e: any) {
|
||||
handleError(req, res, 400, e.message && e.code ? 'submitpackage RPC error: ' + JSON.stringify({ code: e.code, message: e.message })
|
||||
: (e.message || 'Error'));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default new BitcoinRoutes();
|
||||
|
||||
@@ -5,7 +5,7 @@ import { AbstractBitcoinApi, HealthCheckHost } from './bitcoin-api-abstract-fact
|
||||
import { IEsploraApi } from './esplora-api.interface';
|
||||
import logger from '../../logger';
|
||||
import { Common } from '../common';
|
||||
import { SubmitPackageResult, TestMempoolAcceptResult } from './bitcoin-api.interface';
|
||||
import { TestMempoolAcceptResult } from './bitcoin-api.interface';
|
||||
|
||||
interface FailoverHost {
|
||||
host: string,
|
||||
@@ -332,10 +332,6 @@ class ElectrsApi implements AbstractBitcoinApi {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
$submitPackage(rawTransactions: string[]): Promise<SubmitPackageResult> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
$getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend> {
|
||||
return this.failoverRouter.$get<IEsploraApi.Outspend>('/tx/' + txId + '/outspend/' + vout);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ import { calculateFastBlockCpfp, calculateGoodBlockCpfp } from './cpfp';
|
||||
import mempool from './mempool';
|
||||
import CpfpRepository from '../repositories/CpfpRepository';
|
||||
import accelerationApi from './services/acceleration';
|
||||
import { parseDATUMTemplateCreator } from '../utils/bitcoin-script';
|
||||
|
||||
class Blocks {
|
||||
private blocks: BlockExtended[] = [];
|
||||
@@ -343,12 +342,7 @@ class Blocks {
|
||||
id: pool.uniqueId,
|
||||
name: pool.name,
|
||||
slug: pool.slug,
|
||||
minerNames: null,
|
||||
};
|
||||
|
||||
if (extras.pool.name === 'OCEAN') {
|
||||
extras.pool.minerNames = parseDATUMTemplateCreator(extras.coinbaseRaw);
|
||||
}
|
||||
}
|
||||
|
||||
extras.matchRate = null;
|
||||
|
||||
@@ -7,7 +7,7 @@ import cpfpRepository from '../repositories/CpfpRepository';
|
||||
import { RowDataPacket } from 'mysql2';
|
||||
|
||||
class DatabaseMigration {
|
||||
private static currentVersion = 83;
|
||||
private static currentVersion = 82;
|
||||
private queryTimeout = 3600_000;
|
||||
private statisticsAddedIndexed = false;
|
||||
private uniqueLogs: string[] = [];
|
||||
@@ -705,11 +705,6 @@ class DatabaseMigration {
|
||||
await this.$fixBadV1AuditBlocks();
|
||||
await this.updateToSchemaVersion(82);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 83 && isBitcoin === true) {
|
||||
await this.$executeQuery('ALTER TABLE `blocks` ADD first_seen datetime(6) DEFAULT NULL');
|
||||
await this.updateToSchemaVersion(83);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -183,7 +183,7 @@ class MiningRoutes {
|
||||
private async $getHistoricalHashrate(req: Request, res: Response) {
|
||||
let currentHashrate = 0, currentDifficulty = 0;
|
||||
try {
|
||||
currentHashrate = await bitcoinClient.getNetworkHashPs(1008);
|
||||
currentHashrate = await bitcoinClient.getNetworkHashPs();
|
||||
currentDifficulty = await bitcoinClient.getDifficulty();
|
||||
} catch (e) {
|
||||
logger.debug('Bitcoin Core is not available, using zeroed value for current hashrate and difficulty');
|
||||
|
||||
@@ -121,7 +121,6 @@ class TransactionUtils {
|
||||
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 adjustedFeePerVsize = (transaction.fee || 0) / adjustedVsize;
|
||||
const effectiveFeePerVsize = transaction['effectiveFeePerVsize'] || adjustedFeePerVsize || feePerVbytes;
|
||||
const transactionExtended: MempoolTransactionExtended = Object.assign(transaction, {
|
||||
order: this.txidToOrdering(transaction.txid),
|
||||
vsize,
|
||||
@@ -129,7 +128,7 @@ class TransactionUtils {
|
||||
sigops,
|
||||
feePerVsize: feePerVbytes,
|
||||
adjustedFeePerVsize: adjustedFeePerVsize,
|
||||
effectiveFeePerVsize: effectiveFeePerVsize,
|
||||
effectiveFeePerVsize: adjustedFeePerVsize,
|
||||
});
|
||||
if (!transactionExtended?.status?.confirmed && !transactionExtended.firstSeen) {
|
||||
transactionExtended.firstSeen = Math.round((Date.now() / 1000));
|
||||
|
||||
@@ -3,8 +3,7 @@ import * as WebSocket from 'ws';
|
||||
import {
|
||||
BlockExtended, TransactionExtended, MempoolTransactionExtended, WebsocketResponse,
|
||||
OptimizedStatistic, ILoadingIndicators, GbtCandidates, TxTrackingInfo,
|
||||
MempoolDelta, MempoolDeltaTxids,
|
||||
TransactionCompressed
|
||||
MempoolDelta, MempoolDeltaTxids
|
||||
} from '../mempool.interfaces';
|
||||
import blocks from './blocks';
|
||||
import memPool from './mempool';
|
||||
@@ -17,7 +16,6 @@ import transactionUtils from './transaction-utils';
|
||||
import rbfCache, { ReplacementInfo } from './rbf-cache';
|
||||
import difficultyAdjustment from './difficulty-adjustment';
|
||||
import feeApi from './fee-api';
|
||||
import BlocksRepository from '../repositories/BlocksRepository';
|
||||
import BlocksAuditsRepository from '../repositories/BlocksAuditsRepository';
|
||||
import BlocksSummariesRepository from '../repositories/BlocksSummariesRepository';
|
||||
import Audit from './audit';
|
||||
@@ -36,7 +34,6 @@ interface AddressTransactions {
|
||||
}
|
||||
import bitcoinSecondClient from './bitcoin/bitcoin-second-client';
|
||||
import { calculateMempoolTxCpfp } from './cpfp';
|
||||
import { getRecentFirstSeen } from '../utils/file-read';
|
||||
|
||||
// valid 'want' subscriptions
|
||||
const wantable = [
|
||||
@@ -318,7 +315,6 @@ class WebsocketHandler {
|
||||
|
||||
if (parsedMessage && parsedMessage['track-mempool-block'] !== undefined) {
|
||||
if (Number.isInteger(parsedMessage['track-mempool-block']) && parsedMessage['track-mempool-block'] >= 0) {
|
||||
client['track-mempool-blocks'] = undefined;
|
||||
const index = parsedMessage['track-mempool-block'];
|
||||
client['track-mempool-block'] = index;
|
||||
const mBlocksWithTransactions = mempoolBlocks.getMempoolBlocksWithTransactions();
|
||||
@@ -328,31 +324,7 @@ class WebsocketHandler {
|
||||
blockTransactions: (mBlocksWithTransactions[index]?.transactions || []).map(mempoolBlocks.compressTx),
|
||||
});
|
||||
} else {
|
||||
client['track-mempool-block'] = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedMessage && parsedMessage['track-mempool-blocks'] !== undefined) {
|
||||
if (parsedMessage['track-mempool-blocks'].length > 0) {
|
||||
client['track-mempool-block'] = undefined;
|
||||
const indices: number[] = [];
|
||||
const mBlocksWithTransactions = mempoolBlocks.getMempoolBlocksWithTransactions();
|
||||
const updates: { index: number, sequence: number, blockTransactions: TransactionCompressed[] }[] = [];
|
||||
for (const i of parsedMessage['track-mempool-blocks']) {
|
||||
const index = parseInt(i);
|
||||
if (Number.isInteger(index) && index >= 0) {
|
||||
indices.push(index);
|
||||
updates.push({
|
||||
index: index,
|
||||
sequence: this.mempoolSequence,
|
||||
blockTransactions: (mBlocksWithTransactions[index]?.transactions || []).map(mempoolBlocks.compressTx),
|
||||
});
|
||||
}
|
||||
}
|
||||
client['track-mempool-blocks'] = indices;
|
||||
response['projected-block-transactions'] = JSON.stringify(updates);
|
||||
} else {
|
||||
client['track-mempool-blocks'] = undefined;
|
||||
client['track-mempool-block'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -936,19 +908,6 @@ class WebsocketHandler {
|
||||
delta: mBlockDeltas[index],
|
||||
});
|
||||
}
|
||||
} else if (client['track-mempool-blocks']?.length && memPool.isInSync()) {
|
||||
const indices = client['track-mempool-blocks'];
|
||||
const updates: string[] = [];
|
||||
for (const index of indices) {
|
||||
if (mBlockDeltas[index]) {
|
||||
updates.push(getCachedResponse(`projected-block-transactions-${index}`, {
|
||||
index: index,
|
||||
sequence: this.mempoolSequence,
|
||||
delta: mBlockDeltas[index],
|
||||
}));
|
||||
}
|
||||
}
|
||||
response['projected-block-transactions'] = '[' + updates.join(',') + ']';
|
||||
}
|
||||
|
||||
if (client['track-rbf'] === 'all' && rbfReplacements) {
|
||||
@@ -1069,14 +1028,6 @@ class WebsocketHandler {
|
||||
}
|
||||
}
|
||||
|
||||
if (config.CORE_RPC.DEBUG_LOG_PATH && block.extras) {
|
||||
const firstSeen = getRecentFirstSeen(block.id);
|
||||
if (firstSeen) {
|
||||
BlocksRepository.$saveFirstSeenTime(block.id, firstSeen);
|
||||
block.extras.firstSeen = firstSeen;
|
||||
}
|
||||
}
|
||||
|
||||
const confirmedTxids: { [txid: string]: boolean } = {};
|
||||
|
||||
// Update mempool to remove transactions included in the new block
|
||||
@@ -1345,27 +1296,6 @@ class WebsocketHandler {
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (client['track-mempool-blocks']?.length && memPool.isInSync()) {
|
||||
const indices = client['track-mempool-blocks'];
|
||||
const updates: string[] = [];
|
||||
for (const index of indices) {
|
||||
if (mBlockDeltas && mBlockDeltas[index] && mBlocksWithTransactions[index]?.transactions?.length) {
|
||||
if (mBlockDeltas[index].added.length > (mBlocksWithTransactions[index]?.transactions.length / 2)) {
|
||||
updates.push(getCachedResponse(`projected-block-transactions-full-${index}`, {
|
||||
index: index,
|
||||
sequence: this.mempoolSequence,
|
||||
blockTransactions: mBlocksWithTransactions[index].transactions.map(mempoolBlocks.compressTx),
|
||||
}));
|
||||
} else {
|
||||
updates.push(getCachedResponse(`projected-block-transactions-delta-${index}`, {
|
||||
index: index,
|
||||
sequence: this.mempoolSequence,
|
||||
delta: mBlockDeltas[index],
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
response['projected-block-transactions'] = '[' + updates.join(',') + ']';
|
||||
}
|
||||
|
||||
if (client['track-mempool-txids']) {
|
||||
|
||||
@@ -32,7 +32,6 @@ interface IConfig {
|
||||
AUTOMATIC_POOLS_UPDATE: boolean;
|
||||
POOLS_JSON_URL: string,
|
||||
POOLS_JSON_TREE_URL: string,
|
||||
POOLS_UPDATE_DELAY: number,
|
||||
AUDIT: boolean;
|
||||
RUST_GBT: boolean;
|
||||
LIMIT_GBT: boolean;
|
||||
@@ -86,7 +85,6 @@ interface IConfig {
|
||||
TIMEOUT: number;
|
||||
COOKIE: boolean;
|
||||
COOKIE_PATH: string;
|
||||
DEBUG_LOG_PATH: string;
|
||||
};
|
||||
SECOND_CORE_RPC: {
|
||||
HOST: string;
|
||||
@@ -194,9 +192,8 @@ const defaults: IConfig = {
|
||||
'AUTOMATIC_POOLS_UPDATE': false,
|
||||
'POOLS_JSON_URL': 'https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json',
|
||||
'POOLS_JSON_TREE_URL': 'https://api.github.com/repos/mempool/mining-pools/git/trees/master',
|
||||
'POOLS_UPDATE_DELAY': 604800, // in seconds, default is one week
|
||||
'AUDIT': false,
|
||||
'RUST_GBT': true,
|
||||
'RUST_GBT': false,
|
||||
'LIMIT_GBT': false,
|
||||
'CPFP_INDEXING': false,
|
||||
'MAX_BLOCKS_BULK_QUERY': 0,
|
||||
@@ -228,8 +225,7 @@ const defaults: IConfig = {
|
||||
'PASSWORD': 'mempool',
|
||||
'TIMEOUT': 60000,
|
||||
'COOKIE': false,
|
||||
'COOKIE_PATH': '/bitcoin/.cookie',
|
||||
'DEBUG_LOG_PATH': '',
|
||||
'COOKIE_PATH': '/bitcoin/.cookie'
|
||||
},
|
||||
'SECOND_CORE_RPC': {
|
||||
'HOST': '127.0.0.1',
|
||||
|
||||
@@ -211,8 +211,6 @@ class Server {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
poolsUpdater.$startService();
|
||||
}
|
||||
|
||||
async runMainUpdateLoop(): Promise<void> {
|
||||
|
||||
@@ -299,7 +299,6 @@ export interface BlockExtension {
|
||||
id: number; // Note - This is the `unique_id`, not to mix with the auto increment `id`
|
||||
name: string;
|
||||
slug: string;
|
||||
minerNames: string[] | null;
|
||||
};
|
||||
avgFee: number;
|
||||
avgFeeRate: number;
|
||||
@@ -320,7 +319,6 @@ export interface BlockExtension {
|
||||
segwitTotalSize: number;
|
||||
segwitTotalWeight: number;
|
||||
header: string;
|
||||
firstSeen: number | null;
|
||||
utxoSetChange: number;
|
||||
// Requires coinstatsindex, will be set to NULL otherwise
|
||||
utxoSetSize: number | null;
|
||||
|
||||
@@ -14,7 +14,6 @@ import chainTips from '../api/chain-tips';
|
||||
import blocks from '../api/blocks';
|
||||
import BlocksAuditsRepository from './BlocksAuditsRepository';
|
||||
import transactionUtils from '../api/transaction-utils';
|
||||
import { parseDATUMTemplateCreator } from '../utils/bitcoin-script';
|
||||
|
||||
interface DatabaseBlock {
|
||||
id: string;
|
||||
@@ -57,7 +56,6 @@ interface DatabaseBlock {
|
||||
utxoSetChange: number;
|
||||
utxoSetSize: number;
|
||||
totalInputAmt: number;
|
||||
firstSeen: number;
|
||||
}
|
||||
|
||||
const BLOCK_DB_FIELDS = `
|
||||
@@ -100,8 +98,7 @@ const BLOCK_DB_FIELDS = `
|
||||
blocks.header,
|
||||
blocks.utxoset_change AS utxoSetChange,
|
||||
blocks.utxoset_size AS utxoSetSize,
|
||||
blocks.total_input_amt AS totalInputAmt,
|
||||
UNIX_TIMESTAMP(blocks.first_seen) AS firstSeen
|
||||
blocks.total_input_amt AS totalInputAmt
|
||||
`;
|
||||
|
||||
class BlocksRepository {
|
||||
@@ -1023,24 +1020,6 @@ class BlocksRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save block first seen time
|
||||
*
|
||||
* @param id
|
||||
*/
|
||||
public async $saveFirstSeenTime(id: string, firstSeen: number): Promise<void> {
|
||||
try {
|
||||
await DB.query(`
|
||||
UPDATE blocks SET first_seen = FROM_UNIXTIME(?)
|
||||
WHERE hash = ?`,
|
||||
[firstSeen, id]
|
||||
);
|
||||
} catch (e) {
|
||||
logger.err(`Cannot update block first seen time. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a mysql row block into a BlockExtended. Note that you
|
||||
* must provide the correct field into dbBlk object param
|
||||
@@ -1075,7 +1054,6 @@ class BlocksRepository {
|
||||
id: dbBlk.poolId,
|
||||
name: dbBlk.poolName,
|
||||
slug: dbBlk.poolSlug,
|
||||
minerNames: null,
|
||||
};
|
||||
extras.avgFee = dbBlk.avgFee;
|
||||
extras.avgFeeRate = dbBlk.avgFeeRate;
|
||||
@@ -1098,7 +1076,6 @@ class BlocksRepository {
|
||||
extras.utxoSetSize = dbBlk.utxoSetSize;
|
||||
extras.totalInputAmt = dbBlk.totalInputAmt;
|
||||
extras.virtualSize = dbBlk.weight / 4.0;
|
||||
extras.firstSeen = dbBlk.firstSeen;
|
||||
|
||||
// Re-org can happen after indexing so we need to always get the
|
||||
// latest state from core
|
||||
@@ -1146,10 +1123,6 @@ class BlocksRepository {
|
||||
}
|
||||
}
|
||||
|
||||
if (extras.pool.name === 'OCEAN') {
|
||||
extras.pool.minerNames = parseDATUMTemplateCreator(extras.coinbaseRaw);
|
||||
}
|
||||
|
||||
blk.extras = <BlockExtension>extras;
|
||||
return <BlockExtended>blk;
|
||||
}
|
||||
|
||||
@@ -83,7 +83,6 @@ module.exports = {
|
||||
signRawTransaction: 'signrawtransaction', // bitcoind v0.7.0+
|
||||
stop: 'stop',
|
||||
submitBlock: 'submitblock', // bitcoind v0.7.0+
|
||||
submitPackage: 'submitpackage',
|
||||
validateAddress: 'validateaddress',
|
||||
verifyChain: 'verifychain', // bitcoind v0.9.0+
|
||||
verifyMessage: 'verifymessage',
|
||||
|
||||
@@ -6,30 +6,16 @@ import backendInfo from '../api/backend-info';
|
||||
import logger from '../logger';
|
||||
import { SocksProxyAgent } from 'socks-proxy-agent';
|
||||
import * as https from 'https';
|
||||
import { Common } from '../api/common';
|
||||
|
||||
/**
|
||||
* Maintain the most recent version of pools-v2.json
|
||||
*/
|
||||
class PoolsUpdater {
|
||||
tag = 'PoolsUpdater';
|
||||
|
||||
lastRun: number = 0;
|
||||
currentSha: string | null = null;
|
||||
poolsUrl: string = config.MEMPOOL.POOLS_JSON_URL;
|
||||
treeUrl: string = config.MEMPOOL.POOLS_JSON_TREE_URL;
|
||||
|
||||
public async $startService(): Promise<void> {
|
||||
while ('Bitcoin is still alive') {
|
||||
try {
|
||||
await this.updatePoolsJson();
|
||||
} catch (e: any) {
|
||||
logger.info(`Exception ${e} in PoolsUpdater::$startService. Code: ${e.code}. Message: ${e.message}`, this.tag);
|
||||
}
|
||||
await Common.sleep$(10000);
|
||||
}
|
||||
}
|
||||
|
||||
public async updatePoolsJson(): Promise<void> {
|
||||
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === false ||
|
||||
config.MEMPOOL.ENABLED === false
|
||||
@@ -37,8 +23,11 @@ class PoolsUpdater {
|
||||
return;
|
||||
}
|
||||
|
||||
const oneWeek = 604800;
|
||||
const oneDay = 86400;
|
||||
|
||||
const now = new Date().getTime() / 1000;
|
||||
if (now - this.lastRun < config.MEMPOOL.POOLS_UPDATE_DELAY) { // Execute the PoolsUpdate only once a week, or upon restart
|
||||
if (now - this.lastRun < oneWeek) { // Execute the PoolsUpdate only once a week, or upon restart
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -54,7 +43,7 @@ class PoolsUpdater {
|
||||
this.currentSha = await this.getShaFromDb();
|
||||
}
|
||||
|
||||
logger.debug(`pools-v2.json sha | Current: ${this.currentSha} | Github: ${githubSha}`, this.tag);
|
||||
logger.debug(`pools-v2.json sha | Current: ${this.currentSha} | Github: ${githubSha}`);
|
||||
if (this.currentSha !== null && this.currentSha === githubSha) {
|
||||
return;
|
||||
}
|
||||
@@ -64,16 +53,16 @@ class PoolsUpdater {
|
||||
config.MEMPOOL.AUTOMATIC_POOLS_UPDATE !== true && // Automatic pools update is disabled
|
||||
!process.env.npm_config_update_pools // We're not manually updating mining pool
|
||||
) {
|
||||
logger.warn(`Updated mining pools data is available (${githubSha}) but AUTOMATIC_POOLS_UPDATE is disabled`, this.tag);
|
||||
logger.info(`You can update your mining pools using the --update-pools command flag. You may want to clear your nginx cache as well if applicable`, this.tag);
|
||||
logger.warn(`Updated mining pools data is available (${githubSha}) but AUTOMATIC_POOLS_UPDATE is disabled`);
|
||||
logger.info(`You can update your mining pools using the --update-pools command flag. You may want to clear your nginx cache as well if applicable`);
|
||||
return;
|
||||
}
|
||||
|
||||
const network = config.SOCKS5PROXY.ENABLED ? 'tor' : 'clearnet';
|
||||
if (this.currentSha === null) {
|
||||
logger.info(`Downloading pools-v2.json for the first time from ${this.poolsUrl} over ${network}`, this.tag);
|
||||
logger.info(`Downloading pools-v2.json for the first time from ${this.poolsUrl} over ${network}`, logger.tags.mining);
|
||||
} else {
|
||||
logger.warn(`pools-v2.json is outdated, fetching latest from ${this.poolsUrl} over ${network}`, this.tag);
|
||||
logger.warn(`pools-v2.json is outdated, fetching latest from ${this.poolsUrl} over ${network}`, logger.tags.mining);
|
||||
}
|
||||
const poolsJson = await this.query(this.poolsUrl);
|
||||
if (poolsJson === undefined) {
|
||||
@@ -82,7 +71,7 @@ class PoolsUpdater {
|
||||
poolsParser.setMiningPools(poolsJson);
|
||||
|
||||
if (config.DATABASE.ENABLED === false) { // Don't run db operations
|
||||
logger.info(`Mining pools-v2.json (${githubSha}) import completed (no database)`, this.tag);
|
||||
logger.info(`Mining pools-v2.json (${githubSha}) import completed (no database)`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -92,14 +81,14 @@ class PoolsUpdater {
|
||||
await this.updateDBSha(githubSha);
|
||||
await DB.query('COMMIT;');
|
||||
} catch (e) {
|
||||
logger.err(`Could not migrate mining pools, rolling back. Exception: ${JSON.stringify(e)}`, this.tag);
|
||||
logger.err(`Could not migrate mining pools, rolling back. Exception: ${JSON.stringify(e)}`, logger.tags.mining);
|
||||
await DB.query('ROLLBACK;');
|
||||
}
|
||||
logger.info(`Mining pools-v2.json (${githubSha}) import completed`, this.tag);
|
||||
logger.info(`Mining pools-v2.json (${githubSha}) import completed`);
|
||||
|
||||
} catch (e) {
|
||||
this.lastRun = now - 600; // Try again in 10 minutes
|
||||
logger.err(`PoolsUpdater failed. Will try again in 10 minutes. Exception: ${JSON.stringify(e)}`, this.tag);
|
||||
this.lastRun = now - (oneWeek - oneDay); // Try again in 24h instead of waiting next week
|
||||
logger.err(`PoolsUpdater failed. Will try again in 24h. Exception: ${JSON.stringify(e)}`, logger.tags.mining);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +102,7 @@ class PoolsUpdater {
|
||||
await DB.query('DELETE FROM state where name="pools_json_sha"');
|
||||
await DB.query(`INSERT INTO state VALUES('pools_json_sha', NULL, '${githubSha}')`);
|
||||
} catch (e) {
|
||||
logger.err('Cannot save github pools-v2.json sha into the db. Reason: ' + (e instanceof Error ? e.message : e), this.tag);
|
||||
logger.err('Cannot save github pools-v2.json sha into the db. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,7 +115,7 @@ class PoolsUpdater {
|
||||
const [rows]: any[] = await DB.query('SELECT string FROM state WHERE name="pools_json_sha"');
|
||||
return (rows.length > 0 ? rows[0].string : null);
|
||||
} catch (e) {
|
||||
logger.err('Cannot fetch pools-v2.json sha from db. Reason: ' + (e instanceof Error ? e.message : e), this.tag);
|
||||
logger.err('Cannot fetch pools-v2.json sha from db. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -145,7 +134,7 @@ class PoolsUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
logger.err(`Cannot find "pools-v2.json" in git tree (${this.treeUrl})`, this.tag);
|
||||
logger.err(`Cannot find "pools-v2.json" in git tree (${this.treeUrl})`, logger.tags.mining);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -197,7 +186,7 @@ class PoolsUpdater {
|
||||
}
|
||||
return data.data;
|
||||
} catch (e) {
|
||||
logger.err('Could not connect to Github. Reason: ' + (e instanceof Error ? e.message : e), this.tag);
|
||||
logger.err('Could not connect to Github. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
retry++;
|
||||
}
|
||||
await setDelay(config.MEMPOOL.EXTERNAL_RETRY_INTERVAL);
|
||||
|
||||
@@ -200,28 +200,4 @@ export function getVarIntLength(n: number): number {
|
||||
} else {
|
||||
return 9;
|
||||
}
|
||||
}
|
||||
|
||||
/** Extracts miner names from a DATUM coinbase transaction */
|
||||
export function parseDATUMTemplateCreator(coinbaseRaw: string): string[] | null {
|
||||
let bytes: number[] = [];
|
||||
for (let c = 0; c < coinbaseRaw.length; c += 2) {
|
||||
bytes.push(parseInt(coinbaseRaw.slice(c, c + 2), 16));
|
||||
}
|
||||
|
||||
// Skip block height
|
||||
let tagLengthByte = 1 + bytes[0];
|
||||
|
||||
let tagsLength = bytes[tagLengthByte];
|
||||
if (tagsLength == 0x4c) {
|
||||
tagLengthByte += 1;
|
||||
tagsLength = bytes[tagLengthByte];
|
||||
}
|
||||
|
||||
const tagStart = tagLengthByte + 1;
|
||||
const tags = bytes.slice(tagStart, tagStart + tagsLength);
|
||||
let tagString = String.fromCharCode(...tags);
|
||||
tagString = tagString.replace('\x00', '');
|
||||
|
||||
return tagString.split('\x0f').map((name) => name.replace(/[^a-zA-Z0-9 ]/g, ''));
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import * as fs from 'fs';
|
||||
import logger from '../logger';
|
||||
import config from '../config';
|
||||
|
||||
function readFile(filePath: string, bufferSize?: number): string[] {
|
||||
const fileSize = fs.statSync(filePath).size;
|
||||
const chunkSize = bufferSize || fileSize;
|
||||
const fileDescriptor = fs.openSync(filePath, 'r');
|
||||
const buffer = Buffer.alloc(chunkSize);
|
||||
|
||||
fs.readSync(fileDescriptor, buffer, 0, chunkSize, fileSize - chunkSize);
|
||||
fs.closeSync(fileDescriptor);
|
||||
|
||||
const lines = buffer.toString('utf8', 0, chunkSize).split('\n');
|
||||
return lines;
|
||||
}
|
||||
|
||||
function extractDateFromLogLine(line: string): number | undefined {
|
||||
// Extract time from log: "2021-08-31T12:34:56Z" or "2021-08-31T12:34:56.123456Z"
|
||||
const dateMatch = line.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{6})?Z/);
|
||||
if (!dateMatch) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const dateStr = dateMatch[0];
|
||||
const date = new Date(dateStr);
|
||||
let timestamp = Math.floor(date.getTime() / 1000); // Remove decimal (microseconds are added later)
|
||||
|
||||
const timePart = dateStr.split('T')[1];
|
||||
const microseconds = timePart.split('.')[1] || '';
|
||||
|
||||
if (!microseconds) {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
return parseFloat(timestamp + '.' + microseconds);
|
||||
}
|
||||
|
||||
export function getRecentFirstSeen(hash: string): number | undefined {
|
||||
const debugLogPath = config.CORE_RPC.DEBUG_LOG_PATH;
|
||||
if (debugLogPath) {
|
||||
try {
|
||||
// Read the last few lines of debug.log
|
||||
const lines = readFile(debugLogPath, 2048);
|
||||
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i];
|
||||
if (line && line.includes(`Saw new header hash=${hash}`)) {
|
||||
return extractDateFromLogLine(line);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err(`Cannot parse block first seen time from Core logs. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -109,7 +109,6 @@ Below we list all settings from `mempool-config.json` and the corresponding over
|
||||
"AUTOMATIC_POOLS_UPDATE": false,
|
||||
"POOLS_JSON_URL": "https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json",
|
||||
"POOLS_JSON_TREE_URL": "https://api.github.com/repos/mempool/mining-pools/git/trees/master",
|
||||
"POOLS_UPDATE_DELAY": 604800,
|
||||
"CPFP_INDEXING": false,
|
||||
"MAX_BLOCKS_BULK_QUERY": 0,
|
||||
"DISK_CACHE_BLOCK_INTERVAL": 6,
|
||||
@@ -141,7 +140,6 @@ Corresponding `docker-compose.yml` overrides:
|
||||
MEMPOOL_AUTOMATIC_POOLS_UPDATE: ""
|
||||
MEMPOOL_POOLS_JSON_URL: ""
|
||||
MEMPOOL_POOLS_JSON_TREE_URL: ""
|
||||
MEMPOOL_POOLS_UPDATE_DELAY: ""
|
||||
MEMPOOL_CPFP_INDEXING: ""
|
||||
MEMPOOL_MAX_BLOCKS_BULK_QUERY: ""
|
||||
MEMPOOL_DISK_CACHE_BLOCK_INTERVAL: ""
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
"ALLOW_UNREACHABLE": __MEMPOOL_ALLOW_UNREACHABLE__,
|
||||
"POOLS_JSON_TREE_URL": "__MEMPOOL_POOLS_JSON_TREE_URL__",
|
||||
"POOLS_JSON_URL": "__MEMPOOL_POOLS_JSON_URL__",
|
||||
"POOLS_UPDATE_DELAY": __MEMPOOL_POOLS_UPDATE_DELAY__,
|
||||
"PRICE_UPDATES_PER_HOUR": __MEMPOOL_PRICE_UPDATES_PER_HOUR__,
|
||||
"MAX_TRACKED_ADDRESSES": __MEMPOOL_MAX_TRACKED_ADDRESSES__
|
||||
},
|
||||
@@ -47,8 +46,7 @@
|
||||
"PASSWORD": "__CORE_RPC_PASSWORD__",
|
||||
"TIMEOUT": __CORE_RPC_TIMEOUT__,
|
||||
"COOKIE": __CORE_RPC_COOKIE__,
|
||||
"COOKIE_PATH": "__CORE_RPC_COOKIE_PATH__",
|
||||
"DEBUG_LOG_PATH": "__CORE_RPC_DEBUG_LOG_PATH__"
|
||||
"COOKIE_PATH": "__CORE_RPC_COOKIE_PATH__"
|
||||
},
|
||||
"ELECTRUM": {
|
||||
"HOST": "__ELECTRUM_HOST__",
|
||||
|
||||
@@ -29,9 +29,8 @@ __MEMPOOL_STDOUT_LOG_MIN_PRIORITY__=${MEMPOOL_STDOUT_LOG_MIN_PRIORITY:=info}
|
||||
__MEMPOOL_AUTOMATIC_POOLS_UPDATE__=${MEMPOOL_AUTOMATIC_POOLS_UPDATE:=false}
|
||||
__MEMPOOL_POOLS_JSON_URL__=${MEMPOOL_POOLS_JSON_URL:=https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json}
|
||||
__MEMPOOL_POOLS_JSON_TREE_URL__=${MEMPOOL_POOLS_JSON_TREE_URL:=https://api.github.com/repos/mempool/mining-pools/git/trees/master}
|
||||
__MEMPOOL_POOLS_UPDATE_DELAY__=${MEMPOOL_POOLS_UPDATE_DELAY:=604800}
|
||||
__MEMPOOL_AUDIT__=${MEMPOOL_AUDIT:=false}
|
||||
__MEMPOOL_RUST_GBT__=${MEMPOOL_RUST_GBT:=true}
|
||||
__MEMPOOL_RUST_GBT__=${MEMPOOL_RUST_GBT:=false}
|
||||
__MEMPOOL_LIMIT_GBT__=${MEMPOOL_LIMIT_GBT:=false}
|
||||
__MEMPOOL_CPFP_INDEXING__=${MEMPOOL_CPFP_INDEXING:=false}
|
||||
__MEMPOOL_MAX_BLOCKS_BULK_QUERY__=${MEMPOOL_MAX_BLOCKS_BULK_QUERY:=0}
|
||||
@@ -49,7 +48,6 @@ __CORE_RPC_PASSWORD__=${CORE_RPC_PASSWORD:=mempool}
|
||||
__CORE_RPC_TIMEOUT__=${CORE_RPC_TIMEOUT:=60000}
|
||||
__CORE_RPC_COOKIE__=${CORE_RPC_COOKIE:=false}
|
||||
__CORE_RPC_COOKIE_PATH__=${CORE_RPC_COOKIE_PATH:=""}
|
||||
__CORE_RPC_DEBUG_LOG_PATH__=${CORE_RPC_DEBUG_LOG_PATH:=""}
|
||||
|
||||
# ELECTRUM
|
||||
__ELECTRUM_HOST__=${ELECTRUM_HOST:=127.0.0.1}
|
||||
@@ -189,7 +187,6 @@ sed -i "s!__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__!${__MEMPOOL_STDOUT_LOG_MIN_PRIORIT
|
||||
sed -i "s!__MEMPOOL_AUTOMATIC_POOLS_UPDATE__!${__MEMPOOL_AUTOMATIC_POOLS_UPDATE__}!g" mempool-config.json
|
||||
sed -i "s!__MEMPOOL_POOLS_JSON_URL__!${__MEMPOOL_POOLS_JSON_URL__}!g" mempool-config.json
|
||||
sed -i "s!__MEMPOOL_POOLS_JSON_TREE_URL__!${__MEMPOOL_POOLS_JSON_TREE_URL__}!g" mempool-config.json
|
||||
sed -i "s!__MEMPOOL_POOLS_UPDATE_DELAY__!${__MEMPOOL_POOLS_UPDATE_DELAY__}!g" mempool-config.json
|
||||
sed -i "s!__MEMPOOL_AUDIT__!${__MEMPOOL_AUDIT__}!g" mempool-config.json
|
||||
sed -i "s!__MEMPOOL_RUST_GBT__!${__MEMPOOL_RUST_GBT__}!g" mempool-config.json
|
||||
sed -i "s!__MEMPOOL_LIMIT_GBT__!${__MEMPOOL_LIMIT_GBT__}!g" mempool-config.json
|
||||
@@ -208,7 +205,6 @@ sed -i "s!__CORE_RPC_PASSWORD__!${__CORE_RPC_PASSWORD__}!g" mempool-config.json
|
||||
sed -i "s!__CORE_RPC_TIMEOUT__!${__CORE_RPC_TIMEOUT__}!g" mempool-config.json
|
||||
sed -i "s!__CORE_RPC_COOKIE__!${__CORE_RPC_COOKIE__}!g" mempool-config.json
|
||||
sed -i "s!__CORE_RPC_COOKIE_PATH__!${__CORE_RPC_COOKIE_PATH__}!g" mempool-config.json
|
||||
sed -i "s!__CORE_RPC_DEBUG_LOG_PATH__!${__CORE_RPC_DEBUG_LOG_PATH__}!g" mempool-config.json
|
||||
|
||||
sed -i "s!__ELECTRUM_HOST__!${__ELECTRUM_HOST__}!g" mempool-config.json
|
||||
sed -i "s!__ELECTRUM_PORT__!${__ELECTRUM_PORT__}!g" mempool-config.json
|
||||
|
||||
@@ -594,4 +594,63 @@ describe('Mainnet', () => {
|
||||
} else {
|
||||
it.skip(`Tests cannot be run on the selected BASE_MODULE ${baseModule}`);
|
||||
}
|
||||
|
||||
describe('Accelerated Transactions', () => {
|
||||
describe('Unconfirmed Accelerated Transaction', () => {
|
||||
before(() => {
|
||||
cy.intercept('/api/tx/40ba6b3c4ce73e3ba0160c137b1cc6c2c7333a2b9c19537b61ee8a8aaf095e0a', {
|
||||
fixture: 'accelerated_tx.json'
|
||||
}).as('tx');
|
||||
|
||||
cy.intercept('/api/v1/cpfp/40ba6b3c4ce73e3ba0160c137b1cc6c2c7333a2b9c19537b61ee8a8aaf095e0a', {
|
||||
fixture: 'accelerated_cpfp.json'
|
||||
}).as('accelerated_cpfp');
|
||||
|
||||
cy.intercept('/api/v1/transaction-times?txId%5B%5D=40ba6b3c4ce73e3ba0160c137b1cc6c2c7333a2b9c19537b61ee8a8aaf095e0a', {
|
||||
body: '[1723416086]',
|
||||
}).as('transaction-time');
|
||||
|
||||
cy.intercept('https://mempool.space/api/v1/services/accelerator/accelerations/history', {
|
||||
fixture: 'accelerated_history.json'
|
||||
}).as('history');
|
||||
|
||||
cy.viewport('macbook-16');
|
||||
cy.mockMempoolSocket();
|
||||
cy.visit('/tx/40ba6b3c4ce73e3ba0160c137b1cc6c2c7333a2b9c19537b61ee8a8aaf095e0a');
|
||||
|
||||
emitMempoolInfo({
|
||||
'params': {
|
||||
command: 'txPosition'
|
||||
}
|
||||
});
|
||||
cy.waitForSkeletonGone();
|
||||
});
|
||||
|
||||
it('shows unconfirmed accelerated transaction properly', () => {
|
||||
cy.get('.badge-accelerated').should('exist');
|
||||
cy.get('[data-cy="active-acceleration-box"]').should('exist');
|
||||
cy.get('[data-cy="active-acceleration-box"] > table > tbody > :nth-child(1) .oobFees').invoke('text').should('contain', `15.5 `);
|
||||
cy.get('[data-cy="tx-fee-delta"]').invoke('text').should('contain', `3,000`);
|
||||
cy.get('#acceleration-timeline').should('be.visible');
|
||||
});
|
||||
|
||||
// currently doesn't work due to 'accelerations/history' endpoint not being intercepted
|
||||
it.skip('properly render accelerated transacion as it confirms', () => {
|
||||
emitMempoolInfo({
|
||||
'params': {
|
||||
command: 'txPositionConfirmed'
|
||||
}
|
||||
});
|
||||
|
||||
cy.wait(1000);
|
||||
|
||||
cy.get('.badge-accelerated').should('exist');
|
||||
cy.get('[data-cy="active-acceleration-box"]').should('not.exist');
|
||||
cy.get('[data-cy="fee-rate"]').invoke('text').should('contain', `2.17 `);
|
||||
cy.get('[data-cy="tx-fee-delta"]').invoke('text').should('contain', `39`);
|
||||
cy.get('#acceleration-timeline').should('be.visible');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
20
frontend/cypress/fixtures/accelerated_cpfp.json
Normal file
20
frontend/cypress/fixtures/accelerated_cpfp.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"ancestors": [],
|
||||
"bestDescendant": null,
|
||||
"descendants": [],
|
||||
"effectiveFeePerVsize": 15.452914798206278,
|
||||
"sigops": 4,
|
||||
"fee": 446,
|
||||
"adjustedVsize": 223,
|
||||
"acceleration": true,
|
||||
"acceleratedBy": [
|
||||
111,
|
||||
43,
|
||||
102,
|
||||
112,
|
||||
142,
|
||||
115
|
||||
],
|
||||
"acceleratedAt": 1723417553,
|
||||
"feeDelta": 3000
|
||||
}
|
||||
24
frontend/cypress/fixtures/accelerated_history.json
Normal file
24
frontend/cypress/fixtures/accelerated_history.json
Normal file
@@ -0,0 +1,24 @@
|
||||
[
|
||||
{
|
||||
"txid": "40ba6b3c4ce73e3ba0160c137b1cc6c2c7333a2b9c19537b61ee8a8aaf095e0a",
|
||||
"status": "completed",
|
||||
"added": 1723417553,
|
||||
"lastUpdated": 1723424127,
|
||||
"effectiveFee": 446,
|
||||
"effectiveVsize": 223,
|
||||
"feeDelta": 3000,
|
||||
"blockHash": "000000000000000000005bc0a822da172e43c687428cc268177ad27d636f3059",
|
||||
"blockHeight": 856387,
|
||||
"bidBoost": 39,
|
||||
"boostVersion": "v2",
|
||||
"pools": [
|
||||
111,
|
||||
43,
|
||||
102,
|
||||
112,
|
||||
142,
|
||||
115
|
||||
],
|
||||
"minedByPoolUniqueId": 111
|
||||
}
|
||||
]
|
||||
48
frontend/cypress/fixtures/accelerated_position.json
Normal file
48
frontend/cypress/fixtures/accelerated_position.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"txPosition": {
|
||||
"txid": "40ba6b3c4ce73e3ba0160c137b1cc6c2c7333a2b9c19537b61ee8a8aaf095e0a",
|
||||
"position": {
|
||||
"block": 0,
|
||||
"vsize": 37321.5,
|
||||
"accelerated": true
|
||||
},
|
||||
"accelerationPositions": [
|
||||
{
|
||||
"block": 0,
|
||||
"vsize": 37321.5,
|
||||
"poolId": 111,
|
||||
"pool": "Foundry USA"
|
||||
},
|
||||
{
|
||||
"block": 0,
|
||||
"vsize": 37321.5,
|
||||
"poolId": 43,
|
||||
"pool": "Braiins Pool"
|
||||
},
|
||||
{
|
||||
"block": 0,
|
||||
"vsize": 37321.5,
|
||||
"poolId": 102,
|
||||
"pool": "SpiderPool"
|
||||
},
|
||||
{
|
||||
"block": 0,
|
||||
"vsize": 37321.5,
|
||||
"poolId": 112,
|
||||
"pool": "SBI Crypto"
|
||||
},
|
||||
{
|
||||
"block": 0,
|
||||
"vsize": 37321.5,
|
||||
"poolId": 142,
|
||||
"pool": "OCEAN"
|
||||
},
|
||||
{
|
||||
"block": 0,
|
||||
"vsize": 37321.5,
|
||||
"poolId": 115,
|
||||
"pool": "MARA Pool"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"txConfirmed": "40ba6b3c4ce73e3ba0160c137b1cc6c2c7333a2b9c19537b61ee8a8aaf095e0a",
|
||||
"block":{
|
||||
"id": "000000000000000000014cc3d86b7c096ef92aca180e3cf27d72e34ce944caed",
|
||||
"height": 837051,
|
||||
"version": 821051392,
|
||||
"timestamp": 1723452588,
|
||||
"bits": 386079422,
|
||||
"nonce": 2215159619,
|
||||
"difficulty": 90666502495565.78,
|
||||
"merkle_root": "207ad51f6c1150f63fcd043eb1b4624b77ac70558594317e989c1109fbb47c47",
|
||||
"tx_count": 2284,
|
||||
"size": 1490522,
|
||||
"weight": 3993155,
|
||||
"previousblockhash": "00000000000000000002b8a66307c997aa27bf99a384ceb7cfe5f29576eddb26",
|
||||
"mediantime": 1723450608,
|
||||
"stale": false,
|
||||
"extras": {
|
||||
"reward": 319417632,
|
||||
"coinbaseRaw": "0378110d04adccb9662f466f756e6472792055534120506f6f6c202364726f70676f6c642f2c08727fca05000000000000",
|
||||
"orphans": [],
|
||||
"medianFee": 4.021446911342697,
|
||||
"feeRange": [
|
||||
3.1,
|
||||
3.4184397163120566,
|
||||
3.998624011007912,
|
||||
4.444976076555024,
|
||||
5.382978723404255,
|
||||
11.62814371257485,
|
||||
468.75
|
||||
],
|
||||
"totalFees": 6917632,
|
||||
"avgFee": 3030,
|
||||
"avgFeeRate": 6,
|
||||
"utxoSetChange": -2647,
|
||||
"avgTxSize": 652.44,
|
||||
"totalInputs": 8544,
|
||||
"totalOutputs": 5897,
|
||||
"totalOutputAmt": 2950130527407,
|
||||
"segwitTotalTxs": 2084,
|
||||
"segwitTotalSize": 1137877,
|
||||
"segwitTotalWeight": 2582683,
|
||||
"feePercentiles": null,
|
||||
"virtualSize": 998288.75,
|
||||
"coinbaseAddress": "bc1p8k4v4xuz55dv49svzjg43qjxq2whur7ync9tm0xgl5t4wjl9ca9snxgmlt",
|
||||
"coinbaseAddresses": [
|
||||
"bc1p8k4v4xuz55dv49svzjg43qjxq2whur7ync9tm0xgl5t4wjl9ca9snxgmlt",
|
||||
"bc1qxhmdufsvnuaaaer4ynz88fspdsxq2h9e9cetdj"
|
||||
],
|
||||
"coinbaseSignature": "OP_PUSHNUM_1 OP_PUSHBYTES_32 3daaca9b82a51aca960c1491588246029d7e0fc49e0abdbcc8fd17574be5c74b",
|
||||
"coinbaseSignatureAscii": "f/Foundry USA Pool #dropgold/",
|
||||
"header": "0040f03026dbed7695f2e5cfb7ce84a399bf27aa97c90763a6b802000000000000000000477cb4fb09119c987e3194855570ac774b62b4b13e04cd3ff650116c1fd57a20acccb966be1a031743a70884",
|
||||
"utxoSetSize": null,
|
||||
"totalInputAmt": null,
|
||||
"pool": {
|
||||
"id": 111,
|
||||
"name": "Foundry USA",
|
||||
"slug": "foundryusa"
|
||||
},
|
||||
"matchRate": 100,
|
||||
"expectedFees": 6957093,
|
||||
"expectedWeight": 3991895,
|
||||
"similarity": 0.9907343565880212
|
||||
}
|
||||
}
|
||||
}
|
||||
45
frontend/cypress/fixtures/accelerated_tx.json
Normal file
45
frontend/cypress/fixtures/accelerated_tx.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"txid": "40ba6b3c4ce73e3ba0160c137b1cc6c2c7333a2b9c19537b61ee8a8aaf095e0a",
|
||||
"version": 1,
|
||||
"locktime": 0,
|
||||
"vin": [
|
||||
{
|
||||
"txid": "7c6e17739d7225d097db1f08df17d06dc712dc0951f266db1070939b85b5e8e7",
|
||||
"vout": 0,
|
||||
"prevout": {
|
||||
"scriptpubkey": "76a914fb706ea28ba8f83e3cfa2fa1f3f01a6a613b94ca88ac",
|
||||
"scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 fb706ea28ba8f83e3cfa2fa1f3f01a6a613b94ca OP_EQUALVERIFY OP_CHECKSIG",
|
||||
"scriptpubkey_type": "p2pkh",
|
||||
"scriptpubkey_address": "1PvVJ5FvkNnsatmD4nfkb6j59CjKq7dxxy",
|
||||
"value": 16610556
|
||||
},
|
||||
"scriptsig": "483045022100811726483f9c91dd91aa136c6ba4e97e6db79ef7026aa4fdd4216ea6a954f91a0220508b7fdf4078bf82114f7cfed5090b77114dec19b122870a34e562689441399d01210275f84bf0270b233f83be9b1ba6549e3281a133bfd93b24e1c16d80c4e742f09e",
|
||||
"scriptsig_asm": "OP_PUSHBYTES_72 3045022100811726483f9c91dd91aa136c6ba4e97e6db79ef7026aa4fdd4216ea6a954f91a0220508b7fdf4078bf82114f7cfed5090b77114dec19b122870a34e562689441399d01 OP_PUSHBYTES_33 0275f84bf0270b233f83be9b1ba6549e3281a133bfd93b24e1c16d80c4e742f09e",
|
||||
"is_coinbase": false,
|
||||
"sequence": 4294967295
|
||||
}
|
||||
],
|
||||
"vout": [
|
||||
{
|
||||
"scriptpubkey": "0014ce6c0bb00482016d12657174b6468cd01df6421e",
|
||||
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 ce6c0bb00482016d12657174b6468cd01df6421e",
|
||||
"scriptpubkey_type": "v0_p2wpkh",
|
||||
"scriptpubkey_address": "bc1qeekqhvqysgqk6yn9w96tv35v6qwlvss7vuvtj0",
|
||||
"value": 6796193
|
||||
},
|
||||
{
|
||||
"scriptpubkey": "76a914fb706ea28ba8f83e3cfa2fa1f3f01a6a613b94ca88ac",
|
||||
"scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 fb706ea28ba8f83e3cfa2fa1f3f01a6a613b94ca OP_EQUALVERIFY OP_CHECKSIG",
|
||||
"scriptpubkey_type": "p2pkh",
|
||||
"scriptpubkey_address": "1PvVJ5FvkNnsatmD4nfkb6j59CjKq7dxxy",
|
||||
"value": 9813917
|
||||
}
|
||||
],
|
||||
"size": 223,
|
||||
"weight": 892,
|
||||
"sigops": 4,
|
||||
"fee": 446,
|
||||
"status": {
|
||||
"confirmed": false
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,18 @@ export const emitMempoolInfo = ({
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'txPosition': {
|
||||
cy.readFile('cypress/fixtures/accelerated_position.json', 'ascii').then((fixture) => {
|
||||
win.mockSocket.send(JSON.stringify(fixture));
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'txPositionConfirmed': {
|
||||
cy.readFile('cypress/fixtures/accelerated_position_confirmed.json', 'ascii').then((fixture) => {
|
||||
win.mockSocket.send(JSON.stringify(fixture));
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
911
frontend/package-lock.json
generated
911
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -95,7 +95,7 @@
|
||||
"esbuild": "^0.24.0",
|
||||
"tinyify": "^4.0.0",
|
||||
"tlite": "^0.1.9",
|
||||
"tslib": "~2.8.0",
|
||||
"tslib": "~2.7.0",
|
||||
"zone.js": "~0.14.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -105,7 +105,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^7.4.0",
|
||||
"@typescript-eslint/parser": "^7.4.0",
|
||||
"eslint": "^8.57.0",
|
||||
"browser-sync": "^3.0.3",
|
||||
"browser-sync": "^3.0.0",
|
||||
"http-proxy-middleware": "~2.0.6",
|
||||
"prettier": "^3.0.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
@@ -115,7 +115,7 @@
|
||||
"optionalDependencies": {
|
||||
"@cypress/schematic": "^2.5.0",
|
||||
"@types/cypress": "^1.1.3",
|
||||
"cypress": "^13.15.0",
|
||||
"cypress": "^13.14.0",
|
||||
"cypress-fail-on-console-error": "~5.1.0",
|
||||
"cypress-wait-until": "^2.0.1",
|
||||
"mock-socket": "~9.3.1",
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Routes, RouterModule } from '@angular/router';
|
||||
import { AppPreloadingStrategy } from './app.preloading-strategy'
|
||||
import { BlockViewComponent } from './components/block-view/block-view.component';
|
||||
import { EightBlocksComponent } from './components/eight-blocks/eight-blocks.component';
|
||||
import { EightMempoolComponent } from './components/eight-mempool/eight-mempool.component';
|
||||
import { MempoolBlockViewComponent } from './components/mempool-block-view/mempool-block-view.component';
|
||||
import { ClockComponent } from './components/clock/clock.component';
|
||||
import { StatusViewComponent } from './components/status-view/status-view.component';
|
||||
@@ -206,10 +205,6 @@ let routes: Routes = [
|
||||
path: 'view/blocks',
|
||||
component: EightBlocksComponent,
|
||||
},
|
||||
{
|
||||
path: 'view/mempool-blocks',
|
||||
component: EightMempoolComponent,
|
||||
},
|
||||
{
|
||||
path: 'status',
|
||||
data: { networks: ['bitcoin', 'liquid'] },
|
||||
|
||||
@@ -6,7 +6,6 @@ import { ZONE_SERVICE } from './injection-tokens';
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
import { AppComponent } from './components/app/app.component';
|
||||
import { ElectrsApiService } from './services/electrs-api.service';
|
||||
import { OrdApiService } from './services/ord-api.service';
|
||||
import { StateService } from './services/state.service';
|
||||
import { CacheService } from './services/cache.service';
|
||||
import { PriceService } from './services/price.service';
|
||||
@@ -22,7 +21,6 @@ import { StorageService } from './services/storage.service';
|
||||
import { HttpCacheInterceptor } from './services/http-cache.interceptor';
|
||||
import { LanguageService } from './services/language.service';
|
||||
import { ThemeService } from './services/theme.service';
|
||||
import { TimeService } from './services/time.service';
|
||||
import { FiatShortenerPipe } from './shared/pipes/fiat-shortener.pipe';
|
||||
import { FiatCurrencyPipe } from './shared/pipes/fiat-currency.pipe';
|
||||
import { ShortenStringPipe } from './shared/pipes/shorten-string-pipe/shorten-string.pipe';
|
||||
@@ -33,7 +31,6 @@ import { DatePipe } from '@angular/common';
|
||||
|
||||
const providers = [
|
||||
ElectrsApiService,
|
||||
OrdApiService,
|
||||
StateService,
|
||||
CacheService,
|
||||
PriceService,
|
||||
@@ -45,7 +42,6 @@ const providers = [
|
||||
EnterpriseService,
|
||||
LanguageService,
|
||||
ThemeService,
|
||||
TimeService,
|
||||
ShortenStringPipe,
|
||||
FiatShortenerPipe,
|
||||
FiatCurrencyPipe,
|
||||
|
||||
@@ -201,17 +201,12 @@
|
||||
<img class="image" src="/resources/profile/leather.svg" />
|
||||
<span>Leather</span>
|
||||
</a>
|
||||
|
||||
<a href="https://taprootwizards.com/" target="_blank" title="Taproot Wizards">
|
||||
<img class="image" src="/resources/profile/wizardhat.png" />
|
||||
<span>Taproot Wizards</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-container>
|
||||
<div *ngIf="profiles$ | async as profiles" id="community-sponsors-anchor">
|
||||
<div class="community-sponsor whale-sponsor" style="margin-bottom: 68px" *ngIf="profiles.whales.length > 0">
|
||||
<div class="community-sponsor" style="margin-bottom: 68px" *ngIf="profiles.whales.length > 0">
|
||||
<h3 i18n="about.sponsors.withHeart">Whale Sponsors</h3>
|
||||
<div class="wrapper">
|
||||
<ng-container>
|
||||
|
||||
@@ -92,13 +92,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.whale-sponsor {
|
||||
img {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
}
|
||||
}
|
||||
|
||||
.alliances {
|
||||
margin-bottom: 100px;
|
||||
a {
|
||||
|
||||
@@ -374,7 +374,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
||||
this.selectFeeRateIndex = index;
|
||||
this.userBid = Math.max(0, fee);
|
||||
this.cost = this.userBid + this.estimate.mempoolBaseFee + this.estimate.vsizeFee;
|
||||
this.validateChoice();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,8 +525,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
||||
tokenResult.token,
|
||||
cardTag,
|
||||
`accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
|
||||
this.accelerationUUID,
|
||||
costUSD
|
||||
this.accelerationUUID
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.processing = false;
|
||||
@@ -626,8 +624,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
||||
tokenResult.token,
|
||||
cardTag,
|
||||
`accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
|
||||
this.accelerationUUID,
|
||||
costUSD
|
||||
this.accelerationUUID
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.processing = false;
|
||||
@@ -717,8 +714,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
|
||||
tokenResult.token,
|
||||
tokenResult.details.cashAppPay.cashtag,
|
||||
tokenResult.details.cashAppPay.referenceId,
|
||||
this.accelerationUUID,
|
||||
costUSD
|
||||
this.accelerationUUID
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.processing = false;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<div class="interval">
|
||||
<div class="interval-time">
|
||||
@if (eta) {
|
||||
~<app-time [time]="eta?.wait / 1000"></app-time>
|
||||
~<app-time [time]="eta?.wait / 1000"></app-time> <!-- <span *ngIf="accelerateRatio > 1" class="compare"> ({{ accelerateRatio }}x faster)</span> -->
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -38,7 +38,7 @@
|
||||
<div class="node-spacer"></div>
|
||||
<div class="interval">
|
||||
<div class="interval-time">
|
||||
<app-time [time]="firstSeenToAccelerated"></app-time>
|
||||
<app-time [time]="acceleratedAt - transactionTime"></app-time>
|
||||
</div>
|
||||
</div>
|
||||
<div class="node-spacer"></div>
|
||||
@@ -46,8 +46,10 @@
|
||||
<div class="interval-time">
|
||||
@if (tx.status.confirmed) {
|
||||
<div class="interval-time">
|
||||
<app-time [time]="acceleratedToMined"></app-time>
|
||||
<app-time [time]="tx.status.block_time - acceleratedAt"></app-time>
|
||||
</div>
|
||||
} @else if (standardETA && !tx.status.confirmed) {
|
||||
<!-- ~<app-time [time]="standardETA / 1000 - now"></app-time> -->
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,16 +11,19 @@ import { MiningService } from '../../services/mining.service';
|
||||
})
|
||||
export class AccelerationTimelineComponent implements OnInit, OnChanges {
|
||||
@Input() transactionTime: number;
|
||||
@Input() acceleratedAt: number;
|
||||
@Input() tx: Transaction;
|
||||
@Input() accelerationInfo: Acceleration;
|
||||
@Input() eta: ETA;
|
||||
// A mined transaction has standard ETA and accelerated ETA undefined
|
||||
// A transaction in mempool has either standardETA defined (if accelerated) or acceleratedETA defined (if not accelerated yet)
|
||||
@Input() standardETA: number;
|
||||
@Input() acceleratedETA: number;
|
||||
|
||||
acceleratedAt: number;
|
||||
now: number;
|
||||
accelerateRatio: number;
|
||||
useAbsoluteTime: boolean = false;
|
||||
firstSeenToAccelerated: number;
|
||||
acceleratedToMined: number;
|
||||
interval: number;
|
||||
|
||||
tooltipPosition = null;
|
||||
hoverInfo: any = null;
|
||||
@@ -31,24 +34,38 @@ export class AccelerationTimelineComponent implements OnInit, OnChanges {
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.updateTimes();
|
||||
this.acceleratedAt = this.tx.acceleratedAt ?? new Date().getTime() / 1000;
|
||||
this.now = Math.floor(new Date().getTime() / 1000);
|
||||
this.useAbsoluteTime = this.tx.status.block_time < this.now - 7 * 24 * 3600;
|
||||
|
||||
this.miningService.getPools().subscribe(pools => {
|
||||
for (const pool of pools) {
|
||||
this.poolsData[pool.unique_id] = pool;
|
||||
}
|
||||
});
|
||||
|
||||
this.interval = window.setInterval(() => {
|
||||
this.now = Math.floor(new Date().getTime() / 1000);
|
||||
this.useAbsoluteTime = this.tx.status.block_time < this.now - 7 * 24 * 3600;
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
ngOnChanges(changes): void {
|
||||
this.updateTimes();
|
||||
// Hide standard ETA while we don't have a proper standard ETA calculation, see https://github.com/mempool/mempool/issues/65
|
||||
|
||||
// if (changes?.eta?.currentValue || changes?.standardETA?.currentValue || changes?.acceleratedETA?.currentValue) {
|
||||
// if (changes?.eta?.currentValue) {
|
||||
// if (changes?.acceleratedETA?.currentValue) {
|
||||
// this.accelerateRatio = Math.floor((Math.floor(changes.eta.currentValue.time / 1000) - this.now) / (Math.floor(changes.acceleratedETA.currentValue / 1000) - this.now));
|
||||
// } else if (changes?.standardETA?.currentValue) {
|
||||
// this.accelerateRatio = Math.floor((Math.floor(changes.standardETA.currentValue / 1000) - this.now) / (Math.floor(changes.eta.currentValue.time / 1000) - this.now));
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
updateTimes(): void {
|
||||
this.now = Math.floor(new Date().getTime() / 1000);
|
||||
this.useAbsoluteTime = this.tx.status.block_time < this.now - 7 * 24 * 3600;
|
||||
this.firstSeenToAccelerated = Math.max(0, this.acceleratedAt - this.transactionTime);
|
||||
this.acceleratedToMined = Math.max(0, this.tx.status.block_time - this.acceleratedAt);
|
||||
ngOnDestroy(): void {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
|
||||
onHover(event, status: string): void {
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
<span *ngIf="acceleration.status === 'accelerating'" class="badge badge-warning" i18n="accelerator.pending">Pending</span>
|
||||
<span *ngIf="acceleration.status.includes('completed') && acceleration.minedByPoolUniqueId && pools[acceleration.minedByPoolUniqueId]" class="badge badge-success"><ng-container i18n="accelerator.completed">Completed</ng-container><span *ngIf="acceleration.status === 'completed_provisional'"> ⌛</span></span>
|
||||
<span *ngIf="acceleration.status.includes('completed') && (!acceleration.minedByPoolUniqueId || !pools[acceleration.minedByPoolUniqueId])" class="badge badge-success"><ng-container i18n="transaction.rbf.mined">Mined</ng-container><span *ngIf="acceleration.status === 'completed_provisional'"> ⌛</span></span>
|
||||
<span *ngIf="acceleration.status.includes('failed')" class="badge badge-danger"><ng-container i18n="accelerator.canceled">Canceled</ng-container><span *ngIf="acceleration.status === 'failed_provisional'"> ⌛</span></span>
|
||||
<span *ngIf="acceleration.status.includes('failed')" class="badge badge-danger"><ng-container i18n="accelerator.canceled">Failed</ng-container><span *ngIf="acceleration.status === 'failed_provisional'"> ⌛</span></span>
|
||||
</td>
|
||||
<td class="date text-right" *ngIf="!this.widget">
|
||||
<app-time kind="since" [time]="acceleration.added" [fastRender]="true" [showTooltip]="true"></app-time>
|
||||
|
||||
@@ -219,13 +219,9 @@ export class AddressComponent implements OnInit, OnDestroy {
|
||||
address.is_pubkey
|
||||
? this.electrsApiService.getScriptHashTransactions$((address.address.length === 66 ? '21' : '41') + address.address + 'ac')
|
||||
: this.electrsApiService.getAddressTransactions$(address.address),
|
||||
(utxoCount > 2 && utxoCount <= 500 ? (address.is_pubkey
|
||||
utxoCount >= 2 && utxoCount <= 500 ? (address.is_pubkey
|
||||
? this.electrsApiService.getScriptHashUtxos$((address.address.length === 66 ? '21' : '41') + address.address + 'ac')
|
||||
: this.electrsApiService.getAddressUtxos$(address.address)) : of(null)).pipe(
|
||||
catchError(() => {
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
: this.electrsApiService.getAddressUtxos$(address.address)) : of([])
|
||||
]);
|
||||
}),
|
||||
switchMap(([transactions, utxos]) => {
|
||||
@@ -323,7 +319,6 @@ export class AddressComponent implements OnInit, OnDestroy {
|
||||
this.transactions = this.transactions.slice();
|
||||
this.mempoolStats.removeTx(transaction);
|
||||
this.audioService.playSound('magic');
|
||||
this.confirmTransaction(tx);
|
||||
} else {
|
||||
if (this.addTransaction(transaction, false)) {
|
||||
this.audioService.playSound('magic');
|
||||
@@ -350,28 +345,20 @@ export class AddressComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
// update utxos in-place
|
||||
if (this.utxos != null) {
|
||||
let utxosChanged = false;
|
||||
for (const vin of transaction.vin) {
|
||||
const utxoIndex = this.utxos.findIndex((utxo) => utxo.txid === vin.txid && utxo.vout === vin.vout);
|
||||
if (utxoIndex !== -1) {
|
||||
this.utxos.splice(utxoIndex, 1);
|
||||
utxosChanged = true;
|
||||
}
|
||||
for (const vin of transaction.vin) {
|
||||
const utxoIndex = this.utxos.findIndex((utxo) => utxo.txid === vin.txid && utxo.vout === vin.vout);
|
||||
if (utxoIndex !== -1) {
|
||||
this.utxos.splice(utxoIndex, 1);
|
||||
}
|
||||
for (const [index, vout] of transaction.vout.entries()) {
|
||||
if (vout.scriptpubkey_address === this.address.address) {
|
||||
this.utxos.push({
|
||||
txid: transaction.txid,
|
||||
vout: index,
|
||||
value: vout.value,
|
||||
status: JSON.parse(JSON.stringify(transaction.status)),
|
||||
});
|
||||
utxosChanged = true;
|
||||
}
|
||||
}
|
||||
if (utxosChanged) {
|
||||
this.utxos = this.utxos.slice();
|
||||
}
|
||||
for (const [index, vout] of transaction.vout.entries()) {
|
||||
if (vout.scriptpubkey_address === this.address.address) {
|
||||
this.utxos.push({
|
||||
txid: transaction.txid,
|
||||
vout: index,
|
||||
value: vout.value,
|
||||
status: JSON.parse(JSON.stringify(transaction.status)),
|
||||
});
|
||||
}
|
||||
}
|
||||
return true;
|
||||
@@ -387,64 +374,28 @@ export class AddressComponent implements OnInit, OnDestroy {
|
||||
this.transactions = this.transactions.slice();
|
||||
|
||||
// update utxos in-place
|
||||
if (this.utxos != null) {
|
||||
let utxosChanged = false;
|
||||
for (const vin of transaction.vin) {
|
||||
if (vin.prevout?.scriptpubkey_address === this.address.address) {
|
||||
this.utxos.push({
|
||||
txid: vin.txid,
|
||||
vout: vin.vout,
|
||||
value: vin.prevout.value,
|
||||
status: { confirmed: true }, // Assuming the input was confirmed
|
||||
});
|
||||
utxosChanged = true;
|
||||
}
|
||||
for (const vin of transaction.vin) {
|
||||
if (vin.prevout?.scriptpubkey_address === this.address.address) {
|
||||
this.utxos.push({
|
||||
txid: vin.txid,
|
||||
vout: vin.vout,
|
||||
value: vin.prevout.value,
|
||||
status: { confirmed: true }, // Assuming the input was confirmed
|
||||
});
|
||||
}
|
||||
for (const [index, vout] of transaction.vout.entries()) {
|
||||
if (vout.scriptpubkey_address === this.address.address) {
|
||||
const utxoIndex = this.utxos.findIndex((utxo) => utxo.txid === transaction.txid && utxo.vout === index);
|
||||
if (utxoIndex !== -1) {
|
||||
this.utxos.splice(utxoIndex, 1);
|
||||
utxosChanged = true;
|
||||
}
|
||||
}
|
||||
for (const [index, vout] of transaction.vout.entries()) {
|
||||
if (vout.scriptpubkey_address === this.address.address) {
|
||||
const utxoIndex = this.utxos.findIndex((utxo) => utxo.txid === transaction.txid && utxo.vout === index);
|
||||
if (utxoIndex !== -1) {
|
||||
this.utxos.splice(utxoIndex, 1);
|
||||
}
|
||||
}
|
||||
if (utxosChanged) {
|
||||
this.utxos = this.utxos.slice();
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
confirmTransaction(transaction: Transaction): void {
|
||||
// update utxos in-place
|
||||
if (this.utxos != null) {
|
||||
let utxosChanged = false;
|
||||
for (const vin of transaction.vin) {
|
||||
if (vin.prevout?.scriptpubkey_address === this.address.address) {
|
||||
const utxoIndex = this.utxos.findIndex((utxo) => utxo.txid === vin.txid && utxo.vout === vin.vout);
|
||||
if (utxoIndex !== -1) {
|
||||
this.utxos[utxoIndex].status = JSON.parse(JSON.stringify(transaction.status));
|
||||
utxosChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [index, vout] of transaction.vout.entries()) {
|
||||
if (vout.scriptpubkey_address === this.address.address) {
|
||||
const utxoIndex = this.utxos.findIndex((utxo) => utxo.txid === transaction.txid && utxo.vout === index);
|
||||
if (utxoIndex !== -1) {
|
||||
this.utxos[utxoIndex].status = JSON.parse(JSON.stringify(transaction.status));
|
||||
utxosChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (utxosChanged) {
|
||||
this.utxos = this.utxos.slice();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadMore(): void {
|
||||
if (this.isLoadingTransactions || this.fullyLoaded) {
|
||||
return;
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
@if (digitsInfo === '1.8-8') {
|
||||
‎{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis | number }}
|
||||
} @else {
|
||||
‎{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis | amountShortener : (satoshis < 1000 && satoshis > -1000 ? 0 : 1) : undefined : true }}
|
||||
‎{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis | amountShortener : satoshis < 1000 && satoshis > -1000 ? 0 : 1 }}
|
||||
}
|
||||
<span class="symbol">
|
||||
<ng-container *ngTemplateOutlet="prefix"></ng-container>sats
|
||||
|
||||
@@ -553,7 +553,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
|
||||
x: cssX,
|
||||
y: cssY
|
||||
};
|
||||
const selected = this.scene.getTxAt({ x, y: this.displayHeight - y });
|
||||
const selected = this.scene.getTxAt({ x, y });
|
||||
const currentPreview = this.selectedTx || this.hoverTx;
|
||||
|
||||
if (selected !== currentPreview) {
|
||||
@@ -627,7 +627,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
|
||||
if (this.scene) {
|
||||
const x = cssX * window.devicePixelRatio;
|
||||
const y = cssY * window.devicePixelRatio;
|
||||
const selected = this.scene.getTxAt({ x, y: this.displayHeight - y });
|
||||
const selected = this.scene.getTxAt({ x, y });
|
||||
if (selected && selected.txid) {
|
||||
this.txClickEvent.emit({ tx: selected, keyModifier });
|
||||
}
|
||||
@@ -681,9 +681,10 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
|
||||
|
||||
// WebGL shader attributes
|
||||
const attribs = {
|
||||
bounds: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
|
||||
offset: { type: 'FLOAT', count: 2, pointer: null, offset: 0 },
|
||||
posX: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
|
||||
posY: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
|
||||
posR: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
|
||||
colR: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
|
||||
colG: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
|
||||
colB: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
|
||||
@@ -706,9 +707,10 @@ varying lowp vec4 vColor;
|
||||
// each attribute contains [x: startValue, y: endValue, z: startTime, w: rate]
|
||||
// shader interpolates between start and end values at the given rate, from the given time
|
||||
|
||||
attribute vec4 bounds;
|
||||
attribute vec2 offset;
|
||||
attribute vec4 posX;
|
||||
attribute vec4 posY;
|
||||
attribute vec4 posR;
|
||||
attribute vec4 colR;
|
||||
attribute vec4 colG;
|
||||
attribute vec4 colB;
|
||||
@@ -733,7 +735,10 @@ float interpolateAttribute(vec4 attr) {
|
||||
void main() {
|
||||
vec4 screenTransform = vec4(2.0 / screenSize.x, 2.0 / screenSize.y, -1.0, -1.0);
|
||||
// vec4 screenTransform = vec4(1.0 / screenSize.x, 1.0 / screenSize.y, -0.5, -0.5);
|
||||
vec2 position = clamp(vec2(interpolateAttribute(posX), interpolateAttribute(posY)), bounds.xy, bounds.zw);
|
||||
|
||||
float radius = interpolateAttribute(posR);
|
||||
vec2 position = vec2(interpolateAttribute(posX), interpolateAttribute(posY)) + (radius * offset);
|
||||
|
||||
gl_Position = vec4(position * screenTransform.xy + screenTransform.zw, 1.0, 1.0);
|
||||
|
||||
float red = interpolateAttribute(colR);
|
||||
|
||||
@@ -18,8 +18,6 @@ export default class BlockScene {
|
||||
animationOffset: number;
|
||||
highlightingEnabled: boolean;
|
||||
filterFlags: bigint | null = 0b00000100_00000000_00000000_00000000n;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
gridWidth: number;
|
||||
@@ -33,16 +31,14 @@ export default class BlockScene {
|
||||
animateUntil = 0;
|
||||
dirty: boolean;
|
||||
|
||||
constructor({ x = 0, y = 0, width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, theme, highlighting, colorFunction }:
|
||||
{ x?: number, y?: number, width: number, height: number, resolution: number, blockLimit: number, animationDuration: number, animationOffset: number,
|
||||
constructor({ width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, theme, highlighting, colorFunction }:
|
||||
{ width: number, height: number, resolution: number, blockLimit: number, animationDuration: number, animationOffset: number,
|
||||
orientation: string, flip: boolean, vertexArray: FastVertexArray, theme: ThemeService, highlighting: boolean, colorFunction: ((tx: TxView) => Color) | null }
|
||||
) {
|
||||
this.init({ x, y,width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, theme, highlighting, colorFunction });
|
||||
this.init({ width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, theme, highlighting, colorFunction });
|
||||
}
|
||||
|
||||
resize({ x = 0, y = 0, width = this.width, height = this.height, animate = true }: { x?: number, y?: number, width?: number, height?: number, animate: boolean }): void {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
resize({ width = this.width, height = this.height, animate = true }: { width?: number, height?: number, animate: boolean }): void {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.gridSize = this.width / this.gridWidth;
|
||||
@@ -228,11 +224,7 @@ export default class BlockScene {
|
||||
getTxAt(position: Position): TxView | void {
|
||||
if (this.layout) {
|
||||
const gridPosition = this.screenToGrid(position);
|
||||
if (gridPosition.x >= 0 && gridPosition.x < this.gridWidth && gridPosition.y >= 0 && gridPosition.y < this.gridHeight) {
|
||||
return this.layout.getTx(gridPosition);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return this.layout.getTx(gridPosition);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
@@ -246,8 +238,8 @@ export default class BlockScene {
|
||||
this.animateUntil = Math.max(this.animateUntil, tx.setHighlight(value));
|
||||
}
|
||||
|
||||
private init({ x, y, width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, theme, highlighting, colorFunction }:
|
||||
{ x: number, y: number, width: number, height: number, resolution: number, blockLimit: number, animationDuration: number, animationOffset: number,
|
||||
private init({ width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, theme, highlighting, colorFunction }:
|
||||
{ width: number, height: number, resolution: number, blockLimit: number, animationDuration: number, animationOffset: number,
|
||||
orientation: string, flip: boolean, vertexArray: FastVertexArray, theme: ThemeService, highlighting: boolean, colorFunction: ((tx: TxView) => Color) | null }
|
||||
): void {
|
||||
this.animationDuration = animationDuration || this.animationDuration || 1000;
|
||||
@@ -272,7 +264,7 @@ export default class BlockScene {
|
||||
this.vbytesPerUnit = blockLimit / Math.pow(resolution / 1.02, 2);
|
||||
this.gridWidth = resolution;
|
||||
this.gridHeight = resolution;
|
||||
this.resize({ x, y, width, height, animate: true });
|
||||
this.resize({ width, height, animate: true });
|
||||
this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight });
|
||||
|
||||
this.txs = {};
|
||||
@@ -282,7 +274,7 @@ export default class BlockScene {
|
||||
}
|
||||
|
||||
private applyTxUpdate(tx: TxView, update: ViewUpdateParams): void {
|
||||
this.animateUntil = Math.max(this.animateUntil, tx.update(update, { minX: this.x, maxX: this.x + this.width, minY: this.y, maxY: this.y + this.height }));
|
||||
this.animateUntil = Math.max(this.animateUntil, tx.update(update));
|
||||
}
|
||||
|
||||
private updateTxColor(tx: TxView, startTime: number, delay: number, animate: boolean = true, duration?: number): void {
|
||||
@@ -398,7 +390,6 @@ export default class BlockScene {
|
||||
position: {
|
||||
x: tx.screenPosition.x + (direction === 'right' ? this.width + this.animationOffset : (direction === 'left' ? -this.width - this.animationOffset : 0)),
|
||||
y: tx.screenPosition.y + (direction === 'up' ? this.height + this.animationOffset : (direction === 'down' ? -this.height - this.animationOffset : 0)),
|
||||
s: tx.screenPosition.s
|
||||
}
|
||||
},
|
||||
duration: this.animationDuration,
|
||||
@@ -458,18 +449,18 @@ export default class BlockScene {
|
||||
break;
|
||||
}
|
||||
return {
|
||||
x: this.x + x + this.unitPadding - (slotSize / 2),
|
||||
y: this.y + y + this.unitPadding - (slotSize / 2),
|
||||
x: x + this.unitPadding - (slotSize / 2),
|
||||
y: y + this.unitPadding - (slotSize / 2),
|
||||
s: squareSize
|
||||
};
|
||||
} else {
|
||||
return { x: this.x, y: this.y, s: 0 };
|
||||
return { x: 0, y: 0, s: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
private screenToGrid(position: Position): Position {
|
||||
let x = position.x - this.x;
|
||||
let y = position.y - this.y;
|
||||
let x = position.x;
|
||||
let y = this.height - position.y;
|
||||
let t;
|
||||
|
||||
switch (this.orientation) {
|
||||
|
||||
@@ -2,13 +2,12 @@ import { FastVertexArray } from './fast-vertex-array';
|
||||
import { InterpolatedAttribute, Attributes, OptionalAttributes, SpriteUpdateParams, Update } from './sprite-types';
|
||||
|
||||
const attribKeys = ['a', 'b', 't', 'v'];
|
||||
const updateKeys = ['x', 'y', 'r', 'g', 'b', 'a'];
|
||||
const attributeKeys = ['x', 'y', 's', 'r', 'g', 'b', 'a'];
|
||||
const updateKeys = ['x', 'y', 's', 'r', 'g', 'b', 'a'];
|
||||
|
||||
export default class TxSprite {
|
||||
static vertexSize = 28;
|
||||
static vertexSize = 30;
|
||||
static vertexCount = 6;
|
||||
static dataSize: number = (28 * 6);
|
||||
static dataSize: number = (30 * 6);
|
||||
|
||||
vertexArray: FastVertexArray;
|
||||
vertexPointer: number;
|
||||
@@ -17,26 +16,15 @@ export default class TxSprite {
|
||||
attributes: Attributes;
|
||||
tempAttributes: OptionalAttributes;
|
||||
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minY: number;
|
||||
maxY: number;
|
||||
|
||||
|
||||
constructor(params: SpriteUpdateParams, vertexArray: FastVertexArray, minX: number, maxX: number, minY: number, maxY: number) {
|
||||
constructor(params: SpriteUpdateParams, vertexArray: FastVertexArray) {
|
||||
const offsetTime = params.start;
|
||||
this.vertexArray = vertexArray;
|
||||
this.vertexData = Array(TxSprite.dataSize).fill(0);
|
||||
|
||||
this.vertexData = Array(VI.length).fill(0);
|
||||
this.updateMap = {
|
||||
x: 0, y: 0, s: 0, r: 0, g: 0, b: 0, a: 0
|
||||
};
|
||||
|
||||
this.minX = minX;
|
||||
this.maxX = maxX;
|
||||
this.minY = minY;
|
||||
this.maxY = maxY;
|
||||
|
||||
this.attributes = {
|
||||
x: { a: params.x, b: params.x, t: offsetTime, v: 0, d: 0 },
|
||||
y: { a: params.y, b: params.y, t: offsetTime, v: 0, d: 0 },
|
||||
@@ -89,24 +77,11 @@ export default class TxSprite {
|
||||
minDuration: minimum remaining transition duration when adjust = true
|
||||
temp: if true, this update is only temporary (can be reversed with 'resume')
|
||||
*/
|
||||
update(params: SpriteUpdateParams, minX?: number, maxX?: number, minY?: number, maxY?: number): void {
|
||||
update(params: SpriteUpdateParams): void {
|
||||
const offsetTime = params.start || performance.now();
|
||||
const v = params.duration > 0 ? (1 / params.duration) : 0;
|
||||
|
||||
if (minX != null) {
|
||||
this.minX = minX;
|
||||
}
|
||||
if (maxX != null) {
|
||||
this.maxX = maxX;
|
||||
}
|
||||
if (minY != null) {
|
||||
this.minY = minY;
|
||||
}
|
||||
if (maxY != null) {
|
||||
this.maxY = maxY;
|
||||
}
|
||||
|
||||
attributeKeys.forEach(key => {
|
||||
updateKeys.forEach(key => {
|
||||
this.updateMap[key] = params[key];
|
||||
});
|
||||
|
||||
@@ -164,32 +139,18 @@ export default class TxSprite {
|
||||
...this.tempAttributes
|
||||
};
|
||||
}
|
||||
const size = attributes.s;
|
||||
|
||||
// update vertex data in place
|
||||
// ugly, but avoids overhead of allocating large temporary arrays
|
||||
const vertexStride = VI.length + 4;
|
||||
const vertexStride = VI.length + 2;
|
||||
for (let vertex = 0; vertex < 6; vertex++) {
|
||||
this.vertexData[vertex * vertexStride] = this.minX;
|
||||
this.vertexData[(vertex * vertexStride) + 1] = this.minY;
|
||||
this.vertexData[(vertex * vertexStride) + 2] = this.maxX;
|
||||
this.vertexData[(vertex * vertexStride) + 3] = this.maxY;
|
||||
|
||||
// x
|
||||
this.vertexData[(vertex * vertexStride) + 4] = attributes[VI[0].a][VI[0].f] + (vertexOffsetFactors[vertex][0] * attributes.s.a);
|
||||
this.vertexData[(vertex * vertexStride) + 5] = attributes[VI[1].a][VI[1].f] + (vertexOffsetFactors[vertex][0] * attributes.s.b);
|
||||
this.vertexData[(vertex * vertexStride) + 6] = attributes[VI[2].a][VI[2].f];
|
||||
this.vertexData[(vertex * vertexStride) + 7] = attributes[VI[3].a][VI[3].f];
|
||||
|
||||
// y
|
||||
this.vertexData[(vertex * vertexStride) + 8] = attributes[VI[4].a][VI[4].f] + (vertexOffsetFactors[vertex][1] * attributes.s.a);
|
||||
this.vertexData[(vertex * vertexStride) + 9] = attributes[VI[5].a][VI[5].f] + (vertexOffsetFactors[vertex][1] * attributes.s.b);
|
||||
this.vertexData[(vertex * vertexStride) + 10] = attributes[VI[6].a][VI[6].f];
|
||||
this.vertexData[(vertex * vertexStride) + 11] = attributes[VI[7].a][VI[7].f];
|
||||
|
||||
for (let step = 8; step < VI.length; step++) {
|
||||
this.vertexData[vertex * vertexStride] = vertexOffsetFactors[vertex][0];
|
||||
this.vertexData[(vertex * vertexStride) + 1] = vertexOffsetFactors[vertex][1];
|
||||
for (let step = 0; step < VI.length; step++) {
|
||||
// components of each field in the vertex array are defined by an entry in VI:
|
||||
// VI[i].a is the attribute, VI[i].f is the inner field, VI[i].offA and VI[i].offB are offset factors
|
||||
this.vertexData[(vertex * vertexStride) + step + 4] = attributes[VI[step].a][VI[step].f];
|
||||
this.vertexData[(vertex * vertexStride) + step + 2] = attributes[VI[step].a][VI[step].f];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ export default class TxView implements TransactionStripped {
|
||||
|
||||
returns minimum transition end time
|
||||
*/
|
||||
update(params: ViewUpdateParams, { minX, maxX, minY, maxY }: { minX: number, maxX: number, minY: number, maxY: number }): number {
|
||||
update(params: ViewUpdateParams): number {
|
||||
if (params.jitter) {
|
||||
params.delay += (Math.random() * params.jitter);
|
||||
}
|
||||
@@ -115,35 +115,21 @@ export default class TxView implements TransactionStripped {
|
||||
this.initialised = true;
|
||||
this.sprite = new TxSprite(
|
||||
toSpriteUpdate(params),
|
||||
this.vertexArray,
|
||||
minX,
|
||||
maxX,
|
||||
minY,
|
||||
maxY
|
||||
this.vertexArray
|
||||
);
|
||||
// apply any pending hover event
|
||||
if (this.hover) {
|
||||
params.duration = Math.max(params.duration, hoverTransitionTime);
|
||||
this.sprite.update(
|
||||
{
|
||||
...this.hoverColor,
|
||||
duration: hoverTransitionTime,
|
||||
adjust: false,
|
||||
temp: true
|
||||
},
|
||||
minX,
|
||||
maxX,
|
||||
minY,
|
||||
maxY
|
||||
);
|
||||
this.sprite.update({
|
||||
...this.hoverColor,
|
||||
duration: hoverTransitionTime,
|
||||
adjust: false,
|
||||
temp: true
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.sprite.update(
|
||||
toSpriteUpdate(params),
|
||||
minX,
|
||||
maxX,
|
||||
minY,
|
||||
maxY
|
||||
toSpriteUpdate(params)
|
||||
);
|
||||
}
|
||||
this.dirty = false;
|
||||
|
||||
@@ -11,10 +11,6 @@ export function hexToColor(hex: string): Color {
|
||||
};
|
||||
}
|
||||
|
||||
export function colorToHex(color: Color): string {
|
||||
return [color.r, color.g, color.b].map(c => Math.round(c * 255).toString(16)).join('');
|
||||
}
|
||||
|
||||
export function desaturate(color: Color, amount: number): Color {
|
||||
const gray = (color.r + color.g + color.b) / 6;
|
||||
return {
|
||||
@@ -34,15 +30,6 @@ export function darken(color: Color, amount: number): Color {
|
||||
};
|
||||
}
|
||||
|
||||
export function mix(color1: Color, color2: Color, amount: number): Color {
|
||||
return {
|
||||
r: color1.r * (1 - amount) + color2.r * amount,
|
||||
g: color1.g * (1 - amount) + color2.g * amount,
|
||||
b: color1.b * (1 - amount) + color2.b * amount,
|
||||
a: color1.a * (1 - amount) + color2.a * amount,
|
||||
};
|
||||
}
|
||||
|
||||
export function setOpacity(color: Color, opacity: number): Color {
|
||||
return {
|
||||
...color,
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
|
||||
<div class="block-overview-graph">
|
||||
<canvas *browserOnly class="block-overview-canvas" [class.clickable]="!!hoverTx" #blockCanvas></canvas>
|
||||
@if (!disableSpinner) {
|
||||
<div class="loader-wrapper" [class.hidden]="!isLoading && !unavailable">
|
||||
<div *ngIf="!unavailable" class="spinner-border ml-3 loading" role="status"></div>
|
||||
<div *ngIf="!isLoading && unavailable" class="ml-3" i18n="block.not-available">not available</div>
|
||||
</div>
|
||||
}
|
||||
<app-block-overview-tooltip
|
||||
[tx]="selectedTx || hoverTx"
|
||||
[cursorPosition]="tooltipPosition"
|
||||
[clickable]="!!selectedTx"
|
||||
[auditEnabled]="auditHighlighting"
|
||||
[blockConversion]="blockConversion"
|
||||
[filterFlags]="activeFilterFlags"
|
||||
[filterMode]="filterMode"
|
||||
[relativeTime]="relativeTime"
|
||||
></app-block-overview-tooltip>
|
||||
<app-block-filters *ngIf="webGlEnabled && showFilters && filtersAvailable" [excludeFilters]="excludeFilters" [cssWidth]="cssWidth" (onFilterChanged)="setFilterFlags($event)"></app-block-filters>
|
||||
<div *ngIf="!webGlEnabled" class="placeholder">
|
||||
<span i18n="webgl-disabled">Your browser does not support this feature.</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,67 +0,0 @@
|
||||
.block-overview-graph {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--stat-box-bg);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
grid-column: 1/-1;
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.graph-alignment {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.grid-align {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, 75px);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.block-overview-canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.loader-wrapper {
|
||||
position: absolute;
|
||||
background: #181b2d7f;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: opacity 500ms 500ms;
|
||||
pointer-events: none;
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,803 +0,0 @@
|
||||
import { Component, ElementRef, ViewChild, HostListener, Input, Output, EventEmitter, NgZone, AfterViewInit, OnDestroy, OnChanges } from '@angular/core';
|
||||
import { TransactionStripped } from '../../interfaces/node-api.interface';
|
||||
import { FastVertexArray } from '../block-overview-graph/fast-vertex-array';
|
||||
import BlockScene from '../block-overview-graph/block-scene';
|
||||
import TxSprite from '../block-overview-graph/tx-sprite';
|
||||
import TxView from '../block-overview-graph/tx-view';
|
||||
import { Color, Position } from '../block-overview-graph/sprite-types';
|
||||
import { Price } from '../../services/price.service';
|
||||
import { StateService } from '../../services/state.service';
|
||||
import { ThemeService } from '../../services/theme.service';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { defaultColorFunction, setOpacity, defaultAuditColors, defaultColors, ageColorFunction, contrastColorFunction, contrastAuditColors, contrastColors } from '../block-overview-graph/utils';
|
||||
import { ActiveFilter, FilterMode, toFlags } from '../../shared/filters.utils';
|
||||
import { detectWebGL } from '../../shared/graphs.utils';
|
||||
|
||||
const unmatchedOpacity = 0.2;
|
||||
const unmatchedAuditColors = {
|
||||
censored: setOpacity(defaultAuditColors.censored, unmatchedOpacity),
|
||||
missing: setOpacity(defaultAuditColors.missing, unmatchedOpacity),
|
||||
added: setOpacity(defaultAuditColors.added, unmatchedOpacity),
|
||||
added_prioritized: setOpacity(defaultAuditColors.added_prioritized, unmatchedOpacity),
|
||||
prioritized: setOpacity(defaultAuditColors.prioritized, unmatchedOpacity),
|
||||
accelerated: setOpacity(defaultAuditColors.accelerated, unmatchedOpacity),
|
||||
};
|
||||
const unmatchedContrastAuditColors = {
|
||||
censored: setOpacity(contrastAuditColors.censored, unmatchedOpacity),
|
||||
missing: setOpacity(contrastAuditColors.missing, unmatchedOpacity),
|
||||
added: setOpacity(contrastAuditColors.added, unmatchedOpacity),
|
||||
added_prioritized: setOpacity(contrastAuditColors.added_prioritized, unmatchedOpacity),
|
||||
prioritized: setOpacity(contrastAuditColors.prioritized, unmatchedOpacity),
|
||||
accelerated: setOpacity(contrastAuditColors.accelerated, unmatchedOpacity),
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-block-overview-multi',
|
||||
templateUrl: './block-overview-multi.component.html',
|
||||
styleUrls: ['./block-overview-multi.component.scss'],
|
||||
})
|
||||
export class BlockOverviewMultiComponent implements AfterViewInit, OnDestroy, OnChanges {
|
||||
@Input() isLoading: boolean;
|
||||
@Input() resolution: number;
|
||||
@Input() numBlocks: number;
|
||||
@Input() padding: number = 0;
|
||||
@Input() blockWidth: number = 360;
|
||||
@Input() autofit: boolean = false;
|
||||
@Input() blockLimit: number;
|
||||
@Input() orientation = 'left';
|
||||
@Input() flip = true;
|
||||
@Input() animationDuration: number = 1000;
|
||||
@Input() animationOffset: number | null = null;
|
||||
@Input() disableSpinner = false;
|
||||
@Input() mirrorTxid: string | void;
|
||||
@Input() unavailable: boolean = false;
|
||||
@Input() auditHighlighting: boolean = false;
|
||||
@Input() showFilters: boolean = false;
|
||||
@Input() excludeFilters: string[] = [];
|
||||
@Input() filterFlags: bigint | null = null;
|
||||
@Input() filterMode: FilterMode = 'and';
|
||||
@Input() gradientMode: 'fee' | 'age' = 'fee';
|
||||
@Input() relativeTime: number | null;
|
||||
@Input() blockConversion: Price;
|
||||
@Input() overrideColors: ((tx: TxView) => Color) | null = null;
|
||||
@Output() txClickEvent = new EventEmitter<{ tx: TransactionStripped, keyModifier: boolean}>();
|
||||
@Output() txHoverEvent = new EventEmitter<string>();
|
||||
@Output() readyEvent = new EventEmitter();
|
||||
|
||||
@ViewChild('blockCanvas')
|
||||
canvas: ElementRef<HTMLCanvasElement>;
|
||||
themeChangedSubscription: Subscription;
|
||||
|
||||
gl: WebGLRenderingContext;
|
||||
animationFrameRequest: number;
|
||||
animationHeartBeat: number;
|
||||
displayWidth: number;
|
||||
displayHeight: number;
|
||||
displayBlockWidth: number;
|
||||
displayPadding: number;
|
||||
cssWidth: number;
|
||||
cssHeight: number;
|
||||
shaderProgram: WebGLProgram;
|
||||
vertexArray: FastVertexArray;
|
||||
running: boolean;
|
||||
scenes: BlockScene[] = [];
|
||||
hoverTx: TxView | void;
|
||||
selectedTx: TxView | void;
|
||||
highlightTx: TxView | void;
|
||||
mirrorTx: TxView | void;
|
||||
tooltipPosition: Position;
|
||||
|
||||
readyNextFrame = false;
|
||||
lastUpdate: number = 0;
|
||||
pendingUpdates: {
|
||||
count: number,
|
||||
add: { [txid: string]: TransactionStripped },
|
||||
remove: { [txid: string]: string },
|
||||
change: { [txid: string]: { txid: string, rate: number | undefined, acc: boolean | undefined } },
|
||||
direction?: string,
|
||||
}[] = [];
|
||||
|
||||
searchText: string;
|
||||
searchSubscription: Subscription;
|
||||
filtersAvailable: boolean = true;
|
||||
activeFilterFlags: bigint | null = null;
|
||||
|
||||
webGlEnabled = true;
|
||||
|
||||
constructor(
|
||||
readonly ngZone: NgZone,
|
||||
readonly elRef: ElementRef,
|
||||
public stateService: StateService,
|
||||
private themeService: ThemeService,
|
||||
) {
|
||||
this.webGlEnabled = this.stateService.isBrowser && detectWebGL();
|
||||
this.vertexArray = new FastVertexArray(512, TxSprite.dataSize);
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
if (this.canvas) {
|
||||
this.canvas.nativeElement.addEventListener('webglcontextlost', this.handleContextLost, false);
|
||||
this.canvas.nativeElement.addEventListener('webglcontextrestored', this.handleContextRestored, false);
|
||||
this.gl = this.canvas.nativeElement.getContext('webgl');
|
||||
this.initScenes();
|
||||
|
||||
if (this.gl) {
|
||||
this.initCanvas();
|
||||
this.resizeCanvas();
|
||||
this.themeChangedSubscription = this.themeService.themeChanged$.subscribe(() => {
|
||||
for (const scene of this.scenes) {
|
||||
scene.setColorFunction(this.getColorFunction());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initScenes(): void {
|
||||
for (const scene of this.scenes) {
|
||||
if (scene) {
|
||||
scene.destroy();
|
||||
}
|
||||
}
|
||||
this.scenes = [];
|
||||
this.pendingUpdates = [];
|
||||
for (let i = 0; i < this.numBlocks; i++) {
|
||||
this.scenes.push(null);
|
||||
this.pendingUpdates.push({
|
||||
count: 0,
|
||||
add: {},
|
||||
remove: {},
|
||||
change: {},
|
||||
direction: 'left',
|
||||
});
|
||||
}
|
||||
this.resizeCanvas();
|
||||
this.start();
|
||||
}
|
||||
|
||||
ngOnChanges(changes): void {
|
||||
if (changes.numBlocks) {
|
||||
this.initScenes();
|
||||
}
|
||||
if (changes.orientation || changes.flip) {
|
||||
for (const scene of this.scenes) {
|
||||
scene?.setOrientation(this.orientation, this.flip);
|
||||
}
|
||||
}
|
||||
if (changes.auditHighlighting) {
|
||||
this.setHighlightingEnabled(this.auditHighlighting);
|
||||
}
|
||||
if (changes.overrideColor) {
|
||||
for (const scene of this.scenes) {
|
||||
scene?.setColorFunction(this.getFilterColorFunction(0n, this.gradientMode));
|
||||
}
|
||||
}
|
||||
if ((changes.filterFlags || changes.showFilters || changes.filterMode || changes.gradientMode)) {
|
||||
this.setFilterFlags();
|
||||
}
|
||||
}
|
||||
|
||||
setFilterFlags(goggle?: ActiveFilter): void {
|
||||
this.filterMode = goggle?.mode || this.filterMode;
|
||||
this.gradientMode = goggle?.gradient || 'fee'; // this.gradientMode;
|
||||
this.activeFilterFlags = goggle?.filters ? toFlags(goggle.filters) : this.filterFlags;
|
||||
for (const scene of this.scenes) {
|
||||
if (scene) {
|
||||
if (this.activeFilterFlags != null && this.filtersAvailable) {
|
||||
scene.setColorFunction(this.getFilterColorFunction(this.activeFilterFlags, this.gradientMode));
|
||||
} else {
|
||||
scene.setColorFunction(this.getFilterColorFunction(0n, this.gradientMode));
|
||||
}
|
||||
}
|
||||
}
|
||||
this.start();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.animationFrameRequest) {
|
||||
cancelAnimationFrame(this.animationFrameRequest);
|
||||
clearTimeout(this.animationHeartBeat);
|
||||
}
|
||||
if (this.canvas) {
|
||||
this.canvas.nativeElement.removeEventListener('webglcontextlost', this.handleContextLost);
|
||||
this.canvas.nativeElement.removeEventListener('webglcontextrestored', this.handleContextRestored);
|
||||
this.themeChangedSubscription?.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
clear(block: number, direction): void {
|
||||
this.exit(block, direction);
|
||||
this.start();
|
||||
}
|
||||
|
||||
destroy(block: number): void {
|
||||
if (this.scenes[block]) {
|
||||
this.scenes[block].destroy();
|
||||
this.clearUpdateQueue(block);
|
||||
this.start();
|
||||
}
|
||||
}
|
||||
|
||||
// initialize the scene without any entry transition
|
||||
setup(block: number, transactions: TransactionStripped[], sort: boolean = false): void {
|
||||
const filtersAvailable = transactions.reduce((flagSet, tx) => flagSet || tx.flags > 0, false);
|
||||
if (filtersAvailable !== this.filtersAvailable) {
|
||||
this.setFilterFlags();
|
||||
}
|
||||
this.filtersAvailable = filtersAvailable;
|
||||
if (this.scenes[block]) {
|
||||
this.clearUpdateQueue(block);
|
||||
this.scenes[block].setup(transactions, sort);
|
||||
this.readyNextFrame = true;
|
||||
this.start();
|
||||
}
|
||||
}
|
||||
|
||||
enter(block: number, transactions: TransactionStripped[], direction: string): void {
|
||||
if (this.scenes[block]) {
|
||||
this.clearUpdateQueue(block);
|
||||
this.scenes[block].enter(transactions, direction);
|
||||
this.start();
|
||||
}
|
||||
}
|
||||
|
||||
exit(block: number, direction: string): void {
|
||||
if (this.scenes[block]) {
|
||||
this.clearUpdateQueue(block);
|
||||
this.scenes[block].exit(direction);
|
||||
this.start();
|
||||
}
|
||||
}
|
||||
|
||||
replace(block: number, transactions: TransactionStripped[], direction: string, sort: boolean = true, startTime?: number): void {
|
||||
if (this.scenes[block]) {
|
||||
this.clearUpdateQueue(block);
|
||||
this.scenes[block].replace(transactions || [], direction, sort, startTime);
|
||||
this.start();
|
||||
}
|
||||
}
|
||||
|
||||
// collates deferred updates into a set of consistent pending changes
|
||||
queueUpdate(block: number, add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined, acc: boolean | undefined }[], direction: string = 'left'): void {
|
||||
for (const tx of add) {
|
||||
this.pendingUpdates[block].add[tx.txid] = tx;
|
||||
delete this.pendingUpdates[block].remove[tx.txid];
|
||||
delete this.pendingUpdates[block].change[tx.txid];
|
||||
}
|
||||
for (const txid of remove) {
|
||||
delete this.pendingUpdates[block].add[txid];
|
||||
this.pendingUpdates[block].remove[txid] = txid;
|
||||
delete this.pendingUpdates[block].change[txid];
|
||||
}
|
||||
for (const tx of change) {
|
||||
if (this.pendingUpdates[block].add[tx.txid]) {
|
||||
this.pendingUpdates[block].add[tx.txid].rate = tx.rate;
|
||||
this.pendingUpdates[block].add[tx.txid].acc = tx.acc;
|
||||
} else {
|
||||
this.pendingUpdates[block].change[tx.txid] = tx;
|
||||
}
|
||||
}
|
||||
this.pendingUpdates[block].direction = direction;
|
||||
this.pendingUpdates[block].count++;
|
||||
}
|
||||
|
||||
deferredUpdate(block: number, add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined, acc: boolean | undefined }[], direction: string = 'left'): void {
|
||||
this.queueUpdate(block, add, remove, change, direction);
|
||||
this.applyQueuedUpdates();
|
||||
}
|
||||
|
||||
applyQueuedUpdates(): void {
|
||||
for (const [index, pendingUpdate] of this.pendingUpdates.entries()) {
|
||||
if (pendingUpdate.count && performance.now() > (this.lastUpdate + this.animationDuration)) {
|
||||
this.applyUpdate(index, Object.values(pendingUpdate.add), Object.values(pendingUpdate.remove), Object.values(pendingUpdate.change), pendingUpdate.direction);
|
||||
this.clearUpdateQueue(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearUpdateQueue(block: number): void {
|
||||
this.pendingUpdates[block] = {
|
||||
count: 0,
|
||||
add: {},
|
||||
remove: {},
|
||||
change: {},
|
||||
};
|
||||
this.lastUpdate = performance.now();
|
||||
}
|
||||
|
||||
update(block: number, add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined, acc: boolean | undefined }[], direction: string = 'left', resetLayout: boolean = false): void {
|
||||
// merge any pending changes into this update
|
||||
this.queueUpdate(block, add, remove, change, direction);
|
||||
this.applyUpdate(block,Object.values(this.pendingUpdates[block].add), Object.values(this.pendingUpdates[block].remove), Object.values(this.pendingUpdates[block].change), direction, resetLayout);
|
||||
this.clearUpdateQueue(block);
|
||||
}
|
||||
|
||||
applyUpdate(block: number, add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined, acc: boolean | undefined }[], direction: string = 'left', resetLayout: boolean = false): void {
|
||||
if (this.scenes[block]) {
|
||||
add = add.filter(tx => !this.scenes[block].txs[tx.txid]);
|
||||
remove = remove.filter(txid => this.scenes[block].txs[txid]);
|
||||
change = change.filter(tx => this.scenes[block].txs[tx.txid]);
|
||||
|
||||
if (this.gradientMode === 'age') {
|
||||
this.scenes[block].updateAllColors();
|
||||
}
|
||||
this.scenes[block].update(add, remove, change, direction, resetLayout);
|
||||
this.start();
|
||||
this.lastUpdate = performance.now();
|
||||
}
|
||||
}
|
||||
|
||||
initCanvas(): void {
|
||||
if (!this.canvas || !this.gl) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.gl.clearColor(0.0, 0.0, 0.0, 0.0);
|
||||
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
|
||||
|
||||
const shaderSet = [
|
||||
{
|
||||
type: this.gl.VERTEX_SHADER,
|
||||
src: vertShaderSrc
|
||||
},
|
||||
{
|
||||
type: this.gl.FRAGMENT_SHADER,
|
||||
src: fragShaderSrc
|
||||
}
|
||||
];
|
||||
|
||||
this.shaderProgram = this.buildShaderProgram(shaderSet);
|
||||
|
||||
this.gl.useProgram(this.shaderProgram);
|
||||
|
||||
// Set up alpha blending
|
||||
this.gl.enable(this.gl.BLEND);
|
||||
this.gl.blendFunc(this.gl.ONE, this.gl.ONE_MINUS_SRC_ALPHA);
|
||||
|
||||
const glBuffer = this.gl.createBuffer();
|
||||
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, glBuffer);
|
||||
|
||||
/* SET UP SHADER ATTRIBUTES */
|
||||
Object.keys(attribs).forEach((key, i) => {
|
||||
attribs[key].pointer = this.gl.getAttribLocation(this.shaderProgram, key);
|
||||
this.gl.enableVertexAttribArray(attribs[key].pointer);
|
||||
});
|
||||
|
||||
this.start();
|
||||
}
|
||||
|
||||
handleContextLost(event): void {
|
||||
event.preventDefault();
|
||||
cancelAnimationFrame(this.animationFrameRequest);
|
||||
this.animationFrameRequest = null;
|
||||
this.running = false;
|
||||
this.gl = null;
|
||||
}
|
||||
|
||||
handleContextRestored(event): void {
|
||||
if (this.canvas?.nativeElement) {
|
||||
this.gl = this.canvas.nativeElement.getContext('webgl');
|
||||
if (this.gl) {
|
||||
this.initCanvas();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('window:resize', ['$event'])
|
||||
resizeCanvas(): void {
|
||||
if (this.canvas) {
|
||||
this.cssWidth = this.canvas.nativeElement.offsetParent.clientWidth;
|
||||
this.cssHeight = this.canvas.nativeElement.offsetParent.clientHeight;
|
||||
this.displayWidth = window.devicePixelRatio * this.cssWidth;
|
||||
this.displayHeight = window.devicePixelRatio * this.cssHeight;
|
||||
this.displayBlockWidth = window.devicePixelRatio * this.blockWidth;
|
||||
this.displayPadding = window.devicePixelRatio * this.padding;
|
||||
this.canvas.nativeElement.width = this.displayWidth;
|
||||
this.canvas.nativeElement.height = this.displayHeight;
|
||||
if (this.gl) {
|
||||
this.gl.viewport(0, 0, this.displayWidth, this.displayHeight);
|
||||
}
|
||||
for (let i = 0; i < this.scenes.length; i++) {
|
||||
const blocksPerRow = Math.floor(this.displayWidth / (this.displayBlockWidth + (this.displayPadding * 2)));
|
||||
const x = this.displayPadding + ((i % blocksPerRow) * (this.displayBlockWidth + (this.displayPadding * 2)));
|
||||
const numRows = Math.ceil(this.scenes.length / blocksPerRow);
|
||||
const row = numRows - Math.floor(i / blocksPerRow) - 1;
|
||||
const y = this.displayPadding + this.displayHeight - ((row + 1) * (this.displayBlockWidth + (this.displayPadding * 2)));
|
||||
if (this.scenes[i]) {
|
||||
this.scenes[i].resize({ x, y, width: this.displayBlockWidth, height: this.displayBlockWidth, animate: false });
|
||||
this.start();
|
||||
} else {
|
||||
this.scenes[i] = new BlockScene({ x, y, width: this.displayBlockWidth, height: this.displayBlockWidth, resolution: this.resolution,
|
||||
blockLimit: this.blockLimit, orientation: this.orientation, flip: this.flip, vertexArray: this.vertexArray, theme: this.themeService,
|
||||
highlighting: this.auditHighlighting, animationDuration: this.animationDuration, animationOffset: this.animationOffset,
|
||||
colorFunction: this.getColorFunction() });
|
||||
this.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compileShader(src, type): WebGLShader {
|
||||
if (!this.gl) {
|
||||
return;
|
||||
}
|
||||
const shader = this.gl.createShader(type);
|
||||
|
||||
this.gl.shaderSource(shader, src);
|
||||
this.gl.compileShader(shader);
|
||||
|
||||
if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) {
|
||||
console.log(`Error compiling ${type === this.gl.VERTEX_SHADER ? 'vertex' : 'fragment'} shader:`);
|
||||
console.log(this.gl.getShaderInfoLog(shader));
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
buildShaderProgram(shaderInfo): WebGLProgram {
|
||||
if (!this.gl) {
|
||||
return;
|
||||
}
|
||||
const program = this.gl.createProgram();
|
||||
|
||||
shaderInfo.forEach((desc) => {
|
||||
const shader = this.compileShader(desc.src, desc.type);
|
||||
if (shader) {
|
||||
this.gl.attachShader(program, shader);
|
||||
}
|
||||
});
|
||||
|
||||
this.gl.linkProgram(program);
|
||||
|
||||
if (!this.gl.getProgramParameter(program, this.gl.LINK_STATUS)) {
|
||||
console.log('Error linking shader program:');
|
||||
console.log(this.gl.getProgramInfoLog(program));
|
||||
}
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.running = true;
|
||||
this.ngZone.runOutsideAngular(() => this.doRun());
|
||||
}
|
||||
|
||||
doRun(): void {
|
||||
if (this.animationFrameRequest) {
|
||||
cancelAnimationFrame(this.animationFrameRequest);
|
||||
}
|
||||
this.animationFrameRequest = requestAnimationFrame(() => this.run());
|
||||
}
|
||||
|
||||
run(now?: DOMHighResTimeStamp): void {
|
||||
if (!now) {
|
||||
now = performance.now();
|
||||
}
|
||||
this.applyQueuedUpdates();
|
||||
// skip re-render if there's no change to the scene
|
||||
if (this.scenes.length && this.gl) {
|
||||
/* SET UP SHADER UNIFORMS */
|
||||
// screen dimensions
|
||||
this.gl.uniform2f(this.gl.getUniformLocation(this.shaderProgram, 'screenSize'), this.displayWidth, this.displayHeight);
|
||||
// frame timestamp
|
||||
this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgram, 'now'), now);
|
||||
|
||||
if (this.vertexArray.dirty) {
|
||||
/* SET UP SHADER ATTRIBUTES */
|
||||
Object.keys(attribs).forEach((key, i) => {
|
||||
this.gl.vertexAttribPointer(attribs[key].pointer,
|
||||
attribs[key].count, // number of primitives in this attribute
|
||||
this.gl[attribs[key].type], // type of primitive in this attribute (e.g. gl.FLOAT)
|
||||
false, // never normalised
|
||||
stride, // distance between values of the same attribute
|
||||
attribs[key].offset); // offset of the first value
|
||||
});
|
||||
|
||||
const pointArray = this.vertexArray.getVertexData();
|
||||
|
||||
if (pointArray.length) {
|
||||
this.gl.bufferData(this.gl.ARRAY_BUFFER, pointArray, this.gl.DYNAMIC_DRAW);
|
||||
this.gl.drawArrays(this.gl.TRIANGLES, 0, pointArray.length / TxSprite.vertexSize);
|
||||
}
|
||||
this.vertexArray.dirty = false;
|
||||
} else {
|
||||
const pointArray = this.vertexArray.getVertexData();
|
||||
if (pointArray.length) {
|
||||
this.gl.drawArrays(this.gl.TRIANGLES, 0, pointArray.length / TxSprite.vertexSize);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.readyNextFrame) {
|
||||
this.readyNextFrame = false;
|
||||
this.readyEvent.emit();
|
||||
}
|
||||
}
|
||||
|
||||
/* LOOP */
|
||||
if (this.running && this.scenes.length && now <= (this.scenes.reduce((max, scene) => scene.animateUntil > max ? scene.animateUntil : max, 0) + 500)) {
|
||||
this.doRun();
|
||||
} else {
|
||||
if (this.animationHeartBeat) {
|
||||
clearTimeout(this.animationHeartBeat);
|
||||
}
|
||||
this.animationHeartBeat = window.setTimeout(() => {
|
||||
this.start();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('document:click', ['$event'])
|
||||
clickAway(event) {
|
||||
if (!this.elRef.nativeElement.contains(event.target)) {
|
||||
const currentPreview = this.selectedTx || this.hoverTx;
|
||||
if (currentPreview) {
|
||||
for (const scene of this.scenes) {
|
||||
if (scene) {
|
||||
scene.setHover(currentPreview, false);
|
||||
}
|
||||
}
|
||||
this.start();
|
||||
}
|
||||
this.hoverTx = null;
|
||||
this.selectedTx = null;
|
||||
this.onTxHover(null);
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('pointerup', ['$event'])
|
||||
onClick(event) {
|
||||
if (!this.canvas) {
|
||||
return;
|
||||
}
|
||||
if (event.target === this.canvas.nativeElement && event.pointerType === 'touch') {
|
||||
this.setPreviewTx(event.offsetX, event.offsetY, true);
|
||||
} else if (event.target === this.canvas.nativeElement) {
|
||||
const keyMod = event.shiftKey || event.ctrlKey || event.metaKey;
|
||||
const middleClick = event.which === 2 || event.button === 1;
|
||||
this.onTxClick(event.offsetX, event.offsetY, keyMod || middleClick);
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('pointermove', ['$event'])
|
||||
onPointerMove(event) {
|
||||
if (!this.canvas) {
|
||||
return;
|
||||
}
|
||||
if (event.target === this.canvas.nativeElement) {
|
||||
this.setPreviewTx(event.offsetX, event.offsetY, false);
|
||||
} else {
|
||||
this.onPointerLeave(event);
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('pointerleave', ['$event'])
|
||||
onPointerLeave(event) {
|
||||
if (event.pointerType !== 'touch') {
|
||||
this.setPreviewTx(-1, -1, true);
|
||||
}
|
||||
}
|
||||
|
||||
setPreviewTx(cssX: number, cssY: number, clicked: boolean = false) {
|
||||
const x = cssX * window.devicePixelRatio;
|
||||
const y = cssY * window.devicePixelRatio;
|
||||
if (!this.selectedTx || clicked) {
|
||||
this.tooltipPosition = {
|
||||
x: cssX,
|
||||
y: cssY
|
||||
};
|
||||
const currentPreview = this.selectedTx || this.hoverTx;
|
||||
let selected;
|
||||
for (const scene of this.scenes) {
|
||||
if (scene) {
|
||||
selected = scene.getTxAt({ x, y: this.displayHeight - y });
|
||||
if (selected) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selected !== currentPreview) {
|
||||
if (currentPreview) {
|
||||
for (const scene of this.scenes) {
|
||||
if (scene) {
|
||||
scene.setHover(currentPreview, false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.start();
|
||||
}
|
||||
if (selected) {
|
||||
for (const scene of this.scenes) {
|
||||
if (scene) {
|
||||
scene.setHover(selected, true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.start();
|
||||
if (clicked) {
|
||||
this.selectedTx = selected;
|
||||
} else {
|
||||
this.hoverTx = selected;
|
||||
this.onTxHover(this.hoverTx ? this.hoverTx.txid : null);
|
||||
}
|
||||
} else {
|
||||
if (clicked) {
|
||||
this.selectedTx = null;
|
||||
}
|
||||
this.hoverTx = null;
|
||||
this.onTxHover(null);
|
||||
}
|
||||
} else if (clicked) {
|
||||
if (selected === this.selectedTx) {
|
||||
this.hoverTx = this.selectedTx;
|
||||
this.selectedTx = null;
|
||||
this.onTxHover(this.hoverTx ? this.hoverTx.txid : null);
|
||||
} else {
|
||||
this.selectedTx = selected;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateSearchHighlight(): void {
|
||||
if (this.highlightTx && this.highlightTx.txid !== this.searchText) {
|
||||
for (const scene of this.scenes) {
|
||||
if (scene) {
|
||||
scene.setHighlight(this.highlightTx, false);
|
||||
}
|
||||
}
|
||||
this.start();
|
||||
} else if (this.searchText && this.searchText.length === 64) {
|
||||
for (const scene of this.scenes) {
|
||||
if (scene) {
|
||||
const highlightTx = scene.txs[this.searchText];
|
||||
if (highlightTx) {
|
||||
scene.setHighlight(highlightTx, true);
|
||||
this.highlightTx = highlightTx;
|
||||
this.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setHighlightingEnabled(enabled: boolean): void {
|
||||
for (const scene of this.scenes) {
|
||||
scene.setHighlighting(enabled);
|
||||
}
|
||||
this.start();
|
||||
}
|
||||
|
||||
onTxClick(cssX: number, cssY: number, keyModifier: boolean = false) {
|
||||
for (const scene of this.scenes) {
|
||||
if (scene) {
|
||||
const x = cssX * window.devicePixelRatio;
|
||||
const y = cssY * window.devicePixelRatio;
|
||||
const selected = scene.getTxAt({ x, y: this.displayHeight - y });
|
||||
if (selected && selected.txid) {
|
||||
this.txClickEvent.emit({ tx: selected, keyModifier });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onTxHover(hoverId: string) {
|
||||
this.txHoverEvent.emit(hoverId);
|
||||
}
|
||||
|
||||
getColorFunction(): ((tx: TxView) => Color) {
|
||||
if (this.overrideColors) {
|
||||
return this.overrideColors;
|
||||
} else if (this.filterFlags) {
|
||||
return this.getFilterColorFunction(this.filterFlags, this.gradientMode);
|
||||
} else if (this.activeFilterFlags) {
|
||||
return this.getFilterColorFunction(this.activeFilterFlags, this.gradientMode);
|
||||
} else {
|
||||
return this.getFilterColorFunction(0n, this.gradientMode);
|
||||
}
|
||||
}
|
||||
|
||||
getFilterColorFunction(flags: bigint, gradient: 'fee' | 'age'): ((tx: TxView) => Color) {
|
||||
return (tx: TxView) => {
|
||||
if ((this.filterMode === 'and' && (tx.bigintFlags & flags) === flags) || (this.filterMode === 'or' && (flags === 0n || (tx.bigintFlags & flags) > 0n))) {
|
||||
if (this.themeService.theme !== 'contrast' && this.themeService.theme !== 'bukele') {
|
||||
return (gradient === 'age') ? ageColorFunction(tx, defaultColors.fee, defaultAuditColors, this.relativeTime || (Date.now() / 1000)) : defaultColorFunction(tx, defaultColors.fee, defaultAuditColors, this.relativeTime || (Date.now() / 1000));
|
||||
} else {
|
||||
return (gradient === 'age') ? ageColorFunction(tx, contrastColors.fee, contrastAuditColors, this.relativeTime || (Date.now() / 1000)) : contrastColorFunction(tx, contrastColors.fee, contrastAuditColors, this.relativeTime || (Date.now() / 1000));
|
||||
}
|
||||
} else {
|
||||
if (this.themeService.theme !== 'contrast' && this.themeService.theme !== 'bukele') {
|
||||
return (gradient === 'age') ? { r: 1, g: 1, b: 1, a: 0.05 } : defaultColorFunction(
|
||||
tx,
|
||||
defaultColors.unmatchedfee,
|
||||
unmatchedAuditColors,
|
||||
this.relativeTime || (Date.now() / 1000)
|
||||
);
|
||||
} else {
|
||||
return (gradient === 'age') ? { r: 1, g: 1, b: 1, a: 0.05 } : contrastColorFunction(
|
||||
tx,
|
||||
contrastColors.unmatchedfee,
|
||||
unmatchedContrastAuditColors,
|
||||
this.relativeTime || (Date.now() / 1000)
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// WebGL shader attributes
|
||||
const attribs = {
|
||||
bounds: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
|
||||
posX: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
|
||||
posY: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
|
||||
colR: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
|
||||
colG: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
|
||||
colB: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
|
||||
colA: { type: 'FLOAT', count: 4, pointer: null, offset: 0 }
|
||||
};
|
||||
// Calculate the number of bytes per vertex based on specified attributes
|
||||
const stride = Object.values(attribs).reduce((total, attrib) => {
|
||||
return total + (attrib.count * 4);
|
||||
}, 0);
|
||||
// Calculate vertex attribute offsets
|
||||
for (let i = 0, offset = 0; i < Object.keys(attribs).length; i++) {
|
||||
const attrib = Object.values(attribs)[i];
|
||||
attrib.offset = offset;
|
||||
offset += (attrib.count * 4);
|
||||
}
|
||||
|
||||
const vertShaderSrc = `
|
||||
varying lowp vec4 vColor;
|
||||
|
||||
// each attribute contains [x: startValue, y: endValue, z: startTime, w: rate]
|
||||
// shader interpolates between start and end values at the given rate, from the given time
|
||||
|
||||
attribute vec4 bounds;
|
||||
attribute vec4 posX;
|
||||
attribute vec4 posY;
|
||||
attribute vec4 colR;
|
||||
attribute vec4 colG;
|
||||
attribute vec4 colB;
|
||||
attribute vec4 colA;
|
||||
|
||||
uniform vec2 screenSize;
|
||||
uniform float now;
|
||||
|
||||
float smootherstep(float x) {
|
||||
x = clamp(x, 0.0, 1.0);
|
||||
float ix = 1.0 - x;
|
||||
x = x * x;
|
||||
return x / (x + ix * ix);
|
||||
}
|
||||
|
||||
float interpolateAttribute(vec4 attr) {
|
||||
float d = (now - attr.z) * attr.w;
|
||||
float delta = smootherstep(d);
|
||||
return mix(attr.x, attr.y, delta);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 screenTransform = vec4(2.0 / screenSize.x, 2.0 / screenSize.y, -1.0, -1.0);
|
||||
// vec4 screenTransform = vec4(1.0 / screenSize.x, 1.0 / screenSize.y, -0.5, -0.5);
|
||||
vec2 position = clamp(vec2(interpolateAttribute(posX), interpolateAttribute(posY)), bounds.xy, bounds.zw);
|
||||
gl_Position = vec4(position * screenTransform.xy + screenTransform.zw, 1.0, 1.0);
|
||||
|
||||
float red = interpolateAttribute(colR);
|
||||
float green = interpolateAttribute(colG);
|
||||
float blue = interpolateAttribute(colB);
|
||||
float alpha = interpolateAttribute(colA);
|
||||
|
||||
vColor = vec4(red, green, blue, alpha);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragShaderSrc = `
|
||||
varying lowp vec4 vColor;
|
||||
|
||||
void main() {
|
||||
gl_FragColor = vColor;
|
||||
// premultiply alpha
|
||||
gl_FragColor.rgb *= gl_FragColor.a;
|
||||
}
|
||||
`;
|
||||
@@ -53,13 +53,6 @@
|
||||
<td i18n="block.miner">Miner</td>
|
||||
<td *ngIf="stateService.env.MINING_DASHBOARD">
|
||||
<a placement="bottom" [routerLink]="['/mining/pool' | relativeUrl, block.extras.pool.slug]" class="badge" style="color: #FFF;padding:0;">
|
||||
<span class="miner-name" *ngIf="block.extras.pool.minerNames?.length > 1 && block.extras.pool.minerNames[1] != ''">
|
||||
@if (block.extras.pool.minerNames[1].length > 16) {
|
||||
{{ block.extras.pool.minerNames[1].slice(0, 15) }}…
|
||||
} @else {
|
||||
{{ block.extras.pool.minerNames[1] }}
|
||||
}
|
||||
</span>
|
||||
<img class="pool-logo" [src]="'/resources/mining-pools/' + block.extras.pool.slug + '.svg'" onError="this.src = '/resources/mining-pools/default.svg'" [alt]="'Logo of ' + block.extras.pool.name + ' mining pool'">
|
||||
{{ block.extras.pool.name }}
|
||||
</a>
|
||||
@@ -67,15 +60,8 @@
|
||||
<td *ngIf="!stateService.env.MINING_DASHBOARD && stateService.env.BASE_MODULE === 'mempool'">
|
||||
<span [attr.data-cy]="'block-details-miner-badge'" placement="bottom" class="badge"
|
||||
[class]="!block?.extras.pool.name || block?.extras.pool.slug === 'unknown' ? 'badge-secondary' : 'badge-primary'">
|
||||
<span class="miner-name" *ngIf="block.extras.pool.minerNames?.length > 1 && block.extras.pool.minerNames[1] != ''">
|
||||
@if (block.extras.pool.minerNames[1].length > 16) {
|
||||
{{ block.extras.pool.minerNames[1].slice(0, 15) }}…
|
||||
} @else {
|
||||
{{ block.extras.pool.minerNames[1] }}
|
||||
}
|
||||
</span>
|
||||
{{ block.extras.pool.name }}
|
||||
</span>
|
||||
{{ block?.extras.pool.name }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -66,10 +66,10 @@
|
||||
[class.badge-success]="blockAudit?.matchRate >= 99"
|
||||
[class.badge-warning]="blockAudit?.matchRate >= 75 && blockAudit?.matchRate < 99"
|
||||
[class.badge-danger]="blockAudit?.matchRate < 75"
|
||||
*ngIf="blockAudit?.matchRate != null && blockAudit?.id === block.id; else nullHealth"
|
||||
*ngIf="blockAudit?.matchRate != null; else nullHealth"
|
||||
>{{ blockAudit?.matchRate }}%</span>
|
||||
<ng-template #nullHealth>
|
||||
<ng-container *ngIf="!isLoadingOverview && blockAudit?.id === block.id; else loadingHealth">
|
||||
<ng-container *ngIf="!isLoadingOverview; else loadingHealth">
|
||||
<span class="health-badge badge badge-secondary" i18n="unknown">Unknown</span>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
@@ -182,13 +182,6 @@
|
||||
<td i18n="block.miner">Miner</td>
|
||||
<td *ngIf="stateService.env.MINING_DASHBOARD">
|
||||
<a placement="bottom" [routerLink]="['/mining/pool' | relativeUrl, block.extras.pool.slug]" class="badge" style="color: #FFF;padding:0;">
|
||||
<span class="miner-name" *ngIf="block.extras.pool.minerNames?.length > 1 && block.extras.pool.minerNames[1] != ''">
|
||||
@if (block.extras.pool.minerNames[1].length > 16) {
|
||||
{{ block.extras.pool.minerNames[1].slice(0, 15) }}…
|
||||
} @else {
|
||||
{{ block.extras.pool.minerNames[1] }}
|
||||
}
|
||||
</span>
|
||||
<img class="pool-logo" [src]="'/resources/mining-pools/' + block.extras.pool.slug + '.svg'" onError="this.src = '/resources/mining-pools/default.svg'" [alt]="'Logo of ' + block.extras.pool.name + ' mining pool'">
|
||||
{{ block.extras.pool.name }}
|
||||
</a>
|
||||
|
||||
@@ -81,19 +81,6 @@ h1 {
|
||||
}
|
||||
}
|
||||
|
||||
.miner-name {
|
||||
margin-right: 4px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.pool-logo {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.row {
|
||||
flex-direction: column;
|
||||
@media (min-width: 768px) {
|
||||
|
||||
@@ -60,14 +60,9 @@
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="animated" *ngIf="block.extras?.pool != undefined && showPools">
|
||||
<a [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-pool'" class="badge" [class.miner-name]="block.extras.pool.minerNames?.length > 1 && block.extras.pool.minerNames[1] != ''" [routerLink]="[('/mining/pool/' + block.extras.pool.slug) | relativeUrl]">
|
||||
<ng-container *ngIf="block.extras.pool.minerNames?.length > 1 && block.extras.pool.minerNames[1] != ''; else centralisedPool">
|
||||
<img [ngbTooltip]="block.extras.pool.name" class="pool-logo faded" [src]="'/resources/mining-pools/' + block.extras.pool.slug + '.svg'" onError="this.src = '/resources/mining-pools/default.svg'" [alt]="'Logo of ' + block.extras.pool.name + ' mining pool'">
|
||||
{{ block.extras.pool.minerNames[1] }}
|
||||
</ng-container>
|
||||
<ng-template #centralisedPool>
|
||||
<img class="pool-logo" [src]="'/resources/mining-pools/' + block.extras.pool.slug + '.svg'" onError="this.src = '/resources/mining-pools/default.svg'" [alt]="'Logo of ' + block.extras.pool.name + ' mining pool'"> {{ block.extras.pool.name }}
|
||||
</ng-template>
|
||||
<a [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-pool'" class="badge" [routerLink]="[('/mining/pool/' + block.extras.pool.slug) | relativeUrl]">
|
||||
<img class="pool-logo" [src]="'/resources/mining-pools/' + block.extras.pool.slug + '.svg'" onError="this.src = '/resources/mining-pools/default.svg'" [alt]="'Logo of ' + block.extras.pool.name + ' mining pool'">
|
||||
{{ block.extras.pool.name}}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,38 +19,6 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.on-pool-name-text {
|
||||
display: inline-block;
|
||||
padding-top: 2px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
|
||||
.on-pool {
|
||||
align-items: center;
|
||||
background-color: var(--bg);
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
padding: .25em .4em;
|
||||
border-radius: .25rem;
|
||||
}
|
||||
|
||||
.on-pool-container {
|
||||
align-items: center;
|
||||
position: relative;
|
||||
top: -8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.on-pool-container.selected {
|
||||
top: 0px;
|
||||
}
|
||||
|
||||
.pool-container {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.mined-block {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
@@ -187,16 +155,9 @@
|
||||
|
||||
.badge {
|
||||
position: relative;
|
||||
top: 19px;
|
||||
top: 15px;
|
||||
z-index: 101;
|
||||
color: #FFF;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 145px;
|
||||
|
||||
&.miner-name {
|
||||
max-width: 125px;
|
||||
}
|
||||
}
|
||||
|
||||
.pool-logo {
|
||||
@@ -207,10 +168,6 @@
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.pool-logo.faded {
|
||||
filter: grayscale(100%) brightness(1.5);
|
||||
}
|
||||
|
||||
.animated {
|
||||
transition: all 0.15s ease-in-out;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<div *ngIf="!widget" class="float-left" style="display: flex; width: 100%; align-items: center;">
|
||||
<h1 i18n="master-page.blocks">Blocks</h1>
|
||||
<app-svg-images name="blocks-2-3" style="width: 275px; max-width: 90%; margin-top: -10px"></app-svg-images>
|
||||
<div *ngIf="!widget && isLoading" class="spinner-border" role="status"></div>
|
||||
</div>
|
||||
<div *ngIf="!widget && isLoading" class="spinner-border ml-3" role="status"></div>
|
||||
|
||||
<div class="clearfix"></div>
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
.spinner-border {
|
||||
height: 25px;
|
||||
width: 25px;
|
||||
margin-top: -10px;
|
||||
margin-left: -13px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 13px;
|
||||
}
|
||||
|
||||
.container-xl {
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
|
||||
<app-block-overview-multi
|
||||
#blockGraph
|
||||
[isLoading]="isLoadingTransactions"
|
||||
[numBlocks]="numBlocks"
|
||||
[padding]="padding"
|
||||
[blockWidth]="blockWidth"
|
||||
[resolution]="resolution"
|
||||
[blockLimit]="stateService.blockVSize"
|
||||
[orientation]="'top'"
|
||||
[flip]="false"
|
||||
[animationDuration]="animationDuration"
|
||||
[animationOffset]="animationOffset"
|
||||
></app-block-overview-multi>
|
||||
<div class="blocks" [class.wrap]="wrapBlocks">
|
||||
<ng-container *ngFor="let i of blockIndices">
|
||||
<div class="block-wrapper" [style]="wrapperStyle">
|
||||
<div class="block-container" [style]="containerStyle">
|
||||
<app-block-overview-graph
|
||||
#blockGraph
|
||||
[isLoading]="false"
|
||||
[resolution]="resolution"
|
||||
[blockLimit]="stateService.blockVSize"
|
||||
[orientation]="'top'"
|
||||
[flip]="false"
|
||||
[animationDuration]="animationDuration"
|
||||
[animationOffset]="animationOffset"
|
||||
[disableSpinner]="true"
|
||||
[relativeTime]="blockInfo[i]?.timestamp"
|
||||
(txClickEvent)="onTxClick($event)"
|
||||
></app-block-overview-graph>
|
||||
<div *ngIf="showInfo && blockInfo[i]" class="info" @infoChange>
|
||||
<h1 class="height">{{ blockInfo[i].height }}</h1>
|
||||
<h2 class="mined-by">by {{ blockInfo[i].extras.pool.name || 'Unknown' }} <img class="pool-logo" [src]="'/resources/mining-pools/' + blockInfo[i].extras.pool.slug + '.svg'" onError="this.src = '/resources/mining-pools/default.svg'"> </h2>
|
||||
<h2 class="mined-by">by {{ blockInfo[i].extras.pool.name || 'Unknown' }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
.blocks {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 100vw;
|
||||
@@ -69,12 +66,4 @@
|
||||
.block-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.pool-logo {
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
import { Component, OnInit, OnDestroy, ViewChild, HostListener } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, ViewChildren, QueryList } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { catchError, startWith } from 'rxjs/operators';
|
||||
import { Subject, Subscription, of } from 'rxjs';
|
||||
import { StateService } from '../../services/state.service';
|
||||
import { WebsocketService } from '../../services/websocket.service';
|
||||
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
|
||||
import { BlockExtended, TransactionStripped } from '../../interfaces/node-api.interface';
|
||||
import { ApiService } from '../../services/api.service';
|
||||
import { BlockOverviewGraphComponent } from '../block-overview-graph/block-overview-graph.component';
|
||||
import { detectWebGL } from '../../shared/graphs.utils';
|
||||
import { animate, style, transition, trigger } from '@angular/animations';
|
||||
import { BytesPipe } from '../../shared/pipes/bytes-pipe/bytes.pipe';
|
||||
import { BlockOverviewMultiComponent } from '../block-overview-multi/block-overview-multi.component';
|
||||
import { CacheService } from '../../services/cache.service';
|
||||
|
||||
function bestFitResolution(min, max, n): number {
|
||||
const target = (min + max) / 2;
|
||||
@@ -48,27 +48,24 @@ interface BlockInfo extends BlockExtended {
|
||||
})
|
||||
export class EightBlocksComponent implements OnInit, OnDestroy {
|
||||
network = '';
|
||||
latestBlocks: (BlockExtended | null)[] = [];
|
||||
pendingBlocks: Record<number, ((b: BlockExtended) => void)[]> = {};
|
||||
latestBlocks: BlockExtended[] = [];
|
||||
isLoadingTransactions = true;
|
||||
strippedTransactions: { [height: number]: TransactionStripped[] } = {};
|
||||
webGlEnabled = true;
|
||||
hoverTx: string | null = null;
|
||||
|
||||
tipSubscription: Subscription;
|
||||
blocksSubscription: Subscription;
|
||||
cacheBlocksSubscription: Subscription;
|
||||
networkChangedSubscription: Subscription;
|
||||
queryParamsSubscription: Subscription;
|
||||
graphChangeSubscription: Subscription;
|
||||
|
||||
height: number = 0;
|
||||
numBlocks: number = 8;
|
||||
autoNumBlocks: boolean = false;
|
||||
blockIndices: number[] = [...Array(8).keys()];
|
||||
autofit: boolean = false;
|
||||
padding: number = 0;
|
||||
wrapBlocks: boolean = false;
|
||||
blockWidth: number = 360;
|
||||
blockWidth: number = 1080;
|
||||
animationDuration: number = 2000;
|
||||
animationOffset: number = 0;
|
||||
stagger: number = 0;
|
||||
@@ -82,14 +79,13 @@ export class EightBlocksComponent implements OnInit, OnDestroy {
|
||||
wrapperStyle = {
|
||||
'--block-width': '1080px',
|
||||
width: '1080px',
|
||||
height: '1080px',
|
||||
maxWidth: '1080px',
|
||||
margin: '',
|
||||
padding: '',
|
||||
};
|
||||
containerStyle = {};
|
||||
resolution: number = 86;
|
||||
|
||||
@ViewChild('blockGraph') blockGraph: BlockOverviewMultiComponent;
|
||||
@ViewChildren('blockGraph') blockGraphs: QueryList<BlockOverviewGraphComponent>;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
@@ -97,7 +93,6 @@ export class EightBlocksComponent implements OnInit, OnDestroy {
|
||||
public stateService: StateService,
|
||||
private websocketService: WebsocketService,
|
||||
private apiService: ApiService,
|
||||
private cacheService: CacheService,
|
||||
private bytesPipe: BytesPipe,
|
||||
) {
|
||||
this.webGlEnabled = this.stateService.isBrowser && detectWebGL();
|
||||
@@ -108,24 +103,15 @@ export class EightBlocksComponent implements OnInit, OnDestroy {
|
||||
this.network = this.stateService.network;
|
||||
|
||||
this.queryParamsSubscription = this.route.queryParams.subscribe((params) => {
|
||||
this.numBlocks = Number.isInteger(Number(params.numBlocks)) ? Number(params.numBlocks) : 8;
|
||||
this.blockIndices = [...Array(this.numBlocks).keys()];
|
||||
this.autofit = params.autofit !== 'false';
|
||||
this.numBlocks = Number.isInteger(Number(params.numBlocks)) ? Number(params.numBlocks) : 0;
|
||||
this.blockWidth = Number.isInteger(Number(params.blockWidth)) ? Number(params.blockWidth) : 320;
|
||||
this.padding = Number.isInteger(Number(params.padding)) ? Number(params.padding) : 4;
|
||||
this.padding = Number.isInteger(Number(params.padding)) ? Number(params.padding) : 10;
|
||||
this.blockWidth = Number.isInteger(Number(params.blockWidth)) ? Number(params.blockWidth) : 540;
|
||||
this.wrapBlocks = params.wrap !== 'false';
|
||||
this.stagger = Number.isInteger(Number(params.stagger)) ? Number(params.stagger) : 0;
|
||||
this.animationDuration = Number.isInteger(Number(params.animationDuration)) ? Number(params.animationDuration) : 2000;
|
||||
this.animationOffset = 0;
|
||||
|
||||
if (!this.numBlocks) {
|
||||
this.autoNumBlocks = true;
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
const paddedWidth = this.blockWidth + (this.padding * 2);
|
||||
this.numBlocks = Math.floor(width / paddedWidth) * Math.floor(height / paddedWidth);
|
||||
}
|
||||
|
||||
this.blockIndices = [...Array(this.numBlocks).keys()];
|
||||
this.animationOffset = this.padding * 2;
|
||||
|
||||
if (this.autofit) {
|
||||
this.resolution = bestFitResolution(76, 96, this.blockWidth - this.padding * 2);
|
||||
@@ -136,24 +122,24 @@ export class EightBlocksComponent implements OnInit, OnDestroy {
|
||||
this.wrapperStyle = {
|
||||
'--block-width': this.blockWidth + 'px',
|
||||
width: this.blockWidth + 'px',
|
||||
height: this.blockWidth + 'px',
|
||||
maxWidth: this.blockWidth + 'px',
|
||||
margin: (this.padding || 0) +'px ',
|
||||
padding: (this.padding || 0) +'px 0px',
|
||||
};
|
||||
|
||||
this.cacheBlocksSubscription = this.cacheService.loadedBlocks$.subscribe((block: BlockExtended) => {
|
||||
if (this.pendingBlocks[block.height]) {
|
||||
this.pendingBlocks[block.height].forEach(resolve => resolve(block));
|
||||
delete this.pendingBlocks[block.height];
|
||||
if (params.test === 'true') {
|
||||
if (this.blocksSubscription) {
|
||||
this.blocksSubscription.unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
this.tipSubscription?.unsubscribe();
|
||||
this.tipSubscription = this.stateService.chainTip$
|
||||
.subscribe((height) => {
|
||||
this.height = height;
|
||||
this.handleNewBlock(height);
|
||||
this.blocksSubscription = (new Subject<BlockExtended[]>()).subscribe((blocks) => {
|
||||
this.handleNewBlock(blocks.slice(0, this.numBlocks));
|
||||
});
|
||||
this.shiftTestBlocks();
|
||||
} else if (!this.blocksSubscription) {
|
||||
this.blocksSubscription = this.stateService.blocks$
|
||||
.subscribe((blocks) => {
|
||||
this.handleNewBlock(blocks.slice(0, this.numBlocks));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.setupBlockGraphs();
|
||||
@@ -163,80 +149,53 @@ export class EightBlocksComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.setupBlockGraphs();
|
||||
}
|
||||
|
||||
@HostListener('window:resize', ['$event'])
|
||||
resizeCanvas(): void {
|
||||
if (this.autoNumBlocks) {
|
||||
this.autoNumBlocks = true;
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
const paddedWidth = this.blockWidth + (this.padding * 2);
|
||||
this.numBlocks = Math.floor(width / paddedWidth) * Math.floor(height / paddedWidth);
|
||||
this.blockIndices = [...Array(this.numBlocks).keys()];
|
||||
|
||||
if (this.autofit) {
|
||||
this.resolution = bestFitResolution(76, 96, this.blockWidth - this.padding * 2);
|
||||
} else {
|
||||
this.resolution = 86;
|
||||
}
|
||||
|
||||
this.wrapperStyle = {
|
||||
'--block-width': this.blockWidth + 'px',
|
||||
width: this.blockWidth + 'px',
|
||||
height: this.blockWidth + 'px',
|
||||
maxWidth: this.blockWidth + 'px',
|
||||
margin: (this.padding || 0) +'px ',
|
||||
};
|
||||
|
||||
if (this.cacheBlocksSubscription) {
|
||||
this.cacheBlocksSubscription.unsubscribe();
|
||||
}
|
||||
this.cacheBlocksSubscription = this.cacheService.loadedBlocks$.subscribe((block: BlockExtended) => {
|
||||
if (this.pendingBlocks[block.height]) {
|
||||
this.pendingBlocks[block.height].forEach(resolve => resolve(block));
|
||||
delete this.pendingBlocks[block.height];
|
||||
}
|
||||
});
|
||||
|
||||
this.tipSubscription?.unsubscribe();
|
||||
this.tipSubscription = this.stateService.chainTip$
|
||||
.subscribe((height) => {
|
||||
this.height = height;
|
||||
this.handleNewBlock(height);
|
||||
});
|
||||
|
||||
this.graphChangeSubscription = this.blockGraphs.changes.pipe(startWith(null)).subscribe(() => {
|
||||
this.setupBlockGraphs();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stateService.markBlock$.next({});
|
||||
if (this.tipSubscription) {
|
||||
this.tipSubscription?.unsubscribe();
|
||||
if (this.blocksSubscription) {
|
||||
this.blocksSubscription?.unsubscribe();
|
||||
}
|
||||
this.cacheBlocksSubscription?.unsubscribe();
|
||||
this.networkChangedSubscription?.unsubscribe();
|
||||
this.queryParamsSubscription?.unsubscribe();
|
||||
}
|
||||
|
||||
async handleNewBlock(height: number): Promise<void> {
|
||||
shiftTestBlocks(): void {
|
||||
const sub = this.apiService.getBlocks$(this.testHeight).subscribe(result => {
|
||||
sub.unsubscribe();
|
||||
this.handleNewBlock(result.slice(0, this.numBlocks));
|
||||
this.testHeight++;
|
||||
clearTimeout(this.testShiftTimeout);
|
||||
this.testShiftTimeout = window.setTimeout(() => { this.shiftTestBlocks(); }, 10000);
|
||||
});
|
||||
}
|
||||
|
||||
async handleNewBlock(blocks: BlockExtended[]): Promise<void> {
|
||||
const readyPromises: Promise<TransactionStripped[]>[] = [];
|
||||
const previousBlocks = this.latestBlocks;
|
||||
|
||||
const blocks = await this.loadBlocks(height, this.numBlocks);
|
||||
|
||||
const newHeights = {};
|
||||
this.latestBlocks = blocks;
|
||||
for (const block of blocks) {
|
||||
newHeights[block.height] = true;
|
||||
if (!this.strippedTransactions[block.height]) {
|
||||
readyPromises.push(this.loadBlockTransactions(block));
|
||||
readyPromises.push(new Promise((resolve) => {
|
||||
const subscription = this.apiService.getStrippedBlockTransactions$(block.id).pipe(
|
||||
catchError(() => {
|
||||
return of([]);
|
||||
}),
|
||||
).subscribe((transactions) => {
|
||||
this.strippedTransactions[block.height] = transactions;
|
||||
subscription.unsubscribe();
|
||||
resolve(transactions);
|
||||
});
|
||||
}));
|
||||
}
|
||||
}
|
||||
await Promise.allSettled(readyPromises);
|
||||
this.isLoadingTransactions = false;
|
||||
this.updateBlockGraphs(blocks);
|
||||
|
||||
// free up old transactions
|
||||
@@ -247,44 +206,12 @@ export class EightBlocksComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
async loadBlocks(height: number, numBlocks: number): Promise<BlockExtended[]> {
|
||||
const promises: Promise<BlockExtended>[] = [];
|
||||
for (let i = 0; i < numBlocks; i++) {
|
||||
this.cacheService.loadBlock(height - i);
|
||||
const cachedBlock = this.cacheService.getCachedBlock(height - i);
|
||||
if (cachedBlock) {
|
||||
promises.push(Promise.resolve(cachedBlock));
|
||||
} else {
|
||||
promises.push(new Promise((resolve) => {
|
||||
if (!this.pendingBlocks[height - i]) {
|
||||
this.pendingBlocks[height - i] = [];
|
||||
}
|
||||
this.pendingBlocks[height - i].push(resolve);
|
||||
}));
|
||||
}
|
||||
}
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
async loadBlockTransactions(block: BlockExtended): Promise<TransactionStripped[]> {
|
||||
return new Promise((resolve) => {
|
||||
this.apiService.getStrippedBlockTransactions$(block.id).pipe(
|
||||
catchError(() => {
|
||||
return of([]);
|
||||
}),
|
||||
).subscribe((transactions) => {
|
||||
this.strippedTransactions[block.height] = transactions;
|
||||
resolve(transactions);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
updateBlockGraphs(blocks): void {
|
||||
const startTime = performance.now() + 1000 - (this.stagger < 0 ? this.stagger * 8 : 0);
|
||||
if (this.blockGraph) {
|
||||
for (let i = 0; i < this.numBlocks; i++) {
|
||||
this.blockGraph.replace(i, this.strippedTransactions[blocks?.[this.getBlockIndex(i)]?.height] || [], 'right', false, startTime + (this.stagger * i));
|
||||
}
|
||||
if (this.blockGraphs) {
|
||||
this.blockGraphs.forEach((graph, index) => {
|
||||
graph.replace(this.strippedTransactions[blocks?.[index]?.height] || [], 'right', false, startTime + (this.stagger * index));
|
||||
});
|
||||
}
|
||||
this.showInfo = false;
|
||||
setTimeout(() => {
|
||||
@@ -299,22 +226,28 @@ export class EightBlocksComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
setupBlockGraphs(): void {
|
||||
if (this.blockGraph) {
|
||||
for (let i = 0; i < this.numBlocks; i++) {
|
||||
this.blockGraph.destroy(i);
|
||||
this.blockGraph.setup(i, this.strippedTransactions[this.latestBlocks?.[this.getBlockIndex(i)]?.height] || []);
|
||||
}
|
||||
if (this.blockGraphs) {
|
||||
this.blockGraphs.forEach((graph, index) => {
|
||||
graph.destroy();
|
||||
graph.setup(this.strippedTransactions[this.latestBlocks?.[index]?.height] || []);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getBlockIndex(slotIndex: number): number {
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
const paddedWidth = this.blockWidth + (this.padding * 2);
|
||||
const blocksPerRow = Math.floor(width / paddedWidth);
|
||||
const blocksPerColumn = Math.floor(height / paddedWidth);
|
||||
const row = Math.floor(slotIndex / blocksPerRow);
|
||||
const column = slotIndex % blocksPerRow;
|
||||
return (blocksPerColumn - 1 - row) * blocksPerRow + column;
|
||||
onTxClick(event: { tx: TransactionStripped, keyModifier: boolean }): void {
|
||||
const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.tx.txid}`);
|
||||
if (!event.keyModifier) {
|
||||
this.router.navigate([url]);
|
||||
} else {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
onTxHover(txid: string): void {
|
||||
if (txid && txid.length) {
|
||||
this.hoverTx = txid;
|
||||
} else {
|
||||
this.hoverTx = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<app-block-overview-multi
|
||||
#blockGraph
|
||||
[isLoading]="isLoading"
|
||||
[numBlocks]="numBlocks"
|
||||
[padding]="padding"
|
||||
[blockWidth]="blockWidth"
|
||||
[resolution]="resolution"
|
||||
[blockLimit]="stateService.blockVSize"
|
||||
[orientation]="'left'"
|
||||
[flip]="true"
|
||||
[showFilters]="true"
|
||||
[animationDuration]="animationDuration"
|
||||
[animationOffset]="animationOffset"
|
||||
(txClickEvent)="onTxClick($event)"
|
||||
></app-block-overview-multi>
|
||||
@@ -1,72 +0,0 @@
|
||||
.blocks {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 100vw;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
align-content: flex-start;
|
||||
|
||||
&.wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.block-wrapper {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
--block-width: 1080px;
|
||||
|
||||
.info {
|
||||
position: absolute;
|
||||
left: 8%;
|
||||
top: 8%;
|
||||
right: 8%;
|
||||
bottom: 8%;
|
||||
height: 84%;
|
||||
width: 84%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: calc(var(--block-width) * 0.03);
|
||||
text-shadow: 0 0 calc(var(--block-width) * 0.05) black;
|
||||
|
||||
h1 {
|
||||
font-size: 6em;
|
||||
line-height: 1;
|
||||
margin-bottom: calc(var(--block-width) * 0.03);
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.8em;
|
||||
line-height: 1;
|
||||
margin-bottom: calc(var(--block-width) * 0.03);
|
||||
}
|
||||
|
||||
.hash {
|
||||
font-family: monospace;
|
||||
word-wrap: break-word;
|
||||
font-size: 1.4em;
|
||||
line-height: 1;
|
||||
margin-bottom: calc(var(--block-width) * 0.03);
|
||||
}
|
||||
|
||||
.mined-by {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.block-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
import { Component, OnInit, OnDestroy, ViewChild, HostListener } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { StateService } from '../../services/state.service';
|
||||
import { WebsocketService } from '../../services/websocket.service';
|
||||
import { TransactionStripped } from '../../interfaces/node-api.interface';
|
||||
import { ApiService } from '../../services/api.service';
|
||||
import { detectWebGL } from '../../shared/graphs.utils';
|
||||
import { animate, style, transition, trigger } from '@angular/animations';
|
||||
import { BytesPipe } from '../../shared/pipes/bytes-pipe/bytes.pipe';
|
||||
import { BlockOverviewMultiComponent } from '../block-overview-multi/block-overview-multi.component';
|
||||
import { CacheService } from '../../services/cache.service';
|
||||
import { isMempoolDelta, MempoolBlockDelta } from '../../interfaces/websocket.interface';
|
||||
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
|
||||
|
||||
function bestFitResolution(min, max, n): number {
|
||||
const target = (min + max) / 2;
|
||||
let bestScore = Infinity;
|
||||
let best = null;
|
||||
for (let i = min; i <= max; i++) {
|
||||
const remainder = (n % i);
|
||||
if (remainder < bestScore || (remainder === bestScore && (Math.abs(i - target) < Math.abs(best - target)))) {
|
||||
bestScore = remainder;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-eight-mempool',
|
||||
templateUrl: './eight-mempool.component.html',
|
||||
styleUrls: ['./eight-mempool.component.scss'],
|
||||
animations: [
|
||||
trigger('infoChange', [
|
||||
transition(':enter', [
|
||||
style({ opacity: 0 }),
|
||||
animate('1000ms', style({ opacity: 1 })),
|
||||
]),
|
||||
transition(':leave', [
|
||||
animate('1000ms 500ms', style({ opacity: 0 }))
|
||||
])
|
||||
]),
|
||||
],
|
||||
})
|
||||
export class EightMempoolComponent implements OnInit, OnDestroy {
|
||||
network = '';
|
||||
strippedTransactions: { [height: number]: TransactionStripped[] } = {};
|
||||
isLoading = true;
|
||||
webGlEnabled = true;
|
||||
hoverTx: string | null = null;
|
||||
|
||||
networkChangedSubscription: Subscription;
|
||||
queryParamsSubscription: Subscription;
|
||||
graphChangeSubscription: Subscription;
|
||||
blockSub: Subscription;
|
||||
|
||||
chainDirection: string = 'right';
|
||||
poolDirection: string = 'left';
|
||||
|
||||
lastBlockHeight: number = 0;
|
||||
lastBlockHeightUpdate: number[] = [];
|
||||
numBlocks: number = 8;
|
||||
autoNumBlocks: boolean = false;
|
||||
blockIndices: number[] = [];
|
||||
autofit: boolean = false;
|
||||
padding: number = 0;
|
||||
wrapBlocks: boolean = false;
|
||||
blockWidth: number = 360;
|
||||
animationDuration: number = 2000;
|
||||
animationOffset: number = 0;
|
||||
stagger: number = 0;
|
||||
testing: boolean = true;
|
||||
testHeight: number = 800000;
|
||||
testShiftTimeout: number;
|
||||
|
||||
showInfo: boolean = true;
|
||||
|
||||
wrapperStyle = {
|
||||
'--block-width': '1080px',
|
||||
width: '1080px',
|
||||
height: '1080px',
|
||||
maxWidth: '1080px',
|
||||
margin: '',
|
||||
};
|
||||
containerStyle = {};
|
||||
resolution: number = 86;
|
||||
|
||||
@ViewChild('blockGraph') blockGraph: BlockOverviewMultiComponent;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
public stateService: StateService,
|
||||
private websocketService: WebsocketService,
|
||||
private apiService: ApiService,
|
||||
private cacheService: CacheService,
|
||||
private bytesPipe: BytesPipe,
|
||||
) {
|
||||
this.webGlEnabled = this.stateService.isBrowser && detectWebGL();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.websocketService.want(['blocks', 'mempool-blocks']);
|
||||
this.network = this.stateService.network;
|
||||
|
||||
this.stateService.activeGoggles$.next({ mode: 'and', filters: [], gradient: 'fee' });
|
||||
|
||||
this.blockSub = this.stateService.mempoolBlockUpdate$.subscribe((update) => {
|
||||
// process update
|
||||
if (isMempoolDelta(update)) {
|
||||
// delta
|
||||
this.updateBlock(update);
|
||||
} else {
|
||||
const transactionsStripped = update.transactions;
|
||||
const inOldBlock = {};
|
||||
const inNewBlock = {};
|
||||
const added: TransactionStripped[] = [];
|
||||
const changed: { txid: string, rate: number | undefined, flags: number, acc: boolean | undefined }[] = [];
|
||||
const removed: string[] = [];
|
||||
for (const tx of transactionsStripped) {
|
||||
inNewBlock[tx.txid] = true;
|
||||
}
|
||||
for (const txid of Object.keys(this.blockGraph?.scenes[this.numBlocks - update.block - 1]?.txs || {})) {
|
||||
inOldBlock[txid] = true;
|
||||
if (!inNewBlock[txid]) {
|
||||
removed.push(txid);
|
||||
}
|
||||
}
|
||||
for (const tx of transactionsStripped) {
|
||||
if (!inOldBlock[tx.txid]) {
|
||||
added.push(tx);
|
||||
} else {
|
||||
changed.push({
|
||||
txid: tx.txid,
|
||||
rate: tx.rate,
|
||||
flags: tx.flags,
|
||||
acc: tx.acc
|
||||
});
|
||||
}
|
||||
}
|
||||
this.updateBlock({
|
||||
block: update.block,
|
||||
removed,
|
||||
changed,
|
||||
added
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.queryParamsSubscription = this.route.queryParams.subscribe((params) => {
|
||||
this.autofit = params.autofit !== 'false';
|
||||
this.numBlocks = Number.isInteger(Number(params.numBlocks)) ? Number(params.numBlocks) : 0;
|
||||
this.blockWidth = Number.isInteger(Number(params.blockWidth)) ? Number(params.blockWidth) : 320;
|
||||
this.padding = Number.isInteger(Number(params.padding)) ? Number(params.padding) : 4;
|
||||
this.wrapBlocks = params.wrap !== 'false';
|
||||
this.stagger = Number.isInteger(Number(params.stagger)) ? Number(params.stagger) : 0;
|
||||
this.animationDuration = Number.isInteger(Number(params.animationDuration)) ? Number(params.animationDuration) : 2000;
|
||||
this.animationOffset = 0;
|
||||
|
||||
if (!this.numBlocks) {
|
||||
this.autoNumBlocks = true;
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
const paddedWidth = this.blockWidth + (this.padding * 2);
|
||||
this.numBlocks = Math.floor(width / paddedWidth) * Math.floor(height / paddedWidth);
|
||||
}
|
||||
|
||||
this.blockIndices = [...Array(this.numBlocks).keys()];
|
||||
this.lastBlockHeightUpdate = this.blockIndices.map(() => 0);
|
||||
|
||||
if (this.autofit) {
|
||||
this.resolution = bestFitResolution(76, 96, this.blockWidth - this.padding * 2);
|
||||
} else {
|
||||
this.resolution = 86;
|
||||
}
|
||||
|
||||
this.wrapperStyle = {
|
||||
'--block-width': this.blockWidth + 'px',
|
||||
width: this.blockWidth + 'px',
|
||||
height: this.blockWidth + 'px',
|
||||
maxWidth: this.blockWidth + 'px',
|
||||
margin: (this.padding || 0) +'px ',
|
||||
};
|
||||
|
||||
this.websocketService.startTrackMempoolBlocks(this.blockIndices);
|
||||
});
|
||||
|
||||
this.networkChangedSubscription = this.stateService.networkChanged$
|
||||
.subscribe((network) => this.network = network);
|
||||
}
|
||||
|
||||
onTxClick(event: { tx: TransactionStripped, keyModifier: boolean }): void {
|
||||
const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.tx.txid}`);
|
||||
if (!event.keyModifier) {
|
||||
this.router.navigate([url]);
|
||||
} else {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('window:resize', ['$event'])
|
||||
resizeCanvas(): void {
|
||||
if (this.autoNumBlocks) {
|
||||
this.autoNumBlocks = true;
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
const paddedWidth = this.blockWidth + (this.padding * 2);
|
||||
this.numBlocks = Math.floor(width / paddedWidth) * Math.floor(height / paddedWidth);
|
||||
this.blockIndices = [...Array(this.numBlocks).keys()];
|
||||
this.lastBlockHeightUpdate = this.blockIndices.map(() => 0);
|
||||
|
||||
if (this.autofit) {
|
||||
this.resolution = bestFitResolution(76, 96, this.blockWidth - this.padding * 2);
|
||||
} else {
|
||||
this.resolution = 86;
|
||||
}
|
||||
|
||||
this.wrapperStyle = {
|
||||
'--block-width': this.blockWidth + 'px',
|
||||
width: this.blockWidth + 'px',
|
||||
height: this.blockWidth + 'px',
|
||||
maxWidth: this.blockWidth + 'px',
|
||||
margin: (this.padding || 0) +'px ',
|
||||
};
|
||||
|
||||
this.websocketService.startTrackMempoolBlocks(this.blockIndices);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stateService.markBlock$.next({});
|
||||
this.blockSub.unsubscribe();
|
||||
this.networkChangedSubscription?.unsubscribe();
|
||||
this.queryParamsSubscription?.unsubscribe();
|
||||
}
|
||||
|
||||
updateBlock(delta: MempoolBlockDelta): void {
|
||||
const blockMined = (this.stateService.latestBlockHeight > this.lastBlockHeightUpdate[delta.block]);
|
||||
if (blockMined) {
|
||||
this.blockGraph.update(this.numBlocks - delta.block - 1, delta.added, delta.removed, delta.changed || [], blockMined ? this.chainDirection : this.poolDirection, blockMined);
|
||||
} else {
|
||||
this.blockGraph.update(this.numBlocks - delta.block - 1, delta.added, delta.removed, delta.changed || [], this.poolDirection);
|
||||
}
|
||||
this.isLoading = false;
|
||||
|
||||
this.lastBlockHeightUpdate[delta.block] = this.stateService.latestBlockHeight;
|
||||
}
|
||||
}
|
||||
@@ -27,14 +27,6 @@
|
||||
<app-twitter-login customClass="btn btn-sm" width="220px" redirectTo="/testnet4/faucet" buttonString="Sign up with Twitter"></app-twitter-login>
|
||||
</div>
|
||||
}
|
||||
@else if (user && user.status === 'pending' && !user.email && user.snsId) {
|
||||
<div class="alert alert-danger w-100 col d-flex justify-content-center text-left">
|
||||
<span class="d-flex">
|
||||
<fa-icon [icon]="['fas', 'exclamation-triangle']" [fixedWidth]="true" class="mr-1"></fa-icon>
|
||||
<span>Please <a class="text-primary" [routerLink]="['/services/account/settings']">verify your account</a> by providing a valid email address. To mitigate spam, we delete unverified accounts at regular intervals.</span>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@else if (error === 'not_available') {
|
||||
<!-- User logged in but not a paid user or did not link its Twitter account -->
|
||||
<div class="alert alert-mempool d-block text-center w-100">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, OnDestroy, OnInit, ChangeDetectorRef } from "@angular/core";
|
||||
import { FormBuilder, FormGroup, Validators, ValidatorFn, AbstractControl, ValidationErrors } from "@angular/forms";
|
||||
import { Subscription } from "rxjs";
|
||||
import { StorageService } from "../../services/storage.service";
|
||||
import { ServicesApiServices } from "../../services/services-api.service";
|
||||
import { getRegex } from "../../shared/regex.utils";
|
||||
import { StateService } from "../../services/state.service";
|
||||
@@ -33,6 +34,7 @@ export class FaucetComponent implements OnInit, OnDestroy {
|
||||
|
||||
constructor(
|
||||
private cd: ChangeDetectorRef,
|
||||
private storageService: StorageService,
|
||||
private servicesApiService: ServicesApiServices,
|
||||
private formBuilder: FormBuilder,
|
||||
private stateService: StateService,
|
||||
@@ -54,17 +56,14 @@ export class FaucetComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.servicesApiService.userSubject$.subscribe(user => {
|
||||
this.user = user;
|
||||
if (!user) {
|
||||
this.loading = false;
|
||||
this.cd.markForCheck();
|
||||
return;
|
||||
}
|
||||
// Setup form
|
||||
this.updateFaucetStatus();
|
||||
this.cd.markForCheck();
|
||||
});
|
||||
this.user = this.storageService.getAuth()?.user ?? null;
|
||||
if (!this.user) {
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup form
|
||||
this.updateFaucetStatus();
|
||||
|
||||
// Track transaction
|
||||
this.websocketService.want(['blocks', 'mempool-blocks']);
|
||||
@@ -146,6 +145,9 @@ export class FaucetComponent implements OnInit, OnDestroy {
|
||||
'address': ['', [Validators.required, Validators.pattern(getRegex('address', 'testnet4')), this.getNotFaucetAddressValidator(faucetAddress)]],
|
||||
'satoshis': [min, [Validators.required, Validators.min(min), Validators.max(max)]]
|
||||
});
|
||||
|
||||
this.loading = false;
|
||||
this.cd.markForCheck();
|
||||
}
|
||||
|
||||
updateForm(min, max, faucetAddress: string): void {
|
||||
@@ -158,8 +160,6 @@ export class FaucetComponent implements OnInit, OnDestroy {
|
||||
this.faucetForm.get('satoshis').updateValueAndValidity();
|
||||
this.faucetForm.get('satoshis').markAsDirty();
|
||||
}
|
||||
this.loading = false;
|
||||
this.cd.markForCheck();
|
||||
}
|
||||
|
||||
setAmount(value: number): void {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div *ngIf="widget">
|
||||
<div class="pool-distribution" *ngIf="(hashrateObservable$ | async) as hashrates; else loadingStats">
|
||||
<div class="item">
|
||||
<h5 class="card-title" i18n="mining.hashrate">Hashrate (1w)</h5>
|
||||
<h5 class="card-title" i18n="mining.hashrate">Hashrate</h5>
|
||||
<p class="card-text">
|
||||
{{ hashrates.currentHashrate | amountShortener: 1 : 'H/s' }}
|
||||
</p>
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
<li class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" id="btn-home" *ngIf="network.val === '' && stateService.env.ACCELERATOR">
|
||||
<a class="nav-link" [routerLink]="['/acceleration' | relativeUrl]" (click)="collapse()">
|
||||
<fa-icon [icon]="['fas', 'rocket']" [fixedWidth]="true" i18n-title="master-page.accelerator-dashboard" title="Accelerator Dashboard"></fa-icon>
|
||||
<span class="badge badge-pill badge-warning beta" i18n="beta">beta</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" id="btn-pools" *ngIf="stateService.env.MINING_DASHBOARD">
|
||||
|
||||
@@ -12,15 +12,9 @@
|
||||
<span class="badge mr-1 badge-og" *ngIf="user.ogRank">
|
||||
OG #{{ user.ogRank }}
|
||||
</span>
|
||||
@if (user.subscription_tag !== 'free') {
|
||||
<span class="badge mr-1 badge-default" [class]="'badge-' + user.subscription_tag">
|
||||
{{ user.subscription_tag.toUpperCase() }}
|
||||
</span>
|
||||
} @else if (user.type === 'mining_pool') {
|
||||
<span class="badge mr-1 badge-default" [class]="'badge-mining-pool'">
|
||||
MINING POOL
|
||||
</span>
|
||||
}
|
||||
<span class="badge mr-1 badge-default" [class]="'badge-' + user.subscription_tag" *ngIf="user.subscription_tag !== 'free'">
|
||||
{{ user.subscription_tag.toUpperCase() }}
|
||||
</span>
|
||||
</span>
|
||||
<a *ngIf="!userAuth" class="d-flex justify-content-center align-items-center nav-link m-0 menu-click" routerLink="/login" role="tab" (click)="onLinkClick('/login')">
|
||||
<fa-icon class="menu-click" [icon]="['fas', 'user-circle']" [fixedWidth]="true" style="font-size: 25px;margin-right: 15px;"></fa-icon>
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
@if (minted) {
|
||||
<ng-container i18n="ord.mint-n-runes">
|
||||
<span>Mint</span>
|
||||
<span class="amount"> {{ minted >= 100000 ? (minted | amountShortener:undefined:undefined:true) : minted }} </span>
|
||||
<ng-container *ngTemplateOutlet="runeName; context: { $implicit: runestone.mint.toString() }"></ng-container>
|
||||
</ng-container>
|
||||
}
|
||||
@if (runestone?.etching?.supply) {
|
||||
@if (runestone?.etching.premine > 0) {
|
||||
<ng-container i18n="ord.premine-n-runes">
|
||||
<span>Premine</span>
|
||||
<span class="amount"> {{ getAmount(runestone.etching.premine, runestone.etching.divisibility || 0) >= 100000 ? (getAmount(runestone.etching.premine, runestone.etching.divisibility || 0) | amountShortener:undefined:undefined:true) : getAmount(runestone.etching.premine, runestone.etching.divisibility || 0) }} </span>
|
||||
{{ runestone.etching.symbol }}
|
||||
<span class="name">{{ runestone.etching.spacedName }}</span>
|
||||
<span> ({{ toNumber(runestone.etching.premine) / toNumber(runestone.etching.supply) * 100 | amountShortener:0}}% of total supply)</span>
|
||||
</ng-container>
|
||||
} @else {
|
||||
<ng-container i18n="ord.etch-rune">
|
||||
<span>Etching of</span>
|
||||
{{ runestone.etching.symbol }}
|
||||
<span class="name">{{ runestone.etching.spacedName }}</span>
|
||||
</ng-container>
|
||||
}
|
||||
}
|
||||
@if (transferredRunes?.length && type === 'vout') {
|
||||
<div *ngFor="let rune of transferredRunes">
|
||||
<ng-container i18n="ord.transfer-rune">
|
||||
<span>Transfer</span>
|
||||
<ng-container *ngTemplateOutlet="runeName; context: { $implicit: rune.key }"></ng-container>
|
||||
</ng-container>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (inscriptions?.length && type === 'vin') {
|
||||
<div *ngFor="let contentType of inscriptionsData | keyvalue">
|
||||
<div>
|
||||
@if (contentType.key !== 'undefined') {
|
||||
<span class="badge badge-ord mr-1">{{ contentType.value.count > 1 ? contentType.value.count + " " : "" }}{{ contentType.value?.tag || contentType.key }}</span>
|
||||
} @else {
|
||||
<span class="badge badge-ord mr-1" i18n="unknown">Unknown</span>
|
||||
}
|
||||
<span class="badge badge-ord" *ngIf="contentType.value.totalSize > 0">{{ contentType.value.totalSize | bytes:2:'B':undefined:true }}</span>
|
||||
<a *ngIf="contentType.value.delegate" [routerLink]="['/tx' | relativeUrl, contentType.value.delegate]">
|
||||
<span i18n="ord.source-inscription">Source inscription</span>
|
||||
</a>
|
||||
</div>
|
||||
<pre *ngIf="contentType.value.json" class="name" style="white-space: pre-wrap; word-break: break-word;">{{ contentType.value.json | json }}</pre>
|
||||
<pre *ngIf="contentType.value.text" class="name" style="white-space: pre-wrap; word-break: break-word;">{{ contentType.value.text }}</pre>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!runestone && type === 'vout') {
|
||||
<div class="skeleton-loader" style="width: 50%;"></div>
|
||||
}
|
||||
|
||||
@if ((runestone && !minted && !runestone.etching?.supply && !transferredRunes?.length && type === 'vout') || (!inscriptions?.length && type === 'vin')) {
|
||||
<i i18n="error.decoding-data">Error decoding data</i>
|
||||
}
|
||||
|
||||
<ng-template #runeName let-id>
|
||||
{{ runeInfo[id]?.etching.symbol || '' }}
|
||||
<a [routerLink]="id !== '1:0' ? ['/tx' | relativeUrl, runeInfo[id]?.txid] : null" [class.rune-link]="id !== '1:0'" [class.disabled]="id === '1:0'">
|
||||
<span class="name">{{ runeInfo[id]?.etching.spacedName }}</span>
|
||||
</a>
|
||||
</ng-template>
|
||||
@@ -1,35 +0,0 @@
|
||||
.amount {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
a.rune-link {
|
||||
color: inherit;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--transparent-fg);
|
||||
}
|
||||
}
|
||||
|
||||
a.disabled {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.name {
|
||||
color: var(--transparent-fg);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.badge-ord {
|
||||
background-color: var(--grey);
|
||||
position: relative;
|
||||
top: -2px;
|
||||
font-size: 81%;
|
||||
&.primary {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
margin-top: 5px;
|
||||
max-height: 200px;
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core';
|
||||
import { Runestone, Etching } from '../../shared/ord/rune.utils';
|
||||
import { Inscription } from '../../shared/ord/inscription.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'app-ord-data',
|
||||
templateUrl: './ord-data.component.html',
|
||||
styleUrls: ['./ord-data.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class OrdDataComponent implements OnChanges {
|
||||
@Input() inscriptions: Inscription[];
|
||||
@Input() runestone: Runestone;
|
||||
@Input() runeInfo: { [id: string]: { etching: Etching; txid: string } };
|
||||
@Input() type: 'vin' | 'vout';
|
||||
|
||||
toNumber = (value: bigint): number => Number(value);
|
||||
|
||||
// Inscriptions
|
||||
inscriptionsData: { [key: string]: { count: number, totalSize: number, text?: string; json?: JSON; tag?: string; delegate?: string } };
|
||||
// Rune mints
|
||||
minted: number;
|
||||
// Rune transfers
|
||||
transferredRunes: { key: string; etching: Etching; txid: string }[] = [];
|
||||
|
||||
constructor() { }
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes.runestone && this.runestone) {
|
||||
if (this.runestone.mint && this.runeInfo[this.runestone.mint.toString()]) {
|
||||
const mint = this.runestone.mint.toString();
|
||||
const terms = this.runeInfo[mint].etching.terms;
|
||||
const amount = terms?.amount;
|
||||
const divisibility = this.runeInfo[mint].etching.divisibility;
|
||||
if (amount) {
|
||||
this.minted = this.getAmount(amount, divisibility);
|
||||
}
|
||||
}
|
||||
|
||||
this.runestone.edicts.forEach(edict => {
|
||||
if (this.runeInfo[edict.id.toString()]) {
|
||||
this.transferredRunes.push({ key: edict.id.toString(), ...this.runeInfo[edict.id.toString()] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (changes.inscriptions && this.inscriptions) {
|
||||
|
||||
if (this.inscriptions?.length) {
|
||||
this.inscriptionsData = {};
|
||||
this.inscriptions.forEach((inscription) => {
|
||||
// General: count, total size, delegate
|
||||
const key = inscription.content_type_str || 'undefined';
|
||||
if (!this.inscriptionsData[key]) {
|
||||
this.inscriptionsData[key] = { count: 0, totalSize: 0 };
|
||||
}
|
||||
this.inscriptionsData[key].count++;
|
||||
this.inscriptionsData[key].totalSize += inscription.body_length;
|
||||
if (inscription.delegate_txid && !this.inscriptionsData[key].delegate) {
|
||||
this.inscriptionsData[key].delegate = inscription.delegate_txid;
|
||||
}
|
||||
|
||||
// Text / JSON data
|
||||
if ((key.includes('text') || key.includes('json')) && !inscription.is_cropped && !this.inscriptionsData[key].text && !this.inscriptionsData[key].json) {
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
const text = decoder.decode(inscription.body);
|
||||
try {
|
||||
this.inscriptionsData[key].json = JSON.parse(text);
|
||||
if (this.inscriptionsData[key].json['p']) {
|
||||
this.inscriptionsData[key].tag = this.inscriptionsData[key].json['p'].toUpperCase();
|
||||
}
|
||||
} catch (e) {
|
||||
this.inscriptionsData[key].text = text;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getAmount(amount: bigint, divisibility: number): number {
|
||||
const divisor = BigInt(10) ** BigInt(divisibility);
|
||||
const result = amount / divisor;
|
||||
|
||||
return result <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(result) : Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
}
|
||||
@@ -9,66 +9,4 @@
|
||||
<p class="red-color d-inline">{{ error }}</p> <a *ngIf="txId" [routerLink]="['/tx/' | relativeUrl, txId]">{{ txId }}</a>
|
||||
</form>
|
||||
|
||||
@if (network === '' || network === 'testnet' || network === 'testnet4' || network === 'signet') {
|
||||
<br>
|
||||
<h1 class="text-left" style="margin-top: 1rem;" i18n="shared.submit-transactions|Submit Package">Submit Package</h1>
|
||||
|
||||
<form [formGroup]="submitTxsForm" (submit)="submitTxsForm.valid && submitTxs()" novalidate>
|
||||
<div class="mb-3">
|
||||
<textarea formControlName="txs" class="form-control" rows="5" i18n-placeholder="transaction.test-transactions" placeholder="Comma-separated list of raw transactions"></textarea>
|
||||
</div>
|
||||
<label i18n="test.tx.max-fee-rate">Maximum fee rate (sat/vB)</label>
|
||||
<input type="number" class="form-control input-dark" formControlName="maxfeerate" id="maxfeerate"
|
||||
[value]="10000" placeholder="10,000 s/vb" [class]="{invalid: invalidMaxfeerate}">
|
||||
<label i18n="submitpackage.tx.max-burn-amount">Maximum burn amount (sats)</label>
|
||||
<input type="number" class="form-control input-dark" formControlName="maxburnamount" id="maxburnamount"
|
||||
[value]="0" placeholder="0 sat" [class]="{invalid: invalidMaxburnamount}">
|
||||
<br>
|
||||
<button [disabled]="isLoadingPackage" type="submit" class="btn btn-primary mr-2" i18n="shared.submit-transactions|Submit Package">Submit Package</button>
|
||||
<p *ngIf="errorPackage" class="red-color d-inline">{{ errorPackage }}</p>
|
||||
<p *ngIf="packageMessage" class="d-inline">{{ packageMessage }}</p>
|
||||
|
||||
</form>
|
||||
|
||||
<br>
|
||||
|
||||
<div class="box" *ngIf="results?.length">
|
||||
<table class="accept-results table table-fixed table-borderless table-striped">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="allowed" i18n="test-tx.is-allowed">Allowed?</th>
|
||||
<th class="txid" i18n="dashboard.latest-transactions.txid">TXID</th>
|
||||
<th class="rate" i18n="transaction.effective-fee-rate|Effective transaction fee rate">Effective fee rate</th>
|
||||
<th class="reason" i18n="test-tx.rejection-reason">Rejection reason</th>
|
||||
</tr>
|
||||
<ng-container *ngFor="let result of results;">
|
||||
<tr>
|
||||
<td class="allowed">
|
||||
@if (result.error == null) {
|
||||
<span>✅</span>
|
||||
}
|
||||
@else {
|
||||
<span>❌</span>
|
||||
}
|
||||
</td>
|
||||
<td class="txid">
|
||||
@if (!result.error) {
|
||||
<a [routerLink]="['/tx/' | relativeUrl, result.txid]"><app-truncate [text]="result.txid"></app-truncate></a>
|
||||
} @else {
|
||||
<app-truncate [text]="result.txid"></app-truncate>
|
||||
}
|
||||
</td>
|
||||
<td class="rate">
|
||||
<app-fee-rate *ngIf="result.fees?.['effective-feerate'] != null" [fee]="result.fees?.['effective-feerate'] * 100000"></app-fee-rate>
|
||||
<span *ngIf="result.fees?.['effective-feerate'] == null">-</span>
|
||||
</td>
|
||||
<td class="reason">
|
||||
{{ result.error || '-' }}
|
||||
</td>
|
||||
</tr>
|
||||
</ng-container>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -7,7 +7,6 @@ import { OpenGraphService } from '../../services/opengraph.service';
|
||||
import { seoDescriptionNetwork } from '../../shared/common.utils';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
|
||||
import { TxResult } from '../../interfaces/node-api.interface';
|
||||
|
||||
@Component({
|
||||
selector: 'app-push-transaction',
|
||||
@@ -20,16 +19,6 @@ export class PushTransactionComponent implements OnInit {
|
||||
txId: string = '';
|
||||
isLoading = false;
|
||||
|
||||
submitTxsForm: UntypedFormGroup;
|
||||
errorPackage: string = '';
|
||||
packageMessage: string = '';
|
||||
results: TxResult[] = [];
|
||||
invalidMaxfeerate = false;
|
||||
invalidMaxburnamount = false;
|
||||
isLoadingPackage = false;
|
||||
|
||||
network = this.stateService.network;
|
||||
|
||||
constructor(
|
||||
private formBuilder: UntypedFormBuilder,
|
||||
private apiService: ApiService,
|
||||
@@ -46,14 +35,6 @@ export class PushTransactionComponent implements OnInit {
|
||||
txHash: ['', Validators.required],
|
||||
});
|
||||
|
||||
this.submitTxsForm = this.formBuilder.group({
|
||||
txs: ['', Validators.required],
|
||||
maxfeerate: ['', Validators.min(0)],
|
||||
maxburnamount: ['', Validators.min(0)],
|
||||
});
|
||||
|
||||
this.stateService.networkChanged$.subscribe((network) => this.network = network);
|
||||
|
||||
this.seoService.setTitle($localize`:@@meta.title.push-tx:Broadcast Transaction`);
|
||||
this.seoService.setDescription($localize`:@@meta.description.push-tx:Broadcast a transaction to the ${this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet'?'Liquid':'Bitcoin'}${seoDescriptionNetwork(this.stateService.network)} network using the transaction's hash.`);
|
||||
this.ogService.setManualOgImage('tx-push.jpg');
|
||||
@@ -78,7 +59,7 @@ export class PushTransactionComponent implements OnInit {
|
||||
},
|
||||
(error) => {
|
||||
if (typeof error.error === 'string') {
|
||||
const matchText = error.error.replace(/\\/g, '').match('"message":"(.*?)"');
|
||||
const matchText = error.error.match('"message":"(.*?)"');
|
||||
this.error = 'Failed to broadcast transaction, reason: ' + (matchText && matchText[1] || error.error);
|
||||
} else if (error.message) {
|
||||
this.error = 'Failed to broadcast transaction, reason: ' + error.message;
|
||||
@@ -89,67 +70,6 @@ export class PushTransactionComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
submitTxs() {
|
||||
let txs: string[] = [];
|
||||
try {
|
||||
txs = (this.submitTxsForm.get('txs')?.value as string).split(',').map(hex => hex.trim());
|
||||
if (txs?.length === 1) {
|
||||
this.pushTxForm.get('txHash').setValue(txs[0]);
|
||||
this.submitTxsForm.get('txs').setValue('');
|
||||
this.postTx();
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
this.errorPackage = e?.message;
|
||||
return;
|
||||
}
|
||||
|
||||
let maxfeerate;
|
||||
let maxburnamount;
|
||||
this.invalidMaxfeerate = false;
|
||||
this.invalidMaxburnamount = false;
|
||||
try {
|
||||
const maxfeerateVal = this.submitTxsForm.get('maxfeerate')?.value;
|
||||
if (maxfeerateVal != null && maxfeerateVal !== '') {
|
||||
maxfeerate = parseFloat(maxfeerateVal) / 100_000;
|
||||
}
|
||||
} catch (e) {
|
||||
this.invalidMaxfeerate = true;
|
||||
}
|
||||
try {
|
||||
const maxburnamountVal = this.submitTxsForm.get('maxburnamount')?.value;
|
||||
if (maxburnamountVal != null && maxburnamountVal !== '') {
|
||||
maxburnamount = parseInt(maxburnamountVal) / 100_000_000;
|
||||
}
|
||||
} catch (e) {
|
||||
this.invalidMaxburnamount = true;
|
||||
}
|
||||
|
||||
this.isLoadingPackage = true;
|
||||
this.errorPackage = '';
|
||||
this.results = [];
|
||||
this.apiService.submitPackage$(txs, maxfeerate === 0.1 ? null : maxfeerate, maxburnamount === 0 ? null : maxburnamount)
|
||||
.subscribe((result) => {
|
||||
this.isLoadingPackage = false;
|
||||
|
||||
this.packageMessage = result['package_msg'];
|
||||
for (let wtxid in result['tx-results']) {
|
||||
this.results.push(result['tx-results'][wtxid]);
|
||||
}
|
||||
|
||||
this.submitTxsForm.reset();
|
||||
},
|
||||
(error) => {
|
||||
if (typeof error.error?.error === 'string') {
|
||||
const matchText = error.error.error.replace(/\\/g, '').match('"message":"(.*?)"');
|
||||
this.errorPackage = matchText && matchText[1] || error.error.error;
|
||||
} else if (error.message) {
|
||||
this.errorPackage = error.message;
|
||||
}
|
||||
this.isLoadingPackage = false;
|
||||
});
|
||||
}
|
||||
|
||||
private async handleColdcardPushTx(fragmentParams: URLSearchParams): Promise<boolean> {
|
||||
// maybe conforms to Coldcard nfc-pushtx spec
|
||||
if (fragmentParams && fragmentParams.get('t')) {
|
||||
|
||||
@@ -74,7 +74,7 @@ export class TestTransactionsComponent implements OnInit {
|
||||
},
|
||||
(error) => {
|
||||
if (typeof error.error === 'string') {
|
||||
const matchText = error.error.replace(/\\/g, '').match('"message":"(.*?)"');
|
||||
const matchText = error.error.match('"message":"(.*?)"');
|
||||
this.error = matchText && matchText[1] || error.error;
|
||||
} else if (error.message) {
|
||||
this.error = error.message;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, Input, ChangeDetectorRef, OnChanges } from '@angular/core';
|
||||
import { StateService } from '../../services/state.service';
|
||||
import { TimeService } from '../../services/time.service';
|
||||
import { dates } from '../../shared/i18n/dates';
|
||||
import { DatePipe } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-time',
|
||||
@@ -11,9 +12,19 @@ export class TimeComponent implements OnInit, OnChanges, OnDestroy {
|
||||
interval: number;
|
||||
text: string;
|
||||
tooltip: string;
|
||||
precisionThresholds = {
|
||||
year: 100,
|
||||
month: 18,
|
||||
week: 12,
|
||||
day: 31,
|
||||
hour: 48,
|
||||
minute: 90,
|
||||
second: 90
|
||||
};
|
||||
intervals = {};
|
||||
|
||||
@Input() time: number;
|
||||
@Input() dateString: string;
|
||||
@Input() dateString: number;
|
||||
@Input() kind: 'plain' | 'since' | 'until' | 'span' | 'before' | 'within' = 'plain';
|
||||
@Input() fastRender = false;
|
||||
@Input() fixedRender = false;
|
||||
@@ -29,26 +40,37 @@ export class TimeComponent implements OnInit, OnChanges, OnDestroy {
|
||||
constructor(
|
||||
private ref: ChangeDetectorRef,
|
||||
private stateService: StateService,
|
||||
private timeService: TimeService,
|
||||
) {}
|
||||
private datePipe: DatePipe,
|
||||
) {
|
||||
this.intervals = {
|
||||
year: 31536000,
|
||||
month: 2592000,
|
||||
week: 604800,
|
||||
day: 86400,
|
||||
hour: 3600,
|
||||
minute: 60,
|
||||
second: 1
|
||||
};
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.calculateTime();
|
||||
if(this.fixedRender){
|
||||
this.text = this.calculate();
|
||||
return;
|
||||
}
|
||||
if (!this.stateService.isBrowser) {
|
||||
this.text = this.calculate();
|
||||
this.ref.markForCheck();
|
||||
return;
|
||||
}
|
||||
this.interval = window.setInterval(() => {
|
||||
this.calculateTime();
|
||||
this.text = this.calculate();
|
||||
this.ref.markForCheck();
|
||||
}, 1000 * (this.fastRender ? 1 : 60));
|
||||
}
|
||||
|
||||
ngOnChanges() {
|
||||
this.calculateTime();
|
||||
this.text = this.calculate();
|
||||
this.ref.markForCheck();
|
||||
}
|
||||
|
||||
@@ -56,21 +78,224 @@ export class TimeComponent implements OnInit, OnChanges, OnDestroy {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
|
||||
calculateTime(): void {
|
||||
const { text, tooltip } = this.timeService.calculate(
|
||||
this.time,
|
||||
this.kind,
|
||||
this.relative,
|
||||
this.precision,
|
||||
this.minUnit,
|
||||
this.showTooltip,
|
||||
this.units,
|
||||
this.dateString,
|
||||
this.lowercaseStart,
|
||||
this.numUnits,
|
||||
this.fractionDigits,
|
||||
);
|
||||
this.text = text;
|
||||
this.tooltip = tooltip;
|
||||
calculate() {
|
||||
if (this.time == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let seconds: number;
|
||||
switch (this.kind) {
|
||||
case 'since':
|
||||
seconds = Math.floor((+new Date() - +new Date(this.dateString || this.time * 1000)) / 1000);
|
||||
this.tooltip = this.datePipe.transform(new Date(this.dateString || this.time * 1000), 'yyyy-MM-dd HH:mm');
|
||||
break;
|
||||
case 'until':
|
||||
case 'within':
|
||||
seconds = (+new Date(this.time) - +new Date()) / 1000;
|
||||
this.tooltip = this.datePipe.transform(new Date(this.time), 'yyyy-MM-dd HH:mm');
|
||||
break;
|
||||
default:
|
||||
seconds = Math.floor(this.time);
|
||||
this.tooltip = '';
|
||||
}
|
||||
|
||||
if (!this.showTooltip || this.relative) {
|
||||
this.tooltip = '';
|
||||
}
|
||||
|
||||
if (seconds < 1 && this.kind === 'span') {
|
||||
return $localize`:@@date-base.immediately:Immediately`;
|
||||
} else if (seconds < 60) {
|
||||
if (this.relative || this.kind === 'since') {
|
||||
if (this.lowercaseStart) {
|
||||
return $localize`:@@date-base.just-now:Just now`.charAt(0).toLowerCase() + $localize`:@@date-base.just-now:Just now`.slice(1);
|
||||
}
|
||||
return $localize`:@@date-base.just-now:Just now`;
|
||||
} else if (this.kind === 'until' || this.kind === 'within') {
|
||||
seconds = 60;
|
||||
}
|
||||
}
|
||||
|
||||
let counter: number;
|
||||
const result = [];
|
||||
let usedUnits = 0;
|
||||
for (const [index, unit] of this.units.entries()) {
|
||||
let precisionUnit = this.units[Math.min(this.units.length - 1, index + this.precision)];
|
||||
counter = Math.floor(seconds / this.intervals[unit]);
|
||||
const precisionCounter = Math.round(seconds / this.intervals[precisionUnit]);
|
||||
if (precisionCounter > this.precisionThresholds[precisionUnit]) {
|
||||
precisionUnit = unit;
|
||||
}
|
||||
if (this.units.indexOf(precisionUnit) === this.units.indexOf(this.minUnit)) {
|
||||
counter = Math.max(1, counter);
|
||||
}
|
||||
if (counter > 0) {
|
||||
let rounded;
|
||||
const roundFactor = Math.pow(10,this.fractionDigits || 0);
|
||||
if ((this.kind === 'until' || this.kind === 'within') && usedUnits < this.numUnits) {
|
||||
rounded = Math.floor((seconds / this.intervals[precisionUnit]) * roundFactor) / roundFactor;
|
||||
} else {
|
||||
rounded = Math.round((seconds / this.intervals[precisionUnit]) * roundFactor) / roundFactor;
|
||||
}
|
||||
if ((this.kind !== 'until' && this.kind !== 'within')|| this.numUnits === 1) {
|
||||
return this.formatTime(this.kind, precisionUnit, rounded);
|
||||
} else {
|
||||
if (!usedUnits) {
|
||||
result.push(this.formatTime(this.kind, precisionUnit, rounded));
|
||||
} else {
|
||||
result.push(this.formatTime('', precisionUnit, rounded));
|
||||
}
|
||||
seconds -= (rounded * this.intervals[precisionUnit]);
|
||||
usedUnits++;
|
||||
if (usedUnits >= this.numUnits) {
|
||||
return result.join(', ');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.join(', ');
|
||||
}
|
||||
|
||||
private formatTime(kind, unit, number): string {
|
||||
const dateStrings = dates(number);
|
||||
switch (kind) {
|
||||
case 'since':
|
||||
if (number === 1) {
|
||||
switch (unit) { // singular (1 day)
|
||||
case 'year': return $localize`:@@time-since:${dateStrings.i18nYear}:DATE: ago`; break;
|
||||
case 'month': return $localize`:@@time-since:${dateStrings.i18nMonth}:DATE: ago`; break;
|
||||
case 'week': return $localize`:@@time-since:${dateStrings.i18nWeek}:DATE: ago`; break;
|
||||
case 'day': return $localize`:@@time-since:${dateStrings.i18nDay}:DATE: ago`; break;
|
||||
case 'hour': return $localize`:@@time-since:${dateStrings.i18nHour}:DATE: ago`; break;
|
||||
case 'minute': return $localize`:@@time-since:${dateStrings.i18nMinute}:DATE: ago`; break;
|
||||
case 'second': return $localize`:@@time-since:${dateStrings.i18nSecond}:DATE: ago`; break;
|
||||
}
|
||||
} else {
|
||||
switch (unit) { // plural (2 days)
|
||||
case 'year': return $localize`:@@time-since:${dateStrings.i18nYears}:DATE: ago`; break;
|
||||
case 'month': return $localize`:@@time-since:${dateStrings.i18nMonths}:DATE: ago`; break;
|
||||
case 'week': return $localize`:@@time-since:${dateStrings.i18nWeeks}:DATE: ago`; break;
|
||||
case 'day': return $localize`:@@time-since:${dateStrings.i18nDays}:DATE: ago`; break;
|
||||
case 'hour': return $localize`:@@time-since:${dateStrings.i18nHours}:DATE: ago`; break;
|
||||
case 'minute': return $localize`:@@time-since:${dateStrings.i18nMinutes}:DATE: ago`; break;
|
||||
case 'second': return $localize`:@@time-since:${dateStrings.i18nSeconds}:DATE: ago`; break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'until':
|
||||
if (number === 1) {
|
||||
switch (unit) { // singular (In ~1 day)
|
||||
case 'year': return $localize`:@@time-until:In ~${dateStrings.i18nYear}:DATE:`; break;
|
||||
case 'month': return $localize`:@@time-until:In ~${dateStrings.i18nMonth}:DATE:`; break;
|
||||
case 'week': return $localize`:@@time-until:In ~${dateStrings.i18nWeek}:DATE:`; break;
|
||||
case 'day': return $localize`:@@time-until:In ~${dateStrings.i18nDay}:DATE:`; break;
|
||||
case 'hour': return $localize`:@@time-until:In ~${dateStrings.i18nHour}:DATE:`; break;
|
||||
case 'minute': return $localize`:@@time-until:In ~${dateStrings.i18nMinute}:DATE:`;
|
||||
case 'second': return $localize`:@@time-until:In ~${dateStrings.i18nSecond}:DATE:`;
|
||||
}
|
||||
} else {
|
||||
switch (unit) { // plural (In ~2 days)
|
||||
case 'year': return $localize`:@@time-until:In ~${dateStrings.i18nYears}:DATE:`; break;
|
||||
case 'month': return $localize`:@@time-until:In ~${dateStrings.i18nMonths}:DATE:`; break;
|
||||
case 'week': return $localize`:@@time-until:In ~${dateStrings.i18nWeeks}:DATE:`; break;
|
||||
case 'day': return $localize`:@@time-until:In ~${dateStrings.i18nDays}:DATE:`; break;
|
||||
case 'hour': return $localize`:@@time-until:In ~${dateStrings.i18nHours}:DATE:`; break;
|
||||
case 'minute': return $localize`:@@time-until:In ~${dateStrings.i18nMinutes}:DATE:`; break;
|
||||
case 'second': return $localize`:@@time-until:In ~${dateStrings.i18nSeconds}:DATE:`; break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'within':
|
||||
if (number === 1) {
|
||||
switch (unit) { // singular (In ~1 day)
|
||||
case 'year': return $localize`:@@time-within:within ~${dateStrings.i18nYear}:DATE:`; break;
|
||||
case 'month': return $localize`:@@time-within:within ~${dateStrings.i18nMonth}:DATE:`; break;
|
||||
case 'week': return $localize`:@@time-within:within ~${dateStrings.i18nWeek}:DATE:`; break;
|
||||
case 'day': return $localize`:@@time-within:within ~${dateStrings.i18nDay}:DATE:`; break;
|
||||
case 'hour': return $localize`:@@time-within:within ~${dateStrings.i18nHour}:DATE:`; break;
|
||||
case 'minute': return $localize`:@@time-within:within ~${dateStrings.i18nMinute}:DATE:`;
|
||||
case 'second': return $localize`:@@time-within:within ~${dateStrings.i18nSecond}:DATE:`;
|
||||
}
|
||||
} else {
|
||||
switch (unit) { // plural (In ~2 days)
|
||||
case 'year': return $localize`:@@time-within:within ~${dateStrings.i18nYears}:DATE:`; break;
|
||||
case 'month': return $localize`:@@time-within:within ~${dateStrings.i18nMonths}:DATE:`; break;
|
||||
case 'week': return $localize`:@@time-within:within ~${dateStrings.i18nWeeks}:DATE:`; break;
|
||||
case 'day': return $localize`:@@time-within:within ~${dateStrings.i18nDays}:DATE:`; break;
|
||||
case 'hour': return $localize`:@@time-within:within ~${dateStrings.i18nHours}:DATE:`; break;
|
||||
case 'minute': return $localize`:@@time-within:within ~${dateStrings.i18nMinutes}:DATE:`; break;
|
||||
case 'second': return $localize`:@@time-within:within ~${dateStrings.i18nSeconds}:DATE:`; break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'span':
|
||||
if (number === 1) {
|
||||
switch (unit) { // singular (1 day)
|
||||
case 'year': return $localize`:@@time-span:After ${dateStrings.i18nYear}:DATE:`; break;
|
||||
case 'month': return $localize`:@@time-span:After ${dateStrings.i18nMonth}:DATE:`; break;
|
||||
case 'week': return $localize`:@@time-span:After ${dateStrings.i18nWeek}:DATE:`; break;
|
||||
case 'day': return $localize`:@@time-span:After ${dateStrings.i18nDay}:DATE:`; break;
|
||||
case 'hour': return $localize`:@@time-span:After ${dateStrings.i18nHour}:DATE:`; break;
|
||||
case 'minute': return $localize`:@@time-span:After ${dateStrings.i18nMinute}:DATE:`; break;
|
||||
case 'second': return $localize`:@@time-span:After ${dateStrings.i18nSecond}:DATE:`; break;
|
||||
}
|
||||
} else {
|
||||
switch (unit) { // plural (2 days)
|
||||
case 'year': return $localize`:@@time-span:After ${dateStrings.i18nYears}:DATE:`; break;
|
||||
case 'month': return $localize`:@@time-span:After ${dateStrings.i18nMonths}:DATE:`; break;
|
||||
case 'week': return $localize`:@@time-span:After ${dateStrings.i18nWeeks}:DATE:`; break;
|
||||
case 'day': return $localize`:@@time-span:After ${dateStrings.i18nDays}:DATE:`; break;
|
||||
case 'hour': return $localize`:@@time-span:After ${dateStrings.i18nHours}:DATE:`; break;
|
||||
case 'minute': return $localize`:@@time-span:After ${dateStrings.i18nMinutes}:DATE:`; break;
|
||||
case 'second': return $localize`:@@time-span:After ${dateStrings.i18nSeconds}:DATE:`; break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'before':
|
||||
if (number === 1) {
|
||||
switch (unit) { // singular (1 day)
|
||||
case 'year': return $localize`:@@time-before:${dateStrings.i18nYear}:DATE: before`; break;
|
||||
case 'month': return $localize`:@@time-before:${dateStrings.i18nMonth}:DATE: before`; break;
|
||||
case 'week': return $localize`:@@time-before:${dateStrings.i18nWeek}:DATE: before`; break;
|
||||
case 'day': return $localize`:@@time-before:${dateStrings.i18nDay}:DATE: before`; break;
|
||||
case 'hour': return $localize`:@@time-before:${dateStrings.i18nHour}:DATE: before`; break;
|
||||
case 'minute': return $localize`:@@time-before:${dateStrings.i18nMinute}:DATE: before`; break;
|
||||
case 'second': return $localize`:@@time-before:${dateStrings.i18nSecond}:DATE: before`; break;
|
||||
}
|
||||
} else {
|
||||
switch (unit) { // plural (2 days)
|
||||
case 'year': return $localize`:@@time-before:${dateStrings.i18nYears}:DATE: before`; break;
|
||||
case 'month': return $localize`:@@time-before:${dateStrings.i18nMonths}:DATE: before`; break;
|
||||
case 'week': return $localize`:@@time-before:${dateStrings.i18nWeeks}:DATE: before`; break;
|
||||
case 'day': return $localize`:@@time-before:${dateStrings.i18nDays}:DATE: before`; break;
|
||||
case 'hour': return $localize`:@@time-before:${dateStrings.i18nHours}:DATE: before`; break;
|
||||
case 'minute': return $localize`:@@time-before:${dateStrings.i18nMinutes}:DATE: before`; break;
|
||||
case 'second': return $localize`:@@time-before:${dateStrings.i18nSeconds}:DATE: before`; break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (number === 1) {
|
||||
switch (unit) { // singular (1 day)
|
||||
case 'year': return dateStrings.i18nYear; break;
|
||||
case 'month': return dateStrings.i18nMonth; break;
|
||||
case 'week': return dateStrings.i18nWeek; break;
|
||||
case 'day': return dateStrings.i18nDay; break;
|
||||
case 'hour': return dateStrings.i18nHour; break;
|
||||
case 'minute': return dateStrings.i18nMinute; break;
|
||||
case 'second': return dateStrings.i18nSecond; break;
|
||||
}
|
||||
} else {
|
||||
switch (unit) { // plural (2 days)
|
||||
case 'year': return dateStrings.i18nYears; break;
|
||||
case 'month': return dateStrings.i18nMonths; break;
|
||||
case 'week': return dateStrings.i18nWeeks; break;
|
||||
case 'day': return dateStrings.i18nDays; break;
|
||||
case 'hour': return dateStrings.i18nHours; break;
|
||||
case 'minute': return dateStrings.i18nMinutes; break;
|
||||
case 'second': return dateStrings.i18nSeconds; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,12 +164,12 @@
|
||||
<br>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="transactionTime > 0 && tx.acceleratedAt > 0 && isAcceleration">
|
||||
<ng-container *ngIf="transactionTime && isAcceleration">
|
||||
<div class="title float-left">
|
||||
<h2 id="acceleration-timeline" i18n="transaction.acceleration-timeline|Acceleration Timeline">Acceleration Timeline</h2>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
<app-acceleration-timeline [transactionTime]="transactionTime" [acceleratedAt]="tx.acceleratedAt" [tx]="tx" [accelerationInfo]="accelerationInfo" [eta]="(ETA$ | async)"></app-acceleration-timeline>
|
||||
<app-acceleration-timeline [transactionTime]="transactionTime" [tx]="tx" [accelerationInfo]="accelerationInfo" [eta]="(ETA$ | async)" [standardETA]="(standardETA$ | async)?.time"></app-acceleration-timeline>
|
||||
<br>
|
||||
</ng-container>
|
||||
|
||||
@@ -608,7 +608,7 @@
|
||||
<td class="td-width" i18n="transaction.fee|Transaction fee">Fee</td>
|
||||
<td class="text-wrap">{{ tx.fee | number }} <span class="symbol" i18n="shared.sats">sats</span>
|
||||
@if (accelerationInfo?.bidBoost ?? tx.feeDelta > 0) {
|
||||
<span class="oobFees" i18n-ngbTooltip="Acceleration Fees" ngbTooltip="Acceleration fees paid out-of-band"> +{{ accelerationInfo?.bidBoost ?? tx.feeDelta | number }} </span><span class="symbol" i18n="shared.sats">sats</span>
|
||||
<span class="oobFees" [attr.data-cy]="'tx-fee-delta'" i18n-ngbTooltip="Acceleration Fees" ngbTooltip="Acceleration fees paid out-of-band"> +{{ accelerationInfo?.bidBoost ?? tx.feeDelta | number }} </span><span class="symbol" i18n="shared.sats">sats</span>
|
||||
}
|
||||
<span class="fiat"><app-fiat [blockConversion]="tx.price" [value]="tx.fee + ((accelerationInfo?.bidBoost ?? tx.feeDelta) || 0)"></app-fiat></span>
|
||||
</td>
|
||||
@@ -647,9 +647,9 @@
|
||||
<td>
|
||||
<div class="effective-fee-container">
|
||||
@if (accelerationInfo?.acceleratedFeeRate && (!tx.effectiveFeePerVsize || accelerationInfo.acceleratedFeeRate >= tx.effectiveFeePerVsize || tx.acceleration)) {
|
||||
<app-fee-rate [class.oobFees]="isAcceleration" [fee]="accelerationInfo.acceleratedFeeRate"></app-fee-rate>
|
||||
<app-fee-rate [attr.data-cy]="'fee-rate'" [class.oobFees]="isAcceleration" [fee]="accelerationInfo.acceleratedFeeRate"></app-fee-rate>
|
||||
} @else {
|
||||
<app-fee-rate [class.oobFees]="isAcceleration" [fee]="tx.effectiveFeePerVsize"></app-fee-rate>
|
||||
<app-fee-rate [attr.data-cy]="'fee-rate'" [class.oobFees]="isAcceleration" [fee]="tx.effectiveFeePerVsize"></app-fee-rate>
|
||||
}
|
||||
|
||||
@if (tx?.status?.confirmed && !tx.acceleration && !accelerationInfo && tx.fee && tx.effectiveFeePerVsize) {
|
||||
@@ -670,7 +670,7 @@
|
||||
<ng-template #acceleratingRow>
|
||||
<tr>
|
||||
<td rowspan="2" colspan="2" style="padding: 0;">
|
||||
<app-active-acceleration-box [acceleratedBy]="tx.acceleratedBy" [effectiveFeeRate]="tx.effectiveFeePerVsize" [accelerationInfo]="accelerationInfo" [miningStats]="miningStats" [hasCpfp]="hasCpfp" (toggleCpfp)="showCpfpDetails = !showCpfpDetails" [chartPositionLeft]="isMobile"></app-active-acceleration-box>
|
||||
<app-active-acceleration-box [attr.data-cy]="'active-acceleration-box'" [acceleratedBy]="tx.acceleratedBy" [effectiveFeeRate]="tx.effectiveFeePerVsize" [accelerationInfo]="accelerationInfo" [miningStats]="miningStats" [hasCpfp]="hasCpfp" (toggleCpfp)="showCpfpDetails = !showCpfpDetails" [chartPositionLeft]="isMobile"></app-active-acceleration-box>
|
||||
</td>
|
||||
</tr>
|
||||
<tr></tr>
|
||||
@@ -684,15 +684,8 @@
|
||||
@if (pool) {
|
||||
<td class="wrap-cell">
|
||||
<a placement="bottom" [routerLink]="['/mining/pool' | relativeUrl, pool.slug]" class="badge" style="color: #FFF;padding:0;">
|
||||
<span class="miner-name" *ngIf="pool.minerNames?.length > 1 && pool.minerNames[1] != ''">
|
||||
@if (pool.minerNames[1].length > 16) {
|
||||
{{ pool.minerNames[1].slice(0, 15) }}…
|
||||
} @else {
|
||||
{{ pool.minerNames[1] }}
|
||||
}
|
||||
</span>
|
||||
<img class="pool-logo" [src]="'/resources/mining-pools/' + pool.slug + '.svg'" onError="this.src = '/resources/mining-pools/default.svg'" [alt]="'Logo of ' + pool.name + ' mining pool'">
|
||||
{{ pool.name }}
|
||||
{{ pool.name }}
|
||||
</a>
|
||||
</td>
|
||||
} @else {
|
||||
|
||||
@@ -60,19 +60,6 @@
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
.miner-name {
|
||||
margin-right: 4px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.pool-logo {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.badge.badge-accelerated {
|
||||
background-color: var(--tertiary);
|
||||
color: white;
|
||||
|
||||
@@ -42,7 +42,6 @@ interface Pool {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
minerNames: string[] | null;
|
||||
}
|
||||
|
||||
export interface TxAuditStatus {
|
||||
@@ -119,6 +118,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
txChanged$ = new BehaviorSubject<boolean>(false); // triggered whenever this.tx changes (long term, we should refactor to make this.tx an observable itself)
|
||||
isAccelerated$ = new BehaviorSubject<boolean>(false); // refactor this to make isAccelerated an observable itself
|
||||
ETA$: Observable<ETA | null>;
|
||||
standardETA$: Observable<ETA | null>;
|
||||
isCached: boolean = false;
|
||||
now = Date.now();
|
||||
da$: Observable<DifficultyAdjustment>;
|
||||
@@ -882,6 +882,21 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.miningStats = stats;
|
||||
this.isAccelerated$.next(this.isAcceleration); // hack to trigger recalculation of ETA without adding another source observable
|
||||
});
|
||||
if (!this.tx.status?.confirmed) {
|
||||
this.standardETA$ = combineLatest([
|
||||
this.stateService.mempoolBlocks$.pipe(startWith(null)),
|
||||
this.stateService.difficultyAdjustment$.pipe(startWith(null)),
|
||||
]).pipe(
|
||||
map(([mempoolBlocks, da]) => {
|
||||
return this.etaService.calculateUnacceleratedETA(
|
||||
this.tx,
|
||||
mempoolBlocks,
|
||||
da,
|
||||
this.cpfpInfo,
|
||||
);
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
this.isAccelerated$.next(this.isAcceleration);
|
||||
}
|
||||
|
||||
@@ -81,8 +81,7 @@
|
||||
</ng-container>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-right nowrap amount" [class]="{large: vin?.prevout?.value > 1000000000 || vin.isInscription}">
|
||||
<button *ngIf="vin.isInscription" (click)="toggleOrdData(tx.txid, 'vin', vindex)" type="button" class="btn btn-sm badge badge-ord primary" style="margin-right: 10px;">Inscription</button>
|
||||
<td class="text-right nowrap amount" [class]="{large: vin?.prevout?.value > 1000000000}">
|
||||
<ng-template [ngIf]="vin.prevout && vin.prevout.asset && vin.prevout.asset !== nativeAssetId" [ngIfElse]="defaultOutput">
|
||||
<div *ngIf="assetsMinimal && assetsMinimal[vin.prevout.asset] else assetVinNotFound">
|
||||
<ng-container *ngTemplateOutlet="assetBox; context:{ $implicit: vin.prevout }"></ng-container>
|
||||
@@ -97,15 +96,6 @@
|
||||
</ng-template>
|
||||
</td>
|
||||
</tr>
|
||||
<tr *ngIf="showOrdData[tx.txid + '-vin-' + vindex]?.show" [ngClass]="{
|
||||
'assetBox': (assetsMinimal && vin.prevout && assetsMinimal[vin.prevout.asset] && !vin.is_coinbase && vin.prevout.scriptpubkey_address && tx._unblinded) || inputIndex === vindex,
|
||||
'highlight': this.address !== '' && (vin.prevout?.scriptpubkey_address === this.address || (vin.prevout?.scriptpubkey_type === 'p2pk' && vin.prevout?.scriptpubkey.slice(2, -2) === this.address))
|
||||
}">
|
||||
<td></td>
|
||||
<td colspan="2">
|
||||
<app-ord-data [inscriptions]="showOrdData[tx.txid + '-vin-' + vindex]['inscriptions']" [type]="'vin'"></app-ord-data>
|
||||
</td>
|
||||
</tr>
|
||||
<tr *ngIf="(showDetails$ | async) === true">
|
||||
<td colspan="3" class="details-container" >
|
||||
<table class="table table-striped table-fixed table-borderless details-table mb-3">
|
||||
@@ -246,12 +236,7 @@
|
||||
</ng-template>
|
||||
<ng-template #defaultscriptpubkey_type>
|
||||
<ng-template [ngIf]="vout.scriptpubkey_type === 'op_return'" [ngIfElse]="otherPubkeyType">
|
||||
OP_RETURN
|
||||
@if (vout.isRunestone) {
|
||||
<button (click)="toggleOrdData(tx.txid, 'vout', vindex)" type="button" class="btn btn-sm badge badge-ord">Runestone</button>
|
||||
} @else {
|
||||
<a placement="bottom" [ngbTooltip]="vout.scriptpubkey_asm | hex2ascii"><span *ngIf="vout.scriptpubkey_asm !== 'OP_RETURN'" class="badge badge-secondary scriptmessage">{{ vout.scriptpubkey_asm | hex2ascii }}</span></a>
|
||||
}
|
||||
OP_RETURN <a placement="bottom" [ngbTooltip]="vout.scriptpubkey_asm | hex2ascii"><span *ngIf="vout.scriptpubkey_asm !== 'OP_RETURN'" class="badge badge-secondary scriptmessage">{{ vout.scriptpubkey_asm | hex2ascii }}</span></a>
|
||||
</ng-template>
|
||||
<ng-template #otherPubkeyType>{{ vout.scriptpubkey_type | scriptpubkeyType }}</ng-template>
|
||||
</ng-template>
|
||||
@@ -291,15 +276,6 @@
|
||||
</ng-template>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr *ngIf="showOrdData[tx.txid + '-vout-' + vindex]?.show" [ngClass]="{
|
||||
'assetBox': assetsMinimal && assetsMinimal[vout.asset] && vout.scriptpubkey_address && tx.vin && !tx.vin[0].is_coinbase && tx._unblinded || outputIndex === vindex,
|
||||
'highlight': this.address !== '' && (vout.scriptpubkey_address === this.address || (vout.scriptpubkey_type === 'p2pk' && vout.scriptpubkey.slice(2, -2) === this.address))
|
||||
}">
|
||||
<td colspan="3">
|
||||
<app-ord-data [runestone]="showOrdData[tx.txid + '-vout-' + vindex]['runestone']" [runeInfo]="showOrdData[tx.txid + '-vout-' + vindex]['runeInfo']" [type]="'vout'"></app-ord-data>
|
||||
</td>
|
||||
</tr>
|
||||
<tr *ngIf="(showDetails$ | async) === true">
|
||||
<td colspan="3" class=" details-container" >
|
||||
<table class="table table-striped table-borderless details-table mb-3">
|
||||
|
||||
@@ -175,15 +175,4 @@ h2 {
|
||||
.witness-item {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-ord {
|
||||
background-color: var(--grey);
|
||||
position: relative;
|
||||
top: -2px;
|
||||
font-size: 81%;
|
||||
border: 0;
|
||||
&.primary {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,11 @@ import { Outspend, Transaction, Vin, Vout } from '../../interfaces/electrs.inter
|
||||
import { ElectrsApiService } from '../../services/electrs-api.service';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { AssetsService } from '../../services/assets.service';
|
||||
import { filter, map, tap, switchMap, catchError } from 'rxjs/operators';
|
||||
import { filter, map, tap, switchMap, shareReplay, catchError } from 'rxjs/operators';
|
||||
import { BlockExtended } from '../../interfaces/node-api.interface';
|
||||
import { ApiService } from '../../services/api.service';
|
||||
import { PriceService } from '../../services/price.service';
|
||||
import { StorageService } from '../../services/storage.service';
|
||||
import { OrdApiService } from '../../services/ord-api.service';
|
||||
import { Inscription } from '../../shared/ord/inscription.utils';
|
||||
import { Etching, Runestone } from '../../shared/ord/rune.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'app-transactions-list',
|
||||
@@ -53,14 +50,12 @@ export class TransactionsListComponent implements OnInit, OnChanges {
|
||||
outputRowLimit: number = 12;
|
||||
showFullScript: { [vinIndex: number]: boolean } = {};
|
||||
showFullWitness: { [vinIndex: number]: { [witnessIndex: number]: boolean } } = {};
|
||||
showOrdData: { [key: string]: { show: boolean; inscriptions?: Inscription[]; runestone?: Runestone, runeInfo?: { [id: string]: { etching: Etching; txid: string; } }; } } = {};
|
||||
|
||||
constructor(
|
||||
public stateService: StateService,
|
||||
private cacheService: CacheService,
|
||||
private electrsApiService: ElectrsApiService,
|
||||
private apiService: ApiService,
|
||||
private ordApiService: OrdApiService,
|
||||
private assetsService: AssetsService,
|
||||
private ref: ChangeDetectorRef,
|
||||
private priceService: PriceService,
|
||||
@@ -244,24 +239,6 @@ export class TransactionsListComponent implements OnInit, OnChanges {
|
||||
tap((price) => tx['price'] = price),
|
||||
).subscribe();
|
||||
}
|
||||
|
||||
// Check for ord data fingerprints in inputs and outputs
|
||||
if (this.stateService.network !== 'liquid' && this.stateService.network !== 'liquidtestnet') {
|
||||
for (let i = 0; i < tx.vin.length; i++) {
|
||||
if (tx.vin[i].prevout?.scriptpubkey_type === 'v1_p2tr' && tx.vin[i].witness?.length) {
|
||||
const hasAnnex = tx.vin[i].witness?.[tx.vin[i].witness.length - 1].startsWith('50');
|
||||
if (tx.vin[i].witness.length > (hasAnnex ? 2 : 1) && tx.vin[i].witness[tx.vin[i].witness.length - (hasAnnex ? 3 : 2)].includes('0063036f7264')) {
|
||||
tx.vin[i].isInscription = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < tx.vout.length; i++) {
|
||||
if (tx.vout[i]?.scriptpubkey?.startsWith('6a5d')) {
|
||||
tx.vout[i].isRunestone = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (this.blockTime && this.transactions?.length && this.currency) {
|
||||
@@ -395,40 +372,6 @@ export class TransactionsListComponent implements OnInit, OnChanges {
|
||||
this.showFullWitness[vinIndex][witnessIndex] = !this.showFullWitness[vinIndex][witnessIndex];
|
||||
}
|
||||
|
||||
toggleOrdData(txid: string, type: 'vin' | 'vout', index: number) {
|
||||
const tx = this.transactions.find((tx) => tx.txid === txid);
|
||||
if (!tx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = tx.txid + '-' + type + '-' + index;
|
||||
this.showOrdData[key] = this.showOrdData[key] || { show: false };
|
||||
|
||||
if (type === 'vin') {
|
||||
|
||||
if (!this.showOrdData[key].inscriptions) {
|
||||
const hasAnnex = tx.vin[index].witness?.[tx.vin[index].witness.length - 1].startsWith('50');
|
||||
this.showOrdData[key].inscriptions = this.ordApiService.decodeInscriptions(tx.vin[index].witness[tx.vin[index].witness.length - (hasAnnex ? 3 : 2)]);
|
||||
}
|
||||
this.showOrdData[key].show = !this.showOrdData[key].show;
|
||||
|
||||
} else if (type === 'vout') {
|
||||
|
||||
if (!this.showOrdData[key].runestone) {
|
||||
this.ordApiService.decodeRunestone$(tx).pipe(
|
||||
tap((runestone) => {
|
||||
if (runestone) {
|
||||
Object.assign(this.showOrdData[key], runestone);
|
||||
this.ref.markForCheck();
|
||||
}
|
||||
}),
|
||||
).subscribe();
|
||||
}
|
||||
this.showOrdData[key].show = !this.showOrdData[key].show;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.outspendsSubscription.unsubscribe();
|
||||
this.currencyChangeSubscription?.unsubscribe();
|
||||
|
||||
@@ -1,44 +1,12 @@
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, NgZone, OnChanges, OnDestroy, SimpleChanges } from '@angular/core';
|
||||
import { EChartsOption } from '../../graphs/echarts';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { BehaviorSubject, Subscription } from 'rxjs';
|
||||
import { Utxo } from '../../interfaces/electrs.interface';
|
||||
import { StateService } from '../../services/state.service';
|
||||
import { Router } from '@angular/router';
|
||||
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
|
||||
import { renderSats } from '../../shared/common.utils';
|
||||
import { colorToHex, hexToColor, mix } from '../block-overview-graph/utils';
|
||||
import { TimeService } from '../../services/time.service';
|
||||
|
||||
const newColorHex = '1bd8f4';
|
||||
const oldColorHex = '9339f4';
|
||||
const pendingColorHex = 'eba814';
|
||||
const newColor = hexToColor(newColorHex);
|
||||
const oldColor = hexToColor(oldColorHex);
|
||||
|
||||
interface Circle {
|
||||
x: number,
|
||||
y: number,
|
||||
r: number,
|
||||
i: number,
|
||||
}
|
||||
|
||||
interface UtxoCircle extends Circle {
|
||||
utxo: Utxo;
|
||||
}
|
||||
|
||||
function sortedInsert(positions: { c1: Circle, c2: Circle, d: number, p: number, side?: boolean }[], newPosition: { c1: Circle, c2: Circle, d: number, p: number }): void {
|
||||
let left = 0;
|
||||
let right = positions.length;
|
||||
while (left < right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
if (positions[mid].p > newPosition.p) {
|
||||
right = mid;
|
||||
} else {
|
||||
left = mid + 1;
|
||||
}
|
||||
}
|
||||
positions.splice(left, 0, newPosition, {...newPosition, side: true });
|
||||
}
|
||||
@Component({
|
||||
selector: 'app-utxo-graph',
|
||||
templateUrl: './utxo-graph.component.html',
|
||||
@@ -61,8 +29,7 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy {
|
||||
@Input() widget: boolean = false;
|
||||
|
||||
subscription: Subscription;
|
||||
lastUpdate: number = 0;
|
||||
updateInterval;
|
||||
redraw$: BehaviorSubject<boolean> = new BehaviorSubject(false);
|
||||
|
||||
chartOptions: EChartsOption = {};
|
||||
chartInitOptions = {
|
||||
@@ -79,15 +46,7 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy {
|
||||
private zone: NgZone,
|
||||
private router: Router,
|
||||
private relativeUrlPipe: RelativeUrlPipe,
|
||||
private timeService: TimeService,
|
||||
) {
|
||||
// re-render the chart every 10 seconds, to keep the age colors up to date
|
||||
this.updateInterval = setInterval(() => {
|
||||
if (this.lastUpdate < Date.now() - 10000 && this.utxos) {
|
||||
this.prepareChartOptions(this.utxos);
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
) {}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
this.isLoading = true;
|
||||
@@ -99,7 +58,7 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
prepareChartOptions(utxos: Utxo[]): void {
|
||||
prepareChartOptions(utxos: Utxo[]) {
|
||||
if (!utxos || utxos.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -108,110 +67,94 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy {
|
||||
|
||||
// Helper functions
|
||||
const distance = (x1: number, y1: number, x2: number, y2: number): number => Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
|
||||
const intersection = (c1: Circle, c2: Circle, d: number, r: number, side: boolean): { x: number, y: number} => {
|
||||
const d1 = c1.r + r;
|
||||
const d2 = c2.r + r;
|
||||
const a = (d1 * d1 - d2 * d2 + d * d) / (2 * d);
|
||||
const h = Math.sqrt(d1 * d1 - a * a);
|
||||
const x3 = c1.x + a * (c2.x - c1.x) / d;
|
||||
const y3 = c1.y + a * (c2.y - c1.y) / d;
|
||||
return side
|
||||
? { x: x3 + h * (c2.y - c1.y) / d, y: y3 - h * (c2.x - c1.x) / d }
|
||||
: { x: x3 - h * (c2.y - c1.y) / d, y: y3 + h * (c2.x - c1.x) / d };
|
||||
const intersectionPoints = (x1: number, y1: number, r1: number, x2: number, y2: number, r2: number): [number, number][] => {
|
||||
const d = distance(x1, y1, x2, y2);
|
||||
const a = (r1 * r1 - r2 * r2 + d * d) / (2 * d);
|
||||
const h = Math.sqrt(r1 * r1 - a * a);
|
||||
const x3 = x1 + a * (x2 - x1) / d;
|
||||
const y3 = y1 + a * (y2 - y1) / d;
|
||||
return [
|
||||
[x3 + h * (y2 - y1) / d, y3 - h * (x2 - x1) / d],
|
||||
[x3 - h * (y2 - y1) / d, y3 + h * (x2 - x1) / d]
|
||||
];
|
||||
};
|
||||
|
||||
// ~Linear algorithm to pack circles as tightly as possible without overlaps
|
||||
const placedCircles: UtxoCircle[] = [];
|
||||
const positions: { c1: Circle, c2: Circle, d: number, p: number, side?: boolean }[] = [];
|
||||
// Naive algorithm to pack circles as tightly as possible without overlaps
|
||||
const placedCircles: { x: number, y: number, r: number, utxo: Utxo, distances: number[] }[] = [];
|
||||
// Pack in descending order of value, and limit to the top 500 to preserve performance
|
||||
const sortedUtxos = utxos.sort((a, b) => {
|
||||
if (a.value === b.value) {
|
||||
if (a.status.confirmed && !b.status.confirmed) {
|
||||
return -1;
|
||||
} else if (!a.status.confirmed && b.status.confirmed) {
|
||||
return 1;
|
||||
} else {
|
||||
return a.status.block_height - b.status.block_height;
|
||||
}
|
||||
}
|
||||
return b.value - a.value;
|
||||
}).slice(0, 500);
|
||||
const maxR = Math.sqrt(sortedUtxos.reduce((max, utxo) => Math.max(max, utxo.value), 0));
|
||||
const sortedUtxos = utxos.sort((a, b) => b.value - a.value).slice(0, 500);
|
||||
let centerOfMass = { x: 0, y: 0 };
|
||||
let weightOfMass = 0;
|
||||
sortedUtxos.forEach((utxo, index) => {
|
||||
// area proportional to value
|
||||
const r = Math.sqrt(utxo.value);
|
||||
|
||||
// special cases for the first two utxos
|
||||
if (index === 0) {
|
||||
placedCircles.push({ x: 0, y: 0, r, utxo, i: index });
|
||||
placedCircles.push({ x: 0, y: 0, r, utxo, distances: [0] });
|
||||
return;
|
||||
}
|
||||
if (index === 1) {
|
||||
const c = placedCircles[0];
|
||||
placedCircles.push({ x: c.r + r, y: 0, r, utxo, i: index });
|
||||
sortedInsert(positions, { c1: c, c2: placedCircles[1], d: c.r + r, p: 0 });
|
||||
return;
|
||||
}
|
||||
if (index === 2) {
|
||||
const c = placedCircles[0];
|
||||
placedCircles.push({ x: -c.r - r, y: 0, r, utxo, i: index });
|
||||
sortedInsert(positions, { c1: c, c2: placedCircles[2], d: c.r + r, p: 0 });
|
||||
placedCircles.push({ x: c.r + r, y: 0, r, utxo, distances: [c.r + r, 0] });
|
||||
c.distances.push(c.r + r);
|
||||
return;
|
||||
}
|
||||
|
||||
// The best position will be touching two other circles
|
||||
// find the closest such position to the center of the graph
|
||||
// generate a list of candidate points by finding all such positions
|
||||
// where the circle can be placed without overlapping other circles
|
||||
const candidates: [number, number, number[]][] = [];
|
||||
const numCircles = placedCircles.length;
|
||||
let newCircle: UtxoCircle = null;
|
||||
while (positions.length > 0) {
|
||||
const position = positions.shift();
|
||||
// if the circles are too far apart, skip
|
||||
if (position.d > (position.c1.r + position.c2.r + r + r)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { x, y } = intersection(position.c1, position.c2, position.d, r, position.side);
|
||||
if (isNaN(x) || isNaN(y)) {
|
||||
// should never happen
|
||||
continue;
|
||||
}
|
||||
|
||||
// check if the circle would overlap any other circles here
|
||||
let valid = true;
|
||||
const nearbyCircles: { c: UtxoCircle, d: number, s: number }[] = [];
|
||||
for (let k = 0; k < numCircles; k++) {
|
||||
const c = placedCircles[k];
|
||||
if (k === position.c1.i || k === position.c2.i) {
|
||||
nearbyCircles.push({ c, d: c.r + r, s: 0 });
|
||||
for (let i = 0; i < numCircles; i++) {
|
||||
for (let j = i + 1; j < numCircles; j++) {
|
||||
const c1 = placedCircles[i];
|
||||
const c2 = placedCircles[j];
|
||||
if (c1.distances[j] > (c1.r + c2.r + r + r)) {
|
||||
// too far apart for new circle to touch both
|
||||
continue;
|
||||
}
|
||||
const d = distance(x, y, c.x, c.y);
|
||||
if (d < (r + c.r)) {
|
||||
valid = false;
|
||||
break;
|
||||
} else {
|
||||
nearbyCircles.push({ c, d, s: d - c.r - r });
|
||||
}
|
||||
}
|
||||
if (valid) {
|
||||
newCircle = { x, y, r, utxo, i: index };
|
||||
// add new positions to the candidate list
|
||||
const nearest = nearbyCircles.sort((a, b) => a.s - b.s).slice(0, 5);
|
||||
for (const n of nearest) {
|
||||
if (n.d < (n.c.r + r + maxR + maxR)) {
|
||||
sortedInsert(positions, { c1: newCircle, c2: n.c, d: n.d, p: distance((n.c.x + x) / 2, (n.c.y + y), 0, 0) });
|
||||
const points = intersectionPoints(c1.x, c1.y, c1.r + r, c2.x, c2.y, c2.r + r);
|
||||
points.forEach(([x, y]) => {
|
||||
const distances: number[] = [];
|
||||
let valid = true;
|
||||
for (let k = 0; k < numCircles; k++) {
|
||||
const c = placedCircles[k];
|
||||
const d = distance(x, y, c.x, c.y);
|
||||
if (k !== i && k !== j && d < (r + c.r)) {
|
||||
valid = false;
|
||||
break;
|
||||
} else {
|
||||
distances.push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
if (valid) {
|
||||
candidates.push([x, y, distances]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (newCircle) {
|
||||
placedCircles.push(newCircle);
|
||||
} else {
|
||||
// should never happen
|
||||
return;
|
||||
|
||||
// Pick the candidate closest to the center of mass
|
||||
const [x, y, distances] = candidates.length ? candidates.reduce((closest, candidate) =>
|
||||
distance(candidate[0], candidate[1], centerOfMass[0], centerOfMass[1]) <
|
||||
distance(closest[0], closest[1], centerOfMass[0], centerOfMass[1])
|
||||
? candidate
|
||||
: closest
|
||||
) : [0, 0, []];
|
||||
|
||||
placedCircles.push({ x, y, r, utxo, distances });
|
||||
for (let i = 0; i < distances.length; i++) {
|
||||
placedCircles[i].distances.push(distances[i]);
|
||||
}
|
||||
distances.push(0);
|
||||
|
||||
// Update center of mass
|
||||
centerOfMass = {
|
||||
x: (centerOfMass.x * weightOfMass + x) / (weightOfMass + r),
|
||||
y: (centerOfMass.y * weightOfMass + y) / (weightOfMass + r),
|
||||
};
|
||||
weightOfMass += r;
|
||||
});
|
||||
|
||||
// Precompute the bounding box of the graph
|
||||
@@ -222,26 +165,23 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy {
|
||||
const width = maxX - minX;
|
||||
const height = maxY - minY;
|
||||
|
||||
const data = placedCircles.map((circle) => [
|
||||
circle.utxo.txid + circle.utxo.vout,
|
||||
const data = placedCircles.map((circle, index) => [
|
||||
circle.utxo,
|
||||
index,
|
||||
circle.x,
|
||||
circle.y,
|
||||
circle.r,
|
||||
circle.r
|
||||
]);
|
||||
|
||||
this.chartOptions = {
|
||||
series: [{
|
||||
type: 'custom',
|
||||
coordinateSystem: undefined,
|
||||
data: data,
|
||||
encode: {
|
||||
itemName: 0,
|
||||
x: 2,
|
||||
y: 3,
|
||||
r: 4,
|
||||
},
|
||||
data,
|
||||
renderItem: (params, api) => {
|
||||
const idx = params.dataIndex;
|
||||
const datum = data[idx];
|
||||
const utxo = datum[0] as Utxo;
|
||||
const chartWidth = api.getWidth();
|
||||
const chartHeight = api.getHeight();
|
||||
const scale = Math.min(chartWidth / width, chartHeight / height);
|
||||
@@ -249,34 +189,34 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy {
|
||||
const scaledHeight = height * scale;
|
||||
const offsetX = (chartWidth - scaledWidth) / 2 - minX * scale;
|
||||
const offsetY = (chartHeight - scaledHeight) / 2 - minY * scale;
|
||||
|
||||
const datum = data[params.dataIndex];
|
||||
const utxo = datum[1] as Utxo;
|
||||
const x = datum[2] as number;
|
||||
const y = datum[3] as number;
|
||||
const r = datum[4] as number;
|
||||
if (r * scale < 2) {
|
||||
if (r * scale < 3) {
|
||||
// skip items too small to render cleanly
|
||||
return;
|
||||
}
|
||||
|
||||
const valueStr = renderSats(utxo.value, this.stateService.network);
|
||||
const elements: any[] = [
|
||||
{
|
||||
type: 'circle',
|
||||
autoBatch: true,
|
||||
shape: {
|
||||
cx: (x * scale) + offsetX,
|
||||
cy: (y * scale) + offsetY,
|
||||
r: (r * scale) - 1,
|
||||
},
|
||||
style: {
|
||||
fill: '#' + this.getColor(utxo),
|
||||
fill: '#5470c6',
|
||||
}
|
||||
},
|
||||
];
|
||||
const labelFontSize = Math.min(36, r * scale * 0.3);
|
||||
const labelFontSize = Math.min(36, r * scale * 0.25);
|
||||
if (labelFontSize > 8) {
|
||||
elements.push({
|
||||
type: 'text',
|
||||
x: (x * scale) + offsetX,
|
||||
y: (y * scale) + offsetY,
|
||||
style: {
|
||||
text: valueStr,
|
||||
fontSize: labelFontSize,
|
||||
@@ -288,11 +228,9 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy {
|
||||
}
|
||||
return {
|
||||
type: 'group',
|
||||
x: (x * scale) + offsetX,
|
||||
y: (y * scale) + offsetY,
|
||||
children: elements,
|
||||
};
|
||||
},
|
||||
}
|
||||
}],
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(17, 19, 31, 1)',
|
||||
@@ -304,53 +242,27 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy {
|
||||
},
|
||||
borderColor: '#000',
|
||||
formatter: (params: any): string => {
|
||||
const utxo = params.data[1] as Utxo;
|
||||
const utxo = params.data[0] as Utxo;
|
||||
const valueStr = renderSats(utxo.value, this.stateService.network);
|
||||
return `
|
||||
<b style="color: white;">${utxo.txid.slice(0, 6)}...${utxo.txid.slice(-6)}:${utxo.vout}</b>
|
||||
<br>
|
||||
${valueStr}
|
||||
<br>
|
||||
${utxo.status.confirmed ? 'Confirmed ' + this.timeService.calculate(utxo.status.block_time, 'since', true, 1, 'minute').text : 'Pending'}
|
||||
`;
|
||||
${valueStr}`;
|
||||
},
|
||||
}
|
||||
};
|
||||
this.lastUpdate = Date.now();
|
||||
|
||||
this.cd.markForCheck();
|
||||
}
|
||||
|
||||
getColor(utxo: Utxo): string {
|
||||
if (utxo.status.confirmed) {
|
||||
const age = Date.now() / 1000 - utxo.status.block_time;
|
||||
const oneHour = 60 * 60;
|
||||
const fourYears = 4 * 365 * 24 * 60 * 60;
|
||||
|
||||
if (age < oneHour) {
|
||||
return newColorHex;
|
||||
} else if (age >= fourYears) {
|
||||
return oldColorHex;
|
||||
} else {
|
||||
// Logarithmic scale between 1 hour and 4 years
|
||||
const logAge = Math.log(age / oneHour);
|
||||
const logMax = Math.log(fourYears / oneHour);
|
||||
const t = logAge / logMax;
|
||||
return colorToHex(mix(newColor, oldColor, t));
|
||||
}
|
||||
} else {
|
||||
return pendingColorHex;
|
||||
}
|
||||
}
|
||||
|
||||
onChartClick(e): void {
|
||||
if (e.data?.[1]?.txid) {
|
||||
if (e.data?.[0]?.txid) {
|
||||
this.zone.run(() => {
|
||||
const url = this.relativeUrlPipe.transform(`/tx/${e.data[1].txid}`);
|
||||
const url = this.relativeUrlPipe.transform(`/tx/${e.data[0].txid}`);
|
||||
if (e.event.event.shiftKey || e.event.event.ctrlKey || e.event.event.metaKey) {
|
||||
window.open(url + '?mode=details#vout=' + e.data[1].vout);
|
||||
window.open(url + '?mode=details#vout=' + e.data[0].vout);
|
||||
} else {
|
||||
this.router.navigate([url], { fragment: `vout=${e.data[1].vout}` });
|
||||
this.router.navigate([url], { fragment: `vout=${e.data[0].vout}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -365,7 +277,6 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy {
|
||||
if (this.subscription) {
|
||||
this.subscription.unsubscribe();
|
||||
}
|
||||
clearInterval(this.updateInterval);
|
||||
}
|
||||
|
||||
isMobile(): boolean {
|
||||
|
||||
@@ -9163,13 +9163,11 @@ export const restApiDocsData = [
|
||||
Filters can be applied:<ul>
|
||||
<li><code>status</code>: <code>all</code>, <code>requested</code>, <code>accelerating</code>, <code>mined</code>, <code>completed</code>, <code>failed</code></li>
|
||||
<li><code>timeframe</code>: <code>24h</code>, <code>3d</code>, <code>1w</code>, <code>1m</code>, <code>3m</code>, <code>6m</code>, <code>1y</code>, <code>2y</code>, <code>3y</code>, <code>4y</code>, <code>all</code></li>
|
||||
<li><code>minedByPoolUniqueId</code>: any id from <a target="_blank" href="https://github.com/mempool/mining-pools/blob/master/pools-v2.json">pools-v2.json</a>
|
||||
<li><code>poolUniqueId</code>: any id from <a target="_blank" href="https://github.com/mempool/mining-pools/blob/master/pools-v2.json">https://github.com/mempool/mining-pools/blob/master/pools-v2.json</a>. <i>Note: This will return all acceleration requests accepted by the pool but the the listed transactions may have been mined by another pool.</i>
|
||||
<li><code>blockHash</code>: a block hash</a>
|
||||
<li><code>blockHeight</code>: a block height</a>
|
||||
<li><code>page</code>: the requested page number if using pagination <i>(min: 1)</i></a>
|
||||
<li><code>pageLength</code>: the page lenght if using pagination <i>(min: 1, max: 50)</i></a>
|
||||
<li><code>from</code>: unix timestamp (<i>overrides <code>timeframe</code></i>)</a>
|
||||
<li><code>to</code>: unix timestamp (<i>overrides <code>timeframe</code></i>)</a>
|
||||
</ul></p>`
|
||||
},
|
||||
urlString: "/v1/services/accelerator/accelerations/history",
|
||||
@@ -9189,22 +9187,21 @@ export const restApiDocsData = [
|
||||
headers: '',
|
||||
response: `[
|
||||
{
|
||||
"txid": "f829900985aad885c13fb90555d27514b05a338202c7ef5d694f4813ad474487",
|
||||
"status": "completed_provisional",
|
||||
"added": 1728111527,
|
||||
"lastUpdated": 1728112113,
|
||||
"effectiveFee": 1385,
|
||||
"effectiveVsize": 276,
|
||||
"feeDelta": 3000,
|
||||
"blockHash": "00000000000000000000cde89e34036ece454ca2d07ddd7f71ab46307ca87423",
|
||||
"blockHeight": 864248,
|
||||
"bidBoost": 65,
|
||||
"boostVersion": "v2",
|
||||
"txid": "d7e1796d8eb4a09d4e6c174e36cfd852f1e6e6c9f7df4496339933cd32cbdd1d",
|
||||
"status": "completed",
|
||||
"added": 1707421053,
|
||||
"lastUpdated": 1719134667,
|
||||
"effectiveFee": 146,
|
||||
"effectiveVsize": 141,
|
||||
"feeDelta": 14000,
|
||||
"blockHash": "00000000000000000000482f0746d62141694b9210a813b97eb8445780a32003",
|
||||
"blockHeight": 829559,
|
||||
"bidBoost": 3239,
|
||||
"boostVersion": "v1",
|
||||
"pools": [
|
||||
111,
|
||||
115,
|
||||
111
|
||||
],
|
||||
"minedByPoolUniqueId": 115
|
||||
"minedByPoolUniqueId": 111
|
||||
}
|
||||
]`,
|
||||
},
|
||||
|
||||
@@ -74,8 +74,6 @@ export interface Vin {
|
||||
issuance?: Issuance;
|
||||
// Custom
|
||||
lazy?: boolean;
|
||||
// Ord
|
||||
isInscription?: boolean;
|
||||
}
|
||||
|
||||
interface Issuance {
|
||||
@@ -100,8 +98,6 @@ export interface Vout {
|
||||
valuecommitment?: number;
|
||||
asset?: string;
|
||||
pegout?: Pegout;
|
||||
// Ord
|
||||
isRunestone?: boolean;
|
||||
}
|
||||
|
||||
interface Pegout {
|
||||
|
||||
@@ -203,7 +203,6 @@ export interface BlockExtension {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
minerNames: string[] | null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,22 +451,4 @@ export interface TestMempoolAcceptResult {
|
||||
"effective-includes": string[],
|
||||
},
|
||||
['reject-reason']?: string,
|
||||
}
|
||||
|
||||
export interface SubmitPackageResult {
|
||||
package_msg: string;
|
||||
"tx-results": { [wtxid: string]: TxResult };
|
||||
"replaced-transactions"?: string[];
|
||||
}
|
||||
|
||||
export interface TxResult {
|
||||
txid: string;
|
||||
"other-wtxid"?: string;
|
||||
vsize?: number;
|
||||
fees?: {
|
||||
base: number;
|
||||
"effective-feerate"?: number;
|
||||
"effective-includes"?: string[];
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,6 @@ export interface WebsocketResponse {
|
||||
'track-scriptpubkeys'?: string[];
|
||||
'track-asset'?: string;
|
||||
'track-mempool-block'?: number;
|
||||
'track-mempool-blocks'?: number[];
|
||||
'track-rbf'?: string;
|
||||
'track-rbf-summary'?: boolean;
|
||||
'track-accelerations'?: boolean;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
|
||||
import { CpfpInfo, OptimizedMempoolStats, AddressInformation, LiquidPegs, ITranslators, PoolStat, BlockExtended, TransactionStripped, RewardStats, AuditScore, BlockSizesAndWeights,
|
||||
RbfTree, BlockAudit, CurrentPegs, AuditStatus, FederationAddress, FederationUtxo, RecentPeg, PegsVolume, AccelerationInfo, TestMempoolAcceptResult,
|
||||
SubmitPackageResult} from '../interfaces/node-api.interface';
|
||||
RbfTree, BlockAudit, CurrentPegs, AuditStatus, FederationAddress, FederationUtxo, RecentPeg, PegsVolume, AccelerationInfo, TestMempoolAcceptResult } from '../interfaces/node-api.interface';
|
||||
import { BehaviorSubject, Observable, catchError, filter, map, of, shareReplay, take, tap } from 'rxjs';
|
||||
import { StateService } from './state.service';
|
||||
import { Transaction } from '../interfaces/electrs.interface';
|
||||
@@ -245,19 +244,6 @@ export class ApiService {
|
||||
return this.httpClient.post<TestMempoolAcceptResult[]>(this.apiBaseUrl + this.apiBasePath + `/api/txs/test${maxfeerate != null ? '?maxfeerate=' + maxfeerate.toFixed(8) : ''}`, rawTxs);
|
||||
}
|
||||
|
||||
submitPackage$(rawTxs: string[], maxfeerate?: number, maxburnamount?: number): Observable<SubmitPackageResult> {
|
||||
const queryParams = [];
|
||||
|
||||
if (maxfeerate) {
|
||||
queryParams.push(`maxfeerate=${maxfeerate}`);
|
||||
}
|
||||
|
||||
if (maxburnamount) {
|
||||
queryParams.push(`maxburnamount=${maxburnamount}`);
|
||||
}
|
||||
return this.httpClient.post<SubmitPackageResult>(this.apiBaseUrl + this.apiBasePath + '/api/v1/txs/package' + (queryParams.length > 0 ? `?${queryParams.join('&')}` : ''), rawTxs);
|
||||
}
|
||||
|
||||
getTransactionStatus$(txid: string): Observable<any> {
|
||||
return this.httpClient.get<any>(this.apiBaseUrl + this.apiBasePath + '/api/tx/' + txid + '/status');
|
||||
}
|
||||
|
||||
@@ -107,10 +107,6 @@ export class ElectrsApiService {
|
||||
return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/block-height/' + height, {responseType: 'text'});
|
||||
}
|
||||
|
||||
getBlockTxId$(hash: string, index: number): Observable<string> {
|
||||
return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/block/' + hash + '/txid/' + index, { responseType: 'text' });
|
||||
}
|
||||
|
||||
getAddress$(address: string): Observable<Address> {
|
||||
return this.httpClient.get<Address>(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address);
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { catchError, forkJoin, map, Observable, of, switchMap, tap } from 'rxjs';
|
||||
import { Inscription } from '../shared/ord/inscription.utils';
|
||||
import { Transaction } from '../interfaces/electrs.interface';
|
||||
import { getNextInscriptionMark, hexToBytes, extractInscriptionData } from '../shared/ord/inscription.utils';
|
||||
import { decipherRunestone, Runestone, Etching, UNCOMMON_GOODS } from '../shared/ord/rune.utils';
|
||||
import { ElectrsApiService } from './electrs-api.service';
|
||||
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class OrdApiService {
|
||||
|
||||
constructor(
|
||||
private electrsApiService: ElectrsApiService,
|
||||
) { }
|
||||
|
||||
decodeRunestone$(tx: Transaction): Observable<{ runestone: Runestone, runeInfo: { [id: string]: { etching: Etching; txid: string; } } }> {
|
||||
const runestone = decipherRunestone(tx);
|
||||
const runeInfo: { [id: string]: { etching: Etching; txid: string; } } = {};
|
||||
|
||||
if (runestone) {
|
||||
const runesToFetch: Set<string> = new Set();
|
||||
|
||||
if (runestone.mint) {
|
||||
runesToFetch.add(runestone.mint.toString());
|
||||
}
|
||||
|
||||
if (runestone.edicts.length) {
|
||||
runestone.edicts.forEach(edict => {
|
||||
runesToFetch.add(edict.id.toString());
|
||||
});
|
||||
}
|
||||
|
||||
if (runesToFetch.size) {
|
||||
const runeEtchingObservables = Array.from(runesToFetch).map(runeId => this.getEtchingFromRuneId$(runeId));
|
||||
|
||||
return forkJoin(runeEtchingObservables).pipe(
|
||||
map((etchings) => {
|
||||
etchings.forEach((el) => {
|
||||
if (el) {
|
||||
runeInfo[el.runeId] = { etching: el.etching, txid: el.txid };
|
||||
}
|
||||
});
|
||||
return { runestone: runestone, runeInfo };
|
||||
})
|
||||
);
|
||||
}
|
||||
return of({ runestone: runestone, runeInfo });
|
||||
} else {
|
||||
return of({ runestone: null, runeInfo: {} });
|
||||
}
|
||||
}
|
||||
|
||||
// Get etching from runeId by looking up the transaction that etched the rune
|
||||
getEtchingFromRuneId$(runeId: string): Observable<{ runeId: string; etching: Etching; txid: string; }> {
|
||||
if (runeId === '1:0') {
|
||||
return of({ runeId, etching: UNCOMMON_GOODS, txid: '0000000000000000000000000000000000000000000000000000000000000000' });
|
||||
} else {
|
||||
const [blockNumber, txIndex] = runeId.split(':');
|
||||
return this.electrsApiService.getBlockHashFromHeight$(parseInt(blockNumber)).pipe(
|
||||
switchMap(blockHash => this.electrsApiService.getBlockTxId$(blockHash, parseInt(txIndex))),
|
||||
switchMap(txId => this.electrsApiService.getTransaction$(txId)),
|
||||
switchMap(tx => {
|
||||
const runestone = decipherRunestone(tx);
|
||||
if (runestone) {
|
||||
const etching = runestone.etching;
|
||||
if (etching) {
|
||||
return of({ runeId, etching, txid: tx.txid });
|
||||
}
|
||||
}
|
||||
return of(null);
|
||||
}),
|
||||
catchError(() => of(null))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
decodeInscriptions(witness: string): Inscription[] | null {
|
||||
|
||||
const inscriptions: Inscription[] = [];
|
||||
const raw = hexToBytes(witness);
|
||||
let startPosition = 0;
|
||||
|
||||
while (true) {
|
||||
const pointer = getNextInscriptionMark(raw, startPosition);
|
||||
if (pointer === -1) break;
|
||||
|
||||
const inscription = extractInscriptionData(raw, pointer);
|
||||
if (inscription) {
|
||||
inscriptions.push(inscription);
|
||||
}
|
||||
|
||||
startPosition = pointer;
|
||||
}
|
||||
|
||||
return inscriptions;
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,13 @@ import { IBackendInfo } from '../interfaces/websocket.interface';
|
||||
import { Acceleration, AccelerationHistoryParams } from '../interfaces/node-api.interface';
|
||||
import { AccelerationStats } from '../components/acceleration/acceleration-stats/acceleration-stats.component';
|
||||
|
||||
export type ProductType = 'enterprise' | 'community' | 'mining_pool' | 'custom';
|
||||
export interface IUser {
|
||||
username: string;
|
||||
email: string | null;
|
||||
passwordIsSet: boolean;
|
||||
snsId: string;
|
||||
type: 'enterprise' | 'community' | 'mining_pool';
|
||||
type: ProductType;
|
||||
subscription_tag: string;
|
||||
status: 'pending' | 'verified' | 'disabled';
|
||||
features: string | null;
|
||||
@@ -135,16 +136,16 @@ export class ServicesApiServices {
|
||||
return this.httpClient.post<any>(`${this.stateService.env.SERVICES_API}/accelerator/accelerate`, { txInput: txInput, userBid: userBid, accelerationUUID: accelerationUUID });
|
||||
}
|
||||
|
||||
accelerateWithCashApp$(txInput: string, token: string, cashtag: string, referenceId: string, accelerationUUID: string, userApprovedUSD: number) {
|
||||
return this.httpClient.post<any>(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/cashapp`, { txInput: txInput, token: token, cashtag: cashtag, referenceId: referenceId, accelerationUUID: accelerationUUID, userApprovedUSD: userApprovedUSD });
|
||||
accelerateWithCashApp$(txInput: string, token: string, cashtag: string, referenceId: string, accelerationUUID: string) {
|
||||
return this.httpClient.post<any>(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/cashapp`, { txInput: txInput, token: token, cashtag: cashtag, referenceId: referenceId, accelerationUUID: accelerationUUID });
|
||||
}
|
||||
|
||||
accelerateWithApplePay$(txInput: string, token: string, cardTag: string, referenceId: string, accelerationUUID: string, userApprovedUSD: number) {
|
||||
return this.httpClient.post<any>(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/applePay`, { txInput: txInput, cardTag: cardTag, token: token, referenceId: referenceId, accelerationUUID: accelerationUUID, userApprovedUSD: userApprovedUSD });
|
||||
accelerateWithApplePay$(txInput: string, token: string, cardTag: string, referenceId: string, accelerationUUID: string) {
|
||||
return this.httpClient.post<any>(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/applePay`, { txInput: txInput, cardTag: cardTag, token: token, referenceId: referenceId, accelerationUUID: accelerationUUID });
|
||||
}
|
||||
|
||||
accelerateWithGooglePay$(txInput: string, token: string, cardTag: string, referenceId: string, accelerationUUID: string, userApprovedUSD: number) {
|
||||
return this.httpClient.post<any>(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/googlePay`, { txInput: txInput, cardTag: cardTag, token: token, referenceId: referenceId, accelerationUUID: accelerationUUID, userApprovedUSD: userApprovedUSD });
|
||||
accelerateWithGooglePay$(txInput: string, token: string, cardTag: string, referenceId: string, accelerationUUID: string) {
|
||||
return this.httpClient.post<any>(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/googlePay`, { txInput: txInput, cardTag: cardTag, token: token, referenceId: referenceId, accelerationUUID: accelerationUUID });
|
||||
}
|
||||
|
||||
getAccelerations$(): Observable<Acceleration[]> {
|
||||
@@ -164,7 +165,7 @@ export class ServicesApiServices {
|
||||
return this.getAccelerationHistoryObserveResponse$({...params, page}).pipe(
|
||||
map((response) => ({
|
||||
page,
|
||||
total: parseInt(response.headers.get('X-Total-Count'), 10) || 0,
|
||||
total: parseInt(response.headers.get('X-Total-Count'), 10),
|
||||
accelerations: accelerations.concat(response.body || []),
|
||||
})),
|
||||
switchMap(({page, total, accelerations}) => {
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { dates } from '../shared/i18n/dates';
|
||||
|
||||
const intervals = {
|
||||
year: 31536000,
|
||||
month: 2592000,
|
||||
week: 604800,
|
||||
day: 86400,
|
||||
hour: 3600,
|
||||
minute: 60,
|
||||
second: 1
|
||||
};
|
||||
|
||||
const precisionThresholds = {
|
||||
year: 100,
|
||||
month: 18,
|
||||
week: 12,
|
||||
day: 31,
|
||||
hour: 48,
|
||||
minute: 90,
|
||||
second: 90
|
||||
};
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class TimeService {
|
||||
|
||||
constructor(private datePipe: DatePipe) {}
|
||||
|
||||
calculate(
|
||||
time: number,
|
||||
kind: 'plain' | 'since' | 'until' | 'span' | 'before' | 'within',
|
||||
relative: boolean = false,
|
||||
precision: number = 0,
|
||||
minUnit: 'year' | 'month' | 'week' | 'day' | 'hour' | 'minute' | 'second' = 'second',
|
||||
showTooltip: boolean = false,
|
||||
units: string[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second'],
|
||||
dateString?: string,
|
||||
lowercaseStart: boolean = false,
|
||||
numUnits: number = 1,
|
||||
fractionDigits: number = 0,
|
||||
): { text: string, tooltip: string } {
|
||||
if (time == null) {
|
||||
return { text: '', tooltip: '' };
|
||||
}
|
||||
|
||||
let seconds: number;
|
||||
let tooltip: string = '';
|
||||
switch (kind) {
|
||||
case 'since':
|
||||
seconds = Math.floor((+new Date() - +new Date(dateString || time * 1000)) / 1000);
|
||||
tooltip = this.datePipe.transform(new Date(dateString || time * 1000), 'yyyy-MM-dd HH:mm') || '';
|
||||
break;
|
||||
case 'until':
|
||||
case 'within':
|
||||
seconds = (+new Date(time) - +new Date()) / 1000;
|
||||
tooltip = this.datePipe.transform(new Date(time), 'yyyy-MM-dd HH:mm') || '';
|
||||
break;
|
||||
default:
|
||||
seconds = Math.floor(time);
|
||||
tooltip = '';
|
||||
}
|
||||
|
||||
if (!showTooltip || relative) {
|
||||
tooltip = '';
|
||||
}
|
||||
|
||||
if (seconds < 1 && kind === 'span') {
|
||||
return { tooltip, text: $localize`:@@date-base.immediately:Immediately` };
|
||||
} else if (seconds < 60) {
|
||||
if (relative || kind === 'since') {
|
||||
if (lowercaseStart) {
|
||||
return { tooltip, text: $localize`:@@date-base.just-now:Just now`.charAt(0).toLowerCase() + $localize`:@@date-base.just-now:Just now`.slice(1) };
|
||||
}
|
||||
return { tooltip, text: $localize`:@@date-base.just-now:Just now` };
|
||||
} else if (kind === 'until' || kind === 'within') {
|
||||
seconds = 60;
|
||||
}
|
||||
}
|
||||
|
||||
let counter: number;
|
||||
const result: string[] = [];
|
||||
let usedUnits = 0;
|
||||
for (const [index, unit] of units.entries()) {
|
||||
let precisionUnit = units[Math.min(units.length - 1, index + precision)];
|
||||
counter = Math.floor(seconds / intervals[unit]);
|
||||
const precisionCounter = Math.round(seconds / intervals[precisionUnit]);
|
||||
if (precisionCounter > precisionThresholds[precisionUnit]) {
|
||||
precisionUnit = unit;
|
||||
}
|
||||
if (units.indexOf(precisionUnit) === units.indexOf(minUnit)) {
|
||||
counter = Math.max(1, counter);
|
||||
}
|
||||
if (counter > 0) {
|
||||
let rounded;
|
||||
const roundFactor = Math.pow(10,fractionDigits || 0);
|
||||
if ((kind === 'until' || kind === 'within') && usedUnits < numUnits) {
|
||||
rounded = Math.floor((seconds / intervals[precisionUnit]) * roundFactor) / roundFactor;
|
||||
} else {
|
||||
rounded = Math.round((seconds / intervals[precisionUnit]) * roundFactor) / roundFactor;
|
||||
}
|
||||
if ((kind !== 'until' && kind !== 'within')|| numUnits === 1) {
|
||||
return { tooltip, text: this.formatTime(kind, precisionUnit, rounded) };
|
||||
} else {
|
||||
if (!usedUnits) {
|
||||
result.push(this.formatTime(kind, precisionUnit, rounded));
|
||||
} else {
|
||||
result.push(this.formatTime('', precisionUnit, rounded));
|
||||
}
|
||||
seconds -= (rounded * intervals[precisionUnit]);
|
||||
usedUnits++;
|
||||
if (usedUnits >= numUnits) {
|
||||
return { tooltip, text: result.join(', ') };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { tooltip, text: result.join(', ') };
|
||||
}
|
||||
|
||||
private formatTime(kind, unit, number): string {
|
||||
const dateStrings = dates(number);
|
||||
switch (kind) {
|
||||
case 'since':
|
||||
if (number === 1) {
|
||||
switch (unit) { // singular (1 day)
|
||||
case 'year': return $localize`:@@time-since:${dateStrings.i18nYear}:DATE: ago`; break;
|
||||
case 'month': return $localize`:@@time-since:${dateStrings.i18nMonth}:DATE: ago`; break;
|
||||
case 'week': return $localize`:@@time-since:${dateStrings.i18nWeek}:DATE: ago`; break;
|
||||
case 'day': return $localize`:@@time-since:${dateStrings.i18nDay}:DATE: ago`; break;
|
||||
case 'hour': return $localize`:@@time-since:${dateStrings.i18nHour}:DATE: ago`; break;
|
||||
case 'minute': return $localize`:@@time-since:${dateStrings.i18nMinute}:DATE: ago`; break;
|
||||
case 'second': return $localize`:@@time-since:${dateStrings.i18nSecond}:DATE: ago`; break;
|
||||
}
|
||||
} else {
|
||||
switch (unit) { // plural (2 days)
|
||||
case 'year': return $localize`:@@time-since:${dateStrings.i18nYears}:DATE: ago`; break;
|
||||
case 'month': return $localize`:@@time-since:${dateStrings.i18nMonths}:DATE: ago`; break;
|
||||
case 'week': return $localize`:@@time-since:${dateStrings.i18nWeeks}:DATE: ago`; break;
|
||||
case 'day': return $localize`:@@time-since:${dateStrings.i18nDays}:DATE: ago`; break;
|
||||
case 'hour': return $localize`:@@time-since:${dateStrings.i18nHours}:DATE: ago`; break;
|
||||
case 'minute': return $localize`:@@time-since:${dateStrings.i18nMinutes}:DATE: ago`; break;
|
||||
case 'second': return $localize`:@@time-since:${dateStrings.i18nSeconds}:DATE: ago`; break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'until':
|
||||
if (number === 1) {
|
||||
switch (unit) { // singular (In ~1 day)
|
||||
case 'year': return $localize`:@@time-until:In ~${dateStrings.i18nYear}:DATE:`; break;
|
||||
case 'month': return $localize`:@@time-until:In ~${dateStrings.i18nMonth}:DATE:`; break;
|
||||
case 'week': return $localize`:@@time-until:In ~${dateStrings.i18nWeek}:DATE:`; break;
|
||||
case 'day': return $localize`:@@time-until:In ~${dateStrings.i18nDay}:DATE:`; break;
|
||||
case 'hour': return $localize`:@@time-until:In ~${dateStrings.i18nHour}:DATE:`; break;
|
||||
case 'minute': return $localize`:@@time-until:In ~${dateStrings.i18nMinute}:DATE:`;
|
||||
case 'second': return $localize`:@@time-until:In ~${dateStrings.i18nSecond}:DATE:`;
|
||||
}
|
||||
} else {
|
||||
switch (unit) { // plural (In ~2 days)
|
||||
case 'year': return $localize`:@@time-until:In ~${dateStrings.i18nYears}:DATE:`; break;
|
||||
case 'month': return $localize`:@@time-until:In ~${dateStrings.i18nMonths}:DATE:`; break;
|
||||
case 'week': return $localize`:@@time-until:In ~${dateStrings.i18nWeeks}:DATE:`; break;
|
||||
case 'day': return $localize`:@@time-until:In ~${dateStrings.i18nDays}:DATE:`; break;
|
||||
case 'hour': return $localize`:@@time-until:In ~${dateStrings.i18nHours}:DATE:`; break;
|
||||
case 'minute': return $localize`:@@time-until:In ~${dateStrings.i18nMinutes}:DATE:`; break;
|
||||
case 'second': return $localize`:@@time-until:In ~${dateStrings.i18nSeconds}:DATE:`; break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'within':
|
||||
if (number === 1) {
|
||||
switch (unit) { // singular (In ~1 day)
|
||||
case 'year': return $localize`:@@time-within:within ~${dateStrings.i18nYear}:DATE:`; break;
|
||||
case 'month': return $localize`:@@time-within:within ~${dateStrings.i18nMonth}:DATE:`; break;
|
||||
case 'week': return $localize`:@@time-within:within ~${dateStrings.i18nWeek}:DATE:`; break;
|
||||
case 'day': return $localize`:@@time-within:within ~${dateStrings.i18nDay}:DATE:`; break;
|
||||
case 'hour': return $localize`:@@time-within:within ~${dateStrings.i18nHour}:DATE:`; break;
|
||||
case 'minute': return $localize`:@@time-within:within ~${dateStrings.i18nMinute}:DATE:`;
|
||||
case 'second': return $localize`:@@time-within:within ~${dateStrings.i18nSecond}:DATE:`;
|
||||
}
|
||||
} else {
|
||||
switch (unit) { // plural (In ~2 days)
|
||||
case 'year': return $localize`:@@time-within:within ~${dateStrings.i18nYears}:DATE:`; break;
|
||||
case 'month': return $localize`:@@time-within:within ~${dateStrings.i18nMonths}:DATE:`; break;
|
||||
case 'week': return $localize`:@@time-within:within ~${dateStrings.i18nWeeks}:DATE:`; break;
|
||||
case 'day': return $localize`:@@time-within:within ~${dateStrings.i18nDays}:DATE:`; break;
|
||||
case 'hour': return $localize`:@@time-within:within ~${dateStrings.i18nHours}:DATE:`; break;
|
||||
case 'minute': return $localize`:@@time-within:within ~${dateStrings.i18nMinutes}:DATE:`; break;
|
||||
case 'second': return $localize`:@@time-within:within ~${dateStrings.i18nSeconds}:DATE:`; break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'span':
|
||||
if (number === 1) {
|
||||
switch (unit) { // singular (1 day)
|
||||
case 'year': return $localize`:@@time-span:After ${dateStrings.i18nYear}:DATE:`; break;
|
||||
case 'month': return $localize`:@@time-span:After ${dateStrings.i18nMonth}:DATE:`; break;
|
||||
case 'week': return $localize`:@@time-span:After ${dateStrings.i18nWeek}:DATE:`; break;
|
||||
case 'day': return $localize`:@@time-span:After ${dateStrings.i18nDay}:DATE:`; break;
|
||||
case 'hour': return $localize`:@@time-span:After ${dateStrings.i18nHour}:DATE:`; break;
|
||||
case 'minute': return $localize`:@@time-span:After ${dateStrings.i18nMinute}:DATE:`; break;
|
||||
case 'second': return $localize`:@@time-span:After ${dateStrings.i18nSecond}:DATE:`; break;
|
||||
}
|
||||
} else {
|
||||
switch (unit) { // plural (2 days)
|
||||
case 'year': return $localize`:@@time-span:After ${dateStrings.i18nYears}:DATE:`; break;
|
||||
case 'month': return $localize`:@@time-span:After ${dateStrings.i18nMonths}:DATE:`; break;
|
||||
case 'week': return $localize`:@@time-span:After ${dateStrings.i18nWeeks}:DATE:`; break;
|
||||
case 'day': return $localize`:@@time-span:After ${dateStrings.i18nDays}:DATE:`; break;
|
||||
case 'hour': return $localize`:@@time-span:After ${dateStrings.i18nHours}:DATE:`; break;
|
||||
case 'minute': return $localize`:@@time-span:After ${dateStrings.i18nMinutes}:DATE:`; break;
|
||||
case 'second': return $localize`:@@time-span:After ${dateStrings.i18nSeconds}:DATE:`; break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'before':
|
||||
if (number === 1) {
|
||||
switch (unit) { // singular (1 day)
|
||||
case 'year': return $localize`:@@time-before:${dateStrings.i18nYear}:DATE: before`; break;
|
||||
case 'month': return $localize`:@@time-before:${dateStrings.i18nMonth}:DATE: before`; break;
|
||||
case 'week': return $localize`:@@time-before:${dateStrings.i18nWeek}:DATE: before`; break;
|
||||
case 'day': return $localize`:@@time-before:${dateStrings.i18nDay}:DATE: before`; break;
|
||||
case 'hour': return $localize`:@@time-before:${dateStrings.i18nHour}:DATE: before`; break;
|
||||
case 'minute': return $localize`:@@time-before:${dateStrings.i18nMinute}:DATE: before`; break;
|
||||
case 'second': return $localize`:@@time-before:${dateStrings.i18nSecond}:DATE: before`; break;
|
||||
}
|
||||
} else {
|
||||
switch (unit) { // plural (2 days)
|
||||
case 'year': return $localize`:@@time-before:${dateStrings.i18nYears}:DATE: before`; break;
|
||||
case 'month': return $localize`:@@time-before:${dateStrings.i18nMonths}:DATE: before`; break;
|
||||
case 'week': return $localize`:@@time-before:${dateStrings.i18nWeeks}:DATE: before`; break;
|
||||
case 'day': return $localize`:@@time-before:${dateStrings.i18nDays}:DATE: before`; break;
|
||||
case 'hour': return $localize`:@@time-before:${dateStrings.i18nHours}:DATE: before`; break;
|
||||
case 'minute': return $localize`:@@time-before:${dateStrings.i18nMinutes}:DATE: before`; break;
|
||||
case 'second': return $localize`:@@time-before:${dateStrings.i18nSeconds}:DATE: before`; break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (number === 1) {
|
||||
switch (unit) { // singular (1 day)
|
||||
case 'year': return dateStrings.i18nYear; break;
|
||||
case 'month': return dateStrings.i18nMonth; break;
|
||||
case 'week': return dateStrings.i18nWeek; break;
|
||||
case 'day': return dateStrings.i18nDay; break;
|
||||
case 'hour': return dateStrings.i18nHour; break;
|
||||
case 'minute': return dateStrings.i18nMinute; break;
|
||||
case 'second': return dateStrings.i18nSecond; break;
|
||||
}
|
||||
} else {
|
||||
switch (unit) { // plural (2 days)
|
||||
case 'year': return dateStrings.i18nYears; break;
|
||||
case 'month': return dateStrings.i18nMonths; break;
|
||||
case 'week': return dateStrings.i18nWeeks; break;
|
||||
case 'day': return dateStrings.i18nDays; break;
|
||||
case 'hour': return dateStrings.i18nHours; break;
|
||||
case 'minute': return dateStrings.i18nMinutes; break;
|
||||
case 'second': return dateStrings.i18nSeconds; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -29,14 +29,12 @@ export class WebsocketService {
|
||||
private isTrackingTx = false;
|
||||
private trackingTxId: string;
|
||||
private isTrackingMempoolBlock = false;
|
||||
private isTrackingMempoolBlocks = false;
|
||||
private isTrackingRbf: 'all' | 'fullRbf' | false = false;
|
||||
private isTrackingRbfSummary = false;
|
||||
private isTrackingAddress: string | false = false;
|
||||
private isTrackingAddresses: string[] | false = false;
|
||||
private isTrackingAccelerations: boolean = false;
|
||||
private trackingMempoolBlock: number;
|
||||
private trackingMempoolBlocks: number[];
|
||||
private stoppingTrackMempoolBlock: any | null = null;
|
||||
private latestGitCommit = '';
|
||||
private onlineCheckTimeout: number;
|
||||
@@ -124,9 +122,6 @@ export class WebsocketService {
|
||||
if (this.isTrackingMempoolBlock) {
|
||||
this.startTrackMempoolBlock(this.trackingMempoolBlock, true);
|
||||
}
|
||||
if (this.isTrackingMempoolBlocks) {
|
||||
this.startTrackMempoolBlocks(this.trackingMempoolBlocks);
|
||||
}
|
||||
if (this.isTrackingRbf) {
|
||||
this.startTrackRbf(this.isTrackingRbf);
|
||||
}
|
||||
@@ -223,13 +218,6 @@ export class WebsocketService {
|
||||
return false;
|
||||
}
|
||||
|
||||
startTrackMempoolBlocks(blocks: number[], force: boolean = false): boolean {
|
||||
this.websocketSubject.next({ 'track-mempool-blocks': blocks });
|
||||
this.isTrackingMempoolBlocks = true;
|
||||
this.trackingMempoolBlocks = blocks;
|
||||
return true;
|
||||
}
|
||||
|
||||
stopTrackMempoolBlock(): void {
|
||||
if (this.stoppingTrackMempoolBlock) {
|
||||
clearTimeout(this.stoppingTrackMempoolBlock);
|
||||
@@ -243,11 +231,6 @@ export class WebsocketService {
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
stopTrackMempoolBlocks(): void {
|
||||
this.websocketSubject.next({ 'track-mempool-blocks': [] });
|
||||
this.isTrackingMempoolBlocks = false;
|
||||
}
|
||||
|
||||
startTrackRbf(mode: 'all' | 'fullRbf') {
|
||||
this.websocketSubject.next({ 'track-rbf': mode });
|
||||
this.isTrackingRbf = mode;
|
||||
@@ -450,25 +433,20 @@ export class WebsocketService {
|
||||
}
|
||||
|
||||
if (response['projected-block-transactions']) {
|
||||
if (response['projected-block-transactions'].index != null) {
|
||||
const update = response['projected-block-transactions'];
|
||||
if (update.blockTransactions) {
|
||||
if (response['projected-block-transactions'].index == this.trackingMempoolBlock) {
|
||||
if (response['projected-block-transactions'].blockTransactions) {
|
||||
this.stateService.mempoolSequence = response['projected-block-transactions'].sequence;
|
||||
this.stateService.mempoolBlockUpdate$.next({
|
||||
block: update.index,
|
||||
transactions: update.blockTransactions.map(uncompressTx),
|
||||
block: this.trackingMempoolBlock,
|
||||
transactions: response['projected-block-transactions'].blockTransactions.map(uncompressTx),
|
||||
});
|
||||
} else if (update.delta) {
|
||||
this.stateService.mempoolBlockUpdate$.next(uncompressDeltaChange(update.index, update.delta));
|
||||
}
|
||||
} else if (response['projected-block-transactions'].length) {
|
||||
for (const update of response['projected-block-transactions']) {
|
||||
if (update.blockTransactions) {
|
||||
this.stateService.mempoolBlockUpdate$.next({
|
||||
block: update.index,
|
||||
transactions: update.blockTransactions.map(uncompressTx),
|
||||
});
|
||||
} else if (update.delta) {
|
||||
this.stateService.mempoolBlockUpdate$.next(uncompressDeltaChange(update.index, update.delta));
|
||||
} else if (response['projected-block-transactions'].delta) {
|
||||
if (this.stateService.mempoolSequence && response['projected-block-transactions'].sequence !== this.stateService.mempoolSequence + 1) {
|
||||
this.stateService.mempoolSequence = 0;
|
||||
this.startTrackMempoolBlock(this.trackingMempoolBlock, true);
|
||||
} else {
|
||||
this.stateService.mempoolSequence = response['projected-block-transactions'].sequence;
|
||||
this.stateService.mempoolBlockUpdate$.next(uncompressDeltaChange(this.trackingMempoolBlock, response['projected-block-transactions'].delta));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,12 +204,12 @@ export function renderSats(value: number, network: string, mode: 'sats' | 'btc'
|
||||
break;
|
||||
}
|
||||
if (mode === 'btc' || (mode === 'auto' && value >= 1000000)) {
|
||||
return `${amountShortenerPipe.transform(value / 100000000, 2)} ${prefix}BTC`;
|
||||
return `${amountShortenerPipe.transform(value / 100000000)} ${prefix}BTC`;
|
||||
} else {
|
||||
if (prefix.length) {
|
||||
prefix += '-';
|
||||
}
|
||||
return `${amountShortenerPipe.transform(value, 2)} ${prefix}sats`;
|
||||
return `${amountShortenerPipe.transform(value)} ${prefix}sats`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,12 +70,6 @@ export class GeolocationComponent implements OnChanges {
|
||||
if (this.type === 'node') {
|
||||
const city = this.data.city ? this.data.city : '';
|
||||
|
||||
// Handle city-states like Singapore or Hong Kong
|
||||
if (city && city === this.data?.country) {
|
||||
this.formattedLocation = `${this.data.country} ${getFlagEmoji(this.data.iso)}`;
|
||||
return;
|
||||
}
|
||||
|
||||
// City
|
||||
this.formattedLocation = `${city}`;
|
||||
|
||||
|
||||
@@ -1,425 +0,0 @@
|
||||
/*
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 HAUS HOPPE
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
*/
|
||||
|
||||
// Adapted from https://github.com/ordpool-space/ordpool-parser/tree/ce04d7a5b6bb1cf37b9fdadd77ba430f5bd6e7d6/src
|
||||
// Utils functions to decode ord inscriptions
|
||||
|
||||
export const OP_FALSE = 0x00;
|
||||
export const OP_IF = 0x63;
|
||||
export const OP_0 = 0x00;
|
||||
|
||||
export const OP_PUSHBYTES_3 = 0x03; // 3 -- not an actual opcode, but used in documentation --> pushes the next 3 bytes onto the stack.
|
||||
export const OP_PUSHDATA1 = 0x4c; // 76 -- The next byte contains the number of bytes to be pushed onto the stack.
|
||||
export const OP_PUSHDATA2 = 0x4d; // 77 -- The next two bytes contain the number of bytes to be pushed onto the stack in little endian order.
|
||||
export const OP_PUSHDATA4 = 0x4e; // 78 -- The next four bytes contain the number of bytes to be pushed onto the stack in little endian order.
|
||||
export const OP_ENDIF = 0x68; // 104 -- Ends an if/else block.
|
||||
|
||||
export const OP_1NEGATE = 0x4f; // 79 -- The number -1 is pushed onto the stack.
|
||||
export const OP_RESERVED = 0x50; // 80 -- Transaction is invalid unless occuring in an unexecuted OP_IF branch
|
||||
export const OP_PUSHNUM_1 = 0x51; // 81 -- also known as OP_1
|
||||
export const OP_PUSHNUM_2 = 0x52; // 82 -- also known as OP_2
|
||||
export const OP_PUSHNUM_3 = 0x53; // 83 -- also known as OP_3
|
||||
export const OP_PUSHNUM_4 = 0x54; // 84 -- also known as OP_4
|
||||
export const OP_PUSHNUM_5 = 0x55; // 85 -- also known as OP_5
|
||||
export const OP_PUSHNUM_6 = 0x56; // 86 -- also known as OP_6
|
||||
export const OP_PUSHNUM_7 = 0x57; // 87 -- also known as OP_7
|
||||
export const OP_PUSHNUM_8 = 0x58; // 88 -- also known as OP_8
|
||||
export const OP_PUSHNUM_9 = 0x59; // 89 -- also known as OP_9
|
||||
export const OP_PUSHNUM_10 = 0x5a; // 90 -- also known as OP_10
|
||||
export const OP_PUSHNUM_11 = 0x5b; // 91 -- also known as OP_11
|
||||
export const OP_PUSHNUM_12 = 0x5c; // 92 -- also known as OP_12
|
||||
export const OP_PUSHNUM_13 = 0x5d; // 93 -- also known as OP_13
|
||||
export const OP_PUSHNUM_14 = 0x5e; // 94 -- also known as OP_14
|
||||
export const OP_PUSHNUM_15 = 0x5f; // 95 -- also known as OP_15
|
||||
export const OP_PUSHNUM_16 = 0x60; // 96 -- also known as OP_16
|
||||
|
||||
export const OP_RETURN = 0x6a; // 106 -- a standard way of attaching extra data to transactions is to add a zero-value output with a scriptPubKey consisting of OP_RETURN followed by data
|
||||
|
||||
//////////////////////////// Helper ///////////////////////////////
|
||||
|
||||
/**
|
||||
* Inscriptions may include fields before an optional body. Each field consists of two data pushes, a tag and a value.
|
||||
* Currently, there are six defined fields:
|
||||
*/
|
||||
export const knownFields = {
|
||||
// content_type, with a tag of 1, whose value is the MIME type of the body.
|
||||
content_type: 0x01,
|
||||
|
||||
// pointer, with a tag of 2, see pointer docs: https://docs.ordinals.com/inscriptions/pointer.html
|
||||
pointer: 0x02,
|
||||
|
||||
// parent, with a tag of 3, see provenance docs: https://docs.ordinals.com/inscriptions/provenance.html
|
||||
parent: 0x03,
|
||||
|
||||
// metadata, with a tag of 5, see metadata docs: https://docs.ordinals.com/inscriptions/metadata.html
|
||||
metadata: 0x05,
|
||||
|
||||
// metaprotocol, with a tag of 7, whose value is the metaprotocol identifier.
|
||||
metaprotocol: 0x07,
|
||||
|
||||
// content_encoding, with a tag of 9, whose value is the encoding of the body.
|
||||
content_encoding: 0x09,
|
||||
|
||||
// delegate, with a tag of 11, see delegate docs: https://docs.ordinals.com/inscriptions/delegate.html
|
||||
delegate: 0xb
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the value for a given field from an array of field objects.
|
||||
* It returns the value of the first object where the tag matches the specified field.
|
||||
*
|
||||
* @param fields - An array of objects containing tag and value properties.
|
||||
* @param field - The field number to search for.
|
||||
* @returns The value associated with the first matching field, or undefined if no match is found.
|
||||
*/
|
||||
export function getKnownFieldValue(fields: { tag: number; value: Uint8Array }[], field: number): Uint8Array | undefined {
|
||||
const knownField = fields.find(x =>
|
||||
x.tag === field);
|
||||
|
||||
if (knownField === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return knownField.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the values for a given field from an array of field objects.
|
||||
* It returns the values of all objects where the tag matches the specified field.
|
||||
*
|
||||
* @param fields - An array of objects containing tag and value properties.
|
||||
* @param field - The field number to search for.
|
||||
* @returns An array of Uint8Array values associated with the matching fields. If no matches are found, an empty array is returned.
|
||||
*/
|
||||
export function getKnownFieldValues(fields: { tag: number; value: Uint8Array }[], field: number): Uint8Array[] {
|
||||
const knownFields = fields.filter(x =>
|
||||
x.tag === field
|
||||
);
|
||||
|
||||
return knownFields.map(field => field.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for the next position of the ordinal inscription mark (0063036f7264)
|
||||
* within the raw transaction data, starting from a given position.
|
||||
*
|
||||
* This function looks for a specific sequence of 6 bytes that represents the start of an ordinal inscription.
|
||||
* If the sequence is found, the function returns the index immediately following the inscription mark.
|
||||
* If the sequence is not found, the function returns -1, indicating no inscription mark was found.
|
||||
*
|
||||
* Note: This function uses a simple hardcoded approach based on the fixed length of the inscription mark.
|
||||
*
|
||||
* @returns The position immediately after the inscription mark, or -1 if not found.
|
||||
*/
|
||||
export function getNextInscriptionMark(raw: Uint8Array, startPosition: number): number {
|
||||
|
||||
// OP_FALSE
|
||||
// OP_IF
|
||||
// OP_PUSHBYTES_3: This pushes the next 3 bytes onto the stack.
|
||||
// 0x6f, 0x72, 0x64: These bytes translate to the ASCII string "ord"
|
||||
const inscriptionMark = new Uint8Array([OP_FALSE, OP_IF, OP_PUSHBYTES_3, 0x6f, 0x72, 0x64]);
|
||||
|
||||
for (let index = startPosition; index <= raw.length - 6; index++) {
|
||||
if (raw[index] === inscriptionMark[0] &&
|
||||
raw[index + 1] === inscriptionMark[1] &&
|
||||
raw[index + 2] === inscriptionMark[2] &&
|
||||
raw[index + 3] === inscriptionMark[3] &&
|
||||
raw[index + 4] === inscriptionMark[4] &&
|
||||
raw[index + 5] === inscriptionMark[5]) {
|
||||
return index + 6;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/////////////////////////////// Reader ///////////////////////////////
|
||||
|
||||
/**
|
||||
* Reads a specified number of bytes from a Uint8Array starting from a given pointer.
|
||||
*
|
||||
* @param raw - The Uint8Array from which bytes are to be read.
|
||||
* @param pointer - The position in the array from where to start reading.
|
||||
* @param n - The number of bytes to read.
|
||||
* @returns A tuple containing the read bytes as Uint8Array and the updated pointer position.
|
||||
*/
|
||||
export function readBytes(raw: Uint8Array, pointer: number, n: number): [Uint8Array, number] {
|
||||
const slice = raw.slice(pointer, pointer + n);
|
||||
return [slice, pointer + n];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads data based on the Bitcoin script push opcode starting from a specified pointer in the raw data.
|
||||
* Handles different opcodes and direct push (where the opcode itself signifies the number of bytes to push).
|
||||
*
|
||||
* @param raw - The raw transaction data as a Uint8Array.
|
||||
* @param pointer - The current position in the raw data array.
|
||||
* @returns A tuple containing the read data as Uint8Array and the updated pointer position.
|
||||
*/
|
||||
export function readPushdata(raw: Uint8Array, pointer: number): [Uint8Array, number] {
|
||||
|
||||
let [opcodeSlice, newPointer] = readBytes(raw, pointer, 1);
|
||||
const opcode = opcodeSlice[0];
|
||||
|
||||
// Handle the special case of OP_0 (0x00) which pushes an empty array (interpreted as zero)
|
||||
// fixes #18
|
||||
if (opcode === OP_0) {
|
||||
return [new Uint8Array(), newPointer];
|
||||
}
|
||||
|
||||
// Handle the special case of OP_1NEGATE (-1)
|
||||
if (opcode === OP_1NEGATE) {
|
||||
// OP_1NEGATE pushes the value -1 onto the stack, represented as 0x81 in Bitcoin Script
|
||||
return [new Uint8Array([0x81]), newPointer];
|
||||
}
|
||||
|
||||
// Handle minimal push numbers OP_PUSHNUM_1 (0x51) to OP_PUSHNUM_16 (0x60)
|
||||
// which are used to push the values 0x01 (decimal 1) through 0x10 (decimal 16) onto the stack.
|
||||
// To get the value, we can subtract OP_RESERVED (0x50) from the opcode to get the value to be pushed.
|
||||
if (opcode >= OP_PUSHNUM_1 && opcode <= OP_PUSHNUM_16) {
|
||||
// Convert opcode to corresponding byte value
|
||||
const byteValue = opcode - OP_RESERVED;
|
||||
return [Uint8Array.from([byteValue]), newPointer];
|
||||
}
|
||||
|
||||
// Handle direct push of 1 to 75 bytes (OP_PUSHBYTES_1 to OP_PUSHBYTES_75)
|
||||
if (1 <= opcode && opcode <= 75) {
|
||||
return readBytes(raw, newPointer, opcode);
|
||||
}
|
||||
|
||||
let numBytes: number;
|
||||
switch (opcode) {
|
||||
case OP_PUSHDATA1: numBytes = 1; break;
|
||||
case OP_PUSHDATA2: numBytes = 2; break;
|
||||
case OP_PUSHDATA4: numBytes = 4; break;
|
||||
default:
|
||||
throw new Error(`Invalid push opcode ${opcode} at position ${pointer}`);
|
||||
}
|
||||
|
||||
let [dataSizeArray, nextPointer] = readBytes(raw, newPointer, numBytes);
|
||||
let dataSize = littleEndianBytesToNumber(dataSizeArray);
|
||||
return readBytes(raw, nextPointer, dataSize);
|
||||
}
|
||||
|
||||
//////////////////////////// Conversion ////////////////////////////
|
||||
|
||||
/**
|
||||
* Converts a Uint8Array containing UTF-8 encoded data to a normal a UTF-16 encoded string.
|
||||
*
|
||||
* @param bytes - The Uint8Array containing UTF-8 encoded data.
|
||||
* @returns The corresponding UTF-16 encoded JavaScript string.
|
||||
*/
|
||||
export function bytesToUnicodeString(bytes: Uint8Array): string {
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
return decoder.decode(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Uint8Array to a string by treating each byte as a character code.
|
||||
* It avoids interpreting bytes as UTF-8 encoded sequences.
|
||||
* --> Again: it ignores UTF-8 encoding, which is necessary for binary content!
|
||||
*
|
||||
* Note: This method is different from just using `String.fromCharCode(...combinedData)` which can
|
||||
* cause a "Maximum call stack size exceeded" error for large arrays due to the limitation of
|
||||
* the spread operator in JavaScript. (previously the parser broke here, because of large content)
|
||||
*
|
||||
* @param bytes - The byte array to convert.
|
||||
* @returns The resulting string where each byte value is treated as a direct character code.
|
||||
*/
|
||||
export function bytesToBinaryString(bytes: Uint8Array): string {
|
||||
let resultStr = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
resultStr += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return resultStr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a hexadecimal string to a Uint8Array.
|
||||
*
|
||||
* @param hex - A string of hexadecimal characters.
|
||||
* @returns A Uint8Array representing the hex string.
|
||||
*/
|
||||
export function hexToBytes(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0, j = 0; i < hex.length; i += 2, j++) {
|
||||
bytes[j] = parseInt(hex.slice(i, i + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Uint8Array to a hexadecimal string.
|
||||
*
|
||||
* @param bytes - A Uint8Array to convert.
|
||||
* @returns A string of hexadecimal characters representing the byte array.
|
||||
*/
|
||||
export function bytesToHex(bytes: Uint8Array): string {
|
||||
if (!bytes) {
|
||||
return null;
|
||||
}
|
||||
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a little-endian byte array to a JavaScript number.
|
||||
*
|
||||
* This function interprets the provided bytes in little-endian format, where the least significant byte comes first.
|
||||
* It constructs an integer value representing the number encoded by the bytes.
|
||||
*
|
||||
* @param byteArray - An array containing the bytes in little-endian format.
|
||||
* @returns The number represented by the byte array.
|
||||
*/
|
||||
export function littleEndianBytesToNumber(byteArray: Uint8Array): number {
|
||||
let number = 0;
|
||||
for (let i = 0; i < byteArray.length; i++) {
|
||||
// Extract each byte from byteArray, shift it to the left by 8 * i bits, and combine it with number.
|
||||
// The shifting accounts for the little-endian format where the least significant byte comes first.
|
||||
number |= byteArray[i] << (8 * i);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenates multiple Uint8Array objects into a single Uint8Array.
|
||||
*
|
||||
* @param arrays - An array of Uint8Array objects to concatenate.
|
||||
* @returns A new Uint8Array containing the concatenated results of the input arrays.
|
||||
*/
|
||||
export function concatUint8Arrays(arrays: Uint8Array[]): Uint8Array {
|
||||
if (arrays.length === 0) {
|
||||
return new Uint8Array();
|
||||
}
|
||||
|
||||
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
|
||||
for (const array of arrays) {
|
||||
result.set(array, offset);
|
||||
offset += array.length;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
////////////////////////////// Inscription ///////////////////////////
|
||||
|
||||
export interface Inscription {
|
||||
body?: Uint8Array;
|
||||
is_cropped?: boolean;
|
||||
body_length?: number;
|
||||
content_type?: Uint8Array;
|
||||
content_type_str?: string;
|
||||
delegate_txid?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts fields from the raw data until OP_0 is encountered.
|
||||
*
|
||||
* @param raw - The raw data to read.
|
||||
* @param pointer - The current pointer where the reading starts.
|
||||
* @returns An array of fields and the updated pointer position.
|
||||
*/
|
||||
export function extractFields(raw: Uint8Array, pointer: number): [{ tag: number; value: Uint8Array }[], number] {
|
||||
|
||||
const fields: { tag: number; value: Uint8Array }[] = [];
|
||||
let newPointer = pointer;
|
||||
let slice: Uint8Array;
|
||||
|
||||
while (newPointer < raw.length &&
|
||||
// normal inscription - content follows now
|
||||
(raw[newPointer] !== OP_0) &&
|
||||
// delegate - inscription has no further content and ends directly here
|
||||
(raw[newPointer] !== OP_ENDIF)
|
||||
) {
|
||||
|
||||
// tags are encoded by ord as single-byte data pushes, but are accepted by ord as either single-byte pushes, or as OP_NUM data pushes.
|
||||
// tags greater than or equal to 256 should be encoded as little endian integers with trailing zeros omitted.
|
||||
// see: https://github.com/ordinals/ord/issues/2505
|
||||
[slice, newPointer] = readPushdata(raw, newPointer);
|
||||
const tag = slice.length === 1 ? slice[0] : littleEndianBytesToNumber(slice);
|
||||
|
||||
[slice, newPointer] = readPushdata(raw, newPointer);
|
||||
const value = slice;
|
||||
|
||||
fields.push({ tag, value });
|
||||
}
|
||||
|
||||
return [fields, newPointer];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Extracts inscription data starting from the current pointer.
|
||||
* @param raw - The raw data to read.
|
||||
* @param pointer - The current pointer where the reading starts.
|
||||
* @returns The parsed inscription or nullx
|
||||
*/
|
||||
export function extractInscriptionData(raw: Uint8Array, pointer: number): Inscription | null {
|
||||
|
||||
try {
|
||||
|
||||
let fields: { tag: number; value: Uint8Array }[];
|
||||
let newPointer: number;
|
||||
let slice: Uint8Array;
|
||||
|
||||
[fields, newPointer] = extractFields(raw, pointer);
|
||||
|
||||
// Now we are at the beginning of the body
|
||||
// (or at the end of the raw data if there's no body)
|
||||
if (newPointer < raw.length && raw[newPointer] === OP_0) {
|
||||
newPointer++; // Skip OP_0
|
||||
}
|
||||
|
||||
// Collect body data until OP_ENDIF
|
||||
const data: Uint8Array[] = [];
|
||||
while (newPointer < raw.length && raw[newPointer] !== OP_ENDIF) {
|
||||
[slice, newPointer] = readPushdata(raw, newPointer);
|
||||
data.push(slice);
|
||||
}
|
||||
|
||||
const combinedLengthOfAllArrays = data.reduce((acc, curr) => acc + curr.length, 0);
|
||||
let combinedData = new Uint8Array(combinedLengthOfAllArrays);
|
||||
|
||||
// Copy all segments from data into combinedData, forming a single contiguous Uint8Array
|
||||
let idx = 0;
|
||||
for (const segment of data) {
|
||||
combinedData.set(segment, idx);
|
||||
idx += segment.length;
|
||||
}
|
||||
|
||||
const contentTypeRaw = getKnownFieldValue(fields, knownFields.content_type);
|
||||
let contentType: string;
|
||||
|
||||
if (!contentTypeRaw) {
|
||||
contentType = 'undefined';
|
||||
} else {
|
||||
contentType = bytesToUnicodeString(contentTypeRaw);
|
||||
}
|
||||
|
||||
return {
|
||||
content_type_str: contentType,
|
||||
body: combinedData.slice(0, 100_000), // Limit body to 100 kB for now
|
||||
is_cropped: combinedData.length > 100_000,
|
||||
body_length: combinedData.length,
|
||||
delegate_txid: getKnownFieldValue(fields, knownFields.delegate) ? bytesToHex(getKnownFieldValue(fields, knownFields.delegate).reverse()) : null
|
||||
};
|
||||
|
||||
} catch (ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
import { Transaction } from '../../interfaces/electrs.interface';
|
||||
|
||||
export const U128_MAX_BIGINT = 0xffff_ffff_ffff_ffff_ffff_ffff_ffff_ffffn;
|
||||
|
||||
export class RuneId {
|
||||
block: number;
|
||||
index: number;
|
||||
|
||||
constructor(block: number, index: number) {
|
||||
this.block = block;
|
||||
this.index = index;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `${this.block}:${this.index}`;
|
||||
}
|
||||
}
|
||||
|
||||
export type Etching = {
|
||||
divisibility?: number;
|
||||
premine?: bigint;
|
||||
symbol?: string;
|
||||
terms?: {
|
||||
cap?: bigint;
|
||||
amount?: bigint;
|
||||
offset?: {
|
||||
start?: bigint;
|
||||
end?: bigint;
|
||||
};
|
||||
height?: {
|
||||
start?: bigint;
|
||||
end?: bigint;
|
||||
};
|
||||
};
|
||||
turbo?: boolean;
|
||||
name?: string;
|
||||
spacedName?: string;
|
||||
supply?: bigint;
|
||||
};
|
||||
|
||||
export type Edict = {
|
||||
id: RuneId;
|
||||
amount: bigint;
|
||||
output: number;
|
||||
};
|
||||
|
||||
export type Runestone = {
|
||||
mint?: RuneId;
|
||||
pointer?: number;
|
||||
edicts?: Edict[];
|
||||
etching?: Etching;
|
||||
};
|
||||
|
||||
type Message = {
|
||||
fields: Record<number, bigint[]>;
|
||||
edicts: Edict[];
|
||||
}
|
||||
|
||||
export const UNCOMMON_GOODS: Etching = {
|
||||
divisibility: 0,
|
||||
premine: 0n,
|
||||
symbol: '⧉',
|
||||
terms: {
|
||||
cap: U128_MAX_BIGINT,
|
||||
amount: 1n,
|
||||
offset: {
|
||||
start: 0n,
|
||||
end: 0n,
|
||||
},
|
||||
height: {
|
||||
start: 840000n,
|
||||
end: 1050000n,
|
||||
},
|
||||
},
|
||||
turbo: false,
|
||||
name: 'UNCOMMONGOODS',
|
||||
spacedName: 'UNCOMMON•GOODS',
|
||||
supply: U128_MAX_BIGINT,
|
||||
};
|
||||
|
||||
enum Tag {
|
||||
Body = 0,
|
||||
Flags = 2,
|
||||
Rune = 4,
|
||||
Premine = 6,
|
||||
Cap = 8,
|
||||
Amount = 10,
|
||||
HeightStart = 12,
|
||||
HeightEnd = 14,
|
||||
OffsetStart = 16,
|
||||
OffsetEnd = 18,
|
||||
Mint = 20,
|
||||
Pointer = 22,
|
||||
Cenotaph = 126,
|
||||
|
||||
Divisibility = 1,
|
||||
Spacers = 3,
|
||||
Symbol = 5,
|
||||
Nop = 127,
|
||||
}
|
||||
|
||||
const Flag = {
|
||||
ETCHING: 1n,
|
||||
TERMS: 1n << 1n,
|
||||
TURBO: 1n << 2n,
|
||||
CENOTAPH: 1n << 127n,
|
||||
};
|
||||
|
||||
function hexToBytes(hex: string): Uint8Array {
|
||||
return new Uint8Array(hex.match(/.{2}/g).map((byte) => parseInt(byte, 16)));
|
||||
}
|
||||
|
||||
function decodeLEB128(bytes: Uint8Array): bigint[] {
|
||||
const integers: bigint[] = [];
|
||||
let index = 0;
|
||||
while (index < bytes.length) {
|
||||
let value = BigInt(0);
|
||||
let shift = 0;
|
||||
let byte: number;
|
||||
do {
|
||||
byte = bytes[index++];
|
||||
value |= BigInt(byte & 0x7f) << BigInt(shift);
|
||||
shift += 7;
|
||||
} while (byte & 0x80);
|
||||
integers.push(value);
|
||||
}
|
||||
return integers;
|
||||
}
|
||||
|
||||
function integersToMessage(integers: bigint[]): Message {
|
||||
const message = {
|
||||
fields: {},
|
||||
edicts: [],
|
||||
};
|
||||
let inBody = false;
|
||||
while (integers.length) {
|
||||
if (!inBody) {
|
||||
// The integers are interpreted as a sequence of tag/value pairs, with duplicate tags appending their value to the field value.
|
||||
const tag: Tag = Number(integers.shift());
|
||||
if (tag === Tag.Body) {
|
||||
inBody = true;
|
||||
} else {
|
||||
const value = integers.shift();
|
||||
if (message.fields[tag]) {
|
||||
message.fields[tag].push(value);
|
||||
} else {
|
||||
message.fields[tag] = [value];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If a tag with value zero is encountered, all following integers are interpreted as a series of four-integer edicts, each consisting of a rune ID block height, rune ID transaction index, amount, and output.
|
||||
const height = integers.shift();
|
||||
const txIndex = integers.shift();
|
||||
const amount = integers.shift();
|
||||
const output = integers.shift();
|
||||
message.edicts.push({
|
||||
id: new RuneId(Number(height), Number(txIndex)),
|
||||
amount,
|
||||
output,
|
||||
});
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
function parseRuneName(rune: bigint): string {
|
||||
let name = '';
|
||||
rune += 1n;
|
||||
while (rune > 0n) {
|
||||
name = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[Number((rune - 1n) % 26n)] + name;
|
||||
rune = (rune - 1n) / 26n;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function spaceRuneName(name: string, spacers: bigint): string {
|
||||
let i = 0;
|
||||
let spacedName = '';
|
||||
while (spacers > 0n || i < name.length) {
|
||||
spacedName += name[i];
|
||||
if (spacers & 1n) {
|
||||
spacedName += '•';
|
||||
}
|
||||
if (spacers > 0n) {
|
||||
spacers >>= 1n;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return spacedName;
|
||||
}
|
||||
|
||||
function messageToRunestone(message: Message): Runestone {
|
||||
let etching: Etching | undefined;
|
||||
let mint: RuneId | undefined;
|
||||
let pointer: number | undefined;
|
||||
|
||||
const flags = message.fields[Tag.Flags]?.[0] || 0n;
|
||||
if (flags & Flag.ETCHING) {
|
||||
const hasTerms = (flags & Flag.TERMS) > 0n;
|
||||
const isTurbo = (flags & Flag.TURBO) > 0n;
|
||||
const name = parseRuneName(message.fields[Tag.Rune]?.[0] ?? 0n);
|
||||
etching = {
|
||||
divisibility: Number(message.fields[Tag.Divisibility]?.[0] ?? 0n),
|
||||
premine: message.fields[Tag.Premine]?.[0],
|
||||
symbol: message.fields[Tag.Symbol]?.[0] ? String.fromCodePoint(Number(message.fields[Tag.Symbol][0])) : '¤',
|
||||
terms: hasTerms ? {
|
||||
cap: message.fields[Tag.Cap]?.[0],
|
||||
amount: message.fields[Tag.Amount]?.[0],
|
||||
offset: {
|
||||
start: message.fields[Tag.OffsetStart]?.[0],
|
||||
end: message.fields[Tag.OffsetEnd]?.[0],
|
||||
},
|
||||
height: {
|
||||
start: message.fields[Tag.HeightStart]?.[0],
|
||||
end: message.fields[Tag.HeightEnd]?.[0],
|
||||
},
|
||||
} : undefined,
|
||||
turbo: isTurbo,
|
||||
name,
|
||||
spacedName: spaceRuneName(name, message.fields[Tag.Spacers]?.[0] ?? 0n),
|
||||
};
|
||||
etching.supply = (
|
||||
(etching.terms?.cap ?? 0n) * (etching.terms?.amount ?? 0n)
|
||||
) + (etching.premine ?? 0n);
|
||||
}
|
||||
const mintField = message.fields[Tag.Mint];
|
||||
if (mintField) {
|
||||
mint = new RuneId(Number(mintField[0]), Number(mintField[1]));
|
||||
}
|
||||
const pointerField = message.fields[Tag.Pointer];
|
||||
if (pointerField) {
|
||||
pointer = Number(pointerField[0]);
|
||||
}
|
||||
return {
|
||||
mint,
|
||||
pointer,
|
||||
edicts: message.edicts,
|
||||
etching,
|
||||
};
|
||||
}
|
||||
|
||||
export function decipherRunestone(tx: Transaction): Runestone | void {
|
||||
const payload = tx.vout.find((vout) => vout.scriptpubkey.startsWith('6a5d'))?.scriptpubkey_asm.replace(/OP_\w+|\s/g, '');
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const integers = decodeLEB128(hexToBytes(payload));
|
||||
const message = integersToMessage(integers);
|
||||
return messageToRunestone(message);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export class BitcoinsatoshisPipe implements PipeTransform {
|
||||
|
||||
constructor(private sanitizer: DomSanitizer) { }
|
||||
|
||||
transform(value: string, firstPartClass?: string): SafeHtml {
|
||||
transform(value: string): SafeHtml {
|
||||
const newValue = this.insertSpaces(parseFloat(value || '0').toFixed(8));
|
||||
const position = (newValue || '0').search(/[1-9]/);
|
||||
|
||||
@@ -16,7 +16,7 @@ export class BitcoinsatoshisPipe implements PipeTransform {
|
||||
const secondPart = newValue.slice(position);
|
||||
|
||||
return this.sanitizer.bypassSecurityTrustHtml(
|
||||
`<span class="${firstPartClass ? firstPartClass : 'text-secondary'}">${firstPart}</span>${secondPart}`
|
||||
`<span class="text-secondary">${firstPart}</span>${secondPart}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user