Merge branch 'master' into nymkappa/square-errors

This commit is contained in:
nymkappa 2025-01-20 17:23:15 +09:00
commit e2c44b6c62
No known key found for this signature in database
GPG key ID: 92358FC85D9645DE
129 changed files with 5887 additions and 929 deletions

View file

@ -251,17 +251,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
module: ["mempool", "liquid"] module: ["mempool", "liquid", "testnet4"]
include:
- module: "mempool"
spec: |
cypress/e2e/mainnet/*.spec.ts
cypress/e2e/signet/*.spec.ts
cypress/e2e/testnet4/*.spec.ts
- module: "liquid"
spec: |
cypress/e2e/liquid/liquid.spec.ts
cypress/e2e/liquidtestnet/liquidtestnet.spec.ts
name: E2E tests for ${{ matrix.module }} name: E2E tests for ${{ matrix.module }}
steps: steps:
@ -310,8 +300,10 @@ jobs:
- name: Unzip assets before building (src/resources) - name: Unzip assets before building (src/resources)
run: unzip -o promo-video-assets.zip -d ${{ matrix.module }}/frontend/src/resources/promo-video run: unzip -o promo-video-assets.zip -d ${{ matrix.module }}/frontend/src/resources/promo-video
# mempool
- name: Chrome browser tests (${{ matrix.module }}) - name: Chrome browser tests (${{ matrix.module }})
if: ${{ matrix.module == 'mempool' }}
uses: cypress-io/github-action@v5 uses: cypress-io/github-action@v5
with: with:
tag: ${{ github.event_name }} tag: ${{ github.event_name }}
@ -322,7 +314,9 @@ jobs:
wait-on-timeout: 120 wait-on-timeout: 120
record: true record: true
parallel: true parallel: true
spec: ${{ matrix.spec }} spec: |
cypress/e2e/mainnet/*.spec.ts
cypress/e2e/signet/*.spec.ts
group: Tests on Chrome (${{ matrix.module }}) group: Tests on Chrome (${{ matrix.module }})
browser: "chrome" browser: "chrome"
ci-build-id: "${{ github.sha }}-${{ github.workflow }}-${{ github.event_name }}" ci-build-id: "${{ github.sha }}-${{ github.workflow }}-${{ github.event_name }}"
@ -332,6 +326,56 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }} CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
# liquid
- name: Chrome browser tests (${{ matrix.module }})
if: ${{ matrix.module == 'liquid' }}
uses: cypress-io/github-action@v5
with:
tag: ${{ github.event_name }}
working-directory: ${{ matrix.module }}/frontend
build: npm run config:defaults:${{ matrix.module }}
start: npm run start:local-staging
wait-on: "http://localhost:4200"
wait-on-timeout: 120
record: true
parallel: true
spec: |
cypress/e2e/liquid/liquid.spec.ts
cypress/e2e/liquidtestnet/liquidtestnet.spec.ts
group: Tests on Chrome (${{ matrix.module }})
browser: "chrome"
ci-build-id: "${{ github.sha }}-${{ github.workflow }}-${{ github.event_name }}"
env:
COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title }}
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
# testnet
- name: Chrome browser tests (${{ matrix.module }})
if: ${{ matrix.module == 'testnet4' }}
uses: cypress-io/github-action@v5
with:
tag: ${{ github.event_name }}
working-directory: ${{ matrix.module }}/frontend
build: npm run config:defaults:mempool
start: npm run start:local-staging
wait-on: "http://localhost:4200"
wait-on-timeout: 120
record: true
parallel: true
spec: |
cypress/e2e/testnet4/*.spec.ts
group: Tests on Chrome (${{ matrix.module }})
browser: "chrome"
ci-build-id: "${{ github.sha }}-${{ github.workflow }}-${{ github.event_name }}"
env:
CYPRESS_REROUTE_TESTNET: true
COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title }}
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
validate_docker_json: validate_docker_json:
if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')" if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')"
runs-on: "ubuntu-latest" runs-on: "ubuntu-latest"
@ -359,4 +403,4 @@ jobs:
- name: Validate JSON syntax - name: Validate JSON syntax
run: | run: |
cat mempool-config.json | jq cat mempool-config.json | jq
working-directory: docker/docker/backend working-directory: docker/docker/backend

View file

@ -155,6 +155,10 @@
"API": "https://mempool.space/api/v1/services", "API": "https://mempool.space/api/v1/services",
"ACCELERATIONS": false "ACCELERATIONS": false
}, },
"STRATUM": {
"ENABLED": false,
"API": "http://localhost:1234"
},
"FIAT_PRICE": { "FIAT_PRICE": {
"ENABLED": true, "ENABLED": true,
"PAID": false, "PAID": false,

View file

@ -151,5 +151,9 @@
"ENABLED": true, "ENABLED": true,
"PAID": false, "PAID": false,
"API_KEY": "__MEMPOOL_CURRENCY_API_KEY__" "API_KEY": "__MEMPOOL_CURRENCY_API_KEY__"
},
"STRATUM": {
"ENABLED": false,
"API": "http://localhost:1234"
} }
} }

View file

@ -159,6 +159,11 @@ describe('Mempool Backend Config', () => {
PAID: false, PAID: false,
API_KEY: '', API_KEY: '',
}); });
expect(config.STRATUM).toStrictEqual({
ENABLED: false,
API: 'http://localhost:1234',
});
}); });
}); });

View file

@ -3,6 +3,10 @@ import logger from '../../logger';
import bitcoinClient from './bitcoin-client'; import bitcoinClient from './bitcoin-client';
import config from '../../config'; import config from '../../config';
const BLOCKHASH_REGEX = /^[a-f0-9]{64}$/i;
const TXID_REGEX = /^[a-f0-9]{64}$/i;
const RAW_TX_REGEX = /^[a-f0-9]{2,}$/i;
/** /**
* Define a set of routes used by the accelerator server * Define a set of routes used by the accelerator server
* Those routes are not designed to be public * Those routes are not designed to be public
@ -10,7 +14,7 @@ import config from '../../config';
class BitcoinBackendRoutes { class BitcoinBackendRoutes {
private static tag = 'BitcoinBackendRoutes'; private static tag = 'BitcoinBackendRoutes';
public initRoutes(app: Application) { public initRoutes(app: Application): void {
app app
.get(config.MEMPOOL.API_URL_PREFIX + 'internal/bitcoin-core/' + 'get-mempool-entry', this.disableCache, this.$getMempoolEntry) .get(config.MEMPOOL.API_URL_PREFIX + 'internal/bitcoin-core/' + 'get-mempool-entry', this.disableCache, this.$getMempoolEntry)
.post(config.MEMPOOL.API_URL_PREFIX + 'internal/bitcoin-core/' + 'decode-raw-transaction', this.disableCache, this.$decodeRawTransaction) .post(config.MEMPOOL.API_URL_PREFIX + 'internal/bitcoin-core/' + 'decode-raw-transaction', this.disableCache, this.$decodeRawTransaction)
@ -26,10 +30,10 @@ class BitcoinBackendRoutes {
/** /**
* Disable caching for bitcoin core routes * Disable caching for bitcoin core routes
* *
* @param req * @param req
* @param res * @param res
* @param next * @param next
*/ */
private disableCache(req: Request, res: Response, next: NextFunction): void { private disableCache(req: Request, res: Response, next: NextFunction): void {
res.setHeader('Pragma', 'no-cache'); res.setHeader('Pragma', 'no-cache');
@ -40,16 +44,16 @@ class BitcoinBackendRoutes {
/** /**
* Exeption handler to return proper details to the accelerator server * Exeption handler to return proper details to the accelerator server
* *
* @param e * @param e
* @param fnName * @param fnName
* @param res * @param res
*/ */
private static handleException(e: any, fnName: string, res: Response): void { private static handleException(e: any, fnName: string, res: Response): void {
if (typeof(e.code) === 'number') { if (typeof(e.code) === 'number') {
res.status(400).send(JSON.stringify(e, ['code', 'message'])); res.status(400).send(JSON.stringify(e, ['code']));
} else { } else {
const err = `exception in ${fnName}. ${e}. Details: ${JSON.stringify(e, ['code', 'message'])}`; const err = `unknown exception in ${fnName}`;
logger.err(err, BitcoinBackendRoutes.tag); logger.err(err, BitcoinBackendRoutes.tag);
res.status(500).send(err); res.status(500).send(err);
} }
@ -58,13 +62,13 @@ class BitcoinBackendRoutes {
private async $getMempoolEntry(req: Request, res: Response): Promise<void> { private async $getMempoolEntry(req: Request, res: Response): Promise<void> {
const txid = req.query.txid; const txid = req.query.txid;
try { try {
if (typeof(txid) !== 'string' || txid.length !== 64) { if (typeof(txid) !== 'string' || txid.length !== 64 || !TXID_REGEX.test(txid)) {
res.status(400).send(`invalid param txid ${txid}. must be a string of 64 char`); res.status(400).send(`invalid param txid. must be 64 hexadecimal characters`);
return; return;
} }
const mempoolEntry = await bitcoinClient.getMempoolEntry(txid); const mempoolEntry = await bitcoinClient.getMempoolEntry(txid);
if (!mempoolEntry) { if (!mempoolEntry) {
res.status(404).send(`no mempool entry found for txid ${txid}`); res.status(404).send();
return; return;
} }
res.status(200).send(mempoolEntry); res.status(200).send(mempoolEntry);
@ -76,13 +80,13 @@ class BitcoinBackendRoutes {
private async $decodeRawTransaction(req: Request, res: Response): Promise<void> { private async $decodeRawTransaction(req: Request, res: Response): Promise<void> {
const rawTx = req.body.rawTx; const rawTx = req.body.rawTx;
try { try {
if (typeof(rawTx) !== 'string') { if (typeof(rawTx) !== 'string' || !RAW_TX_REGEX.test(rawTx)) {
res.status(400).send(`invalid param rawTx ${rawTx}. must be a string`); res.status(400).send(`invalid param rawTx. must be a string of hexadecimal characters`);
return; return;
} }
const decodedTx = await bitcoinClient.decodeRawTransaction(rawTx); const decodedTx = await bitcoinClient.decodeRawTransaction(rawTx);
if (!decodedTx) { if (!decodedTx) {
res.status(400).send(`unable to decode rawTx ${rawTx}`); res.status(400).send(`unable to decode rawTx`);
return; return;
} }
res.status(200).send(decodedTx); res.status(200).send(decodedTx);
@ -95,23 +99,23 @@ class BitcoinBackendRoutes {
const txid = req.query.txid; const txid = req.query.txid;
const verbose = req.query.verbose; const verbose = req.query.verbose;
try { try {
if (typeof(txid) !== 'string' || txid.length !== 64) { if (typeof(txid) !== 'string' || txid.length !== 64 || !TXID_REGEX.test(txid)) {
res.status(400).send(`invalid param txid ${txid}. must be a string of 64 char`); res.status(400).send(`invalid param txid. must be 64 hexadecimal characters`);
return; return;
} }
if (typeof(verbose) !== 'string') { if (typeof(verbose) !== 'string') {
res.status(400).send(`invalid param verbose ${verbose}. must be a string representing an integer`); res.status(400).send(`invalid param verbose. must be a string representing an integer`);
return; return;
} }
const verboseNumber = parseInt(verbose, 10); const verboseNumber = parseInt(verbose, 10);
if (typeof(verboseNumber) !== 'number') { if (typeof(verboseNumber) !== 'number') {
res.status(400).send(`invalid param verbose ${verbose}. must be a valid integer`); res.status(400).send(`invalid param verbose. must be a valid integer`);
return; return;
} }
const decodedTx = await bitcoinClient.getRawTransaction(txid, verboseNumber); const decodedTx = await bitcoinClient.getRawTransaction(txid, verboseNumber);
if (!decodedTx) { if (!decodedTx) {
res.status(400).send(`unable to get raw transaction for txid ${txid}`); res.status(400).send(`unable to get raw transaction`);
return; return;
} }
res.status(200).send(decodedTx); res.status(200).send(decodedTx);
@ -123,13 +127,13 @@ class BitcoinBackendRoutes {
private async $sendRawTransaction(req: Request, res: Response): Promise<void> { private async $sendRawTransaction(req: Request, res: Response): Promise<void> {
const rawTx = req.body.rawTx; const rawTx = req.body.rawTx;
try { try {
if (typeof(rawTx) !== 'string') { if (typeof(rawTx) !== 'string' || !RAW_TX_REGEX.test(rawTx)) {
res.status(400).send(`invalid param rawTx ${rawTx}. must be a string`); res.status(400).send(`invalid param rawTx. must be a string of hexadecimal characters`);
return; return;
} }
const txHex = await bitcoinClient.sendRawTransaction(rawTx); const txHex = await bitcoinClient.sendRawTransaction(rawTx);
if (!txHex) { if (!txHex) {
res.status(400).send(`unable to send rawTx ${rawTx}`); res.status(400).send(`unable to send rawTx`);
return; return;
} }
res.status(200).send(txHex); res.status(200).send(txHex);
@ -141,13 +145,13 @@ class BitcoinBackendRoutes {
private async $testMempoolAccept(req: Request, res: Response): Promise<void> { private async $testMempoolAccept(req: Request, res: Response): Promise<void> {
const rawTxs = req.body.rawTxs; const rawTxs = req.body.rawTxs;
try { try {
if (typeof(rawTxs) !== 'object') { if (typeof(rawTxs) !== 'object' || !Array.isArray(rawTxs) || rawTxs.some((tx) => typeof(tx) !== 'string' || !RAW_TX_REGEX.test(tx))) {
res.status(400).send(`invalid param rawTxs ${JSON.stringify(rawTxs)}. must be an array of string`); res.status(400).send(`invalid param rawTxs. must be an array of strings of hexadecimal characters`);
return; return;
} }
const txHex = await bitcoinClient.testMempoolAccept(rawTxs); const txHex = await bitcoinClient.testMempoolAccept(rawTxs);
if (typeof(txHex) !== 'object' || txHex.length === 0) { if (typeof(txHex) !== 'object' || txHex.length === 0) {
res.status(400).send(`testmempoolaccept failed for raw txs ${JSON.stringify(rawTxs)}, got an empty result`); res.status(400).send(`testmempoolaccept failed for raw txs, got an empty result`);
return; return;
} }
res.status(200).send(txHex); res.status(200).send(txHex);
@ -160,18 +164,18 @@ class BitcoinBackendRoutes {
const txid = req.query.txid; const txid = req.query.txid;
const verbose = req.query.verbose; const verbose = req.query.verbose;
try { try {
if (typeof(txid) !== 'string' || txid.length !== 64) { if (typeof(txid) !== 'string' || txid.length !== 64 || !TXID_REGEX.test(txid)) {
res.status(400).send(`invalid param txid ${txid}. must be a string of 64 char`); res.status(400).send(`invalid param txid. must be 64 hexadecimal characters`);
return; return;
} }
if (typeof(verbose) !== 'string' || (verbose !== 'true' && verbose !== 'false')) { if (typeof(verbose) !== 'string' || (verbose !== 'true' && verbose !== 'false')) {
res.status(400).send(`invalid param verbose ${verbose}. must be a string ('true' | 'false')`); res.status(400).send(`invalid param verbose. must be a string ('true' | 'false')`);
return; return;
} }
const ancestors = await bitcoinClient.getMempoolAncestors(txid, verbose === 'true' ? true : false); const ancestors = await bitcoinClient.getMempoolAncestors(txid, verbose === 'true' ? true : false);
if (!ancestors) { if (!ancestors) {
res.status(400).send(`unable to get mempool ancestors for txid ${txid}`); res.status(400).send(`unable to get mempool ancestors`);
return; return;
} }
res.status(200).send(ancestors); res.status(200).send(ancestors);
@ -184,23 +188,23 @@ class BitcoinBackendRoutes {
const blockHash = req.query.hash; const blockHash = req.query.hash;
const verbosity = req.query.verbosity; const verbosity = req.query.verbosity;
try { try {
if (typeof(blockHash) !== 'string' || blockHash.length !== 64) { if (typeof(blockHash) !== 'string' || blockHash.length !== 64 || !BLOCKHASH_REGEX.test(blockHash)) {
res.status(400).send(`invalid param blockHash ${blockHash}. must be a string of 64 char`); res.status(400).send(`invalid param blockHash. must be 64 hexadecimal characters`);
return; return;
} }
if (typeof(verbosity) !== 'string') { if (typeof(verbosity) !== 'string') {
res.status(400).send(`invalid param verbosity ${verbosity}. must be a string representing an integer`); res.status(400).send(`invalid param verbosity. must be a string representing an integer`);
return; return;
} }
const verbosityNumber = parseInt(verbosity, 10); const verbosityNumber = parseInt(verbosity, 10);
if (typeof(verbosityNumber) !== 'number') { if (typeof(verbosityNumber) !== 'number') {
res.status(400).send(`invalid param verbosity ${verbosity}. must be a valid integer`); res.status(400).send(`invalid param verbosity. must be a valid integer`);
return; return;
} }
const block = await bitcoinClient.getBlock(blockHash, verbosityNumber); const block = await bitcoinClient.getBlock(blockHash, verbosityNumber);
if (!block) { if (!block) {
res.status(400).send(`unable to get block for block hash ${blockHash}`); res.status(400).send(`unable to get block`);
return; return;
} }
res.status(200).send(block); res.status(200).send(block);
@ -213,18 +217,18 @@ class BitcoinBackendRoutes {
const blockHeight = req.query.height; const blockHeight = req.query.height;
try { try {
if (typeof(blockHeight) !== 'string') { if (typeof(blockHeight) !== 'string') {
res.status(400).send(`invalid param blockHeight ${blockHeight}, must be a string representing an integer`); res.status(400).send(`invalid param blockHeight, must be a string representing an integer`);
return; return;
} }
const blockHeightNumber = parseInt(blockHeight, 10); const blockHeightNumber = parseInt(blockHeight, 10);
if (typeof(blockHeightNumber) !== 'number') { if (typeof(blockHeightNumber) !== 'number') {
res.status(400).send(`invalid param blockHeight ${blockHeight}. must be a valid integer`); res.status(400).send(`invalid param blockHeight. must be a valid integer`);
return; return;
} }
const block = await bitcoinClient.getBlockHash(blockHeightNumber); const block = await bitcoinClient.getBlockHash(blockHeightNumber);
if (!block) { if (!block) {
res.status(400).send(`unable to get block hash for block height ${blockHeightNumber}`); res.status(400).send(`unable to get block hash`);
return; return;
} }
res.status(200).send(block); res.status(200).send(block);
@ -247,4 +251,4 @@ class BitcoinBackendRoutes {
} }
} }
export default new BitcoinBackendRoutes export default new BitcoinBackendRoutes;

View file

@ -22,6 +22,11 @@ import rbfCache from '../rbf-cache';
import { calculateMempoolTxCpfp } from '../cpfp'; import { calculateMempoolTxCpfp } from '../cpfp';
import { handleError } from '../../utils/api'; import { handleError } from '../../utils/api';
const TXID_REGEX = /^[a-f0-9]{64}$/i;
const BLOCK_HASH_REGEX = /^[a-f0-9]{64}$/i;
const ADDRESS_REGEX = /^[a-z0-9]{2,120}$/i;
const SCRIPT_HASH_REGEX = /^([a-f0-9]{2})+$/i;
class BitcoinRoutes { class BitcoinRoutes {
public initRoutes(app: Application) { public initRoutes(app: Application) {
app app
@ -90,7 +95,7 @@ class BitcoinRoutes {
res.set('Content-Type', 'application/json'); res.set('Content-Type', 'application/json');
res.send(result); res.send(result);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get init data');
} }
} }
@ -109,7 +114,7 @@ class BitcoinRoutes {
const result = mempoolBlocks.getMempoolBlocks(); const result = mempoolBlocks.getMempoolBlocks();
res.json(result); res.json(result);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get mempool blocks');
} }
} }
@ -121,7 +126,10 @@ class BitcoinRoutes {
const txIds: string[] = []; const txIds: string[] = [];
for (const _txId in req.query.txId) { for (const _txId in req.query.txId) {
if (typeof req.query.txId[_txId] === 'string') { if (typeof req.query.txId[_txId] === 'string') {
txIds.push(req.query.txId[_txId].toString()); const txid = req.query.txId[_txId].toString();
if (TXID_REGEX.test(txid)) {
txIds.push(txid);
}
} }
} }
@ -140,18 +148,22 @@ class BitcoinRoutes {
handleError(req, res, 400, 'Too many txids requested'); handleError(req, res, 400, 'Too many txids requested');
return; return;
} }
if (txids.some((txid) => !TXID_REGEX.test(txid))) {
handleError(req, res, 400, 'Invalid txids format');
return;
}
try { try {
const batchedOutspends = await bitcoinApi.$getBatchedOutspends(txids); const batchedOutspends = await bitcoinApi.$getBatchedOutspends(txids);
res.json(batchedOutspends); res.json(batchedOutspends);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get batched outspends');
} }
} }
private async $getCpfpInfo(req: Request, res: Response) { private async $getCpfpInfo(req: Request, res: Response) {
if (!/^[a-fA-F0-9]{64}$/.test(req.params.txId)) { if (!TXID_REGEX.test(req.params.txId)) {
handleError(req, res, 501, `Invalid transaction ID.`); handleError(req, res, 501, `Invalid transaction ID`);
return; return;
} }
@ -184,7 +196,7 @@ class BitcoinRoutes {
try { try {
cpfpInfo = await transactionRepository.$getCpfpInfo(req.params.txId); cpfpInfo = await transactionRepository.$getCpfpInfo(req.params.txId);
} catch (e) { } catch (e) {
handleError(req, res, 500, 'failed to get CPFP info'); handleError(req, res, 500, 'Failed to get CPFP info');
return; return;
} }
} }
@ -205,6 +217,10 @@ class BitcoinRoutes {
} }
private async getTransaction(req: Request, res: Response) { private async getTransaction(req: Request, res: Response) {
if (!TXID_REGEX.test(req.params.txId)) {
handleError(req, res, 501, `Invalid transaction ID`);
return;
}
try { try {
const transaction = await transactionUtils.$getTransactionExtended(req.params.txId, true, false, false, true); const transaction = await transactionUtils.$getTransactionExtended(req.params.txId, true, false, false, true);
res.json(transaction); res.json(transaction);
@ -212,12 +228,18 @@ class BitcoinRoutes {
let statusCode = 500; let statusCode = 500;
if (e instanceof Error && e instanceof Error && e.message && e.message.indexOf('No such mempool or blockchain transaction') > -1) { if (e instanceof Error && e instanceof Error && e.message && e.message.indexOf('No such mempool or blockchain transaction') > -1) {
statusCode = 404; statusCode = 404;
handleError(req, res, statusCode, 'No such mempool or blockchain transaction');
return;
} }
handleError(req, res, statusCode, e instanceof Error ? e.message : e); handleError(req, res, statusCode, 'Failed to get transaction');
} }
} }
private async getRawTransaction(req: Request, res: Response) { private async getRawTransaction(req: Request, res: Response) {
if (!TXID_REGEX.test(req.params.txId)) {
handleError(req, res, 501, `Invalid transaction ID`);
return;
}
try { try {
const transaction: IEsploraApi.Transaction = await bitcoinApi.$getRawTransaction(req.params.txId, true); const transaction: IEsploraApi.Transaction = await bitcoinApi.$getRawTransaction(req.params.txId, true);
res.setHeader('content-type', 'text/plain'); res.setHeader('content-type', 'text/plain');
@ -226,8 +248,10 @@ class BitcoinRoutes {
let statusCode = 500; let statusCode = 500;
if (e instanceof Error && e.message && e.message.indexOf('No such mempool or blockchain transaction') > -1) { if (e instanceof Error && e.message && e.message.indexOf('No such mempool or blockchain transaction') > -1) {
statusCode = 404; statusCode = 404;
handleError(req, res, statusCode, 'No such mempool or blockchain transaction');
return;
} }
handleError(req, res, statusCode, e instanceof Error ? e.message : e); handleError(req, res, statusCode, 'Failed to get raw transaction');
} }
} }
@ -292,14 +316,18 @@ class BitcoinRoutes {
} }
} catch (e: any) { } catch (e: any) {
if (e instanceof Error && new RegExp(notFoundError).test(e.message)) { if (e instanceof Error && new RegExp(notFoundError).test(e.message)) {
handleError(req, res, 404, e.message); handleError(req, res, 404, notFoundError);
} else { } else {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to process PSBT');
} }
} }
} }
private async getTransactionStatus(req: Request, res: Response) { private async getTransactionStatus(req: Request, res: Response) {
if (!TXID_REGEX.test(req.params.txId)) {
handleError(req, res, 501, `Invalid transaction ID`);
return;
}
try { try {
const transaction = await transactionUtils.$getTransactionExtended(req.params.txId, true); const transaction = await transactionUtils.$getTransactionExtended(req.params.txId, true);
res.json(transaction.status); res.json(transaction.status);
@ -307,36 +335,54 @@ class BitcoinRoutes {
let statusCode = 500; let statusCode = 500;
if (e instanceof Error && e.message && e.message.indexOf('No such mempool or blockchain transaction') > -1) { if (e instanceof Error && e.message && e.message.indexOf('No such mempool or blockchain transaction') > -1) {
statusCode = 404; statusCode = 404;
handleError(req, res, statusCode, 'No such mempool or blockchain transaction');
return;
} }
handleError(req, res, statusCode, e instanceof Error ? e.message : e); handleError(req, res, statusCode, 'Failed to get transaction status');
} }
} }
private async getStrippedBlockTransactions(req: Request, res: Response) { private async getStrippedBlockTransactions(req: Request, res: Response) {
if (!BLOCK_HASH_REGEX.test(req.params.hash)) {
handleError(req, res, 501, `Invalid block hash`);
return;
}
try { try {
const transactions = await blocks.$getStrippedBlockTransactions(req.params.hash); const transactions = await blocks.$getStrippedBlockTransactions(req.params.hash);
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString());
res.json(transactions); res.json(transactions);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get block summary');
} }
} }
private async getStrippedBlockTransaction(req: Request, res: Response) { private async getStrippedBlockTransaction(req: Request, res: Response) {
if (!BLOCK_HASH_REGEX.test(req.params.hash)) {
handleError(req, res, 501, `Invalid block hash`);
return;
}
if (!TXID_REGEX.test(req.params.txid)) {
handleError(req, res, 501, `Invalid transaction ID`);
return;
}
try { try {
const transaction = await blocks.$getSingleTxFromSummary(req.params.hash, req.params.txid); const transaction = await blocks.$getSingleTxFromSummary(req.params.hash, req.params.txid);
if (!transaction) { if (!transaction) {
handleError(req, res, 404, `transaction not found in summary`); handleError(req, res, 404, `Transaction not found in summary`);
return; return;
} }
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString());
res.json(transaction); res.json(transaction);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get transaction from summary');
} }
} }
private async getBlock(req: Request, res: Response) { private async getBlock(req: Request, res: Response) {
if (!BLOCK_HASH_REGEX.test(req.params.hash)) {
handleError(req, res, 501, `Invalid block hash`);
return;
}
try { try {
const block = await blocks.$getBlock(req.params.hash); const block = await blocks.$getBlock(req.params.hash);
@ -348,53 +394,69 @@ class BitcoinRoutes {
} else if (blockAge > 30 * day) { } else if (blockAge > 30 * day) {
cacheDuration = 10 * day; cacheDuration = 10 * day;
} else { } else {
cacheDuration = 600 cacheDuration = 600;
} }
res.setHeader('Expires', new Date(Date.now() + 1000 * cacheDuration).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * cacheDuration).toUTCString());
res.json(block); res.json(block);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get block');
} }
} }
private async getBlockHeader(req: Request, res: Response) { private async getBlockHeader(req: Request, res: Response) {
if (!BLOCK_HASH_REGEX.test(req.params.hash)) {
handleError(req, res, 501, `Invalid block hash`);
return;
}
try { try {
const blockHeader = await bitcoinApi.$getBlockHeader(req.params.hash); const blockHeader = await bitcoinApi.$getBlockHeader(req.params.hash);
res.setHeader('content-type', 'text/plain'); res.setHeader('content-type', 'text/plain');
res.send(blockHeader); res.send(blockHeader);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get block header');
} }
} }
private async getBlockAuditSummary(req: Request, res: Response) { private async getBlockAuditSummary(req: Request, res: Response) {
if (!BLOCK_HASH_REGEX.test(req.params.hash)) {
handleError(req, res, 501, `Invalid block hash`);
return;
}
try { try {
const auditSummary = await blocks.$getBlockAuditSummary(req.params.hash); const auditSummary = await blocks.$getBlockAuditSummary(req.params.hash);
if (auditSummary) { if (auditSummary) {
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString());
res.json(auditSummary); res.json(auditSummary);
} else { } else {
handleError(req, res, 404, `audit not available`); handleError(req, res, 404, `Audit not available`);
return; return;
} }
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get block audit summary');
} }
} }
private async $getBlockTxAuditSummary(req: Request, res: Response) { private async $getBlockTxAuditSummary(req: Request, res: Response) {
if (!BLOCK_HASH_REGEX.test(req.params.hash)) {
handleError(req, res, 501, `Invalid block hash`);
return;
}
if (!TXID_REGEX.test(req.params.txid)) {
handleError(req, res, 501, `Invalid transaction ID`);
return;
}
try { try {
const auditSummary = await blocks.$getBlockTxAuditSummary(req.params.hash, req.params.txid); const auditSummary = await blocks.$getBlockTxAuditSummary(req.params.hash, req.params.txid);
if (auditSummary) { if (auditSummary) {
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString());
res.json(auditSummary); res.json(auditSummary);
} else { } else {
handleError(req, res, 404, `transaction audit not available`); handleError(req, res, 404, `Transaction audit not available`);
return; return;
} }
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get transaction audit summary');
} }
} }
@ -408,7 +470,7 @@ class BitcoinRoutes {
return await this.getLegacyBlocks(req, res); return await this.getLegacyBlocks(req, res);
} }
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get blocks');
} }
} }
@ -450,7 +512,7 @@ class BitcoinRoutes {
res.json(await blocks.$getBlocksBetweenHeight(from, to)); res.json(await blocks.$getBlocksBetweenHeight(from, to));
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get blocks');
} }
} }
@ -485,11 +547,15 @@ class BitcoinRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(returnBlocks); res.json(returnBlocks);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get blocks');
} }
} }
private async getBlockTransactions(req: Request, res: Response) { private async getBlockTransactions(req: Request, res: Response) {
if (!BLOCK_HASH_REGEX.test(req.params.hash)) {
handleError(req, res, 501, `Invalid block hash`);
return;
}
try { try {
loadingIndicators.setProgress('blocktxs-' + req.params.hash, 0); loadingIndicators.setProgress('blocktxs-' + req.params.hash, 0);
@ -510,7 +576,7 @@ class BitcoinRoutes {
res.json(transactions); res.json(transactions);
} catch (e) { } catch (e) {
loadingIndicators.setProgress('blocktxs-' + req.params.hash, 100); loadingIndicators.setProgress('blocktxs-' + req.params.hash, 100);
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get block transactions');
} }
} }
@ -519,7 +585,7 @@ class BitcoinRoutes {
const blockHash = await bitcoinApi.$getBlockHash(parseInt(req.params.height, 10)); const blockHash = await bitcoinApi.$getBlockHash(parseInt(req.params.height, 10));
res.send(blockHash); res.send(blockHash);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get block at height');
} }
} }
@ -528,16 +594,20 @@ class BitcoinRoutes {
handleError(req, res, 405, 'Address lookups cannot be used with bitcoind as backend.'); handleError(req, res, 405, 'Address lookups cannot be used with bitcoind as backend.');
return; return;
} }
if (!ADDRESS_REGEX.test(req.params.address)) {
handleError(req, res, 501, `Invalid address`);
return;
}
try { try {
const addressData = await bitcoinApi.$getAddress(req.params.address); const addressData = await bitcoinApi.$getAddress(req.params.address);
res.json(addressData); res.json(addressData);
} catch (e) { } catch (e) {
if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) { if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) {
handleError(req, res, 413, e instanceof Error ? e.message : e); handleError(req, res, 413, e.message);
return; return;
} }
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get address');
} }
} }
@ -546,6 +616,10 @@ class BitcoinRoutes {
handleError(req, res, 405, 'Address lookups cannot be used with bitcoind as backend.'); handleError(req, res, 405, 'Address lookups cannot be used with bitcoind as backend.');
return; return;
} }
if (!ADDRESS_REGEX.test(req.params.address)) {
handleError(req, res, 501, `Invalid address`);
return;
}
try { try {
let lastTxId: string = ''; let lastTxId: string = '';
@ -556,10 +630,10 @@ class BitcoinRoutes {
res.json(transactions); res.json(transactions);
} catch (e) { } catch (e) {
if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) { if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) {
handleError(req, res, 413, e instanceof Error ? e.message : e); handleError(req, res, 413, e.message);
return; return;
} }
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get address transactions');
} }
} }
@ -575,6 +649,10 @@ class BitcoinRoutes {
handleError(req, res, 405, 'Address lookups cannot be used with bitcoind as backend.'); handleError(req, res, 405, 'Address lookups cannot be used with bitcoind as backend.');
return; return;
} }
if (!SCRIPT_HASH_REGEX.test(req.params.scripthash)) {
handleError(req, res, 501, `Invalid scripthash`);
return;
}
try { try {
// electrum expects scripthashes in little-endian // electrum expects scripthashes in little-endian
@ -583,10 +661,10 @@ class BitcoinRoutes {
res.json(addressData); res.json(addressData);
} catch (e) { } catch (e) {
if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) { if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) {
handleError(req, res, 413, e instanceof Error ? e.message : e); handleError(req, res, 413, e.message);
return; return;
} }
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get script hash');
} }
} }
@ -595,6 +673,10 @@ class BitcoinRoutes {
handleError(req, res, 405, 'Address lookups cannot be used with bitcoind as backend.'); handleError(req, res, 405, 'Address lookups cannot be used with bitcoind as backend.');
return; return;
} }
if (!SCRIPT_HASH_REGEX.test(req.params.scripthash)) {
handleError(req, res, 501, `Invalid scripthash`);
return;
}
try { try {
// electrum expects scripthashes in little-endian // electrum expects scripthashes in little-endian
@ -607,10 +689,10 @@ class BitcoinRoutes {
res.json(transactions); res.json(transactions);
} catch (e) { } catch (e) {
if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) { if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) {
handleError(req, res, 413, e instanceof Error ? e.message : e); handleError(req, res, 413, e.message);
return; return;
} }
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get script hash transactions');
} }
} }
@ -623,10 +705,10 @@ class BitcoinRoutes {
private async getAddressPrefix(req: Request, res: Response) { private async getAddressPrefix(req: Request, res: Response) {
try { try {
const blockHash = await bitcoinApi.$getAddressPrefix(req.params.prefix); const addressPrefix = await bitcoinApi.$getAddressPrefix(req.params.prefix);
res.send(blockHash); res.send(addressPrefix);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get address prefix');
} }
} }
@ -667,7 +749,7 @@ class BitcoinRoutes {
res.setHeader('content-type', 'text/plain'); res.setHeader('content-type', 'text/plain');
res.send(result.toString()); res.send(result.toString());
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get height at tip');
} }
} }
@ -677,39 +759,55 @@ class BitcoinRoutes {
res.setHeader('content-type', 'text/plain'); res.setHeader('content-type', 'text/plain');
res.send(result); res.send(result);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get hash at tip');
} }
} }
private async getRawBlock(req: Request, res: Response) { private async getRawBlock(req: Request, res: Response) {
if (!BLOCK_HASH_REGEX.test(req.params.hash)) {
handleError(req, res, 501, `Invalid block hash`);
return;
}
try { try {
const result = await bitcoinApi.$getRawBlock(req.params.hash); const result = await bitcoinApi.$getRawBlock(req.params.hash);
res.setHeader('content-type', 'application/octet-stream'); res.setHeader('content-type', 'application/octet-stream');
res.send(result); res.send(result);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get raw block');
} }
} }
private async getTxIdsForBlock(req: Request, res: Response) { private async getTxIdsForBlock(req: Request, res: Response) {
if (!BLOCK_HASH_REGEX.test(req.params.hash)) {
handleError(req, res, 501, `Invalid block hash`);
return;
}
try { try {
const result = await bitcoinApi.$getTxIdsForBlock(req.params.hash); const result = await bitcoinApi.$getTxIdsForBlock(req.params.hash);
res.json(result); res.json(result);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get txids for block');
} }
} }
private async validateAddress(req: Request, res: Response) { private async validateAddress(req: Request, res: Response) {
if (!ADDRESS_REGEX.test(req.params.address)) {
handleError(req, res, 501, `Invalid address`);
return;
}
try { try {
const result = await bitcoinClient.validateAddress(req.params.address); const result = await bitcoinClient.validateAddress(req.params.address);
res.json(result); res.json(result);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to validate address');
} }
} }
private async getRbfHistory(req: Request, res: Response) { private async getRbfHistory(req: Request, res: Response) {
if (!TXID_REGEX.test(req.params.txId)) {
handleError(req, res, 501, `Invalid transaction ID`);
return;
}
try { try {
const replacements = rbfCache.getRbfTree(req.params.txId) || null; const replacements = rbfCache.getRbfTree(req.params.txId) || null;
const replaces = rbfCache.getReplaces(req.params.txId) || null; const replaces = rbfCache.getReplaces(req.params.txId) || null;
@ -718,7 +816,7 @@ class BitcoinRoutes {
replaces replaces
}); });
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get rbf history');
} }
} }
@ -727,7 +825,7 @@ class BitcoinRoutes {
const result = rbfCache.getRbfTrees(false); const result = rbfCache.getRbfTrees(false);
res.json(result); res.json(result);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get rbf trees');
} }
} }
@ -736,11 +834,15 @@ class BitcoinRoutes {
const result = rbfCache.getRbfTrees(true); const result = rbfCache.getRbfTrees(true);
res.json(result); res.json(result);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get full rbf replacements');
} }
} }
private async getCachedTx(req: Request, res: Response) { private async getCachedTx(req: Request, res: Response) {
if (!TXID_REGEX.test(req.params.txId)) {
handleError(req, res, 501, `Invalid transaction ID`);
return;
}
try { try {
const result = rbfCache.getTx(req.params.txId); const result = rbfCache.getTx(req.params.txId);
if (result) { if (result) {
@ -749,16 +851,20 @@ class BitcoinRoutes {
res.status(204).send(); res.status(204).send();
} }
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get cached tx');
} }
} }
private async getTransactionOutspends(req: Request, res: Response) { private async getTransactionOutspends(req: Request, res: Response) {
if (!TXID_REGEX.test(req.params.txId)) {
handleError(req, res, 501, `Invalid transaction ID`);
return;
}
try { try {
const result = await bitcoinApi.$getOutspends(req.params.txId); const result = await bitcoinApi.$getOutspends(req.params.txId);
res.json(result); res.json(result);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get transaction outspends');
} }
} }
@ -771,7 +877,7 @@ class BitcoinRoutes {
handleError(req, res, 503, `Service Temporarily Unavailable`); handleError(req, res, 503, `Service Temporarily Unavailable`);
} }
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get difficulty change');
} }
} }
@ -782,8 +888,8 @@ class BitcoinRoutes {
const txIdResult = await bitcoinApi.$sendRawTransaction(rawTx); const txIdResult = await bitcoinApi.$sendRawTransaction(rawTx);
res.send(txIdResult); res.send(txIdResult);
} catch (e: any) { } catch (e: any) {
handleError(req, res, 400, e.message && e.code ? 'sendrawtransaction RPC error: ' + JSON.stringify({ code: e.code, message: e.message }) handleError(req, res, 400, (e.message && e.code) ? 'sendrawtransaction RPC error: ' + JSON.stringify({ code: e.code })
: (e.message || 'Error')); : 'Failed to send raw transaction');
} }
} }
@ -794,8 +900,8 @@ class BitcoinRoutes {
const txIdResult = await bitcoinClient.sendRawTransaction(txHex); const txIdResult = await bitcoinClient.sendRawTransaction(txHex);
res.send(txIdResult); res.send(txIdResult);
} catch (e: any) { } catch (e: any) {
handleError(req, res, 400, e.message && e.code ? 'sendrawtransaction RPC error: ' + JSON.stringify({ code: e.code, message: e.message }) handleError(req, res, 400, (e.message && e.code) ? 'sendrawtransaction RPC error: ' + JSON.stringify({ code: e.code })
: (e.message || 'Error')); : 'Failed to send raw transaction');
} }
} }
@ -806,8 +912,8 @@ class BitcoinRoutes {
const result = await bitcoinApi.$testMempoolAccept(rawTxs, maxfeerate); const result = await bitcoinApi.$testMempoolAccept(rawTxs, maxfeerate);
res.send(result); res.send(result);
} catch (e: any) { } catch (e: any) {
handleError(req, res, 400, e.message && e.code ? 'testmempoolaccept RPC error: ' + JSON.stringify({ code: e.code, message: e.message }) handleError(req, res, 400, (e.message && e.code) ? 'testmempoolaccept RPC error: ' + JSON.stringify({ code: e.code })
: (e.message || 'Error')); : 'Failed to test transactions');
} }
} }
@ -819,8 +925,8 @@ class BitcoinRoutes {
const result = await bitcoinClient.submitPackage(rawTxs, maxfeerate ?? undefined, maxburnamount ?? undefined); const result = await bitcoinClient.submitPackage(rawTxs, maxfeerate ?? undefined, maxburnamount ?? undefined);
res.send(result); res.send(result);
} catch (e: any) { } catch (e: any) {
handleError(req, res, 400, e.message && e.code ? 'submitpackage RPC error: ' + JSON.stringify({ code: e.code, message: e.message }) handleError(req, res, 400, (e.message && e.code) ? 'submitpackage RPC error: ' + JSON.stringify({ code: e.code })
: (e.message || 'Error')); : 'Failed to submit package');
} }
} }

