Merge branch 'master' into mononaut/redis-error-handling

This commit is contained in:
softsimon 2024-02-19 21:21:05 +07:00 committed by GitHub
commit 086ae6978a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
169 changed files with 4993 additions and 832 deletions

View file

@ -115,6 +115,10 @@ jobs:
- name: Sync-assets - name: Sync-assets
run: npm run sync-assets-dev run: npm run sync-assets-dev
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
MEMPOOL_CDN: 1
VERBOSE: 1
working-directory: assets/frontend working-directory: assets/frontend
- name: Zip mining-pool assets - name: Zip mining-pool assets
@ -237,6 +241,8 @@ jobs:
working-directory: ${{ matrix.node }}/${{ matrix.flavor }}/frontend working-directory: ${{ matrix.node }}/${{ matrix.flavor }}/frontend
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
MEMPOOL_CDN: 1
VERBOSE: 1
e2e: e2e:
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/')"
@ -330,3 +336,31 @@ 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 }}
validate_docker_json:
if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')"
runs-on: "ubuntu-latest"
name: Validate generated backend Docker JSON
steps:
- name: Checkout
uses: actions/checkout@v3
with:
path: docker
- name: Install jq
run: sudo apt-get install jq -y
- name: Create new start script to run on CI
run: |
sed '$d' start.sh > start_ci.sh
working-directory: docker/docker/backend
- name: Run the script to generate the sample JSON
run: |
sh start_ci.sh
working-directory: docker/docker/backend
- name: Validate JSON syntax
run: |
cat mempool-config.json | jq
working-directory: docker/docker/backend

1
.gitignore vendored
View file

@ -6,3 +6,4 @@ backend/mempool-config.json
frontend/src/resources/config.template.js frontend/src/resources/config.template.js
frontend/src/resources/config.js frontend/src/resources/config.js
target target
docker/backend/start_ci.sh

6
backend/.gitignore vendored
View file

@ -7,6 +7,12 @@ mempool-config.json
pools.json pools.json
icons.json icons.json
# docker
Dockerfile
GeoIP
start.sh
wait-for-it.sh
# compiled output # compiled output
/dist /dist
/tmp /tmp

View file

@ -17,7 +17,7 @@
"crypto-js": "~4.2.0", "crypto-js": "~4.2.0",
"express": "~4.18.2", "express": "~4.18.2",
"maxmind": "~4.3.11", "maxmind": "~4.3.11",
"mysql2": "~3.7.0", "mysql2": "~3.9.1",
"redis": "^4.6.6", "redis": "^4.6.6",
"rust-gbt": "file:./rust-gbt", "rust-gbt": "file:./rust-gbt",
"socks-proxy-agent": "~7.0.0", "socks-proxy-agent": "~7.0.0",
@ -3673,9 +3673,9 @@
"dev": true "dev": true
}, },
"node_modules/follow-redirects": { "node_modules/follow-redirects": {
"version": "1.15.2", "version": "1.15.5",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz",
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@ -6110,9 +6110,9 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
}, },
"node_modules/mysql2": { "node_modules/mysql2": {
"version": "3.7.0", "version": "3.9.1",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.7.0.tgz", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.1.tgz",
"integrity": "sha512-c45jA3Jc1X8yJKzrWu1GpplBKGwv/wIV6ITZTlCSY7npF2YfJR+6nMP5e+NTQhUeJPSyOQAbGDCGEHbAl8HN9w==", "integrity": "sha512-3njoWAAhGBYy0tWBabqUQcLtczZUxrmmtc2vszQUekg3kTJyZ5/IeLC3Fo04u6y6Iy5Sba7pIIa2P/gs8D3ZeQ==",
"dependencies": { "dependencies": {
"denque": "^2.1.0", "denque": "^2.1.0",
"generate-function": "^2.3.1", "generate-function": "^2.3.1",
@ -10440,9 +10440,9 @@
"dev": true "dev": true
}, },
"follow-redirects": { "follow-redirects": {
"version": "1.15.2", "version": "1.15.5",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz",
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==" "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw=="
}, },
"form-data": { "form-data": {
"version": "4.0.0", "version": "4.0.0",
@ -12230,9 +12230,9 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
}, },
"mysql2": { "mysql2": {
"version": "3.7.0", "version": "3.9.1",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.7.0.tgz", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.1.tgz",
"integrity": "sha512-c45jA3Jc1X8yJKzrWu1GpplBKGwv/wIV6ITZTlCSY7npF2YfJR+6nMP5e+NTQhUeJPSyOQAbGDCGEHbAl8HN9w==", "integrity": "sha512-3njoWAAhGBYy0tWBabqUQcLtczZUxrmmtc2vszQUekg3kTJyZ5/IeLC3Fo04u6y6Iy5Sba7pIIa2P/gs8D3ZeQ==",
"requires": { "requires": {
"denque": "^2.1.0", "denque": "^2.1.0",
"generate-function": "^2.3.1", "generate-function": "^2.3.1",

View file

@ -47,7 +47,7 @@
"crypto-js": "~4.2.0", "crypto-js": "~4.2.0",
"express": "~4.18.2", "express": "~4.18.2",
"maxmind": "~4.3.11", "maxmind": "~4.3.11",
"mysql2": "~3.7.0", "mysql2": "~3.9.1",
"rust-gbt": "file:./rust-gbt", "rust-gbt": "file:./rust-gbt",
"redis": "^4.6.6", "redis": "^4.6.6",
"socks-proxy-agent": "~7.0.0", "socks-proxy-agent": "~7.0.0",

View file

@ -11,9 +11,35 @@ describe('Mempool Difficulty Adjustment', () => {
}; };
const vectors = [ const vectors = [
[ // Vector 1 [ // Vector 1 (normal adjustment)
[ // Inputs
dt('2024-02-02T15:42:06.000Z'), // Last DA time (in seconds)
dt('2024-02-08T14:43:05.000Z'), // timestamp of 504 blocks ago (in seconds)
dt('2024-02-11T22:43:01.000Z'), // Current time (now) (in seconds)
830027, // Current block height
7.333505241141637, // Previous retarget % (Passed through)
'mainnet', // Network (if testnet, next value is non-zero)
0, // Latest block timestamp in seconds (only used if difficulty already locked in)
],
{ // Expected Result
progressPercent: 71.97420634920636,
difficultyChange: 8.512745140778843,
estimatedRetargetDate: 1708004001715,
remainingBlocks: 565,
remainingTime: 312620715,
previousRetarget: 7.333505241141637,
previousTime: 1706888526,
nextRetargetHeight: 830592,
timeAvg: 553311,
adjustedTimeAvg: 553311,
timeOffset: 0,
expectedBlocks: 1338.0916666666667,
},
],
[ // Vector 2 (within quarter-epoch overlap)
[ // Inputs [ // Inputs
dt('2022-08-18T11:07:00.000Z'), // Last DA time (in seconds) dt('2022-08-18T11:07:00.000Z'), // Last DA time (in seconds)
dt('2022-08-16T03:16:54.000Z'), // timestamp of 504 blocks ago (in seconds)
dt('2022-08-19T14:03:53.000Z'), // Current time (now) (in seconds) dt('2022-08-19T14:03:53.000Z'), // Current time (now) (in seconds)
750134, // Current block height 750134, // Current block height
0.6280047707459726, // Previous retarget % (Passed through) 0.6280047707459726, // Previous retarget % (Passed through)
@ -22,21 +48,23 @@ describe('Mempool Difficulty Adjustment', () => {
], ],
{ // Expected Result { // Expected Result
progressPercent: 9.027777777777777, progressPercent: 9.027777777777777,
difficultyChange: 13.180707740199772, difficultyChange: 1.0420538959004633,
estimatedRetargetDate: 1661895424692, estimatedRetargetDate: 1662009048328,
remainingBlocks: 1834, remainingBlocks: 1834,
remainingTime: 977591692, remainingTime: 1091215328,
previousRetarget: 0.6280047707459726, previousRetarget: 0.6280047707459726,
previousTime: 1660820820, previousTime: 1660820820,
nextRetargetHeight: 751968, nextRetargetHeight: 751968,
timeAvg: 533038, timeAvg: 533038,
adjustedTimeAvg: 594992,
timeOffset: 0, timeOffset: 0,
expectedBlocks: 161.68833333333333, expectedBlocks: 161.68833333333333,
}, },
], ],
[ // Vector 2 (testnet) [ // Vector 3 (testnet)
[ // Inputs [ // Inputs
dt('2022-08-18T11:07:00.000Z'), // Last DA time (in seconds) dt('2022-08-18T11:07:00.000Z'), // Last DA time (in seconds)
dt('2022-08-16T03:16:54.000Z'), // timestamp of 504 blocks ago (in seconds)
dt('2022-08-19T14:03:53.000Z'), // Current time (now) (in seconds) dt('2022-08-19T14:03:53.000Z'), // Current time (now) (in seconds)
750134, // Current block height 750134, // Current block height
0.6280047707459726, // Previous retarget % (Passed through) 0.6280047707459726, // Previous retarget % (Passed through)
@ -45,22 +73,24 @@ describe('Mempool Difficulty Adjustment', () => {
], ],
{ // Expected Result is same other than timeOffset { // Expected Result is same other than timeOffset
progressPercent: 9.027777777777777, progressPercent: 9.027777777777777,
difficultyChange: 13.180707740199772, difficultyChange: 1.0420538959004633,
estimatedRetargetDate: 1661895424692, estimatedRetargetDate: 1662009048328,
remainingBlocks: 1834, remainingBlocks: 1834,
remainingTime: 977591692, remainingTime: 1091215328,
previousTime: 1660820820, previousTime: 1660820820,
previousRetarget: 0.6280047707459726, previousRetarget: 0.6280047707459726,
nextRetargetHeight: 751968, nextRetargetHeight: 751968,
timeAvg: 533038, timeAvg: 533038,
adjustedTimeAvg: 594992,
timeOffset: -667000, // 11 min 7 seconds since last block (testnet only) timeOffset: -667000, // 11 min 7 seconds since last block (testnet only)
// If we add time avg to abs(timeOffset) it makes exactly 1200000 ms, or 20 minutes // If we add time avg to abs(timeOffset) it makes exactly 1200000 ms, or 20 minutes
expectedBlocks: 161.68833333333333, expectedBlocks: 161.68833333333333,
}, },
], ],
[ // Vector 3 (mainnet lock-in (epoch ending 788255)) [ // Vector 4 (mainnet lock-in (epoch ending 788255))
[ // Inputs [ // Inputs
dt('2023-04-20T09:57:33.000Z'), // Last DA time (in seconds) dt('2023-04-20T09:57:33.000Z'), // Last DA time (in seconds)
dt('2022-08-16T03:16:54.000Z'), // timestamp of 504 blocks ago (in seconds)
dt('2023-05-04T14:54:09.000Z'), // Current time (now) (in seconds) dt('2023-05-04T14:54:09.000Z'), // Current time (now) (in seconds)
788255, // Current block height 788255, // Current block height
1.7220298879531821, // Previous retarget % (Passed through) 1.7220298879531821, // Previous retarget % (Passed through)
@ -77,16 +107,17 @@ describe('Mempool Difficulty Adjustment', () => {
previousTime: 1681984653, previousTime: 1681984653,
nextRetargetHeight: 788256, nextRetargetHeight: 788256,
timeAvg: 609129, timeAvg: 609129,
adjustedTimeAvg: 609129,
timeOffset: 0, timeOffset: 0,
expectedBlocks: 2045.66, expectedBlocks: 2045.66,
}, },
], ],
] as [[number, number, number, number, string, number], DifficultyAdjustment][]; ] as [[number, number, number, number, number, string, number], DifficultyAdjustment][];
for (const vector of vectors) { for (const vector of vectors) {
const result = calcDifficultyAdjustment(...vector[0]); const result = calcDifficultyAdjustment(...vector[0]);
// previousRetarget is passed through untouched // previousRetarget is passed through untouched
expect(result.previousRetarget).toStrictEqual(vector[0][3]); expect(result.previousRetarget).toStrictEqual(vector[0][4]);
expect(result).toStrictEqual(vector[1]); expect(result).toStrictEqual(vector[1]);
} }
}); });

View file

@ -646,7 +646,7 @@ class BisqMarketsApi {
case 'year': case 'year':
return strtotime('midnight first day of january', ts); return strtotime('midnight first day of january', ts);
default: default:
throw new Error('Unsupported interval: ' + interval); throw new Error('Unsupported interval');
} }
} }

View file

@ -106,6 +106,7 @@ export namespace IBitcoinApi {
address?: string; // (string) bitcoin address address?: string; // (string) bitcoin address
addresses?: string[]; // (string) bitcoin addresses addresses?: string[]; // (string) bitcoin addresses
pegout_chain?: string; // (string) Elements peg-out chain pegout_chain?: string; // (string) Elements peg-out chain
pegout_address?: string; // (string) Elements peg-out address
pegout_addresses?: string[]; // (string) Elements peg-out addresses pegout_addresses?: string[]; // (string) Elements peg-out addresses
}; };
} }

View file

@ -4,6 +4,7 @@ import http from 'http';
import { AbstractBitcoinApi } from './bitcoin-api-abstract-factory'; import { AbstractBitcoinApi } 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';
interface FailoverHost { interface FailoverHost {
host: string, host: string,
@ -15,11 +16,13 @@ interface FailoverHost {
outOfSync?: boolean, outOfSync?: boolean,
unreachable?: boolean, unreachable?: boolean,
preferred?: boolean, preferred?: boolean,
checked: boolean,
} }
class FailoverRouter { class FailoverRouter {
activeHost: FailoverHost; activeHost: FailoverHost;
fallbackHost: FailoverHost; fallbackHost: FailoverHost;
maxHeight: number = 0;
hosts: FailoverHost[]; hosts: FailoverHost[];
multihost: boolean; multihost: boolean;
pollInterval: number = 60000; pollInterval: number = 60000;
@ -34,6 +37,7 @@ class FailoverRouter {
this.hosts = (config.ESPLORA.FALLBACK || []).map(domain => { this.hosts = (config.ESPLORA.FALLBACK || []).map(domain => {
return { return {
host: domain, host: domain,
checked: false,
rtts: [], rtts: [],
rtt: Infinity, rtt: Infinity,
failures: 0, failures: 0,
@ -46,6 +50,7 @@ class FailoverRouter {
failures: 0, failures: 0,
socket: !!config.ESPLORA.UNIX_SOCKET_PATH, socket: !!config.ESPLORA.UNIX_SOCKET_PATH,
preferred: true, preferred: true,
checked: false,
}; };
this.fallbackHost = this.activeHost; this.fallbackHost = this.activeHost;
this.hosts.unshift(this.activeHost); this.hosts.unshift(this.activeHost);
@ -74,27 +79,24 @@ class FailoverRouter {
clearTimeout(this.pollTimer); clearTimeout(this.pollTimer);
} }
const results = await Promise.allSettled(this.hosts.map(async (host) => { const start = Date.now();
if (host.socket) {
return this.pollConnection.get<number>('/blocks/tip/height', { socketPath: host.host, timeout: config.ESPLORA.FALLBACK_TIMEOUT });
} else {
return this.pollConnection.get<number>(host.host + '/blocks/tip/height', { timeout: config.ESPLORA.FALLBACK_TIMEOUT });
}
}));
const maxHeight = results.reduce((max, result) => Math.max(max, result.status === 'fulfilled' ? result.value?.data || 0 : 0), 0);
// update rtts & sync status // update rtts & sync status
for (let i = 0; i < results.length; i++) { for (const host of this.hosts) {
const host = this.hosts[i]; try {
const result = results[i].status === 'fulfilled' ? (results[i] as PromiseFulfilledResult<AxiosResponse<number, any>>).value : null; const result = await (host.socket
? this.pollConnection.get<number>('/blocks/tip/height', { socketPath: host.host, timeout: config.ESPLORA.FALLBACK_TIMEOUT })
: this.pollConnection.get<number>(host.host + '/blocks/tip/height', { timeout: config.ESPLORA.FALLBACK_TIMEOUT })
);
if (result) { if (result) {
const height = result.data; const height = result.data;
this.maxHeight = Math.max(height, this.maxHeight);
const rtt = result.config['meta'].rtt; const rtt = result.config['meta'].rtt;
host.rtts.unshift(rtt); host.rtts.unshift(rtt);
host.rtts.slice(0, 5); host.rtts.slice(0, 5);
host.rtt = host.rtts.reduce((acc, l) => acc + l, 0) / host.rtts.length; host.rtt = host.rtts.reduce((acc, l) => acc + l, 0) / host.rtts.length;
host.latestHeight = height; host.latestHeight = height;
if (height == null || isNaN(height) || (maxHeight - height > 2)) { if (height == null || isNaN(height) || (this.maxHeight - height > 2)) {
host.outOfSync = true; host.outOfSync = true;
} else { } else {
host.outOfSync = false; host.outOfSync = false;
@ -103,15 +105,22 @@ class FailoverRouter {
} else { } else {
host.outOfSync = true; host.outOfSync = true;
host.unreachable = true; host.unreachable = true;
host.rtts = [];
host.rtt = Infinity;
} }
} catch (e) {
host.outOfSync = true;
host.unreachable = true;
host.rtts = [];
host.rtt = Infinity;
} }
host.checked = true;
this.sortHosts();
logger.debug(`Tomahawk ranking:\n${this.hosts.map((host, index) => this.formatRanking(index, host, this.activeHost, maxHeight)).join('\n')}`);
// switch if the current host is out of sync or significantly slower than the next best alternative // switch if the current host is out of sync or significantly slower than the next best alternative
if (this.activeHost.outOfSync || this.activeHost.unreachable || (this.activeHost !== this.hosts[0] && this.hosts[0].preferred) || (!this.activeHost.preferred && this.activeHost.rtt > (this.hosts[0].rtt * 2) + 50)) { const rankOrder = this.sortHosts();
// switch if the current host is out of sync or significantly slower than the next best alternative
if (this.activeHost.outOfSync || this.activeHost.unreachable || (this.activeHost !== rankOrder[0] && rankOrder[0].preferred) || (!this.activeHost.preferred && this.activeHost.rtt > (rankOrder[0].rtt * 2) + 50)) {
if (this.activeHost.unreachable) { if (this.activeHost.unreachable) {
logger.warn(`🚨🚨🚨 Unable to reach ${this.activeHost.host}, failing over to next best alternative 🚨🚨🚨`); logger.warn(`🚨🚨🚨 Unable to reach ${this.activeHost.host}, failing over to next best alternative 🚨🚨🚨`);
} else if (this.activeHost.outOfSync) { } else if (this.activeHost.outOfSync) {
@ -121,19 +130,36 @@ class FailoverRouter {
} }
this.electHost(); this.electHost();
} }
await Common.sleep$(50);
}
this.pollTimer = setTimeout(() => { this.pollHosts(); }, this.pollInterval); const rankOrder = this.updateFallback();
logger.debug(`Tomahawk ranking:\n${rankOrder.map((host, index) => this.formatRanking(index, host, this.activeHost, this.maxHeight)).join('\n')}`);
const elapsed = Date.now() - start;
this.pollTimer = setTimeout(() => { this.pollHosts(); }, Math.max(1, this.pollInterval - elapsed));
} }
private formatRanking(index: number, host: FailoverHost, active: FailoverHost, maxHeight: number): string { private formatRanking(index: number, host: FailoverHost, active: FailoverHost, maxHeight: number): string {
const heightStatus = host.outOfSync ? '🚫' : (host.latestHeight && host.latestHeight < maxHeight ? '🟧' : '✅'); const heightStatus = !host.checked ? '⏳' : (host.outOfSync ? '🚫' : (host.latestHeight && host.latestHeight < maxHeight ? '🟧' : '✅'));
return `${host === active ? '⭐️' : ' '} ${host.rtt < Infinity ? Math.round(host.rtt).toString().padStart(5, ' ') + 'ms' : ' - '} ${host.unreachable ? '🔥' : '✅'} | block: ${host.latestHeight || '??????'} ${heightStatus} | ${host.host} ${host === active ? '⭐️' : ' '}`; return `${host === active ? '⭐️' : ' '} ${host.rtt < Infinity ? Math.round(host.rtt).toString().padStart(5, ' ') + 'ms' : ' - '} ${!host.checked ? '⏳' : (host.unreachable ? '🔥' : '✅')} | block: ${host.latestHeight || '??????'} ${heightStatus} | ${host.host} ${host === active ? '⭐️' : ' '}`;
}
private updateFallback(): FailoverHost[] {
const rankOrder = this.sortHosts();
if (rankOrder.length > 1 && rankOrder[0] === this.activeHost) {
this.fallbackHost = rankOrder[1];
} else {
this.fallbackHost = rankOrder[0];
}
return rankOrder;
} }
// sort hosts by connection quality, and update default fallback // sort hosts by connection quality, and update default fallback
private sortHosts(): void { private sortHosts(): FailoverHost[] {
// sort by connection quality // sort by connection quality
this.hosts.sort((a, b) => { return this.hosts.slice().sort((a, b) => {
if ((a.unreachable || a.outOfSync) === (b.unreachable || b.outOfSync)) { if ((a.unreachable || a.outOfSync) === (b.unreachable || b.outOfSync)) {
if (a.preferred === b.preferred) { if (a.preferred === b.preferred) {
// lower rtt is best // lower rtt is best
@ -145,19 +171,14 @@ class FailoverRouter {
return (a.unreachable || a.outOfSync) ? 1 : -1; return (a.unreachable || a.outOfSync) ? 1 : -1;
} }
}); });
if (this.hosts.length > 1 && this.hosts[0] === this.activeHost) {
this.fallbackHost = this.hosts[1];
} else {
this.fallbackHost = this.hosts[0];
}
} }
// depose the active host and choose the next best replacement // depose the active host and choose the next best replacement
private electHost(): void { private electHost(): void {
this.activeHost.outOfSync = true; this.activeHost.outOfSync = true;
this.activeHost.failures = 0; this.activeHost.failures = 0;
this.sortHosts(); const rankOrder = this.sortHosts();
this.activeHost = this.hosts[0]; this.activeHost = rankOrder[0];
logger.warn(`Switching esplora host to ${this.activeHost.host}`); logger.warn(`Switching esplora host to ${this.activeHost.host}`);
} }

View file

@ -2,7 +2,7 @@ import config from '../config';
import bitcoinApi, { bitcoinCoreApi } from './bitcoin/bitcoin-api-factory'; import bitcoinApi, { bitcoinCoreApi } from './bitcoin/bitcoin-api-factory';
import logger from '../logger'; import logger from '../logger';
import memPool from './mempool'; import memPool from './mempool';
import { BlockExtended, BlockExtension, BlockSummary, PoolTag, TransactionExtended, TransactionMinerInfo, CpfpSummary, MempoolTransactionExtended, TransactionClassified } from '../mempool.interfaces'; import { BlockExtended, BlockExtension, BlockSummary, PoolTag, TransactionExtended, TransactionMinerInfo, CpfpSummary, MempoolTransactionExtended, TransactionClassified, BlockAudit } from '../mempool.interfaces';
import { Common } from './common'; import { Common } from './common';
import diskCache from './disk-cache'; import diskCache from './disk-cache';
import transactionUtils from './transaction-utils'; import transactionUtils from './transaction-utils';
@ -37,8 +37,10 @@ class Blocks {
private currentBits = 0; private currentBits = 0;
private lastDifficultyAdjustmentTime = 0; private lastDifficultyAdjustmentTime = 0;
private previousDifficultyRetarget = 0; private previousDifficultyRetarget = 0;
private quarterEpochBlockTime: number | null = null;
private newBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void)[] = []; private newBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void)[] = [];
private newAsyncBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: MempoolTransactionExtended[]) => Promise<void>)[] = []; private newAsyncBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: MempoolTransactionExtended[]) => Promise<void>)[] = [];
private classifyingBlocks: boolean = false;
private mainLoopTimeout: number = 120000; private mainLoopTimeout: number = 120000;
@ -451,7 +453,9 @@ class Blocks {
if (config.MEMPOOL.BACKEND === 'esplora') { if (config.MEMPOOL.BACKEND === 'esplora') {
const txs = (await bitcoinApi.$getTxsForBlock(block.hash)).map(tx => transactionUtils.extendTransaction(tx)); const txs = (await bitcoinApi.$getTxsForBlock(block.hash)).map(tx => transactionUtils.extendTransaction(tx));
const cpfpSummary = await this.$indexCPFP(block.hash, block.height, txs); const cpfpSummary = await this.$indexCPFP(block.hash, block.height, txs);
if (cpfpSummary) {
await this.$getStrippedBlockTransactions(block.hash, true, true, cpfpSummary, block.height); // This will index the block summary await this.$getStrippedBlockTransactions(block.hash, true, true, cpfpSummary, block.height); // This will index the block summary
}
} else { } else {
await this.$getStrippedBlockTransactions(block.hash, true, true); // This will index the block summary await this.$getStrippedBlockTransactions(block.hash, true, true); // This will index the block summary
} }
@ -565,6 +569,11 @@ class Blocks {
* [INDEXING] Index transaction classification flags for Goggles * [INDEXING] Index transaction classification flags for Goggles
*/ */
public async $classifyBlocks(): Promise<void> { public async $classifyBlocks(): Promise<void> {
if (this.classifyingBlocks) {
return;
}
this.classifyingBlocks = true;
// classification requires an esplora backend // classification requires an esplora backend
if (!Common.gogglesIndexingEnabled() || config.MEMPOOL.BACKEND !== 'esplora') { if (!Common.gogglesIndexingEnabled() || config.MEMPOOL.BACKEND !== 'esplora') {
return; return;
@ -676,6 +685,8 @@ class Blocks {
indexedThisRun = 0; indexedThisRun = 0;
} }
} }
this.classifyingBlocks = false;
} }
/** /**
@ -773,6 +784,16 @@ class Blocks {
} else { } else {
this.currentBlockHeight = this.blocks[this.blocks.length - 1].height; this.currentBlockHeight = this.blocks[this.blocks.length - 1].height;
} }
if (this.currentBlockHeight >= 503) {
try {
const quarterEpochBlockHash = await bitcoinApi.$getBlockHash(this.currentBlockHeight - 503);
const quarterEpochBlock = await bitcoinApi.$getBlock(quarterEpochBlockHash);
this.quarterEpochBlockTime = quarterEpochBlock?.timestamp;
} catch (e) {
this.quarterEpochBlockTime = null;
logger.warn('failed to update last epoch block time: ' + (e instanceof Error ? e.message : e));
}
}
if (blockHeightTip - this.currentBlockHeight > config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 2) { if (blockHeightTip - this.currentBlockHeight > config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 2) {
logger.info(`${blockHeightTip - this.currentBlockHeight} blocks since tip. Fast forwarding to the ${config.MEMPOOL.INITIAL_BLOCKS_AMOUNT} recent blocks`); logger.info(`${blockHeightTip - this.currentBlockHeight} blocks since tip. Fast forwarding to the ${config.MEMPOOL.INITIAL_BLOCKS_AMOUNT} recent blocks`);
@ -995,11 +1016,11 @@ class Blocks {
return state; return state;
} }
private updateTimerProgress(state, msg) { private updateTimerProgress(state, msg): void {
state.progress = msg; state.progress = msg;
} }
private clearTimer(state) { private clearTimer(state): void {
if (state.timer) { if (state.timer) {
clearTimeout(state.timer); clearTimeout(state.timer);
} }
@ -1088,13 +1109,19 @@ class Blocks {
summary = { summary = {
id: hash, id: hash,
transactions: cpfpSummary.transactions.map(tx => { transactions: cpfpSummary.transactions.map(tx => {
let flags: number = 0;
try {
flags = tx.flags || Common.getTransactionFlags(tx);
} catch (e) {
logger.warn('Failed to classify transaction: ' + (e instanceof Error ? e.message : e));
}
return { return {
txid: tx.txid, txid: tx.txid,
fee: tx.fee || 0, fee: tx.fee || 0,
vsize: tx.vsize, vsize: tx.vsize,
value: Math.round(tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0)), value: Math.round(tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0)),
rate: tx.effectiveFeePerVsize, rate: tx.effectiveFeePerVsize,
flags: tx.flags || Common.getTransactionFlags(tx), flags: flags,
}; };
}), }),
}; };
@ -1284,7 +1311,7 @@ class Blocks {
return blocks; return blocks;
} }
public async $getBlockAuditSummary(hash: string): Promise<any> { public async $getBlockAuditSummary(hash: string): Promise<BlockAudit | null> {
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) { if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) {
return BlocksAuditsRepository.$getBlockAudit(hash); return BlocksAuditsRepository.$getBlockAudit(hash);
} else { } else {
@ -1300,11 +1327,15 @@ class Blocks {
return this.previousDifficultyRetarget; return this.previousDifficultyRetarget;
} }
public getQuarterEpochBlockTime(): number | null {
return this.quarterEpochBlockTime;
}
public getCurrentBlockHeight(): number { public getCurrentBlockHeight(): number {
return this.currentBlockHeight; return this.currentBlockHeight;
} }
public async $indexCPFP(hash: string, height: number, txs?: TransactionExtended[]): Promise<CpfpSummary> { public async $indexCPFP(hash: string, height: number, txs?: TransactionExtended[]): Promise<CpfpSummary | null> {
let transactions = txs; let transactions = txs;
if (!transactions) { if (!transactions) {
if (config.MEMPOOL.BACKEND === 'esplora') { if (config.MEMPOOL.BACKEND === 'esplora') {
@ -1319,6 +1350,7 @@ class Blocks {
} }
} }
if (transactions?.length != null) {
const summary = Common.calculateCpfp(height, transactions as TransactionExtended[]); const summary = Common.calculateCpfp(height, transactions as TransactionExtended[]);
await this.$saveCpfp(hash, height, summary); await this.$saveCpfp(hash, height, summary);
@ -1327,6 +1359,10 @@ class Blocks {
await blocksRepository.$saveEffectiveFeeStats(hash, effectiveFeeStats); await blocksRepository.$saveEffectiveFeeStats(hash, effectiveFeeStats);
return summary; return summary;
} else {
logger.err(`Cannot index CPFP for block ${height} - missing transaction data`);
return null;
}
} }
public async $saveCpfp(hash: string, height: number, cpfpSummary: CpfpSummary): Promise<void> { public async $saveCpfp(hash: string, height: number, cpfpSummary: CpfpSummary): Promise<void> {

View file

@ -6,6 +6,7 @@ import { NodeSocket } from '../repositories/NodesSocketsRepository';
import { isIP } from 'net'; import { isIP } from 'net';
import transactionUtils from './transaction-utils'; import transactionUtils from './transaction-utils';
import { isPoint } from '../utils/secp256k1'; import { isPoint } from '../utils/secp256k1';
import logger from '../logger';
export class Common { export class Common {
static nativeAssetId = config.MEMPOOL.NETWORK === 'liquidtestnet' ? static nativeAssetId = config.MEMPOOL.NETWORK === 'liquidtestnet' ?
'144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49' '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49'
@ -245,7 +246,8 @@ export class Common {
} else if (tx.version === 2) { } else if (tx.version === 2) {
flags |= TransactionFlags.v2; flags |= TransactionFlags.v2;
} }
const reusedAddresses: { [address: string ]: number } = {}; const reusedInputAddresses: { [address: string ]: number } = {};
const reusedOutputAddresses: { [address: string ]: number } = {};
const inValues = {}; const inValues = {};
const outValues = {}; const outValues = {};
let rbf = false; let rbf = false;
@ -261,6 +263,9 @@ export class Common {
case 'v0_p2wpkh': flags |= TransactionFlags.p2wpkh; break; case 'v0_p2wpkh': flags |= TransactionFlags.p2wpkh; break;
case 'v0_p2wsh': flags |= TransactionFlags.p2wsh; break; case 'v0_p2wsh': flags |= TransactionFlags.p2wsh; break;
case 'v1_p2tr': { case 'v1_p2tr': {
if (!vin.witness?.length) {
throw new Error('Taproot input missing witness data');
}
flags |= TransactionFlags.p2tr; flags |= TransactionFlags.p2tr;
// in taproot, if the last witness item begins with 0x50, it's an annex // in taproot, if the last witness item begins with 0x50, it's an annex
const hasAnnex = vin.witness?.[vin.witness.length - 1].startsWith('50'); const hasAnnex = vin.witness?.[vin.witness.length - 1].startsWith('50');
@ -286,7 +291,7 @@ export class Common {
} }
if (vin.prevout?.scriptpubkey_address) { if (vin.prevout?.scriptpubkey_address) {
reusedAddresses[vin.prevout?.scriptpubkey_address] = (reusedAddresses[vin.prevout?.scriptpubkey_address] || 0) + 1; reusedInputAddresses[vin.prevout?.scriptpubkey_address] = (reusedInputAddresses[vin.prevout?.scriptpubkey_address] || 0) + 1;
} }
inValues[vin.prevout?.value || Math.random()] = (inValues[vin.prevout?.value || Math.random()] || 0) + 1; inValues[vin.prevout?.value || Math.random()] = (inValues[vin.prevout?.value || Math.random()] || 0) + 1;
} }
@ -301,7 +306,7 @@ export class Common {
case 'p2pk': { case 'p2pk': {
flags |= TransactionFlags.p2pk; flags |= TransactionFlags.p2pk;
// detect fake pubkey (i.e. not a valid DER point on the secp256k1 curve) // detect fake pubkey (i.e. not a valid DER point on the secp256k1 curve)
hasFakePubkey = hasFakePubkey || !isPoint(vout.scriptpubkey.slice(2, -2)); hasFakePubkey = hasFakePubkey || !isPoint(vout.scriptpubkey?.slice(2, -2));
} break; } break;
case 'multisig': { case 'multisig': {
flags |= TransactionFlags.p2ms; flags |= TransactionFlags.p2ms;
@ -321,7 +326,7 @@ export class Common {
case 'op_return': flags |= TransactionFlags.op_return; break; case 'op_return': flags |= TransactionFlags.op_return; break;
} }
if (vout.scriptpubkey_address) { if (vout.scriptpubkey_address) {
reusedAddresses[vout.scriptpubkey_address] = (reusedAddresses[vout.scriptpubkey_address] || 0) + 1; reusedOutputAddresses[vout.scriptpubkey_address] = (reusedOutputAddresses[vout.scriptpubkey_address] || 0) + 1;
} }
outValues[vout.value || Math.random()] = (outValues[vout.value || Math.random()] || 0) + 1; outValues[vout.value || Math.random()] = (outValues[vout.value || Math.random()] || 0) + 1;
} }
@ -331,7 +336,7 @@ export class Common {
// fast but bad heuristic to detect possible coinjoins // fast but bad heuristic to detect possible coinjoins
// (at least 5 inputs and 5 outputs, less than half of which are unique amounts, with no address reuse) // (at least 5 inputs and 5 outputs, less than half of which are unique amounts, with no address reuse)
const addressReuse = Object.values(reusedAddresses).reduce((acc, count) => Math.max(acc, count), 0) > 1; const addressReuse = Object.keys(reusedOutputAddresses).reduce((acc, key) => Math.max(acc, (reusedInputAddresses[key] || 0) + (reusedOutputAddresses[key] || 0)), 0) > 1;
if (!addressReuse && tx.vin.length >= 5 && tx.vout.length >= 5 && (Object.keys(inValues).length + Object.keys(outValues).length) <= (tx.vin.length + tx.vout.length) / 2 ) { if (!addressReuse && tx.vin.length >= 5 && tx.vout.length >= 5 && (Object.keys(inValues).length + Object.keys(outValues).length) <= (tx.vin.length + tx.vout.length) / 2 ) {
flags |= TransactionFlags.coinjoin; flags |= TransactionFlags.coinjoin;
} }
@ -348,7 +353,12 @@ export class Common {
} }
static classifyTransaction(tx: TransactionExtended): TransactionClassified { static classifyTransaction(tx: TransactionExtended): TransactionClassified {
const flags = Common.getTransactionFlags(tx); let flags = 0;
try {
flags = Common.getTransactionFlags(tx);
} catch (e) {
logger.warn('Failed to add classification flags to transaction: ' + (e instanceof Error ? e.message : e));
}
tx.flags = flags; tx.flags = flags;
return { return {
...Common.stripTransaction(tx), ...Common.stripTransaction(tx),

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 = 67; private static currentVersion = 68;
private queryTimeout = 3600_000; private queryTimeout = 3600_000;
private statisticsAddedIndexed = false; private statisticsAddedIndexed = false;
private uniqueLogs: string[] = []; private uniqueLogs: string[] = [];
@ -566,6 +566,20 @@ class DatabaseMigration {
await this.$executeQuery('ALTER TABLE `blocks_templates` ADD INDEX `version` (`version`)'); await this.$executeQuery('ALTER TABLE `blocks_templates` ADD INDEX `version` (`version`)');
await this.updateToSchemaVersion(67); await this.updateToSchemaVersion(67);
} }
if (databaseSchemaVersion < 68 && config.MEMPOOL.NETWORK === "liquid") {
await this.$executeQuery('TRUNCATE TABLE elements_pegs');
await this.$executeQuery('ALTER TABLE elements_pegs ADD PRIMARY KEY (txid, txindex);');
await this.$executeQuery(`UPDATE state SET number = 0 WHERE name = 'last_elements_block';`);
// Create the federation_addresses table and add the two Liquid Federation change addresses in
await this.$executeQuery(this.getCreateFederationAddressesTableQuery(), await this.$checkIfTableExists('federation_addresses'));
await this.$executeQuery(`INSERT INTO federation_addresses (bitcoinaddress) VALUES ('bc1qxvay4an52gcghxq5lavact7r6qe9l4laedsazz8fj2ee2cy47tlqff4aj4')`); // Federation change address
await this.$executeQuery(`INSERT INTO federation_addresses (bitcoinaddress) VALUES ('3EiAcrzq1cELXScc98KeCswGWZaPGceT1d')`); // Federation change address
// Create the federation_txos table that uses the federation_addresses table as a foreign key
await this.$executeQuery(this.getCreateFederationTxosTableQuery(), await this.$checkIfTableExists('federation_txos'));
await this.$executeQuery(`INSERT INTO state VALUES('last_bitcoin_block_audit', 0, NULL);`);
await this.updateToSchemaVersion(68);
}
} }
/** /**
@ -813,6 +827,32 @@ class DatabaseMigration {
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`; ) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
} }
private getCreateFederationAddressesTableQuery(): string {
return `CREATE TABLE IF NOT EXISTS federation_addresses (
bitcoinaddress varchar(100) NOT NULL,
PRIMARY KEY (bitcoinaddress)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
}
private getCreateFederationTxosTableQuery(): string {
return `CREATE TABLE IF NOT EXISTS federation_txos (
txid varchar(65) NOT NULL,
txindex int(11) NOT NULL,
bitcoinaddress varchar(100) NOT NULL,
amount bigint(20) unsigned NOT NULL,
blocknumber int(11) unsigned NOT NULL,
blocktime int(11) unsigned NOT NULL,
unspent tinyint(1) NOT NULL,
lastblockupdate int(11) unsigned NOT NULL,
lasttimeupdate int(11) unsigned NOT NULL,
pegtxid varchar(65) NOT NULL,
pegindex int(11) NOT NULL,
pegblocktime int(11) unsigned NOT NULL,
PRIMARY KEY (txid, txindex),
FOREIGN KEY (bitcoinaddress) REFERENCES federation_addresses (bitcoinaddress)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
}
private getCreatePoolsTableQuery(): string { private getCreatePoolsTableQuery(): string {
return `CREATE TABLE IF NOT EXISTS pools ( return `CREATE TABLE IF NOT EXISTS pools (
id int(11) NOT NULL AUTO_INCREMENT, id int(11) NOT NULL AUTO_INCREMENT,

View file

@ -12,6 +12,7 @@ export interface DifficultyAdjustment {
previousTime: number; // Unix time in ms previousTime: number; // Unix time in ms
nextRetargetHeight: number; // Block Height nextRetargetHeight: number; // Block Height
timeAvg: number; // Duration of time in ms timeAvg: number; // Duration of time in ms
adjustedTimeAvg; // Expected block interval with hashrate implied over last 504 blocks
timeOffset: number; // (Testnet) Time since last block (cap @ 20min) in ms timeOffset: number; // (Testnet) Time since last block (cap @ 20min) in ms
expectedBlocks: number; // Block count expectedBlocks: number; // Block count
} }
@ -80,6 +81,7 @@ export function calcBitsDifference(oldBits: number, newBits: number): number {
export function calcDifficultyAdjustment( export function calcDifficultyAdjustment(
DATime: number, DATime: number,
quarterEpochTime: number | null,
nowSeconds: number, nowSeconds: number,
blockHeight: number, blockHeight: number,
previousRetarget: number, previousRetarget: number,
@ -100,8 +102,20 @@ export function calcDifficultyAdjustment(
let difficultyChange = 0; let difficultyChange = 0;
let timeAvgSecs = blocksInEpoch ? diffSeconds / blocksInEpoch : BLOCK_SECONDS_TARGET; let timeAvgSecs = blocksInEpoch ? diffSeconds / blocksInEpoch : BLOCK_SECONDS_TARGET;
let adjustedTimeAvgSecs = timeAvgSecs;
// for the first 504 blocks of the epoch, calculate the expected avg block interval
// from a sliding window over the last 504 blocks
if (quarterEpochTime && blocksInEpoch < 503) {
const timeLastEpoch = DATime - quarterEpochTime;
const adjustedTimeLastEpoch = timeLastEpoch * (1 + (previousRetarget / 100));
const adjustedTimeSpan = diffSeconds + adjustedTimeLastEpoch;
adjustedTimeAvgSecs = adjustedTimeSpan / 503;
difficultyChange = (BLOCK_SECONDS_TARGET / (adjustedTimeSpan / 504) - 1) * 100;
} else {
difficultyChange = (BLOCK_SECONDS_TARGET / (actualTimespan / (blocksInEpoch + 1)) - 1) * 100; difficultyChange = (BLOCK_SECONDS_TARGET / (actualTimespan / (blocksInEpoch + 1)) - 1) * 100;
}
// Max increase is x4 (+300%) // Max increase is x4 (+300%)
if (difficultyChange > 300) { if (difficultyChange > 300) {
difficultyChange = 300; difficultyChange = 300;
@ -126,7 +140,8 @@ export function calcDifficultyAdjustment(
} }
const timeAvg = Math.floor(timeAvgSecs * 1000); const timeAvg = Math.floor(timeAvgSecs * 1000);
const remainingTime = remainingBlocks * timeAvg; const adjustedTimeAvg = Math.floor(adjustedTimeAvgSecs * 1000);
const remainingTime = remainingBlocks * adjustedTimeAvg;
const estimatedRetargetDate = remainingTime + nowSeconds * 1000; const estimatedRetargetDate = remainingTime + nowSeconds * 1000;
return { return {
@ -139,6 +154,7 @@ export function calcDifficultyAdjustment(
previousTime: DATime, previousTime: DATime,
nextRetargetHeight, nextRetargetHeight,
timeAvg, timeAvg,
adjustedTimeAvg,
timeOffset, timeOffset,
expectedBlocks, expectedBlocks,
}; };
@ -155,9 +171,10 @@ class DifficultyAdjustmentApi {
return null; return null;
} }
const nowSeconds = Math.floor(new Date().getTime() / 1000); const nowSeconds = Math.floor(new Date().getTime() / 1000);
const quarterEpochBlockTime = blocks.getQuarterEpochBlockTime();
return calcDifficultyAdjustment( return calcDifficultyAdjustment(
DATime, nowSeconds, blockHeight, previousRetarget, DATime, quarterEpochBlockTime, nowSeconds, blockHeight, previousRetarget,
config.MEMPOOL.NETWORK, latestBlock.timestamp config.MEMPOOL.NETWORK, latestBlock.timestamp
); );
} }

View file

@ -5,8 +5,12 @@ import { Common } from '../common';
import DB from '../../database'; import DB from '../../database';
import logger from '../../logger'; import logger from '../../logger';
const federationChangeAddresses = ['bc1qxvay4an52gcghxq5lavact7r6qe9l4laedsazz8fj2ee2cy47tlqff4aj4', '3EiAcrzq1cELXScc98KeCswGWZaPGceT1d'];
const auditBlockOffsetWithTip = 1; // Wait for 1 block confirmation before processing the block in the audit process to reduce the risk of reorgs
class ElementsParser { class ElementsParser {
private isRunning = false; private isRunning = false;
private isUtxosUpdatingRunning = false;
constructor() { } constructor() { }
@ -32,12 +36,6 @@ class ElementsParser {
} }
} }
public async $getPegDataByMonth(): Promise<any> {
const query = `SELECT SUM(amount) AS amount, DATE_FORMAT(FROM_UNIXTIME(datetime), '%Y-%m-01') AS date FROM elements_pegs GROUP BY DATE_FORMAT(FROM_UNIXTIME(datetime), '%Y%m')`;
const [rows] = await DB.query(query);
return rows;
}
protected async $parseBlock(block: IBitcoinApi.Block) { protected async $parseBlock(block: IBitcoinApi.Block) {
for (const tx of block.tx) { for (const tx of block.tx) {
await this.$parseInputs(tx, block); await this.$parseInputs(tx, block);
@ -55,29 +53,30 @@ class ElementsParser {
protected async $parsePegIn(input: IBitcoinApi.Vin, vindex: number, txid: string, block: IBitcoinApi.Block) { protected async $parsePegIn(input: IBitcoinApi.Vin, vindex: number, txid: string, block: IBitcoinApi.Block) {
const bitcoinTx: IBitcoinApi.Transaction = await bitcoinSecondClient.getRawTransaction(input.txid, true); const bitcoinTx: IBitcoinApi.Transaction = await bitcoinSecondClient.getRawTransaction(input.txid, true);
const bitcoinBlock: IBitcoinApi.Block = await bitcoinSecondClient.getBlock(bitcoinTx.blockhash);
const prevout = bitcoinTx.vout[input.vout || 0]; const prevout = bitcoinTx.vout[input.vout || 0];
const outputAddress = prevout.scriptPubKey.address || (prevout.scriptPubKey.addresses && prevout.scriptPubKey.addresses[0]) || ''; const outputAddress = prevout.scriptPubKey.address || (prevout.scriptPubKey.addresses && prevout.scriptPubKey.addresses[0]) || '';
await this.$savePegToDatabase(block.height, block.time, prevout.value * 100000000, txid, vindex, await this.$savePegToDatabase(block.height, block.time, prevout.value * 100000000, txid, vindex,
outputAddress, bitcoinTx.txid, prevout.n, 1); outputAddress, bitcoinTx.txid, prevout.n, bitcoinBlock.height, bitcoinBlock.time, 1);
} }
protected async $parseOutputs(tx: IBitcoinApi.Transaction, block: IBitcoinApi.Block) { protected async $parseOutputs(tx: IBitcoinApi.Transaction, block: IBitcoinApi.Block) {
for (const output of tx.vout) { for (const output of tx.vout) {
if (output.scriptPubKey.pegout_chain) { if (output.scriptPubKey.pegout_chain) {
await this.$savePegToDatabase(block.height, block.time, 0 - output.value * 100000000, tx.txid, output.n, await this.$savePegToDatabase(block.height, block.time, 0 - output.value * 100000000, tx.txid, output.n,
(output.scriptPubKey.pegout_addresses && output.scriptPubKey.pegout_addresses[0] || ''), '', 0, 0); (output.scriptPubKey.pegout_address || ''), '', 0, 0, 0, 0);
} }
if (!output.scriptPubKey.pegout_chain && output.scriptPubKey.type === 'nulldata' if (!output.scriptPubKey.pegout_chain && output.scriptPubKey.type === 'nulldata'
&& output.value && output.value > 0 && output.asset && output.asset === Common.nativeAssetId) { && output.value && output.value > 0 && output.asset && output.asset === Common.nativeAssetId) {
await this.$savePegToDatabase(block.height, block.time, 0 - output.value * 100000000, tx.txid, output.n, await this.$savePegToDatabase(block.height, block.time, 0 - output.value * 100000000, tx.txid, output.n,
(output.scriptPubKey.pegout_addresses && output.scriptPubKey.pegout_addresses[0] || ''), '', 0, 1); (output.scriptPubKey.pegout_address || ''), '', 0, 0, 0, 1);
} }
} }
} }
protected async $savePegToDatabase(height: number, blockTime: number, amount: number, txid: string, protected async $savePegToDatabase(height: number, blockTime: number, amount: number, txid: string,
txindex: number, bitcoinaddress: string, bitcointxid: string, bitcoinindex: number, final_tx: number): Promise<void> { txindex: number, bitcoinaddress: string, bitcointxid: string, bitcoinindex: number, bitcoinblock: number, bitcoinBlockTime: number, final_tx: number): Promise<void> {
const query = `INSERT INTO elements_pegs( const query = `INSERT IGNORE INTO elements_pegs(
block, datetime, amount, txid, txindex, bitcoinaddress, bitcointxid, bitcoinindex, final_tx block, datetime, amount, txid, txindex, bitcoinaddress, bitcointxid, bitcoinindex, final_tx
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`; ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`;
@ -85,7 +84,22 @@ class ElementsParser {
height, blockTime, amount, txid, txindex, bitcoinaddress, bitcointxid, bitcoinindex, final_tx height, blockTime, amount, txid, txindex, bitcoinaddress, bitcointxid, bitcoinindex, final_tx
]; ];
await DB.query(query, params); await DB.query(query, params);
logger.debug(`Saved L-BTC peg from block height #${height} with TXID ${txid}.`); logger.debug(`Saved L-BTC peg from Liquid block height #${height} with TXID ${txid}.`);
if (amount > 0) { // Peg-in
// Add the address to the federation addresses table
await DB.query(`INSERT IGNORE INTO federation_addresses (bitcoinaddress) VALUES (?)`, [bitcoinaddress]);
// Add the UTXO to the federation txos table
const query_utxos = `INSERT IGNORE INTO federation_txos (txid, txindex, bitcoinaddress, amount, blocknumber, blocktime, unspent, lastblockupdate, lasttimeupdate, pegtxid, pegindex, pegblocktime) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
const params_utxos: (string | number)[] = [bitcointxid, bitcoinindex, bitcoinaddress, amount, bitcoinblock, bitcoinBlockTime, 1, bitcoinblock - 1, 0, txid, txindex, blockTime];
await DB.query(query_utxos, params_utxos);
const [minBlockUpdate] = await DB.query(`SELECT MIN(lastblockupdate) AS lastblockupdate FROM federation_txos WHERE unspent = 1`)
await this.$saveLastBlockAuditToDatabase(minBlockUpdate[0]['lastblockupdate']);
logger.debug(`Saved new Federation UTXO ${bitcointxid}:${bitcoinindex} belonging to ${bitcoinaddress} to federation txos`);
}
} }
protected async $getLatestBlockHeightFromDatabase(): Promise<number> { protected async $getLatestBlockHeightFromDatabase(): Promise<number> {
@ -98,6 +112,328 @@ class ElementsParser {
const query = `UPDATE state SET number = ? WHERE name = 'last_elements_block'`; const query = `UPDATE state SET number = ? WHERE name = 'last_elements_block'`;
await DB.query(query, [blockHeight]); await DB.query(query, [blockHeight]);
} }
///////////// FEDERATION AUDIT //////////////
public async $updateFederationUtxos() {
if (this.isUtxosUpdatingRunning) {
return;
}
this.isUtxosUpdatingRunning = true;
try {
let auditProgress = await this.$getAuditProgress();
// If no peg in transaction was found in the database, return
if (!auditProgress.lastBlockAudit) {
logger.debug(`No Federation UTXOs found in the database. Waiting for some to be confirmed before starting the Federation UTXOs audit`);
this.isUtxosUpdatingRunning = false;
return;
}
const bitcoinBlocksToSync = await this.$getBitcoinBlockchainState();
// If the bitcoin blockchain is not synced yet, return
if (bitcoinBlocksToSync.bitcoinHeaders > bitcoinBlocksToSync.bitcoinBlocks + 1) {
logger.debug(`Bitcoin client is not synced yet. ${bitcoinBlocksToSync.bitcoinHeaders - bitcoinBlocksToSync.bitcoinBlocks} blocks remaining to sync before the Federation audit process can start`);
this.isUtxosUpdatingRunning = false;
return;
}
auditProgress.lastBlockAudit++;
// Logging
let indexedThisRun = 0;
let timer = Date.now() / 1000;
const startedAt = Date.now() / 1000;
const indexingSpeeds: number[] = [];
while (auditProgress.lastBlockAudit <= auditProgress.confirmedTip) {
// First, get the current UTXOs that need to be scanned in the block
const utxos = await this.$getFederationUtxosToScan(auditProgress.lastBlockAudit);
// Get the peg-out addresses that need to be scanned
const redeemAddresses = await this.$getRedeemAddressesToScan();
// The fast way: check if these UTXOs are still unspent as of the current block with gettxout
let spentAsTip: any[];
let unspentAsTip: any[];
if (auditProgress.confirmedTip - auditProgress.lastBlockAudit <= 150) { // If the audit status is not too far in the past, we can use gettxout (fast way)
const utxosToParse = await this.$getFederationUtxosToParse(utxos);
spentAsTip = utxosToParse.spentAsTip;
unspentAsTip = utxosToParse.unspentAsTip;
logger.debug(`Found ${utxos.length} Federation UTXOs and ${redeemAddresses.length} Peg-Out Addresses to scan in Bitcoin block height #${auditProgress.lastBlockAudit} / #${auditProgress.confirmedTip}`);
logger.debug(`${unspentAsTip.length} / ${utxos.length} Federation UTXOs are unspent as of tip`);
} else { // If the audit status is too far in the past, it is useless and wasteful to look for still unspent txos since they will all be spent as of the tip
spentAsTip = utxos;
unspentAsTip = [];
// Logging
const elapsedSeconds = (Date.now() / 1000) - timer;
if (elapsedSeconds > 5) {
const runningFor = (Date.now() / 1000) - startedAt;
const blockPerSeconds = indexedThisRun / elapsedSeconds;
indexingSpeeds.push(blockPerSeconds);
if (indexingSpeeds.length > 100) indexingSpeeds.shift(); // Keep the length of the up to 100 last indexing speeds
const meanIndexingSpeed = indexingSpeeds.reduce((a, b) => a + b, 0) / indexingSpeeds.length;
const eta = (auditProgress.confirmedTip - auditProgress.lastBlockAudit) / meanIndexingSpeed;
logger.debug(`Scanning ${utxos.length} Federation UTXOs and ${redeemAddresses.length} Peg-Out Addresses at Bitcoin block height #${auditProgress.lastBlockAudit} / #${auditProgress.confirmedTip} | ~${meanIndexingSpeed.toFixed(2)} blocks/sec | elapsed: ${(runningFor / 60).toFixed(0)} minutes | ETA: ${(eta / 60).toFixed(0)} minutes`);
timer = Date.now() / 1000;
indexedThisRun = 0;
}
}
// The slow way: parse the block to look for the spending tx
const blockHash: IBitcoinApi.ChainTips = await bitcoinSecondClient.getBlockHash(auditProgress.lastBlockAudit);
const block: IBitcoinApi.Block = await bitcoinSecondClient.getBlock(blockHash, 2);
await this.$parseBitcoinBlock(block, spentAsTip, unspentAsTip, auditProgress.confirmedTip, redeemAddresses);
// Finally, update the lastblockupdate of the remaining UTXOs and save to the database
const [minBlockUpdate] = await DB.query(`SELECT MIN(lastblockupdate) AS lastblockupdate FROM federation_txos WHERE unspent = 1`)
await this.$saveLastBlockAuditToDatabase(minBlockUpdate[0]['lastblockupdate']);
auditProgress = await this.$getAuditProgress();
auditProgress.lastBlockAudit++;
indexedThisRun++;
}
this.isUtxosUpdatingRunning = false;
} catch (e) {
this.isUtxosUpdatingRunning = false;
throw new Error(e instanceof Error ? e.message : 'Error');
}
}
// Get the UTXOs that need to be scanned in block height (UTXOs that were last updated in the block height - 1)
protected async $getFederationUtxosToScan(height: number) {
const query = `SELECT txid, txindex, bitcoinaddress, amount FROM federation_txos WHERE lastblockupdate = ? AND unspent = 1`;
const [rows] = await DB.query(query, [height - 1]);
return rows as any[];
}
// Returns the UTXOs that are spent as of tip and need to be scanned
protected async $getFederationUtxosToParse(utxos: any[]): Promise<any> {
const spentAsTip: any[] = [];
const unspentAsTip: any[] = [];
for (const utxo of utxos) {
const result = await bitcoinSecondClient.getTxOut(utxo.txid, utxo.txindex, false);
result ? unspentAsTip.push(utxo) : spentAsTip.push(utxo);
}
return {spentAsTip, unspentAsTip};
}
protected async $parseBitcoinBlock(block: IBitcoinApi.Block, spentAsTip: any[], unspentAsTip: any[], confirmedTip: number, redeemAddressesData: any[] = []) {
const redeemAddresses: string[] = redeemAddressesData.map(redeemAddress => redeemAddress.bitcoinaddress);
for (const tx of block.tx) {
let mightRedeemInThisTx = false; // If a Federation UTXO is spent in this block, we might find a peg-out address in the outputs...
// Check if the Federation UTXOs that was spent as of tip are spent in this block
for (const input of tx.vin) {
const txo = spentAsTip.find(txo => txo.txid === input.txid && txo.txindex === input.vout);
if (txo) {
mightRedeemInThisTx = true;
await DB.query(`UPDATE federation_txos SET unspent = 0, lastblockupdate = ?, lasttimeupdate = ? WHERE txid = ? AND txindex = ?`, [block.height, block.time, txo.txid, txo.txindex]);
// Remove the TXO from the utxo array
spentAsTip.splice(spentAsTip.indexOf(txo), 1);
logger.debug(`Federation UTXO ${txo.txid}:${txo.txindex} (${txo.amount} sats) was spent in block ${block.height}`);
}
}
// Check if an output is sent to a change address of the federation
for (const output of tx.vout) {
if (output.scriptPubKey.address && federationChangeAddresses.includes(output.scriptPubKey.address)) {
// Check that the UTXO was not already added in the DB by previous scans
const [rows_check] = await DB.query(`SELECT txid FROM federation_txos WHERE txid = ? AND txindex = ?`, [tx.txid, output.n]) as any[];
if (rows_check.length === 0) {
const query_utxos = `INSERT INTO federation_txos (txid, txindex, bitcoinaddress, amount, blocknumber, blocktime, unspent, lastblockupdate, lasttimeupdate, pegtxid, pegindex, pegblocktime) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
const params_utxos: (string | number)[] = [tx.txid, output.n, output.scriptPubKey.address, output.value * 100000000, block.height, block.time, 1, block.height, 0, '', 0, 0];
await DB.query(query_utxos, params_utxos);
// Add the UTXO to the utxo array
spentAsTip.push({
txid: tx.txid,
txindex: output.n,
bitcoinaddress: output.scriptPubKey.address,
amount: output.value * 100000000
});
logger.debug(`Added new Federation UTXO ${tx.txid}:${output.n} (${output.value * 100000000} sats), change address: ${output.scriptPubKey.address}`);
}
}
if (mightRedeemInThisTx && output.scriptPubKey.address && redeemAddresses.includes(output.scriptPubKey.address)) {
// Find the number of times output.scriptPubKey.address appears in redeemAddresses. There can be address reuse for peg-outs...
const matchingAddress: any[] = redeemAddressesData.filter(redeemAddress => redeemAddress.bitcoinaddress === output.scriptPubKey.address && -redeemAddress.amount === Math.round(output.value * 100000000));
if (matchingAddress.length > 0) {
if (matchingAddress.length > 1) {
// If there are more than one peg out address with the same amount, we can't know which one redeemed the UTXO: we take the oldest one
matchingAddress.sort((a, b) => a.datetime - b.datetime);
logger.debug(`Found redeem txid ${tx.txid}:${output.n} to peg-out address ${matchingAddress[0].bitcoinaddress}, amount ${matchingAddress[0].amount}, datetime ${matchingAddress[0].datetime}`);
} else {
logger.debug(`Found redeem txid ${tx.txid}:${output.n} to peg-out address ${matchingAddress[0].bitcoinaddress}, amount ${matchingAddress[0].amount}`);
}
const query_add_redeem = `UPDATE elements_pegs SET bitcointxid = ?, bitcoinindex = ? WHERE bitcoinaddress = ? AND amount = ? AND datetime = ?`;
const params_add_redeem: (string | number)[] = [tx.txid, output.n, matchingAddress[0].bitcoinaddress, matchingAddress[0].amount, matchingAddress[0].datetime];
await DB.query(query_add_redeem, params_add_redeem);
const index = redeemAddressesData.indexOf(matchingAddress[0]);
redeemAddressesData.splice(index, 1);
redeemAddresses.splice(index, 1);
} else { // The output amount does not match the peg-out amount... log it
logger.debug(`Found redeem txid ${tx.txid}:${output.n} to peg-out address ${output.scriptPubKey.address} but output amount ${Math.round(output.value * 100000000)} does not match the peg-out amount!`);
}
}
}
}
for (const utxo of spentAsTip) {
await DB.query(`UPDATE federation_txos SET lastblockupdate = ? WHERE txid = ? AND txindex = ?`, [block.height, utxo.txid, utxo.txindex]);
}
for (const utxo of unspentAsTip) {
await DB.query(`UPDATE federation_txos SET lastblockupdate = ? WHERE txid = ? AND txindex = ?`, [confirmedTip, utxo.txid, utxo.txindex]);
}
}
protected async $saveLastBlockAuditToDatabase(blockHeight: number) {
const query = `UPDATE state SET number = ? WHERE name = 'last_bitcoin_block_audit'`;
await DB.query(query, [blockHeight]);
}
// Get the bitcoin block where the audit process was last updated
protected async $getAuditProgress(): Promise<any> {
const lastblockaudit = await this.$getLastBlockAudit();
const bitcoinBlocksToSync = await this.$getBitcoinBlockchainState();
return {
lastBlockAudit: lastblockaudit,
confirmedTip: bitcoinBlocksToSync.bitcoinBlocks - auditBlockOffsetWithTip,
};
}
// Get the bitcoin blocks remaining to be synced
protected async $getBitcoinBlockchainState(): Promise<any> {
const result = await bitcoinSecondClient.getBlockchainInfo();
return {
bitcoinBlocks: result.blocks,
bitcoinHeaders: result.headers,
}
}
protected async $getLastBlockAudit(): Promise<number> {
const query = `SELECT number FROM state WHERE name = 'last_bitcoin_block_audit'`;
const [rows] = await DB.query(query);
return rows[0]['number'];
}
protected async $getRedeemAddressesToScan(): Promise<any[]> {
const query = `SELECT datetime, amount, bitcoinaddress FROM elements_pegs where amount < 0 AND bitcoinaddress != '' AND bitcointxid = '';`;
const [rows]: any[] = await DB.query(query);
return rows;
}
///////////// DATA QUERY //////////////
public async $getAuditStatus(): Promise<any> {
const lastBlockAudit = await this.$getLastBlockAudit();
const bitcoinBlocksToSync = await this.$getBitcoinBlockchainState();
return {
bitcoinBlocks: bitcoinBlocksToSync.bitcoinBlocks,
bitcoinHeaders: bitcoinBlocksToSync.bitcoinHeaders,
lastBlockAudit: lastBlockAudit,
isAuditSynced: bitcoinBlocksToSync.bitcoinHeaders - bitcoinBlocksToSync.bitcoinBlocks <= 2 && bitcoinBlocksToSync.bitcoinBlocks - lastBlockAudit <= 3,
};
}
public async $getPegDataByMonth(): Promise<any> {
const query = `SELECT SUM(amount) AS amount, DATE_FORMAT(FROM_UNIXTIME(datetime), '%Y-%m-01') AS date FROM elements_pegs GROUP BY DATE_FORMAT(FROM_UNIXTIME(datetime), '%Y%m')`;
const [rows] = await DB.query(query);
return rows;
}
public async $getFederationReservesByMonth(): Promise<any> {
const query = `
SELECT SUM(amount) AS amount, DATE_FORMAT(FROM_UNIXTIME(blocktime), '%Y-%m-01') AS date FROM federation_txos
WHERE
(blocktime > UNIX_TIMESTAMP(LAST_DAY(FROM_UNIXTIME(blocktime) - INTERVAL 1 MONTH) + INTERVAL 1 DAY))
AND
((unspent = 1) OR (unspent = 0 AND lasttimeupdate > UNIX_TIMESTAMP(LAST_DAY(FROM_UNIXTIME(blocktime)) + INTERVAL 1 DAY)))
GROUP BY
date;`;
const [rows] = await DB.query(query);
return rows;
}
// Get the current L-BTC pegs and the last Liquid block it was updated
public async $getCurrentLbtcSupply(): Promise<any> {
const [rows] = await DB.query(`SELECT SUM(amount) AS LBTC_supply FROM elements_pegs;`);
const lastblockupdate = await this.$getLatestBlockHeightFromDatabase();
const hash = await bitcoinClient.getBlockHash(lastblockupdate);
return {
amount: rows[0]['LBTC_supply'],
lastBlockUpdate: lastblockupdate,
hash: hash
};
}
// Get the current reserves of the federation and the last Bitcoin block it was updated
public async $getCurrentFederationReserves(): Promise<any> {
const [rows] = await DB.query(`SELECT SUM(amount) AS total_balance FROM federation_txos WHERE unspent = 1;`);
const lastblockaudit = await this.$getLastBlockAudit();
const hash = await bitcoinSecondClient.getBlockHash(lastblockaudit);
return {
amount: rows[0]['total_balance'],
lastBlockUpdate: lastblockaudit,
hash: hash
};
}
// Get all of the federation addresses, most balances first
public async $getFederationAddresses(): Promise<any> {
const query = `SELECT bitcoinaddress, SUM(amount) AS balance FROM federation_txos WHERE unspent = 1 GROUP BY bitcoinaddress ORDER BY balance DESC;`;
const [rows] = await DB.query(query);
return rows;
}
// Get all of the UTXOs held by the federation, most recent first
public async $getFederationUtxos(): Promise<any> {
const query = `SELECT txid, txindex, bitcoinaddress, amount, blocknumber, blocktime, pegtxid, pegindex, pegblocktime FROM federation_txos WHERE unspent = 1 ORDER BY blocktime DESC;`;
const [rows] = await DB.query(query);
return rows;
}
// Get the total number of federation addresses
public async $getFederationAddressesNumber(): Promise<any> {
const query = `SELECT COUNT(DISTINCT bitcoinaddress) AS address_count FROM federation_txos WHERE unspent = 1;`;
const [rows] = await DB.query(query);
return rows[0];
}
// Get the total number of federation utxos
public async $getFederationUtxosNumber(): Promise<any> {
const query = `SELECT COUNT(*) AS utxo_count FROM federation_txos WHERE unspent = 1;`;
const [rows] = await DB.query(query);
return rows[0];
}
// Get recent pegs in / out
public async $getPegsList(count: number = 0): Promise<any> {
const query = `SELECT txid, txindex, amount, bitcoinaddress, bitcointxid, bitcoinindex, datetime AS blocktime FROM elements_pegs ORDER BY block DESC LIMIT 15 OFFSET ?;`;
const [rows] = await DB.query(query, [count]);
return rows;
}
// Get all peg in / out from the last month
public async $getPegsVolumeDaily(): Promise<any> {
const pegInQuery = await DB.query(`SELECT SUM(amount) AS volume, COUNT(*) AS number FROM elements_pegs WHERE amount > 0 and datetime > UNIX_TIMESTAMP(TIMESTAMPADD(DAY, -1, CURRENT_TIMESTAMP()));`);
const pegOutQuery = await DB.query(`SELECT SUM(amount) AS volume, COUNT(*) AS number FROM elements_pegs WHERE amount < 0 and datetime > UNIX_TIMESTAMP(TIMESTAMPADD(DAY, -1, CURRENT_TIMESTAMP()));`);
return [
pegInQuery[0][0],
pegOutQuery[0][0]
];
}
// Get the total pegs number
public async $getPegsCount(): Promise<any> {
const [rows] = await DB.query(`SELECT COUNT(*) AS pegs_count FROM elements_pegs;`);
return rows[0];
}
} }
export default new ElementsParser(); export default new ElementsParser();

View file

@ -15,7 +15,18 @@ class LiquidRoutes {
if (config.DATABASE.ENABLED) { if (config.DATABASE.ENABLED) {
app app
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/pegs', this.$getElementsPegs)
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/pegs/month', this.$getElementsPegsByMonth) .get(config.MEMPOOL.API_URL_PREFIX + 'liquid/pegs/month', this.$getElementsPegsByMonth)
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/pegs/list/:count', this.$getPegsList)
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/pegs/volume', this.$getPegsVolumeDaily)
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/pegs/count', this.$getPegsCount)
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves', this.$getFederationReserves)
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/month', this.$getFederationReservesByMonth)
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/addresses', this.$getFederationAddresses)
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/addresses/total', this.$getFederationAddressesNumber)
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/utxos', this.$getFederationUtxos)
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/utxos/total', this.$getFederationUtxosNumber)
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/status', this.$getFederationAuditStatus)
; ;
} }
} }
@ -63,11 +74,147 @@ class LiquidRoutes {
private async $getElementsPegsByMonth(req: Request, res: Response) { private async $getElementsPegsByMonth(req: Request, res: Response) {
try { try {
const pegs = await elementsParser.$getPegDataByMonth(); const pegs = await elementsParser.$getPegDataByMonth();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 60 * 60).toUTCString());
res.json(pegs); res.json(pegs);
} catch (e) { } catch (e) {
res.status(500).send(e instanceof Error ? e.message : e); res.status(500).send(e instanceof Error ? e.message : e);
} }
} }
private async $getFederationReservesByMonth(req: Request, res: Response) {
try {
const reserves = await elementsParser.$getFederationReservesByMonth();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 60 * 60).toUTCString());
res.json(reserves);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getElementsPegs(req: Request, res: Response) {
try {
const currentSupply = await elementsParser.$getCurrentLbtcSupply();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(currentSupply);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getFederationReserves(req: Request, res: Response) {
try {
const currentReserves = await elementsParser.$getCurrentFederationReserves();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(currentReserves);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getFederationAuditStatus(req: Request, res: Response) {
try {
const auditStatus = await elementsParser.$getAuditStatus();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(auditStatus);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getFederationAddresses(req: Request, res: Response) {
try {
const federationAddresses = await elementsParser.$getFederationAddresses();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(federationAddresses);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getFederationAddressesNumber(req: Request, res: Response) {
try {
const federationAddresses = await elementsParser.$getFederationAddressesNumber();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(federationAddresses);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getFederationUtxos(req: Request, res: Response) {
try {
const federationUtxos = await elementsParser.$getFederationUtxos();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(federationUtxos);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getFederationUtxosNumber(req: Request, res: Response) {
try {
const federationUtxos = await elementsParser.$getFederationUtxosNumber();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(federationUtxos);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getPegsList(req: Request, res: Response) {
try {
const recentPegs = await elementsParser.$getPegsList(parseInt(req.params?.count));
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(recentPegs);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getPegsVolumeDaily(req: Request, res: Response) {
try {
const pegsVolume = await elementsParser.$getPegsVolumeDaily();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(pegsVolume);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getPegsCount(req: Request, res: Response) {
try {
const pegsCount = await elementsParser.$getPegsCount();
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
res.json(pegsCount);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
} }
export default new LiquidRoutes(); export default new LiquidRoutes();

View file

@ -1,6 +1,6 @@
import { GbtGenerator, GbtResult, ThreadTransaction as RustThreadTransaction, ThreadAcceleration as RustThreadAcceleration } from 'rust-gbt'; import { GbtGenerator, GbtResult, ThreadTransaction as RustThreadTransaction, ThreadAcceleration as RustThreadAcceleration } from 'rust-gbt';
import logger from '../logger'; import logger from '../logger';
import { MempoolBlock, MempoolTransactionExtended, MempoolBlockWithTransactions, MempoolBlockDelta, Ancestor, CompactThreadTransaction, EffectiveFeeStats, PoolTag, TransactionClassified } from '../mempool.interfaces'; import { MempoolBlock, MempoolTransactionExtended, MempoolBlockWithTransactions, MempoolBlockDelta, Ancestor, CompactThreadTransaction, EffectiveFeeStats, PoolTag, TransactionClassified, TransactionCompressed, MempoolDeltaChange } from '../mempool.interfaces';
import { Common, OnlineFeeStatsCalculator } from './common'; import { Common, OnlineFeeStatsCalculator } from './common';
import config from '../config'; import config from '../config';
import { Worker } from 'worker_threads'; import { Worker } from 'worker_threads';
@ -171,7 +171,7 @@ class MempoolBlocks {
for (let i = 0; i < Math.max(mempoolBlocks.length, prevBlocks.length); i++) { for (let i = 0; i < Math.max(mempoolBlocks.length, prevBlocks.length); i++) {
let added: TransactionClassified[] = []; let added: TransactionClassified[] = [];
let removed: string[] = []; let removed: string[] = [];
const changed: { txid: string, rate: number | undefined, acc: boolean | undefined }[] = []; const changed: TransactionClassified[] = [];
if (mempoolBlocks[i] && !prevBlocks[i]) { if (mempoolBlocks[i] && !prevBlocks[i]) {
added = mempoolBlocks[i].transactions; added = mempoolBlocks[i].transactions;
} else if (!mempoolBlocks[i] && prevBlocks[i]) { } else if (!mempoolBlocks[i] && prevBlocks[i]) {
@ -194,14 +194,14 @@ class MempoolBlocks {
if (!prevIds[tx.txid]) { if (!prevIds[tx.txid]) {
added.push(tx); added.push(tx);
} else if (tx.rate !== prevIds[tx.txid].rate || tx.acc !== prevIds[tx.txid].acc) { } else if (tx.rate !== prevIds[tx.txid].rate || tx.acc !== prevIds[tx.txid].acc) {
changed.push({ txid: tx.txid, rate: tx.rate, acc: tx.acc }); changed.push(tx);
} }
}); });
} }
mempoolBlockDeltas.push({ mempoolBlockDeltas.push({
added, added: added.map(this.compressTx),
removed, removed,
changed, changed: changed.map(this.compressDeltaChange),
}); });
} }
return mempoolBlockDeltas; return mempoolBlockDeltas;
@ -691,6 +691,38 @@ class MempoolBlocks {
}); });
return { blocks: convertedBlocks, blockWeights, rates: convertedRates, clusters: convertedClusters, overflow: convertedOverflow }; return { blocks: convertedBlocks, blockWeights, rates: convertedRates, clusters: convertedClusters, overflow: convertedOverflow };
} }
public compressTx(tx: TransactionClassified): TransactionCompressed {
if (tx.acc) {
return [
tx.txid,
tx.fee,
tx.vsize,
tx.value,
Math.round((tx.rate || (tx.fee / tx.vsize)) * 100) / 100,
tx.flags,
1
];
} else {
return [
tx.txid,
tx.fee,
tx.vsize,
tx.value,
Math.round((tx.rate || (tx.fee / tx.vsize)) * 100) / 100,
tx.flags,
];
}
}
public compressDeltaChange(tx: TransactionClassified): MempoolDeltaChange {
return [
tx.txid,
Math.round((tx.rate || (tx.fee / tx.vsize)) * 100) / 100,
tx.flags,
tx.acc ? 1 : 0,
];
}
} }
export default new MempoolBlocks(); export default new MempoolBlocks();

View file

@ -142,7 +142,7 @@ class Mining {
public async $getPoolStat(slug: string): Promise<object> { public async $getPoolStat(slug: string): Promise<object> {
const pool = await PoolsRepository.$getPool(slug); const pool = await PoolsRepository.$getPool(slug);
if (!pool) { if (!pool) {
throw new Error('This mining pool does not exist ' + escape(slug)); throw new Error('This mining pool does not exist');
} }
const blockCount: number = await BlocksRepository.$blockCount(pool.id); const blockCount: number = await BlocksRepository.$blockCount(pool.id);

View file

@ -285,7 +285,7 @@ class StatisticsApi {
public async $list2H(): Promise<OptimizedStatistic[]> { public async $list2H(): Promise<OptimizedStatistic[]> {
try { try {
const query = `SELECT *, UNIX_TIMESTAMP(added) as added FROM statistics ORDER BY statistics.added DESC LIMIT 120`; const query = `SELECT *, UNIX_TIMESTAMP(added) as added FROM statistics WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL 2 HOUR) AND NOW() ORDER BY statistics.added DESC`;
const [rows] = await DB.query({ sql: query, timeout: this.queryTimeout }); const [rows] = await DB.query({ sql: query, timeout: this.queryTimeout });
return this.mapStatisticToOptimizedStatistic(rows as Statistic[]); return this.mapStatisticToOptimizedStatistic(rows as Statistic[]);
} catch (e) { } catch (e) {
@ -296,7 +296,7 @@ class StatisticsApi {
public async $list24H(): Promise<OptimizedStatistic[]> { public async $list24H(): Promise<OptimizedStatistic[]> {
try { try {
const query = `SELECT *, UNIX_TIMESTAMP(added) as added FROM statistics ORDER BY statistics.added DESC LIMIT 1440`; const query = `SELECT *, UNIX_TIMESTAMP(added) as added FROM statistics WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL 24 HOUR) AND NOW() ORDER BY statistics.added DESC`;
const [rows] = await DB.query({ sql: query, timeout: this.queryTimeout }); const [rows] = await DB.query({ sql: query, timeout: this.queryTimeout });
return this.mapStatisticToOptimizedStatistic(rows as Statistic[]); return this.mapStatisticToOptimizedStatistic(rows as Statistic[]);
} catch (e) { } catch (e) {

View file

@ -6,6 +6,7 @@ import statisticsApi from './statistics-api';
class Statistics { class Statistics {
protected intervalTimer: NodeJS.Timer | undefined; protected intervalTimer: NodeJS.Timer | undefined;
protected lastRun: number = 0;
protected newStatisticsEntryCallback: ((stats: OptimizedStatistic) => void) | undefined; protected newStatisticsEntryCallback: ((stats: OptimizedStatistic) => void) | undefined;
public setNewStatisticsEntryCallback(fn: (stats: OptimizedStatistic) => void) { public setNewStatisticsEntryCallback(fn: (stats: OptimizedStatistic) => void) {
@ -23,15 +24,21 @@ class Statistics {
setTimeout(() => { setTimeout(() => {
this.runStatistics(); this.runStatistics();
this.intervalTimer = setInterval(() => { this.intervalTimer = setInterval(() => {
this.runStatistics(); this.runStatistics(true);
}, 1 * 60 * 1000); }, 1 * 60 * 1000);
}, difference); }, difference);
} }
private async runStatistics(): Promise<void> { public async runStatistics(skipIfRecent = false): Promise<void> {
if (!memPool.isInSync()) { if (!memPool.isInSync()) {
return; return;
} }
if (skipIfRecent && new Date().getTime() / 1000 - this.lastRun < 30) {
return;
}
this.lastRun = new Date().getTime() / 1000;
const currentMempool = memPool.getMempool(); const currentMempool = memPool.getMempool();
const txPerSecond = memPool.getTxPerSecond(); const txPerSecond = memPool.getTxPerSecond();
const vBytesPerSecond = memPool.getVBytesPerSecond(); const vBytesPerSecond = memPool.getVBytesPerSecond();

View file

@ -23,6 +23,7 @@ import priceUpdater from '../tasks/price-updater';
import { ApiPrice } from '../repositories/PricesRepository'; import { ApiPrice } from '../repositories/PricesRepository';
import accelerationApi from './services/acceleration'; import accelerationApi from './services/acceleration';
import mempool from './mempool'; import mempool from './mempool';
import statistics from './statistics/statistics';
interface AddressTransactions { interface AddressTransactions {
mempool: MempoolTransactionExtended[], mempool: MempoolTransactionExtended[],
@ -259,7 +260,7 @@ class WebsocketHandler {
const mBlocksWithTransactions = mempoolBlocks.getMempoolBlocksWithTransactions(); const mBlocksWithTransactions = mempoolBlocks.getMempoolBlocksWithTransactions();
response['projected-block-transactions'] = JSON.stringify({ response['projected-block-transactions'] = JSON.stringify({
index: index, index: index,
blockTransactions: mBlocksWithTransactions[index]?.transactions || [], blockTransactions: (mBlocksWithTransactions[index]?.transactions || []).map(mempoolBlocks.compressTx),
}); });
} else { } else {
client['track-mempool-block'] = null; client['track-mempool-block'] = null;
@ -723,6 +724,7 @@ class WebsocketHandler {
} }
this.printLogs(); this.printLogs();
await statistics.runStatistics();
const _memPool = memPool.getMempool(); const _memPool = memPool.getMempool();
@ -999,7 +1001,7 @@ class WebsocketHandler {
if (mBlockDeltas[index].added.length > (mBlocksWithTransactions[index]?.transactions.length / 2)) { if (mBlockDeltas[index].added.length > (mBlocksWithTransactions[index]?.transactions.length / 2)) {
response['projected-block-transactions'] = getCachedResponse(`projected-block-transactions-full-${index}`, { response['projected-block-transactions'] = getCachedResponse(`projected-block-transactions-full-${index}`, {
index: index, index: index,
blockTransactions: mBlocksWithTransactions[index].transactions, blockTransactions: mBlocksWithTransactions[index].transactions.map(mempoolBlocks.compressTx),
}); });
} else { } else {
response['projected-block-transactions'] = getCachedResponse(`projected-block-transactions-delta-${index}`, { response['projected-block-transactions'] = getCachedResponse(`projected-block-transactions-delta-${index}`, {
@ -1014,6 +1016,8 @@ class WebsocketHandler {
client.send(this.serializeResponse(response)); client.send(this.serializeResponse(response));
} }
}); });
await statistics.runStatistics();
} }
// takes a dictionary of JSON serialized values // takes a dictionary of JSON serialized values

View file

@ -266,6 +266,7 @@ class Server {
blocks.setNewBlockCallback(async () => { blocks.setNewBlockCallback(async () => {
try { try {
await elementsParser.$parse(); await elementsParser.$parse();
await elementsParser.$updateFederationUtxos();
} catch (e) { } catch (e) {
logger.warn('Elements parsing error: ' + (e instanceof Error ? e.message : e)); logger.warn('Elements parsing error: ' + (e instanceof Error ? e.message : e));
} }

View file

@ -185,7 +185,8 @@ class Indexer {
await blocks.$generateCPFPDatabase(); await blocks.$generateCPFPDatabase();
await blocks.$generateAuditStats(); await blocks.$generateAuditStats();
await auditReplicator.$sync(); await auditReplicator.$sync();
await blocks.$classifyBlocks(); // do not wait for classify blocks to finish
blocks.$classifyBlocks();
} catch (e) { } catch (e) {
this.indexerRunning = false; this.indexerRunning = false;
logger.err(`Indexer failed, trying again in 10 seconds. Reason: ` + (e instanceof Error ? e.message : e)); logger.err(`Indexer failed, trying again in 10 seconds. Reason: ` + (e instanceof Error ? e.message : e));

View file

@ -65,9 +65,9 @@ export interface MempoolBlockWithTransactions extends MempoolBlock {
} }
export interface MempoolBlockDelta { export interface MempoolBlockDelta {
added: TransactionClassified[]; added: TransactionCompressed[];
removed: string[]; removed: string[];
changed: { txid: string, rate: number | undefined, flags?: number }[]; changed: MempoolDeltaChange[];
} }
interface VinStrippedToScriptsig { interface VinStrippedToScriptsig {
@ -196,6 +196,11 @@ export interface TransactionClassified extends TransactionStripped {
flags: number; flags: number;
} }
// [txid, fee, vsize, value, rate, flags, acceleration?]
export type TransactionCompressed = [string, number, number, number, number, number, 1?];
// [txid, rate, flags, acceleration?]
export type MempoolDeltaChange = [string, number, number, (1|0)];
// binary flags for transaction classification // binary flags for transaction classification
export const TransactionFlags = { export const TransactionFlags = {
// features // features

View file

@ -59,7 +59,7 @@ class BlocksAuditRepositories {
} }
} }
public async $getBlockAudit(hash: string): Promise<any> { public async $getBlockAudit(hash: string): Promise<BlockAudit | null> {
try { try {
const [rows]: any[] = await DB.query( const [rows]: any[] = await DB.query(
`SELECT blocks_audits.height, blocks_audits.hash as id, UNIX_TIMESTAMP(blocks_audits.time) as timestamp, `SELECT blocks_audits.height, blocks_audits.hash as id, UNIX_TIMESTAMP(blocks_audits.time) as timestamp,
@ -75,8 +75,8 @@ class BlocksAuditRepositories {
expected_weight as expectedWeight expected_weight as expectedWeight
FROM blocks_audits FROM blocks_audits
JOIN blocks_templates ON blocks_templates.id = blocks_audits.hash JOIN blocks_templates ON blocks_templates.id = blocks_audits.hash
WHERE blocks_audits.hash = "${hash}" WHERE blocks_audits.hash = ?
`); `, [hash]);
if (rows.length) { if (rows.length) {
rows[0].missingTxs = JSON.parse(rows[0].missingTxs); rows[0].missingTxs = JSON.parse(rows[0].missingTxs);
@ -101,8 +101,8 @@ class BlocksAuditRepositories {
const [rows]: any[] = await DB.query( const [rows]: any[] = await DB.query(
`SELECT hash, match_rate as matchRate, expected_fees as expectedFees, expected_weight as expectedWeight `SELECT hash, match_rate as matchRate, expected_fees as expectedFees, expected_weight as expectedWeight
FROM blocks_audits FROM blocks_audits
WHERE blocks_audits.hash = "${hash}" WHERE blocks_audits.hash = ?
`); `, [hash]);
return rows[0]; return rows[0];
} catch (e: any) { } catch (e: any) {
logger.err(`Cannot fetch block audit from db. Reason: ` + (e instanceof Error ? e.message : e)); logger.err(`Cannot fetch block audit from db. Reason: ` + (e instanceof Error ? e.message : e));

View file

@ -5,7 +5,7 @@ import logger from '../logger';
import { Common } from '../api/common'; import { Common } from '../api/common';
import PoolsRepository from './PoolsRepository'; import PoolsRepository from './PoolsRepository';
import HashratesRepository from './HashratesRepository'; import HashratesRepository from './HashratesRepository';
import { escape } from 'mysql2'; import { RowDataPacket, escape } from 'mysql2';
import BlocksSummariesRepository from './BlocksSummariesRepository'; import BlocksSummariesRepository from './BlocksSummariesRepository';
import DifficultyAdjustmentsRepository from './DifficultyAdjustmentsRepository'; import DifficultyAdjustmentsRepository from './DifficultyAdjustmentsRepository';
import bitcoinClient from '../api/bitcoin/bitcoin-client'; import bitcoinClient from '../api/bitcoin/bitcoin-client';
@ -478,7 +478,7 @@ class BlocksRepository {
public async $getBlocksByPool(slug: string, startHeight?: number): Promise<BlockExtended[]> { public async $getBlocksByPool(slug: string, startHeight?: number): Promise<BlockExtended[]> {
const pool = await PoolsRepository.$getPool(slug); const pool = await PoolsRepository.$getPool(slug);
if (!pool) { if (!pool) {
throw new Error('This mining pool does not exist ' + escape(slug)); throw new Error('This mining pool does not exist');
} }
const params: any[] = []; const params: any[] = [];
@ -802,10 +802,10 @@ class BlocksRepository {
/** /**
* Get a list of blocks that have been indexed * Get a list of blocks that have been indexed
*/ */
public async $getIndexedBlocks(): Promise<any[]> { public async $getIndexedBlocks(): Promise<{ height: number, hash: string }[]> {
try { try {
const [rows]: any = await DB.query(`SELECT height, hash FROM blocks ORDER BY height DESC`); const [rows] = await DB.query(`SELECT height, hash FROM blocks ORDER BY height DESC`) as RowDataPacket[][];
return rows; return rows as { height: number, hash: string }[];
} catch (e) { } catch (e) {
logger.err('Cannot generate block size and weight history. Reason: ' + (e instanceof Error ? e.message : e)); logger.err('Cannot generate block size and weight history. Reason: ' + (e instanceof Error ? e.message : e));
throw e; throw e;
@ -815,7 +815,7 @@ class BlocksRepository {
/** /**
* Get a list of blocks that have not had CPFP data indexed * Get a list of blocks that have not had CPFP data indexed
*/ */
public async $getCPFPUnindexedBlocks(): Promise<any[]> { public async $getCPFPUnindexedBlocks(): Promise<number[]> {
try { try {
const blockchainInfo = await bitcoinClient.getBlockchainInfo(); const blockchainInfo = await bitcoinClient.getBlockchainInfo();
const currentBlockHeight = blockchainInfo.blocks; const currentBlockHeight = blockchainInfo.blocks;
@ -825,13 +825,13 @@ class BlocksRepository {
} }
const minHeight = Math.max(0, currentBlockHeight - indexingBlockAmount + 1); const minHeight = Math.max(0, currentBlockHeight - indexingBlockAmount + 1);
const [rows]: any[] = await DB.query(` const [rows] = await DB.query(`
SELECT height SELECT height
FROM compact_cpfp_clusters FROM compact_cpfp_clusters
WHERE height <= ? AND height >= ? WHERE height <= ? AND height >= ?
GROUP BY height GROUP BY height
ORDER BY height DESC; ORDER BY height DESC;
`, [currentBlockHeight, minHeight]); `, [currentBlockHeight, minHeight]) as RowDataPacket[][];
const indexedHeights = {}; const indexedHeights = {};
rows.forEach((row) => { indexedHeights[row.height] = true; }); rows.forEach((row) => { indexedHeights[row.height] = true; });

View file

@ -1,3 +1,4 @@
import { RowDataPacket } from 'mysql2';
import DB from '../database'; import DB from '../database';
import logger from '../logger'; import logger from '../logger';
import { BlockSummary, TransactionClassified } from '../mempool.interfaces'; import { BlockSummary, TransactionClassified } from '../mempool.interfaces';
@ -69,7 +70,7 @@ class BlocksSummariesRepository {
public async $getIndexedSummariesId(): Promise<string[]> { public async $getIndexedSummariesId(): Promise<string[]> {
try { try {
const [rows]: any[] = await DB.query(`SELECT id from blocks_summaries`); const [rows] = await DB.query(`SELECT id from blocks_summaries`) as RowDataPacket[][];
return rows.map(row => row.id); return rows.map(row => row.id);
} catch (e) { } catch (e) {
logger.err(`Cannot get block summaries id list. Reason: ` + (e instanceof Error ? e.message : e)); logger.err(`Cannot get block summaries id list. Reason: ` + (e instanceof Error ? e.message : e));

View file

@ -139,7 +139,7 @@ class HashratesRepository {
public async $getPoolWeeklyHashrate(slug: string): Promise<any[]> { public async $getPoolWeeklyHashrate(slug: string): Promise<any[]> {
const pool = await PoolsRepository.$getPool(slug); const pool = await PoolsRepository.$getPool(slug);
if (!pool) { if (!pool) {
throw new Error('This mining pool does not exist ' + escape(slug)); throw new Error('This mining pool does not exist');
} }
// Find hashrate boundaries // Find hashrate boundaries

View file

@ -31,6 +31,9 @@ const curveP = BigInt(`0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
* @returns {boolean} true if the point is on the SECP256K1 curve * @returns {boolean} true if the point is on the SECP256K1 curve
*/ */
export function isPoint(pointHex: string): boolean { export function isPoint(pointHex: string): boolean {
if (!pointHex?.length) {
return false;
}
if ( if (
!( !(
// is uncompressed // is uncompressed

View file

@ -0,0 +1,3 @@
I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of December 20, 2023.
Signed: jamesblacklock

View file

@ -1,3 +1,3 @@
I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of November 16, 2023. I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of November 16, 2023.
Signed: natsee Signed: natsoni

View file

@ -35,7 +35,7 @@
"ALLOW_UNREACHABLE": __MEMPOOL_ALLOW_UNREACHABLE__, "ALLOW_UNREACHABLE": __MEMPOOL_ALLOW_UNREACHABLE__,
"POOLS_JSON_TREE_URL": "__MEMPOOL_POOLS_JSON_TREE_URL__", "POOLS_JSON_TREE_URL": "__MEMPOOL_POOLS_JSON_TREE_URL__",
"POOLS_JSON_URL": "__MEMPOOL_POOLS_JSON_URL__", "POOLS_JSON_URL": "__MEMPOOL_POOLS_JSON_URL__",
"PRICE_UPDATES_PER_HOUR": __MEMPOOL_PRICE_UPDATES_PER_HOUR__ "PRICE_UPDATES_PER_HOUR": __MEMPOOL_PRICE_UPDATES_PER_HOUR__,
"MAX_TRACKED_ADDRESSES": __MEMPOOL_MAX_TRACKED_ADDRESSES__ "MAX_TRACKED_ADDRESSES": __MEMPOOL_MAX_TRACKED_ADDRESSES__
}, },
"CORE_RPC": { "CORE_RPC": {

View file

@ -55,7 +55,7 @@ __ELECTRUM_TLS_ENABLED__=${ELECTRUM_TLS_ENABLED:=false}
# ESPLORA # ESPLORA
__ESPLORA_REST_API_URL__=${ESPLORA_REST_API_URL:=http://127.0.0.1:3000} __ESPLORA_REST_API_URL__=${ESPLORA_REST_API_URL:=http://127.0.0.1:3000}
__ESPLORA_UNIX_SOCKET_PATH__=${ESPLORA_UNIX_SOCKET_PATH:="null"} __ESPLORA_UNIX_SOCKET_PATH__=${ESPLORA_UNIX_SOCKET_PATH:=""}
__ESPLORA_BATCH_QUERY_BASE_SIZE__=${ESPLORA_BATCH_QUERY_BASE_SIZE:=1000} __ESPLORA_BATCH_QUERY_BASE_SIZE__=${ESPLORA_BATCH_QUERY_BASE_SIZE:=1000}
__ESPLORA_RETRY_UNIX_SOCKET_AFTER__=${ESPLORA_RETRY_UNIX_SOCKET_AFTER:=30000} __ESPLORA_RETRY_UNIX_SOCKET_AFTER__=${ESPLORA_RETRY_UNIX_SOCKET_AFTER:=30000}
__ESPLORA_REQUEST_TIMEOUT__=${ESPLORA_REQUEST_TIMEOUT:=5000} __ESPLORA_REQUEST_TIMEOUT__=${ESPLORA_REQUEST_TIMEOUT:=5000}

7
frontend/.gitignore vendored
View file

@ -6,6 +6,13 @@
/out-tsc /out-tsc
server.run.js server.run.js
# docker
Dockerfile
entrypoint.sh
nginx-mempool.conf
nginx.conf
wait-for
# Only exists if Bazel was run # Only exists if Bazel was run
/bazel-out /bazel-out

View file

@ -22,6 +22,7 @@ import { FiatCurrencyPipe } from './shared/pipes/fiat-currency.pipe';
import { ShortenStringPipe } from './shared/pipes/shorten-string-pipe/shorten-string.pipe'; import { ShortenStringPipe } from './shared/pipes/shorten-string-pipe/shorten-string.pipe';
import { CapAddressPipe } from './shared/pipes/cap-address-pipe/cap-address-pipe'; import { CapAddressPipe } from './shared/pipes/cap-address-pipe/cap-address-pipe';
import { AppPreloadingStrategy } from './app.preloading-strategy'; import { AppPreloadingStrategy } from './app.preloading-strategy';
import { ServicesApiServices } from './services/services-api.service';
const providers = [ const providers = [
ElectrsApiService, ElectrsApiService,
@ -40,6 +41,7 @@ const providers = [
FiatCurrencyPipe, FiatCurrencyPipe,
CapAddressPipe, CapAddressPipe,
AppPreloadingStrategy, AppPreloadingStrategy,
ServicesApiServices,
{ provide: HTTP_INTERCEPTORS, useClass: HttpCacheInterceptor, multi: true } { provide: HTTP_INTERCEPTORS, useClass: HttpCacheInterceptor, multi: true }
]; ];

View file

@ -1,14 +1,14 @@
<div id="become-sponsor-container"> <div id="become-sponsor-container" [ngClass]="context">
<div class="become-sponsor community"> <div class="become-sponsor community">
<p style="font-weight: 700; font-size: 18px;">If you're an individual...</p> <p style="font-weight: 700; font-size: 18px;">If you're an individual...</p>
<a href="https://mempool.space/sponsor" class="btn" style="background-color: rgba(152, 88, 255, 0.75); box-shadow: 0px 0px 50px 5px rgba(152, 88, 255, 0.75)" i18n="about.community-sponsor-button" (click)="onSponsorClick($event)">Become a Community Sponsor</a> <a [href]="host + '/sponsor'" class="btn" style="background-color: rgba(152, 88, 255, 0.75); box-shadow: 0px 0px 50px 5px rgba(152, 88, 255, 0.75)" i18n="about.community-sponsor-button" (click)="onSponsorClick($event)">Become a Community Sponsor</a>
<p class="sponsor-feature"><fa-icon [icon]="['fas', 'check']"></fa-icon> Exclusive swag</p> <p class="sponsor-feature"><fa-icon [icon]="['fas', 'check']"></fa-icon> Exclusive swag</p>
<p class="sponsor-feature"><fa-icon [icon]="['fas', 'check']"></fa-icon> Your avatar on the About page</p> <p class="sponsor-feature"><fa-icon [icon]="['fas', 'check']"></fa-icon> Your avatar on the About page</p>
<p class="sponsor-feature"><fa-icon [icon]="['fas', 'check']"></fa-icon> And more coming soon :)</p> <p class="sponsor-feature"><fa-icon [icon]="['fas', 'check']"></fa-icon> And more coming soon :)</p>
</div> </div>
<div class="become-sponsor enterprise"> <div class="become-sponsor enterprise">
<p style="font-weight: 700; font-size: 18px;">If you're a business...</p> <p style="font-weight: 700; font-size: 18px;">If you're a business...</p>
<a href="https://mempool.space/enterprise" class="btn" style="background-color: rgba(152, 88, 255, 0.75); box-shadow: 0px 0px 50px 5px rgba(152, 88, 255, 0.75)" i18n="about.enterprise-sponsor-button" (click)="onEnterpriseClick($event)">Become an Enterprise Sponsor</a> <a [href]="host + '/enterprise'" class="btn" style="background-color: rgba(152, 88, 255, 0.75); box-shadow: 0px 0px 50px 5px rgba(152, 88, 255, 0.75)" i18n="about.enterprise-sponsor-button" (click)="onEnterpriseClick($event)">Become an Enterprise Sponsor</a>
<p class="sponsor-feature"><fa-icon [icon]="['fas', 'check']"></fa-icon> Increased API limits</p> <p class="sponsor-feature"><fa-icon [icon]="['fas', 'check']"></fa-icon> Increased API limits</p>
<p class="sponsor-feature"><fa-icon [icon]="['fas', 'check']"></fa-icon> Co-branded instance</p> <p class="sponsor-feature"><fa-icon [icon]="['fas', 'check']"></fa-icon> Co-branded instance</p>
<p class="sponsor-feature"><fa-icon [icon]="['fas', 'check']"></fa-icon> 99% service-level agreement</p> <p class="sponsor-feature"><fa-icon [icon]="['fas', 'check']"></fa-icon> 99% service-level agreement</p>

View file

@ -6,6 +6,11 @@
align-items: center; align-items: center;
gap: 20px; gap: 20px;
margin: 68px auto; margin: 68px auto;
text-align: center;
}
#become-sponsor-container.account {
margin: 20px auto;
} }
.become-sponsor { .become-sponsor {

View file

@ -1,4 +1,4 @@
import { Component } from '@angular/core'; import { Component, Input } from '@angular/core';
import { EnterpriseService } from '../../services/enterprise.service'; import { EnterpriseService } from '../../services/enterprise.service';
@Component({ @Component({
@ -7,6 +7,9 @@ import { EnterpriseService } from '../../services/enterprise.service';
styleUrls: ['./about-sponsors.component.scss'], styleUrls: ['./about-sponsors.component.scss'],
}) })
export class AboutSponsorsComponent { export class AboutSponsorsComponent {
@Input() host = 'https://mempool.space';
@Input() context = 'about';
constructor(private enterpriseService: EnterpriseService) { constructor(private enterpriseService: EnterpriseService) {
} }

View file

@ -4,6 +4,7 @@ import { Subscription, catchError, of, tap } from 'rxjs';
import { StorageService } from '../../services/storage.service'; import { StorageService } from '../../services/storage.service';
import { Transaction } from '../../interfaces/electrs.interface'; import { Transaction } from '../../interfaces/electrs.interface';
import { nextRoundNumber } from '../../shared/common.utils'; import { nextRoundNumber } from '../../shared/common.utils';
import { ServicesApiServices } from '../../services/services-api.service';
import { AudioService } from '../../services/audio.service'; import { AudioService } from '../../services/audio.service';
export type AccelerationEstimate = { export type AccelerationEstimate = {
@ -62,7 +63,7 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges
maxRateOptions: RateOption[] = []; maxRateOptions: RateOption[] = [];
constructor( constructor(
private apiService: ApiService, private servicesApiService: ServicesApiServices,
private storageService: StorageService, private storageService: StorageService,
private audioService: AudioService, private audioService: AudioService,
private cd: ChangeDetectorRef private cd: ChangeDetectorRef
@ -83,7 +84,7 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges
ngOnInit() { ngOnInit() {
this.user = this.storageService.getAuth()?.user ?? null; this.user = this.storageService.getAuth()?.user ?? null;
this.estimateSubscription = this.apiService.estimate$(this.tx.txid).pipe( this.estimateSubscription = this.servicesApiService.estimate$(this.tx.txid).pipe(
tap((response) => { tap((response) => {
if (response.status === 204) { if (response.status === 204) {
this.estimate = undefined; this.estimate = undefined;
@ -183,7 +184,7 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges
if (this.accelerationSubscription) { if (this.accelerationSubscription) {
this.accelerationSubscription.unsubscribe(); this.accelerationSubscription.unsubscribe();
} }
this.accelerationSubscription = this.apiService.accelerate$( this.accelerationSubscription = this.servicesApiService.accelerate$(
this.tx.txid, this.tx.txid,
this.userBid this.userBid
).subscribe({ ).subscribe({

View file

@ -27,12 +27,6 @@
</form> </form>
</div> </div>
<div *ngIf="widget">
<div class="item">
<h5 class="card-title" i18n="acceleration.total-bid-boost">Total Bid Boost</h5>
</div>
</div>
<div [class.chart]="!widget" [class.chart-widget]="widget" echarts [initOpts]="chartInitOptions" [options]="chartOptions" <div [class.chart]="!widget" [class.chart-widget]="widget" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
(chartInit)="onChartInit($event)"> (chartInit)="onChartInit($event)">
</div> </div>

View file

@ -53,11 +53,6 @@
padding-bottom: 55px; padding-bottom: 55px;
} }
} }
.chart-widget {
width: 100%;
height: 100%;
max-height: 290px;
}
h5 { h5 {
margin-bottom: 10px; margin-bottom: 10px;

View file

@ -1,8 +1,7 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnDestroy, OnInit } from '@angular/core';
import { EChartsOption, graphic } from 'echarts'; import { EChartsOption, graphic } from 'echarts';
import { Observable, Subscription, combineLatest } from 'rxjs'; import { Observable, Subscription, combineLatest, fromEvent } from 'rxjs';
import { map, max, startWith, switchMap, tap } from 'rxjs/operators'; import { map, max, startWith, switchMap, tap } from 'rxjs/operators';
import { ApiService } from '../../../services/api.service';
import { SeoService } from '../../../services/seo.service'; import { SeoService } from '../../../services/seo.service';
import { formatNumber } from '@angular/common'; import { formatNumber } from '@angular/common';
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
@ -11,6 +10,8 @@ import { StorageService } from '../../../services/storage.service';
import { MiningService } from '../../../services/mining.service'; import { MiningService } from '../../../services/mining.service';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { Acceleration } from '../../../interfaces/node-api.interface'; import { Acceleration } from '../../../interfaces/node-api.interface';
import { ServicesApiServices } from '../../../services/services-api.service';
import { ApiService } from '../../../services/api.service';
@Component({ @Component({
selector: 'app-acceleration-fees-graph', selector: 'app-acceleration-fees-graph',
@ -28,6 +29,7 @@ import { Acceleration } from '../../../interfaces/node-api.interface';
}) })
export class AccelerationFeesGraphComponent implements OnInit, OnDestroy { export class AccelerationFeesGraphComponent implements OnInit, OnDestroy {
@Input() widget: boolean = false; @Input() widget: boolean = false;
@Input() height: number | string = '200';
@Input() right: number | string = 45; @Input() right: number | string = 45;
@Input() left: number | string = 75; @Input() left: number | string = 75;
@Input() accelerations$: Observable<Acceleration[]>; @Input() accelerations$: Observable<Acceleration[]>;
@ -54,6 +56,7 @@ export class AccelerationFeesGraphComponent implements OnInit, OnDestroy {
@Inject(LOCALE_ID) public locale: string, @Inject(LOCALE_ID) public locale: string,
private seoService: SeoService, private seoService: SeoService,
private apiService: ApiService, private apiService: ApiService,
private servicesApiService: ServicesApiServices,
private formBuilder: UntypedFormBuilder, private formBuilder: UntypedFormBuilder,
private storageService: StorageService, private storageService: StorageService,
private miningService: MiningService, private miningService: MiningService,
@ -72,8 +75,9 @@ export class AccelerationFeesGraphComponent implements OnInit, OnDestroy {
this.timespan = this.miningWindowPreference; this.timespan = this.miningWindowPreference;
this.statsObservable$ = combineLatest([ this.statsObservable$ = combineLatest([
(this.accelerations$ || this.apiService.getAccelerationHistory$({ timeframe: this.miningWindowPreference })), (this.accelerations$ || this.servicesApiService.getAccelerationHistory$({ timeframe: this.miningWindowPreference })),
this.apiService.getHistoricalBlockFees$(this.miningWindowPreference), this.apiService.getHistoricalBlockFees$(this.miningWindowPreference),
fromEvent(window, 'resize').pipe(startWith(null)),
]).pipe( ]).pipe(
tap(([accelerations, blockFeesResponse]) => { tap(([accelerations, blockFeesResponse]) => {
this.prepareChartOptions(accelerations, blockFeesResponse.body); this.prepareChartOptions(accelerations, blockFeesResponse.body);
@ -101,7 +105,7 @@ export class AccelerationFeesGraphComponent implements OnInit, OnDestroy {
this.isLoading = true; this.isLoading = true;
this.storageService.setValue('miningWindowPreference', timespan); this.storageService.setValue('miningWindowPreference', timespan);
this.timespan = timespan; this.timespan = timespan;
return this.apiService.getAccelerationHistory$({}); return this.servicesApiService.getAccelerationHistory$({});
}) })
), ),
this.radioGroupForm.get('dateSpan').valueChanges.pipe( this.radioGroupForm.get('dateSpan').valueChanges.pipe(
@ -173,6 +177,7 @@ export class AccelerationFeesGraphComponent implements OnInit, OnDestroy {
], ],
animation: false, animation: false,
grid: { grid: {
height: this.height,
right: this.right, right: this.right,
left: this.left, left: this.left,
bottom: this.widget ? 30 : 80, bottom: this.widget ? 30 : 80,

View file

@ -1,9 +1,9 @@
import { Component, OnInit, ChangeDetectionStrategy, Input, ChangeDetectorRef } from '@angular/core'; import { Component, OnInit, ChangeDetectionStrategy, Input, ChangeDetectorRef } from '@angular/core';
import { Observable, catchError, of, switchMap, tap } from 'rxjs'; import { Observable, catchError, of, switchMap, tap } from 'rxjs';
import { Acceleration, BlockExtended } from '../../../interfaces/node-api.interface'; import { Acceleration, BlockExtended } from '../../../interfaces/node-api.interface';
import { ApiService } from '../../../services/api.service';
import { StateService } from '../../../services/state.service'; import { StateService } from '../../../services/state.service';
import { WebsocketService } from '../../../services/websocket.service'; import { WebsocketService } from '../../../services/websocket.service';
import { ServicesApiServices } from '../../../services/services-api.service';
@Component({ @Component({
selector: 'app-accelerations-list', selector: 'app-accelerations-list',
@ -26,7 +26,7 @@ export class AccelerationsListComponent implements OnInit {
skeletonLines: number[] = []; skeletonLines: number[] = [];
constructor( constructor(
private apiService: ApiService, private servicesApiService: ServicesApiServices,
private websocketService: WebsocketService, private websocketService: WebsocketService,
public stateService: StateService, public stateService: StateService,
private cd: ChangeDetectorRef, private cd: ChangeDetectorRef,
@ -41,7 +41,7 @@ export class AccelerationsListComponent implements OnInit {
this.skeletonLines = this.widget === true ? [...Array(6).keys()] : [...Array(15).keys()]; this.skeletonLines = this.widget === true ? [...Array(6).keys()] : [...Array(15).keys()];
this.paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5; this.paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5;
const accelerationObservable$ = this.accelerations$ || (this.pending ? this.apiService.getAccelerations$() : this.apiService.getAccelerationHistory$({ timeframe: '1m' })); const accelerationObservable$ = this.accelerations$ || (this.pending ? this.servicesApiService.getAccelerations$() : this.servicesApiService.getAccelerationHistory$({ timeframe: '1m' }));
this.accelerationList$ = accelerationObservable$.pipe( this.accelerationList$ = accelerationObservable$.pipe(
switchMap(accelerations => { switchMap(accelerations => {
if (this.pending) { if (this.pending) {

View file

@ -37,6 +37,11 @@
<div class="col" style="margin-bottom: 1.47rem"> <div class="col" style="margin-bottom: 1.47rem">
<div class="card"> <div class="card">
<div class="card-body pl-lg-3 pr-lg-3 pl-2 pr-2"> <div class="card-body pl-lg-3 pr-lg-3 pl-2 pr-2">
<a class="title-link" href="" [routerLink]="['/mempool-block/0' | relativeUrl]">
<h5 class="card-title d-inline" i18n="dashboard.mempool-goggles-accelerations">Mempool Goggles: Accelerations</h5>
<span>&nbsp;</span>
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: 'text-top'; font-size: 13px; color: #4a68b9"></fa-icon>
</a>
<div class="mempool-block-wrapper"> <div class="mempool-block-wrapper">
<app-mempool-block-overview [index]="0" [overrideColors]="getAcceleratorColor"></app-mempool-block-overview> <app-mempool-block-overview [index]="0" [overrideColors]="getAcceleratorColor"></app-mempool-block-overview>
</div> </div>
@ -48,7 +53,15 @@
<div class="col" style="margin-bottom: 1.47rem"> <div class="col" style="margin-bottom: 1.47rem">
<div class="card graph-card"> <div class="card graph-card">
<div class="card-body pl-2 pr-2"> <div class="card-body pl-2 pr-2">
<app-acceleration-fees-graph [attr.data-cy]="'acceleration-fees'" [widget]=true [accelerations$]="accelerations$"></app-acceleration-fees-graph> <h5 class="card-title" i18n="acceleration.total-bid-boost">Total Bid Boost</h5>
<div class="mempool-graph">
<app-acceleration-fees-graph
[height]="graphHeight"
[attr.data-cy]="'acceleration-fees'"
[widget]=true
[accelerations$]="accelerations$"
></app-acceleration-fees-graph>
</div>
<div class="mt-1"><a [attr.data-cy]="'acceleration-fees-view-more'" [routerLink]="['/graphs/acceleration/fees' | relativeUrl]" i18n="dashboard.view-more">View more &raquo;</a></div> <div class="mt-1"><a [attr.data-cy]="'acceleration-fees-view-more'" [routerLink]="['/graphs/acceleration/fees' | relativeUrl]" i18n="dashboard.view-more">View more &raquo;</a></div>
</div> </div>
</div> </div>

View file

@ -17,6 +17,16 @@
} }
} }
.mempool-graph {
height: 295px;
@media (min-width: 768px) {
height: 325px;
}
@media (min-width: 992px) {
height: 409px;
}
}
.card-title { .card-title {
font-size: 1rem; font-size: 1rem;
color: #4a68b9; color: #4a68b9;
@ -135,7 +145,12 @@
} }
.card { .card {
height: 385px; @media (min-width: 768px) {
height: 420px;
}
@media (min-width: 992px) {
height: 510px;
}
} }
.list-card { .list-card {
height: 410px; height: 410px;
@ -145,7 +160,16 @@
} }
.mempool-block-wrapper { .mempool-block-wrapper {
max-height: 380px; max-height: 430px;
max-width: 380px; max-width: 430px;
margin: auto; margin: auto;
@media (min-width: 768px) {
max-height: 344px;
max-width: 344px;
}
@media (min-width: 992px) {
max-height: 430px;
max-width: 430px;
}
} }

View file

@ -1,14 +1,14 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, HostListener, OnInit } from '@angular/core';
import { SeoService } from '../../../services/seo.service'; import { SeoService } from '../../../services/seo.service';
import { WebsocketService } from '../../../services/websocket.service'; import { WebsocketService } from '../../../services/websocket.service';
import { Acceleration, BlockExtended } from '../../../interfaces/node-api.interface'; import { Acceleration, BlockExtended } from '../../../interfaces/node-api.interface';
import { StateService } from '../../../services/state.service'; import { StateService } from '../../../services/state.service';
import { Observable, Subject, catchError, combineLatest, distinctUntilChanged, interval, map, of, share, startWith, switchMap, tap } from 'rxjs'; import { Observable, catchError, combineLatest, distinctUntilChanged, interval, map, of, share, startWith, switchMap, tap } from 'rxjs';
import { ApiService } from '../../../services/api.service';
import { Color } from '../../block-overview-graph/sprite-types'; import { Color } from '../../block-overview-graph/sprite-types';
import { hexToColor } from '../../block-overview-graph/utils'; import { hexToColor } from '../../block-overview-graph/utils';
import TxView from '../../block-overview-graph/tx-view'; import TxView from '../../block-overview-graph/tx-view';
import { feeLevels, mempoolFeeColors } from '../../../app.constants'; import { feeLevels, mempoolFeeColors } from '../../../app.constants';
import { ServicesApiServices } from '../../../services/services-api.service';
const acceleratedColor: Color = hexToColor('8F5FF6'); const acceleratedColor: Color = hexToColor('8F5FF6');
const normalColors = mempoolFeeColors.map(hex => hexToColor(hex + '5F')); const normalColors = mempoolFeeColors.map(hex => hexToColor(hex + '5F'));
@ -30,43 +30,48 @@ export class AcceleratorDashboardComponent implements OnInit {
minedAccelerations$: Observable<Acceleration[]>; minedAccelerations$: Observable<Acceleration[]>;
loadingBlocks: boolean = true; loadingBlocks: boolean = true;
graphHeight: number = 300;
constructor( constructor(
private seoService: SeoService, private seoService: SeoService,
private websocketService: WebsocketService, private websocketService: WebsocketService,
private apiService: ApiService, private serviceApiServices: ServicesApiServices,
private stateService: StateService, private stateService: StateService,
) { ) {
this.seoService.setTitle($localize`:@@a681a4e2011bb28157689dbaa387de0dd0aa0c11:Accelerator Dashboard`); this.seoService.setTitle($localize`:@@a681a4e2011bb28157689dbaa387de0dd0aa0c11:Accelerator Dashboard`);
} }
ngOnInit(): void { ngOnInit(): void {
this.onResize();
this.websocketService.want(['blocks', 'mempool-blocks', 'stats']); this.websocketService.want(['blocks', 'mempool-blocks', 'stats']);
this.pendingAccelerations$ = interval(30000).pipe( this.pendingAccelerations$ = interval(30000).pipe(
startWith(true), startWith(true),
switchMap(() => { switchMap(() => {
return this.apiService.getAccelerations$(); return this.serviceApiServices.getAccelerations$().pipe(
}), catchError(() => {
catchError((e) => {
return of([]); return of([]);
}), }),
);
}),
share(), share(),
); );
this.accelerations$ = this.stateService.chainTip$.pipe( this.accelerations$ = this.stateService.chainTip$.pipe(
distinctUntilChanged(), distinctUntilChanged(),
switchMap((chainTip) => { switchMap(() => {
return this.apiService.getAccelerationHistory$({ timeframe: '1m' }); return this.serviceApiServices.getAccelerationHistory$({ timeframe: '1m' }).pipe(
}), catchError(() => {
catchError((e) => {
return of([]); return of([]);
}), }),
);
}),
share(), share(),
); );
this.minedAccelerations$ = this.accelerations$.pipe( this.minedAccelerations$ = this.accelerations$.pipe(
map(accelerations => { map(accelerations => {
return accelerations.filter(acc => ['mined', 'completed'].includes(acc.status)) return accelerations.filter(acc => ['mined', 'completed', 'failed'].includes(acc.status));
}) })
); );
@ -119,4 +124,15 @@ export class AcceleratorDashboardComponent implements OnInit {
return normalColors[feeLevelIndex] || normalColors[mempoolFeeColors.length - 1]; return normalColors[feeLevelIndex] || normalColors[mempoolFeeColors.length - 1];
} }
} }
@HostListener('window:resize', ['$event'])
onResize(): void {
if (window.innerWidth >= 992) {
this.graphHeight = 330;
} else if (window.innerWidth >= 768) {
this.graphHeight = 245;
} else {
this.graphHeight = 210;
}
}
} }

View file

@ -1,8 +1,8 @@
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import { switchMap } from 'rxjs/operators'; import { switchMap } from 'rxjs/operators';
import { ApiService } from '../../../services/api.service';
import { Acceleration } from '../../../interfaces/node-api.interface'; import { Acceleration } from '../../../interfaces/node-api.interface';
import { ServicesApiServices } from '../../../services/services-api.service';
@Component({ @Component({
selector: 'app-pending-stats', selector: 'app-pending-stats',
@ -15,11 +15,11 @@ export class PendingStatsComponent implements OnInit {
public accelerationStats$: Observable<any>; public accelerationStats$: Observable<any>;
constructor( constructor(
private apiService: ApiService, private servicesApiService: ServicesApiServices,
) { } ) { }
ngOnInit(): void { ngOnInit(): void {
this.accelerationStats$ = (this.accelerations$ || this.apiService.getAccelerations$()).pipe( this.accelerationStats$ = (this.accelerations$ || this.servicesApiService.getAccelerations$()).pipe(
switchMap(accelerations => { switchMap(accelerations => {
let totalAccelerations = 0; let totalAccelerations = 0;
let totalFeeDelta = 0; let totalFeeDelta = 0;

View file

@ -43,7 +43,7 @@ export class AddressLabelsComponent implements OnChanges {
handleVin() { handleVin() {
if (this.vin.inner_witnessscript_asm) { if (this.vin.inner_witnessscript_asm) {
if (this.vin.inner_witnessscript_asm.indexOf('OP_DEPTH OP_PUSHNUM_12 OP_EQUAL OP_IF OP_PUSHNUM_11') === 0) { if (this.vin.inner_witnessscript_asm.indexOf('OP_DEPTH OP_PUSHNUM_12 OP_EQUAL OP_IF OP_PUSHNUM_11') === 0 || this.vin.inner_witnessscript_asm.indexOf('OP_PUSHNUM_15 OP_CHECKMULTISIG OP_IFDUP OP_NOTIF OP_PUSHBYTES_2') === 1259) {
if (this.vin.witness.length > 11) { if (this.vin.witness.length > 11) {
this.label = 'Liquid Peg Out'; this.label = 'Liquid Peg Out';
} else { } else {

View file

@ -31,8 +31,7 @@ export class AddressComponent implements OnInit, OnDestroy {
addressLoadingStatus$: Observable<number>; addressLoadingStatus$: Observable<number>;
addressInfo: null | AddressInformation = null; addressInfo: null | AddressInformation = null;
totalConfirmedTxCount = 0; fullyLoaded = false;
loadedConfirmedTxCount = 0;
txCount = 0; txCount = 0;
received = 0; received = 0;
sent = 0; sent = 0;
@ -66,7 +65,7 @@ export class AddressComponent implements OnInit, OnDestroy {
switchMap((params: ParamMap) => { switchMap((params: ParamMap) => {
this.error = undefined; this.error = undefined;
this.isLoadingAddress = true; this.isLoadingAddress = true;
this.loadedConfirmedTxCount = 0; this.fullyLoaded = false;
this.address = null; this.address = null;
this.isLoadingTransactions = true; this.isLoadingTransactions = true;
this.transactions = null; this.transactions = null;
@ -105,7 +104,7 @@ export class AddressComponent implements OnInit, OnDestroy {
.pipe( .pipe(
filter((address) => !!address), filter((address) => !!address),
tap((address: Address) => { tap((address: Address) => {
if ((this.stateService.network === 'liquid' || this.stateService.network === 'liquidtestnet') && /^([m-zA-HJ-NP-Z1-9]{26,35}|[a-z]{2,5}1[ac-hj-np-z02-9]{8,100}|[a-km-zA-HJ-NP-Z1-9]{80})$/.test(address.address)) { if ((this.stateService.network === 'liquid' || this.stateService.network === 'liquidtestnet') && /^([a-zA-HJ-NP-Z1-9]{26,35}|[a-z]{2,5}1[ac-hj-np-z02-9]{8,100}|[a-km-zA-HJ-NP-Z1-9]{80})$/.test(address.address)) {
this.apiService.validateAddress$(address.address) this.apiService.validateAddress$(address.address)
.subscribe((addressInfo) => { .subscribe((addressInfo) => {
this.addressInfo = addressInfo; this.addressInfo = addressInfo;
@ -128,7 +127,6 @@ export class AddressComponent implements OnInit, OnDestroy {
this.tempTransactions = transactions; this.tempTransactions = transactions;
if (transactions.length) { if (transactions.length) {
this.lastTransactionTxId = transactions[transactions.length - 1].txid; this.lastTransactionTxId = transactions[transactions.length - 1].txid;
this.loadedConfirmedTxCount += transactions.filter((tx) => tx.status.confirmed).length;
} }
const fetchTxs: string[] = []; const fetchTxs: string[] = [];
@ -191,8 +189,6 @@ export class AddressComponent implements OnInit, OnDestroy {
this.audioService.playSound('magic'); this.audioService.playSound('magic');
} }
} }
this.totalConfirmedTxCount++;
this.loadedConfirmedTxCount++;
}); });
} }
@ -252,16 +248,19 @@ export class AddressComponent implements OnInit, OnDestroy {
} }
loadMore() { loadMore() {
if (this.isLoadingTransactions || !this.totalConfirmedTxCount || this.loadedConfirmedTxCount >= this.totalConfirmedTxCount) { if (this.isLoadingTransactions || this.fullyLoaded) {
return; return;
} }
this.isLoadingTransactions = true; this.isLoadingTransactions = true;
this.retryLoadMore = false; this.retryLoadMore = false;
this.electrsApiService.getAddressTransactions$(this.address.address, this.lastTransactionTxId) this.electrsApiService.getAddressTransactions$(this.address.address, this.lastTransactionTxId)
.subscribe((transactions: Transaction[]) => { .subscribe((transactions: Transaction[]) => {
if (transactions && transactions.length) {
this.lastTransactionTxId = transactions[transactions.length - 1].txid; this.lastTransactionTxId = transactions[transactions.length - 1].txid;
this.loadedConfirmedTxCount += transactions.length;
this.transactions = this.transactions.concat(transactions); this.transactions = this.transactions.concat(transactions);
} else {
this.fullyLoaded = true;
}
this.isLoadingTransactions = false; this.isLoadingTransactions = false;
}, },
(error) => { (error) => {
@ -278,7 +277,6 @@ export class AddressComponent implements OnInit, OnDestroy {
this.received = this.address.chain_stats.funded_txo_sum + this.address.mempool_stats.funded_txo_sum; this.received = this.address.chain_stats.funded_txo_sum + this.address.mempool_stats.funded_txo_sum;
this.sent = this.address.chain_stats.spent_txo_sum + this.address.mempool_stats.spent_txo_sum; this.sent = this.address.chain_stats.spent_txo_sum + this.address.mempool_stats.spent_txo_sum;
this.txCount = this.address.chain_stats.tx_count + this.address.mempool_stats.tx_count; this.txCount = this.address.chain_stats.tx_count + this.address.mempool_stats.tx_count;
this.totalConfirmedTxCount = this.address.chain_stats.tx_count;
} }
ngOnDestroy() { ngOnDestroy() {

View file

@ -19,7 +19,7 @@
</ng-template> </ng-template>
<ng-template #default> <ng-template #default>
&lrm;{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis / 100000000 | number : digitsInfo }} &lrm;{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis / 100000000 | number : digitsInfo }}
<span class="symbol"><ng-template [ngIf]="network === 'liquid'">L-</ng-template> <span class="symbol"><ng-template [ngIf]="network === 'liquid' && !forceBtc">L-</ng-template>
<ng-template [ngIf]="network === 'liquidtestnet'">tL-</ng-template> <ng-template [ngIf]="network === 'liquidtestnet'">tL-</ng-template>
<ng-template [ngIf]="network === 'testnet'">t</ng-template> <ng-template [ngIf]="network === 'testnet'">t</ng-template>
<ng-template [ngIf]="network === 'signet'">s</ng-template>BTC</span> <ng-template [ngIf]="network === 'signet'">s</ng-template>BTC</span>

View file

@ -23,6 +23,7 @@ export class AmountComponent implements OnInit, OnDestroy {
@Input() noFiat = false; @Input() noFiat = false;
@Input() addPlus = false; @Input() addPlus = false;
@Input() blockConversion: Price; @Input() blockConversion: Price;
@Input() forceBtc: boolean = false;
constructor( constructor(
private stateService: StateService, private stateService: StateService,

View file

@ -1,4 +1,4 @@
<div class="block-filters" [class.filters-active]="activeFilters.length > 0" [class.menu-open]="menuOpen" [class.small]="cssWidth < 500" [class.vsmall]="cssWidth < 400" [class.tiny]="cssWidth < 200"> <div class="block-filters" [class.filters-active]="activeFilters.length > 0" [class.any-mode]="filterMode === 'or'" [class.menu-open]="menuOpen" [class.small]="cssWidth < 500" [class.vsmall]="cssWidth < 400" [class.tiny]="cssWidth < 200">
<a *ngIf="menuOpen" [routerLink]="['/docs/faq' | relativeUrl]" fragment="how-do-mempool-goggles-work" class="info-badges" i18n-ngbTooltip="Mempool Goggles tooltip" ngbTooltip="select filter categories to highlight matching transactions"> <a *ngIf="menuOpen" [routerLink]="['/docs/faq' | relativeUrl]" fragment="how-do-mempool-goggles-work" class="info-badges" i18n-ngbTooltip="Mempool Goggles tooltip" ngbTooltip="select filter categories to highlight matching transactions">
<span class="badge badge-pill badge-warning beta" i18n="beta">beta</span> <span class="badge badge-pill badge-warning beta" i18n="beta">beta</span>
<fa-icon [icon]="['fas', 'info-circle']" [fixedWidth]="true" size="lg"></fa-icon> <fa-icon [icon]="['fas', 'info-circle']" [fixedWidth]="true" size="lg"></fa-icon>
@ -14,6 +14,15 @@
</div> </div>
</div> </div>
<div class="filter-menu" *ngIf="menuOpen && cssWidth > 280"> <div class="filter-menu" *ngIf="menuOpen && cssWidth > 280">
<h5>Match</h5>
<div class="btn-group btn-group-toggle">
<label class="btn btn-xs blue mode-toggle" [class.active]="filterMode === 'and'">
<input type="radio" [value]="'all'" fragment="all" (click)="setFilterMode('and')">All
</label>
<label class="btn btn-xs green mode-toggle" [class.active]="filterMode === 'or'">
<input type="radio" [value]="'any'" fragment="any" (click)="setFilterMode('or')">Any
</label>
</div>
<ng-container *ngFor="let group of filterGroups;"> <ng-container *ngFor="let group of filterGroups;">
<h5>{{ group.label }}</h5> <h5>{{ group.label }}</h5>
<div class="filter-group"> <div class="filter-group">

View file

@ -77,6 +77,49 @@
} }
} }
&.any-mode {
.filter-tag {
border: solid 1px #1a9436;
&.selected {
background-color: #1a9436;
}
}
}
.btn-group {
font-size: 0.9em;
margin-right: 0.25em;
}
.mode-toggle {
padding: 0.2em 0.5em;
pointer-events: all;
line-height: 1.5;
background: #181b2daf;
&:first-child {
border-top-left-radius: 0.2rem;
border-bottom-left-radius: 0.2rem;
}
&:last-child {
border-top-right-radius: 0.2rem;
border-bottom-right-radius: 0.2rem;
}
&.blue {
border: solid 1px #105fb0;
&.active {
background: #105fb0;
}
}
&.green {
border: solid 1px #1a9436;
&.active {
background: #1a9436;
}
}
}
:host-context(.block-overview-graph:hover) &, &:hover, &:active { :host-context(.block-overview-graph:hover) &, &:hover, &:active {
.menu-toggle { .menu-toggle {
opacity: 0.5; opacity: 0.5;
@ -132,6 +175,11 @@
.filter-tag { .filter-tag {
font-size: 0.7em; font-size: 0.7em;
} }
.mode-toggle {
font-size: 0.7em;
margin-bottom: 5px;
margin-top: 2px;
}
} }
&.tiny { &.tiny {

View file

@ -1,5 +1,5 @@
import { Component, EventEmitter, Output, HostListener, Input, ChangeDetectorRef, OnChanges, SimpleChanges, OnInit, OnDestroy } from '@angular/core'; import { Component, EventEmitter, Output, HostListener, Input, ChangeDetectorRef, OnChanges, SimpleChanges, OnInit, OnDestroy } from '@angular/core';
import { FilterGroups, TransactionFilters } from '../../shared/filters.utils'; import { ActiveFilter, FilterGroups, FilterMode, TransactionFilters } from '../../shared/filters.utils';
import { StateService } from '../../services/state.service'; import { StateService } from '../../services/state.service';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
@ -12,7 +12,7 @@ import { Subscription } from 'rxjs';
export class BlockFiltersComponent implements OnInit, OnChanges, OnDestroy { export class BlockFiltersComponent implements OnInit, OnChanges, OnDestroy {
@Input() cssWidth: number = 800; @Input() cssWidth: number = 800;
@Input() excludeFilters: string[] = []; @Input() excludeFilters: string[] = [];
@Output() onFilterChanged: EventEmitter<bigint | null> = new EventEmitter(); @Output() onFilterChanged: EventEmitter<ActiveFilter | null> = new EventEmitter();
filterSubscription: Subscription; filterSubscription: Subscription;
@ -21,6 +21,7 @@ export class BlockFiltersComponent implements OnInit, OnChanges, OnDestroy {
disabledFilters: { [key: string]: boolean } = {}; disabledFilters: { [key: string]: boolean } = {};
activeFilters: string[] = []; activeFilters: string[] = [];
filterFlags: { [key: string]: boolean } = {}; filterFlags: { [key: string]: boolean } = {};
filterMode: FilterMode = 'and';
menuOpen: boolean = false; menuOpen: boolean = false;
constructor( constructor(
@ -29,15 +30,16 @@ export class BlockFiltersComponent implements OnInit, OnChanges, OnDestroy {
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
this.filterSubscription = this.stateService.activeGoggles$.subscribe((activeFilters: string[]) => { this.filterSubscription = this.stateService.activeGoggles$.subscribe((active: ActiveFilter) => {
this.filterMode = active.mode;
for (const key of Object.keys(this.filterFlags)) { for (const key of Object.keys(this.filterFlags)) {
this.filterFlags[key] = false; this.filterFlags[key] = false;
} }
for (const key of activeFilters) { for (const key of active.filters) {
this.filterFlags[key] = !this.disabledFilters[key]; this.filterFlags[key] = !this.disabledFilters[key];
} }
this.activeFilters = [...activeFilters.filter(key => !this.disabledFilters[key])]; this.activeFilters = [...active.filters.filter(key => !this.disabledFilters[key])];
this.onFilterChanged.emit(this.getBooleanFlags()); this.onFilterChanged.emit({ mode: active.mode, filters: this.activeFilters });
}); });
} }
@ -53,6 +55,12 @@ export class BlockFiltersComponent implements OnInit, OnChanges, OnDestroy {
} }
} }
setFilterMode(mode): void {
this.filterMode = mode;
this.onFilterChanged.emit({ mode: this.filterMode, filters: this.activeFilters });
this.stateService.activeGoggles$.next({ mode: this.filterMode, filters: [...this.activeFilters] });
}
toggleFilter(key): void { toggleFilter(key): void {
const filter = this.filters[key]; const filter = this.filters[key];
this.filterFlags[key] = !this.filterFlags[key]; this.filterFlags[key] = !this.filterFlags[key];
@ -73,8 +81,8 @@ export class BlockFiltersComponent implements OnInit, OnChanges, OnDestroy {
this.activeFilters = this.activeFilters.filter(f => f != key); this.activeFilters = this.activeFilters.filter(f => f != key);
} }
const booleanFlags = this.getBooleanFlags(); const booleanFlags = this.getBooleanFlags();
this.onFilterChanged.emit(booleanFlags); this.onFilterChanged.emit({ mode: this.filterMode, filters: this.activeFilters });
this.stateService.activeGoggles$.next([...this.activeFilters]); this.stateService.activeGoggles$.next({ mode: this.filterMode, filters: [...this.activeFilters] });
} }
getBooleanFlags(): bigint | null { getBooleanFlags(): bigint | null {
@ -90,7 +98,7 @@ export class BlockFiltersComponent implements OnInit, OnChanges, OnDestroy {
@HostListener('document:click', ['$event']) @HostListener('document:click', ['$event'])
onClick(event): boolean { onClick(event): boolean {
// click away from menu // click away from menu
if (!event.target.closest('button')) { if (!event.target.closest('button') && !event.target.closest('label')) {
this.menuOpen = false; this.menuOpen = false;
} }
return true; return true;

View file

@ -13,6 +13,9 @@
[auditEnabled]="auditHighlighting" [auditEnabled]="auditHighlighting"
[blockConversion]="blockConversion" [blockConversion]="blockConversion"
></app-block-overview-tooltip> ></app-block-overview-tooltip>
<app-block-filters *ngIf="showFilters && filtersAvailable" [excludeFilters]="excludeFilters" [cssWidth]="cssWidth" (onFilterChanged)="setFilterFlags($event)"></app-block-filters> <app-block-filters *ngIf="webGlEnabled && showFilters && filtersAvailable" [excludeFilters]="excludeFilters" [cssWidth]="cssWidth" (onFilterChanged)="setFilterFlags($event)"></app-block-filters>
<div *ngIf="!webGlEnabled" class="placeholder">
<span i18n="webgl-disabled">Your browser does not support this feature.</span>
</div>
</div> </div>
</div> </div>

View file

@ -7,6 +7,19 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
grid-column: 1/-1; grid-column: 1/-1;
.placeholder {
display: flex;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
height: 100%;
width: 100%;
align-items: center;
justify-content: center;
}
} }
.grid-align { .grid-align {

View file

@ -9,6 +9,8 @@ import { Price } from '../../services/price.service';
import { StateService } from '../../services/state.service'; import { StateService } from '../../services/state.service';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { defaultColorFunction, setOpacity, defaultFeeColors, defaultAuditFeeColors, defaultMarginalFeeColors, defaultAuditColors } from './utils'; import { defaultColorFunction, setOpacity, defaultFeeColors, defaultAuditFeeColors, defaultMarginalFeeColors, defaultAuditColors } from './utils';
import { ActiveFilter, FilterMode, toFlags } from '../../shared/filters.utils';
import { detectWebGL } from '../../shared/graphs.utils';
const unmatchedOpacity = 0.2; const unmatchedOpacity = 0.2;
const unmatchedFeeColors = defaultFeeColors.map(c => setOpacity(c, unmatchedOpacity)); const unmatchedFeeColors = defaultFeeColors.map(c => setOpacity(c, unmatchedOpacity));
@ -42,6 +44,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
@Input() showFilters: boolean = false; @Input() showFilters: boolean = false;
@Input() excludeFilters: string[] = []; @Input() excludeFilters: string[] = [];
@Input() filterFlags: bigint | null = null; @Input() filterFlags: bigint | null = null;
@Input() filterMode: FilterMode = 'and';
@Input() blockConversion: Price; @Input() blockConversion: Price;
@Input() overrideColors: ((tx: TxView) => Color) | null = null; @Input() overrideColors: ((tx: TxView) => Color) | null = null;
@Output() txClickEvent = new EventEmitter<{ tx: TransactionStripped, keyModifier: boolean}>(); @Output() txClickEvent = new EventEmitter<{ tx: TransactionStripped, keyModifier: boolean}>();
@ -75,11 +78,14 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
filtersAvailable: boolean = true; filtersAvailable: boolean = true;
activeFilterFlags: bigint | null = null; activeFilterFlags: bigint | null = null;
webGlEnabled = true;
constructor( constructor(
readonly ngZone: NgZone, readonly ngZone: NgZone,
readonly elRef: ElementRef, readonly elRef: ElementRef,
private stateService: StateService, private stateService: StateService,
) { ) {
this.webGlEnabled = detectWebGL();
this.vertexArray = new FastVertexArray(512, TxSprite.dataSize); this.vertexArray = new FastVertexArray(512, TxSprite.dataSize);
this.searchSubscription = this.stateService.searchText$.subscribe((text) => { this.searchSubscription = this.stateService.searchText$.subscribe((text) => {
this.searchText = text; this.searchText = text;
@ -113,16 +119,17 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
if (changes.overrideColor && this.scene) { if (changes.overrideColor && this.scene) {
this.scene.setColorFunction(this.overrideColors); this.scene.setColorFunction(this.overrideColors);
} }
if ((changes.filterFlags || changes.showFilters)) { if ((changes.filterFlags || changes.showFilters || changes.filterMode)) {
this.setFilterFlags(); this.setFilterFlags();
} }
} }
setFilterFlags(flags?: bigint | null): void { setFilterFlags(goggle?: ActiveFilter): void {
this.activeFilterFlags = this.filterFlags || flags || null; this.filterMode = goggle?.mode || this.filterMode;
this.activeFilterFlags = goggle?.filters ? toFlags(goggle.filters) : this.filterFlags;
if (this.scene) { if (this.scene) {
if (flags != null) { if (this.activeFilterFlags != null && this.filtersAvailable) {
this.scene.setColorFunction(this.getFilterColorFunction(flags)); this.scene.setColorFunction(this.getFilterColorFunction(this.activeFilterFlags));
} else { } else {
this.scene.setColorFunction(this.overrideColors); this.scene.setColorFunction(this.overrideColors);
} }
@ -156,7 +163,11 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
// initialize the scene without any entry transition // initialize the scene without any entry transition
setup(transactions: TransactionStripped[]): void { setup(transactions: TransactionStripped[]): void {
this.filtersAvailable = transactions.reduce((flagSet, tx) => flagSet || tx.flags > 0, false); const filtersAvailable = transactions.reduce((flagSet, tx) => flagSet || tx.flags > 0, false);
if (filtersAvailable !== this.filtersAvailable) {
this.setFilterFlags();
}
this.filtersAvailable = filtersAvailable;
if (this.scene) { if (this.scene) {
this.scene.setup(transactions); this.scene.setup(transactions);
this.readyNextFrame = true; this.readyNextFrame = true;
@ -499,6 +510,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
} }
onTxClick(cssX: number, cssY: number, keyModifier: boolean = false) { onTxClick(cssX: number, cssY: number, keyModifier: boolean = false) {
if (this.scene) {
const x = cssX * window.devicePixelRatio; const x = cssX * window.devicePixelRatio;
const y = cssY * window.devicePixelRatio; const y = cssY * window.devicePixelRatio;
const selected = this.scene.getTxAt({ x, y }); const selected = this.scene.getTxAt({ x, y });
@ -506,6 +518,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
this.txClickEvent.emit({ tx: selected, keyModifier }); this.txClickEvent.emit({ tx: selected, keyModifier });
} }
} }
}
onTxHover(hoverId: string) { onTxHover(hoverId: string) {
this.txHoverEvent.emit(hoverId); this.txHoverEvent.emit(hoverId);
@ -523,7 +536,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
getFilterColorFunction(flags: bigint): ((tx: TxView) => Color) { getFilterColorFunction(flags: bigint): ((tx: TxView) => Color) {
return (tx: TxView) => { return (tx: TxView) => {
if ((tx.bigintFlags & flags) === flags) { if ((this.filterMode === 'and' && (tx.bigintFlags & flags) === flags) || (this.filterMode === 'or' && (flags === 0n || (tx.bigintFlags & flags) > 0n))) {
return defaultColorFunction(tx); return defaultColorFunction(tx);
} else { } else {
return defaultColorFunction( return defaultColorFunction(

View file

@ -10,6 +10,7 @@ import { BlockExtended, TransactionStripped } from '../../interfaces/node-api.in
import { ApiService } from '../../services/api.service'; import { ApiService } from '../../services/api.service';
import { seoDescriptionNetwork } from '../../shared/common.utils'; import { seoDescriptionNetwork } from '../../shared/common.utils';
import { BlockOverviewGraphComponent } from '../../components/block-overview-graph/block-overview-graph.component'; import { BlockOverviewGraphComponent } from '../../components/block-overview-graph/block-overview-graph.component';
import { ServicesApiServices } from '../../services/services-api.service';
@Component({ @Component({
selector: 'app-block-preview', selector: 'app-block-preview',
@ -42,7 +43,8 @@ export class BlockPreviewComponent implements OnInit, OnDestroy {
public stateService: StateService, public stateService: StateService,
private seoService: SeoService, private seoService: SeoService,
private openGraphService: OpenGraphService, private openGraphService: OpenGraphService,
private apiService: ApiService private apiService: ApiService,
private servicesApiService: ServicesApiServices,
) { } ) { }
ngOnInit() { ngOnInit() {
@ -134,7 +136,7 @@ export class BlockPreviewComponent implements OnInit, OnDestroy {
return of(transactions); return of(transactions);
}) })
), ),
this.stateService.env.ACCELERATOR === true && block.height > 819500 ? this.apiService.getAccelerationHistory$({ blockHash: block.id }) : of([]) this.stateService.env.ACCELERATOR === true && block.height > 819500 ? this.servicesApiService.getAccelerationHistory$({ blockHash: block.id }) : of([])
]); ]);
} }
), ),

View file

@ -16,6 +16,7 @@ import { detectWebGL } from '../../shared/graphs.utils';
import { seoDescriptionNetwork } from '../../shared/common.utils'; import { seoDescriptionNetwork } from '../../shared/common.utils';
import { PriceService, Price } from '../../services/price.service'; import { PriceService, Price } from '../../services/price.service';
import { CacheService } from '../../services/cache.service'; import { CacheService } from '../../services/cache.service';
import { ServicesApiServices } from '../../services/services-api.service';
@Component({ @Component({
selector: 'app-block', selector: 'app-block',
@ -103,6 +104,7 @@ export class BlockComponent implements OnInit, OnDestroy {
private apiService: ApiService, private apiService: ApiService,
private priceService: PriceService, private priceService: PriceService,
private cacheService: CacheService, private cacheService: CacheService,
private servicesApiService: ServicesApiServices,
) { ) {
this.webGlEnabled = detectWebGL(); this.webGlEnabled = detectWebGL();
} }
@ -329,7 +331,7 @@ export class BlockComponent implements OnInit, OnDestroy {
return of(null); return of(null);
}) })
), ),
this.stateService.env.ACCELERATOR === true && block.height > 819500 ? this.apiService.getAccelerationHistory$({ blockHash: block.id }) : of([]) this.stateService.env.ACCELERATOR === true && block.height > 819500 ? this.servicesApiService.getAccelerationHistory$({ blockHash: block.id }) : of([])
]); ]);
}) })
) )

View file

@ -26,7 +26,7 @@
</div> </div>
<ng-template #emptyfees> <ng-template #emptyfees>
<div [attr.data-cy]="'bitcoin-block-offset=' + offset + '-index-' + i + '-fees'" class="fees"> <div [attr.data-cy]="'bitcoin-block-offset=' + offset + '-index-' + i + '-fees'" class="fees">
&nbsp; <app-fee-rate unitClass=""></app-fee-rate>
</div> </div>
</ng-template> </ng-template>
<div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-fee-span'" class="fee-span" <div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-fee-span'" class="fee-span"
@ -37,7 +37,7 @@
</div> </div>
<ng-template #emptyfeespan> <ng-template #emptyfeespan>
<div [attr.data-cy]="'bitcoin-block-offset=' + offset + '-index-' + i + '-fees'" class="fee-span"> <div [attr.data-cy]="'bitcoin-block-offset=' + offset + '-index-' + i + '-fees'" class="fee-span">
&nbsp; <app-fee-rate unitClass=""></app-fee-rate>
</div> </div>
</ng-template> </ng-template>
<div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-total-fees'" *ngIf="showMiningInfo" <div [attr.data-cy]="'bitcoin-block-' + offset + '-index-' + i + '-total-fees'" *ngIf="showMiningInfo"

View file

@ -92,21 +92,18 @@
<span class="skeleton-loader" style="max-width: 75px"></span> <span class="skeleton-loader" style="max-width: 75px"></span>
</td> </td>
<td *ngIf="isMempoolModule" class="pool text-left" [ngClass]="{'widget': widget, 'legacy': !isMempoolModule}"> <td *ngIf="isMempoolModule" class="pool text-left" [ngClass]="{'widget': widget, 'legacy': !isMempoolModule}">
<span class="skeleton-loader" style="max-width: 125px"></span> <span class="skeleton-loader" style="max-width: 150px"></span>
</td> </td>
<td class="timestamp" *ngIf="!widget" [class]="isMempoolModule ? '' : 'legacy'"> <td class="timestamp" *ngIf="!widget" [class]="isMempoolModule ? '' : 'legacy'">
<span class="skeleton-loader" style="max-width: 150px"></span> <span class="skeleton-loader" style="max-width: 150px"></span>
</td> </td>
<td class="mined" *ngIf="!widget" [class]="isMempoolModule ? '' : 'legacy'">
<span class="skeleton-loader" style="max-width: 125px"></span>
</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}">
<span class="skeleton-loader" style="max-width: 75px"></span> <span class="skeleton-loader" style="max-width: 75px"></span>
</td> </td>
<td *ngIf="isMempoolModule" class="reward text-right" [ngClass]="{'widget': widget, 'legacy': !isMempoolModule}"> <td *ngIf="isMempoolModule" class="reward text-right" [ngClass]="{'widget': widget, 'legacy': !isMempoolModule}">
<span class="skeleton-loader" style="max-width: 75px"></span> <span class="skeleton-loader" style="max-width: 75px"></span>
</td> </td>
<td *ngIf="isMempoolModule && !widget" class="fees text-right" [class]="isMempoolModule ? '' : 'legacy'"> <td *ngIf="isMempoolModule && !auditAvailable || isMempoolModule && !widget" class="fees text-right" [class]="isMempoolModule ? '' : 'legacy'">
<span class="skeleton-loader" style="max-width: 75px"></span> <span class="skeleton-loader" style="max-width: 75px"></span>
</td> </td>
<td *ngIf="auditAvailable && !widget" class="fee-delta" [class]="isMempoolModule ? '' : 'legacy'"> <td *ngIf="auditAvailable && !widget" class="fee-delta" [class]="isMempoolModule ? '' : 'legacy'">

View file

@ -14,7 +14,7 @@
</div> </div>
<div class="item"> <div class="item">
<h5 class="card-title" i18n="difficulty-box.estimate">Estimate</h5> <h5 class="card-title" i18n="difficulty-box.estimate">Estimate</h5>
<div *ngIf="epochData.remainingBlocks < 1870; else recentlyAdjusted" class="card-text" [ngStyle]="{'color': epochData.colorAdjustments}"> <div class="card-text" [ngStyle]="{'color': epochData.colorAdjustments}">
<span *ngIf="epochData.change > 0; else arrowDownDifficulty" > <span *ngIf="epochData.change > 0; else arrowDownDifficulty" >
<fa-icon class="retarget-sign" [icon]="['fas', 'caret-up']" [fixedWidth]="true"></fa-icon> <fa-icon class="retarget-sign" [icon]="['fas', 'caret-up']" [fixedWidth]="true"></fa-icon>
</span> </span>
@ -24,9 +24,6 @@
{{ epochData.change | absolute | number: '1.2-2' }} {{ epochData.change | absolute | number: '1.2-2' }}
<span class="symbol">%</span> <span class="symbol">%</span>
</div> </div>
<ng-template #recentlyAdjusted>
<div class="card-text">&#8212;</div>
</ng-template>
<div class="symbol"> <div class="symbol">
<span i18n="difficulty-box.previous">Previous</span>: <span i18n="difficulty-box.previous">Previous</span>:
<span [ngStyle]="{'color': epochData.colorPreviousAdjustments}"> <span [ngStyle]="{'color': epochData.colorPreviousAdjustments}">
@ -49,13 +46,15 @@
<div class="item" *ngIf="showHalving"> <div class="item" *ngIf="showHalving">
<h5 class="card-title" i18n="difficulty-box.next-halving">Next Halving</h5> <h5 class="card-title" i18n="difficulty-box.next-halving">Next Halving</h5>
<div class="card-text" i18n-ngbTooltip="mining.average-fee" [ngbTooltip]="halvingBlocksLeft" [tooltipContext]="{ epochData: epochData }" placement="bottom"> <div class="card-text" i18n-ngbTooltip="mining.average-fee" [ngbTooltip]="halvingBlocksLeft" [tooltipContext]="{ epochData: epochData }" placement="bottom">
<span>{{ timeUntilHalving | date }}</span> <ng-container *ngTemplateOutlet="epochData.blocksUntilHalving === 1 ? blocksSingular : blocksPlural; context: {$implicit: epochData.blocksUntilHalving }"></ng-container>
<ng-template #blocksPlural let-i i18n="shared.blocks">{{ i }} <span class="shared-block">blocks</span></ng-template>
<ng-template #blocksSingular let-i i18n="shared.block">{{ i }} <span class="shared-block">block</span></ng-template>
<div class="symbol" *ngIf="blocksUntilHalving === 1; else approxTime"> <div class="symbol" *ngIf="blocksUntilHalving === 1; else approxTime">
<app-time kind="until" [time]="epochData.timeAvg + now" [fastRender]="false" [fixedRender]="true" [precision]="1" minUnit="minute"></app-time> <app-time kind="until" [time]="epochData.adjustedTimeAvg + now" [fastRender]="false" [fixedRender]="true" [precision]="1" minUnit="minute"></app-time>
</div> </div>
<ng-template #approxTime> <ng-template #approxTime>
<div class="symbol"> <div class="symbol">
<app-time kind="until" [time]="timeUntilHalving" [fastRender]="false" [fixedRender]="true" [precision]="0" [numUnits]="2" [units]="['year', 'day', 'hour', 'minute']"></app-time> <span>{{ timeUntilHalving | date }}</span>
</div> </div>
</ng-template> </ng-template>
</div> </div>

View file

@ -16,6 +16,7 @@ interface EpochProgress {
blocksUntilHalving: number; blocksUntilHalving: number;
timeUntilHalving: number; timeUntilHalving: number;
timeAvg: number; timeAvg: number;
adjustedTimeAvg: number;
} }
@Component({ @Component({
@ -85,6 +86,7 @@ export class DifficultyMiningComponent implements OnInit {
blocksUntilHalving: this.blocksUntilHalving, blocksUntilHalving: this.blocksUntilHalving,
timeUntilHalving: this.timeUntilHalving, timeUntilHalving: this.timeUntilHalving,
timeAvg: da.timeAvg, timeAvg: da.timeAvg,
adjustedTimeAvg: da.adjustedTimeAvg,
}; };
return data; return data;
}) })

View file

@ -42,7 +42,7 @@
<div class="symbol" i18n="difficulty-box.average-block-time">Average block time</div> <div class="symbol" i18n="difficulty-box.average-block-time">Average block time</div>
</div> </div>
<div class="item"> <div class="item">
<div *ngIf="epochData.remainingBlocks < 1870; else recentlyAdjusted" class="card-text bigger" [ngStyle]="{'color': epochData.colorAdjustments}"> <div class="card-text bigger" [ngStyle]="{'color': epochData.colorAdjustments}">
<span *ngIf="epochData.change > 0; else arrowDownDifficulty" > <span *ngIf="epochData.change > 0; else arrowDownDifficulty" >
<fa-icon class="retarget-sign" [icon]="['fas', 'caret-up']" [fixedWidth]="true"></fa-icon> <fa-icon class="retarget-sign" [icon]="['fas', 'caret-up']" [fixedWidth]="true"></fa-icon>
</span> </span>
@ -52,9 +52,6 @@
{{ epochData.change | absolute | number: '1.2-2' }} {{ epochData.change | absolute | number: '1.2-2' }}
<span class="symbol">%</span> <span class="symbol">%</span>
</div> </div>
<ng-template #recentlyAdjusted>
<div class="card-text">&#8212;</div>
</ng-template>
<div class="symbol"> <div class="symbol">
<span i18n="difficulty-box.previous">Previous</span>: <span i18n="difficulty-box.previous">Previous</span>:
<span [ngStyle]="{'color': epochData.colorPreviousAdjustments}"> <span [ngStyle]="{'color': epochData.colorPreviousAdjustments}">

View file

@ -19,6 +19,7 @@ interface EpochProgress {
blocksUntilHalving: number; blocksUntilHalving: number;
timeUntilHalving: number; timeUntilHalving: number;
timeAvg: number; timeAvg: number;
adjustedTimeAvg: number;
} }
type BlockStatus = 'mined' | 'behind' | 'ahead' | 'next' | 'remaining'; type BlockStatus = 'mined' | 'behind' | 'ahead' | 'next' | 'remaining';
@ -153,6 +154,7 @@ export class DifficultyComponent implements OnInit {
blocksUntilHalving, blocksUntilHalving,
timeUntilHalving, timeUntilHalving,
timeAvg: da.timeAvg, timeAvg: da.timeAvg,
adjustedTimeAvg: da.adjustedTimeAvg,
}; };
return data; return data;
}) })

View file

@ -54,7 +54,7 @@
</form> </form>
</div> </div>
<div [class]="!widget ? 'chart' : 'chart-widget'" echarts [initOpts]="chartInitOptions" [options]="chartOptions" <div [class]="!widget ? 'chart' : 'chart-widget'" [style]="{ height: widget ? ((height + 20) + 'px') : null}" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
(chartInit)="onChartInit($event)"> (chartInit)="onChartInit($event)">
</div> </div>
<div class="text-center loadingGraphs" *ngIf="isLoading"> <div class="text-center loadingGraphs" *ngIf="isLoading">

View file

@ -57,8 +57,6 @@
} }
.chart-widget { .chart-widget {
width: 100%; width: 100%;
height: 100%;
height: 240px;
} }
.pool-distribution { .pool-distribution {

View file

@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component, Inject, Input, LOCALE_ID, OnInit, HostBinding } from '@angular/core'; import { ChangeDetectionStrategy, Component, Inject, Input, LOCALE_ID, OnInit, HostBinding } from '@angular/core';
import { echarts, EChartsOption } from '../../graphs/echarts'; import { echarts, EChartsOption } from '../../graphs/echarts';
import { merge, Observable, of } from 'rxjs'; import { combineLatest, fromEvent, merge, Observable, of } from 'rxjs';
import { map, mergeMap, share, startWith, switchMap, tap } from 'rxjs/operators'; import { map, mergeMap, share, startWith, switchMap, tap } from 'rxjs/operators';
import { ApiService } from '../../services/api.service'; import { ApiService } from '../../services/api.service';
import { SeoService } from '../../services/seo.service'; import { SeoService } from '../../services/seo.service';
@ -31,6 +31,7 @@ import { seoDescriptionNetwork } from '../../shared/common.utils';
export class HashrateChartComponent implements OnInit { export class HashrateChartComponent implements OnInit {
@Input() tableOnly = false; @Input() tableOnly = false;
@Input() widget = false; @Input() widget = false;
@Input() height: number = 300;
@Input() right: number | string = 45; @Input() right: number | string = 45;
@Input() left: number | string = 75; @Input() left: number | string = 75;
@ -86,7 +87,8 @@ export class HashrateChartComponent implements OnInit {
} }
}); });
this.hashrateObservable$ = merge( this.hashrateObservable$ = combineLatest(
merge(
this.radioGroupForm.get('dateSpan').valueChanges this.radioGroupForm.get('dateSpan').valueChanges
.pipe( .pipe(
startWith(this.radioGroupForm.controls.dateSpan.value), startWith(this.radioGroupForm.controls.dateSpan.value),
@ -107,7 +109,10 @@ export class HashrateChartComponent implements OnInit {
return this.apiService.getHistoricalHashrate$(this.timespan); return this.apiService.getHistoricalHashrate$(this.timespan);
}) })
) )
),
fromEvent(window, 'resize').pipe(startWith(null)),
).pipe( ).pipe(
map(([response, _]) => response),
tap((response: any) => { tap((response: any) => {
const data = response.body; const data = response.body;
@ -221,6 +226,7 @@ export class HashrateChartComponent implements OnInit {
]), ]),
], ],
grid: { grid: {
height: (this.widget && this.height) ? this.height - 30 : undefined,
top: this.widget ? 20 : 40, top: this.widget ? 20 : 40,
bottom: this.widget ? 30 : 70, bottom: this.widget ? 30 : 70,
right: this.right, right: this.right,

View file

@ -11,6 +11,13 @@ import { MiningService } from '../../services/mining.service';
import { download } from '../../shared/graphs.utils'; import { download } from '../../shared/graphs.utils';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
interface Hashrate {
timestamp: number;
avgHashRate: number;
share: number;
poolName: string;
}
@Component({ @Component({
selector: 'app-hashrate-chart-pools', selector: 'app-hashrate-chart-pools',
templateUrl: './hashrate-chart-pools.component.html', templateUrl: './hashrate-chart-pools.component.html',
@ -32,6 +39,7 @@ export class HashrateChartPoolsComponent implements OnInit {
miningWindowPreference: string; miningWindowPreference: string;
radioGroupForm: UntypedFormGroup; radioGroupForm: UntypedFormGroup;
hashrates: Hashrate[];
chartOptions: EChartsOption = {}; chartOptions: EChartsOption = {};
chartInitOptions = { chartInitOptions = {
renderer: 'svg', renderer: 'svg',
@ -87,26 +95,66 @@ export class HashrateChartPoolsComponent implements OnInit {
return this.apiService.getHistoricalPoolsHashrate$(timespan) return this.apiService.getHistoricalPoolsHashrate$(timespan)
.pipe( .pipe(
tap((response) => { tap((response) => {
const hashrates = response.body; this.hashrates = response.body;
// Prepare series (group all hashrates data point by pool) // Prepare series (group all hashrates data point by pool)
const grouped = {}; const series = this.applyHashrates();
for (const hashrate of hashrates) { if (series.length === 0) {
if (!grouped.hasOwnProperty(hashrate.poolName)) { this.cd.markForCheck();
grouped[hashrate.poolName] = []; throw new Error();
} }
grouped[hashrate.poolName].push(hashrate); }),
map((response) => {
return {
blockCount: parseInt(response.headers.get('x-total-count'), 10),
} }
}),
retryWhen((errors) => errors.pipe(
delay(60000)
))
);
}),
share()
);
}
applyHashrates(): any[] {
const times: { [time: number]: { hashrates: { [pool: string]: Hashrate } } } = {};
const pools = {};
for (const hashrate of this.hashrates) {
if (!times[hashrate.timestamp]) {
times[hashrate.timestamp] = { hashrates: {} };
}
times[hashrate.timestamp].hashrates[hashrate.poolName] = hashrate;
if (!pools[hashrate.poolName]) {
pools[hashrate.poolName] = true;
}
}
const sortedTimes = Object.keys(times).sort((a,b) => parseInt(a) - parseInt(b)).map(time => ({ time: parseInt(time), hashrates: times[time].hashrates }));
const lastHashrates = sortedTimes[sortedTimes.length - 1].hashrates;
const sortedPools = Object.keys(pools).sort((a,b) => {
if (lastHashrates[b]?.share ?? lastHashrates[a]?.share ?? false) {
// sort by descending share of hashrate in latest period
return (lastHashrates[b]?.share || 0) - (lastHashrates[a]?.share || 0);
} else {
// tiebreak by pool name
b < a;
}
});
const series = []; const series = [];
const legends = []; const legends = [];
for (const name in grouped) { for (const name of sortedPools) {
const data = sortedTimes.map(({ time, hashrates }) => {
return [time * 1000, (hashrates[name]?.share || 0) * 100];
});
series.push({ series.push({
zlevel: 0, zlevel: 0,
stack: 'Total', stack: 'Total',
name: name, name: name,
showSymbol: false, showSymbol: false,
symbol: 'none', symbol: 'none',
data: grouped[name].map((val) => [val.timestamp * 1000, val.share * 100]), data,
type: 'line', type: 'line',
lineStyle: { width: 0 }, lineStyle: { width: 0 },
areaStyle: { opacity: 1 }, areaStyle: { opacity: 1 },
@ -137,23 +185,7 @@ export class HashrateChartPoolsComponent implements OnInit {
}); });
this.isLoading = false; this.isLoading = false;
if (series.length === 0) { return series;
this.cd.markForCheck();
throw new Error();
}
}),
map((response) => {
return {
blockCount: parseInt(response.headers.get('x-total-count'), 10),
}
}),
retryWhen((errors) => errors.pipe(
delay(60000)
))
);
}),
share()
);
} }
prepareChartOptions(data) { prepareChartOptions(data) {
@ -256,6 +288,7 @@ export class HashrateChartPoolsComponent implements OnInit {
}, },
}], }],
}; };
this.cd.markForCheck();
} }
onChartInit(ec) { onChartInit(ec) {

View file

@ -18,16 +18,15 @@ import { EChartsOption } from '../../graphs/echarts';
}) })
export class LbtcPegsGraphComponent implements OnInit, OnChanges { export class LbtcPegsGraphComponent implements OnInit, OnChanges {
@Input() data: any; @Input() data: any;
@Input() height: number | string = '320';
pegsChartOptions: EChartsOption; pegsChartOptions: EChartsOption;
height: number | string = '200';
right: number | string = '10'; right: number | string = '10';
top: number | string = '20'; top: number | string = '20';
left: number | string = '50'; left: number | string = '50';
template: ('widget' | 'advanced') = 'widget'; template: ('widget' | 'advanced') = 'widget';
isLoading = true; isLoading = true;
pegsChartOption: EChartsOption = {};
pegsChartInitOption = { pegsChartInitOption = {
renderer: 'svg' renderer: 'svg'
}; };
@ -41,20 +40,24 @@ export class LbtcPegsGraphComponent implements OnInit, OnChanges {
} }
ngOnChanges() { ngOnChanges() {
if (!this.data) { if (!this.data?.liquidPegs) {
return; return;
} }
this.pegsChartOptions = this.createChartOptions(this.data.series, this.data.labels); if (!this.data.liquidReserves) {
this.pegsChartOptions = this.createChartOptions(this.data.liquidPegs.series, this.data.liquidPegs.labels);
} else {
this.pegsChartOptions = this.createChartOptions(this.data.liquidPegs.series, this.data.liquidPegs.labels, this.data.liquidReserves.series);
}
} }
rendered() { rendered() {
if (!this.data) { if (!this.data.liquidPegs) {
return; return;
} }
this.isLoading = false; this.isLoading = false;
} }
createChartOptions(series: number[], labels: string[]): EChartsOption { createChartOptions(pegSeries: number[], labels: string[], reservesSeries?: number[],): EChartsOption {
return { return {
grid: { grid: {
height: this.height, height: this.height,
@ -99,17 +102,18 @@ export class LbtcPegsGraphComponent implements OnInit, OnChanges {
type: 'line', type: 'line',
}, },
formatter: (params: any) => { formatter: (params: any) => {
const colorSpan = (color: string) => `<span class="indicator" style="background-color: #116761;"></span>`; const colorSpan = (color: string) => `<span class="indicator" style="background-color: ${color};"></span>`;
let itemFormatted = '<div class="title">' + params[0].axisValue + '</div>'; let itemFormatted = '<div class="title">' + params[0].axisValue + '</div>';
params.map((item: any, index: number) => { for (let index = params.length - 1; index >= 0; index--) {
const item = params[index];
if (index < 26) { if (index < 26) {
itemFormatted += `<div class="item"> itemFormatted += `<div class="item">
<div class="indicator-container">${colorSpan(item.color)}</div> <div class="indicator-container">${colorSpan(item.color)}</div>
<div class="grow"></div> <div style="margin-right: 5px"></div>
<div class="value">${formatNumber(item.value, this.locale, '1.2-2')} <span class="symbol">L-BTC</span></div> <div class="value">${formatNumber(item.value, this.locale, '1.2-2')} <span class="symbol">${item.seriesName}</span></div>
</div>`; </div>`;
} }
}); }
return `<div class="tx-wrapper-tooltip-chart ${(this.template === 'advanced') ? 'tx-wrapper-tooltip-chart-advanced' : ''}">${itemFormatted}</div>`; return `<div class="tx-wrapper-tooltip-chart ${(this.template === 'advanced') ? 'tx-wrapper-tooltip-chart-advanced' : ''}">${itemFormatted}</div>`;
} }
}, },
@ -138,20 +142,34 @@ export class LbtcPegsGraphComponent implements OnInit, OnChanges {
}, },
series: [ series: [
{ {
data: series, data: pegSeries,
name: 'L-BTC',
color: '#116761',
type: 'line', type: 'line',
stack: 'total', stack: 'total',
smooth: false, smooth: true,
showSymbol: false, showSymbol: false,
areaStyle: { areaStyle: {
opacity: 0.2, opacity: 0.2,
color: '#116761', color: '#116761',
}, },
lineStyle: { lineStyle: {
width: 3, width: 2,
color: '#116761', color: '#116761',
}, },
}, },
{
data: reservesSeries,
name: 'BTC',
color: '#EA983B',
type: 'line',
smooth: true,
showSymbol: false,
lineStyle: {
width: 2,
color: '#EA983B',
},
},
], ],
}; };
} }

View file

@ -78,7 +78,7 @@
<li class="nav-item" routerLinkActive="active" id="btn-assets"> <li class="nav-item" routerLinkActive="active" id="btn-assets">
<a class="nav-link" [routerLink]="['/assets' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'database']" [fixedWidth]="true" i18n-title="master-page.assets" title="Assets"></fa-icon></a> <a class="nav-link" [routerLink]="['/assets' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'database']" [fixedWidth]="true" i18n-title="master-page.assets" title="Assets"></fa-icon></a>
</li> </li>
<li [hidden]="isMobile" class="nav-item mr-2" routerLinkActive="active" id="btn-docs"> <li class="nav-item mr-2" routerLinkActive="active" id="btn-docs">
<a class="nav-link" [routerLink]="['/docs' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'book']" [fixedWidth]="true" i18n-title="master-page.docs" title="Docs"></fa-icon></a> <a class="nav-link" [routerLink]="['/docs' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'book']" [fixedWidth]="true" i18n-title="master-page.docs" title="Docs"></fa-icon></a>
</li> </li>
<li class="nav-item" routerLinkActive="active" id="btn-about"> <li class="nav-item" routerLinkActive="active" id="btn-about">

View file

@ -23,6 +23,11 @@ li.nav-item {
margin: auto 10px; margin: auto 10px;
padding-left: 10px; padding-left: 10px;
padding-right: 10px; padding-right: 10px;
@media (max-width: 429px) {
margin: auto 5px;
padding-left: 6px;
padding-right: 6px;
}
} }
@media (min-width: 992px) { @media (min-width: 992px) {

View file

@ -0,0 +1,72 @@
<div [ngClass]="{'widget': widget}">
<div class="clearfix"></div>
<div style="min-height: 295px">
<table class="table table-borderless">
<thead style="vertical-align: middle;">
<th class="address text-left" [ngClass]="{'widget': widget}" i18n="shared.address">Address</th>
<th class="amount text-right" [ngClass]="{'widget': widget}" i18n="address.balance">Balance</th>
</thead>
<tbody *ngIf="federationAddresses$ | async as addresses; else skeleton" [style]="isLoading ? 'opacity: 0.75' : ''">
<ng-container *ngIf="widget; else regularRows">
<tr *ngFor="let address of addresses | slice:0:5">
<td class="address text-left widget">
<a href="{{ env.MEMPOOL_WEBSITE_URL + '/address/' + address.bitcoinaddress }}" target="_blank" style="color:#b86d12">
<app-truncate [text]="address.bitcoinaddress" [lastChars]="6"></app-truncate>
</a>
</td>
<td class="amount text-right widget">
<app-amount [satoshis]="+address.balance" [noFiat]="true" [forceBtc]="true"></app-amount>
</td>
</tr>
</ng-container>
<ng-template #regularRows>
<tr *ngFor="let address of addresses | slice:(page - 1) * pageSize:page * pageSize">
<td class="address text-left">
<a href="{{ env.MEMPOOL_WEBSITE_URL + '/address/' + address.bitcoinaddress }}" target="_blank" style="color:#b86d12">
<app-truncate [text]="address.bitcoinaddress" [lastChars]="6"></app-truncate>
</a>
</td>
<td class="amount text-right">
<app-amount [satoshis]="+address.balance" [noFiat]="true" [forceBtc]="true"></app-amount>
</td>
</tr>
</ng-template>
</tbody>
<ng-template #skeleton>
<tbody *ngIf="widget; else regularRowsSkeleton">
<tr *ngFor="let item of skeletonLines">
<td class="address text-left widget">
<span class="skeleton-loader" style="max-width: 400px"></span>
</td>
<td class="amount text-right widget">
<span class="skeleton-loader" style="max-width: 350px"></span>
</td>
</tr>
</tbody>
<ng-template #regularRowsSkeleton>
<tr *ngFor="let item of skeletonLines">
<td class="address text-left">
<span class="skeleton-loader" style="max-width: 600px"></span>
</td>
<td class="amount text-right">
<span class="skeleton-loader" style="max-width: 400px"></span>
</td>
</tr>
</ng-template>
</ng-template>
</table>
<ngb-pagination *ngIf="!widget && federationAddresses$ | async as addresses" class="pagination-container float-right mt-2" [class]="isLoading ? 'disabled' : ''"
[collectionSize]="addresses.length" [rotate]="true" [maxSize]="maxSize" [pageSize]="15" [(page)]="page"
(pageChange)="pageChange(page)" [boundaryLinks]="true" [ellipses]="false">
</ngb-pagination>
<ng-template [ngIf]="!widget">
<div class="clearfix"></div>
<br>
</ng-template>
</div>
</div>

View file

@ -0,0 +1,48 @@
.spinner-border {
height: 25px;
width: 25px;
margin-top: 13px;
}
tr, td, th {
border: 0px;
padding-top: 0.65rem;
padding-bottom: 0.6rem;
padding-right: 2rem;
.widget &.widget {
padding-right: 1rem;
@media (max-width: 510px) {
padding-right: 0.5rem;
}
}
}
.clear-link {
color: white;
}
.disabled {
pointer-events: none;
opacity: 0.5;
}
.progress {
background-color: #2d3348;
}
.address {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 160px;
}
.address.widget {
width: 60%;
}
.amount {
width: 25%;
}
.amount.widget {
width: 40%;
}

View file

@ -0,0 +1,109 @@
import { Component, OnInit, ChangeDetectionStrategy, Input } from '@angular/core';
import { Observable, Subject, combineLatest, of, timer } from 'rxjs';
import { delayWhen, filter, map, share, shareReplay, switchMap, takeUntil, tap, throttleTime } from 'rxjs/operators';
import { ApiService } from '../../../services/api.service';
import { Env, StateService } from '../../../services/state.service';
import { AuditStatus, CurrentPegs, FederationAddress } from '../../../interfaces/node-api.interface';
import { WebsocketService } from '../../../services/websocket.service';
@Component({
selector: 'app-federation-addresses-list',
templateUrl: './federation-addresses-list.component.html',
styleUrls: ['./federation-addresses-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FederationAddressesListComponent implements OnInit {
@Input() widget: boolean = false;
@Input() federationAddresses$: Observable<FederationAddress[]>;
env: Env;
isLoading = true;
page = 1;
pageSize = 15;
maxSize = window.innerWidth <= 767.98 ? 3 : 5;
skeletonLines: number[] = [];
auditStatus$: Observable<AuditStatus>;
auditUpdated$: Observable<boolean>;
lastReservesBlockUpdate: number = 0;
currentPeg$: Observable<CurrentPegs>;
lastPegBlockUpdate: number = 0;
lastPegAmount: string = '';
isLoad: boolean = true;
private destroy$ = new Subject();
constructor(
private apiService: ApiService,
public stateService: StateService,
private websocketService: WebsocketService
) {
}
ngOnInit(): void {
this.isLoading = !this.widget;
this.env = this.stateService.env;
this.skeletonLines = this.widget === true ? [...Array(5).keys()] : [...Array(15).keys()];
if (!this.widget) {
this.websocketService.want(['blocks']);
this.auditStatus$ = this.stateService.blocks$.pipe(
takeUntil(this.destroy$),
throttleTime(40000),
delayWhen(_ => this.isLoad ? timer(0) : timer(2000)),
tap(() => this.isLoad = false),
switchMap(() => this.apiService.federationAuditSynced$()),
shareReplay(1)
);
this.currentPeg$ = this.auditStatus$.pipe(
filter(auditStatus => auditStatus.isAuditSynced === true),
switchMap(_ =>
this.apiService.liquidPegs$().pipe(
filter((currentPegs) => currentPegs.lastBlockUpdate >= this.lastPegBlockUpdate),
tap((currentPegs) => {
this.lastPegBlockUpdate = currentPegs.lastBlockUpdate;
})
)
),
share()
);
this.auditUpdated$ = combineLatest([
this.auditStatus$,
this.currentPeg$
]).pipe(
filter(([auditStatus, _]) => auditStatus.isAuditSynced === true),
map(([auditStatus, currentPeg]) => ({
lastBlockAudit: auditStatus.lastBlockAudit,
currentPegAmount: currentPeg.amount
})),
switchMap(({ lastBlockAudit, currentPegAmount }) => {
const blockAuditCheck = lastBlockAudit > this.lastReservesBlockUpdate;
const amountCheck = currentPegAmount !== this.lastPegAmount;
this.lastReservesBlockUpdate = lastBlockAudit;
this.lastPegAmount = currentPegAmount;
return of(blockAuditCheck || amountCheck);
}),
share()
);
this.federationAddresses$ = this.auditUpdated$.pipe(
filter(auditUpdated => auditUpdated === true),
throttleTime(40000),
switchMap(_ => this.apiService.federationAddresses$()),
tap(_ => this.isLoading = false),
share()
);
}
}
ngOnDestroy(): void {
this.destroy$.next(1);
this.destroy$.complete();
}
pageChange(page: number): void {
this.page = page;
}
}

View file

@ -0,0 +1,31 @@
<div *ngIf="(federationWalletStats$ | async) as federationWalletStats; else loadingData">
<div class="fee-estimation-container">
<div class="item">
<a class="title-link" [routerLink]="['/audit/wallet/addresses' | relativeUrl]">
<h5 class="card-title"><ng-container i18n="liquid.federation-wallet">Liquid Federation Wallet</ng-container>&nbsp;<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="font-size: 13px; color: #4a68b9"></fa-icon></h5>
</a>
<div class="card-text">
<div class="fee-text">{{ federationWalletStats.address_count }} <span i18n="shared.addresses">addresses</span></div>
<div class="fiat">{{ federationWalletStats.utxo_count }} <span i18n="shared.utxos">UTXOs</span></div>
</div>
</div>
</div>
</div>
<ng-template #loadingData>
<div class="fee-estimation-container loading-container">
<div class="item">
<a class="title-link" [routerLink]="['/audit/wallet/addresses' | relativeUrl]">
<h5 class="card-title"><ng-container i18n="liquid.federation-wallet">Liquid Federation Wallet</ng-container>&nbsp;<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="font-size: 13px; color: #4a68b9"></fa-icon></h5>
</a>
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
</div>
</div>
</div>
</ng-template>
<ng-template #loadingSkeleton>
<div class="skeleton-loader skeleton-loader-transactions" style="margin-top: 8px; margin-bottom: 8px;"></div>
</ng-template>

View file

@ -0,0 +1,73 @@
.fee-estimation-container {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
@media (min-width: 376px) {
flex-direction: row;
}
.item {
max-width: 300px;
margin: 0;
width: -webkit-fill-available;
@media (min-width: 376px) {
margin: 0 auto 0px;
}
.card-title {
margin: 0;
color: #4a68b9;
font-size: 10px;
font-size: 1rem;
white-space: nowrap;
}
.card-text {
padding-top: 9px;
font-size: 22px;
span {
font-size: 11px;
position: relative;
top: -2px;
}
}
.card-text span {
color: #ffffff66;
font-size: 12px;
top: 0px;
}
.fee-text{
border-bottom: 1px solid #ffffff1c;
width: fit-content;
margin: auto;
line-height: 1.45;
padding: 0px 2px;
}
.fiat {
display: block;
font-size: 14px !important;
}
}
}
.card-text {
.skeleton-loader {
width: 100%;
display: block;
&:first-child {
max-width: 90px;
margin: 15px auto 3px;
}
&:last-child {
margin: 10px auto 3px;
max-width: 55px;
}
}
}
.title-link, .title-link:hover, .title-link:focus, .title-link:active {
display: block;
margin-bottom: 4px;
text-decoration: none;
color: inherit;
}

View file

@ -0,0 +1,31 @@
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
import { Observable, combineLatest, map, of } from 'rxjs';
@Component({
selector: 'app-federation-addresses-stats',
templateUrl: './federation-addresses-stats.component.html',
styleUrls: ['./federation-addresses-stats.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FederationAddressesStatsComponent implements OnInit {
@Input() federationAddressesNumber$: Observable<number>;
@Input() federationUtxosNumber$: Observable<number>;
federationWalletStats$: Observable<any>;
constructor() { }
ngOnInit(): void {
this.federationWalletStats$ = combineLatest([
this.federationAddressesNumber$ ?? of(undefined),
this.federationUtxosNumber$ ?? of(undefined)
]).pipe(
map(([address_count, utxo_count]) => {
if (address_count === undefined || utxo_count === undefined) {
return undefined;
}
return { address_count, utxo_count}
})
)
}
}

View file

@ -0,0 +1,109 @@
<div [ngClass]="{'widget': widget}">
<div class="clearfix"></div>
<div style="min-height: 295px">
<table class="table table-borderless">
<thead style="vertical-align: middle;">
<th class="txid text-left" [ngClass]="{'widget': widget}" i18n="transaction.output">Output</th>
<th class="address text-left" *ngIf="!widget" i18n="shared.address">Address</th>
<th class="amount text-right" [ngClass]="{'widget': widget}" i18n="shared.amount">Amount</th>
<th class="pegin text-left" *ngIf="!widget" i18n="liquid.related-peg-in">Related Peg-In</th>
<th class="timestamp text-left" i18n="shared.date" [ngClass]="{'widget': widget}">Date</th>
</thead>
<tbody *ngIf="federationUtxos$ | async as utxos; else skeleton" [style]="isLoading ? 'opacity: 0.75' : ''">
<ng-container *ngIf="widget; else regularRows">
<tr *ngFor="let utxo of utxos | slice:0:6">
<td class="txid text-left widget">
<a href="{{ env.MEMPOOL_WEBSITE_URL + '/tx/' + utxo.txid + ':' + utxo.txindex }}" target="_blank" style="color:#b86d12">
<app-truncate [text]="utxo.txid + ':' + utxo.txindex" [lastChars]="6"></app-truncate>
</a>
</td>
<td class="amount text-right widget">
<app-amount [satoshis]="utxo.amount" [noFiat]="true" [forceBtc]="true"></app-amount>
</td>
<td class="timestamp text-left widget">
<app-time kind="since" [time]="utxo.blocktime"></app-time>
</td>
</tr>
</ng-container>
<ng-template #regularRows>
<tr *ngFor="let utxo of utxos | slice:(page - 1) * pageSize:page * pageSize">
<td class="txid text-left">
<a href="{{ env.MEMPOOL_WEBSITE_URL + '/tx/' + utxo.txid + ':' + utxo.txindex }}" target="_blank" style="color:#b86d12">
<app-truncate [text]="utxo.txid + ':' + utxo.txindex" [lastChars]="6"></app-truncate>
</a>
</td>
<td class="address text-left">
<a href="{{ env.MEMPOOL_WEBSITE_URL + '/address/' + utxo.bitcoinaddress }}" target="_blank" style="color:#b86d12">
<app-truncate [text]="utxo.bitcoinaddress" [lastChars]="6"></app-truncate>
</a>
</td>
<td class="amount text-right">
<app-amount [satoshis]="utxo.amount" [noFiat]="true" [forceBtc]="true"></app-amount>
</td>
<td class="pegin text-left">
<ng-container *ngIf="utxo.pegtxid; else noPeginMessage">
<a [routerLink]="['/tx' | relativeUrl, utxo.pegtxid]" [fragment]="'vin=' + utxo.pegindex">
<app-truncate [text]="utxo.pegtxid + ':' + utxo.pegindex" [lastChars]="6"></app-truncate>
</a>
</ng-container>
<ng-template #noPeginMessage>
<i><span class="text-muted" i18n="liquid.change-output">Change output</span></i>
</ng-template>
</td>
<td class="timestamp text-left">
&lrm;{{ utxo.blocktime * 1000 | date:'yyyy-MM-dd HH:mm' }}
<div class="symbol lg-inline relative-time"><i>(<app-time kind="since" [time]="utxo.blocktime"></app-time>)</i></div>
</td>
</tr>
</ng-template>
</tbody>
<ng-template #skeleton>
<tbody *ngIf="widget; else regularRowsSkeleton">
<tr *ngFor="let item of skeletonLines">
<td class="txid text-left widget">
<span class="skeleton-loader" style="max-width: 400px"></span>
</td>
<td class="amount text-right widget">
<span class="skeleton-loader" style="max-width: 300px"></span>
</td>
<td class="timestamp text-left widget">
<span class="skeleton-loader" style="max-width: 300px"></span>
</td>
</tr>
</tbody>
<ng-template #regularRowsSkeleton>
<tr *ngFor="let item of skeletonLines">
<td class="txid text-left">
<span class="skeleton-loader" style="max-width: 300px"></span>
</td>
<td class="address text-left">
<span class="skeleton-loader" style="max-width: 300px"></span>
</td>
<td class="amount text-right">
<span class="skeleton-loader" style="max-width: 140px"></span>
</td>
<td class="pegin text-left">
<span class="skeleton-loader" style="max-width: 300px"></span>
</td>
<td class="timestamp text-left">
<span class="skeleton-loader" style="max-width: 140px"></span>
</td>
</tr>
</ng-template>
</ng-template>
</table>
<ngb-pagination *ngIf="!widget && federationUtxos$ | async as utxos" class="pagination-container float-right mt-2" [class]="isLoading ? 'disabled' : ''"
[collectionSize]="utxos.length" [rotate]="true" [maxSize]="maxSize" [pageSize]="15" [(page)]="page"
(pageChange)="pageChange(page)" [boundaryLinks]="true" [ellipses]="false">
</ngb-pagination>
<ng-template [ngIf]="!widget">
<div class="clearfix"></div>
<br>
</ng-template>
</div>
</div>

View file

@ -0,0 +1,94 @@
.spinner-border {
height: 25px;
width: 25px;
margin-top: 13px;
}
tr, td, th {
border: 0px;
padding-top: 0.65rem !important;
padding-bottom: 0.6rem !important;
padding-right: 2rem !important;
.widget {
padding-right: 1rem !important;
}
}
.clear-link {
color: white;
}
.disabled {
pointer-events: none;
opacity: 0.5;
}
.progress {
background-color: #2d3348;
}
.txid {
width: 25%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 160px;
}
.txid.widget {
width: 40%;
}
.address {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 160px;
@media (max-width: 527px) {
display: none;
}
}
.amount {
width: 12%;
}
.amount.widget {
width: 30%;
}
.pegin {
width: 25%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 160px;
@media (max-width: 872px) {
display: none;
}
}
.timestamp {
width: 18%;
@media (max-width: 800px) {
display: none;
}
@media (max-width: 1000px) {
.relative-time {
display: none;
}
}
}
.timestamp.widget {
width: 100%;
@media (min-width: 768px) AND (max-width: 1050px) {
display: none;
}
@media (max-width: 767px) {
display: block;
}
@media (max-width: 500px) {
display: none;
}
}

View file

@ -0,0 +1,109 @@
import { Component, OnInit, ChangeDetectionStrategy, Input } from '@angular/core';
import { Observable, Subject, combineLatest, of, timer } from 'rxjs';
import { delayWhen, filter, map, share, shareReplay, switchMap, takeUntil, tap, throttleTime } from 'rxjs/operators';
import { ApiService } from '../../../services/api.service';
import { Env, StateService } from '../../../services/state.service';
import { AuditStatus, CurrentPegs, FederationUtxo } from '../../../interfaces/node-api.interface';
import { WebsocketService } from '../../../services/websocket.service';
@Component({
selector: 'app-federation-utxos-list',
templateUrl: './federation-utxos-list.component.html',
styleUrls: ['./federation-utxos-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FederationUtxosListComponent implements OnInit {
@Input() widget: boolean = false;
@Input() federationUtxos$: Observable<FederationUtxo[]>;
env: Env;
isLoading = true;
page = 1;
pageSize = 15;
maxSize = window.innerWidth <= 767.98 ? 3 : 5;
skeletonLines: number[] = [];
auditStatus$: Observable<AuditStatus>;
auditUpdated$: Observable<boolean>;
lastReservesBlockUpdate: number = 0;
currentPeg$: Observable<CurrentPegs>;
lastPegBlockUpdate: number = 0;
lastPegAmount: string = '';
isLoad: boolean = true;
private destroy$ = new Subject();
constructor(
private apiService: ApiService,
public stateService: StateService,
private websocketService: WebsocketService,
) {
}
ngOnInit(): void {
this.isLoading = !this.widget;
this.env = this.stateService.env;
this.skeletonLines = this.widget === true ? [...Array(6).keys()] : [...Array(15).keys()];
if (!this.widget) {
this.websocketService.want(['blocks']);
this.auditStatus$ = this.stateService.blocks$.pipe(
takeUntil(this.destroy$),
throttleTime(40000),
delayWhen(_ => this.isLoad ? timer(0) : timer(2000)),
tap(() => this.isLoad = false),
switchMap(() => this.apiService.federationAuditSynced$()),
shareReplay(1)
);
this.currentPeg$ = this.auditStatus$.pipe(
filter(auditStatus => auditStatus.isAuditSynced === true),
switchMap(_ =>
this.apiService.liquidPegs$().pipe(
filter((currentPegs) => currentPegs.lastBlockUpdate >= this.lastPegBlockUpdate),
tap((currentPegs) => {
this.lastPegBlockUpdate = currentPegs.lastBlockUpdate;
})
)
),
share()
);
this.auditUpdated$ = combineLatest([
this.auditStatus$,
this.currentPeg$
]).pipe(
filter(([auditStatus, _]) => auditStatus.isAuditSynced === true),
map(([auditStatus, currentPeg]) => ({
lastBlockAudit: auditStatus.lastBlockAudit,
currentPegAmount: currentPeg.amount
})),
switchMap(({ lastBlockAudit, currentPegAmount }) => {
const blockAuditCheck = lastBlockAudit > this.lastReservesBlockUpdate;
const amountCheck = currentPegAmount !== this.lastPegAmount;
this.lastReservesBlockUpdate = lastBlockAudit;
this.lastPegAmount = currentPegAmount;
return of(blockAuditCheck || amountCheck);
}),
share()
);
this.federationUtxos$ = this.auditUpdated$.pipe(
filter(auditUpdated => auditUpdated === true),
throttleTime(40000),
switchMap(_ => this.apiService.federationUtxos$()),
tap(_ => this.isLoading = false),
share()
);
}
}
ngOnDestroy(): void {
this.destroy$.next(1);
this.destroy$.complete();
}
pageChange(page: number): void {
this.page = page;
}
}

View file

@ -0,0 +1,24 @@
<div class="container-xl">
<div>
<h1 i18n="liquid.federation-wallet">Liquid Federation Wallet</h1>
</div>
<div class="nav-container">
<ul class="nav nav-pills">
<li class="nav-item">
<a class="nav-link" [routerLink]="['/audit/wallet/utxos' | relativeUrl]" routerLinkActive="active">UTXOs</a>
</li>
<li class="nav-item">
<a class="nav-link" [routerLink]="['/audit/wallet/addresses' | relativeUrl]" routerLinkActive="active"><ng-container i18n="mining.addresses">Addresses</ng-container></a>
</li>
</ul>
</div>
<div class="clearfix"></div>
<router-outlet></router-outlet>
</div>
<br>

View file

@ -0,0 +1,13 @@
ul {
margin-bottom: 20px;
}
@media (max-width: 767.98px) {
.nav-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: auto;
}
}

View file

@ -0,0 +1,20 @@
import { Component, OnInit } from '@angular/core';
import { SeoService } from '../../../services/seo.service';
@Component({
selector: 'app-federation-wallet',
templateUrl: './federation-wallet.component.html',
styleUrls: ['./federation-wallet.component.scss']
})
export class FederationWalletComponent implements OnInit {
constructor(
private seoService: SeoService
) {
this.seoService.setTitle($localize`:@@993e5bc509c26db81d93018e24a6afe6e50cae52:Liquid Federation Wallet`);
}
ngOnInit(): void {
}
}

View file

@ -0,0 +1,137 @@
<div [ngClass]="{'container-xl': !widget, 'widget': widget}">
<div *ngIf="!widget">
<h1 i18n="liquid.recent-pegs">Recent Peg-In / Out's</h1>
</div>
<div class="clearfix"></div>
<div style="min-height: 295px">
<table class="table table-borderless">
<thead style="vertical-align: middle;">
<th class="transaction text-left" [ngClass]="{'widget': widget}" i18n="shared.transaction">Transaction</th>
<th class="timestamp text-left" i18n="shared.date" [ngClass]="{'widget': widget}">Date</th>
<th class="amount text-right" [ngClass]="{'widget': widget}" i18n="shared.amount">Amount</th>
<th class="output text-left" *ngIf="!widget" i18n="liquid.fund-redemption-tx">Fund / Redemption Tx</th>
<th class="address text-left" *ngIf="!widget" i18n="liquid.bitcoin-address">BTC Address</th>
</thead>
<tbody *ngIf="recentPegsList$ | async as pegs; else skeleton" [style]="isLoading ? 'opacity: 0.75' : ''">
<ng-container *ngIf="widget; else regularRows">
<tr *ngFor="let peg of pegs | slice:0:5">
<td class="transaction text-left widget">
<ng-container *ngIf="peg.amount > 0">
<a [routerLink]="['/tx' | relativeUrl, peg.txid]" [fragment]="'vin=' + peg.txindex">
<app-truncate [text]="peg.txid"></app-truncate>
</a>
</ng-container>
<ng-container *ngIf="peg.amount < 0">
<a [routerLink]="['/tx' | relativeUrl, peg.txid]" [fragment]="'vout=' + peg.txindex">
<app-truncate [text]="peg.txid"></app-truncate>
</a>
</ng-container>
</td>
<td class="timestamp text-left widget">
<app-time kind="since" [time]="peg.blocktime"></app-time>
</td>
<td class="amount text-right widget" [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>
</td>
</tr>
</ng-container>
<ng-template #regularRows>
<tr *ngFor="let peg of pegs;">
<td class="transaction text-left">
<ng-container *ngIf="peg.amount > 0">
<a [routerLink]="['/tx' | relativeUrl, peg.txid]" [fragment]="'vin=' + peg.txindex">
<app-truncate [text]="peg.txid" [lastChars]="6"></app-truncate>
</a>
</ng-container>
<ng-container *ngIf="peg.amount < 0">
<a [routerLink]="['/tx' | relativeUrl, peg.txid]" [fragment]="'vout=' + peg.txindex">
<app-truncate [text]="peg.txid" [lastChars]="6"></app-truncate>
</a>
</ng-container>
</td>
<td class="timestamp text-left">
&lrm;{{ peg.blocktime * 1000 | date:'yyyy-MM-dd HH:mm' }}
<div class="symbol lg-inline relative-time"><i>(<app-time kind="since" [time]="peg.blocktime"></app-time>)</i></div>
</td>
<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>
</td>
<td class="output text-left">
<ng-container *ngIf="peg.bitcointxid; else redeemInProgress">
<a href="{{ env.MEMPOOL_WEBSITE_URL + '/tx/' + peg.bitcointxid + ':' + peg.bitcoinindex }}" target="_blank" style="color:#b86d12">
<app-truncate [text]="peg.bitcointxid + ':' + peg.bitcoinindex" [lastChars]="6"></app-truncate>
</a>
</ng-container>
<ng-template #redeemInProgress>
<ng-container *ngIf="peg.bitcoinaddress; else noRedeem">
<i><span class="text-muted" i18n="liquid.redemption-in-progress">Peg out in progress...</span></i>
</ng-container>
</ng-template>
</td>
<td class="address text-left">
<ng-container *ngIf="peg.bitcoinaddress; else noRedeem">
<a href="{{ env.MEMPOOL_WEBSITE_URL + '/address/' + peg.bitcoinaddress }}" target="_blank" style="color:#b86d12">
<app-truncate [text]="peg.bitcoinaddress" [lastChars]="6"></app-truncate>
</a>
</ng-container>
</td>
</tr>
</ng-template>
</tbody>
<ng-template #skeleton>
<tbody *ngIf="widget; else regularRowsSkeleton">
<tr *ngFor="let item of skeletonLines">
<td class="transaction text-left widget">
<span class="skeleton-loader" style="max-width: 400px"></span>
</td>
<td class="timestamp text-left widget">
<span class="skeleton-loader" style="max-width: 300px"></span>
</td>
<td class="amount text-right widget">
<span class="skeleton-loader" style="max-width: 300px"></span>
</td>
</tr>
</tbody>
<ng-template #regularRowsSkeleton>
<tr *ngFor="let item of skeletonLines">
<td class="transaction text-left">
<span class="skeleton-loader" style="max-width: 300px"></span>
</td>
<td class="timestamp text-left">
<span class="skeleton-loader" style="max-width: 240px"></span>
</td>
<td class="amount text-right">
<span class="skeleton-loader" style="max-width: 140px"></span>
</td>
<td class="output text-left">
<span class="skeleton-loader" style="max-width: 300px"></span>
</td>
<td class="address text-left">
<span class="skeleton-loader" style="max-width: 240px"></span>
</td>
</tr>
</ng-template>
</ng-template>
</table>
<ngb-pagination *ngIf="!widget && pegsCount$ | async as pegsCount" class="pagination-container float-right mt-2" [class]="isLoading || isPegCountLoading ? 'disabled' : ''"
[collectionSize]="pegsCount" [rotate]="true" [maxSize]="maxSize" [pageSize]="15" [(page)]="page"
(pageChange)="pageChange(page)" [boundaryLinks]="true" [ellipses]="false">
</ngb-pagination>
<ng-template [ngIf]="!widget">
<div class="clearfix"></div>
<br>
</ng-template>
</div>
</div>
<br>
<ng-template #noRedeem>
<span class="text-muted">-</span>
</ng-template>

View file

@ -0,0 +1,123 @@
.spinner-border {
height: 25px;
width: 25px;
margin-top: 13px;
}
tr, td, th {
border: 0px;
padding-top: 0.65rem;
padding-bottom: 0.6rem;
padding-right: 2rem;
.widget &.widget {
padding-right: 1rem;
@media (max-width: 510px) {
padding-right: 0.5rem;
}
}
}
.clear-link {
color: white;
}
.disabled {
pointer-events: none;
opacity: 0.5;
}
.progress {
background-color: #2d3348;
}
.transaction {
width: 20%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 120px;
}
.transaction.widget {
width: 100%;
}
.address {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 160px;
@media (max-width: 527px) {
display: none;
}
}
.amount {
width: 0%;
}
.output {
width: 20%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 160px;
@media (max-width: 800px) {
display: none;
}
}
.address {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 160px;
@media (max-width: 960px) {
display: none;
}
}
.timestamp {
width: 0%;
@media (max-width: 650px) {
display: none;
}
@media (max-width: 1000px) {
.relative-time {
display: none;
}
}
}
.timestamp.widget {
@media (min-width: 768px) AND (max-width: 1050px) {
display: none;
}
@media (max-width: 767px) {
display: block;
}
@media (max-width: 510px) {
display: none;
}
}
.credit {
color: #7CB342;
}
.debit {
color: #D81B60;
}
.glow-effect {
animation: color-oscillation 1s ease-in-out infinite alternate;
}
@keyframes color-oscillation {
0% {
color: #777983;
}
100% {
color: #D81B60;
}
}

View file

@ -0,0 +1,139 @@
import { Component, OnInit, ChangeDetectionStrategy, Input } from '@angular/core';
import { BehaviorSubject, Observable, Subject, combineLatest, of, timer } from 'rxjs';
import { delayWhen, filter, map, share, shareReplay, switchMap, takeUntil, tap, throttleTime } from 'rxjs/operators';
import { ApiService } from '../../../services/api.service';
import { Env, StateService } from '../../../services/state.service';
import { AuditStatus, CurrentPegs, RecentPeg } from '../../../interfaces/node-api.interface';
import { WebsocketService } from '../../../services/websocket.service';
import { SeoService } from '../../../services/seo.service';
@Component({
selector: 'app-recent-pegs-list',
templateUrl: './recent-pegs-list.component.html',
styleUrls: ['./recent-pegs-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RecentPegsListComponent implements OnInit {
@Input() widget: boolean = false;
@Input() recentPegsList$: Observable<RecentPeg[]>;
env: Env;
isLoading = true;
isPegCountLoading = true;
page = 1;
pageSize = 15;
maxSize = window.innerWidth <= 767.98 ? 3 : 5;
skeletonLines: number[] = [];
auditStatus$: Observable<AuditStatus>;
auditUpdated$: Observable<boolean>;
lastReservesBlockUpdate: number = 0;
currentPeg$: Observable<CurrentPegs>;
pegsCount$: Observable<number>;
startingIndexSubject: BehaviorSubject<number> = new BehaviorSubject(0);
currentIndex: number = 0;
lastPegBlockUpdate: number = 0;
lastPegAmount: string = '';
isLoad: boolean = true;
private destroy$ = new Subject();
constructor(
private apiService: ApiService,
public stateService: StateService,
private websocketService: WebsocketService,
private seoService: SeoService
) {
}
ngOnInit(): void {
this.isLoading = !this.widget;
this.env = this.stateService.env;
this.skeletonLines = this.widget === true ? [...Array(5).keys()] : [...Array(15).keys()];
if (!this.widget) {
this.seoService.setTitle($localize`:@@a8b0889ea1b41888f1e247f2731cc9322198ca04:Recent Peg-In / Out's`);
this.websocketService.want(['blocks']);
this.auditStatus$ = this.stateService.blocks$.pipe(
takeUntil(this.destroy$),
throttleTime(40000),
delayWhen(_ => this.isLoad ? timer(0) : timer(2000)),
tap(() => this.isLoad = false),
switchMap(() => this.apiService.federationAuditSynced$()),
shareReplay(1)
);
this.currentPeg$ = this.auditStatus$.pipe(
filter(auditStatus => auditStatus.isAuditSynced === true),
switchMap(_ =>
this.apiService.liquidPegs$().pipe(
filter((currentPegs) => currentPegs.lastBlockUpdate >= this.lastPegBlockUpdate),
tap((currentPegs) => {
this.lastPegBlockUpdate = currentPegs.lastBlockUpdate;
})
)
),
share()
);
this.auditUpdated$ = combineLatest([
this.auditStatus$,
this.currentPeg$
]).pipe(
filter(([auditStatus, _]) => auditStatus.isAuditSynced === true),
map(([auditStatus, currentPeg]) => ({
lastBlockAudit: auditStatus.lastBlockAudit,
currentPegAmount: currentPeg.amount
})),
switchMap(({ lastBlockAudit, currentPegAmount }) => {
const blockAuditCheck = lastBlockAudit > this.lastReservesBlockUpdate;
const amountCheck = currentPegAmount !== this.lastPegAmount;
this.lastReservesBlockUpdate = lastBlockAudit;
this.lastPegAmount = currentPegAmount;
return of(blockAuditCheck || amountCheck);
}),
share()
);
this.pegsCount$ = this.auditUpdated$.pipe(
filter(auditUpdated => auditUpdated === true),
tap(() => this.isPegCountLoading = true),
switchMap(_ => this.apiService.pegsCount$()),
map((data) => data.pegs_count),
tap(() => this.isPegCountLoading = false),
share()
);
this.recentPegsList$ = combineLatest([
this.auditStatus$,
this.auditUpdated$,
this.startingIndexSubject
]).pipe(
filter(([auditStatus, auditUpdated, startingIndex]) => {
const auditStatusCheck = auditStatus.isAuditSynced === true;
const auditUpdatedCheck = auditUpdated === true;
const startingIndexCheck = startingIndex !== this.currentIndex;
return auditStatusCheck && (auditUpdatedCheck || startingIndexCheck);
}),
tap(([_, __, startingIndex]) => {
this.currentIndex = startingIndex;
this.isLoading = true;
}),
switchMap(([_, __, startingIndex]) => this.apiService.recentPegsList$(startingIndex)),
tap(() => this.isLoading = false),
share()
);
}
}
ngOnDestroy(): void {
this.destroy$.next(1);
this.destroy$.complete();
}
pageChange(page: number): void {
this.startingIndexSubject.next((page - 1) * 15);
this.page = page;
}
}

View file

@ -0,0 +1,47 @@
<div *ngIf="(pegsVolume$ | async) as pegsVolume; else loadingData">
<div class="fee-estimation-container">
<div class="item">
<a class="title-link" [routerLink]="['/audit/pegs' | relativeUrl]">
<h5 class="card-title"><ng-container i18n="liquid.recent-pegs">Recent Peg-In / Out's</ng-container>&nbsp;<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="font-size: 13px; color: #4a68b9"></fa-icon></h5>
</a>
</div>
</div>
<div class="fee-estimation-container">
<div class="item">
<div class="card-text">
<div class="fee-text credit" i18n-ngbTooltip="liquid.peg-ins-volume-day" ngbTooltip="24h Peg-In Volume" placement="top">+{{ (+pegsVolume[0].volume) / 100000000 | number: '1.2-2' }} <span i18n="shared.addresses">BTC</span></div>
<div class="fiat">{{ (+pegsVolume[0].number) }} <span i18n="liquid.peg-ins">Peg-Ins</span></div>
</div>
</div>
<div class="item">
<div class="card-text">
<div class="fee-text debit" i18n-ngbTooltip="liquid.peg-out-volume-day" ngbTooltip="24h Peg-Out Volume" placement="top">{{ (+pegsVolume[1].volume) / 100000000 | number: '1.2-2' }} <span i18n="shared.addresses">BTC</span></div>
<div class="fiat">{{ (+pegsVolume[1].number) }} <span i18n="liquid.peg-outs">Peg-Outs</span></div>
</div>
</div>
</div>
</div>
<ng-template #loadingData>
<div class="fee-estimation-container loading-container">
<div class="item">
<a class="title-link" [routerLink]="['/audit/pegs' | relativeUrl]">
<h5 class="card-title"><ng-container i18n="liquid.recent-pegs">Recent Peg-In / Out's</ng-container>&nbsp;<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="font-size: 13px; color: #4a68b9"></fa-icon></h5>
</a>
</div>
</div>
<div class="fee-estimation-container">
<div class="item">
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
</div>
</div>
<div class="item">
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
</div>
</div>
</div>
</ng-template>

View file

@ -0,0 +1,80 @@
.fee-estimation-container {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
@media (min-width: 376px) {
flex-direction: row;
}
.item {
max-width: 300px;
margin: 0;
width: -webkit-fill-available;
@media (min-width: 376px) {
margin: 0 auto 0px;
}
.card-title {
margin: 0;
color: #4a68b9;
font-size: 10px;
font-size: 1rem;
white-space: nowrap;
}
.card-text {
font-size: 22px;
span {
font-size: 11px;
position: relative;
top: -2px;
}
}
.card-text span {
color: #ffffff66;
font-size: 12px;
top: 0px;
}
.fee-text{
border-bottom: 1px solid #ffffff1c;
width: fit-content;
margin: auto;
line-height: 1.45;
padding: 0px 2px;
}
.fiat {
display: block;
font-size: 14px !important;
}
}
}
.card-text {
.skeleton-loader {
width: 100%;
display: block;
&:first-child {
max-width: 90px;
margin: 15px auto 3px;
}
&:last-child {
margin: 10px auto 3px;
max-width: 55px;
}
}
}
.title-link, .title-link:hover, .title-link:focus, .title-link:active {
display: block;
margin-bottom: 4px;
text-decoration: none;
color: inherit;
}
.credit {
color: #7CB342;
}
.debit {
color: #D81B60;
}

View file

@ -0,0 +1,19 @@
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { PegsVolume } from '../../../interfaces/node-api.interface';
@Component({
selector: 'app-recent-pegs-stats',
templateUrl: './recent-pegs-stats.component.html',
styleUrls: ['./recent-pegs-stats.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RecentPegsStatsComponent implements OnInit {
@Input() pegsVolume$: Observable<PegsVolume[]>;
constructor() { }
ngOnInit(): void {
}
}

View file

@ -0,0 +1,42 @@
<div *ngIf="(unbackedMonths$ | async) as unbackedMonths; else loadingData">
<ng-container *ngIf="unbackedMonths.historyComplete; else loadingData">
<div class="fee-estimation-container">
<div class="item">
<h5 class="card-title" i18n="liquid.unpeg">Unpeg</h5>
<div class="card-text">
<div class="fee-text" [ngClass]="{'danger' : unbackedMonths.total > 0, 'correct': unbackedMonths.total === 0}">
{{ unbackedMonths.total }} <span i18n="liquid.unpeg-event">Unpeg Event</span>
</div>
</div>
</div>
<div class="item">
<h5 class="card-title" i18n="liquid.avg-peg-ratio">Avg Peg Ratio</h5>
<div class="card-text">
<div class="fee-text" [ngClass]="{'danger' : unbackedMonths.avg < 1, 'correct': unbackedMonths.avg >= 1}">
{{ (unbackedMonths.avg * 100).toFixed(3) }} %
</div>
</div>
</div>
</div>
</ng-container>
</div>
<ng-template #loadingData>
<div class="fee-estimation-container loading-container">
<div class="item">
<h5 class="card-title" i18n="liquid.unpeg">Unpeg</h5>
<div class="card-text">
<div class="skeleton-loader"></div>
</div>
</div>
<div class="item">
<h5 class="card-title" i18n="liquid.avg-peg-ratio">Avg Peg Ratio</h5>
<div class="card-text">
<div class="skeleton-loader"></div>
</div>
</div>
</div>
</ng-template>

View file

@ -0,0 +1,63 @@
.fee-estimation-container {
display: flex;
justify-content: space-between;
@media (min-width: 376px) {
flex-direction: row;
}
.item {
max-width: 300px;
margin: 0;
width: -webkit-fill-available;
@media (min-width: 376px) {
margin: 0 auto 0px;
}
.card-title {
margin-bottom: 4px;
color: #4a68b9;
font-size: 10px;
font-size: 1rem;
white-space: nowrap;
}
.card-text {
font-size: 22px;
span {
font-size: 11px;
position: relative;
top: -2px;
}
.danger {
color: #D81B60;
}
.correct {
color: #7CB342;
}
}
.card-text span {
color: #ffffff66;
font-size: 12px;
top: 0px;
}
.fee-text{
width: fit-content;
margin: auto;
line-height: 1.45;
padding: 0px 2px;
}
}
}
.loading-container{
min-height: 76px;
}
.card-text {
.skeleton-loader {
width: 100%;
display: block;
max-width: 90px;
margin: 15px auto 3px;
}
}

View file

@ -0,0 +1,51 @@
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
import { Observable, map } from 'rxjs';
@Component({
selector: 'app-reserves-ratio-stats',
templateUrl: './reserves-ratio-stats.component.html',
styleUrls: ['./reserves-ratio-stats.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ReservesRatioStatsComponent implements OnInit {
@Input() fullHistory$: Observable<any>;
unbackedMonths$: Observable<any>
constructor() { }
ngOnInit(): void {
if (!this.fullHistory$) {
return;
}
this.unbackedMonths$ = this.fullHistory$
.pipe(
map((fullHistory) => {
if (fullHistory.liquidPegs.series.length !== fullHistory.liquidReserves.series.length) {
return {
historyComplete: false,
total: null
};
}
// Only check the last 3 years
let ratioSeries = fullHistory.liquidReserves.series.map((value: number, index: number) => value / fullHistory.liquidPegs.series[index]);
ratioSeries = ratioSeries.slice(Math.max(ratioSeries.length - 36, 0));
let total = 0;
let avg = 0;
for (let i = 0; i < ratioSeries.length; i++) {
avg += ratioSeries[i];
if (ratioSeries[i] < 1) {
total++;
}
}
avg = avg / ratioSeries.length;
return {
historyComplete: true,
total: total,
avg: avg,
};
})
);
}
}

View file

@ -0,0 +1,4 @@
<div class="echarts" echarts [initOpts]="ratioChartInitOptions" [options]="ratioChartOptions" (chartRendered)="rendered()"></div>
<div class="text-center loadingGraphs" *ngIf="isLoading">
<div class="spinner-border text-light"></div>
</div>

View file

@ -0,0 +1,6 @@
.loadingGraphs {
position: absolute;
top: 50%;
left: calc(50% - 16px);
z-index: 100;
}

View file

@ -0,0 +1,175 @@
import { Component, ChangeDetectionStrategy, Input, OnChanges, OnInit, HostListener } from '@angular/core';
import { EChartsOption } from '../../../graphs/echarts';
import { CurrentPegs } from '../../../interfaces/node-api.interface';
@Component({
selector: 'app-reserves-ratio',
templateUrl: './reserves-ratio.component.html',
styleUrls: ['./reserves-ratio.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ReservesRatioComponent implements OnInit, OnChanges {
@Input() currentPeg: CurrentPegs;
@Input() currentReserves: CurrentPegs;
ratioChartOptions: EChartsOption;
height: number | string = '200';
right: number | string = '10';
top: number | string = '20';
left: number | string = '50';
template: ('widget' | 'advanced') = 'widget';
isLoading = true;
ratioChartInitOptions = {
renderer: 'svg'
};
constructor() { }
ngOnInit() {
this.isLoading = true;
}
ngOnChanges() {
this.updateChartOptions();
}
updateChartOptions() {
if (!this.currentPeg || !this.currentReserves || this.currentPeg.amount === '0') {
return;
}
this.ratioChartOptions = this.createChartOptions(this.currentPeg, this.currentReserves);
}
rendered() {
if (!this.currentPeg || !this.currentReserves) {
return;
}
this.isLoading = false;
}
createChartOptions(currentPeg: CurrentPegs, currentReserves: CurrentPegs): EChartsOption {
const value = parseFloat(currentReserves.amount) / parseFloat(currentPeg.amount);
const hideMaxAxisLabels = value >= 1.001;
const hideMinAxisLabels = value <= 0.999;
let axisFontSize = 14;
let pointerLength = '50%';
let pointerWidth = 16;
let offsetCenter = ['0%', '-22%'];
if (window.innerWidth >= 992) {
axisFontSize = 14;
pointerLength = '50%';
pointerWidth = 16;
offsetCenter = value >= 1.0007 || value <= 0.9993 ? ['0%', '-30%'] : ['0%', '-22%'];
} else if (window.innerWidth >= 768) {
axisFontSize = 10;
pointerLength = '35%';
pointerWidth = 12;
offsetCenter = value >= 1.0007 || value <= 0.9993 ? ['0%', '-37%'] : ['0%', '-27%'];
} else if (window.innerWidth >= 450) {
axisFontSize = 14;
pointerLength = '45%';
pointerWidth = 14;
offsetCenter = value >= 1.0007 || value <= 0.9993 ? ['0%', '-32%'] : ['0%', '-22%'];
} else {
axisFontSize = 10;
pointerLength = '35%';
pointerWidth = 12;
offsetCenter = value >= 1.0007 || value <= 0.9993 ? ['0%', '-37%'] : ['0%', '-27%'];
}
return {
series: [
{
type: 'gauge',
startAngle: 180,
endAngle: 0,
center: ['50%', '75%'],
radius: '100%',
min: 0.999,
max: 1.001,
splitNumber: 2,
axisLine: {
lineStyle: {
width: 6,
color: [
[0.49, '#D81B60'],
[1, '#7CB342']
]
}
},
axisLabel: {
color: 'inherit',
fontFamily: 'inherit',
fontSize: axisFontSize,
formatter: function (value) {
if (value === 0.999) {
return hideMinAxisLabels ? '' : '99.9%';
} else if (value === 1.001) {
return hideMaxAxisLabels ? '' : '100.1%';
} else {
return '100%';
}
},
},
pointer: {
icon: 'path://M2090.36389,615.30999 L2090.36389,615.30999 C2091.48372,615.30999 2092.40383,616.194028 2092.44859,617.312956 L2096.90698,728.755929 C2097.05155,732.369577 2094.2393,735.416212 2090.62566,735.56078 C2090.53845,735.564269 2090.45117,735.566014 2090.36389,735.566014 L2090.36389,735.566014 C2086.74736,735.566014 2083.81557,732.63423 2083.81557,729.017692 C2083.81557,728.930412 2083.81732,728.84314 2083.82081,728.755929 L2088.2792,617.312956 C2088.32396,616.194028 2089.24407,615.30999 2090.36389,615.30999 Z',
length: pointerLength,
width: pointerWidth,
offsetCenter: offsetCenter,
itemStyle: {
color: 'auto'
}
},
axisTick: {
length: 12,
lineStyle: {
color: 'auto',
width: 2
}
},
splitLine: {
length: 20,
lineStyle: {
color: 'auto',
width: 5
}
},
title: {
show: true,
offsetCenter: [0, '-127%'],
fontSize: 18,
color: '#4a68b9',
fontFamily: 'inherit',
fontWeight: 500,
},
detail: {
fontSize: 25,
offsetCenter: [0, '-0%'],
valueAnimation: true,
fontFamily: 'inherit',
fontWeight: 500,
formatter: function (value) {
return (value * 100).toFixed(3) + '%';
},
color: 'inherit'
},
data: [
{
value: value,
name: 'Assets vs Liabilities'
}
]
}
]
};
}
@HostListener('window:resize', ['$event'])
onResize(): void {
this.updateChartOptions();
}
}

View file

@ -0,0 +1,29 @@
<div class="fee-estimation-container">
<div class="item">
<h5 class="card-title" i18n="dashboard.lbtc-pegs-in-circulation">L-BTC in circulation</h5>
<div *ngIf="(currentPeg$ | async) as currentPeg; else loadingData" class="card-text">
<div class="fee-text">{{ (+currentPeg.amount) / 100000000 | number: '1.2-2' }} <span>L-BTC</span></div>
<span class="fiat">
<span><ng-container i18n="shared.as-of-block">As of block</ng-container>&nbsp;<a [routerLink]="['/block', currentPeg.hash]">{{ currentPeg.lastBlockUpdate }}</a></span>
</span>
</div>
</div>
<div class="item">
<h5 class="card-title" i18n="dashboard.btc-holdings">BTC Holdings</h5>
<div *ngIf="(currentReserves$ | async) as currentReserves; else loadingData" class="card-text">
<div class="fee-text">{{ (+currentReserves.amount) / 100000000 | number: '1.2-2' }} <span style="color: #b86d12;">BTC</span></div>
<span class="fiat">
<span><ng-container i18n="shared.as-of-block">As of block</ng-container>&nbsp;<a href="{{ env.MEMPOOL_WEBSITE_URL + '/block/' + currentReserves.hash }}" target="_blank" style="color:#b86d12">{{ currentReserves.lastBlockUpdate }}</a></span>
</span>
</div>
</div>
</div>
<ng-template #loadingData>
<div class="card-text">
<div class="skeleton-loader"></div>
<div class="skeleton-loader"></div>
</div>
</ng-template>

View file

@ -0,0 +1,74 @@
.fee-estimation-container {
display: flex;
justify-content: space-between;
@media (min-width: 376px) {
flex-direction: row;
}
.item {
max-width: 150px;
margin: 0;
width: -webkit-fill-available;
@media (min-width: 376px) {
margin: 0 auto 0px;
}
.card-title {
color: #4a68b9;
font-size: 10px;
margin-bottom: 4px;
font-size: 1rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-text {
font-size: 22px;
span {
font-size: 11px;
position: relative;
top: -2px;
}
}
&:last-child {
margin-bottom: 0;
}
.card-text span {
color: #ffffff66;
font-size: 12px;
top: 0px;
}
.fee-text{
border-bottom: 1px solid #ffffff1c;
color: #ffffff;
width: fit-content;
margin: auto;
line-height: 1.45;
padding: 0px 2px;
}
.fiat {
display: block;
font-size: 14px !important;
}
}
}
.loading-container{
min-height: 76px;
}
.card-text {
.skeleton-loader {
width: 100%;
display: block;
&:first-child {
max-width: 90px;
margin: 15px auto 3px;
}
&:last-child {
margin: 10px auto 3px;
max-width: 55px;
}
}
}

View file

@ -0,0 +1,24 @@
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { Env, StateService } from '../../../services/state.service';
import { CurrentPegs } from '../../../interfaces/node-api.interface';
@Component({
selector: 'app-reserves-supply-stats',
templateUrl: './reserves-supply-stats.component.html',
styleUrls: ['./reserves-supply-stats.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ReservesSupplyStatsComponent implements OnInit {
@Input() currentReserves$: Observable<CurrentPegs>;
@Input() currentPeg$: Observable<CurrentPegs>;
env: Env;
constructor(private stateService: StateService) { }
ngOnInit(): void {
this.env = this.stateService.env;
}
}

View file

@ -1,11 +1,13 @@
<app-block-overview-graph <app-block-overview-graph
#blockGraph #blockGraph
[isLoading]="isLoading$ | async" [isLoading]="isLoading$ | async"
[resolution]="86" [resolution]="resolution"
[blockLimit]="stateService.blockVSize" [blockLimit]="stateService.blockVSize"
[orientation]="timeLtr ? 'right' : 'left'" [orientation]="timeLtr ? 'right' : 'left'"
[flip]="true" [flip]="true"
[showFilters]="showFilters" [showFilters]="showFilters"
[filterFlags]="filterFlags"
[filterMode]="filterMode"
[overrideColors]="overrideColors" [overrideColors]="overrideColors"
(txClickEvent)="onTxClick($event)" (txClickEvent)="onTxClick($event)"
></app-block-overview-graph> ></app-block-overview-graph>

View file

@ -10,6 +10,7 @@ import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pi
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Color } from '../block-overview-graph/sprite-types'; import { Color } from '../block-overview-graph/sprite-types';
import TxView from '../block-overview-graph/tx-view'; import TxView from '../block-overview-graph/tx-view';
import { FilterMode } from '../../shared/filters.utils';
@Component({ @Component({
selector: 'app-mempool-block-overview', selector: 'app-mempool-block-overview',
@ -18,8 +19,11 @@ import TxView from '../block-overview-graph/tx-view';
}) })
export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChanges, AfterViewInit { export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChanges, AfterViewInit {
@Input() index: number; @Input() index: number;
@Input() resolution = 86;
@Input() showFilters: boolean = false; @Input() showFilters: boolean = false;
@Input() overrideColors: ((tx: TxView) => Color) | null = null; @Input() overrideColors: ((tx: TxView) => Color) | null = null;
@Input() filterFlags: bigint | undefined = undefined;
@Input() filterMode: FilterMode = 'and';
@Output() txPreviewEvent = new EventEmitter<TransactionStripped | void>(); @Output() txPreviewEvent = new EventEmitter<TransactionStripped | void>();
@ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent; @ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent;
@ -96,7 +100,7 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang
const inOldBlock = {}; const inOldBlock = {};
const inNewBlock = {}; const inNewBlock = {};
const added: TransactionStripped[] = []; const added: TransactionStripped[] = [];
const changed: { txid: string, rate: number | undefined, acc: boolean | undefined }[] = []; const changed: { txid: string, rate: number | undefined, flags: number, acc: boolean | undefined }[] = [];
const removed: string[] = []; const removed: string[] = [];
for (const tx of transactionsStripped) { for (const tx of transactionsStripped) {
inNewBlock[tx.txid] = true; inNewBlock[tx.txid] = true;
@ -114,6 +118,7 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang
changed.push({ changed.push({
txid: tx.txid, txid: tx.txid,
rate: tx.rate, rate: tx.rate,
flags: tx.flags,
acc: tx.acc acc: tx.acc
}); });
} }

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