View file

@ -1,12 +1,12 @@
import config from '../../config'; import config from '../../config';
import axios, { AxiosResponse, isAxiosError } from 'axios'; import axios, { isAxiosError } from 'axios';
import http from 'http'; import http from 'http';
import { AbstractBitcoinApi, HealthCheckHost } from './bitcoin-api-abstract-factory'; import { AbstractBitcoinApi, HealthCheckHost } from './bitcoin-api-abstract-factory';
import { IEsploraApi } from './esplora-api.interface'; import { IEsploraApi } from './esplora-api.interface';
import logger from '../../logger'; import logger from '../../logger';
import { Common } from '../common'; import { Common } from '../common';
import { SubmitPackageResult, TestMempoolAcceptResult } from './bitcoin-api.interface'; import { SubmitPackageResult, TestMempoolAcceptResult } from './bitcoin-api.interface';
import os from 'os';
interface FailoverHost { interface FailoverHost {
host: string, host: string,
rtts: number[], rtts: number[],
@ -20,6 +20,13 @@ interface FailoverHost {
preferred?: boolean, preferred?: boolean,
checked: boolean, checked: boolean,
lastChecked?: number, lastChecked?: number,
publicDomain: string,
hashes: {
frontend?: string,
backend?: string,
electrs?: string,
lastUpdated: number,
}
} }
class FailoverRouter { class FailoverRouter {
@ -29,14 +36,21 @@ class FailoverRouter {
maxHeight: number = 0; maxHeight: number = 0;
hosts: FailoverHost[]; hosts: FailoverHost[];
multihost: boolean; multihost: boolean;
pollInterval: number = 60000; gitHashInterval: number = 600000; // 10 minutes
pollInterval: number = 60000; // 1 minute
pollTimer: NodeJS.Timeout | null = null; pollTimer: NodeJS.Timeout | null = null;
pollConnection = axios.create(); pollConnection = axios.create();
localHostname: string = 'localhost';
requestConnection = axios.create({ requestConnection = axios.create({
httpAgent: new http.Agent({ keepAlive: true }) httpAgent: new http.Agent({ keepAlive: true })
}); });
constructor() { constructor() {
try {
this.localHostname = os.hostname();
} catch (e) {
logger.warn('Failed to set local hostname, using "localhost"');
}
// setup list of hosts // setup list of hosts
this.hosts = (config.ESPLORA.FALLBACK || []).map(domain => { this.hosts = (config.ESPLORA.FALLBACK || []).map(domain => {
return { return {
@ -45,6 +59,10 @@ class FailoverRouter {
rtts: [], rtts: [],
rtt: Infinity, rtt: Infinity,
failures: 0, failures: 0,
publicDomain: 'https://' + this.extractPublicDomain(domain),
hashes: {
lastUpdated: 0,
},
}; };
}); });
this.activeHost = { this.activeHost = {
@ -55,6 +73,10 @@ class FailoverRouter {
socket: !!config.ESPLORA.UNIX_SOCKET_PATH, socket: !!config.ESPLORA.UNIX_SOCKET_PATH,
preferred: true, preferred: true,
checked: false, checked: false,
publicDomain: `http://${this.localHostname}`,
hashes: {
lastUpdated: 0,
},
}; };
this.fallbackHost = this.activeHost; this.fallbackHost = this.activeHost;
this.hosts.unshift(this.activeHost); this.hosts.unshift(this.activeHost);
@ -106,6 +128,24 @@ class FailoverRouter {
host.outOfSync = false; host.outOfSync = false;
} }
host.unreachable = false; host.unreachable = false;
// update esplora git hash using the x-powered-by header from the height check
const poweredBy = result.headers['x-powered-by'];
if (poweredBy) {
const match = poweredBy.match(/([a-fA-F0-9]{5,40})/);
if (match && match[1]?.length) {
host.hashes.electrs = match[1];
}
}
// Check front and backend git hashes less often
if (Date.now() - host.hashes.lastUpdated > this.gitHashInterval) {
await Promise.all([
this.$updateFrontendGitHash(host),
this.$updateBackendGitHash(host)
]);
host.hashes.lastUpdated = Date.now();
}
} else { } else {
host.outOfSync = true; host.outOfSync = true;
host.unreachable = true; host.unreachable = true;
@ -202,6 +242,47 @@ class FailoverRouter {
} }
} }
// methods for retrieving git hashes by host
private async $updateFrontendGitHash(host: FailoverHost): Promise<void> {
try {
const url = `${host.publicDomain}/resources/config.js`;
const response = await this.pollConnection.get<string>(url, { timeout: config.ESPLORA.FALLBACK_TIMEOUT });
const match = response.data.match(/GIT_COMMIT_HASH\s*=\s*['"](.*?)['"]/);
if (match && match[1]?.length) {
host.hashes.frontend = match[1];
}
} catch (e) {
// failed to get frontend build hash - do nothing
}
}
private async $updateBackendGitHash(host: FailoverHost): Promise<void> {
try {
const url = `${host.publicDomain}/api/v1/backend-info`;
const response = await this.pollConnection.get<any>(url, { timeout: config.ESPLORA.FALLBACK_TIMEOUT });
if (response.data?.gitCommit) {
host.hashes.backend = response.data.gitCommit;
}
} catch (e) {
// failed to get backend build hash - do nothing
}
}
// returns the public mempool domain corresponding to an esplora server url
// (a bit of a hack to avoid manually specifying frontend & backend URLs for each esplora server)
private extractPublicDomain(url: string): string {
// force the url to start with a valid protocol
const urlWithProtocol = url.startsWith('http') ? url : `https://${url}`;
// parse as URL and extract the hostname
try {
const parsed = new URL(urlWithProtocol);
return parsed.hostname;
} catch (e) {
// fallback to the original url
return url;
}
}
private async $query<T>(method: 'get'| 'post', path, data: any, responseType = 'json', host = this.activeHost, retry: boolean = true): Promise<T> { private async $query<T>(method: 'get'| 'post', path, data: any, responseType = 'json', host = this.activeHost, retry: boolean = true): Promise<T> {
let axiosConfig; let axiosConfig;
let url; let url;
@ -381,6 +462,7 @@ class ElectrsApi implements AbstractBitcoinApi {
unreachable: !!host.unreachable, unreachable: !!host.unreachable,
checked: !!host.checked, checked: !!host.checked,
lastChecked: host.lastChecked || 0, lastChecked: host.lastChecked || 0,
hashes: host.hashes,
})); }));
} else { } else {
return []; return [];

View file

@ -7,7 +7,7 @@ import cpfpRepository from '../repositories/CpfpRepository';
import { RowDataPacket } from 'mysql2'; import { RowDataPacket } from 'mysql2';
class DatabaseMigration { class DatabaseMigration {
private static currentVersion = 83; private static currentVersion = 94;
private queryTimeout = 3600_000; private queryTimeout = 3600_000;
private statisticsAddedIndexed = false; private statisticsAddedIndexed = false;
private uniqueLogs: string[] = []; private uniqueLogs: string[] = [];
@ -710,6 +710,414 @@ class DatabaseMigration {
await this.$executeQuery('ALTER TABLE `blocks` ADD first_seen datetime(6) DEFAULT NULL'); await this.$executeQuery('ALTER TABLE `blocks` ADD first_seen datetime(6) DEFAULT NULL');
await this.updateToSchemaVersion(83); await this.updateToSchemaVersion(83);
} }
// add new pools indexes
if (databaseSchemaVersion < 84 && isBitcoin === true) {
await this.$executeQuery(`
ALTER TABLE \`pools\`
ADD INDEX \`slug\` (\`slug\`),
ADD INDEX \`unique_id\` (\`unique_id\`)
`);
await this.updateToSchemaVersion(84);
}
// lightning channels indexes
if (databaseSchemaVersion < 85 && isBitcoin === true) {
await this.$executeQuery(`
ALTER TABLE \`channels\`
ADD INDEX \`created\` (\`created\`),
ADD INDEX \`capacity\` (\`capacity\`),
ADD INDEX \`closing_reason\` (\`closing_reason\`),
ADD INDEX \`closing_resolved\` (\`closing_resolved\`)
`);
await this.updateToSchemaVersion(85);
}
// lightning nodes indexes
if (databaseSchemaVersion < 86 && isBitcoin === true) {
await this.$executeQuery(`
ALTER TABLE \`nodes\`
ADD INDEX \`status\` (\`status\`),
ADD INDEX \`channels\` (\`channels\`),
ADD INDEX \`country_id\` (\`country_id\`),
ADD INDEX \`as_number\` (\`as_number\`),
ADD INDEX \`first_seen\` (\`first_seen\`)
`);
await this.updateToSchemaVersion(86);
}
// lightning node sockets indexes
if (databaseSchemaVersion < 87 && isBitcoin === true) {
await this.$executeQuery('ALTER TABLE `nodes_sockets` ADD INDEX `type` (`type`)');
await this.updateToSchemaVersion(87);
}
// lightning stats indexes
if (databaseSchemaVersion < 88 && isBitcoin === true) {
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD INDEX `added` (`added`)');
await this.updateToSchemaVersion(88);
}
// geo names indexes
if (databaseSchemaVersion < 89 && isBitcoin === true) {
await this.$executeQuery('ALTER TABLE `geo_names` ADD INDEX `names` (`names`)');
await this.updateToSchemaVersion(89);
}
// hashrates indexes
if (databaseSchemaVersion < 90 && isBitcoin === true) {
await this.$executeQuery('ALTER TABLE `hashrates` ADD INDEX `type` (`type`)');
await this.updateToSchemaVersion(90);
}
// block audits indexes
if (databaseSchemaVersion < 91 && isBitcoin === true) {
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD INDEX `time` (`time`)');
await this.updateToSchemaVersion(91);
}
// elements_pegs indexes
if (databaseSchemaVersion < 92 && config.MEMPOOL.NETWORK === 'liquid') {
await this.$executeQuery(`
ALTER TABLE \`elements_pegs\`
ADD INDEX \`block\` (\`block\`),
ADD INDEX \`datetime\` (\`datetime\`),
ADD INDEX \`amount\` (\`amount\`),
ADD INDEX \`bitcoinaddress\` (\`bitcoinaddress\`),
ADD INDEX \`bitcointxid\` (\`bitcointxid\`)
`);
await this.updateToSchemaVersion(92);
}
// federation_txos indexes
if (databaseSchemaVersion < 93 && config.MEMPOOL.NETWORK === 'liquid') {
await this.$executeQuery(`
ALTER TABLE \`federation_txos\`
ADD INDEX \`unspent\` (\`unspent\`),
ADD INDEX \`lastblockupdate\` (\`lastblockupdate\`),
ADD INDEX \`blocktime\` (\`blocktime\`),
ADD INDEX \`emergencyKey\` (\`emergencyKey\`),
ADD INDEX \`expiredAt\` (\`expiredAt\`)
`);
await this.updateToSchemaVersion(93);
}
// Unify database schema for all mempool netwoks
// versions above 94 should not use network-specific flags
if (databaseSchemaVersion < 94) {
if (!isBitcoin) {
// Apply all the bitcoin specific migrations to non-bitcoin networks: liquid, liquidtestnet and testnet4 (!)
// Version 5
await this.$executeQuery('ALTER TABLE blocks ADD `reward` double unsigned NOT NULL DEFAULT "0"');
// Version 6
await this.$executeQuery('ALTER TABLE blocks MODIFY `height` integer unsigned NOT NULL DEFAULT "0"');
await this.$executeQuery('ALTER TABLE blocks MODIFY `tx_count` smallint unsigned NOT NULL DEFAULT "0"');
await this.$executeQuery('ALTER TABLE blocks MODIFY `size` integer unsigned NOT NULL DEFAULT "0"');
await this.$executeQuery('ALTER TABLE blocks MODIFY `weight` integer unsigned NOT NULL DEFAULT "0"');
await this.$executeQuery('ALTER TABLE blocks MODIFY `difficulty` double NOT NULL DEFAULT "0"');
await this.$executeQuery('ALTER TABLE blocks DROP FOREIGN KEY IF EXISTS `blocks_ibfk_1`');
await this.$executeQuery('ALTER TABLE pools MODIFY `id` smallint unsigned AUTO_INCREMENT');
await this.$executeQuery('ALTER TABLE blocks MODIFY `pool_id` smallint unsigned NULL');
await this.$executeQuery('ALTER TABLE blocks ADD FOREIGN KEY (`pool_id`) REFERENCES `pools` (`id`)');
await this.$executeQuery('ALTER TABLE blocks ADD `version` integer unsigned NOT NULL DEFAULT "0"');
await this.$executeQuery('ALTER TABLE blocks ADD `bits` integer unsigned NOT NULL DEFAULT "0"');
await this.$executeQuery('ALTER TABLE blocks ADD `nonce` bigint unsigned NOT NULL DEFAULT "0"');
await this.$executeQuery('ALTER TABLE blocks ADD `merkle_root` varchar(65) NOT NULL DEFAULT ""');
await this.$executeQuery('ALTER TABLE blocks ADD `previous_block_hash` varchar(65) NULL');
// Version 7
await this.$executeQuery('DROP table IF EXISTS hashrates;');
await this.$executeQuery(this.getCreateDailyStatsTableQuery(), await this.$checkIfTableExists('hashrates'));
// Version 8
await this.$executeQuery('ALTER TABLE `hashrates` DROP INDEX `PRIMARY`');
await this.$executeQuery('ALTER TABLE `hashrates` ADD `id` int NOT NULL AUTO_INCREMENT PRIMARY KEY FIRST');
await this.$executeQuery('ALTER TABLE `hashrates` ADD `share` float NOT NULL DEFAULT "0"');
await this.$executeQuery('ALTER TABLE `hashrates` ADD `type` enum("daily", "weekly") DEFAULT "daily"');
// Version 9
await this.$executeQuery('ALTER TABLE `state` CHANGE `name` `name` varchar(100)');
await this.$executeQuery('ALTER TABLE `hashrates` ADD UNIQUE `hashrate_timestamp_pool_id` (`hashrate_timestamp`, `pool_id`)');
// Version 10
await this.$executeQuery('ALTER TABLE `blocks` ADD INDEX `blockTimestamp` (`blockTimestamp`)');
// Version 11
await this.$executeQuery(`ALTER TABLE blocks
ADD avg_fee INT UNSIGNED NULL,
ADD avg_fee_rate INT UNSIGNED NULL
`);
await this.$executeQuery('ALTER TABLE blocks MODIFY `reward` BIGINT UNSIGNED NOT NULL DEFAULT "0"');
await this.$executeQuery('ALTER TABLE blocks MODIFY `median_fee` INT UNSIGNED NOT NULL DEFAULT "0"');
await this.$executeQuery('ALTER TABLE blocks MODIFY `fees` INT UNSIGNED NOT NULL DEFAULT "0"');
// Version 12
await this.$executeQuery('ALTER TABLE blocks MODIFY `fees` BIGINT UNSIGNED NOT NULL DEFAULT "0"');
// Version 13
await this.$executeQuery('ALTER TABLE blocks MODIFY `difficulty` DOUBLE UNSIGNED NOT NULL DEFAULT "0"');
await this.$executeQuery('ALTER TABLE blocks MODIFY `median_fee` BIGINT UNSIGNED NOT NULL DEFAULT "0"');
await this.$executeQuery('ALTER TABLE blocks MODIFY `avg_fee` BIGINT UNSIGNED NOT NULL DEFAULT "0"');
await this.$executeQuery('ALTER TABLE blocks MODIFY `avg_fee_rate` BIGINT UNSIGNED NOT NULL DEFAULT "0"');
// Version 14
await this.$executeQuery('ALTER TABLE `hashrates` DROP FOREIGN KEY `hashrates_ibfk_1`');
await this.$executeQuery('ALTER TABLE `hashrates` MODIFY `pool_id` SMALLINT UNSIGNED NOT NULL DEFAULT "0"');
// Version 17
await this.$executeQuery('ALTER TABLE `pools` ADD `slug` CHAR(50) NULL');
// Version 18
await this.$executeQuery('ALTER TABLE `blocks` ADD INDEX `hash` (`hash`);');
// Version 20
await this.$executeQuery(this.getCreateBlocksSummariesTableQuery(), await this.$checkIfTableExists('blocks_summaries'));
// Version 22
await this.$executeQuery('DROP TABLE IF EXISTS `difficulty_adjustments`');
await this.$executeQuery(this.getCreateDifficultyAdjustmentsTableQuery(), await this.$checkIfTableExists('difficulty_adjustments'));
// Version 24
await this.$executeQuery('DROP TABLE IF EXISTS `blocks_audits`');
await this.$executeQuery(this.getCreateBlocksAuditsTableQuery(), await this.$checkIfTableExists('blocks_audits'));
// Version 25
await this.$executeQuery(this.getCreateLightningStatisticsQuery(), await this.$checkIfTableExists('lightning_stats'));
await this.$executeQuery(this.getCreateNodesQuery(), await this.$checkIfTableExists('nodes'));
await this.$executeQuery(this.getCreateChannelsQuery(), await this.$checkIfTableExists('channels'));
await this.$executeQuery(this.getCreateNodesStatsQuery(), await this.$checkIfTableExists('node_stats'));
// Version 26
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD tor_nodes int(11) NOT NULL DEFAULT "0"');
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD clearnet_nodes int(11) NOT NULL DEFAULT "0"');
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD unannounced_nodes int(11) NOT NULL DEFAULT "0"');
// Version 27
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD avg_capacity bigint(20) unsigned NOT NULL DEFAULT "0"');
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD avg_fee_rate int(11) unsigned NOT NULL DEFAULT "0"');
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD avg_base_fee_mtokens bigint(20) unsigned NOT NULL DEFAULT "0"');
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD med_capacity bigint(20) unsigned NOT NULL DEFAULT "0"');
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD med_fee_rate int(11) unsigned NOT NULL DEFAULT "0"');
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD med_base_fee_mtokens bigint(20) unsigned NOT NULL DEFAULT "0"');
// Version 28
await this.$executeQuery(`ALTER TABLE lightning_stats MODIFY added DATE`);
// Version 29
await this.$executeQuery(this.getCreateGeoNamesTableQuery(), await this.$checkIfTableExists('geo_names'));
await this.$executeQuery('ALTER TABLE `nodes` ADD as_number int(11) unsigned NULL DEFAULT NULL');
await this.$executeQuery('ALTER TABLE `nodes` ADD city_id int(11) unsigned NULL DEFAULT NULL');
await this.$executeQuery('ALTER TABLE `nodes` ADD country_id int(11) unsigned NULL DEFAULT NULL');
await this.$executeQuery('ALTER TABLE `nodes` ADD accuracy_radius int(11) unsigned NULL DEFAULT NULL');
await this.$executeQuery('ALTER TABLE `nodes` ADD subdivision_id int(11) unsigned NULL DEFAULT NULL');
await this.$executeQuery('ALTER TABLE `nodes` ADD longitude double NULL DEFAULT NULL');
await this.$executeQuery('ALTER TABLE `nodes` ADD latitude double NULL DEFAULT NULL');
// Version 30
await this.$executeQuery('ALTER TABLE `geo_names` CHANGE `type` `type` enum("city","country","division","continent","as_organization") NOT NULL');
// Version 31
await this.$executeQuery('ALTER TABLE `prices` ADD `id` int NULL AUTO_INCREMENT UNIQUE');
await this.$executeQuery('DROP TABLE IF EXISTS `blocks_prices`');
await this.$executeQuery(this.getCreateBlocksPricesTableQuery(), await this.$checkIfTableExists('blocks_prices'));
// Version 32
await this.$executeQuery('ALTER TABLE `blocks_summaries` ADD `template` JSON DEFAULT "[]"');
// Version 33
await this.$executeQuery('ALTER TABLE `geo_names` CHANGE `type` `type` enum("city","country","division","continent","as_organization", "country_iso_code") NOT NULL');
// Version 34
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD clearnet_tor_nodes int(11) NOT NULL DEFAULT "0"');
// Version 35
await this.$executeQuery('DELETE from `lightning_stats` WHERE added > "2021-09-19"');
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD CONSTRAINT added_unique UNIQUE (added);');
// Version 36
await this.$executeQuery('ALTER TABLE `nodes` ADD status TINYINT NOT NULL DEFAULT "1"');
// Version 37
await this.$executeQuery(this.getCreateLNNodesSocketsTableQuery(), await this.$checkIfTableExists('nodes_sockets'));
// Version 38
await this.$executeQuery(`TRUNCATE lightning_stats`);
await this.$executeQuery(`TRUNCATE node_stats`);
await this.$executeQuery('ALTER TABLE `lightning_stats` CHANGE `added` `added` timestamp NULL');
await this.$executeQuery('ALTER TABLE `node_stats` CHANGE `added` `added` timestamp NULL');
await this.updateToSchemaVersion(38);
// Version 39
await this.$executeQuery('ALTER TABLE `nodes` ADD alias_search TEXT NULL DEFAULT NULL AFTER `alias`');
await this.$executeQuery('ALTER TABLE nodes ADD FULLTEXT(alias_search)');
// Version 40
await this.$executeQuery('ALTER TABLE `nodes` ADD capacity bigint(20) unsigned DEFAULT NULL');
await this.$executeQuery('ALTER TABLE `nodes` ADD channels int(11) unsigned DEFAULT NULL');
await this.$executeQuery('ALTER TABLE `nodes` ADD INDEX `capacity` (`capacity`);');
// Version 41
await this.$executeQuery('UPDATE channels SET closing_reason = NULL WHERE closing_reason = 1');
// Version 42
await this.$executeQuery('ALTER TABLE `channels` ADD closing_resolved tinyint(1) DEFAULT 0');
// Version 43
await this.$executeQuery(this.getCreateLNNodeRecordsTableQuery(), await this.$checkIfTableExists('nodes_records'));
// Version 44
await this.$executeQuery('UPDATE blocks_summaries SET template = NULL');
// Version 45
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD fresh_txs JSON DEFAULT "[]"');
// Version 48
await this.$executeQuery('ALTER TABLE `channels` ADD source_checked tinyint(1) DEFAULT 0');
await this.$executeQuery('ALTER TABLE `channels` ADD closing_fee bigint(20) unsigned DEFAULT 0');
await this.$executeQuery('ALTER TABLE `channels` ADD node1_funding_balance bigint(20) unsigned DEFAULT 0');
await this.$executeQuery('ALTER TABLE `channels` ADD node2_funding_balance bigint(20) unsigned DEFAULT 0');
await this.$executeQuery('ALTER TABLE `channels` ADD node1_closing_balance bigint(20) unsigned DEFAULT 0');
await this.$executeQuery('ALTER TABLE `channels` ADD node2_closing_balance bigint(20) unsigned DEFAULT 0');
await this.$executeQuery('ALTER TABLE `channels` ADD funding_ratio float unsigned DEFAULT NULL');
await this.$executeQuery('ALTER TABLE `channels` ADD closed_by varchar(66) DEFAULT NULL');
await this.$executeQuery('ALTER TABLE `channels` ADD single_funded tinyint(1) DEFAULT 0');
await this.$executeQuery('ALTER TABLE `channels` ADD outputs JSON DEFAULT "[]"');
// Version 57
await this.$executeQuery(`ALTER TABLE nodes MODIFY updated_at datetime NULL`);
// Version 60
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD sigop_txs JSON DEFAULT "[]"');
// Version 61
if (! await this.$checkIfTableExists('blocks_templates')) {
await this.$executeQuery('CREATE TABLE blocks_templates AS SELECT id, template FROM blocks_summaries WHERE template != "[]"');
}
await this.$executeQuery('ALTER TABLE blocks_templates MODIFY template JSON DEFAULT "[]"');
await this.$executeQuery('ALTER TABLE blocks_templates ADD PRIMARY KEY (id)');
await this.$executeQuery('ALTER TABLE blocks_summaries DROP COLUMN template');
// Version 62
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD expected_fees BIGINT UNSIGNED DEFAULT NULL');
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD expected_weight BIGINT UNSIGNED DEFAULT NULL');
// Version 63
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD fullrbf_txs JSON DEFAULT "[]"');
// Version 64
await this.$executeQuery('ALTER TABLE `nodes` ADD features text NULL');
// Version 65
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD accelerated_txs JSON DEFAULT "[]"');
// Version 67
await this.$executeQuery('ALTER TABLE `blocks_summaries` ADD version INT NOT NULL DEFAULT 0');
await this.$executeQuery('ALTER TABLE `blocks_summaries` ADD INDEX `version` (`version`)');
await this.$executeQuery('ALTER TABLE `blocks_templates` ADD version INT NOT NULL DEFAULT 0');
await this.$executeQuery('ALTER TABLE `blocks_templates` ADD INDEX `version` (`version`)');
// Version 76
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD prioritized_txs JSON DEFAULT "[]"');
// Version 81
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD version INT NOT NULL DEFAULT 0');
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD INDEX `version` (`version`)');
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD unseen_txs JSON DEFAULT "[]"');
// Version 83
await this.$executeQuery('ALTER TABLE `blocks` ADD first_seen datetime(6) DEFAULT NULL');
// Version 84
await this.$executeQuery(`
ALTER TABLE \`pools\`
ADD INDEX \`slug\` (\`slug\`),
ADD INDEX \`unique_id\` (\`unique_id\`)
`);
// Version 85
await this.$executeQuery(`
ALTER TABLE \`channels\`
ADD INDEX \`created\` (\`created\`),
ADD INDEX \`capacity\` (\`capacity\`),
ADD INDEX \`closing_reason\` (\`closing_reason\`),
ADD INDEX \`closing_resolved\` (\`closing_resolved\`)
`);
// Version 86
await this.$executeQuery(`
ALTER TABLE \`nodes\`
ADD INDEX \`status\` (\`status\`),
ADD INDEX \`channels\` (\`channels\`),
ADD INDEX \`country_id\` (\`country_id\`),
ADD INDEX \`as_number\` (\`as_number\`),
ADD INDEX \`first_seen\` (\`first_seen\`)
`);
// Version 87
await this.$executeQuery('ALTER TABLE `nodes_sockets` ADD INDEX `type` (`type`)');
await this.updateToSchemaVersion(87);
// Version 88
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD INDEX `added` (`added`)');
// Version 89
await this.$executeQuery('ALTER TABLE `geo_names` ADD INDEX `names` (`names`)');
// Version 90
await this.$executeQuery('ALTER TABLE `hashrates` ADD INDEX `type` (`type`)');
// Version 91
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD INDEX `time` (`time`)');
}
if (config.MEMPOOL.NETWORK !== 'liquid') {
// Apply all the liquid specific migrations to all other networks
// Version 68
await this.$executeQuery('ALTER TABLE elements_pegs ADD PRIMARY KEY (txid, txindex);');
await this.$executeQuery(this.getCreateFederationAddressesTableQuery(), await this.$checkIfTableExists('federation_addresses'));
await this.$executeQuery(this.getCreateFederationTxosTableQuery(), await this.$checkIfTableExists('federation_txos'));
// Version 71
await this.$executeQuery('ALTER TABLE `federation_txos` ADD timelock INT NOT NULL DEFAULT 0');
await this.$executeQuery('ALTER TABLE `federation_txos` ADD expiredAt INT NOT NULL DEFAULT 0');
await this.$executeQuery('ALTER TABLE `federation_txos` ADD emergencyKey TINYINT NOT NULL DEFAULT 0');
// Version 92
await this.$executeQuery(`
ALTER TABLE \`elements_pegs\`
ADD INDEX \`block\` (\`block\`),
ADD INDEX \`datetime\` (\`datetime\`),
ADD INDEX \`amount\` (\`amount\`),
ADD INDEX \`bitcoinaddress\` (\`bitcoinaddress\`),
ADD INDEX \`bitcointxid\` (\`bitcointxid\`)
`);
// Version 93
await this.$executeQuery(`
ALTER TABLE \`federation_txos\`
ADD INDEX \`unspent\` (\`unspent\`),
ADD INDEX \`lastblockupdate\` (\`lastblockupdate\`),
ADD INDEX \`blocktime\` (\`blocktime\`),
ADD INDEX \`emergencyKey\` (\`emergencyKey\`),
ADD INDEX \`expiredAt\` (\`expiredAt\`)
`);
}
if (config.MEMPOOL.NETWORK !== 'mainnet') {
// Apply all the mainnet specific migrations to all other networks
// Version 69
await this.$executeQuery(this.getCreateAccelerationsTableQuery(), await this.$checkIfTableExists('accelerations'));
// Version 70
await this.$executeQuery('ALTER TABLE accelerations MODIFY COLUMN added DATETIME;');
// Version 77
await this.$executeQuery('ALTER TABLE `accelerations` ADD requested datetime DEFAULT NULL');
}
await this.updateToSchemaVersion(94);
}
} }
/** /**

View file

@ -3,6 +3,8 @@ import { Application, Request, Response } from 'express';
import channelsApi from './channels.api'; import channelsApi from './channels.api';
import { handleError } from '../../utils/api'; import { handleError } from '../../utils/api';
const TXID_REGEX = /^[a-f0-9]{64}$/i;
class ChannelsRoutes { class ChannelsRoutes {
constructor() { } constructor() { }
@ -23,7 +25,7 @@ class ChannelsRoutes {
const channels = await channelsApi.$searchChannelsById(req.params.search); const channels = await channelsApi.$searchChannelsById(req.params.search);
res.json(channels); res.json(channels);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to search channels by id');
} }
} }
@ -39,7 +41,7 @@ class ChannelsRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(channel); res.json(channel);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get channel');
} }
} }
@ -70,7 +72,7 @@ class ChannelsRoutes {
res.header('X-Total-Count', channelsCount.toString()); res.header('X-Total-Count', channelsCount.toString());
res.json(channels); res.json(channels);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get channels for node');
} }
} }
@ -83,7 +85,10 @@ class ChannelsRoutes {
const txIds: string[] = []; const txIds: string[] = [];
for (const _txId in req.query.txId) { for (const _txId in req.query.txId) {
if (typeof req.query.txId[_txId] === 'string') { if (typeof req.query.txId[_txId] === 'string') {
txIds.push(req.query.txId[_txId].toString()); const txid = req.query.txId[_txId].toString();
if (TXID_REGEX.test(txid)) {
txIds.push(txid);
}
} }
} }
const channels = await channelsApi.$getChannelsByTransactionId(txIds); const channels = await channelsApi.$getChannelsByTransactionId(txIds);
@ -108,7 +113,7 @@ class ChannelsRoutes {
res.json(result); res.json(result);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get channels by transaction ids');
} }
} }
@ -120,7 +125,7 @@ class ChannelsRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(channels); res.json(channels);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get penalty closed channels');
} }
} }
@ -133,7 +138,7 @@ class ChannelsRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(channels); res.json(channels);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get channel geodata');
} }
} }

View file

@ -29,7 +29,7 @@ class GeneralLightningRoutes {
channels: channels, channels: channels,
}); });
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to search for nodes and channels');
} }
} }
@ -43,7 +43,7 @@ class GeneralLightningRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(statistics); res.json(statistics);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get lightning statistics');
} }
} }
@ -52,7 +52,7 @@ class GeneralLightningRoutes {
const statistics = await statisticsApi.$getLatestStatistics(); const statistics = await statisticsApi.$getLatestStatistics();
res.json(statistics); res.json(statistics);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get lightning statistics');
} }
} }
} }

View file

@ -32,7 +32,7 @@ class NodesRoutes {
const nodes = await nodesApi.$searchNodeByPublicKeyOrAlias(req.params.search); const nodes = await nodesApi.$searchNodeByPublicKeyOrAlias(req.params.search);
res.json(nodes); res.json(nodes);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to search for node');
} }
} }
@ -188,7 +188,7 @@ class NodesRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(nodes); res.json(nodes);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get node group');
} }
} }
@ -204,7 +204,7 @@ class NodesRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(node); res.json(node);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get node');
} }
} }
@ -216,7 +216,7 @@ class NodesRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(statistics); res.json(statistics);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get historical node stats');
} }
} }
@ -232,7 +232,7 @@ class NodesRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(node); res.json(node);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get fee histogram');
} }
} }
@ -248,7 +248,7 @@ class NodesRoutes {
topByChannels: topChannelsNodes, topByChannels: topChannelsNodes,
}); });
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get nodes ranking');
} }
} }
@ -260,7 +260,7 @@ class NodesRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(topCapacityNodes); res.json(topCapacityNodes);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get top nodes by capacity');
} }
} }
@ -272,7 +272,7 @@ class NodesRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(topCapacityNodes); res.json(topCapacityNodes);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get top nodes by channels');
} }
} }
@ -284,7 +284,7 @@ class NodesRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(topCapacityNodes); res.json(topCapacityNodes);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get oldest nodes');
} }
} }
@ -296,7 +296,7 @@ class NodesRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString());
res.json(nodesPerAs); res.json(nodesPerAs);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get ISP ranking');
} }
} }
@ -308,7 +308,7 @@ class NodesRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString());
res.json(worldNodes); res.json(worldNodes);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get world nodes');
} }
} }
@ -336,7 +336,7 @@ class NodesRoutes {
nodes: nodes, nodes: nodes,
}); });
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get nodes per country');
} }
} }
@ -363,7 +363,7 @@ class NodesRoutes {
nodes: nodes, nodes: nodes,
}); });
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get nodes per ISP');
} }
} }
@ -375,7 +375,7 @@ class NodesRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString());
res.json(nodesPerAs); res.json(nodesPerAs);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get nodes per country');
} }
} }
} }

View file

@ -83,7 +83,7 @@ class LiquidRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60 * 60).toUTCString());
res.json(pegs); res.json(pegs);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get pegs by month');
} }
} }
@ -95,7 +95,7 @@ class LiquidRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60 * 60).toUTCString());
res.json(reserves); res.json(reserves);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get reserves by month');
} }
} }
@ -107,7 +107,7 @@ class LiquidRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(currentSupply); res.json(currentSupply);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get pegs');
} }
} }
@ -119,7 +119,7 @@ class LiquidRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(currentReserves); res.json(currentReserves);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get reserves');
} }
} }
@ -131,7 +131,7 @@ class LiquidRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(auditStatus); res.json(auditStatus);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get federation audit status');
} }
} }
@ -143,7 +143,7 @@ class LiquidRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(federationAddresses); res.json(federationAddresses);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get federation addresses');
} }
} }
@ -155,7 +155,7 @@ class LiquidRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(federationAddresses); res.json(federationAddresses);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get federation addresses');
} }
} }
@ -167,7 +167,7 @@ class LiquidRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(federationUtxos); res.json(federationUtxos);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get federation utxos');
} }
} }
@ -179,7 +179,7 @@ class LiquidRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(expiredUtxos); res.json(expiredUtxos);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get expired utxos');
} }
} }
@ -191,7 +191,7 @@ class LiquidRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(federationUtxos); res.json(federationUtxos);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get federation utxos number');
} }
} }
@ -203,7 +203,7 @@ class LiquidRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(emergencySpentUtxos); res.json(emergencySpentUtxos);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get emergency spent utxos');
} }
} }
@ -215,7 +215,7 @@ class LiquidRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(emergencySpentUtxos); res.json(emergencySpentUtxos);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get emergency spent utxos stats');
} }
} }
@ -227,7 +227,7 @@ class LiquidRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(recentPegs); res.json(recentPegs);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get pegs list');
} }
} }
@ -239,7 +239,7 @@ class LiquidRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(pegsVolume); res.json(pegsVolume);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get pegs volume daily');
} }
} }
@ -251,7 +251,7 @@ class LiquidRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(pegsCount); res.json(pegsCount);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get pegs count');
} }
} }

View file

@ -382,7 +382,7 @@ class MempoolBlocks {
const ancestors: Ancestor[] = []; const ancestors: Ancestor[] = [];
const descendants: Ancestor[] = []; const descendants: Ancestor[] = [];
let ancestor: MempoolTransactionExtended let ancestor: MempoolTransactionExtended;
for (const cluster of clusters) { for (const cluster of clusters) {
for (const memberTxid of cluster) { for (const memberTxid of cluster) {
const mempoolTx = mempool[memberTxid]; const mempoolTx = mempool[memberTxid];
@ -462,7 +462,7 @@ class MempoolBlocks {
for (let i = 0; i < block.length; i++) { for (let i = 0; i < block.length; i++) {
const txid = block[i]; const txid = block[i];
if (txid) { if (txid in mempool) {
mempoolTx = mempool[txid]; mempoolTx = mempool[txid];
// save position in projected blocks // save position in projected blocks
mempoolTx.position = { mempoolTx.position = {
@ -481,6 +481,9 @@ class MempoolBlocks {
mempoolTx.acceleratedAt = acceleration?.added; mempoolTx.acceleratedAt = acceleration?.added;
mempoolTx.feeDelta = acceleration?.feeDelta; mempoolTx.feeDelta = acceleration?.feeDelta;
for (const ancestor of mempoolTx.ancestors || []) { for (const ancestor of mempoolTx.ancestors || []) {
if (!(ancestor.txid in mempool)) {
continue;
}
if (!mempool[ancestor.txid].acceleration) { if (!mempool[ancestor.txid].acceleration) {
mempool[ancestor.txid].cpfpDirty = true; mempool[ancestor.txid].cpfpDirty = true;
} }
@ -688,7 +691,7 @@ class MempoolBlocks {
[pool: string]: { name: string, block: number, vsize: number, accelerations: string[], complete: boolean }; [pool: string]: { name: string, block: number, vsize: number, accelerations: string[], complete: boolean };
} = {}; } = {};
// prepare a list of accelerations in ascending order (we'll pop items off the end of the list) // prepare a list of accelerations in ascending order (we'll pop items off the end of the list)
const accQueue: { acceleration: Acceleration, rate: number, vsize: number }[] = Object.values(accelerations).map(acc => { const accQueue: { acceleration: Acceleration, rate: number, vsize: number }[] = Object.values(accelerations).filter(acc => acc.txid in mempoolCache).map(acc => {
let vsize = mempoolCache[acc.txid].vsize; let vsize = mempoolCache[acc.txid].vsize;
for (const ancestor of mempoolCache[acc.txid].ancestors || []) { for (const ancestor of mempoolCache[acc.txid].ancestors || []) {
vsize += (ancestor.weight / 4); vsize += (ancestor.weight / 4);

View file

@ -72,7 +72,7 @@ class MiningRoutes {
} }
res.status(200).send(response); res.status(200).send(response);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get historical prices');
} }
} }
@ -87,7 +87,7 @@ class MiningRoutes {
if (e instanceof Error && e.message.indexOf('This mining pool does not exist') > -1) { if (e instanceof Error && e.message.indexOf('This mining pool does not exist') > -1) {
handleError(req, res, 404, e.message); handleError(req, res, 404, e.message);
} else { } else {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get pool');
} }
} }
} }
@ -106,7 +106,7 @@ class MiningRoutes {
if (e instanceof Error && e.message.indexOf('This mining pool does not exist') > -1) { if (e instanceof Error && e.message.indexOf('This mining pool does not exist') > -1) {
handleError(req, res, 404, e.message); handleError(req, res, 404, e.message);
} else { } else {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get blocks for pool');
} }
} }
} }
@ -130,7 +130,7 @@ class MiningRoutes {
res.json(pools); res.json(pools);
} }
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get pools');
} }
} }
@ -144,7 +144,7 @@ class MiningRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(stats); res.json(stats);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get pools');
} }
} }
@ -158,7 +158,7 @@ class MiningRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString());
res.json(hashrates); res.json(hashrates);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get pools historical hashrate');
} }
} }
@ -175,7 +175,7 @@ class MiningRoutes {
if (e instanceof Error && e.message.indexOf('This mining pool does not exist') > -1) { if (e instanceof Error && e.message.indexOf('This mining pool does not exist') > -1) {
handleError(req, res, 404, e.message); handleError(req, res, 404, e.message);
} else { } else {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get pool historical hashrate');
} }
} }
} }
@ -204,7 +204,7 @@ class MiningRoutes {
currentDifficulty: currentDifficulty, currentDifficulty: currentDifficulty,
}); });
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get historical hashrate');
} }
} }
@ -218,7 +218,7 @@ class MiningRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(blockFees); res.json(blockFees);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get historical block fees');
} }
} }
@ -236,7 +236,7 @@ class MiningRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(blockFees); res.json(blockFees);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get historical block fees');
} }
} }
@ -250,7 +250,7 @@ class MiningRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(blockRewards); res.json(blockRewards);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get historical block rewards');
} }
} }
@ -264,7 +264,7 @@ class MiningRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(blockFeeRates); res.json(blockFeeRates);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get historical block fee rates');
} }
} }
@ -282,7 +282,7 @@ class MiningRoutes {
weights: blockWeights weights: blockWeights
}); });
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get historical block size and weight');
} }
} }
@ -294,7 +294,7 @@ class MiningRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString());
res.json(difficulty.map(adj => [adj.time, adj.height, adj.difficulty, adj.adjustment])); res.json(difficulty.map(adj => [adj.time, adj.height, adj.difficulty, adj.adjustment]));
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get historical difficulty adjustments');
} }
} }
@ -304,7 +304,7 @@ class MiningRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(response); res.json(response);
} catch (e) { } catch (e) {
res.status(500).end(); handleError(req, res, 500, 'Failed to get reward stats');
} }
} }
@ -318,7 +318,7 @@ class MiningRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(blocksHealth.map(health => [health.time, health.height, health.match_rate])); res.json(blocksHealth.map(health => [health.time, health.height, health.match_rate]));
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get historical blocks health');
} }
} }
@ -336,7 +336,7 @@ class MiningRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24).toUTCString());
res.json(audit); res.json(audit);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get block audit');
} }
} }
@ -359,7 +359,7 @@ class MiningRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString());
res.json(result); res.json(result);
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get height from timestamp');
} }
} }
@ -372,7 +372,7 @@ class MiningRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
res.json(await BlocksAuditsRepository.$getBlockAuditScores(height, height - 15)); res.json(await BlocksAuditsRepository.$getBlockAuditScores(height, height - 15));
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get block audit scores');
} }
} }
@ -385,7 +385,7 @@ class MiningRoutes {
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24).toUTCString()); res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24).toUTCString());
res.json(audit || 'null'); res.json(audit || 'null');
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get block audit score');
} }
} }
@ -400,7 +400,7 @@ class MiningRoutes {
} }
res.status(200).send(await AccelerationRepository.$getAccelerationInfo(req.params.slug)); res.status(200).send(await AccelerationRepository.$getAccelerationInfo(req.params.slug));
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get accelerations by pool');
} }
} }
@ -416,7 +416,7 @@ class MiningRoutes {
const height = req.params.height === undefined ? undefined : parseInt(req.params.height, 10); const height = req.params.height === undefined ? undefined : parseInt(req.params.height, 10);
res.status(200).send(await AccelerationRepository.$getAccelerationInfo(null, height)); res.status(200).send(await AccelerationRepository.$getAccelerationInfo(null, height));
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get accelerations by height');
} }
} }
@ -431,7 +431,7 @@ class MiningRoutes {
} }
res.status(200).send(await AccelerationRepository.$getAccelerationInfo(null, null, req.params.interval)); res.status(200).send(await AccelerationRepository.$getAccelerationInfo(null, null, req.params.interval));
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get recent accelerations');
} }
} }
@ -446,7 +446,7 @@ class MiningRoutes {
} }
res.status(200).send(await AccelerationRepository.$getAccelerationTotals(<string>req.query.pool, <string>req.query.interval)); res.status(200).send(await AccelerationRepository.$getAccelerationTotals(<string>req.query.pool, <string>req.query.interval));
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get acceleration totals');
} }
} }
@ -461,7 +461,7 @@ class MiningRoutes {
} }
res.status(200).send(Object.values(accelerationApi.getAccelerations() || {})); res.status(200).send(Object.values(accelerationApi.getAccelerations() || {}));
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get active accelerations');
} }
} }
@ -473,7 +473,7 @@ class MiningRoutes {
accelerationApi.accelerationRequested(req.params.txid); accelerationApi.accelerationRequested(req.params.txid);
res.status(200).send(); res.status(200).send();
} catch (e) { } catch (e) {
handleError(req, res, 500, e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to request acceleration');
} }
} }
} }

View file

@ -1,10 +1,15 @@
import { Application, Request, Response } from 'express'; import { Application, Request, Response } from 'express';
import config from '../../config'; import config from '../../config';
import pricesUpdater from '../../tasks/price-updater'; import pricesUpdater from '../../tasks/price-updater';
import logger from '../../logger';
import PricesRepository from '../../repositories/PricesRepository';
class PricesRoutes { class PricesRoutes {
public initRoutes(app: Application): void { public initRoutes(app: Application): void {
app.get(config.MEMPOOL.API_URL_PREFIX + 'prices', this.$getCurrentPrices.bind(this)); app
.get(config.MEMPOOL.API_URL_PREFIX + 'prices', this.$getCurrentPrices.bind(this))
.get(config.MEMPOOL.API_URL_PREFIX + 'internal/usd-price-history', this.$getAllPrices.bind(this))
;
} }
private $getCurrentPrices(req: Request, res: Response): void { private $getCurrentPrices(req: Request, res: Response): void {
@ -14,6 +19,23 @@ class PricesRoutes {
res.json(pricesUpdater.getLatestPrices()); res.json(pricesUpdater.getLatestPrices());
} }
private async $getAllPrices(req: Request, res: Response): Promise<void> {
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 360_0000 / config.MEMPOOL.PRICE_UPDATES_PER_HOUR).toUTCString());
try {
const usdPriceHistory = await PricesRepository.$getPricesTimesAndId();
const responseData = usdPriceHistory.map(p => {
return { time: p.time, USD: p.USD };
});
res.status(200).json(responseData);
} catch (e: any) {
logger.err(`Exception ${e} in PricesRoutes::$getAllPrices. Code: ${e.code}. Message: ${e.message}`);
res.status(403).send();
}
}
} }
export default new PricesRoutes(); export default new PricesRoutes();

View file

@ -119,7 +119,11 @@ class RbfCache {
public add(replaced: MempoolTransactionExtended[], newTxExtended: MempoolTransactionExtended): void { public add(replaced: MempoolTransactionExtended[], newTxExtended: MempoolTransactionExtended): void {
if (!newTxExtended || !replaced?.length || this.txs.has(newTxExtended.txid)) { if ( !newTxExtended
|| !replaced?.length
|| this.txs.has(newTxExtended.txid)
|| !(replaced.some(tx => !this.replacedBy.has(tx.txid)))
) {
return; return;
} }

View file

@ -246,17 +246,22 @@ class AccelerationApi {
this.startedWebsocketLoop = true; this.startedWebsocketLoop = true;
if (!this.ws) { if (!this.ws) {
this.ws = new WebSocket(this.websocketPath); this.ws = new WebSocket(this.websocketPath);
this.websocketConnected = true; this.lastPing = 0;
this.ws.on('open', () => { this.ws.on('open', () => {
logger.info(`Acceleration websocket opened to ${this.websocketPath}`); logger.info(`Acceleration websocket opened to ${this.websocketPath}`);
this.websocketConnected = true;
this.ws?.send(JSON.stringify({ this.ws?.send(JSON.stringify({
'watch-accelerations': true 'watch-accelerations': true
})); }));
}); });
this.ws.on('error', (error) => { this.ws.on('error', (error) => {
logger.err(`Acceleration websocket error on ${this.websocketPath}: ` + error); let errMsg = `Acceleration websocket error on ${this.websocketPath}: ${error['code']}`;
if (error['errors']) {
errMsg += ' - ' + error['errors'].join(' - ');
}
logger.err(errMsg);
this.ws = null; this.ws = null;
this.websocketConnected = false; this.websocketConnected = false;
}); });
@ -285,16 +290,28 @@ class AccelerationApi {
logger.debug('received pong from acceleration websocket server'); logger.debug('received pong from acceleration websocket server');
this.lastPong = Date.now(); this.lastPong = Date.now();
}); });
} else { } else if (this.websocketConnected) {
if (this.lastPing > this.lastPong && Date.now() - this.lastPing > 10000) { if (this.lastPing && this.lastPing > this.lastPong && (Date.now() - this.lastPing > 10000)) {
logger.warn('No pong received within 10 seconds, terminating connection'); logger.warn('No pong received within 10 seconds, terminating connection');
this.ws.terminate(); try {
this.ws = null; this.ws?.terminate();
this.websocketConnected = false; } catch (e) {
} else if (Date.now() - this.lastPing > 30000) { logger.warn('failed to terminate acceleration websocket connection: ' + (e instanceof Error ? e.message : e));
} finally {
this.ws = null;
this.websocketConnected = false;
this.lastPing = 0;
}
} else if (!this.lastPing || (Date.now() - this.lastPing > 30000)) {
logger.debug('sending ping to acceleration websocket server'); logger.debug('sending ping to acceleration websocket server');
this.ws.ping(); if (this.ws?.readyState === WebSocket.OPEN) {
this.lastPing = Date.now(); try {
this.ws?.ping();
this.lastPing = Date.now();
} catch (e) {
logger.warn('failed to send ping to acceleration websocket server: ' + (e instanceof Error ? e.message : e));
}
}
} }
} }
await new Promise(resolve => setTimeout(resolve, 5000)); await new Promise(resolve => setTimeout(resolve, 5000));

View file

@ -1,6 +1,7 @@
import { Application, Request, Response } from 'express'; import { Application, Request, Response } from 'express';
import config from '../../config'; import config from '../../config';
import WalletApi from './wallets'; import WalletApi from './wallets';
import { handleError } from '../../utils/api';
class ServicesRoutes { class ServicesRoutes {
public initRoutes(app: Application): void { public initRoutes(app: Application): void {
@ -18,7 +19,7 @@ class ServicesRoutes {
const wallet = await WalletApi.getWallet(walletId); const wallet = await WalletApi.getWallet(walletId);
res.status(200).send(wallet); res.status(200).send(wallet);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get wallet');
} }
} }
} }

View file

@ -0,0 +1,105 @@
import { WebSocket } from 'ws';
import logger from '../../logger';
import config from '../../config';
import websocketHandler from '../websocket-handler';
export interface StratumJob {
pool: number;
height: number;
coinbase: string;
scriptsig: string;
reward: number;
jobId: string;
extraNonce: string;
extraNonce2Size: number;
prevHash: string;
coinbase1: string;
coinbase2: string;
merkleBranches: string[];
version: string;
bits: string;
time: string;
timestamp: number;
cleanJobs: boolean;
received: number;
}
function isStratumJob(obj: any): obj is StratumJob {
return obj
&& typeof obj === 'object'
&& 'pool' in obj
&& 'prevHash' in obj
&& 'height' in obj
&& 'received' in obj
&& 'version' in obj
&& 'timestamp' in obj
&& 'bits' in obj
&& 'merkleBranches' in obj
&& 'cleanJobs' in obj;
}
class StratumApi {
private ws: WebSocket | null = null;
private runWebsocketLoop: boolean = false;
private startedWebsocketLoop: boolean = false;
private websocketConnected: boolean = false;
private jobs: Record<string, StratumJob> = {};
public constructor() {}
public getJobs(): Record<string, StratumJob> {
return this.jobs;
}
private handleWebsocketMessage(msg: any): void {
if (isStratumJob(msg)) {
this.jobs[msg.pool] = msg;
websocketHandler.handleNewStratumJob(this.jobs[msg.pool]);
}
}
public async connectWebsocket(): Promise<void> {
if (!config.STRATUM.ENABLED) {
return;
}
this.runWebsocketLoop = true;
if (this.startedWebsocketLoop) {
return;
}
while (this.runWebsocketLoop) {
this.startedWebsocketLoop = true;
if (!this.ws) {
this.ws = new WebSocket(`${config.STRATUM.API}`);
this.websocketConnected = true;
this.ws.on('open', () => {
logger.info('Stratum websocket opened');
});
this.ws.on('error', (error) => {
logger.err('Stratum websocket error: ' + error);
this.ws = null;
this.websocketConnected = false;
});
this.ws.on('close', () => {
logger.info('Stratum websocket closed');
this.ws = null;
this.websocketConnected = false;
});
this.ws.on('message', (data, isBinary) => {
try {
const parsedMsg = JSON.parse((isBinary ? data : data.toString()) as string);
this.handleWebsocketMessage(parsedMsg);
} catch (e) {
logger.warn('Failed to parse stratum websocket message: ' + (e instanceof Error ? e.message : e));
}
});
}
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
}
export default new StratumApi();

View file

@ -1,7 +1,7 @@
import { Application, Request, Response } from 'express'; import { Application, Request, Response } from 'express';
import config from '../../config'; import config from '../../config';
import statisticsApi from './statistics-api'; import statisticsApi from './statistics-api';
import { handleError } from '../../utils/api';
class StatisticsRoutes { class StatisticsRoutes {
public initRoutes(app: Application) { public initRoutes(app: Application) {
app app
@ -65,7 +65,7 @@ class StatisticsRoutes {
} }
res.json(result); res.json(result);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); handleError(req, res, 500, 'Failed to get statistics');
} }
} }
} }

View file

@ -38,6 +38,7 @@ interface AddressTransactions {
import bitcoinSecondClient from './bitcoin/bitcoin-second-client'; import bitcoinSecondClient from './bitcoin/bitcoin-second-client';
import { calculateMempoolTxCpfp } from './cpfp'; import { calculateMempoolTxCpfp } from './cpfp';
import { getRecentFirstSeen } from '../utils/file-read'; import { getRecentFirstSeen } from '../utils/file-read';
import stratumApi, { StratumJob } from './services/stratum';
// valid 'want' subscriptions // valid 'want' subscriptions
const wantable = [ const wantable = [
@ -403,6 +404,16 @@ class WebsocketHandler {
delete client['track-mempool']; delete client['track-mempool'];
} }
if (parsedMessage && parsedMessage['track-stratum'] != null) {
if (parsedMessage['track-stratum']) {
const sub = parsedMessage['track-stratum'];
client['track-stratum'] = sub;
response['stratumJobs'] = this.socketData['stratumJobs'];
} else {
client['track-stratum'] = false;
}
}
if (Object.keys(response).length) { if (Object.keys(response).length) {
client.send(this.serializeResponse(response)); client.send(this.serializeResponse(response));
} }
@ -1384,6 +1395,23 @@ class WebsocketHandler {
await statistics.runStatistics(); await statistics.runStatistics();
} }
public handleNewStratumJob(job: StratumJob): void {
this.updateSocketDataFields({ 'stratumJobs': stratumApi.getJobs() });
for (const server of this.webSocketServers) {
server.clients.forEach((client) => {
if (client.readyState !== WebSocket.OPEN) {
return;
}
if (client['track-stratum'] && (client['track-stratum'] === 'all' || client['track-stratum'] === job.pool)) {
client.send(JSON.stringify({
'stratumJob': job
}));
}
});
}
}
// takes a dictionary of JSON serialized values // takes a dictionary of JSON serialized values
// and zips it together into a valid JSON object // and zips it together into a valid JSON object
private serializeResponse(response): string { private serializeResponse(response): string {

View file

@ -165,6 +165,10 @@ interface IConfig {
WALLETS: { WALLETS: {
ENABLED: boolean; ENABLED: boolean;
WALLETS: string[]; WALLETS: string[];
},
STRATUM: {
ENABLED: boolean;
API: string;
} }
} }
@ -332,6 +336,10 @@ const defaults: IConfig = {
'ENABLED': false, 'ENABLED': false,
'WALLETS': [], 'WALLETS': [],
}, },
'STRATUM': {
'ENABLED': false,
'API': 'http://localhost:1234',
}
}; };
class Config implements IConfig { class Config implements IConfig {
@ -354,6 +362,7 @@ class Config implements IConfig {
REDIS: IConfig['REDIS']; REDIS: IConfig['REDIS'];
FIAT_PRICE: IConfig['FIAT_PRICE']; FIAT_PRICE: IConfig['FIAT_PRICE'];
WALLETS: IConfig['WALLETS']; WALLETS: IConfig['WALLETS'];
STRATUM: IConfig['STRATUM'];
constructor() { constructor() {
const configs = this.merge(configFromFile, defaults); const configs = this.merge(configFromFile, defaults);
@ -376,6 +385,7 @@ class Config implements IConfig {
this.REDIS = configs.REDIS; this.REDIS = configs.REDIS;
this.FIAT_PRICE = configs.FIAT_PRICE; this.FIAT_PRICE = configs.FIAT_PRICE;
this.WALLETS = configs.WALLETS; this.WALLETS = configs.WALLETS;
this.STRATUM = configs.STRATUM;
} }
merge = (...objects: object[]): IConfig => { merge = (...objects: object[]): IConfig => {

View file

@ -48,6 +48,7 @@ import accelerationRoutes from './api/acceleration/acceleration.routes';
import aboutRoutes from './api/about.routes'; import aboutRoutes from './api/about.routes';
import mempoolBlocks from './api/mempool-blocks'; import mempoolBlocks from './api/mempool-blocks';
import walletApi from './api/services/wallets'; import walletApi from './api/services/wallets';
import stratumApi from './api/services/stratum';
class Server { class Server {
private wss: WebSocket.Server | undefined; private wss: WebSocket.Server | undefined;
@ -320,11 +321,16 @@ class Server {
loadingIndicators.setProgressChangedCallback(websocketHandler.handleLoadingChanged.bind(websocketHandler)); loadingIndicators.setProgressChangedCallback(websocketHandler.handleLoadingChanged.bind(websocketHandler));
accelerationApi.connectWebsocket(); accelerationApi.connectWebsocket();
if (config.STRATUM.ENABLED) {
stratumApi.connectWebsocket();
}
} }
setUpHttpApiRoutes(): void { setUpHttpApiRoutes(): void {
bitcoinRoutes.initRoutes(this.app); bitcoinRoutes.initRoutes(this.app);
bitcoinCoreRoutes.initRoutes(this.app); if (config.MEMPOOL.OFFICIAL) {
bitcoinCoreRoutes.initRoutes(this.app);
}
pricesRoutes.initRoutes(this.app); pricesRoutes.initRoutes(this.app);
if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED && config.MEMPOOL.ENABLED) { if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED && config.MEMPOOL.ENABLED) {
statisticsRoutes.initRoutes(this.app); statisticsRoutes.initRoutes(this.app);

View file

@ -148,6 +148,10 @@
"API": "__MEMPOOL_SERVICES_API__", "API": "__MEMPOOL_SERVICES_API__",
"ACCELERATIONS": __MEMPOOL_SERVICES_ACCELERATIONS__ "ACCELERATIONS": __MEMPOOL_SERVICES_ACCELERATIONS__
}, },
"STRATUM": {
"ENABLED": __STRATUM_ENABLED__,
"API": "__STRATUM_API__"
},
"REDIS": { "REDIS": {
"ENABLED": __REDIS_ENABLED__, "ENABLED": __REDIS_ENABLED__,
"UNIX_SOCKET_PATH": "__REDIS_UNIX_SOCKET_PATH__", "UNIX_SOCKET_PATH": "__REDIS_UNIX_SOCKET_PATH__",

View file

@ -149,6 +149,10 @@ __REPLICATION_SERVERS__=${REPLICATION_SERVERS:=[]}
__MEMPOOL_SERVICES_API__=${MEMPOOL_SERVICES_API:="https://mempool.space/api/v1/services"} __MEMPOOL_SERVICES_API__=${MEMPOOL_SERVICES_API:="https://mempool.space/api/v1/services"}
__MEMPOOL_SERVICES_ACCELERATIONS__=${MEMPOOL_SERVICES_ACCELERATIONS:=false} __MEMPOOL_SERVICES_ACCELERATIONS__=${MEMPOOL_SERVICES_ACCELERATIONS:=false}
# STRATUM
__STRATUM_ENABLED__=${STRATUM_ENABLED:=false}
__STRATUM_API__=${STRATUM_API:="http://localhost:1234"}
# REDIS # REDIS
__REDIS_ENABLED__=${REDIS_ENABLED:=false} __REDIS_ENABLED__=${REDIS_ENABLED:=false}
__REDIS_UNIX_SOCKET_PATH__=${REDIS_UNIX_SOCKET_PATH:=""} __REDIS_UNIX_SOCKET_PATH__=${REDIS_UNIX_SOCKET_PATH:=""}
@ -300,6 +304,10 @@ sed -i "s!__REPLICATION_SERVERS__!${__REPLICATION_SERVERS__}!g" mempool-config.j
sed -i "s!__MEMPOOL_SERVICES_API__!${__MEMPOOL_SERVICES_API__}!g" mempool-config.json sed -i "s!__MEMPOOL_SERVICES_API__!${__MEMPOOL_SERVICES_API__}!g" mempool-config.json
sed -i "s!__MEMPOOL_SERVICES_ACCELERATIONS__!${__MEMPOOL_SERVICES_ACCELERATIONS__}!g" mempool-config.json sed -i "s!__MEMPOOL_SERVICES_ACCELERATIONS__!${__MEMPOOL_SERVICES_ACCELERATIONS__}!g" mempool-config.json
# STRATUM
sed -i "s!__STRATUM_ENABLED__!${__STRATUM_ENABLED__}!g" mempool-config.json
sed -i "s!__STRATUM_API__!${__STRATUM_API__}!g" mempool-config.json
# REDIS # REDIS
sed -i "s!__REDIS_ENABLED__!${__REDIS_ENABLED__}!g" mempool-config.json sed -i "s!__REDIS_ENABLED__!${__REDIS_ENABLED__}!g" mempool-config.json
sed -i "s!__REDIS_UNIX_SOCKET_PATH__!${__REDIS_UNIX_SOCKET_PATH__}!g" mempool-config.json sed -i "s!__REDIS_UNIX_SOCKET_PATH__!${__REDIS_UNIX_SOCKET_PATH__}!g" mempool-config.json

View file

@ -0,0 +1,51 @@
{
"theme": "contrast",
"enterprise": "meta",
"branding": {
"name": "metaplanet",
"title": "Metaplanet",
"site_id": 21,
"header_img": "/resources/metalogo.svg",
"footer_img": "/resources/metalogo.svg"
},
"dashboard": {
"widgets": [
{
"component": "fees",
"mobileOrder": 4
},
{
"component": "walletBalance",
"mobileOrder": 1,
"props": {
"wallet": "3350"
}
},
{
"component": "twitter",
"mobileOrder": 5,
"props": {
"handle": "Metaplanet_JP"
}
},
{
"component": "wallet",
"mobileOrder": 2,
"props": {
"wallet": "3350",
"period": "all"
}
},
{
"component": "blocks"
},
{
"component": "walletTransactions",
"mobileOrder": 3,
"props": {
"wallet": "3350"
}
}
]
}
}

View file

@ -344,7 +344,9 @@ describe('Mainnet', () => {
cy.visit('/'); cy.visit('/');
cy.waitForSkeletonGone(); cy.waitForSkeletonGone();
cy.changeNetwork('testnet4'); //TODO(knorrium): add a check for the proxied server
// cy.changeNetwork('testnet4');
cy.changeNetwork('signet'); cy.changeNetwork('signet');
cy.changeNetwork('mainnet'); cy.changeNetwork('mainnet');
}); });

View file

@ -27,5 +27,6 @@
"ACCELERATOR": false, "ACCELERATOR": false,
"ACCELERATOR_BUTTON": true, "ACCELERATOR_BUTTON": true,
"PUBLIC_ACCELERATIONS": false, "PUBLIC_ACCELERATIONS": false,
"STRATUM_ENABLED": false,
"SERVICES_API": "https://mempool.space/api/v1/services" "SERVICES_API": "https://mempool.space/api/v1/services"
} }

View file

@ -23,9 +23,9 @@
"@angular/router": "^17.3.1", "@angular/router": "^17.3.1",
"@angular/ssr": "^17.3.1", "@angular/ssr": "^17.3.1",
"@fortawesome/angular-fontawesome": "~0.14.1", "@fortawesome/angular-fontawesome": "~0.14.1",
"@fortawesome/fontawesome-common-types": "~6.6.0", "@fortawesome/fontawesome-common-types": "~6.7.2",
"@fortawesome/fontawesome-svg-core": "~6.6.0", "@fortawesome/fontawesome-svg-core": "~6.7.2",
"@fortawesome/free-solid-svg-icons": "~6.6.0", "@fortawesome/free-solid-svg-icons": "~6.7.2",
"@mempool/mempool.js": "2.3.0", "@mempool/mempool.js": "2.3.0",
"@ng-bootstrap/ng-bootstrap": "^16.0.0", "@ng-bootstrap/ng-bootstrap": "^16.0.0",
"@types/qrcode": "~1.5.0", "@types/qrcode": "~1.5.0",
@ -35,7 +35,6 @@
"domino": "^2.1.6", "domino": "^2.1.6",
"echarts": "~5.5.0", "echarts": "~5.5.0",
"esbuild": "^0.24.0", "esbuild": "^0.24.0",
"lightweight-charts": "~3.8.0",
"ngx-echarts": "~17.2.0", "ngx-echarts": "~17.2.0",
"ngx-infinite-scroll": "^17.0.0", "ngx-infinite-scroll": "^17.0.0",
"qrcode": "1.5.1", "qrcode": "1.5.1",
@ -62,7 +61,7 @@
"optionalDependencies": { "optionalDependencies": {
"@cypress/schematic": "^2.5.0", "@cypress/schematic": "^2.5.0",
"@types/cypress": "^1.1.3", "@types/cypress": "^1.1.3",
"cypress": "^13.15.0", "cypress": "^13.17.0",
"cypress-fail-on-console-error": "~5.1.0", "cypress-fail-on-console-error": "~5.1.0",
"cypress-wait-until": "^2.0.1", "cypress-wait-until": "^2.0.1",
"mock-socket": "~9.3.1", "mock-socket": "~9.3.1",
@ -3113,9 +3112,10 @@
} }
}, },
"node_modules/@cypress/request": { "node_modules/@cypress/request": {
"version": "3.0.5", "version": "3.0.7",
"resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.5.tgz", "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.7.tgz",
"integrity": "sha512-v+XHd9XmWbufxF1/bTaVm2yhbxY+TB4YtWRqF2zaXBlDNMkls34KiATz0AVDLavL3iB6bQk9/7n3oY1EoLSWGA==", "integrity": "sha512-LzxlLEMbBOPYB85uXrDqvD4MgcenjRBLIns3zyhx7vTPj/0u2eQhzXvPiGcaJrV38Q9dbkExWp6cOHPJ+EtFYg==",
"license": "Apache-2.0",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"aws-sign2": "~0.7.0", "aws-sign2": "~0.7.0",
@ -3131,9 +3131,9 @@
"json-stringify-safe": "~5.0.1", "json-stringify-safe": "~5.0.1",
"mime-types": "~2.1.19", "mime-types": "~2.1.19",
"performance-now": "^2.1.0", "performance-now": "^2.1.0",
"qs": "6.13.0", "qs": "6.13.1",
"safe-buffer": "^5.1.2", "safe-buffer": "^5.1.2",
"tough-cookie": "^4.1.3", "tough-cookie": "^5.0.0",
"tunnel-agent": "^0.6.0", "tunnel-agent": "^0.6.0",
"uuid": "^8.3.2" "uuid": "^8.3.2"
}, },
@ -3141,6 +3141,22 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/@cypress/request/node_modules/qs": {
"version": "6.13.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.1.tgz",
"integrity": "sha512-EJPeIn0CYrGu+hli1xilKAPXODtJ12T0sP63Ijx2/khC2JtuaN3JyNIpvmnkmaEtha9ocbG4A4cMcr+TvqvwQg==",
"license": "BSD-3-Clause",
"optional": true,
"dependencies": {
"side-channel": "^1.0.6"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/@cypress/schematic": { "node_modules/@cypress/schematic": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/@cypress/schematic/-/schematic-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@cypress/schematic/-/schematic-2.5.0.tgz",
@ -3674,30 +3690,33 @@
} }
}, },
"node_modules/@fortawesome/fontawesome-common-types": { "node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.6.0", "version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz",
"integrity": "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw==", "integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==",
"license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/@fortawesome/fontawesome-svg-core": { "node_modules/@fortawesome/fontawesome-svg-core": {
"version": "6.6.0", "version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.6.0.tgz", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz",
"integrity": "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==", "integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==",
"license": "MIT",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-common-types": "6.6.0" "@fortawesome/fontawesome-common-types": "6.7.2"
}, },
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/@fortawesome/free-solid-svg-icons": { "node_modules/@fortawesome/free-solid-svg-icons": {
"version": "6.6.0", "version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.6.0.tgz", "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.2.tgz",
"integrity": "sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA==", "integrity": "sha512-GsBrnOzU8uj0LECDfD5zomZJIjrPhIlWU82AHwa2s40FKH+kcxQaBvBo3Z4TxyZHIyX8XTDxsyA33/Vx9eFuQA==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-common-types": "6.6.0" "@fortawesome/fontawesome-common-types": "6.7.2"
}, },
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@ -5673,6 +5692,7 @@
"version": "0.2.6", "version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"safer-buffer": "~2.1.0" "safer-buffer": "~2.1.0"
@ -5707,6 +5727,7 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
"integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==",
"license": "MIT",
"optional": true, "optional": true,
"engines": { "engines": {
"node": ">=0.8" "node": ">=0.8"
@ -5827,6 +5848,7 @@
"version": "0.7.0", "version": "0.7.0",
"resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
"integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==",
"license": "Apache-2.0",
"optional": true, "optional": true,
"engines": { "engines": {
"node": "*" "node": "*"
@ -5836,6 +5858,7 @@
"version": "1.13.2", "version": "1.13.2",
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz",
"integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==",
"license": "MIT",
"optional": true "optional": true
}, },
"node_modules/axios": { "node_modules/axios": {
@ -5993,6 +6016,7 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"license": "BSD-3-Clause",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"tweetnacl": "^0.14.3" "tweetnacl": "^0.14.3"
@ -7068,6 +7092,7 @@
"version": "0.12.0", "version": "0.12.0",
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
"license": "Apache-2.0",
"optional": true "optional": true
}, },
"node_modules/chai": { "node_modules/chai": {
@ -7170,15 +7195,16 @@
} }
}, },
"node_modules/ci-info": { "node_modules/ci-info": {
"version": "3.8.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.8.0.tgz", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.1.0.tgz",
"integrity": "sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw==", "integrity": "sha512-HutrvTNsF48wnxkzERIXOe5/mlcfFcbfCmwcg6CJnizbSue78AbDt+1cgl26zwn61WFxhcPykPfZrbqjGmBb4A==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/sibiraj-s" "url": "https://github.com/sponsors/sibiraj-s"
} }
], ],
"license": "MIT",
"optional": true, "optional": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -7953,13 +7979,14 @@
"peer": true "peer": true
}, },
"node_modules/cypress": { "node_modules/cypress": {
"version": "13.15.0", "version": "13.17.0",
"resolved": "https://registry.npmjs.org/cypress/-/cypress-13.15.0.tgz", "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.17.0.tgz",
"integrity": "sha512-53aO7PwOfi604qzOkCSzNlWquCynLlKE/rmmpSPcziRH6LNfaDUAklQT6WJIsD8ywxlIy+uVZsnTMCCQVd2kTw==", "integrity": "sha512-5xWkaPurwkIljojFidhw8lFScyxhtiFHl/i/3zov+1Z5CmY4t9tjIdvSXfu82Y3w7wt0uR9KkucbhkVvJZLQSA==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@cypress/request": "^3.0.4", "@cypress/request": "^3.0.6",
"@cypress/xvfb": "^1.2.4", "@cypress/xvfb": "^1.2.4",
"@types/sinonjs__fake-timers": "8.1.1", "@types/sinonjs__fake-timers": "8.1.1",
"@types/sizzle": "^2.3.2", "@types/sizzle": "^2.3.2",
@ -7970,6 +7997,7 @@
"cachedir": "^2.3.0", "cachedir": "^2.3.0",
"chalk": "^4.1.0", "chalk": "^4.1.0",
"check-more-types": "^2.24.0", "check-more-types": "^2.24.0",
"ci-info": "^4.0.0",
"cli-cursor": "^3.1.0", "cli-cursor": "^3.1.0",
"cli-table3": "~0.6.1", "cli-table3": "~0.6.1",
"commander": "^6.2.1", "commander": "^6.2.1",
@ -7984,7 +8012,6 @@
"figures": "^3.2.0", "figures": "^3.2.0",
"fs-extra": "^9.1.0", "fs-extra": "^9.1.0",
"getos": "^3.2.1", "getos": "^3.2.1",
"is-ci": "^3.0.1",
"is-installed-globally": "~0.4.0", "is-installed-globally": "~0.4.0",
"lazy-ass": "^1.6.0", "lazy-ass": "^1.6.0",
"listr2": "^3.8.3", "listr2": "^3.8.3",
@ -7999,6 +8026,7 @@
"semver": "^7.5.3", "semver": "^7.5.3",
"supports-color": "^8.1.1", "supports-color": "^8.1.1",
"tmp": "~0.2.3", "tmp": "~0.2.3",
"tree-kill": "1.2.2",
"untildify": "^4.0.0", "untildify": "^4.0.0",
"yauzl": "^2.10.0" "yauzl": "^2.10.0"
}, },
@ -8201,6 +8229,7 @@
"version": "1.14.1", "version": "1.14.1",
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
"integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==",
"license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"assert-plus": "^1.0.0" "assert-plus": "^1.0.0"
@ -8687,6 +8716,7 @@
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
"integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==",
"license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"jsbn": "~0.1.0", "jsbn": "~0.1.0",
@ -9905,6 +9935,7 @@
"engines": [ "engines": [
"node >=0.6.0" "node >=0.6.0"
], ],
"license": "MIT",
"optional": true "optional": true
}, },
"node_modules/falafel": { "node_modules/falafel": {
@ -9921,11 +9952,6 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/fancy-canvas": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-0.2.2.tgz",
"integrity": "sha512-50qi8xA0QkHbjmb8h7XQ6k2fvD7y/yMfiUw9YTarJ7rWrq6o5/3CCXPouYk+XSLASvvxtjyiQLRBFt3qkE3oyA=="
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -10193,6 +10219,7 @@
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
"integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==",
"license": "Apache-2.0",
"optional": true, "optional": true,
"engines": { "engines": {
"node": "*" "node": "*"
@ -10400,6 +10427,7 @@
"version": "0.1.7", "version": "0.1.7",
"resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz",
"integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==",
"license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"assert-plus": "^1.0.0" "assert-plus": "^1.0.0"
@ -10854,6 +10882,7 @@
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz",
"integrity": "sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==", "integrity": "sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==",
"license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"assert-plus": "^1.0.0", "assert-plus": "^1.0.0",
@ -11220,18 +11249,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-ci": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz",
"integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==",
"optional": true,
"dependencies": {
"ci-info": "^3.2.0"
},
"bin": {
"is-ci": "bin.js"
}
},
"node_modules/is-core-module": { "node_modules/is-core-module": {
"version": "2.13.1", "version": "2.13.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz",
@ -11481,6 +11498,7 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
"integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==",
"license": "MIT",
"optional": true "optional": true
}, },
"node_modules/is-unicode-supported": { "node_modules/is-unicode-supported": {
@ -11545,6 +11563,7 @@
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
"integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==",
"license": "MIT",
"optional": true "optional": true
}, },
"node_modules/istanbul-lib-coverage": { "node_modules/istanbul-lib-coverage": {
@ -11678,6 +11697,7 @@
"version": "0.1.1", "version": "0.1.1",
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
"integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==",
"license": "MIT",
"optional": true "optional": true
}, },
"node_modules/jsesc": { "node_modules/jsesc": {
@ -11706,6 +11726,7 @@
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"license": "(AFL-2.1 OR BSD-3-Clause)",
"optional": true "optional": true
}, },
"node_modules/json-schema-traverse": { "node_modules/json-schema-traverse": {
@ -11723,6 +11744,7 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
"license": "ISC",
"optional": true "optional": true
}, },
"node_modules/json5": { "node_modules/json5": {
@ -11783,6 +11805,7 @@
"engines": [ "engines": [
"node >=0.6.0" "node >=0.6.0"
], ],
"license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"assert-plus": "1.0.0", "assert-plus": "1.0.0",
@ -12106,14 +12129,6 @@
} }
} }
}, },
"node_modules/lightweight-charts": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-3.8.0.tgz",
"integrity": "sha512-7yFGnYuE1RjRJG9RwUTBz5wvF1QtjBOSW4FFlikr8Dh+/TDNt4ci+HsWSYmStgQUpawpvkCJ3j5/W25GppGj9Q==",
"dependencies": {
"fancy-canvas": "0.2.2"
}
},
"node_modules/limiter": { "node_modules/limiter": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
@ -14110,6 +14125,7 @@
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT",
"optional": true "optional": true
}, },
"node_modules/picocolors": { "node_modules/picocolors": {
@ -14540,12 +14556,6 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/psl": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
"integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==",
"optional": true
},
"node_modules/public-encrypt": { "node_modules/public-encrypt": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz",
@ -14661,12 +14671,6 @@
"node": ">=0.4.x" "node": ">=0.4.x"
} }
}, },
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"optional": true
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -16028,6 +16032,7 @@
"version": "1.18.0", "version": "1.18.0",
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz",
"integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==",
"license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"asn1": "~0.2.3", "asn1": "~0.2.3",
@ -16577,6 +16582,26 @@
"readable-stream": "3" "readable-stream": "3"
} }
}, },
"node_modules/tldts": {
"version": "6.1.70",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.70.tgz",
"integrity": "sha512-/W1YVgYVJd9ZDjey5NXadNh0mJXkiUMUue9Zebd0vpdo1sU+H4zFFTaJ1RKD4N6KFoHfcXy6l+Vu7bh+bdWCzA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tldts-core": "^6.1.70"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "6.1.70",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.70.tgz",
"integrity": "sha512-RNnIXDB1FD4T9cpQRErEqw6ZpjLlGdMOitdV+0xtbsnwr4YFka1zpc7D4KD+aAn8oSG5JyFrdasZTE04qDE9Yg==",
"license": "MIT",
"optional": true
},
"node_modules/tlite": { "node_modules/tlite": {
"version": "0.1.9", "version": "0.1.9",
"resolved": "https://registry.npmjs.org/tlite/-/tlite-0.1.9.tgz", "resolved": "https://registry.npmjs.org/tlite/-/tlite-0.1.9.tgz",
@ -16621,27 +16646,16 @@
} }
}, },
"node_modules/tough-cookie": { "node_modules/tough-cookie": {
"version": "4.1.4", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.0.0.tgz",
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", "integrity": "sha512-FRKsF7cz96xIIeMZ82ehjC3xW2E+O2+v11udrDYewUbszngYhsGa8z6YUMMzO9QJZzzyd0nGGXnML/TReX6W8Q==",
"license": "BSD-3-Clause",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"psl": "^1.1.33", "tldts": "^6.1.32"
"punycode": "^2.1.1",
"universalify": "^0.2.0",
"url-parse": "^1.5.3"
}, },
"engines": { "engines": {
"node": ">=6" "node": ">=16"
}
},
"node_modules/tough-cookie/node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"optional": true,
"engines": {
"node": ">= 4.0.0"
} }
}, },
"node_modules/transform-ast": { "node_modules/transform-ast": {
@ -16810,6 +16824,7 @@
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
@ -16822,6 +16837,7 @@
"version": "0.14.5", "version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"license": "Unlicense",
"optional": true "optional": true
}, },
"node_modules/type": { "node_modules/type": {
@ -17130,16 +17146,6 @@
"querystring": "0.2.0" "querystring": "0.2.0"
} }
}, },
"node_modules/url-parse": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"optional": true,
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
}
},
"node_modules/url/node_modules/punycode": { "node_modules/url/node_modules/punycode": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz",
@ -17207,6 +17213,7 @@
"engines": [ "engines": [
"node >=0.6.0" "node >=0.6.0"
], ],
"license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"assert-plus": "^1.0.0", "assert-plus": "^1.0.0",
@ -20348,9 +20355,9 @@
} }
}, },
"@cypress/request": { "@cypress/request": {
"version": "3.0.5", "version": "3.0.7",
"resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.5.tgz", "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.7.tgz",
"integrity": "sha512-v+XHd9XmWbufxF1/bTaVm2yhbxY+TB4YtWRqF2zaXBlDNMkls34KiATz0AVDLavL3iB6bQk9/7n3oY1EoLSWGA==", "integrity": "sha512-LzxlLEMbBOPYB85uXrDqvD4MgcenjRBLIns3zyhx7vTPj/0u2eQhzXvPiGcaJrV38Q9dbkExWp6cOHPJ+EtFYg==",
"optional": true, "optional": true,
"requires": { "requires": {
"aws-sign2": "~0.7.0", "aws-sign2": "~0.7.0",
@ -20366,11 +20373,22 @@
"json-stringify-safe": "~5.0.1", "json-stringify-safe": "~5.0.1",
"mime-types": "~2.1.19", "mime-types": "~2.1.19",
"performance-now": "^2.1.0", "performance-now": "^2.1.0",
"qs": "6.13.0", "qs": "6.13.1",
"safe-buffer": "^5.1.2", "safe-buffer": "^5.1.2",
"tough-cookie": "^4.1.3", "tough-cookie": "^5.0.0",
"tunnel-agent": "^0.6.0", "tunnel-agent": "^0.6.0",
"uuid": "^8.3.2" "uuid": "^8.3.2"
},
"dependencies": {
"qs": {
"version": "6.13.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.1.tgz",
"integrity": "sha512-EJPeIn0CYrGu+hli1xilKAPXODtJ12T0sP63Ijx2/khC2JtuaN3JyNIpvmnkmaEtha9ocbG4A4cMcr+TvqvwQg==",
"optional": true,
"requires": {
"side-channel": "^1.0.6"
}
}
} }
}, },
"@cypress/schematic": { "@cypress/schematic": {
@ -20649,24 +20667,24 @@
} }
}, },
"@fortawesome/fontawesome-common-types": { "@fortawesome/fontawesome-common-types": {
"version": "6.6.0", "version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz",
"integrity": "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw==" "integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg=="
}, },
"@fortawesome/fontawesome-svg-core": { "@fortawesome/fontawesome-svg-core": {
"version": "6.6.0", "version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.6.0.tgz", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz",
"integrity": "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==", "integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==",
"requires": { "requires": {
"@fortawesome/fontawesome-common-types": "6.6.0" "@fortawesome/fontawesome-common-types": "6.7.2"
} }
}, },
"@fortawesome/free-solid-svg-icons": { "@fortawesome/free-solid-svg-icons": {
"version": "6.6.0", "version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.6.0.tgz", "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.2.tgz",
"integrity": "sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA==", "integrity": "sha512-GsBrnOzU8uj0LECDfD5zomZJIjrPhIlWU82AHwa2s40FKH+kcxQaBvBo3Z4TxyZHIyX8XTDxsyA33/Vx9eFuQA==",
"requires": { "requires": {
"@fortawesome/fontawesome-common-types": "6.6.0" "@fortawesome/fontawesome-common-types": "6.7.2"
} }
}, },
"@goto-bus-stop/common-shake": { "@goto-bus-stop/common-shake": {
@ -23298,9 +23316,9 @@
"integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==" "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg=="
}, },
"ci-info": { "ci-info": {
"version": "3.8.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.8.0.tgz", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.1.0.tgz",
"integrity": "sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw==", "integrity": "sha512-HutrvTNsF48wnxkzERIXOe5/mlcfFcbfCmwcg6CJnizbSue78AbDt+1cgl26zwn61WFxhcPykPfZrbqjGmBb4A==",
"optional": true "optional": true
}, },
"cipher-base": { "cipher-base": {
@ -23896,12 +23914,12 @@
"peer": true "peer": true
}, },
"cypress": { "cypress": {
"version": "13.15.0", "version": "13.17.0",
"resolved": "https://registry.npmjs.org/cypress/-/cypress-13.15.0.tgz", "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.17.0.tgz",
"integrity": "sha512-53aO7PwOfi604qzOkCSzNlWquCynLlKE/rmmpSPcziRH6LNfaDUAklQT6WJIsD8ywxlIy+uVZsnTMCCQVd2kTw==", "integrity": "sha512-5xWkaPurwkIljojFidhw8lFScyxhtiFHl/i/3zov+1Z5CmY4t9tjIdvSXfu82Y3w7wt0uR9KkucbhkVvJZLQSA==",
"optional": true, "optional": true,
"requires": { "requires": {
"@cypress/request": "^3.0.4", "@cypress/request": "^3.0.6",
"@cypress/xvfb": "^1.2.4", "@cypress/xvfb": "^1.2.4",
"@types/sinonjs__fake-timers": "8.1.1", "@types/sinonjs__fake-timers": "8.1.1",
"@types/sizzle": "^2.3.2", "@types/sizzle": "^2.3.2",
@ -23912,6 +23930,7 @@
"cachedir": "^2.3.0", "cachedir": "^2.3.0",
"chalk": "^4.1.0", "chalk": "^4.1.0",
"check-more-types": "^2.24.0", "check-more-types": "^2.24.0",
"ci-info": "^4.0.0",
"cli-cursor": "^3.1.0", "cli-cursor": "^3.1.0",
"cli-table3": "~0.6.1", "cli-table3": "~0.6.1",
"commander": "^6.2.1", "commander": "^6.2.1",
@ -23926,7 +23945,6 @@
"figures": "^3.2.0", "figures": "^3.2.0",
"fs-extra": "^9.1.0", "fs-extra": "^9.1.0",
"getos": "^3.2.1", "getos": "^3.2.1",
"is-ci": "^3.0.1",
"is-installed-globally": "~0.4.0", "is-installed-globally": "~0.4.0",
"lazy-ass": "^1.6.0", "lazy-ass": "^1.6.0",
"listr2": "^3.8.3", "listr2": "^3.8.3",
@ -23941,6 +23959,7 @@
"semver": "^7.5.3", "semver": "^7.5.3",
"supports-color": "^8.1.1", "supports-color": "^8.1.1",
"tmp": "~0.2.3", "tmp": "~0.2.3",
"tree-kill": "1.2.2",
"untildify": "^4.0.0", "untildify": "^4.0.0",
"yauzl": "^2.10.0" "yauzl": "^2.10.0"
}, },
@ -25433,11 +25452,6 @@
"object-keys": "^1.0.6" "object-keys": "^1.0.6"
} }
}, },
"fancy-canvas": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-0.2.2.tgz",
"integrity": "sha512-50qi8xA0QkHbjmb8h7XQ6k2fvD7y/yMfiUw9YTarJ7rWrq6o5/3CCXPouYk+XSLASvvxtjyiQLRBFt3qkE3oyA=="
},
"fast-deep-equal": { "fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -26373,15 +26387,6 @@
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.3.tgz", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.3.tgz",
"integrity": "sha512-J1DcMe8UYTBSrKezuIUTUwjXsho29693unXM2YhJUTR2txK/eG47bvNa/wipPFmZFgr/N6f1GA66dv0mEyTIyQ==" "integrity": "sha512-J1DcMe8UYTBSrKezuIUTUwjXsho29693unXM2YhJUTR2txK/eG47bvNa/wipPFmZFgr/N6f1GA66dv0mEyTIyQ=="
}, },
"is-ci": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz",
"integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==",
"optional": true,
"requires": {
"ci-info": "^3.2.0"
}
},
"is-core-module": { "is-core-module": {
"version": "2.13.1", "version": "2.13.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz",
@ -27015,14 +27020,6 @@
"webpack-sources": "^3.0.0" "webpack-sources": "^3.0.0"
} }
}, },
"lightweight-charts": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-3.8.0.tgz",
"integrity": "sha512-7yFGnYuE1RjRJG9RwUTBz5wvF1QtjBOSW4FFlikr8Dh+/TDNt4ci+HsWSYmStgQUpawpvkCJ3j5/W25GppGj9Q==",
"requires": {
"fancy-canvas": "0.2.2"
}
},
"limiter": { "limiter": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
@ -28806,12 +28803,6 @@
"event-stream": "=3.3.4" "event-stream": "=3.3.4"
} }
}, },
"psl": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
"integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==",
"optional": true
},
"public-encrypt": { "public-encrypt": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz",
@ -28903,12 +28894,6 @@
"resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz", "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz",
"integrity": "sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM=" "integrity": "sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM="
}, },
"querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"optional": true
},
"queue-microtask": { "queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -30373,6 +30358,21 @@
} }
} }
}, },
"tldts": {
"version": "6.1.70",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.70.tgz",
"integrity": "sha512-/W1YVgYVJd9ZDjey5NXadNh0mJXkiUMUue9Zebd0vpdo1sU+H4zFFTaJ1RKD4N6KFoHfcXy6l+Vu7bh+bdWCzA==",
"optional": true,
"requires": {
"tldts-core": "^6.1.70"
}
},
"tldts-core": {
"version": "6.1.70",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.70.tgz",
"integrity": "sha512-RNnIXDB1FD4T9cpQRErEqw6ZpjLlGdMOitdV+0xtbsnwr4YFka1zpc7D4KD+aAn8oSG5JyFrdasZTE04qDE9Yg==",
"optional": true
},
"tlite": { "tlite": {
"version": "0.1.9", "version": "0.1.9",
"resolved": "https://registry.npmjs.org/tlite/-/tlite-0.1.9.tgz", "resolved": "https://registry.npmjs.org/tlite/-/tlite-0.1.9.tgz",
@ -30405,23 +30405,12 @@
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="
}, },
"tough-cookie": { "tough-cookie": {
"version": "4.1.4", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.0.0.tgz",
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", "integrity": "sha512-FRKsF7cz96xIIeMZ82ehjC3xW2E+O2+v11udrDYewUbszngYhsGa8z6YUMMzO9QJZzzyd0nGGXnML/TReX6W8Q==",
"optional": true, "optional": true,
"requires": { "requires": {
"psl": "^1.1.33", "tldts": "^6.1.32"
"punycode": "^2.1.1",
"universalify": "^0.2.0",
"url-parse": "^1.5.3"
},
"dependencies": {
"universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"optional": true
}
} }
}, },
"transform-ast": { "transform-ast": {
@ -30757,16 +30746,6 @@
} }
} }
}, },
"url-parse": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"optional": true,
"requires": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
}
},
"util-deprecate": { "util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View file

@ -76,9 +76,9 @@
"@angular/router": "^17.3.1", "@angular/router": "^17.3.1",
"@angular/ssr": "^17.3.1", "@angular/ssr": "^17.3.1",
"@fortawesome/angular-fontawesome": "~0.14.1", "@fortawesome/angular-fontawesome": "~0.14.1",
"@fortawesome/fontawesome-common-types": "~6.6.0", "@fortawesome/fontawesome-common-types": "~6.7.2",
"@fortawesome/fontawesome-svg-core": "~6.6.0", "@fortawesome/fontawesome-svg-core": "~6.7.2",
"@fortawesome/free-solid-svg-icons": "~6.6.0", "@fortawesome/free-solid-svg-icons": "~6.7.2",
"@mempool/mempool.js": "2.3.0", "@mempool/mempool.js": "2.3.0",
"@ng-bootstrap/ng-bootstrap": "^16.0.0", "@ng-bootstrap/ng-bootstrap": "^16.0.0",
"@types/qrcode": "~1.5.0", "@types/qrcode": "~1.5.0",
@ -87,7 +87,6 @@
"clipboard": "^2.0.11", "clipboard": "^2.0.11",
"domino": "^2.1.6", "domino": "^2.1.6",
"echarts": "~5.5.0", "echarts": "~5.5.0",
"lightweight-charts": "~3.8.0",
"ngx-echarts": "~17.2.0", "ngx-echarts": "~17.2.0",
"ngx-infinite-scroll": "^17.0.0", "ngx-infinite-scroll": "^17.0.0",
"qrcode": "1.5.1", "qrcode": "1.5.1",
@ -115,7 +114,7 @@
"optionalDependencies": { "optionalDependencies": {
"@cypress/schematic": "^2.5.0", "@cypress/schematic": "^2.5.0",
"@types/cypress": "^1.1.3", "@types/cypress": "^1.1.3",
"cypress": "^13.15.0", "cypress": "^13.17.0",
"cypress-fail-on-console-error": "~5.1.0", "cypress-fail-on-console-error": "~5.1.0",
"cypress-wait-until": "^2.0.1", "cypress-wait-until": "^2.0.1",
"mock-socket": "~9.3.1", "mock-socket": "~9.3.1",

View file

@ -3,8 +3,10 @@ const fs = require('fs');
let PROXY_CONFIG = require('./proxy.conf'); let PROXY_CONFIG = require('./proxy.conf');
PROXY_CONFIG.forEach(entry => { PROXY_CONFIG.forEach(entry => {
entry.target = entry.target.replace("mempool.space", "mempool-staging.fra.mempool.space"); const hostname = process.env.CYPRESS_REROUTE_TESTNET === 'true' ? 'mempool-staging.fra.mempool.space' : 'node201.fmt.mempool.space';
entry.target = entry.target.replace("liquid.network", "liquid-staging.fra.mempool.space"); console.log(`e2e tests running against ${hostname}`);
entry.target = entry.target.replace("mempool.space", hostname);
entry.target = entry.target.replace("liquid.network", "liquid-staging.fmt.mempool.space");
}); });
module.exports = PROXY_CONFIG; module.exports = PROXY_CONFIG;

View file

@ -439,4 +439,39 @@ export const fiatCurrencies = {
code: 'ZAR', code: 'ZAR',
indexed: true, indexed: true,
}, },
}; };
export interface Timezone {
offset: string;
name: string;
}
export const timezones: Timezone[] = [
{ offset: '-12', name: 'Anywhere on Earth (AoE)' },
{ offset: '-11', name: 'Samoa Standard Time (SST)' },
{ offset: '-10', name: 'Hawaii Standard Time (HST)' },
{ offset: '-9', name: 'Alaska Standard Time (AKST)' },
{ offset: '-8', name: 'Pacific Standard Time (PST)' },
{ offset: '-7', name: 'Mountain Standard Time (MST)' },
{ offset: '-6', name: 'Central Standard Time (CST)' },
{ offset: '-5', name: 'Eastern Standard Time (EST)' },
{ offset: '-4', name: 'Atlantic Standard Time (AST)' },
{ offset: '-3', name: 'Argentina Time (ART)' },
{ offset: '-2', name: 'Fernando de Noronha Time (FNT)' },
{ offset: '-1', name: 'Azores Time (AZOT)' },
{ offset: '+0', name: 'Greenwich Mean Time (GMT)' },
{ offset: '+1', name: 'Central European Time (CET)' },
{ offset: '+2', name: 'Eastern European Time (EET)' },
{ offset: '+3', name: 'Moscow Standard Time (MSK)' },
{ offset: '+4', name: 'Armenia Time (AMT)' },
{ offset: '+5', name: 'Pakistan Standard Time (PKT)' },
{ offset: '+6', name: 'Xinjiang Time (XJT)' },
{ offset: '+7', name: 'Indochina Time (ICT)' },
{ offset: '+8', name: 'Hong Kong Time (HKT)' },
{ offset: '+9', name: 'Japan Standard Time (JST)' },
{ offset: '+10', name: 'Australian Eastern Standard Time (AEST)' },
{ offset: '+11', name: 'Norfolk Time (NFT)' },
{ offset: '+12', name: 'New Zealand Standard Time (NZST)' },
{ offset: '+13', name: 'Tonga Time (TOT)' },
{ offset: '+14', name: 'Line Islands Time (LINT)' }
];

View file

@ -1,4 +1,4 @@
<div class="box card w-100" style="background: var(--box-bg)" id=acceleratePreviewAnchor> <div class="box card w-100 accelerate-checkout-inner" [class.input-disabled]="isCheckoutLocked > 0" style="background: var(--box-bg)" id=acceleratePreviewAnchor>
@if (accelerateError) { @if (accelerateError) {
@if (accelerateError.includes('Payment declined')) { @if (accelerateError.includes('Payment declined')) {
<div class="row mb-1 text-center"> <div class="row mb-1 text-center">
@ -369,7 +369,7 @@
<div class="row text-center justify-content-center mx-2"> <div class="row text-center justify-content-center mx-2">
<p i18n="accelerator.payment-to-mempool-space">Payment to mempool.space for acceleration of txid <a [routerLink]="'/tx/' + tx.txid" target="_blank">{{ tx.txid.substr(0, 10) }}..{{ tx.txid.substr(-10) }}</a></p> <p i18n="accelerator.payment-to-mempool-space">Payment to mempool.space for acceleration of txid <a [routerLink]="'/tx/' + tx.txid" target="_blank">{{ tx.txid.substr(0, 10) }}..{{ tx.txid.substr(-10) }}</a></p>
</div> </div>
@if (canPayWithBalance || !(canPayWithBitcoin || canPayWithCashapp)) { @if (canPayWithBalance || !(canPayWithBitcoin || canPayWithCashapp || canPayWithApplePay || canPayWithGooglePay)) {
<div class="row"> <div class="row">
<div class="col-sm text-center d-flex flex-column justify-content-center align-items-center"> <div class="col-sm text-center d-flex flex-column justify-content-center align-items-center">
<p><ng-container i18n="accelerator.your-account-will-be-debited">Your account will be debited no more than</ng-container>&nbsp;<small style="font-family: monospace;">{{ cost | number }}</small>&nbsp;<span class="symbol" i18n="shared.sats">sats</span></p> <p><ng-container i18n="accelerator.your-account-will-be-debited">Your account will be debited no more than</ng-container>&nbsp;<small style="font-family: monospace;">{{ cost | number }}</small>&nbsp;<span class="symbol" i18n="shared.sats">sats</span></p>
@ -492,6 +492,11 @@
</div> </div>
} }
</div> </div>
@if (isTokenizing > 0) {
<div class="d-flex flex-row justify-content-center">
<div class="ml-2 spinner-border text-light" style="width: 25px; height: 25px"></div>
</div>
}
</div> </div>
</div> </div>

View file

@ -8,6 +8,13 @@
color: var(--green) color: var(--green)
} }
.accelerate-checkout-inner {
&.input-disabled {
pointer-events: none;
opacity: 0.75;
}
}
.paymentMethod { .paymentMethod {
padding: 10px; padding: 10px;
background-color: var(--secondary); background-color: var(--secondary);

View file

@ -2,7 +2,7 @@
import { Component, OnInit, OnDestroy, Output, EventEmitter, Input, ChangeDetectorRef, SimpleChanges, HostListener } from '@angular/core'; import { Component, OnInit, OnDestroy, Output, EventEmitter, Input, ChangeDetectorRef, SimpleChanges, HostListener } from '@angular/core';
import { Subscription, tap, of, catchError, Observable, switchMap } from 'rxjs'; import { Subscription, tap, of, catchError, Observable, switchMap } from 'rxjs';
import { ServicesApiServices } from '@app/services/services-api.service'; import { ServicesApiServices } from '@app/services/services-api.service';
import { md5, insecureRandomUUID } from '@app/shared/common.utils'; import { md5 } from '@app/shared/common.utils';
import { StateService } from '@app/services/state.service'; import { StateService } from '@app/services/state.service';
import { AudioService } from '@app/services/audio.service'; import { AudioService } from '@app/services/audio.service';
import { ETA, EtaService } from '@app/services/eta.service'; import { ETA, EtaService } from '@app/services/eta.service';
@ -76,6 +76,8 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
calculating = true; calculating = true;
processing = false; processing = false;
isCheckoutLocked = 0; // reference counter, 0 = unlocked, >0 = locked
isTokenizing = 0; // reference counter, 0 = false, >0 = true
selectedOption: 'wait' | 'accel'; selectedOption: 'wait' | 'accel';
cantPayReason = ''; cantPayReason = '';
quoteError = ''; // error fetching estimate or initial data quoteError = ''; // error fetching estimate or initial data
@ -94,7 +96,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
auth: IAuth | null = null; auth: IAuth | null = null;
// accelerator stuff // accelerator stuff
accelerationUUID: string;
accelerationSubscription: Subscription; accelerationSubscription: Subscription;
difficultySubscription: Subscription; difficultySubscription: Subscription;
estimateSubscription: Subscription; estimateSubscription: Subscription;
@ -138,7 +139,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
private enterpriseService: EnterpriseService, private enterpriseService: EnterpriseService,
) { ) {
this.isProdDomain = this.stateService.env.PROD_DOMAINS.indexOf(document.location.hostname) > -1; this.isProdDomain = this.stateService.env.PROD_DOMAINS.indexOf(document.location.hostname) > -1;
this.accelerationUUID = insecureRandomUUID();
// Check if Apple Pay available // Check if Apple Pay available
// https://developer.apple.com/documentation/apple_pay_on_the_web/apple_pay_js_api/checking_for_apple_pay_availability#overview // https://developer.apple.com/documentation/apple_pay_on_the_web/apple_pay_js_api/checking_for_apple_pay_availability#overview
@ -156,7 +156,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.accelerateError = null; this.accelerateError = null;
this.timePaid = 0; this.timePaid = 0;
this.btcpayInvoiceFailed = false; this.btcpayInvoiceFailed = false;
this.moveToStep('summary'); this.moveToStep('summary', true);
} else { } else {
this.auth = auth; this.auth = auth;
} }
@ -165,11 +165,11 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('cash_request_id')) { // Redirected from cashapp if (urlParams.get('cash_request_id')) { // Redirected from cashapp
this.moveToStep('processing'); this.moveToStep('processing', true);
this.insertSquare(); this.insertSquare();
this.setupSquare(); this.setupSquare();
} else { } else {
this.moveToStep('summary'); this.moveToStep('summary', true);
} }
this.conversionsSubscription = this.stateService.conversions$.subscribe( this.conversionsSubscription = this.stateService.conversions$.subscribe(
@ -194,14 +194,18 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
} }
if (changes.accelerating && this.accelerating) { if (changes.accelerating && this.accelerating) {
if (this.step === 'processing' || this.step === 'paid') { if (this.step === 'processing' || this.step === 'paid') {
this.moveToStep('success'); this.moveToStep('success', true);
} else { // Edge case where the transaction gets accelerated by someone else or on another session } else { // Edge case where the transaction gets accelerated by someone else or on another session
this.closeModal(); this.closeModal();
} }
} }
} }
moveToStep(step: CheckoutStep): void { moveToStep(step: CheckoutStep, force: boolean = false): void {
if (this.isCheckoutLocked > 0 && !force) {
return;
}
this.processing = false;
this._step = step; this._step = step;
if (this.timeoutTimer) { if (this.timeoutTimer) {
clearTimeout(this.timeoutTimer); clearTimeout(this.timeoutTimer);
@ -243,7 +247,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
closeModal(): void { closeModal(): void {
this.completed.emit(true); this.completed.emit(true);
this.moveToStep('summary'); this.moveToStep('summary', true);
} }
/** /**
@ -387,7 +391,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.accelerationSubscription = this.servicesApiService.accelerate$( this.accelerationSubscription = this.servicesApiService.accelerate$(
this.tx.txid, this.tx.txid,
this.userBid, this.userBid,
this.accelerationUUID
).subscribe({ ).subscribe({
next: () => { next: () => {
this.processing = false; this.processing = false;
@ -395,7 +398,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.audioService.playSound('ascend-chime-cartoon'); this.audioService.playSound('ascend-chime-cartoon');
this.showSuccess = true; this.showSuccess = true;
this.estimateSubscription.unsubscribe(); this.estimateSubscription.unsubscribe();
this.moveToStep('paid'); this.moveToStep('paid', true);
}, },
error: (response) => { error: (response) => {
this.processing = false; this.processing = false;
@ -505,57 +508,75 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
} }
this.loadingApplePay = false; this.loadingApplePay = false;
applePayButton.addEventListener('click', async event => { applePayButton.addEventListener('click', async event => {
if (this.isCheckoutLocked > 0 || this.isTokenizing > 0) {
return;
}
event.preventDefault(); event.preventDefault();
const tokenResult = await this.applePay.tokenize(); try {
if (tokenResult?.status === 'OK') { // lock the checkout UI and show a loading spinner until the square modals are finished
const card = tokenResult.details?.card; this.isCheckoutLocked++;
if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) { this.isTokenizing++;
console.error(`Cannot retreive payment card details`); const tokenResult = await this.applePay.tokenize();
this.accelerateError = 'apple_pay_no_card_details'; if (tokenResult?.status === 'OK') {
this.processing = false; const card = tokenResult.details?.card;
return; if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) {
} console.error(`Cannot retreive payment card details`);
const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase()); this.accelerateError = 'apple_pay_no_card_details';
this.servicesApiService.accelerateWithApplePay$(
this.tx.txid,
tokenResult.token,
cardTag,
`accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
this.accelerationUUID,
costUSD
).subscribe({
next: () => {
this.processing = false; this.processing = false;
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); return;
this.audioService.playSound('ascend-chime-cartoon');
if (this.applePay) {
this.applePay.destroy();
}
setTimeout(() => {
this.moveToStep('paid');
}, 1000);
},
error: (response) => {
this.processing = false;
this.accelerateError = response.error;
if (!(response.status === 403 && response.error === 'not_available')) {
setTimeout(() => {
// Reset everything by reloading the page :D, can be improved
const urlParams = new URLSearchParams(window.location.search);
window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``));
}, 10000);
}
} }
}); const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase());
} else { // keep checkout in loading state until the acceleration request completes
this.processing = false; this.isTokenizing++;
let errorMessage = `Tokenization failed with status: ${tokenResult.status}`; this.isCheckoutLocked++;
if (tokenResult.errors) { this.servicesApiService.accelerateWithApplePay$(
errorMessage += ` and errors: ${JSON.stringify( this.tx.txid,
tokenResult.errors, tokenResult.token,
)}`; cardTag,
`accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
costUSD
).subscribe({
next: () => {
this.processing = false;
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
this.audioService.playSound('ascend-chime-cartoon');
if (this.applePay) {
this.applePay.destroy();
}
setTimeout(() => {
this.isTokenizing--;
this.isCheckoutLocked--;
this.moveToStep('paid', true);
}, 1000);
},
error: (response) => {
this.processing = false;
this.accelerateError = response.error;
if (!(response.status === 403 && response.error === 'not_available')) {
setTimeout(() => {
this.isTokenizing--;
this.isCheckoutLocked--;
// Reset everything by reloading the page :D, can be improved
const urlParams = new URLSearchParams(window.location.search);
window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``));
}, 10000);
}
}
});
} else {
this.processing = false;
let errorMessage = `Tokenization failed with status: ${tokenResult.status}`;
if (tokenResult.errors) {
errorMessage += ` and errors: ${JSON.stringify(
tokenResult.errors,
)}`;
}
throw new Error(errorMessage);
} }
throw new Error(errorMessage); } finally {
// always unlock the checkout once we're finished
this.isTokenizing--;
this.isCheckoutLocked--;
} }
}); });
} catch (e) { } catch (e) {
@ -605,57 +626,84 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.loadingGooglePay = false; this.loadingGooglePay = false;
document.getElementById('google-pay-button').addEventListener('click', async event => { document.getElementById('google-pay-button').addEventListener('click', async event => {
if (this.isCheckoutLocked > 0 || this.isTokenizing > 0) {
return;
}
event.preventDefault(); event.preventDefault();
const tokenResult = await this.googlePay.tokenize(); try {
if (tokenResult?.status === 'OK') { // lock the checkout UI and show a loading spinner until the square modals are finished
const card = tokenResult.details?.card; this.isCheckoutLocked++;
if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) { this.isTokenizing++;
console.error(`Cannot retreive payment card details`); const tokenResult = await this.googlePay.tokenize();
this.accelerateError = 'apple_pay_no_card_details'; if (tokenResult?.status === 'OK') {
this.processing = false; const card = tokenResult.details?.card;
return; if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) {
} console.error(`Cannot retreive payment card details`);
const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase()); this.accelerateError = 'apple_pay_no_card_details';
this.servicesApiService.accelerateWithGooglePay$(
this.tx.txid,
tokenResult.token,
cardTag,
`accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
this.accelerationUUID,
costUSD
).subscribe({
next: () => {
this.processing = false; this.processing = false;
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); return;
this.audioService.playSound('ascend-chime-cartoon');
if (this.googlePay) {
this.googlePay.destroy();
}
setTimeout(() => {
this.moveToStep('paid');
}, 1000);
},
error: (response) => {
this.processing = false;
this.accelerateError = response.error;
if (!(response.status === 403 && response.error === 'not_available')) {
setTimeout(() => {
// Reset everything by reloading the page :D, can be improved
const urlParams = new URLSearchParams(window.location.search);
window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``));
}, 10000);
}
} }
}); const verificationToken = await this.$verifyBuyer(this.payments, tokenResult.token, tokenResult.details, costUSD.toFixed(2));
} else { if (!verificationToken || !verificationToken.token) {
this.processing = false; console.error(`SCA verification failed`);
let errorMessage = `Tokenization failed with status: ${tokenResult.status}`; this.accelerateError = 'SCA Verification Failed. Payment Declined.';
if (tokenResult.errors) { this.processing = false;
errorMessage += ` and errors: ${JSON.stringify( return;
tokenResult.errors, }
)}`; const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase());
// keep checkout in loading state until the acceleration request completes
this.isCheckoutLocked++;
this.isTokenizing++;
this.servicesApiService.accelerateWithGooglePay$(
this.tx.txid,
tokenResult.token,
verificationToken.token,
cardTag,
`accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
costUSD,
verificationToken.userChallenged
).subscribe({
next: () => {
this.processing = false;
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
this.audioService.playSound('ascend-chime-cartoon');
if (this.googlePay) {
this.googlePay.destroy();
}
setTimeout(() => {
this.isTokenizing--;
this.isCheckoutLocked--;
this.moveToStep('paid', true);
}, 1000);
},
error: (response) => {
this.processing = false;
this.accelerateError = response.error;
this.isTokenizing--;
this.isCheckoutLocked--;
if (!(response.status === 403 && response.error === 'not_available')) {
setTimeout(() => {
// Reset everything by reloading the page :D, can be improved
const urlParams = new URLSearchParams(window.location.search);
window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``));
}, 10000);
}
}
});
} else {
this.processing = false;
let errorMessage = `Tokenization failed with status: ${tokenResult.status}`;
if (tokenResult.errors) {
errorMessage += ` and errors: ${JSON.stringify(
tokenResult.errors,
)}`;
}
throw new Error(errorMessage);
} }
throw new Error(errorMessage); } finally {
// always unlock the checkout once we're finished
this.isTokenizing--;
this.isCheckoutLocked--;
} }
}); });
} }
@ -712,7 +760,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
tokenResult.token, tokenResult.token,
tokenResult.details.cashAppPay.cashtag, tokenResult.details.cashAppPay.cashtag,
tokenResult.details.cashAppPay.referenceId, tokenResult.details.cashAppPay.referenceId,
this.accelerationUUID,
costUSD costUSD
).subscribe({ ).subscribe({
next: () => { next: () => {
@ -723,7 +770,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.cashAppPay.destroy(); this.cashAppPay.destroy();
} }
setTimeout(() => { setTimeout(() => {
this.moveToStep('paid'); this.moveToStep('paid', true);
if (window.history.replaceState) { if (window.history.replaceState) {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
window.history.replaceState(null, null, window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, '')); window.history.replaceState(null, null, window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ''));
@ -748,6 +795,32 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
); );
} }
/**
* https://developer.squareup.com/docs/sca-overview
*/
async $verifyBuyer(payments, token, details, amount): Promise<{token: string, userChallenged: boolean}> {
const verificationDetails = {
amount: amount,
currencyCode: 'USD',
intent: 'CHARGE',
billingContact: {
givenName: details.card?.billing?.givenName,
familyName: details.card?.billing?.familyName,
phone: details.card?.billing?.phone,
addressLines: details.card?.billing?.addressLines,
city: details.card?.billing?.city,
state: details.card?.billing?.state,
countryCode: details.card?.billing?.countryCode,
},
};
const verificationResults = await payments.verifyBuyer(
token,
verificationDetails,
);
return verificationResults;
}
/** /**
* BTCPay * BTCPay
*/ */
@ -771,7 +844,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe(); this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
this.audioService.playSound('ascend-chime-cartoon'); this.audioService.playSound('ascend-chime-cartoon');
this.estimateSubscription.unsubscribe(); this.estimateSubscription.unsubscribe();
this.moveToStep('paid'); this.moveToStep('paid', true);
} }
isLoggedIn(): boolean { isLoggedIn(): boolean {

View file

@ -1,6 +1,6 @@
<div class="acceleration-timeline box" [class.lower-padding]="!tx.status.confirmed"> <div class="acceleration-timeline box" [class.lower-padding]="!tx.status.confirmed">
<div class="timeline-wrapper"> <div class="timeline-wrapper">
@if (!tx.status.confirmed) { @if (!tx.status.confirmed || canceled) {
<div class="timeline"> <div class="timeline">
<div class="intervals"> <div class="intervals">
<div class="node-spacer"></div> <div class="node-spacer"></div>
@ -8,7 +8,7 @@
<div class="node-spacer"></div> <div class="node-spacer"></div>
<div class="interval"> <div class="interval">
<div class="interval-time"> <div class="interval-time">
@if (eta) { @if (eta && !canceled) {
~<app-time [time]="eta?.wait / 1000"></app-time> ~<app-time [time]="eta?.wait / 1000"></app-time>
} }
</div> </div>
@ -19,16 +19,20 @@
<div class="node-spacer"></div> <div class="node-spacer"></div>
<div class="interval-spacer"></div> <div class="interval-spacer"></div>
<div class="node"> <div class="node">
<div class="acc-to-confirmed right go-faster"></div> <div class="acc-to-confirmed right go-faster" [class.no-animation]="canceled"></div>
</div> </div>
<div class="interval-spacer"> <div class="interval-spacer">
</div> </div>
<div class="node" [id]="'confirmed'"> <div class="node" [id]="'confirmed'">
<div class="acc-to-confirmed left go-faster"></div> <div class="acc-to-confirmed left go-faster" [class.no-animation]="canceled"></div>
<div class="shape-border waiting"> <div class="shape-border waiting">
<div class="shape"></div> <div class="shape"></div>
</div> </div>
<div class="status"><span class="badge badge-waiting" i18n="transaction.rbf.mined">Mined</span></div> @if (canceled) {
<div class="status"><span class="badge badge-danger" i18n="accelerator.canceled">Canceled</span></div>
} @else {
<div class="status"><span class="badge badge-waiting" i18n="transaction.rbf.mined">Mined</span></div>
}
</div> </div>
</div> </div>
</div> </div>
@ -45,9 +49,9 @@
<div class="interval"> <div class="interval">
<div class="interval-time"> <div class="interval-time">
@if (tx.status.confirmed) { @if (tx.status.confirmed) {
<div class="interval-time"> <app-time [time]="acceleratedToMined"></app-time>
<app-time [time]="acceleratedToMined"></app-time> } @else if (eta && canceled) {
</div> ~<app-time [time]="eta?.wait / 1000"></app-time>
} }
</div> </div>
</div> </div>
@ -71,42 +75,42 @@
<div class="interval-spacer"> <div class="interval-spacer">
<div class="seen-to-acc"></div> <div class="seen-to-acc"></div>
</div> </div>
<div class="node" [class.accelerated]="!tx.status.confirmed" [id]="'accelerated'"> <div class="node" [class.accelerated]="!tx.status.confirmed && !canceled" [id]="'accelerated'">
<div class="seen-to-acc left"></div> <div class="seen-to-acc left"></div>
@if (tx.status.confirmed) { @if (tx.status.confirmed && !canceled) {
<div class="acc-to-confirmed right"></div> <div class="acc-to-confirmed right"></div>
} @else { } @else {
<div class="seen-to-acc right"></div> <div class="seen-to-acc right"></div>
} }
<div class="shape-border hovering" (pointerover)="onHover($event, 'accelerated');" (pointerout)="onBlur($event);"> <div class="shape-border hovering" (pointerover)="onHover($event, 'accelerated');" (pointerout)="onBlur($event);">
<div class="shape"></div> <div class="shape"></div>
@if (!tx.status.confirmed) { @if (!tx.status.confirmed || canceled) {
<div class="connector down loading"></div> <div class="connector down" [class.loading]="!canceled"></div>
} }
</div> </div>
@if (tx.status.confirmed) { @if (tx.status.confirmed && !canceled) {
<div class="status"><span class="badge badge-accelerated" i18n="transaction.audit.accelerated">Accelerated</span></div> <div class="status"><span class="badge badge-accelerated" i18n="transaction.audit.accelerated">Accelerated</span></div>
} }
<div class="time" [class.no-margin]="!tx.status.confirmed" [class.offset-left]="!tx.status.confirmed"> <div class="time" [class.no-margin]="!tx.status.confirmed || canceled" [class.offset-left]="!tx.status.confirmed || canceled">
@if (!tx.status.confirmed) { @if (!tx.status.confirmed) {
<span i18n="transaction.audit.accelerated">Accelerated</span>{{ "" }} <span i18n="transaction.audit.accelerated">Accelerated</span>{{ "" }}
} }
@if (useAbsoluteTime) { @if (useAbsoluteTime) {
<span>{{ acceleratedAt * 1000 | date }}</span> <span>{{ acceleratedAt * 1000 | date }}</span>
} @else { } @else {
<app-time kind="since" [time]="acceleratedAt" [lowercaseStart]="!tx.status.confirmed"></app-time> <app-time kind="since" [time]="acceleratedAt" [lowercaseStart]="!tx.status.confirmed || canceled"></app-time>
} }
</div> </div>
</div> </div>
<div class="interval-spacer"> <div class="interval-spacer">
@if (tx.status.confirmed) { @if (tx.status.confirmed && !canceled) {
<div class="acc-to-confirmed"></div> <div class="acc-to-confirmed"></div>
} @else { } @else {
<div class="seen-to-acc"></div> <div class="seen-to-acc"></div>
} }
</div> </div>
<div class="node" [class.selected]="tx.status.confirmed" [id]="'confirmed'"> <div class="node" [class.selected]="tx.status.confirmed" [id]="'confirmed'">
@if (tx.status.confirmed) { @if (tx.status.confirmed && !canceled) {
<div class="acc-to-confirmed left"></div> <div class="acc-to-confirmed left"></div>
} @else { } @else {
<div class="seen-to-acc left"></div> <div class="seen-to-acc left"></div>

View file

@ -129,6 +129,9 @@
margin-left: calc(-4em + 5px); margin-left: calc(-4em + 5px);
animation: goFasterLeft 0.8s infinite linear; animation: goFasterLeft 0.8s infinite linear;
} }
&.no-animation {
animation: none;
}
} }
&.left { &.left {

View file

@ -15,6 +15,7 @@ export class AccelerationTimelineComponent implements OnInit, OnChanges {
@Input() tx: Transaction; @Input() tx: Transaction;
@Input() accelerationInfo: Acceleration; @Input() accelerationInfo: Acceleration;
@Input() eta: ETA; @Input() eta: ETA;
@Input() canceled: boolean;
now: number; now: number;
accelerateRatio: number; accelerateRatio: number;

View file

@ -46,6 +46,8 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest
aggregatedHistory$: Observable<any>; aggregatedHistory$: Observable<any>;
statsSubscription: Subscription; statsSubscription: Subscription;
aggregatedHistorySubscription: Subscription;
fragmentSubscription: Subscription;
isLoading = true; isLoading = true;
formatNumber = formatNumber; formatNumber = formatNumber;
timespan = ''; timespan = '';
@ -79,8 +81,8 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest
} }
this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference });
this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference);
this.route.fragment.subscribe((fragment) => { this.fragmentSubscription = this.route.fragment.subscribe((fragment) => {
if (['24h', '3d', '1w', '1m', '3m', 'all'].indexOf(fragment) > -1) { if (['24h', '3d', '1w', '1m', '3m', 'all'].indexOf(fragment) > -1) {
this.radioGroupForm.controls.dateSpan.setValue(fragment, { emitEvent: false }); this.radioGroupForm.controls.dateSpan.setValue(fragment, { emitEvent: false });
} }
@ -113,7 +115,7 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest
share(), share(),
); );
this.aggregatedHistory$.subscribe(); this.aggregatedHistorySubscription = this.aggregatedHistory$.subscribe();
} }
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
@ -335,8 +337,8 @@ export class AccelerationFeesGraphComponent implements OnInit, OnChanges, OnDest
} }
ngOnDestroy(): void { ngOnDestroy(): void {
if (this.statsSubscription) { this.aggregatedHistorySubscription?.unsubscribe();
this.statsSubscription.unsubscribe(); this.fragmentSubscription?.unsubscribe();
} this.statsSubscription?.unsubscribe();
} }
} }

View file

@ -4,7 +4,7 @@
<div class="clearfix"></div> <div class="clearfix"></div>
<div class="acceleration-list"> <div class="acceleration-list" *ngIf="{ accelerations: accelerationList$ | async } as state">
<table *ngIf="nonEmptyAccelerations; else noData" class="table table-borderless table-fixed"> <table *ngIf="nonEmptyAccelerations; else noData" class="table table-borderless table-fixed">
<thead> <thead>
<th class="txid text-left" i18n="dashboard.latest-transactions.txid">TXID</th> <th class="txid text-left" i18n="dashboard.latest-transactions.txid">TXID</th>
@ -21,8 +21,8 @@
<th class="date text-right" i18n="accelerator.requested" *ngIf="!this.widget">Requested</th> <th class="date text-right" i18n="accelerator.requested" *ngIf="!this.widget">Requested</th>
</ng-container> </ng-container>
</thead> </thead>
<tbody *ngIf="accelerationList$ | async as accelerations; else skeleton" [style]="isLoading ? 'opacity: 0.75' : ''"> <tbody *ngIf="state.accelerations && nonEmptyAccelerations; else skeleton" [style]="isLoading ? 'opacity: 0.75' : ''">
<tr *ngFor="let acceleration of accelerations; let i= index;"> <tr *ngFor="let acceleration of state.accelerations; let i= index;">
<td class="txid text-left"> <td class="txid text-left">
<a [routerLink]="['/tx' | relativeUrl, acceleration.txid]"> <a [routerLink]="['/tx' | relativeUrl, acceleration.txid]">
<app-truncate [text]="acceleration.txid" [lastChars]="5"></app-truncate> <app-truncate [text]="acceleration.txid" [lastChars]="5"></app-truncate>

View file

@ -10,7 +10,6 @@ import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pip
import { StateService } from '@app/services/state.service'; import { StateService } from '@app/services/state.service';
import { PriceService } from '@app/services/price.service'; import { PriceService } from '@app/services/price.service';
import { FiatCurrencyPipe } from '@app/shared/pipes/fiat-currency.pipe'; import { FiatCurrencyPipe } from '@app/shared/pipes/fiat-currency.pipe';
import { FiatShortenerPipe } from '@app/shared/pipes/fiat-shortener.pipe';
const periodSeconds = { const periodSeconds = {
'1d': (60 * 60 * 24), '1d': (60 * 60 * 24),
@ -45,14 +44,18 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
@Input() right: number | string = 10; @Input() right: number | string = 10;
@Input() left: number | string = 70; @Input() left: number | string = 70;
@Input() widget: boolean = false; @Input() widget: boolean = false;
@Input() defaultFiat: boolean = false;
@Input() showLegend: boolean = true;
@Input() showYAxis: boolean = true;
adjustedLeft: number;
adjustedRight: number;
data: any[] = []; data: any[] = [];
fiatData: any[] = []; fiatData: any[] = [];
hoverData: any[] = []; hoverData: any[] = [];
conversions: any; conversions: any;
allowZoom: boolean = false; allowZoom: boolean = false;
initialRight = this.right;
initialLeft = this.left;
selected = { [$localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`]: true, 'Fiat': false }; selected = { [$localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`]: true, 'Fiat': false };
subscription: Subscription; subscription: Subscription;
@ -77,7 +80,6 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
private relativeUrlPipe: RelativeUrlPipe, private relativeUrlPipe: RelativeUrlPipe,
private priceService: PriceService, private priceService: PriceService,
private fiatCurrencyPipe: FiatCurrencyPipe, private fiatCurrencyPipe: FiatCurrencyPipe,
private fiatShortenerPipe: FiatShortenerPipe,
private zone: NgZone, private zone: NgZone,
) {} ) {}
@ -86,6 +88,9 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
if (!this.addressSummary$ && (!this.address || !this.stats)) { if (!this.addressSummary$ && (!this.address || !this.stats)) {
return; return;
} }
if (changes.defaultFiat) {
this.selected['Fiat'] = !!this.defaultFiat;
}
if (changes.address || changes.isPubkey || changes.addressSummary$ || changes.stats) { if (changes.address || changes.isPubkey || changes.addressSummary$ || changes.stats) {
if (this.subscription) { if (this.subscription) {
this.subscription.unsubscribe(); this.subscription.unsubscribe();
@ -118,7 +123,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
} else if (this.conversions && this.conversions['USD']) { } else if (this.conversions && this.conversions['USD']) {
price = this.conversions['USD']; price = this.conversions['USD'];
} }
return { ...item, price: price } return { ...item, price: price };
}); });
} }
}), }),
@ -147,7 +152,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
if (!summary) { if (!summary) {
return; return;
} }
const total = this.stats ? (this.stats.funded_txo_sum - this.stats.spent_txo_sum) : summary.reduce((acc, tx) => acc + tx.value, 0); const total = this.stats ? (this.stats.funded_txo_sum - this.stats.spent_txo_sum) : summary.reduce((acc, tx) => acc + tx.value, 0);
let runningTotal = total; let runningTotal = total;
const processData = summary.map(d => { const processData = summary.map(d => {
@ -161,7 +166,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
d d
}; };
}).reverse(); }).reverse();
this.data = processData.filter(({ d }) => d.txid !== undefined).map(({ time, balance, d }) => [time, balance, d]); this.data = processData.filter(({ d }) => d.txid !== undefined).map(({ time, balance, d }) => [time, balance, d]);
this.fiatData = processData.map(({ time, fiatBalance, balance, d }) => [time, fiatBalance, d, balance]); this.fiatData = processData.map(({ time, fiatBalance, balance, d }) => [time, fiatBalance, d, balance]);
@ -179,6 +184,9 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
const maxValue = this.data.reduce((acc, d) => Math.max(acc, Math.abs(d[1] ?? d.value[1])), 0); const maxValue = this.data.reduce((acc, d) => Math.max(acc, Math.abs(d[1] ?? d.value[1])), 0);
const minValue = this.data.reduce((acc, d) => Math.min(acc, Math.abs(d[1] ?? d.value[1])), maxValue); const minValue = this.data.reduce((acc, d) => Math.min(acc, Math.abs(d[1] ?? d.value[1])), maxValue);
this.adjustedRight = this.selected['Fiat'] ? +this.right + 40 : +this.right;
this.adjustedLeft = this.selected[$localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`] ? +this.left : +this.left - 40;
this.chartOptions = { this.chartOptions = {
color: [ color: [
new echarts.graphic.LinearGradient(0, 0, 0, 1, [ new echarts.graphic.LinearGradient(0, 0, 0, 1, [
@ -194,10 +202,10 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
grid: { grid: {
top: 20, top: 20,
bottom: this.allowZoom ? 65 : 20, bottom: this.allowZoom ? 65 : 20,
right: this.right, right: this.adjustedRight,
left: this.left, left: this.adjustedLeft,
}, },
legend: !this.stateService.isAnyTestnet() ? { legend: (this.showLegend && !this.stateService.isAnyTestnet()) ? {
data: [ data: [
{ {
name: $localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`, name: $localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`,
@ -245,21 +253,22 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
let tooltip = '<div>'; let tooltip = '<div>';
const hasTx = data[0].data[2].txid; const hasTx = data[0].data[2].txid;
const date = new Date(data[0].data[0]).toLocaleTimeString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' });
tooltip += `<div>
<div style="text-align: right;">
<div><b>${date}</b></div>`;
if (hasTx) { if (hasTx) {
const header = data.length === 1 const header = data.length === 1
? `${data[0].data[2].txid.slice(0, 6)}...${data[0].data[2].txid.slice(-6)}` ? `${data[0].data[2].txid.slice(0, 6)}...${data[0].data[2].txid.slice(-6)}`
: `${data.length} transactions`; : `${data.length} transactions`;
tooltip += `<span><b>${header}</b></span>`; tooltip += `<div><b>${header}</b></div>`;
} }
const date = new Date(data[0].data[0]).toLocaleTimeString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' });
tooltip += `<div>
<div style="text-align: right;">`;
const formatBTC = (val, decimal) => (val / 100_000_000).toFixed(decimal); const formatBTC = (val, decimal) => (val / 100_000_000).toFixed(decimal);
const formatFiat = (val) => this.fiatCurrencyPipe.transform(val, null, 'USD'); const formatFiat = (val) => this.fiatCurrencyPipe.transform(val, null, 'USD');
const btcVal = btcData.reduce((total, d) => total + d.data[2].value, 0); const btcVal = btcData.reduce((total, d) => total + d.data[2].value, 0);
const fiatVal = fiatData.reduce((total, d) => total + d.data[2].value * d.data[2].price / 100_000_000, 0); const fiatVal = fiatData.reduce((total, d) => total + d.data[2].value * d.data[2].price / 100_000_000, 0);
const btcColor = btcVal === 0 ? '' : (btcVal > 0 ? 'var(--green)' : 'var(--red)'); const btcColor = btcVal === 0 ? '' : (btcVal > 0 ? 'var(--green)' : 'var(--red)');
@ -291,7 +300,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
} }
} }
tooltip += `</div><span>${date}</span></div>`; tooltip += `</div></div>`;
return tooltip; return tooltip;
}.bind(this) }.bind(this)
}, },
@ -307,22 +316,26 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
type: 'value', type: 'value',
position: 'left', position: 'left',
axisLabel: { axisLabel: {
show: this.showYAxis,
color: 'rgb(110, 112, 121)', color: 'rgb(110, 112, 121)',
formatter: (val): string => { formatter: (val): string => {
let valSpan = maxValue - (this.period === 'all' ? 0 : minValue); let valSpan = maxValue - (this.period === 'all' ? 0 : minValue);
if (valSpan > 100_000_000_000) { if (valSpan > 100_000_000_000) {
return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 0)} BTC`; return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 0, undefined, true)} BTC`;
} }
else if (valSpan > 1_000_000_000) { else if (valSpan > 1_000_000_000) {
return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 2)} BTC`; return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 2, undefined, true)} BTC`;
} else if (valSpan > 100_000_000) { } else if (valSpan > 100_000_000) {
return `${(val / 100_000_000).toFixed(1)} BTC`; return `${(val / 100_000_000).toFixed(1)} BTC`;
} else if (valSpan > 10_000_000) { } else if (valSpan > 10_000_000) {
return `${(val / 100_000_000).toFixed(2)} BTC`; return `${(val / 100_000_000).toFixed(2)} BTC`;
} else if (valSpan > 1_000_000) { } else if (valSpan > 1_000_000) {
if (maxValue > 100_000_000_000) {
return `${this.amountShortenerPipe.transform(Math.round(val / 100_000_000), 3, undefined, true)} BTC`;
}
return `${(val / 100_000_000).toFixed(3)} BTC`; return `${(val / 100_000_000).toFixed(3)} BTC`;
} else { } else {
return `${this.amountShortenerPipe.transform(val, 0)} sats`; return `${this.amountShortenerPipe.transform(val, 0, undefined, true)} sats`;
} }
} }
}, },
@ -334,9 +347,10 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
{ {
type: 'value', type: 'value',
axisLabel: { axisLabel: {
show: this.showYAxis,
color: 'rgb(110, 112, 121)', color: 'rgb(110, 112, 121)',
formatter: function(val) { formatter: function(val) {
return this.fiatShortenerPipe.transform(val, null, 'USD'); return `$${this.amountShortenerPipe.transform(val, 3, undefined, true, true)}`;
}.bind(this) }.bind(this)
}, },
splitLine: { splitLine: {
@ -390,8 +404,8 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
type: 'slider', type: 'slider',
brushSelect: false, brushSelect: false,
realtime: true, realtime: true,
left: this.left, left: this.adjustedLeft,
right: this.right, right: this.adjustedRight,
selectedDataBackground: { selectedDataBackground: {
lineStyle: { lineStyle: {
color: '#fff', color: '#fff',
@ -404,7 +418,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
onChartClick(e) { onChartClick(e) {
if (this.hoverData?.length && this.hoverData[0]?.[2]?.txid) { if (this.hoverData?.length && this.hoverData[0]?.[2]?.txid) {
this.zone.run(() => { this.zone.run(() => {
const url = this.relativeUrlPipe.transform(`/tx/${this.hoverData[0][2].txid}`); const url = this.relativeUrlPipe.transform(`/tx/${this.hoverData[0][2].txid}`);
if (e.event.event.shiftKey || e.event.event.ctrlKey || e.event.event.metaKey) { if (e.event.event.shiftKey || e.event.event.ctrlKey || e.event.event.metaKey) {
window.open(url); window.open(url);
@ -421,26 +435,26 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
onLegendSelectChanged(e) { onLegendSelectChanged(e) {
this.selected = e.selected; this.selected = e.selected;
this.right = this.selected['Fiat'] ? +this.initialRight + 40 : this.initialRight; this.adjustedRight = this.selected['Fiat'] ? +this.right + 40 : +this.right;
this.left = this.selected[$localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`] ? this.initialLeft : +this.initialLeft - 40; this.adjustedLeft = this.selected[$localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`] ? +this.left : +this.left - 40;
this.chartOptions = { this.chartOptions = {
grid: { grid: {
right: this.right, right: this.adjustedRight,
left: this.left, left: this.adjustedLeft,
}, },
legend: { legend: {
selected: this.selected, selected: this.selected,
}, },
dataZoom: this.allowZoom ? [{ dataZoom: this.allowZoom ? [{
left: this.left, left: this.adjustedLeft,
right: this.right, right: this.adjustedRight,
}, { }, {
left: this.left, left: this.adjustedLeft,
right: this.right, right: this.adjustedRight,
}] : undefined }] : undefined
}; };
if (this.chartInstance) { if (this.chartInstance) {
this.chartInstance.setOption(this.chartOptions); this.chartInstance.setOption(this.chartOptions);
} }
@ -464,25 +478,30 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
} }
extendSummary(summary) { extendSummary(summary) {
let extendedSummary = summary.slice(); const extendedSummary = summary.slice();
// Add a point at today's date to make the graph end at the current time // Add a point at today's date to make the graph end at the current time
extendedSummary.unshift({ time: Date.now() / 1000, value: 0 }); extendedSummary.unshift({ time: Date.now() / 1000, value: 0 });
extendedSummary.reverse();
let maxTime = Date.now() / 1000;
let oneHour = 60 * 60;
const oneHour = 60 * 60;
// Fill gaps longer than interval // Fill gaps longer than interval
for (let i = 0; i < extendedSummary.length - 1; i++) { for (let i = 0; i < extendedSummary.length - 1; i++) {
let hours = Math.floor((extendedSummary[i + 1].time - extendedSummary[i].time) / oneHour); if (extendedSummary[i].time > maxTime) {
extendedSummary[i].time = maxTime - 30;
}
maxTime = extendedSummary[i].time;
const hours = Math.floor((extendedSummary[i].time - extendedSummary[i + 1].time) / oneHour);
if (hours > 1) { if (hours > 1) {
for (let j = 1; j < hours; j++) { for (let j = 1; j < hours; j++) {
let newTime = extendedSummary[i].time + oneHour * j; const newTime = extendedSummary[i].time - oneHour * j;
extendedSummary.splice(i + j, 0, { time: newTime, value: 0 }); extendedSummary.splice(i + j, 0, { time: newTime, value: 0 });
} }
i += hours - 1; i += hours - 1;
} }
} }
return extendedSummary.reverse(); return extendedSummary;
} }
} }

View file

@ -41,7 +41,7 @@ export class AppComponent implements OnInit {
@HostListener('document:keydown', ['$event']) @HostListener('document:keydown', ['$event'])
handleKeyboardEvents(event: KeyboardEvent) { handleKeyboardEvents(event: KeyboardEvent) {
if (event.target instanceof HTMLInputElement) { if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return; return;
} }
// prevent arrow key horizontal scrolling // prevent arrow key horizontal scrolling

View file

@ -172,13 +172,19 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
ngOnDestroy(): void { ngOnDestroy(): void {
if (this.animationFrameRequest) { if (this.animationFrameRequest) {
cancelAnimationFrame(this.animationFrameRequest); cancelAnimationFrame(this.animationFrameRequest);
clearTimeout(this.animationHeartBeat);
} }
clearTimeout(this.animationHeartBeat);
if (this.canvas) { if (this.canvas) {
this.canvas.nativeElement.removeEventListener('webglcontextlost', this.handleContextLost); this.canvas.nativeElement.removeEventListener('webglcontextlost', this.handleContextLost);
this.canvas.nativeElement.removeEventListener('webglcontextrestored', this.handleContextRestored); this.canvas.nativeElement.removeEventListener('webglcontextrestored', this.handleContextRestored);
this.themeChangedSubscription?.unsubscribe();
} }
if (this.scene) {
this.scene.destroy();
}
this.vertexArray.destroy();
this.vertexArray = null;
this.themeChangedSubscription?.unsubscribe();
this.searchSubscription?.unsubscribe();
} }
clear(direction): void { clear(direction): void {
@ -447,7 +453,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
} }
this.applyQueuedUpdates(); this.applyQueuedUpdates();
// skip re-render if there's no change to the scene // skip re-render if there's no change to the scene
if (this.scene && this.gl) { if (this.scene && this.gl && this.vertexArray) {
/* SET UP SHADER UNIFORMS */ /* SET UP SHADER UNIFORMS */
// screen dimensions // screen dimensions
this.gl.uniform2f(this.gl.getUniformLocation(this.shaderProgram, 'screenSize'), this.displayWidth, this.displayHeight); this.gl.uniform2f(this.gl.getUniformLocation(this.shaderProgram, 'screenSize'), this.displayWidth, this.displayHeight);
@ -489,9 +495,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
if (this.running && this.scene && now <= (this.scene.animateUntil + 500)) { if (this.running && this.scene && now <= (this.scene.animateUntil + 500)) {
this.doRun(); this.doRun();
} else { } else {
if (this.animationHeartBeat) { clearTimeout(this.animationHeartBeat);
clearTimeout(this.animationHeartBeat);
}
this.animationHeartBeat = window.setTimeout(() => { this.animationHeartBeat = window.setTimeout(() => {
this.start(); this.start();
}, 1000); }, 1000);

View file

@ -19,6 +19,7 @@ export class FastVertexArray {
freeSlots: number[]; freeSlots: number[];
lastSlot: number; lastSlot: number;
dirty = false; dirty = false;
destroyed = false;
constructor(length, stride) { constructor(length, stride) {
this.length = length; this.length = length;
@ -32,6 +33,9 @@ export class FastVertexArray {
} }
insert(sprite: TxSprite): number { insert(sprite: TxSprite): number {
if (this.destroyed) {
return;
}
this.count++; this.count++;
let position; let position;
@ -45,11 +49,14 @@ export class FastVertexArray {
} }
} }
this.sprites[position] = sprite; this.sprites[position] = sprite;
return position;
this.dirty = true; this.dirty = true;
return position;
} }
remove(index: number): void { remove(index: number): void {
if (this.destroyed) {
return;
}
this.count--; this.count--;
this.clearData(index); this.clearData(index);
this.freeSlots.push(index); this.freeSlots.push(index);
@ -61,20 +68,26 @@ export class FastVertexArray {
} }
setData(index: number, dataChunk: number[]): void { setData(index: number, dataChunk: number[]): void {
if (this.destroyed) {
return;
}
this.data.set(dataChunk, (index * this.stride)); this.data.set(dataChunk, (index * this.stride));
this.dirty = true; this.dirty = true;
} }
clearData(index: number): void { private clearData(index: number): void {
this.data.fill(0, (index * this.stride), ((index + 1) * this.stride)); this.data.fill(0, (index * this.stride), ((index + 1) * this.stride));
this.dirty = true; this.dirty = true;
} }
getData(index: number): Float32Array { getData(index: number): Float32Array {
if (this.destroyed) {
return;
}
return this.data.subarray(index, this.stride); return this.data.subarray(index, this.stride);
} }
expand(): void { private expand(): void {
this.length *= 2; this.length *= 2;
const newData = new Float32Array(this.length * this.stride); const newData = new Float32Array(this.length * this.stride);
newData.set(this.data); newData.set(this.data);
@ -82,7 +95,7 @@ export class FastVertexArray {
this.dirty = true; this.dirty = true;
} }
compact(): void { private compact(): void {
// New array length is the smallest power of 2 larger than the sprite count (but no smaller than 512) // New array length is the smallest power of 2 larger than the sprite count (but no smaller than 512)
const newLength = Math.max(512, Math.pow(2, Math.ceil(Math.log2(this.count)))); const newLength = Math.max(512, Math.pow(2, Math.ceil(Math.log2(this.count))));
if (newLength !== this.length) { if (newLength !== this.length) {
@ -110,4 +123,13 @@ export class FastVertexArray {
getVertexData(): Float32Array { getVertexData(): Float32Array {
return this.data; return this.data;
} }
destroy(): void {
this.data = null;
this.sprites = null;
this.freeSlots = null;
this.lastSlot = 0;
this.dirty = false;
this.destroyed = true;
}
} }

View file

@ -116,7 +116,7 @@ export class BlockViewComponent implements OnInit, OnDestroy {
this.isLoadingBlock = false; this.isLoadingBlock = false;
this.isLoadingOverview = true; this.isLoadingOverview = true;
}), }),
shareReplay(1) shareReplay({ bufferSize: 1, refCount: true })
); );
this.overviewSubscription = block$.pipe( this.overviewSubscription = block$.pipe(
@ -176,5 +176,8 @@ export class BlockViewComponent implements OnInit, OnDestroy {
if (this.queryParamsSubscription) { if (this.queryParamsSubscription) {
this.queryParamsSubscription.unsubscribe(); this.queryParamsSubscription.unsubscribe();
} }
if (this.blockGraph) {
this.blockGraph.destroy();
}
} }
} }

View file

@ -117,7 +117,7 @@ export class BlockPreviewComponent implements OnInit, OnDestroy {
this.openGraphService.waitOver('block-data-' + this.rawId); this.openGraphService.waitOver('block-data-' + this.rawId);
}), }),
throttleTime(50, asyncScheduler, { leading: true, trailing: true }), throttleTime(50, asyncScheduler, { leading: true, trailing: true }),
shareReplay(1) shareReplay({ bufferSize: 1, refCount: true })
); );
this.overviewSubscription = block$.pipe( this.overviewSubscription = block$.pipe(

View file

@ -1,8 +1,8 @@
import { Component, OnInit, OnDestroy, ViewChildren, QueryList, ChangeDetectorRef } from '@angular/core'; import { Component, OnInit, OnDestroy, ViewChildren, QueryList, ChangeDetectorRef } from '@angular/core';
import { Location } from '@angular/common'; import { Location } from '@angular/common';
import { ActivatedRoute, ParamMap, Router } from '@angular/router'; import { ActivatedRoute, ParamMap, Params, Router } from '@angular/router';
import { ElectrsApiService } from '@app/services/electrs-api.service'; import { ElectrsApiService } from '@app/services/electrs-api.service';
import { switchMap, tap, throttleTime, catchError, map, shareReplay, startWith, filter } from 'rxjs/operators'; import { switchMap, tap, throttleTime, catchError, map, shareReplay, startWith, filter, take } from 'rxjs/operators';
import { Observable, of, Subscription, asyncScheduler, EMPTY, combineLatest, forkJoin } from 'rxjs'; import { Observable, of, Subscription, asyncScheduler, EMPTY, combineLatest, forkJoin } from 'rxjs';
import { StateService } from '@app/services/state.service'; import { StateService } from '@app/services/state.service';
import { SeoService } from '@app/services/seo.service'; import { SeoService } from '@app/services/seo.service';
@ -68,6 +68,7 @@ export class BlockComponent implements OnInit, OnDestroy {
paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5; paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5;
numUnexpected: number = 0; numUnexpected: number = 0;
mode: 'projected' | 'actual' = 'projected'; mode: 'projected' | 'actual' = 'projected';
currentQueryParams: Params;
overviewSubscription: Subscription; overviewSubscription: Subscription;
accelerationsSubscription: Subscription; accelerationsSubscription: Subscription;
@ -80,8 +81,8 @@ export class BlockComponent implements OnInit, OnDestroy {
timeLtr: boolean; timeLtr: boolean;
childChangeSubscription: Subscription; childChangeSubscription: Subscription;
auditPrefSubscription: Subscription; auditPrefSubscription: Subscription;
isAuditEnabledSubscription: Subscription;
oobSubscription: Subscription; oobSubscription: Subscription;
priceSubscription: Subscription; priceSubscription: Subscription;
blockConversion: Price; blockConversion: Price;
@ -118,7 +119,7 @@ export class BlockComponent implements OnInit, OnDestroy {
this.setAuditAvailable(this.auditSupported); this.setAuditAvailable(this.auditSupported);
if (this.auditSupported) { if (this.auditSupported) {
this.isAuditEnabledFromParam().subscribe(auditParam => { this.isAuditEnabledSubscription = this.isAuditEnabledFromParam().subscribe(auditParam => {
if (this.auditParamEnabled) { if (this.auditParamEnabled) {
this.auditModeEnabled = auditParam; this.auditModeEnabled = auditParam;
} else { } else {
@ -281,7 +282,7 @@ export class BlockComponent implements OnInit, OnDestroy {
} }
}), }),
throttleTime(300, asyncScheduler, { leading: true, trailing: true }), throttleTime(300, asyncScheduler, { leading: true, trailing: true }),
shareReplay(1) shareReplay({ bufferSize: 1, refCount: true })
); );
this.overviewSubscription = this.block$.pipe( this.overviewSubscription = this.block$.pipe(
@ -363,6 +364,7 @@ export class BlockComponent implements OnInit, OnDestroy {
.subscribe((network) => this.network = network); .subscribe((network) => this.network = network);
this.queryParamsSubscription = this.route.queryParams.subscribe((params) => { this.queryParamsSubscription = this.route.queryParams.subscribe((params) => {
this.currentQueryParams = params;
if (params.showDetails === 'true') { if (params.showDetails === 'true') {
this.showDetails = true; this.showDetails = true;
} else { } else {
@ -414,6 +416,7 @@ export class BlockComponent implements OnInit, OnDestroy {
ngOnDestroy(): void { ngOnDestroy(): void {
this.stateService.markBlock$.next({}); this.stateService.markBlock$.next({});
this.overviewSubscription?.unsubscribe(); this.overviewSubscription?.unsubscribe();
this.accelerationsSubscription?.unsubscribe();
this.keyNavigationSubscription?.unsubscribe(); this.keyNavigationSubscription?.unsubscribe();
this.blocksSubscription?.unsubscribe(); this.blocksSubscription?.unsubscribe();
this.cacheBlocksSubscription?.unsubscribe(); this.cacheBlocksSubscription?.unsubscribe();
@ -421,8 +424,16 @@ export class BlockComponent implements OnInit, OnDestroy {
this.queryParamsSubscription?.unsubscribe(); this.queryParamsSubscription?.unsubscribe();
this.timeLtrSubscription?.unsubscribe(); this.timeLtrSubscription?.unsubscribe();
this.childChangeSubscription?.unsubscribe(); this.childChangeSubscription?.unsubscribe();
this.priceSubscription?.unsubscribe(); this.auditPrefSubscription?.unsubscribe();
this.isAuditEnabledSubscription?.unsubscribe();
this.oobSubscription?.unsubscribe(); this.oobSubscription?.unsubscribe();
this.priceSubscription?.unsubscribe();
this.blockGraphProjected.forEach(graph => {
graph.destroy();
});
this.blockGraphActual.forEach(graph => {
graph.destroy();
});
} }
// TODO - Refactor this.fees/this.reward for liquid because it is not // TODO - Refactor this.fees/this.reward for liquid because it is not
@ -733,19 +744,18 @@ export class BlockComponent implements OnInit, OnDestroy {
toggleAuditMode(): void { toggleAuditMode(): void {
this.stateService.hideAudit.next(this.auditModeEnabled); this.stateService.hideAudit.next(this.auditModeEnabled);
this.route.queryParams.subscribe(params => { const queryParams = { ...this.currentQueryParams };
const queryParams = { ...params }; delete queryParams['audit'];
delete queryParams['audit'];
let newUrl = this.router.url.split('?')[0]; let newUrl = this.router.url.split('?')[0];
const queryString = new URLSearchParams(queryParams).toString(); const queryString = new URLSearchParams(queryParams).toString();
if (queryString) { if (queryString) {
newUrl += '?' + queryString; newUrl += '?' + queryString;
} }
this.location.replaceState(newUrl);
this.location.replaceState(newUrl);
});
// avoid duplicate subscriptions
this.auditPrefSubscription?.unsubscribe();
this.auditPrefSubscription = this.stateService.hideAudit.subscribe((hide) => { this.auditPrefSubscription = this.stateService.hideAudit.subscribe((hide) => {
this.auditModeEnabled = !hide; this.auditModeEnabled = !hide;
this.showAudit = this.auditAvailable && this.auditModeEnabled; this.showAudit = this.auditAvailable && this.auditModeEnabled;
@ -762,7 +772,7 @@ export class BlockComponent implements OnInit, OnDestroy {
return this.route.queryParams.pipe( return this.route.queryParams.pipe(
map(params => { map(params => {
this.auditParamEnabled = 'audit' in params; this.auditParamEnabled = 'audit' in params;
return this.auditParamEnabled ? !(params['audit'] === 'false') : true; return this.auditParamEnabled ? !(params['audit'] === 'false') : true;
}) })
); );

View file

@ -49,7 +49,7 @@
</div> </div>
</td> </td>
<td class="timestamp" *ngIf="!widget" [ngClass]="{'widget': widget, 'legacy': !isMempoolModule}"> <td class="timestamp" *ngIf="!widget" [ngClass]="{'widget': widget, 'legacy': !isMempoolModule}">
&lrm;{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm:ss' }} <app-timestamp [customFormat]="'yyyy-MM-dd HH:mm:ss'" [unixTime]="block.timestamp" [hideTimeSince]="true"></app-timestamp>
</td> </td>
<td *ngIf="auditAvailable" class="health text-right" [ngClass]="{'widget': widget, 'legacy': !isMempoolModule}"> <td *ngIf="auditAvailable" class="health text-right" [ngClass]="{'widget': widget, 'legacy': !isMempoolModule}">
<a <a

View file

@ -1,15 +1,17 @@
<ng-template [ngIf]="button" [ngIfElse]="btnLink"> <ng-template [ngIf]="button" [ngIfElse]="btnLink">
<button #btn [attr.data-clipboard-text]="text" [class]="class" type="button" [disabled]="text === ''"> <button [class]="class" type="button" [disabled]="text === ''" style="box-shadow: none;" (click)="copyText()">
<span #buttonWrapper [attr.data-tlite]="copiedMessage" style="position: relative;top: -2px;left: 1px;"> <span style="position: relative;top: -2px;left: 1px;">
<app-svg-images name="clippy" [width]="widths[size]" viewBox="0 0 1000 1000"></app-svg-images> <app-svg-images name="clippy" [width]="widths[size]" viewBox="0 0 1000 1000"></app-svg-images>
<span *ngIf="showMessage" class="copied-message" style="top: 29px; left: -23.5px;">{{ copiedMessage }}</span>
</span> </span>
</button> </button>
</ng-template> </ng-template>
<ng-template #btnLink> <ng-template #btnLink>
<span #buttonWrapper [attr.data-tlite]="copiedMessage" style="position: relative;"> <span style="position: relative;">
<button #btn class="btn btn-sm btn-link pt-0 {{ leftPadding ? 'padding' : '' }}" [attr.data-clipboard-text]="text"> <button class="btn btn-sm btn-link pt-0 {{ leftPadding ? 'padding' : '' }}" style="box-shadow: none;" (click)="copyText()">
<app-svg-images name="clippy" [width]="widths[size]" viewBox="0 0 1000 1000"></app-svg-images> <app-svg-images name="clippy" [width]="widths[size]" viewBox="0 0 1000 1000"></app-svg-images>
</button> </button>
<span *ngIf="showMessage" class="copied-message" style="top: 29px; left: -23.5px;">{{ copiedMessage }}</span>
</span> </span>
</ng-template> </ng-template>

View file

@ -7,7 +7,19 @@
padding-left: 0.4rem; padding-left: 0.4rem;
} }
img { .copied-message {
position: relative; background: color-mix(in srgb, var(--active-bg) 95%, transparent);
left: -3px; color: var(--fg);
} font-family: sans-serif;
font-size: .8rem;
font-weight: 400;
text-decoration: none;
text-align: left;
padding: .6em .75rem;
border-radius: 4px;
position: absolute;
white-space: nowrap;
box-shadow: 0 .5rem 1rem -.5rem #000;
z-index: 1000;
opacity: .9;
}

View file

@ -1,6 +1,4 @@
import { Component, ViewChild, ElementRef, AfterViewInit, Input, ChangeDetectionStrategy } from '@angular/core'; import { Component, Input, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import * as ClipboardJS from 'clipboard';
import * as tlite from 'tlite';
@Component({ @Component({
selector: 'app-clipboard', selector: 'app-clipboard',
@ -8,15 +6,14 @@ import * as tlite from 'tlite';
styleUrls: ['./clipboard.component.scss'], styleUrls: ['./clipboard.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class ClipboardComponent implements AfterViewInit { export class ClipboardComponent {
@ViewChild('btn') btn: ElementRef;
@ViewChild('buttonWrapper') buttonWrapper: ElementRef;
@Input() button = false; @Input() button = false;
@Input() class = 'btn btn-secondary ml-1'; @Input() class = 'btn btn-secondary ml-1';
@Input() size: 'small' | 'normal' | 'large' = 'normal'; @Input() size: 'small' | 'normal' | 'large' = 'normal';
@Input() text: string; @Input() text: string;
@Input() leftPadding = true; @Input() leftPadding = true;
copiedMessage: string = $localize`:@@clipboard.copied-message:Copied!`; copiedMessage: string = $localize`:@@clipboard.copied-message:Copied!`;
showMessage = false;
widths = { widths = {
small: '10', small: '10',
@ -24,22 +21,40 @@ export class ClipboardComponent implements AfterViewInit {
large: '18', large: '18',
}; };
clipboard: any; constructor(
private cd: ChangeDetectorRef,
) { }
constructor() { } async copyText() {
if (this.text && !this.showMessage) {
ngAfterViewInit() { try {
this.clipboard = new ClipboardJS(this.btn.nativeElement); await this.copyToClipboard(this.text);
this.clipboard.on('success', () => { this.showMessage = true;
tlite.show(this.buttonWrapper.nativeElement); this.cd.markForCheck();
setTimeout(() => { setTimeout(() => {
tlite.hide(this.buttonWrapper.nativeElement); this.showMessage = false;
}, 1000); this.cd.markForCheck();
}); }, 1000);
} catch (error) {
console.error('Clipboard copy failed:', error);
}
}
} }
onDestroy() { async copyToClipboard(text: string) {
this.clipboard.destroy(); if (navigator.clipboard) {
await navigator.clipboard.writeText(text);
} else {
// Use the 'out of viewport hidden text area' trick on non-secure contexts
const textarea = document.createElement('textarea');
textarea.value = this.text;
textarea.style.opacity = '0';
textarea.setAttribute('readonly', 'true'); // Don't trigger keyboard on mobile
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
textarea.remove();
}
} }
} }

View file

@ -238,7 +238,7 @@
<span>&nbsp;</span> <span>&nbsp;</span>
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: text-top; font-size: 13px; color: var(--title-fg)"></fa-icon> <fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: text-top; font-size: 13px; color: var(--title-fg)"></fa-icon>
</a> </a>
<app-address-graph [address]="widget.props.address" [addressSummary$]="addressSummary$" [period]="widget.props.period || 'all'" [stats]="address ? address.chain_stats : null" [widget]="true" [height]="graphHeight"></app-address-graph> <app-address-graph [address]="widget.props.address" [addressSummary$]="addressSummary$" [period]="widget.props.period || 'all'" [stats]="address ? address.chain_stats : null" [widget]="true" [defaultFiat]="true" [height]="graphHeight"></app-address-graph>
</div> </div>
</div> </div>
</div> </div>
@ -281,9 +281,11 @@
<div class="col" style="max-height: 410px" [style.order]="isMobile && widget.mobileOrder || 8"> <div class="col" style="max-height: 410px" [style.order]="isMobile && widget.mobileOrder || 8">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<span class="title-link"> <a class="title-link mb-0" style="margin-top: -2px" href="" [routerLink]="['/wallet/' + widget.props.wallet | relativeUrl]">
<h5 class="card-title d-inline" i18n="dashboard.treasury-transactions">Treasury Transactions</h5> <h5 class="card-title d-inline" i18n="dashboard.treasury-transactions">Treasury Transactions</h5>
</span> <span>&nbsp;</span>
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: text-top; font-size: 13px; color: var(--title-fg)"></fa-icon>
</a>
<app-address-transactions-widget [addressSummary$]="walletSummary$"></app-address-transactions-widget> <app-address-transactions-widget [addressSummary$]="walletSummary$"></app-address-transactions-widget>
</div> </div>
</div> </div>

View file

@ -162,6 +162,9 @@ export class EightBlocksComponent implements OnInit, OnDestroy {
this.cacheBlocksSubscription?.unsubscribe(); this.cacheBlocksSubscription?.unsubscribe();
this.networkChangedSubscription?.unsubscribe(); this.networkChangedSubscription?.unsubscribe();
this.queryParamsSubscription?.unsubscribe(); this.queryParamsSubscription?.unsubscribe();
this.blockGraphs.forEach(graph => {
graph.destroy();
});
} }
shiftTestBlocks(): void { shiftTestBlocks(): void {

View file

@ -56,8 +56,7 @@
</ng-template> </ng-template>
</td> </td>
<td class="timestamp text-left"> <td class="timestamp text-left">
&lrm;{{ utxo.blocktime * 1000 | date:'yyyy-MM-dd HH:mm' }} <app-timestamp [customFormat]="'yyyy-MM-dd HH:mm'" [unixTime]="utxo.blocktime"></app-timestamp>
<div class="symbol lg-inline relative-time"><i>(<app-time kind="since" [time]="utxo.blocktime"></app-time>)</i></div>
</td> </td>
<td class="expires-in text-left" [ngStyle]="{ 'color': getGradientColor(utxo.blocknumber + utxo.timelock - lastReservesBlockUpdate) }"> <td class="expires-in text-left" [ngStyle]="{ 'color': getGradientColor(utxo.blocknumber + utxo.timelock - lastReservesBlockUpdate) }">
{{ utxo.blocknumber + utxo.timelock - lastReservesBlockUpdate < 0 ? -(utxo.blocknumber + utxo.timelock - lastReservesBlockUpdate) : utxo.blocknumber + utxo.timelock - lastReservesBlockUpdate }} <span i18n="shared.blocks" class="symbol">blocks</span> {{ utxo.blocknumber + utxo.timelock - lastReservesBlockUpdate < 0 ? -(utxo.blocknumber + utxo.timelock - lastReservesBlockUpdate) : utxo.blocknumber + utxo.timelock - lastReservesBlockUpdate }} <span i18n="shared.blocks" class="symbol">blocks</span>

View file

@ -53,8 +53,7 @@
</ng-container> </ng-container>
</td> </td>
<td class="timestamp text-left"> <td class="timestamp text-left">
&lrm;{{ peg.blocktime * 1000 | date:'yyyy-MM-dd HH:mm' }} <app-timestamp [customFormat]="'yyyy-MM-dd HH:mm'" [unixTime]="peg.blocktime"></app-timestamp>
<div class="symbol lg-inline relative-time"><i>(<app-time kind="since" [time]="peg.blocktime"></app-time>)</i></div>
</td> </td>
<td class="amount text-right" [ngClass]="{'credit': peg.amount > 0, 'debit': peg.amount < 0, 'glow-effect': peg.amount < 0 && peg.bitcoinaddress && !peg.bitcointxid}"> <td class="amount text-right" [ngClass]="{'credit': peg.amount > 0, 'debit': peg.amount < 0, 'glow-effect': peg.amount < 0 && peg.bitcoinaddress && !peg.bitcointxid}">
<app-amount [satoshis]="peg.amount" [noFiat]="true" [forceBtc]="true" [addPlus]="true"></app-amount> <app-amount [satoshis]="peg.amount" [noFiat]="true" [forceBtc]="true" [addPlus]="true"></app-amount>

View file

@ -120,6 +120,7 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.blockGraph?.destroy();
this.blockSub.unsubscribe(); this.blockSub.unsubscribe();
this.timeLtrSubscription.unsubscribe(); this.timeLtrSubscription.unsubscribe();
this.websocketService.stopTrackMempoolBlock(); this.websocketService.stopTrackMempoolBlock();

View file

@ -10,7 +10,7 @@
<h1 class="m-0 pt-1 pt-md-0">{{ poolStats.pool.name }}</h1> <h1 class="m-0 pt-1 pt-md-0">{{ poolStats.pool.name }}</h1>
</div> </div>
<div class="box"> <div class="box pool-details">
<div class="row"> <div class="row">
<div class="col-lg-6"> <div class="col-lg-6">
@ -173,7 +173,119 @@
<div class="spinner-border text-light"></div> <div class="spinner-border text-light"></div>
</div> </div>
<!-- Stratum Job -->
<ng-container *ngIf="(job$ | async) as job;">
<h2 i18n="pool.next_block">Next block</h2>
<div class="box mb-3">
<div class="row" >
<div class="col">
<table class="table table-borderless table-striped">
<tbody>
<tr>
<td>
<table class="job-table table table-xs table-borderless table-fixed table-data">
<thead>
<tr>
<th class="data-title clip text-center height" i18n="latest-blocks.height">Height</th>
<th class="data-title clip text-center expected" i18n="next-block.expected-time">Expected</th>
<th class="data-title clip text-center reward" i18n="latest-blocks.reward">Reward</th>
<th class="data-title clip text-center timestamp" i18n="next-block.timestamp">Timestamp</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center height">
{{ job.height }}
</td>
<td class="text-center expected">
<ng-container *ngIf="(expectedBlockTime$ | async) as expectedBlockTime; else expectedPlaceholder">
<app-time kind="until" [time]="expectedBlockTime" [fastRender]="false" [fixedRender]="true" [precision]="1" minUnit="minute"></app-time>
</ng-container>
<ng-template #expectedPlaceholder>~</ng-template>
</td>
<td class="text-center reward">
<app-amount [satoshis]="job.reward"></app-amount>
</td>
<td class="text-center timestamp">
<app-timestamp [customFormat]="'yyyy-MM-dd HH:mm:ss'" [unixTime]="job.timestamp" [precision]="1" minUnit="minute" [hideTimeSince]="true"></app-timestamp>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td>
<table class="job-table table table-xs table-borderless table-fixed table-data">
<thead>
<tr>
<th class="data-title clip text-center coinbase" i18n="latest-blocks.coinbasetag">Coinbase tag</th>
<th class="data-title clip text-center clean" i18n="next-block.clean">Clean</th>
<th class="data-title clip text-center prevhash" i18n="next-block.prevhash">Prevhash</th>
<th class="data-title clip text-center job-received" i18n="next-block.job-received">Job Received</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center coinbase">
{{ job.scriptsig | hex2ascii }}
</td>
<td class="text-center clean">
@if (job.cleanJobs) {
<fa-icon [icon]="['fas', 'check-circle']" [fixedWidth]="true"></fa-icon>
} @else {
<fa-icon [icon]="['fas', 'times-circle']" [fixedWidth]="true"></fa-icon>
}
</td>
<td class="text-center prevhash">
<a [routerLink]="['/block' | relativeUrl, job.prevHash]">
<app-truncate [text]="job.prevHash" [lastChars]="8"></app-truncate>
</a>
</td>
<td class="text-center job-received">
<app-timestamp [customFormat]="'yyyy-MM-dd HH:mm:ss'" [unixTime]="job.received / 1000" [precision]="1" minUnit="minute" [hideTimeSince]="true"></app-timestamp>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td>
<table class="stratum-table">
<thead>
<tr>
<th class="data-title clip text-center" [attr.colspan]="Math.max(job.merkleBranches.length, 12)">
<a class="title-link" href="" [routerLink]="['/stratum' | relativeUrl]">
Merkle Branches
<span>&nbsp;</span>
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: text-top; font-size: 13px; color: var(--title-fg)"></fa-icon>
</a>
</th>
</tr>
</thead>
<tbody>
<tr>
@for (branch of job.merkleBranches; track $index) {
<td class="merkle" [style.background-color]="branch ? '#' + branch.slice(0, 6) : ''"></td>
}
@for (_ of [].constructor(Math.max(0, 12 - job.merkleBranches.length)); track $index) {
<td class="merkle empty-branch"></td>
}
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</ng-container>
<!-- Blocks list --> <!-- Blocks list -->
<h2 i18n="master-page.blocks">Blocks</h2>
<table class="table table-borderless" [alwaysCallback]="true" infiniteScroll [infiniteScrollDistance]="1.5" <table class="table table-borderless" [alwaysCallback]="true" infiniteScroll [infiniteScrollDistance]="1.5"
[infiniteScrollUpDistance]="1.5" [infiniteScrollThrottle]="50" (scrolled)="loadMore()"> [infiniteScrollUpDistance]="1.5" [infiniteScrollThrottle]="50" (scrolled)="loadMore()">
<ng-container *ngIf="blocks$ | async as blocks; else skeleton"> <ng-container *ngIf="blocks$ | async as blocks; else skeleton">
@ -194,7 +306,7 @@
<a [routerLink]="['/block' | relativeUrl, block.id]">{{ block.height }}</a> <a [routerLink]="['/block' | relativeUrl, block.id]">{{ block.height }}</a>
</td> </td>
<td class="timestamp"> <td class="timestamp">
&lrm;{{ block.timestamp * 1000 | date:'yyyy-MM-dd HH:mm:ss' }} <app-timestamp [customFormat]="'yyyy-MM-dd HH:mm:ss'" [unixTime]="block.timestamp" [hideTimeSince]="true"></app-timestamp>
</td> </td>
<td class="mined"> <td class="mined">
<app-time kind="since" [time]="block.timestamp" [fastRender]="true" [showTooltip]="true"></app-time> <app-time kind="since" [time]="block.timestamp" [fastRender]="true" [showTooltip]="true"></app-time>

View file

@ -49,111 +49,110 @@ div.scrollable {
max-height: 75px; max-height: 75px;
} }
.box { .pool-details {
padding-bottom: 5px;
@media (min-width: 767.98px) { @media (min-width: 767.98px) {
min-height: 187px; min-height: 187px;
} }
}
.label { .label {
width: 25%; width: 25%;
@media (min-width: 767.98px) { @media (min-width: 767.98px) {
vertical-align: middle; vertical-align: middle;
}
@media (max-width: 767.98px) {
font-weight: bold;
}
} }
@media (max-width: 767.98px) { .label.addresses {
font-weight: bold; vertical-align: top;
padding-top: 25px;
}
.addresses-data {
vertical-align: top;
font-family: monospace;
font-size: 14px;
} }
}
.label.addresses {
vertical-align: top;
padding-top: 25px;
}
.addresses-data {
vertical-align: top;
font-family: monospace;
font-size: 14px;
}
.data { .data {
text-align: right;
padding-left: 5%;
@media (max-width: 992px) {
text-align: left;
padding-left: 12px;
}
@media (max-width: 450px) {
text-align: right; text-align: right;
padding-left: 5%;
@media (max-width: 992px) {
text-align: left;
padding-left: 12px;
}
@media (max-width: 450px) {
text-align: right;
}
} }
}
.progress { .progress {
background-color: var(--secondary); background-color: var(--secondary);
} }
.coinbase { .coinbase {
width: 20%;
@media (max-width: 875px) {
display: none;
}
}
.height {
width: 10%;
}
.timestamp {
@media (max-width: 875px) {
padding-left: 50px;
}
@media (max-width: 685px) {
display: none;
}
}
.mined {
width: 13%;
@media (max-width: 1100px) {
display: none;
}
}
.txs {
padding-right: 40px;
@media (max-width: 1100px) {
padding-right: 10px;
}
@media (max-width: 875px) {
padding-right: 20px;
}
@media (max-width: 567px) {
padding-right: 10px;
}
}
.size {
width: 12%;
@media (max-width: 1000px) {
width: 15%;
}
@media (max-width: 875px) {
width: 20%; width: 20%;
@media (max-width: 875px) {
display: none;
}
} }
@media (max-width: 650px) {
width: 20%;
}
@media (max-width: 450px) {
display: none;
}
}
.scriptmessage { .height {
overflow: hidden; width: 10%;
display: inline-block; }
text-overflow: ellipsis;
vertical-align: middle; .timestamp {
width: auto; @media (max-width: 875px) {
text-align: left; padding-left: 50px;
}
@media (max-width: 685px) {
display: none;
}
}
.mined {
width: 13%;
@media (max-width: 1100px) {
display: none;
}
}
.txs {
padding-right: 40px;
@media (max-width: 1100px) {
padding-right: 10px;
}
@media (max-width: 875px) {
padding-right: 20px;
}
@media (max-width: 567px) {
padding-right: 10px;
}
}
.size {
width: 12%;
@media (max-width: 1000px) {
width: 15%;
}
@media (max-width: 875px) {
width: 20%;
}
@media (max-width: 650px) {
width: 20%;
}
@media (max-width: 450px) {
display: none;
}
}
.scriptmessage {
overflow: hidden;
display: inline-block;
text-overflow: ellipsis;
vertical-align: middle;
width: auto;
text-align: left;
}
} }
.skeleton-loader { .skeleton-loader {
@ -214,4 +213,55 @@ div.scrollable {
.taller-row { .taller-row {
height: 75px; height: 75px;
}
.stratum-table {
width: 100%;
.merkle {
width: 100px;
}
.empty-branch {
outline: solid 1px white;
outline-offset: -1px;
&::after {
content: "";
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 100%;
background: linear-gradient(to top left, transparent, transparent 48%, white 49%, white 51%, transparent 52%, transparent);
}
}
td {
position: relative;
height: 2em;
}
}
.job-table {
td, th {
width: 25%;
max-width: 25%;
min-width: 25%;
overflow: hidden;
text-overflow: ellipsis;
padding: 0.1rem 0.2rem;
}
@media (max-width: 767.98px) {
.expected, .timestamp, .clean, .job-received {
display: none;
}
}
}
.title-link, .title-link:hover, .title-link:focus, .title-link:active {
display: block;
text-decoration: none;
color: inherit;
} }

View file

@ -10,6 +10,9 @@ import { selectPowerOfTen } from '@app/bitcoin.utils';
import { formatNumber } from '@angular/common'; import { formatNumber } from '@angular/common';
import { SeoService } from '@app/services/seo.service'; import { SeoService } from '@app/services/seo.service';
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { StratumJob } from '../../interfaces/websocket.interface';
import { WebsocketService } from '../../services/websocket.service';
import { MiningService } from '../../services/mining.service';
interface AccelerationTotal { interface AccelerationTotal {
cost: number, cost: number,
@ -27,12 +30,16 @@ export class PoolComponent implements OnInit {
@Input() left: number | string = 75; @Input() left: number | string = 75;
gfg = true; gfg = true;
stratumEnabled = this.stateService.env.STRATUM_ENABLED;
formatNumber = formatNumber; formatNumber = formatNumber;
Math = Math;
slugSubscription: Subscription; slugSubscription: Subscription;
poolStats$: Observable<PoolStat>; poolStats$: Observable<PoolStat>;
blocks$: Observable<BlockExtended[]>; blocks$: Observable<BlockExtended[]>;
oobFees$: Observable<AccelerationTotal[]>; oobFees$: Observable<AccelerationTotal[]>;
job$: Observable<StratumJob | null>;
expectedBlockTime$: Observable<number>;
isLoading = true; isLoading = true;
error: HttpErrorResponse | null = null; error: HttpErrorResponse | null = null;
@ -53,6 +60,8 @@ export class PoolComponent implements OnInit {
private apiService: ApiService, private apiService: ApiService,
private route: ActivatedRoute, private route: ActivatedRoute,
public stateService: StateService, public stateService: StateService,
private websocketService: WebsocketService,
private miningService: MiningService,
private seoService: SeoService, private seoService: SeoService,
) { ) {
this.auditAvailable = this.stateService.env.AUDIT; this.auditAvailable = this.stateService.env.AUDIT;
@ -62,7 +71,7 @@ export class PoolComponent implements OnInit {
this.slugSubscription = this.route.params.pipe(map((params) => params.slug)).subscribe((slug) => { this.slugSubscription = this.route.params.pipe(map((params) => params.slug)).subscribe((slug) => {
this.isLoading = true; this.isLoading = true;
this.blocks = []; this.blocks = [];
this.chartOptions = {}; this.chartOptions = {};
this.slug = slug; this.slug = slug;
this.initializeObservables(); this.initializeObservables();
}); });
@ -129,6 +138,31 @@ export class PoolComponent implements OnInit {
}), }),
filter(oob => oob.length === 3 && oob[2].count > 0) filter(oob => oob.length === 3 && oob[2].count > 0)
); );
if (this.stratumEnabled) {
this.job$ = combineLatest([
this.poolStats$.pipe(
tap((poolStats) => {
this.websocketService.startTrackStratum(poolStats.pool.unique_id);
})
),
this.stateService.stratumJobs$
]).pipe(
map(([poolStats, jobs]) => {
return jobs[poolStats.pool.unique_id];
})
);
this.expectedBlockTime$ = combineLatest([
this.miningService.getMiningStats('1w'),
this.poolStats$,
this.stateService.difficultyAdjustment$
]).pipe(
map(([miningStats, poolStat, da]) => {
return (da.timeAvg / ((poolStat.estimatedHashrate || 0) / (miningStats.lastEstimatedHashrate * 1_000_000_000_000_000_000))) + Date.now() + da.timeOffset;
})
);
}
} }
prepareChartOptions(hashrate, share) { prepareChartOptions(hashrate, share) {

View file

@ -0,0 +1,34 @@
.accept-results {
td, th {
&.allowed {
width: 10%;
text-align: center;
}
&.txid {
width: 50%;
}
&.rate {
width: 20%;
text-align: right;
white-space: wrap;
}
&.reason {
width: 20%;
text-align: right;
white-space: wrap;
}
}
@media (max-width: 950px) {
table-layout: auto;
td, th {
&.allowed {
width: 100px;
}
&.txid {
max-width: 200px;
}
}
}
}

View file

@ -19,6 +19,9 @@
<th class="rtt only-small">RTT</th> <th class="rtt only-small">RTT</th>
<th class="rtt only-large">RTT</th> <th class="rtt only-large">RTT</th>
<th class="height">Height</th> <th class="height">Height</th>
<th class="frontend only-large">Front</th>
<th class="backend only-large">Back</th>
<th class="electrs only-large">Electrs</th>
</tr> </tr>
<tr *ngFor="let host of hosts; let i = index; trackBy: trackByFn"> <tr *ngFor="let host of hosts; let i = index; trackBy: trackByFn">
<td class="rank">{{ i + 1 }}</td> <td class="rank">{{ i + 1 }}</td>
@ -28,6 +31,15 @@
<td class="rtt only-small">{{ (host.rtt / 1000) | number : '1.1-1' }} {{ host.rtt == null ? '' : 's'}} {{ !host.checked ? '⏳' : (host.unreachable ? '🔥' : '✅') }}</td> <td class="rtt only-small">{{ (host.rtt / 1000) | number : '1.1-1' }} {{ host.rtt == null ? '' : 's'}} {{ !host.checked ? '⏳' : (host.unreachable ? '🔥' : '✅') }}</td>
<td class="rtt only-large">{{ host.rtt | number : '1.0-0' }} {{ host.rtt == null ? '' : 'ms'}} {{ !host.checked ? '⏳' : (host.unreachable ? '🔥' : '✅') }}</td> <td class="rtt only-large">{{ host.rtt | number : '1.0-0' }} {{ host.rtt == null ? '' : 'ms'}} {{ !host.checked ? '⏳' : (host.unreachable ? '🔥' : '✅') }}</td>
<td class="height">{{ host.latestHeight }} {{ !host.checked ? '⏳' : (host.outOfSync ? '🚫' : (host.latestHeight && host.latestHeight < maxHeight ? '🟧' : '')) }}</td> <td class="height">{{ host.latestHeight }} {{ !host.checked ? '⏳' : (host.outOfSync ? '🚫' : (host.latestHeight && host.latestHeight < maxHeight ? '🟧' : '')) }}</td>
<ng-container *ngFor="let type of ['frontend', 'backend', 'electrs']">
<td class="{{type}} only-large" [style.background-color]="host.hashes?.[type] ? '#' + host.hashes[type].slice(0, 6) : ''">
@if (host.hashes?.[type]) {
<a [style.color]="'white'" href="https://github.com/mempool/{{type === 'electrs' ? 'electrs' : 'mempool'}}/commit/{{ host.hashes[type] }}" target="_blank">{{ host.hashes[type].slice(0, 8) || '?' }}</a>
} @else {
<span>?</span>
}
</td>
</ng-container>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View file

@ -9,7 +9,7 @@
} }
.status-panel { .status-panel {
max-width: 720px; max-width: 1000px;
margin: auto; margin: auto;
padding: 1em; padding: 1em;
background: var(--box-bg); background: var(--box-bg);

View file

@ -0,0 +1,49 @@
<div class="container-xl" style="min-height: 335px">
<h1 class="float-left" i18n="master-page.blocks">Stratum Jobs</h1>
<div class="clearfix"></div>
<div style="min-height: 295px">
<table *ngIf="poolsReady && (rows$ | async) as rows;" class="stratum-table">
<thead>
<tr>
<td class="height">Height</td>
<td class="reward">Reward</td>
<td class="tag">Coinbase Tag</td>
<td class="merkle" [attr.colspan]="rows[0]?.merkleCells?.length || 4">
Merkle Branches
</td>
<td class="pool">Pool</td>
</tr>
</thead>
<tbody>
@for (row of rows; track row.job.pool) {
<tr>
<td class="height">
{{ row.job.height }}
</td>
<td class="reward">
<app-amount [satoshis]="row.job.reward"></app-amount>
</td>
<td class="tag">
{{ row.job.tag }}
</td>
@for (cell of row.merkleCells; track $index) {
<td class="merkle" [style.background-color]="cell.hash ? '#' + cell.hash.slice(0, 6) : ''">
<div class="pipe-segment" [class]="pipeToClass(cell.type)"></div>
</td>
}
<td class="pool">
@if (pools[row.job.pool]) {
<a class="badge" [routerLink]="[('/mining/pool/' + pools[row.job.pool].slug) | relativeUrl]">
<img class="pool-logo" [src]="'/resources/mining-pools/' + pools[row.job.pool].slug + '.svg'" onError="this.src = '/resources/mining-pools/default.svg'" [alt]="'Logo of ' + pools[row.job.pool].name + ' mining pool'">
{{ pools[row.job.pool].name}}
</a>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>

View file

@ -0,0 +1,108 @@
.stratum-table {
width: 100%;
}
td {
position: relative;
height: 2em;
&.height, &.reward, &.tag {
padding: 0 5px;
}
&.tag {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&.pool {
padding-left: 5px;
padding-right: 20px;
}
&.merkle {
width: 100px;
.pipe-segment {
position: absolute;
border-color: white;
box-sizing: content-box;
&.vertical {
top: 0;
right: 0;
width: 50%;
height: 100%;
border-left: solid 4px;
}
&.horizontal {
bottom: 0;
left: 0;
width: 100%;
height: 50%;
border-top: solid 4px;
}
&.branch-top {
bottom: 0;
right: 0;
width: 100%;
height: 50%;
border-top: solid 4px;
&::after {
content: "";
position: absolute;
box-sizing: content-box;
top: -4px;
right: 0px;
bottom: 0;
width: 50%;
border-top: solid 4px;
border-left: solid 4px;
border-top-left-radius: 5px;
}
}
&.branch-mid {
bottom: 0;
right: 0px;
width: 50%;
height: 100%;
border-left: solid 4px;
&::after {
content: "";
position: absolute;
box-sizing: content-box;
top: -4px;
left: -4px;
width: 100%;
height: 50%;
border-bottom: solid 4px;
border-left: solid 4px;
border-bottom-left-radius: 5px;
}
}
&.branch-end {
top: -4px;
right: 0;
width: 50%;
height: 50%;
border-bottom-left-radius: 5px;
border-bottom: solid 4px;
border-left: solid 4px;
}
}
}
}
.badge {
position: relative;
color: #FFF;
}
.pool-logo {
width: 15px;
height: 15px;
position: relative;
top: -1px;
margin-right: 2px;
}

View file

@ -0,0 +1,224 @@
import { Component, OnInit, ChangeDetectionStrategy, OnDestroy, ChangeDetectorRef } from '@angular/core';
import { StateService } from '../../../services/state.service';
import { WebsocketService } from '../../../services/websocket.service';
import { map, Observable } from 'rxjs';
import { StratumJob } from '../../../interfaces/websocket.interface';
import { MiningService } from '../../../services/mining.service';
import { SinglePoolStats } from '../../../interfaces/node-api.interface';
type MerkleCellType = ' ' | '┬' | '├' | '└' | '│' | '─' | 'leaf';
interface TaggedStratumJob extends StratumJob {
tag: string;
merkleBranchIds: string[];
}
interface MerkleCell {
hash: string;
type: MerkleCellType;
job?: TaggedStratumJob;
}
interface MerkleTree {
hash?: string;
job: string;
size: number;
children?: MerkleTree[];
}
interface PoolRow {
job: TaggedStratumJob;
merkleCells: MerkleCell[];
}
function parseTag(scriptSig: string): string {
const hex = scriptSig.slice(8).replace(/6d6d.{64}/, '');
const bytes: number[] = [];
for (let i = 0; i < hex.length; i += 2) {
bytes.push(parseInt(hex.substr(i, 2), 16));
}
// eslint-disable-next-line no-control-regex
const ascii = new TextDecoder('utf8').decode(Uint8Array.from(bytes)).replace(/\uFFFD/g, '').replace(/\\0/g, '').replace(/[\x00-\x1F\x7F-\x9F]/g, '');
if (ascii.includes('/ViaBTC/')) {
return '/ViaBTC/';
} else if (ascii.includes('SpiderPool/')) {
return 'SpiderPool/';
}
return (ascii.match(/\/.*\//)?.[0] || ascii).trim();
}
function getMerkleBranchIds(merkleBranches: string[], numBranches: number): string[] {
let lastHash = '';
const ids: string[] = [];
for (let i = 0; i < numBranches; i++) {
if (merkleBranches[i]) {
lastHash = merkleBranches[i];
}
ids.push(`${i}-${lastHash}`);
}
return ids;
}
@Component({
selector: 'app-stratum-list',
templateUrl: './stratum-list.component.html',
styleUrls: ['./stratum-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StratumList implements OnInit, OnDestroy {
rows$: Observable<PoolRow[]>;
pools: { [id: number]: SinglePoolStats } = {};
poolsReady: boolean = false;
constructor(
private stateService: StateService,
private websocketService: WebsocketService,
private miningService: MiningService,
private cd: ChangeDetectorRef,
) {}
ngOnInit(): void {
this.websocketService.want(['stats', 'blocks', 'mempool-blocks']);
this.miningService.getPools().subscribe(pools => {
this.pools = {};
for (const pool of pools) {
this.pools[pool.unique_id] = pool;
}
this.poolsReady = true;
this.cd.markForCheck();
});
this.rows$ = this.stateService.stratumJobs$.pipe(
map((jobs) => this.processJobs(jobs)),
);
this.websocketService.startTrackStratum('all');
}
processJobs(rawJobs: Record<string, StratumJob>): PoolRow[] {
const numBranches = Math.max(...Object.values(rawJobs).map(job => job.merkleBranches.length));
const jobs: Record<string, TaggedStratumJob> = {};
for (const [id, job] of Object.entries(rawJobs)) {
jobs[id] = { ...job, tag: parseTag(job.scriptsig), merkleBranchIds: getMerkleBranchIds(job.merkleBranches, numBranches) };
}
if (Object.keys(jobs).length === 0) {
return [];
}
let trees: MerkleTree[] = Object.keys(jobs).map(job => ({
job,
size: 1,
}));
// build tree from bottom up
for (let col = numBranches - 1; col >= 0; col--) {
const groups: Record<string, MerkleTree[]> = {};
for (const tree of trees) {
const branchId = jobs[tree.job].merkleBranchIds[col];
if (!groups[branchId]) {
groups[branchId] = [];
}
groups[branchId].push(tree);
}
trees = Object.values(groups).map(group => ({
hash: jobs[group[0].job].merkleBranches[col],
job: group[0].job,
children: group,
size: group.reduce((acc, tree) => acc + tree.size, 0),
}));
}
// initialize grid of cells
const rows: (MerkleCell | null)[][] = [];
for (let i = 0; i < Object.keys(jobs).length; i++) {
const row: (MerkleCell | null)[] = [];
for (let j = 0; j <= numBranches; j++) {
row.push(null);
}
rows.push(row);
}
// fill in the cells
let colTrees = [trees.sort((a, b) => {
if (a.size !== b.size) {
return b.size - a.size;
}
return a.job.localeCompare(b.job);
})];
for (let col = 0; col <= numBranches; col++) {
let row = 0;
const nextTrees: MerkleTree[][] = [];
for (let g = 0; g < colTrees.length; g++) {
for (let t = 0; t < colTrees[g].length; t++) {
const tree = colTrees[g][t];
const isFirstTree = (t === 0);
const isLastTree = (t === colTrees[g].length - 1);
for (let i = 0; i < tree.size; i++) {
const isFirstCell = (i === 0);
const isLeaf = (col === numBranches);
rows[row][col] = {
hash: tree.hash,
job: isLeaf ? jobs[tree.job] : undefined,
type: 'leaf',
};
if (col > 0) {
rows[row][col - 1].type = getCellType(isFirstCell, isFirstTree, isLastTree);
}
row++;
}
if (tree.children) {
nextTrees.push(tree.children.sort((a, b) => {
if (a.size !== b.size) {
return b.size - a.size;
}
return a.job.localeCompare(b.job);
}));
}
}
}
colTrees = nextTrees;
}
return rows.map(row => ({
job: row[row.length - 1].job,
merkleCells: row.slice(0, -1),
}));
}
pipeToClass(type: MerkleCellType): string {
return {
' ': 'empty',
'┬': 'branch-top',
'├': 'branch-mid',
'└': 'branch-end',
'│': 'vertical',
'─': 'horizontal',
'leaf': 'leaf'
}[type];
}
ngOnDestroy(): void {
this.websocketService.stopTrackStratum();
}
}
function getCellType(isFirstCell, isFirstTree, isLastTree): MerkleCellType {
if (isFirstCell) {
if (isFirstTree) {
if (isLastTree) {
return '─';
} else {
return '┬';
}
} else if (isLastTree) {
return '└';
} else {
return '├';
}
} else {
if (isLastTree) {
return ' ';
} else {
return '│';
}
}
}

View file

@ -0,0 +1,8 @@
<div [formGroup]="timezoneForm" class="text-small text-center">
<select formControlName="mode" class="custom-select custom-select-sm form-control-secondary form-control mx-auto" style="width: 110px;" (change)="changeMode()">
<option value="local">UTC{{ localTimezoneOffset !== '+0' ? localTimezoneOffset : '' }} {{ localTimezoneName ? '- ' + localTimezoneName : '' }}</option>
<option value="+0" *ngIf="localTimezoneOffset !== '+0'">UTC - Greenwich Mean Time (GMT)</option>
<option disabled>────</option>
<option *ngFor="let timezone of timezones" [value]="timezone.offset">UTC{{ timezone.offset !== '+0' ? timezone.offset : '' }} - {{ timezone.name }}</option>
</select>
</div>

View file

@ -0,0 +1,58 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { StorageService } from '@app/services/storage.service';
import { StateService } from '@app/services/state.service';
import { timezones } from '@app/app.constants';
@Component({
selector: 'app-timezone-selector',
templateUrl: './timezone-selector.component.html',
styleUrls: ['./timezone-selector.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TimezoneSelectorComponent implements OnInit {
timezoneForm: UntypedFormGroup;
timezones = timezones;
localTimezoneOffset: string = '';
localTimezoneName: string;
constructor(
private formBuilder: UntypedFormBuilder,
private stateService: StateService,
private storageService: StorageService,
) { }
ngOnInit() {
this.setLocalTimezone();
this.timezoneForm = this.formBuilder.group({
mode: ['local'],
});
this.stateService.timezone$.subscribe((mode) => {
this.timezoneForm.get('mode')?.setValue(mode);
});
}
changeMode() {
const newMode = this.timezoneForm.get('mode')?.value;
this.storageService.setValue('timezone-preference', newMode);
this.stateService.timezone$.next(newMode);
}
setLocalTimezone() {
const offset = new Date().getTimezoneOffset();
const sign = offset <= 0 ? "+" : "-";
const absOffset = Math.abs(offset);
const hours = String(Math.floor(absOffset / 60));
const minutes = String(absOffset % 60).padStart(2, '0');
if (minutes === '00') {
this.localTimezoneOffset = `${sign}${hours}`;
} else {
this.localTimezoneOffset = `${sign}${hours.padStart(2, '0')}:${minutes}`;
}
const timezone = this.timezones.find(tz => tz.offset === this.localTimezoneOffset);
this.timezones = this.timezones.filter(tz => tz.offset !== this.localTimezoneOffset && tz.offset !== '+0');
this.localTimezoneName = timezone ? timezone.name : '';
}
}

View file

@ -88,7 +88,7 @@
<div class="field narrower mt-2"> <div class="field narrower mt-2">
<div class="label" i18n="transaction.confirmed-at">Confirmed at</div> <div class="label" i18n="transaction.confirmed-at">Confirmed at</div>
<div class="value"> <div class="value">
&lrm;{{ tx.status.block_time * 1000 | date:'yyyy-MM-dd HH:mm' }} <app-timestamp [customFormat]="'yyyy-MM-dd HH:mm'" [unixTime]="tx.status.block_time" [hideTimeSince]="true"></app-timestamp>
<div class="lg-inline"> <div class="lg-inline">
<i class="symbol">(<app-time kind="since" [time]="tx.status.block_time" [fastRender]="true" [showTooltip]="true"></app-time>)</i> <i class="symbol">(<app-time kind="since" [time]="tx.status.block_time" [fastRender]="true" [showTooltip]="true"></app-time>)</i>
</div> </div>

View file

@ -61,10 +61,7 @@
<tr> <tr>
<td i18n="block.timestamp">Timestamp</td> <td i18n="block.timestamp">Timestamp</td>
<td> <td>
&lrm;{{ tx.status.block_time * 1000 | date:'yyyy-MM-dd HH:mm:ss' }} <app-timestamp [customFormat]="'yyyy-MM-dd HH:mm:ss'" [unixTime]="tx.status.block_time"></app-timestamp>
<div class="lg-inline">
<i class="symbol">(<app-time kind="since" [time]="tx.status.block_time" [fastRender]="true"></app-time>)</i>
</div>
</td> </td>
</tr> </tr>
} @else { } @else {
@ -217,10 +214,10 @@
<tr> <tr>
<td class="td-width" i18n="transaction.fee|Transaction fee">Fee</td> <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> <td class="text-wrap">{{ tx.fee | number }} <span class="symbol" i18n="shared.sats">sats</span>
@if (accelerationInfo?.bidBoost ?? tx.feeDelta > 0) { @if (isAcceleration && 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" 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> <span class="fiat"><app-fiat [blockConversion]="tx.price" [value]="tx.fee + (isAcceleration ? ((accelerationInfo?.bidBoost ?? tx.feeDelta) || 0) : 0)"></app-fiat></span>
</td> </td>
</tr> </tr>
} @else { } @else {
@ -247,7 +244,7 @@
<ng-template #effectiveRateRow> <ng-template #effectiveRateRow>
@if (!isLoadingTx) { @if (!isLoadingTx) {
@if ((cpfpInfo && hasEffectiveFeeRate) || accelerationInfo) { @if ((cpfpInfo && hasEffectiveFeeRate) || (accelerationInfo && isAcceleration)) {
<tr> <tr>
@if (isAcceleration) { @if (isAcceleration) {
<td i18n="transaction.accelerated-fee-rate|Accelerated transaction fee rate">Accelerated fee rate</td> <td i18n="transaction.accelerated-fee-rate|Accelerated transaction fee rate">Accelerated fee rate</td>
@ -280,7 +277,7 @@
<ng-template #acceleratingRow> <ng-template #acceleratingRow>
<tr> <tr>
<td rowspan="2" colspan="2" style="padding: 0;"> <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 [acceleratedBy]="tx.acceleratedBy" [effectiveFeeRate]="tx.effectiveFeePerVsize" [accelerationInfo]="accelerationInfo" [miningStats]="miningStats" [hasCpfp]="hasCpfp" (toggleCpfp)="toggleCpfp()" [chartPositionLeft]="isMobile"></app-active-acceleration-box>
</td> </td>
</tr> </tr>
<tr></tr> <tr></tr>

View file

@ -29,7 +29,6 @@ export class TransactionDetailsComponent implements OnInit {
@Input() hasEffectiveFeeRate: boolean; @Input() hasEffectiveFeeRate: boolean;
@Input() cpfpInfo: CpfpInfo; @Input() cpfpInfo: CpfpInfo;
@Input() hasCpfp: boolean; @Input() hasCpfp: boolean;
@Input() showCpfpDetails: boolean;
@Input() accelerationInfo: Acceleration; @Input() accelerationInfo: Acceleration;
@Input() acceleratorAvailable: boolean; @Input() acceleratorAvailable: boolean;
@Input() accelerateCtaType: string; @Input() accelerateCtaType: string;
@ -51,7 +50,7 @@ export class TransactionDetailsComponent implements OnInit {
this.accelerateClicked.emit(true); this.accelerateClicked.emit(true);
} }
toggleCpfp(): void { toggleCpfp(): void {
this.toggleCpfp$.emit(); this.toggleCpfp$.emit();
} }
} }

View file

@ -24,6 +24,7 @@
[height]="tx?.status?.block_height" [height]="tx?.status?.block_height"
[replaced]="replaced" [replaced]="replaced"
[removed]="this.rbfInfo?.mined && !this.tx?.status?.confirmed" [removed]="this.rbfInfo?.mined && !this.tx?.status?.confirmed"
[cached]="isCached"
></app-confirmations> ></app-confirmations>
</div> </div>
</ng-container> </ng-container>
@ -52,7 +53,6 @@
[hasEffectiveFeeRate]="hasEffectiveFeeRate" [hasEffectiveFeeRate]="hasEffectiveFeeRate"
[cpfpInfo]="cpfpInfo" [cpfpInfo]="cpfpInfo"
[hasCpfp]="hasCpfp" [hasCpfp]="hasCpfp"
[showCpfpDetails]="showCpfpDetails"
[accelerationInfo]="accelerationInfo" [accelerationInfo]="accelerationInfo"
[replaced]="replaced" [replaced]="replaced"
[isCached]="isCached" [isCached]="isCached"
@ -166,12 +166,12 @@
<br> <br>
</ng-container> </ng-container>
<ng-container *ngIf="transactionTime > 0 && tx.acceleratedAt > 0 && isAcceleration"> <ng-container *ngIf="transactionTime > 0 && tx.acceleratedAt > 0 && (isAcceleration || accelerationCanceled)">
<div class="title float-left"> <div class="title float-left">
<h2 id="acceleration-timeline" i18n="transaction.acceleration-timeline|Acceleration Timeline">Acceleration Timeline</h2> <h2 id="acceleration-timeline" i18n="transaction.acceleration-timeline|Acceleration Timeline">Acceleration Timeline</h2>
</div> </div>
<div class="clearfix"></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" [acceleratedAt]="tx.acceleratedAt" [tx]="tx" [accelerationInfo]="accelerationInfo" [eta]="(ETA$ | async)" [canceled]="accelerationCanceled"></app-acceleration-timeline>
<br> <br>
</ng-container> </ng-container>

View file

@ -107,6 +107,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
pool: Pool | null; pool: Pool | null;
auditStatus: TxAuditStatus | null; auditStatus: TxAuditStatus | null;
isAcceleration: boolean = false; isAcceleration: boolean = false;
accelerationCanceled: boolean = false;
filters: Filter[] = []; filters: Filter[] = [];
showCpfpDetails = false; showCpfpDetails = false;
miningStats: MiningStats; miningStats: MiningStats;
@ -239,7 +240,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
retry({ count: 2, delay: 2000 }), retry({ count: 2, delay: 2000 }),
// Try again until we either get a valid response, or the transaction is confirmed // Try again until we either get a valid response, or the transaction is confirmed
repeat({ delay: 2000 }), repeat({ delay: 2000 }),
filter((transactionTimes) => transactionTimes?.length && transactionTimes[0] > 0 && !this.tx.status?.confirmed), filter((transactionTimes) => transactionTimes?.[0] > 0 || this.tx.status?.confirmed),
take(1), take(1),
)), )),
) )
@ -360,16 +361,17 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
).subscribe((accelerationHistory) => { ).subscribe((accelerationHistory) => {
for (const acceleration of accelerationHistory) { for (const acceleration of accelerationHistory) {
if (acceleration.txid === this.txId) { if (acceleration.txid === this.txId) {
if (acceleration.status === 'completed' || acceleration.status === 'completed_provisional') { if ((acceleration.status === 'completed' || acceleration.status === 'completed_provisional') && acceleration.pools.includes(acceleration.minedByPoolUniqueId)) {
if (acceleration.pools.includes(acceleration.minedByPoolUniqueId)) { const boostCost = acceleration.boostCost || acceleration.bidBoost;
const boostCost = acceleration.boostCost || acceleration.bidBoost; acceleration.acceleratedFeeRate = Math.max(acceleration.effectiveFee, acceleration.effectiveFee + boostCost) / acceleration.effectiveVsize;
acceleration.acceleratedFeeRate = Math.max(acceleration.effectiveFee, acceleration.effectiveFee + boostCost) / acceleration.effectiveVsize; acceleration.boost = boostCost;
acceleration.boost = boostCost; this.tx.acceleratedAt = acceleration.added;
this.tx.acceleratedAt = acceleration.added; this.accelerationInfo = acceleration;
this.accelerationInfo = acceleration; }
} else { if (acceleration.status === 'failed' || acceleration.status === 'failed_provisional') {
this.tx.feeDelta = undefined; this.accelerationCanceled = true;
} this.tx.acceleratedAt = acceleration.added;
this.accelerationInfo = acceleration;
} }
this.waitingForAccelerationInfo = false; this.waitingForAccelerationInfo = false;
this.setIsAccelerated(); this.setIsAccelerated();
@ -878,6 +880,13 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.tx.acceleratedBy = cpfpInfo.acceleratedBy; this.tx.acceleratedBy = cpfpInfo.acceleratedBy;
this.tx.acceleratedAt = cpfpInfo.acceleratedAt; this.tx.acceleratedAt = cpfpInfo.acceleratedAt;
this.tx.feeDelta = cpfpInfo.feeDelta; this.tx.feeDelta = cpfpInfo.feeDelta;
this.accelerationCanceled = false;
this.setIsAccelerated(firstCpfp);
} else if (cpfpInfo.acceleratedAt) { // Acceleration was cancelled: reset acceleration state
this.tx.acceleratedBy = cpfpInfo.acceleratedBy;
this.tx.acceleratedAt = cpfpInfo.acceleratedAt;
this.tx.feeDelta = cpfpInfo.feeDelta;
this.accelerationCanceled = true;
this.setIsAccelerated(firstCpfp); this.setIsAccelerated(firstCpfp);
} }
@ -901,7 +910,12 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
} }
setIsAccelerated(initialState: boolean = false) { setIsAccelerated(initialState: boolean = false) {
this.isAcceleration = ((this.tx.acceleration && (!this.tx.status.confirmed || this.waitingForAccelerationInfo)) || (this.accelerationInfo && this.pool && this.accelerationInfo.pools.some(pool => (pool === this.pool.id)))); this.isAcceleration =
(
(this.tx.acceleration && (!this.tx.status.confirmed || this.waitingForAccelerationInfo)) ||
(this.accelerationInfo && this.pool && this.accelerationInfo.pools.some(pool => (pool === this.pool.id)))
) &&
!this.accelerationCanceled;
if (this.isAcceleration) { if (this.isAcceleration) {
if (initialState) { if (initialState) {
this.accelerationFlowCompleted = true; this.accelerationFlowCompleted = true;

View file

@ -6,7 +6,7 @@
<app-truncate [text]="tx.txid"></app-truncate> <app-truncate [text]="tx.txid"></app-truncate>
</a> </a>
<div> <div>
<ng-template [ngIf]="tx.status.confirmed">&lrm;{{ tx.status.block_time * 1000 | date:'yyyy-MM-dd HH:mm:ss' }}</ng-template> <ng-template [ngIf]="tx.status.confirmed"><app-timestamp [customFormat]="'yyyy-MM-dd HH:mm:ss'" [unixTime]="tx.status.block_time" [hideTimeSince]="true"></app-timestamp></ng-template>
<ng-template [ngIf]="!tx.status.confirmed && tx.firstSeen"> <ng-template [ngIf]="!tx.status.confirmed && tx.firstSeen">
<i><app-time kind="since" [time]="tx.firstSeen" [fastRender]="true" [showTooltip]="true"></app-time></i> <i><app-time kind="since" [time]="tx.firstSeen" [fastRender]="true" [showTooltip]="true"></app-time></i>
</ng-template> </ng-template>
@ -81,7 +81,7 @@
</ng-container> </ng-container>
</div> </div>
</td> </td>
<td class="text-right nowrap amount" [class]="{large: vin?.prevout?.value > 1000000000 || vin.isInscription}"> <td class="text-right nowrap amount" [class]="{large: tx.largeInput}">
<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> <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>
<ng-template [ngIf]="vin.prevout && vin.prevout.asset && vin.prevout.asset !== nativeAssetId" [ngIfElse]="defaultOutput"> <ng-template [ngIf]="vin.prevout && vin.prevout.asset && vin.prevout.asset !== nativeAssetId" [ngIfElse]="defaultOutput">
<div *ngIf="assetsMinimal && assetsMinimal[vin.prevout.asset] else assetVinNotFound"> <div *ngIf="assetsMinimal && assetsMinimal[vin.prevout.asset] else assetVinNotFound">
@ -257,7 +257,7 @@
</ng-template> </ng-template>
</ng-template> </ng-template>
</td> </td>
<td class="text-right nowrap amount" [class]="{large: vout?.value > 1000000000}"> <td class="text-right nowrap amount" [class]="{large: tx.largeOutput}">
<ng-template [ngIf]="vout.asset && vout.asset !== nativeAssetId" [ngIfElse]="defaultOutput"> <ng-template [ngIf]="vout.asset && vout.asset !== nativeAssetId" [ngIfElse]="defaultOutput">
<div *ngIf="assetsMinimal && assetsMinimal[vout.asset] else assetNotFound"> <div *ngIf="assetsMinimal && assetsMinimal[vout.asset] else assetNotFound">
<ng-container *ngTemplateOutlet="assetBox; context:{ $implicit: vout }"></ng-container> <ng-container *ngTemplateOutlet="assetBox; context:{ $implicit: vout }"></ng-container>

View file

@ -202,12 +202,12 @@ export class TransactionsListComponent implements OnInit, OnChanges {
for (const address of this.addresses) { for (const address of this.addresses) {
switch (address.length) { switch (address.length) {
case 130: { case 130: {
if (v.scriptpubkey === '21' + address + 'ac') { if (v.scriptpubkey === '41' + address + 'ac') {
return v.value; return v.value;
} }
} break; } break;
case 66: { case 66: {
if (v.scriptpubkey === '41' + address + 'ac') { if (v.scriptpubkey === '21' + address + 'ac') {
return v.value; return v.value;
} }
} break; } break;
@ -224,12 +224,12 @@ export class TransactionsListComponent implements OnInit, OnChanges {
for (const address of this.addresses) { for (const address of this.addresses) {
switch (address.length) { switch (address.length) {
case 130: { case 130: {
if (v.prevout?.scriptpubkey === '21' + address + 'ac') { if (v.prevout?.scriptpubkey === '41' + address + 'ac') {
return v.prevout?.value; return v.prevout?.value;
} }
} break; } break;
case 66: { case 66: {
if (v.prevout?.scriptpubkey === '41' + address + 'ac') { if (v.prevout?.scriptpubkey === '21' + address + 'ac') {
return v.prevout?.value; return v.prevout?.value;
} }
} break; } break;
@ -258,6 +258,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
const hasAnnex = tx.vin[i].witness?.[tx.vin[i].witness.length - 1].startsWith('50'); 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')) { 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; tx.vin[i].isInscription = true;
tx.largeInput = true;
} }
} }
} }
@ -268,6 +269,9 @@ export class TransactionsListComponent implements OnInit, OnChanges {
} }
} }
} }
tx.largeInput = tx.largeInput || tx.vin.some(vin => (vin?.prevout?.value > 1000000000));
tx.largeOutput = tx.vout.some(vout => (vout?.value > 1000000000));
}); });
if (this.blockTime && this.transactions?.length && this.currency) { if (this.blockTime && this.transactions?.length && this.currency) {
@ -351,8 +355,12 @@ export class TransactionsListComponent implements OnInit, OnChanges {
this.electrsApiService.getTransaction$(tx.txid) this.electrsApiService.getTransaction$(tx.txid)
.subscribe((newTx) => { .subscribe((newTx) => {
tx['@vinLoaded'] = true; tx['@vinLoaded'] = true;
let temp = tx.vin;
tx.vin = newTx.vin; tx.vin = newTx.vin;
tx.fee = newTx.fee; tx.fee = newTx.fee;
for (const [index, vin] of temp.entries()) {
newTx.vin[index].isInscription = vin.isInscription;
}
this.ref.markForCheck(); this.ref.markForCheck();
}); });
} }

View file

@ -0,0 +1,31 @@
<div class="box preview-box" *ngIf="(walletAddresses$ | async) as walletAddresses">
<app-preview-title>
<span i18n="shared.wallet">Wallet</span>
</app-preview-title>
<div>
<div class="table-col">
<table class="table table-borderless dual-col-striped table-fixed wallet-table" *ngIf="(walletStats$ | async) as walletStats">
<tbody>
<tr>
<td i18n="address.number-addresses">Addresses</td>
<td class="wrap-cell">{{ addressStrings.length }}</td>
<td class="spacer"></td>
<td i18n="address.utxos">UTXOs</td>
<td class="wrap-cell">{{ walletStats.utxos }}</td>
</tr>
<tr>
<td i18n="wallet.balance-btc">Balance (BTC)</td>
<td class="wrap-cell"><app-amount [satoshis]="walletStats.balance" [noFiat]="true" [digitsInfo]="walletStats.balance > 1_000_000_000 ? '1.4-4' : '1.8-8'"></app-amount></td>
<td class="spacer"></td>
<td i18n="wallet.balance-usd">Balance (USD)</td>
<td class="wrap-cell"><span class="fiat"><app-fiat [value]="walletStats.balance"></app-fiat></span></td>
</tr>
</tbody>
</table>
</div>
<div class="w-100 d-block d-md-none"></div>
<div class="col-md graph-col">
<app-address-graph [addressSummary$]="walletSummary$" period="all" [widget]="true" [defaultFiat]="true" [height]="330" [left]="-40" [right]="-40" [showLegend]="false" [showYAxis]="false"/>
</div>
</div>
</div>

View file

@ -0,0 +1,31 @@
.title-wrapper {
padding: 0 15px;
}
.graph-col {
height: 350px;
text-align: center;
padding: 0;
margin-left: 2px;
margin-right: 15px;
}
.table-col {
overflow: hidden;
}
.table {
font-size: 32px;
::ng-deep .symbol {
font-size: 24px;
}
.spacer {
background: none;
}
}
.fiat {
display: block;
}

View file

@ -0,0 +1,245 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { ActivatedRoute, ParamMap } from '@angular/router';
import { switchMap, catchError, map, tap, shareReplay, startWith, scan } from 'rxjs/operators';
import { Address, AddressTxSummary, ChainStats, Transaction } from '@interfaces/electrs.interface';
import { StateService } from '@app/services/state.service';
import { ApiService } from '@app/services/api.service';
import { of, Observable, Subscription } from 'rxjs';
import { SeoService } from '@app/services/seo.service';
import { seoDescriptionNetwork } from '@app/shared/common.utils';
import { WalletAddress } from '@interfaces/node-api.interface';
import { OpenGraphService } from '../../services/opengraph.service';
import { WebsocketService } from '../../services/websocket.service';
class WalletStats implements ChainStats {
addresses: string[];
funded_txo_count: number;
funded_txo_sum: number;
spent_txo_count: number;
spent_txo_sum: number;
tx_count: number;
constructor (stats: ChainStats[], addresses: string[]) {
Object.assign(this, stats.reduce((acc, stat) => {
acc.funded_txo_count += stat.funded_txo_count;
acc.funded_txo_sum += stat.funded_txo_sum;
acc.spent_txo_count += stat.spent_txo_count;
acc.spent_txo_sum += stat.spent_txo_sum;
return acc;
}, {
funded_txo_count: 0,
funded_txo_sum: 0,
spent_txo_count: 0,
spent_txo_sum: 0,
tx_count: 0,
})
);
this.addresses = addresses;
}
public addTx(tx: Transaction): void {
for (const vin of tx.vin) {
if (this.addresses.includes(vin.prevout?.scriptpubkey_address)) {
this.spendTxo(vin.prevout.value);
}
}
for (const vout of tx.vout) {
if (this.addresses.includes(vout.scriptpubkey_address)) {
this.fundTxo(vout.value);
}
}
this.tx_count++;
}
public removeTx(tx: Transaction): void {
for (const vin of tx.vin) {
if (this.addresses.includes(vin.prevout?.scriptpubkey_address)) {
this.unspendTxo(vin.prevout.value);
}
}
for (const vout of tx.vout) {
if (this.addresses.includes(vout.scriptpubkey_address)) {
this.unfundTxo(vout.value);
}
}
this.tx_count--;
}
private fundTxo(value: number): void {
this.funded_txo_sum += value;
this.funded_txo_count++;
}
private unfundTxo(value: number): void {
this.funded_txo_sum -= value;
this.funded_txo_count--;
}
private spendTxo(value: number): void {
this.spent_txo_sum += value;
this.spent_txo_count++;
}
private unspendTxo(value: number): void {
this.spent_txo_sum -= value;
this.spent_txo_count--;
}
get balance(): number {
return this.funded_txo_sum - this.spent_txo_sum;
}
get totalReceived(): number {
return this.funded_txo_sum;
}
get utxos(): number {
return this.funded_txo_count - this.spent_txo_count;
}
}
@Component({
selector: 'app-wallet-preview',
templateUrl: './wallet-preview.component.html',
styleUrls: ['./wallet-preview.component.scss']
})
export class WalletPreviewComponent implements OnInit, OnDestroy {
network = '';
addresses: Address[] = [];
addressStrings: string[] = [];
walletName: string;
isLoadingWallet = true;
wallet$: Observable<Record<string, WalletAddress>>;
walletAddresses$: Observable<Record<string, Address>>;
walletSummary$: Observable<AddressTxSummary[]>;
walletStats$: Observable<WalletStats>;
error: any;
walletSubscription: Subscription;
collapseAddresses: boolean = true;
fullyLoaded = false;
txCount = 0;
received = 0;
sent = 0;
chainBalance = 0;
constructor(
private route: ActivatedRoute,
private stateService: StateService,
private apiService: ApiService,
private seoService: SeoService,
private websocketService: WebsocketService,
private openGraphService: OpenGraphService,
) { }
ngOnInit(): void {
this.websocketService.want(['blocks', 'stats']);
this.stateService.networkChanged$.subscribe((network) => this.network = network);
this.wallet$ = this.route.paramMap.pipe(
map((params: ParamMap) => params.get('wallet') as string),
tap((walletName: string) => {
this.walletName = walletName;
this.openGraphService.waitFor('wallet-addresses-' + this.walletName);
this.openGraphService.waitFor('wallet-data-' + this.walletName);
this.openGraphService.waitFor('wallet-txs-' + this.walletName);
this.seoService.setTitle($localize`:@@wallet.component.browser-title:Wallet: ${walletName}:INTERPOLATION:`);
this.seoService.setDescription($localize`:@@meta.description.bitcoin.wallet:See mempool transactions, confirmed transactions, balance, and more for ${this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet'?'Liquid':'Bitcoin'}${seoDescriptionNetwork(this.stateService.network)} wallet ${walletName}:INTERPOLATION:.`);
}),
switchMap((walletName: string) => this.apiService.getWallet$(walletName).pipe(
catchError((err) => {
this.error = err;
this.seoService.logSoft404();
console.log(err);
this.openGraphService.fail('wallet-addresses-' + this.walletName);
this.openGraphService.fail('wallet-data-' + this.walletName);
this.openGraphService.fail('wallet-txs-' + this.walletName);
return of({});
})
)),
shareReplay(1),
);
this.walletAddresses$ = this.wallet$.pipe(
map(wallet => {
const walletInfo: Record<string, Address> = {};
for (const address of Object.keys(wallet)) {
walletInfo[address] = {
address,
chain_stats: wallet[address].stats,
mempool_stats: {
funded_txo_count: 0,
funded_txo_sum: 0,
spent_txo_count: 0, spent_txo_sum: 0, tx_count: 0
},
};
}
return walletInfo;
}),
tap(() => {
this.isLoadingWallet = false;
})
);
this.walletSubscription = this.walletAddresses$.subscribe(wallet => {
this.addressStrings = Object.keys(wallet);
this.addresses = Object.values(wallet);
this.openGraphService.waitOver('wallet-addresses-' + this.walletName);
});
this.walletSummary$ = this.wallet$.pipe(
map(wallet => this.deduplicateWalletTransactions(Object.values(wallet).flatMap(address => address.transactions))),
tap(() => {
this.openGraphService.waitOver('wallet-txs-' + this.walletName);
})
);
this.walletStats$ = this.wallet$.pipe(
switchMap(wallet => {
const walletStats = new WalletStats(Object.values(wallet).map(w => w.stats), Object.keys(wallet));
return this.stateService.walletTransactions$.pipe(
startWith([]),
scan((stats, newTransactions) => {
for (const tx of newTransactions) {
stats.addTx(tx);
}
return stats;
}, walletStats),
);
}),
tap(() => {
this.openGraphService.waitOver('wallet-data-' + this.walletName);
})
);
}
deduplicateWalletTransactions(walletTransactions: AddressTxSummary[]): AddressTxSummary[] {
const transactions = new Map<string, AddressTxSummary>();
for (const tx of walletTransactions) {
if (transactions.has(tx.txid)) {
transactions.get(tx.txid).value += tx.value;
} else {
transactions.set(tx.txid, tx);
}
}
return Array.from(transactions.values()).sort((a, b) => {
if (a.height === b.height) {
return b.tx_position - a.tx_position;
}
return b.height - a.height;
});
}
normalizeAddress(address: string): string {
if (/^[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100}|04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}$/.test(address)) {
return address.toLowerCase();
} else {
return address;
}
}
ngOnDestroy(): void {
this.walletSubscription.unsubscribe();
}
}

View file

@ -1,6 +1,6 @@
<div class="container-xl" [class.liquid-address]="network === 'liquid' || network === 'liquidtestnet'"> <div class="container-xl" [class.liquid-address]="network === 'liquid' || network === 'liquidtestnet'">
<div class="title-address"> <div class="title-address">
<h1 i18n="shared.wallet">Wallet</h1> <h1>{{ walletName }}</h1>
</div> </div>
<div class="clearfix"></div> <div class="clearfix"></div>
@ -74,6 +74,36 @@
</ng-container> </ng-container>
</ng-container> </ng-container>
<br>
<div class="title-tx">
<h2 class="text-left" i18n="address.transactions">Transactions</h2>
</div>
<app-transactions-list [transactions]="transactions" [showConfirmations]="true" [addresses]="addressStrings" (loadMore)="loadMore()"></app-transactions-list>
<div class="text-center">
<ng-template [ngIf]="isLoadingTransactions">
<div class="header-bg box">
<div class="row" style="height: 107px;">
<div class="col-sm">
<span class="skeleton-loader"></span>
</div>
<div class="col-sm">
<span class="skeleton-loader"></span>
</div>
</div>
</div>
</ng-template>
<ng-template [ngIf]="retryLoadMore">
<br>
<button type="button" class="btn btn-outline-info btn-sm" (click)="loadMore()"><fa-icon [icon]="['fas', 'redo-alt']" [fixedWidth]="true"></fa-icon></button>
</ng-template>
</div>
<ng-template #loadingTemplate> <ng-template #loadingTemplate>
<div class="box" *ngIf="!error; else errorTemplate"> <div class="box" *ngIf="!error; else errorTemplate">

View file

@ -9,6 +9,8 @@ import { of, Observable, Subscription } from 'rxjs';
import { SeoService } from '@app/services/seo.service'; import { SeoService } from '@app/services/seo.service';
import { seoDescriptionNetwork } from '@app/shared/common.utils'; import { seoDescriptionNetwork } from '@app/shared/common.utils';
import { WalletAddress } from '@interfaces/node-api.interface'; import { WalletAddress } from '@interfaces/node-api.interface';
import { ElectrsApiService } from '@app/services/electrs-api.service';
import { AudioService } from '@app/services/audio.service';
class WalletStats implements ChainStats { class WalletStats implements ChainStats {
addresses: string[]; addresses: string[];
@ -24,6 +26,7 @@ class WalletStats implements ChainStats {
acc.funded_txo_sum += stat.funded_txo_sum; acc.funded_txo_sum += stat.funded_txo_sum;
acc.spent_txo_count += stat.spent_txo_count; acc.spent_txo_count += stat.spent_txo_count;
acc.spent_txo_sum += stat.spent_txo_sum; acc.spent_txo_sum += stat.spent_txo_sum;
acc.tx_count += stat.tx_count;
return acc; return acc;
}, { }, {
funded_txo_count: 0, funded_txo_count: 0,
@ -109,12 +112,17 @@ export class WalletComponent implements OnInit, OnDestroy {
addressStrings: string[] = []; addressStrings: string[] = [];
walletName: string; walletName: string;
isLoadingWallet = true; isLoadingWallet = true;
isLoadingTransactions = true;
transactions: Transaction[];
totalTransactionCount: number;
retryLoadMore = false;
wallet$: Observable<Record<string, WalletAddress>>; wallet$: Observable<Record<string, WalletAddress>>;
walletAddresses$: Observable<Record<string, Address>>; walletAddresses$: Observable<Record<string, Address>>;
walletSummary$: Observable<AddressTxSummary[]>; walletSummary$: Observable<AddressTxSummary[]>;
walletStats$: Observable<WalletStats>; walletStats$: Observable<WalletStats>;
error: any; error: any;
walletSubscription: Subscription; walletSubscription: Subscription;
transactionSubscription: Subscription;
collapseAddresses: boolean = true; collapseAddresses: boolean = true;
@ -129,6 +137,8 @@ export class WalletComponent implements OnInit, OnDestroy {
private websocketService: WebsocketService, private websocketService: WebsocketService,
private stateService: StateService, private stateService: StateService,
private apiService: ApiService, private apiService: ApiService,
private electrsApiService: ElectrsApiService,
private audioService: AudioService,
private seoService: SeoService, private seoService: SeoService,
) { } ) { }
@ -172,6 +182,21 @@ export class WalletComponent implements OnInit, OnDestroy {
}), }),
switchMap(initial => this.stateService.walletTransactions$.pipe( switchMap(initial => this.stateService.walletTransactions$.pipe(
startWith(null), startWith(null),
tap((transactions) => {
if (!transactions?.length) {
return;
}
for (const transaction of transactions) {
const tx = this.transactions.find((t) => t.txid === transaction.txid);
if (tx) {
tx.status = transaction.status;
} else {
this.transactions.unshift(transaction);
}
}
this.transactions = this.transactions.slice();
this.audioService.playSound('magic');
}),
scan((wallet, walletTransactions) => { scan((wallet, walletTransactions) => {
for (const tx of (walletTransactions || [])) { for (const tx of (walletTransactions || [])) {
const funded: Record<string, number> = {}; const funded: Record<string, number> = {};
@ -267,8 +292,57 @@ export class WalletComponent implements OnInit, OnDestroy {
return stats; return stats;
}, walletStats), }, walletStats),
); );
}), })
); );
this.transactionSubscription = this.wallet$.pipe(
switchMap(wallet => {
const addresses = Object.keys(wallet).map(addr => this.normalizeAddress(addr));
return this.electrsApiService.getAddressesTransactions$(addresses);
}),
map(transactions => {
// only confirmed transactions supported for now
return transactions.filter(tx => tx.status.confirmed).sort((a, b) => b.status.block_height - a.status.block_height);
}),
catchError((error) => {
console.log(error);
this.error = error;
this.seoService.logSoft404();
this.isLoadingWallet = false;
return of([]);
})
).subscribe((transactions: Transaction[] | null) => {
if (!transactions) {
return;
}
this.transactions = transactions;
this.isLoadingTransactions = false;
});
}
loadMore(): void {
if (this.isLoadingTransactions || this.fullyLoaded) {
return;
}
this.isLoadingTransactions = true;
this.retryLoadMore = false;
this.electrsApiService.getAddressesTransactions$(this.addressStrings, this.transactions[this.transactions.length - 1].txid)
.subscribe((transactions: Transaction[]) => {
if (transactions && transactions.length) {
this.transactions = this.transactions.concat(transactions.sort((a, b) => b.status.block_height - a.status.block_height));
} else {
this.fullyLoaded = true;
}
this.isLoadingTransactions = false;
},
(error) => {
this.isLoadingTransactions = false;
this.retryLoadMore = true;
// In the unlikely event of the txid wasn't found in the mempool anymore and we must reload the page.
if (error.status === 422) {
window.location.reload();
}
});
} }
deduplicateWalletTransactions(walletTransactions: AddressTxSummary[]): AddressTxSummary[] { deduplicateWalletTransactions(walletTransactions: AddressTxSummary[]): AddressTxSummary[] {
@ -299,5 +373,6 @@ export class WalletComponent implements OnInit, OnDestroy {
ngOnDestroy(): void { ngOnDestroy(): void {
this.websocketService.stopTrackingWallet(); this.websocketService.stopTrackingWallet();
this.walletSubscription.unsubscribe(); this.walletSubscription.unsubscribe();
this.transactionSubscription.unsubscribe();
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core'; import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
import { Env, StateService } from '@app/services/state.service'; import { Env, StateService } from '@app/services/state.service';
import { restApiDocsData } from '@app/docs/api-docs/api-docs-data'; import { restApiDocsData, wsApiDocsData } from '@app/docs/api-docs/api-docs-data';
import { faqData } from '@app/docs/api-docs/api-docs-data'; import { faqData } from '@app/docs/api-docs/api-docs-data';
@Component({ @Component({
@ -28,6 +28,8 @@ export class ApiDocsNavComponent implements OnInit {
this.auditEnabled = this.env.AUDIT; this.auditEnabled = this.env.AUDIT;
if (this.whichTab === 'rest') { if (this.whichTab === 'rest') {
this.tabData = restApiDocsData; this.tabData = restApiDocsData;
} else if (this.whichTab === 'websocket') {
this.tabData = wsApiDocsData;
} else if (this.whichTab === 'faq') { } else if (this.whichTab === 'faq') {
this.tabData = faqData; this.tabData = faqData;
} }

View file

@ -108,18 +108,43 @@
</div> </div>
</div> </div>
<div id="websocketAPI" *ngIf="( whichTab === 'websocket' )"> <div id="websocketAPI" *ngIf="whichTab === 'websocket'">
<div class="api-category">
<div class="websocket"> <div id="doc-nav-desktop" class="hide-on-mobile" [ngClass]="desktopDocsNavPosition">
<div class="endpoint"> <app-api-docs-nav (navLinkClickEvent)="anchorLinkClick( $event )" [network]="{ val: network$ | async }" [whichTab]="whichTab"></app-api-docs-nav>
<div class="subtitle" i18n="Api docs endpoint">Endpoint</div> </div>
{{ wrapUrl(network.val, wsDocs, true) }}
<div class="doc-content">
<div id="enterprise-cta-mobile" *ngIf="officialMempoolInstance && showMobileEnterpriseUpsell">
<p>Get higher API limits with <span class="no-line-break">Mempool Enterprise®</span></p>
<div class="button-group">
<a class="btn btn-small btn-secondary" (click)="showMobileEnterpriseUpsell = false">No Thanks</a>
<a class="btn btn-small btn-purple" href="https://mempool.space/enterprise">More Info <fa-icon [icon]="['fas', 'angle-right']" [styles]="{'font-size': '12px'}"></fa-icon></a>
</div> </div>
<div class="description"> </div>
<div class="subtitle" i18n>Description</div>
<div i18n="api-docs.websocket.websocket">Default push: <code>{{ '{' }} action: 'want', data: ['blocks', ...] {{ '}' }}</code> to express what you want pushed. Available: <code>blocks</code>, <code>mempool-blocks</code>, <code>live-2h-chart</code>, and <code>stats</code>.<br><br>Push transactions related to address: <code>{{ '{' }} 'track-address': '3PbJ...bF9B' {{ '}' }}</code> to receive all new transactions containing that address as input or output. Returns an array of transactions. <code>address-transactions</code> for new mempool transactions, and <code>block-transactions</code> for new block confirmed transactions.</div> <p class="doc-welcome-note">Below is a reference for the {{ network.val === '' ? 'Bitcoin' : network.val.charAt(0).toUpperCase() + network.val.slice(1) }} <ng-container i18n="api-docs.title-websocket">Websocket service</ng-container> running at {{ websocketUrl(network.val) }}.</p>
<p class="doc-welcome-note api-note" *ngIf="officialMempoolInstance">Note that usage limits apply to our WebSocket API. Consider an <a href="https://mempool.space/enterprise">enterprise sponsorship</a> if you need higher API limits, such as higher tracking limits.</p>
<div class="doc-item-container" *ngFor="let item of wsDocs">
<div *ngIf="!item.hasOwnProperty('options') || ( item.hasOwnProperty('options') && item.options.hasOwnProperty('officialOnly') && item.options.officialOnly && officialMempoolInstance )">
<h3 *ngIf="( item.type === 'category' ) && ( item.showConditions.indexOf(network.val) > -1 )">{{ item.title }}</h3>
<div *ngIf="( item.type !== 'category' ) && ( item.showConditions.indexOf(network.val) > -1 )" class="endpoint-container" id="{{ item.fragment }}">
<a id="{{ item.fragment + '-tab-header' }}" class="section-header" (click)="anchorLinkClick({event: $event, fragment: item.fragment})">{{ item.title }} <span>{{ item.category }}</span></a>
<div class="endpoint-content">
<div class="description">
<div class="subtitle" i18n>Description</div>
<div [innerHTML]="item.description.default" i18n></div>
</div>
<div class="description">
<div class="subtitle" i18n>Payload</div>
<pre><code [innerText]="item.payload"></code></pre>
</div>
<app-code-template [hostname]="hostname" [baseNetworkUrl]="baseNetworkUrl" [method]="item.httpRequestMethod" [code]="item.codeExample.default" [network]="network.val" [showCodeExample]="item.showJsExamples"></app-code-template>
</div>
</div>
</div> </div>
<app-code-template [method]="'websocket'" [hostname]="hostname" [code]="wsDocs" [network]="network.val" [showCodeExample]="wsDocs.showJsExamples"></app-code-template>
</div> </div>
</div> </div>
</div> </div>

View file

@ -470,3 +470,21 @@ dd {
margin-left: 1em; margin-left: 1em;
} }
} }
code {
background-color: var(--bg);
font-family: Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New;
}
pre {
display: block;
font-size: 87.5%;
color: #f18920;
background-color: var(--bg);
padding: 30px;
code{
background-color: transparent;
white-space: break-spaces;
word-break: break-all;
}
}

View file

@ -145,7 +145,7 @@ export class ApiDocsComponent implements OnInit, AfterViewInit {
if (document.getElementById( targetId + "-tab-header" )) { if (document.getElementById( targetId + "-tab-header" )) {
tabHeaderHeight = document.getElementById( targetId + "-tab-header" ).scrollHeight; tabHeaderHeight = document.getElementById( targetId + "-tab-header" ).scrollHeight;
} }
if( ( window.innerWidth <= 992 ) && ( ( this.whichTab === 'rest' ) || ( this.whichTab === 'faq' ) ) && targetId ) { if( ( window.innerWidth <= 992 ) && ( ( this.whichTab === 'rest' ) || ( this.whichTab === 'faq' ) || ( this.whichTab === 'websocket' ) ) && targetId ) {
const endpointContainerEl = document.querySelector<HTMLElement>( "#" + targetId ); const endpointContainerEl = document.querySelector<HTMLElement>( "#" + targetId );
const endpointContentEl = document.querySelector<HTMLElement>( "#" + targetId + " .endpoint-content" ); const endpointContentEl = document.querySelector<HTMLElement>( "#" + targetId + " .endpoint-content" );
const endPointContentElHeight = endpointContentEl.clientHeight; const endPointContentElHeight = endpointContentEl.clientHeight;
@ -207,13 +207,29 @@ export class ApiDocsComponent implements OnInit, AfterViewInit {
text = text.replace('%{' + indexNumber + '}', curlText); text = text.replace('%{' + indexNumber + '}', curlText);
} }
if (websocket) {
const wsHostname = this.hostname.replace('https://', 'wss://');
wsHostname.replace('http://', 'ws://');
return `${wsHostname}${curlNetwork}${text}`;
}
return `${this.hostname}${curlNetwork}${text}`; return `${this.hostname}${curlNetwork}${text}`;
} }
websocketUrl(network: string) {
let curlNetwork = '';
if (this.env.BASE_MODULE === 'mempool') {
if (!['', 'mainnet'].includes(network)) {
curlNetwork = `/${network}`;
}
} else if (this.env.BASE_MODULE === 'liquid') {
if (!['', 'liquid'].includes(network)) {
curlNetwork = `/${network}`;
}
}
if (network === this.env.ROOT_NETWORK) {
curlNetwork = '';
}
let wsHostname = this.hostname.replace('https://', 'wss://');
wsHostname = wsHostname.replace('http://', 'ws://');
return `${wsHostname}${curlNetwork}/api/v1/ws`;
}
} }

View file

@ -36,6 +36,7 @@ import { HashrateChartPoolsComponent } from '@components/hashrates-chart-pools/h
import { BlockHealthGraphComponent } from '@components/block-health-graph/block-health-graph.component'; import { BlockHealthGraphComponent } from '@components/block-health-graph/block-health-graph.component';
import { AddressComponent } from '@components/address/address.component'; import { AddressComponent } from '@components/address/address.component';
import { WalletComponent } from '@components/wallet/wallet.component'; import { WalletComponent } from '@components/wallet/wallet.component';
import { WalletPreviewComponent } from '@components/wallet/wallet-preview.component';
import { AddressGraphComponent } from '@components/address-graph/address-graph.component'; import { AddressGraphComponent } from '@components/address-graph/address-graph.component';
import { UtxoGraphComponent } from '@components/utxo-graph/utxo-graph.component'; import { UtxoGraphComponent } from '@components/utxo-graph/utxo-graph.component';
import { ActiveAccelerationBox } from '@components/acceleration/active-acceleration-box/active-acceleration-box.component'; import { ActiveAccelerationBox } from '@components/acceleration/active-acceleration-box/active-acceleration-box.component';
@ -49,6 +50,7 @@ import { CommonModule } from '@angular/common';
MempoolBlockComponent, MempoolBlockComponent,
AddressComponent, AddressComponent,
WalletComponent, WalletComponent,
WalletPreviewComponent,
MiningDashboardComponent, MiningDashboardComponent,
AcceleratorDashboardComponent, AcceleratorDashboardComponent,

View file

@ -32,6 +32,8 @@ export interface Transaction {
price?: Price; price?: Price;
sigops?: number; sigops?: number;
flags?: bigint; flags?: bigint;
largeInput?: boolean;
largeOutput?: boolean;
} }
export interface TransactionChannels { export interface TransactionChannels {

View file

@ -1,4 +1,4 @@
import { AddressTxSummary, Block, ChainStats, Transaction } from "./electrs.interface"; import { AddressTxSummary, Block, ChainStats } from "./electrs.interface";
export interface OptimizedMempoolStats { export interface OptimizedMempoolStats {
added: number; added: number;

View file

@ -21,6 +21,8 @@ export interface WebsocketResponse {
rbfInfo?: RbfTree; rbfInfo?: RbfTree;
rbfLatest?: RbfTree[]; rbfLatest?: RbfTree[];
rbfLatestSummary?: ReplacementInfo[]; rbfLatestSummary?: ReplacementInfo[];
stratumJob?: StratumJob;
stratumJobs?: Record<number, StratumJob>;
utxoSpent?: object; utxoSpent?: object;
transactions?: TransactionStripped[]; transactions?: TransactionStripped[];
loadingIndicators?: ILoadingIndicators; loadingIndicators?: ILoadingIndicators;
@ -37,6 +39,7 @@ export interface WebsocketResponse {
'track-rbf-summary'?: boolean; 'track-rbf-summary'?: boolean;
'track-accelerations'?: boolean; 'track-accelerations'?: boolean;
'track-wallet'?: string; 'track-wallet'?: string;
'track-stratum'?: string | number;
'watch-mempool'?: boolean; 'watch-mempool'?: boolean;
'refresh-blocks'?: boolean; 'refresh-blocks'?: boolean;
} }
@ -144,4 +147,30 @@ export interface HealthCheckHost {
link?: string; link?: string;
statusPage?: SafeResourceUrl; statusPage?: SafeResourceUrl;
flag?: string; flag?: string;
hashes?: {
frontend?: string;
backend?: string;
electrs?: string;
}
}
export interface StratumJob {
pool: number;
height: number;
coinbase: string;
scriptsig: string;
reward: number;
jobId: string;
extraNonce: string;
extraNonce2Size: number;
prevHash: string;
coinbase1: string;
coinbase2: string;
merkleBranches: string[];
version: string;
bits: string;
time: string;
timestamp: number;
cleanJobs: boolean;
received: number;
} }

View file

@ -21,7 +21,7 @@
<tbody> <tbody>
<tr> <tr>
<td i18n="lightning.created">Created</td> <td i18n="lightning.created">Created</td>
<td>{{ channel.created | date:'yyyy-MM-dd HH:mm' }}</td> <td><app-timestamp [customFormat]="'yyyy-MM-dd HH:mm'" [unixTime]="channel.created" [hideTimeSince]="true"></app-timestamp></td>
</tr> </tr>
<tr> <tr>
<td i18n="lightning.capacity">Capacity</td> <td i18n="lightning.capacity">Capacity</td>

View file

@ -19,7 +19,7 @@
<ng-container *ngFor="let channel of channels;"> <ng-container *ngFor="let channel of channels;">
<tr> <tr>
<td class="timestamp"> <td class="timestamp">
&lrm;{{ channel.closing_date | date:'yyyy-MM-dd HH:mm' }} <app-timestamp [customFormat]="'yyyy-MM-dd HH:mm'" [unixTime]="channel.closing_date" [hideTimeSince]="true"></app-timestamp>
</td> </td>
<td class="capacity text-right"> <td class="capacity text-right">
<app-amount *ngIf="channel.capacity > 100000000; else smallnode" [satoshis]="channel.capacity" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount> <app-amount *ngIf="channel.capacity > 100000000; else smallnode" [satoshis]="channel.capacity" [digitsInfo]="'1.2-2'" [noFiat]="true"></app-amount>

View file

@ -142,12 +142,12 @@ const routes: Routes = [
if (window['__env']?.OFFICIAL_MEMPOOL_SPACE) { if (window['__env']?.OFFICIAL_MEMPOOL_SPACE) {
routes[0].children.push({ routes[0].children.push({
path: 'nodes', path: 'monitoring',
data: { networks: ['bitcoin', 'liquid'] }, data: { networks: ['bitcoin', 'liquid'] },
component: ServerHealthComponent component: ServerHealthComponent
}); });
routes[0].children.push({ routes[0].children.push({
path: 'network', path: 'nodes',
data: { networks: ['bitcoin', 'liquid'] }, data: { networks: ['bitcoin', 'liquid'] },
component: ServerStatusComponent component: ServerStatusComponent
}); });

View file

@ -10,9 +10,10 @@ import { TestTransactionsComponent } from '@components/test-transactions/test-tr
import { CalculatorComponent } from '@components/calculator/calculator.component'; import { CalculatorComponent } from '@components/calculator/calculator.component';
import { BlocksList } from '@components/blocks-list/blocks-list.component'; import { BlocksList } from '@components/blocks-list/blocks-list.component';
import { RbfList } from '@components/rbf-list/rbf-list.component'; import { RbfList } from '@components/rbf-list/rbf-list.component';
import { StratumList } from '@components/stratum/stratum-list/stratum-list.component';
import { ServerHealthComponent } from '@components/server-health/server-health.component'; import { ServerHealthComponent } from '@components/server-health/server-health.component';
import { ServerStatusComponent } from '@components/server-health/server-status.component'; import { ServerStatusComponent } from '@components/server-health/server-status.component';
import { FaucetComponent } from '@components/faucet/faucet.component' import { FaucetComponent } from '@components/faucet/faucet.component';
const browserWindow = window || {}; const browserWindow = window || {};
// @ts-ignore // @ts-ignore
@ -56,6 +57,16 @@ const routes: Routes = [
path: 'rbf', path: 'rbf',
component: RbfList, component: RbfList,
}, },
...(browserWindowEnv.STRATUM_ENABLED ? [{
path: 'stratum',
component: StartComponent,
children: [
{
path: '',
component: StratumList,
}
]
}] : []),
{ {
path: 'terms-of-service', path: 'terms-of-service',
loadChildren: () => import('@components/terms-of-service/terms-of-service.module').then(m => m.TermsOfServiceModule), loadChildren: () => import('@components/terms-of-service/terms-of-service.module').then(m => m.TermsOfServiceModule),

View file

@ -3,6 +3,7 @@ import { RouterModule, Routes } from '@angular/router';
import { TransactionPreviewComponent } from '@components/transaction/transaction-preview.component'; import { TransactionPreviewComponent } from '@components/transaction/transaction-preview.component';
import { BlockPreviewComponent } from '@components/block/block-preview.component'; import { BlockPreviewComponent } from '@components/block/block-preview.component';
import { AddressPreviewComponent } from '@components/address/address-preview.component'; import { AddressPreviewComponent } from '@components/address/address-preview.component';
import { WalletPreviewComponent } from '@components/wallet/wallet-preview.component';
import { PoolPreviewComponent } from '@components/pool/pool-preview.component'; import { PoolPreviewComponent } from '@components/pool/pool-preview.component';
import { MasterPagePreviewComponent } from '@components/master-page-preview/master-page-preview.component'; import { MasterPagePreviewComponent } from '@components/master-page-preview/master-page-preview.component';
@ -20,6 +21,11 @@ const routes: Routes = [
children: [], children: [],
component: AddressPreviewComponent component: AddressPreviewComponent
}, },
{
path: 'wallet/:wallet',
children: [],
component: WalletPreviewComponent
},
{ {
path: 'tx/:id', path: 'tx/:id',
children: [], children: [],

View file

@ -142,12 +142,16 @@ export class ElectrsApiService {
return this.httpClient.get<Transaction[]>(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address + '/txs', { params }); return this.httpClient.get<Transaction[]>(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address + '/txs', { params });
} }
getAddressesTransactions$(addresses: string[], txid?: string): Observable<Transaction[]> { getAddressesTransactions$(addresses: string[], txid?: string): Observable<Transaction[]> {
let params = new HttpParams(); let params = new HttpParams();
if (txid) { if (txid) {
params = params.append('after_txid', txid); params = params.append('after_txid', txid);
} }
return this.httpClient.get<Transaction[]>(this.apiBaseUrl + this.apiBasePath + `/api/addresses/txs?addresses=${addresses.join(',')}`, { params }); return this.httpClient.post<Transaction[]>(
this.apiBaseUrl + this.apiBasePath + '/api/addresses/txs',
addresses,
{ params }
);
} }
getAddressSummary$(address: string, txid?: string): Observable<AddressTxSummary[]> { getAddressSummary$(address: string, txid?: string): Observable<AddressTxSummary[]> {
@ -163,7 +167,7 @@ export class ElectrsApiService {
if (txid) { if (txid) {
params = params.append('after_txid', txid); params = params.append('after_txid', txid);
} }
return this.httpClient.get<AddressTxSummary[]>(this.apiBaseUrl + this.apiBasePath + `/api/addresses/txs/summary?addresses=${addresses.join(',')}`, { params }); return this.httpClient.post<AddressTxSummary[]>(this.apiBaseUrl + this.apiBasePath + '/api/addresses/txs/summary', addresses, { params });
} }
getScriptHashTransactions$(script: string, txid?: string): Observable<Transaction[]> { getScriptHashTransactions$(script: string, txid?: string): Observable<Transaction[]> {
@ -182,7 +186,7 @@ export class ElectrsApiService {
params = params.append('after_txid', txid); params = params.append('after_txid', txid);
} }
return from(Promise.all(scripts.map(script => calcScriptHash$(script)))).pipe( return from(Promise.all(scripts.map(script => calcScriptHash$(script)))).pipe(
switchMap(scriptHashes => this.httpClient.get<Transaction[]>(this.apiBaseUrl + this.apiBasePath + `/api/scripthashes/txs?scripthashes=${scriptHashes.join(',')}`, { params })), switchMap(scriptHashes => this.httpClient.post<Transaction[]>(this.apiBaseUrl + this.apiBasePath + '/api/scripthashes/txs', scriptHashes, { params })),
); );
} }
@ -212,7 +216,7 @@ export class ElectrsApiService {
params = params.append('after_txid', txid); params = params.append('after_txid', txid);
} }
return from(Promise.all(scripts.map(script => calcScriptHash$(script)))).pipe( return from(Promise.all(scripts.map(script => calcScriptHash$(script)))).pipe(
switchMap(scriptHashes => this.httpClient.get<AddressTxSummary[]>(this.apiBaseUrl + this.apiBasePath + `/api/scripthashes/txs/summary?scripthashes=${scriptHashes.join(',')}`, { params })), switchMap(scriptHashes => this.httpClient.post<AddressTxSummary[]>(this.apiBaseUrl + this.apiBasePath + '/api/scripthashes/txs/summary', scriptHashes, { params })),
); );
} }

View file

@ -55,7 +55,7 @@ export class EtaService {
return { return {
hashratePercentage: acceleratingHashrateFraction * 100, hashratePercentage: acceleratingHashrateFraction * 100,
ETA: Date.now() + da.timeAvg * mempoolPosition.block, ETA: Date.now() + da.adjustedTimeAvg * mempoolPosition.block,
acceleratedETA: this.calculateETAFromShares([ acceleratedETA: this.calculateETAFromShares([
{ block: mempoolPosition.block, hashrateShare: (1 - acceleratingHashrateFraction) }, { block: mempoolPosition.block, hashrateShare: (1 - acceleratingHashrateFraction) },
{ block: 0, hashrateShare: acceleratingHashrateFraction }, { block: 0, hashrateShare: acceleratingHashrateFraction },
@ -216,7 +216,7 @@ export class EtaService {
} }
// at max depth, the transaction is guaranteed to be mined in the next block if it hasn't already // at max depth, the transaction is guaranteed to be mined in the next block if it hasn't already
Q += ((max + 1) * (1-tailProb)); Q += ((max + 1) * (1-tailProb));
const eta = da.timeAvg * Q; // T x Q const eta = da.adjustedTimeAvg * Q; // T x Q
return { return {
now, now,

View file

@ -64,8 +64,8 @@ export class MiningService {
); );
} }
} }
/** /**
* Get names and slugs of all pools * Get names and slugs of all pools
*/ */
public getPools(): Observable<any[]> { public getPools(): Observable<any[]> {
@ -75,7 +75,6 @@ export class MiningService {
return this.poolsData; return this.poolsData;
}) })
); );
} }
/** /**
* Set the hashrate power of ten we want to display * Set the hashrate power of ten we want to display

View file

@ -18,7 +18,6 @@ export interface IUser {
subscription_tag: string; subscription_tag: string;
status: 'pending' | 'verified' | 'disabled'; status: 'pending' | 'verified' | 'disabled';
features: string | null; features: string | null;
fullName: string | null;
countryCode: string | null; countryCode: string | null;
imageMd5: string; imageMd5: string;
ogRank: number | null; ogRank: number | null;
@ -131,20 +130,20 @@ export class ServicesApiServices {
return this.httpClient.post<any>(`${this.stateService.env.SERVICES_API}/accelerator/estimate`, { txInput: txInput }, { observe: 'response' }); return this.httpClient.post<any>(`${this.stateService.env.SERVICES_API}/accelerator/estimate`, { txInput: txInput }, { observe: 'response' });
} }
accelerate$(txInput: string, userBid: number, accelerationUUID: string) { accelerate$(txInput: string, userBid: number) {
return this.httpClient.post<any>(`${this.stateService.env.SERVICES_API}/accelerator/accelerate`, { txInput: txInput, userBid: userBid, accelerationUUID: accelerationUUID }); return this.httpClient.post<any>(`${this.stateService.env.SERVICES_API}/accelerator/accelerate`, { txInput: txInput, userBid: userBid});
} }
accelerateWithCashApp$(txInput: string, token: string, cashtag: string, referenceId: string, accelerationUUID: string, userApprovedUSD: number) { accelerateWithCashApp$(txInput: string, token: string, cashtag: string, referenceId: 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 }); return this.httpClient.post<any>(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/cashapp`, { txInput: txInput, token: token, cashtag: cashtag, referenceId: referenceId, userApprovedUSD: userApprovedUSD });
} }
accelerateWithApplePay$(txInput: string, token: string, cardTag: string, referenceId: string, accelerationUUID: string, userApprovedUSD: number) { accelerateWithApplePay$(txInput: string, token: string, cardTag: string, referenceId: 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 }); return this.httpClient.post<any>(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/applePay`, { txInput: txInput, cardTag: cardTag, token: token, referenceId: referenceId, userApprovedUSD: userApprovedUSD });
} }
accelerateWithGooglePay$(txInput: string, token: string, cardTag: string, referenceId: string, accelerationUUID: string, userApprovedUSD: number) { accelerateWithGooglePay$(txInput: string, token: string, verificationToken: string, cardTag: string, referenceId: string, userApprovedUSD: number, userChallenged: boolean) {
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 }); return this.httpClient.post<any>(`${this.stateService.env.SERVICES_API}/accelerator/accelerate/googlePay`, { txInput: txInput, cardTag: cardTag, token: token, verificationToken: verificationToken, referenceId: referenceId, userApprovedUSD: userApprovedUSD, userChallenged: userChallenged });
} }
getAccelerations$(): Observable<Acceleration[]> { getAccelerations$(): Observable<Acceleration[]> {

View file

@ -1,7 +1,7 @@
import { Inject, Injectable, PLATFORM_ID, LOCALE_ID } from '@angular/core'; import { Inject, Injectable, PLATFORM_ID, LOCALE_ID } from '@angular/core';
import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable } from 'rxjs'; import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable } from 'rxjs';
import { AddressTxSummary, Transaction } from '@interfaces/electrs.interface'; import { Transaction } from '@interfaces/electrs.interface';
import { AccelerationDelta, HealthCheckHost, IBackendInfo, MempoolBlock, MempoolBlockUpdate, MempoolInfo, Recommendedfees, ReplacedTransaction, ReplacementInfo, isMempoolState } from '@interfaces/websocket.interface'; import { AccelerationDelta, HealthCheckHost, IBackendInfo, MempoolBlock, MempoolBlockUpdate, MempoolInfo, Recommendedfees, ReplacedTransaction, ReplacementInfo, StratumJob, isMempoolState } from '@interfaces/websocket.interface';
import { Acceleration, AccelerationPosition, BlockExtended, CpfpInfo, DifficultyAdjustment, MempoolPosition, OptimizedMempoolStats, RbfTree, TransactionStripped } from '@interfaces/node-api.interface'; import { Acceleration, AccelerationPosition, BlockExtended, CpfpInfo, DifficultyAdjustment, MempoolPosition, OptimizedMempoolStats, RbfTree, TransactionStripped } from '@interfaces/node-api.interface';
import { Router, NavigationStart } from '@angular/router'; import { Router, NavigationStart } from '@angular/router';
import { isPlatformBrowser } from '@angular/common'; import { isPlatformBrowser } from '@angular/common';
@ -81,6 +81,7 @@ export interface Env {
ADDITIONAL_CURRENCIES: boolean; ADDITIONAL_CURRENCIES: boolean;
GIT_COMMIT_HASH_MEMPOOL_SPACE?: string; GIT_COMMIT_HASH_MEMPOOL_SPACE?: string;
PACKAGE_JSON_VERSION_MEMPOOL_SPACE?: string; PACKAGE_JSON_VERSION_MEMPOOL_SPACE?: string;
STRATUM_ENABLED: boolean;
SERVICES_API?: string; SERVICES_API?: string;
customize?: Customization; customize?: Customization;
PROD_DOMAINS: string[]; PROD_DOMAINS: string[];
@ -123,6 +124,7 @@ const defaultEnv: Env = {
'ACCELERATOR_BUTTON': true, 'ACCELERATOR_BUTTON': true,
'PUBLIC_ACCELERATIONS': false, 'PUBLIC_ACCELERATIONS': false,
'ADDITIONAL_CURRENCIES': false, 'ADDITIONAL_CURRENCIES': false,
'STRATUM_ENABLED': false,
'SERVICES_API': 'https://mempool.space/api/v1/services', 'SERVICES_API': 'https://mempool.space/api/v1/services',
'PROD_DOMAINS': [], 'PROD_DOMAINS': [],
}; };
@ -159,6 +161,8 @@ export class StateService {
liveMempoolBlockTransactions$: Observable<{ block: number, transactions: { [txid: string]: TransactionStripped} }>; liveMempoolBlockTransactions$: Observable<{ block: number, transactions: { [txid: string]: TransactionStripped} }>;
accelerations$ = new Subject<AccelerationDelta>(); accelerations$ = new Subject<AccelerationDelta>();
liveAccelerations$: Observable<Acceleration[]>; liveAccelerations$: Observable<Acceleration[]>;
stratumJobUpdate$ = new Subject<{ state: Record<string, StratumJob> } | { job: StratumJob }>();
stratumJobs$ = new BehaviorSubject<Record<string, StratumJob>>({});
txConfirmed$ = new Subject<[string, BlockExtended]>(); txConfirmed$ = new Subject<[string, BlockExtended]>();
txReplaced$ = new Subject<ReplacedTransaction>(); txReplaced$ = new Subject<ReplacedTransaction>();
txRbfInfo$ = new Subject<RbfTree>(); txRbfInfo$ = new Subject<RbfTree>();
@ -186,6 +190,7 @@ export class StateService {
live2Chart$ = new Subject<OptimizedMempoolStats>(); live2Chart$ = new Subject<OptimizedMempoolStats>();
viewAmountMode$: BehaviorSubject<'btc' | 'sats' | 'fiat'>; viewAmountMode$: BehaviorSubject<'btc' | 'sats' | 'fiat'>;
timezone$: BehaviorSubject<string>;
connectionState$ = new BehaviorSubject<0 | 1 | 2>(2); connectionState$ = new BehaviorSubject<0 | 1 | 2>(2);
isTabHidden$: Observable<boolean>; isTabHidden$: Observable<boolean>;
@ -302,6 +307,24 @@ export class StateService {
map((accMap) => Object.values(accMap).sort((a,b) => b.added - a.added)) map((accMap) => Object.values(accMap).sort((a,b) => b.added - a.added))
); );
this.stratumJobUpdate$.pipe(
scan((acc: Record<string, StratumJob>, update: { state: Record<string, StratumJob> } | { job: StratumJob }) => {
if ('state' in update) {
// Replace the entire state
return update.state;
} else {
// Update or create a single job entry
return {
...acc,
[update.job.pool]: update.job
};
}
}, {}),
shareReplay(1)
).subscribe(val => {
this.stratumJobs$.next(val);
});
this.networkChanged$.subscribe((network) => { this.networkChanged$.subscribe((network) => {
this.transactions$ = new BehaviorSubject<TransactionStripped[]>(null); this.transactions$ = new BehaviorSubject<TransactionStripped[]>(null);
this.blocksSubject$.next([]); this.blocksSubject$.next([]);
@ -347,6 +370,9 @@ export class StateService {
const viewAmountModePreference = this.storageService.getValue('view-amount-mode') as 'btc' | 'sats' | 'fiat'; const viewAmountModePreference = this.storageService.getValue('view-amount-mode') as 'btc' | 'sats' | 'fiat';
this.viewAmountMode$ = new BehaviorSubject<'btc' | 'sats' | 'fiat'>(viewAmountModePreference || 'btc'); this.viewAmountMode$ = new BehaviorSubject<'btc' | 'sats' | 'fiat'>(viewAmountModePreference || 'btc');
const timezonePreference = this.storageService.getValue('timezone-preference');
this.timezone$ = new BehaviorSubject<string>(timezonePreference || 'local');
this.backend$.subscribe(backend => { this.backend$.subscribe(backend => {
this.backend = backend; this.backend = backend;
}); });

View file

@ -36,7 +36,9 @@ export class WebsocketService {
private isTrackingAccelerations: boolean = false; private isTrackingAccelerations: boolean = false;
private isTrackingWallet: boolean = false; private isTrackingWallet: boolean = false;
private trackingWalletName: string; private trackingWalletName: string;
private isTrackingStratum: string | number | false = false;
private trackingMempoolBlock: number; private trackingMempoolBlock: number;
private trackingMempoolBlockNetwork: string;
private stoppingTrackMempoolBlock: any | null = null; private stoppingTrackMempoolBlock: any | null = null;
private latestGitCommit = ''; private latestGitCommit = '';
private onlineCheckTimeout: number; private onlineCheckTimeout: number;
@ -142,6 +144,9 @@ export class WebsocketService {
if (this.isTrackingWallet) { if (this.isTrackingWallet) {
this.startTrackingWallet(this.trackingWalletName); this.startTrackingWallet(this.trackingWalletName);
} }
if (this.isTrackingStratum !== false) {
this.startTrackStratum(this.isTrackingStratum);
}
this.stateService.connectionState$.next(2); this.stateService.connectionState$.next(2);
} }
@ -226,10 +231,11 @@ export class WebsocketService {
clearTimeout(this.stoppingTrackMempoolBlock); clearTimeout(this.stoppingTrackMempoolBlock);
} }
// skip duplicate tracking requests // skip duplicate tracking requests
if (force || this.trackingMempoolBlock !== block) { if (force || this.trackingMempoolBlock !== block || this.network !== this.trackingMempoolBlockNetwork) {
this.websocketSubject.next({ 'track-mempool-block': block }); this.websocketSubject.next({ 'track-mempool-block': block });
this.isTrackingMempoolBlock = true; this.isTrackingMempoolBlock = true;
this.trackingMempoolBlock = block; this.trackingMempoolBlock = block;
this.trackingMempoolBlockNetwork = this.network;
return true; return true;
} }
return false; return false;
@ -287,6 +293,18 @@ export class WebsocketService {
} }
} }
startTrackStratum(pool: number | string) {
this.websocketSubject.next({ 'track-stratum': pool });
this.isTrackingStratum = pool;
}
stopTrackStratum() {
if (this.isTrackingStratum) {
this.websocketSubject.next({ 'track-stratum': null });
this.isTrackingStratum = false;
}
}
fetchStatistics(historicalDate: string) { fetchStatistics(historicalDate: string) {
this.websocketSubject.next({ historicalDate }); this.websocketSubject.next({ historicalDate });
} }
@ -510,6 +528,14 @@ export class WebsocketService {
this.stateService.previousRetarget$.next(response.previousRetarget); this.stateService.previousRetarget$.next(response.previousRetarget);
} }
if (response.stratumJobs) {
this.stateService.stratumJobUpdate$.next({ state: response.stratumJobs });
}
if (response.stratumJob) {
this.stateService.stratumJobUpdate$.next({ job: response.stratumJob });
}
if (response['tomahawk']) { if (response['tomahawk']) {
this.stateService.serverHealth$.next(response['tomahawk']); this.stateService.serverHealth$.next(response['tomahawk']);
} }

Some files were not shown because too many files have changed in this diff Show more