mirror of
https://github.com/mempool/mempool.git
synced 2025-02-24 14:50:52 +01:00
Merge branch 'master' into nymkappa/api-key-rest
This commit is contained in:
commit
3b3081f884
155 changed files with 4900 additions and 828 deletions
36
.github/workflows/ci.yml
vendored
36
.github/workflows/ci.yml
vendored
|
@ -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/')"
|
||||||
|
@ -329,4 +335,32 @@ jobs:
|
||||||
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||||
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
1
.gitignore
vendored
|
@ -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
6
backend/.gitignore
vendored
|
@ -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
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
"MEMPOOL_BLOCKS_AMOUNT": 8,
|
"MEMPOOL_BLOCKS_AMOUNT": 8,
|
||||||
"INDEXING_BLOCKS_AMOUNT": 11000,
|
"INDEXING_BLOCKS_AMOUNT": 11000,
|
||||||
"BLOCKS_SUMMARIES_INDEXING": false,
|
"BLOCKS_SUMMARIES_INDEXING": false,
|
||||||
|
"GOGGLES_INDEXING": false,
|
||||||
"USE_SECOND_NODE_FOR_MINFEE": false,
|
"USE_SECOND_NODE_FOR_MINFEE": false,
|
||||||
"EXTERNAL_ASSETS": [],
|
"EXTERNAL_ASSETS": [],
|
||||||
"EXTERNAL_MAX_RETRY": 1,
|
"EXTERNAL_MAX_RETRY": 1,
|
||||||
|
|
26
backend/package-lock.json
generated
26
backend/package-lock.json
generated
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
"NETWORK": "__MEMPOOL_NETWORK__",
|
"NETWORK": "__MEMPOOL_NETWORK__",
|
||||||
"BACKEND": "__MEMPOOL_BACKEND__",
|
"BACKEND": "__MEMPOOL_BACKEND__",
|
||||||
"BLOCKS_SUMMARIES_INDEXING": true,
|
"BLOCKS_SUMMARIES_INDEXING": true,
|
||||||
|
"GOGGLES_INDEXING": false,
|
||||||
"HTTP_PORT": 1,
|
"HTTP_PORT": 1,
|
||||||
"SPAWN_CLUSTER_PROCS": 2,
|
"SPAWN_CLUSTER_PROCS": 2,
|
||||||
"API_URL_PREFIX": "__MEMPOOL_API_URL_PREFIX__",
|
"API_URL_PREFIX": "__MEMPOOL_API_URL_PREFIX__",
|
||||||
|
|
|
@ -17,6 +17,7 @@ describe('Mempool Backend Config', () => {
|
||||||
NETWORK: 'mainnet',
|
NETWORK: 'mainnet',
|
||||||
BACKEND: 'none',
|
BACKEND: 'none',
|
||||||
BLOCKS_SUMMARIES_INDEXING: false,
|
BLOCKS_SUMMARIES_INDEXING: false,
|
||||||
|
GOGGLES_INDEXING: false,
|
||||||
HTTP_PORT: 8999,
|
HTTP_PORT: 8999,
|
||||||
SPAWN_CLUSTER_PROCS: 0,
|
SPAWN_CLUSTER_PROCS: 0,
|
||||||
API_URL_PREFIX: '/api/v1/',
|
API_URL_PREFIX: '/api/v1/',
|
||||||
|
|
|
@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,66 +79,87 @@ 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
|
||||||
if (result) {
|
? this.pollConnection.get<number>('/blocks/tip/height', { socketPath: host.host, timeout: config.ESPLORA.FALLBACK_TIMEOUT })
|
||||||
const height = result.data;
|
: this.pollConnection.get<number>(host.host + '/blocks/tip/height', { timeout: config.ESPLORA.FALLBACK_TIMEOUT })
|
||||||
const rtt = result.config['meta'].rtt;
|
);
|
||||||
host.rtts.unshift(rtt);
|
if (result) {
|
||||||
host.rtts.slice(0, 5);
|
const height = result.data;
|
||||||
host.rtt = host.rtts.reduce((acc, l) => acc + l, 0) / host.rtts.length;
|
this.maxHeight = Math.max(height, this.maxHeight);
|
||||||
host.latestHeight = height;
|
const rtt = result.config['meta'].rtt;
|
||||||
if (height == null || isNaN(height) || (maxHeight - height > 2)) {
|
host.rtts.unshift(rtt);
|
||||||
host.outOfSync = true;
|
host.rtts.slice(0, 5);
|
||||||
|
host.rtt = host.rtts.reduce((acc, l) => acc + l, 0) / host.rtts.length;
|
||||||
|
host.latestHeight = height;
|
||||||
|
if (height == null || isNaN(height) || (this.maxHeight - height > 2)) {
|
||||||
|
host.outOfSync = true;
|
||||||
|
} else {
|
||||||
|
host.outOfSync = false;
|
||||||
|
}
|
||||||
|
host.unreachable = false;
|
||||||
} else {
|
} else {
|
||||||
host.outOfSync = false;
|
host.outOfSync = true;
|
||||||
|
host.unreachable = true;
|
||||||
|
host.rtts = [];
|
||||||
|
host.rtt = Infinity;
|
||||||
}
|
}
|
||||||
host.unreachable = false;
|
} catch (e) {
|
||||||
} else {
|
|
||||||
host.outOfSync = true;
|
host.outOfSync = true;
|
||||||
host.unreachable = true;
|
host.unreachable = true;
|
||||||
|
host.rtts = [];
|
||||||
|
host.rtt = Infinity;
|
||||||
}
|
}
|
||||||
|
host.checked = true;
|
||||||
|
|
||||||
|
|
||||||
|
// switch if the current host is out of sync or significantly slower than the next best alternative
|
||||||
|
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) {
|
||||||
|
logger.warn(`🚨🚨🚨 Unable to reach ${this.activeHost.host}, failing over to next best alternative 🚨🚨🚨`);
|
||||||
|
} else if (this.activeHost.outOfSync) {
|
||||||
|
logger.warn(`🚨🚨🚨 ${this.activeHost.host} has fallen behind, failing over to next best alternative 🚨🚨🚨`);
|
||||||
|
} else {
|
||||||
|
logger.debug(`🛠️ ${this.activeHost.host} is no longer the best esplora host 🛠️`);
|
||||||
|
}
|
||||||
|
this.electHost();
|
||||||
|
}
|
||||||
|
await Common.sleep$(50);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sortHosts();
|
const rankOrder = this.updateFallback();
|
||||||
|
logger.debug(`Tomahawk ranking:\n${rankOrder.map((host, index) => this.formatRanking(index, host, this.activeHost, this.maxHeight)).join('\n')}`);
|
||||||
|
|
||||||
logger.debug(`Tomahawk ranking:\n${this.hosts.map((host, index) => this.formatRanking(index, host, this.activeHost, maxHeight)).join('\n')}`);
|
const elapsed = Date.now() - start;
|
||||||
|
|
||||||
// switch if the current host is out of sync or significantly slower than the next best alternative
|
this.pollTimer = setTimeout(() => { this.pollHosts(); }, Math.max(1, this.pollInterval - elapsed));
|
||||||
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)) {
|
|
||||||
if (this.activeHost.unreachable) {
|
|
||||||
logger.warn(`🚨🚨🚨 Unable to reach ${this.activeHost.host}, failing over to next best alternative 🚨🚨🚨`);
|
|
||||||
} else if (this.activeHost.outOfSync) {
|
|
||||||
logger.warn(`🚨🚨🚨 ${this.activeHost.host} has fallen behind, failing over to next best alternative 🚨🚨🚨`);
|
|
||||||
} else {
|
|
||||||
logger.debug(`🛠️ ${this.activeHost.host} is no longer the best esplora host 🛠️`);
|
|
||||||
}
|
|
||||||
this.electHost();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.pollTimer = setTimeout(() => { this.pollHosts(); }, this.pollInterval);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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';
|
||||||
|
@ -451,7 +451,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);
|
||||||
await this.$getStrippedBlockTransactions(block.hash, true, true, cpfpSummary, block.height); // This will index the block summary
|
if (cpfpSummary) {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
@ -566,7 +568,7 @@ class Blocks {
|
||||||
*/
|
*/
|
||||||
public async $classifyBlocks(): Promise<void> {
|
public async $classifyBlocks(): Promise<void> {
|
||||||
// classification requires an esplora backend
|
// classification requires an esplora backend
|
||||||
if (!Common.blocksSummariesIndexingEnabled() || config.MEMPOOL.BACKEND !== 'esplora') {
|
if (!Common.gogglesIndexingEnabled() || config.MEMPOOL.BACKEND !== 'esplora') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -617,6 +619,7 @@ class Blocks {
|
||||||
// classify
|
// classify
|
||||||
const { transactions: classifiedTxs } = this.summarizeBlockTransactions(blockHash, cpfpSummary.transactions);
|
const { transactions: classifiedTxs } = this.summarizeBlockTransactions(blockHash, cpfpSummary.transactions);
|
||||||
await BlocksSummariesRepository.$saveTransactions(height, blockHash, classifiedTxs, 1);
|
await BlocksSummariesRepository.$saveTransactions(height, blockHash, classifiedTxs, 1);
|
||||||
|
await Common.sleep$(250);
|
||||||
}
|
}
|
||||||
if (unclassifiedTemplates[height]) {
|
if (unclassifiedTemplates[height]) {
|
||||||
// classify template
|
// classify template
|
||||||
|
@ -656,6 +659,7 @@ class Blocks {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await BlocksSummariesRepository.$saveTemplate({ height, template: { id: blockHash, transactions: classifiedTemplate }, version: 1 });
|
await BlocksSummariesRepository.$saveTemplate({ height, template: { id: blockHash, transactions: classifiedTemplate }, version: 1 });
|
||||||
|
await Common.sleep$(250);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn(`Failed to classify template or block summary at ${height}`, logger.tags.goggles);
|
logger.warn(`Failed to classify template or block summary at ${height}`, logger.tags.goggles);
|
||||||
|
@ -993,11 +997,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);
|
||||||
}
|
}
|
||||||
|
@ -1086,13 +1090,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,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
@ -1282,7 +1292,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 {
|
||||||
|
@ -1302,7 +1312,7 @@ class Blocks {
|
||||||
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') {
|
||||||
|
@ -1317,14 +1327,19 @@ class Blocks {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const summary = Common.calculateCpfp(height, transactions as TransactionExtended[]);
|
if (transactions?.length != null) {
|
||||||
|
const summary = Common.calculateCpfp(height, transactions as TransactionExtended[]);
|
||||||
|
|
||||||
await this.$saveCpfp(hash, height, summary);
|
await this.$saveCpfp(hash, height, summary);
|
||||||
|
|
||||||
const effectiveFeeStats = Common.calcEffectiveFeeStatistics(summary.transactions);
|
const effectiveFeeStats = Common.calcEffectiveFeeStatistics(summary.transactions);
|
||||||
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> {
|
||||||
|
|
|
@ -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),
|
||||||
|
@ -508,6 +518,13 @@ export class Common {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static gogglesIndexingEnabled(): boolean {
|
||||||
|
return (
|
||||||
|
Common.blocksSummariesIndexingEnabled() &&
|
||||||
|
config.MEMPOOL.GOGGLES_INDEXING === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static cpfpIndexingEnabled(): boolean {
|
static cpfpIndexingEnabled(): boolean {
|
||||||
return (
|
return (
|
||||||
Common.indexingEnabled() &&
|
Common.indexingEnabled() &&
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,337 @@ 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 all of the federation addresses one month ago, most balances first
|
||||||
|
public async $getFederationAddressesOneMonthAgo(): Promise<any> {
|
||||||
|
const query = `
|
||||||
|
SELECT COUNT(*) AS addresses_count_one_month FROM (
|
||||||
|
SELECT bitcoinaddress, SUM(amount) AS balance
|
||||||
|
FROM federation_txos
|
||||||
|
WHERE
|
||||||
|
(blocktime < UNIX_TIMESTAMP(TIMESTAMPADD(DAY, -30, CURRENT_TIMESTAMP())))
|
||||||
|
AND
|
||||||
|
((unspent = 1) OR (unspent = 0 AND lasttimeupdate > UNIX_TIMESTAMP(TIMESTAMPADD(DAY, -30, CURRENT_TIMESTAMP()))))
|
||||||
|
GROUP BY bitcoinaddress
|
||||||
|
) AS result;`;
|
||||||
|
const [rows] = await DB.query(query);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all of the UTXOs held by the federation one month ago, most recent first
|
||||||
|
public async $getFederationUtxosOneMonthAgo(): Promise<any> {
|
||||||
|
const query = `
|
||||||
|
SELECT COUNT(*) AS utxos_count_one_month FROM federation_txos
|
||||||
|
WHERE
|
||||||
|
(blocktime < UNIX_TIMESTAMP(TIMESTAMPADD(DAY, -30, CURRENT_TIMESTAMP())))
|
||||||
|
AND
|
||||||
|
((unspent = 1) OR (unspent = 0 AND lasttimeupdate > UNIX_TIMESTAMP(TIMESTAMPADD(DAY, -30, CURRENT_TIMESTAMP()))))
|
||||||
|
ORDER BY blocktime DESC;`;
|
||||||
|
const [rows] = await DB.query(query);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get recent pegouts from the federation (3 months old)
|
||||||
|
public async $getRecentPegouts(): Promise<any> {
|
||||||
|
const query = `SELECT txid, txindex, amount, bitcoinaddress, bitcointxid, bitcoinindex, datetime AS blocktime FROM elements_pegs WHERE amount < 0 AND datetime > UNIX_TIMESTAMP(TIMESTAMPADD(DAY, -90, CURRENT_TIMESTAMP())) ORDER BY blocktime;`;
|
||||||
|
const [rows] = await DB.query(query);
|
||||||
|
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]
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new ElementsParser();
|
export default new ElementsParser();
|
||||||
|
|
|
@ -15,7 +15,17 @@ 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/volume', this.$getPegsVolumeDaily)
|
||||||
|
.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/pegouts', this.$getPegOuts)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/addresses', this.$getFederationAddresses)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/addresses/previous-month', this.$getFederationAddressesOneMonthAgo)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/utxos', this.$getFederationUtxos)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/utxos/previous-month', this.$getFederationUtxosOneMonthAgo)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/status', this.$getFederationAuditStatus)
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -63,11 +73,135 @@ 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 $getFederationAddressesOneMonthAgo(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const federationAddresses = await elementsParser.$getFederationAddressesOneMonthAgo();
|
||||||
|
res.header('Pragma', 'public');
|
||||||
|
res.header('Cache-control', 'public');
|
||||||
|
res.setHeader('Expires', new Date(Date.now() + 1000 * 60 * 60 * 24).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 $getFederationUtxosOneMonthAgo(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const federationUtxos = await elementsParser.$getFederationUtxosOneMonthAgo();
|
||||||
|
res.header('Pragma', 'public');
|
||||||
|
res.header('Cache-control', 'public');
|
||||||
|
res.setHeader('Expires', new Date(Date.now() + 1000 * 60 * 60 * 24).toUTCString());
|
||||||
|
res.json(federationUtxos);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async $getPegOuts(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const recentPegOuts = await elementsParser.$getRecentPegouts();
|
||||||
|
res.header('Pragma', 'public');
|
||||||
|
res.header('Cache-control', 'public');
|
||||||
|
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
|
||||||
|
res.json(recentPegOuts);
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new LiquidRoutes();
|
export default new LiquidRoutes();
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -20,6 +20,7 @@ interface IConfig {
|
||||||
MEMPOOL_BLOCKS_AMOUNT: number;
|
MEMPOOL_BLOCKS_AMOUNT: number;
|
||||||
INDEXING_BLOCKS_AMOUNT: number;
|
INDEXING_BLOCKS_AMOUNT: number;
|
||||||
BLOCKS_SUMMARIES_INDEXING: boolean;
|
BLOCKS_SUMMARIES_INDEXING: boolean;
|
||||||
|
GOGGLES_INDEXING: boolean;
|
||||||
USE_SECOND_NODE_FOR_MINFEE: boolean;
|
USE_SECOND_NODE_FOR_MINFEE: boolean;
|
||||||
EXTERNAL_ASSETS: string[];
|
EXTERNAL_ASSETS: string[];
|
||||||
EXTERNAL_MAX_RETRY: number;
|
EXTERNAL_MAX_RETRY: number;
|
||||||
|
@ -175,6 +176,7 @@ const defaults: IConfig = {
|
||||||
'MEMPOOL_BLOCKS_AMOUNT': 8,
|
'MEMPOOL_BLOCKS_AMOUNT': 8,
|
||||||
'INDEXING_BLOCKS_AMOUNT': 11000, // 0 = disable indexing, -1 = index all blocks
|
'INDEXING_BLOCKS_AMOUNT': 11000, // 0 = disable indexing, -1 = index all blocks
|
||||||
'BLOCKS_SUMMARIES_INDEXING': false,
|
'BLOCKS_SUMMARIES_INDEXING': false,
|
||||||
|
'GOGGLES_INDEXING': false,
|
||||||
'USE_SECOND_NODE_FOR_MINFEE': false,
|
'USE_SECOND_NODE_FOR_MINFEE': false,
|
||||||
'EXTERNAL_ASSETS': [],
|
'EXTERNAL_ASSETS': [],
|
||||||
'EXTERNAL_MAX_RETRY': 1,
|
'EXTERNAL_MAX_RETRY': 1,
|
||||||
|
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
|
@ -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));
|
||||||
|
|
|
@ -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; });
|
||||||
|
|
|
@ -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));
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
3
contributors/jamesblacklock.txt
Normal file
3
contributors/jamesblacklock.txt
Normal 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
|
|
@ -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
|
|
@ -22,6 +22,7 @@
|
||||||
"STDOUT_LOG_MIN_PRIORITY": "__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__",
|
"STDOUT_LOG_MIN_PRIORITY": "__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__",
|
||||||
"INDEXING_BLOCKS_AMOUNT": __MEMPOOL_INDEXING_BLOCKS_AMOUNT__,
|
"INDEXING_BLOCKS_AMOUNT": __MEMPOOL_INDEXING_BLOCKS_AMOUNT__,
|
||||||
"BLOCKS_SUMMARIES_INDEXING": __MEMPOOL_BLOCKS_SUMMARIES_INDEXING__,
|
"BLOCKS_SUMMARIES_INDEXING": __MEMPOOL_BLOCKS_SUMMARIES_INDEXING__,
|
||||||
|
"GOGGLES_INDEXING": __MEMPOOL_GOGGLES_INDEXING__,
|
||||||
"AUTOMATIC_BLOCK_REINDEXING": __MEMPOOL_AUTOMATIC_BLOCK_REINDEXING__,
|
"AUTOMATIC_BLOCK_REINDEXING": __MEMPOOL_AUTOMATIC_BLOCK_REINDEXING__,
|
||||||
"AUDIT": __MEMPOOL_AUDIT__,
|
"AUDIT": __MEMPOOL_AUDIT__,
|
||||||
"ADVANCED_GBT_AUDIT": __MEMPOOL_ADVANCED_GBT_AUDIT__,
|
"ADVANCED_GBT_AUDIT": __MEMPOOL_ADVANCED_GBT_AUDIT__,
|
||||||
|
@ -34,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": {
|
||||||
|
|
|
@ -17,6 +17,7 @@ __MEMPOOL_INITIAL_BLOCKS_AMOUNT__=${MEMPOOL_INITIAL_BLOCKS_AMOUNT:=8}
|
||||||
__MEMPOOL_MEMPOOL_BLOCKS_AMOUNT__=${MEMPOOL_MEMPOOL_BLOCKS_AMOUNT:=8}
|
__MEMPOOL_MEMPOOL_BLOCKS_AMOUNT__=${MEMPOOL_MEMPOOL_BLOCKS_AMOUNT:=8}
|
||||||
__MEMPOOL_INDEXING_BLOCKS_AMOUNT__=${MEMPOOL_INDEXING_BLOCKS_AMOUNT:=11000}
|
__MEMPOOL_INDEXING_BLOCKS_AMOUNT__=${MEMPOOL_INDEXING_BLOCKS_AMOUNT:=11000}
|
||||||
__MEMPOOL_BLOCKS_SUMMARIES_INDEXING__=${MEMPOOL_BLOCKS_SUMMARIES_INDEXING:=false}
|
__MEMPOOL_BLOCKS_SUMMARIES_INDEXING__=${MEMPOOL_BLOCKS_SUMMARIES_INDEXING:=false}
|
||||||
|
__MEMPOOL_GOGGLES_INDEXING__=${MEMPOOL_GOGGLES_INDEXING:=false}
|
||||||
__MEMPOOL_USE_SECOND_NODE_FOR_MINFEE__=${MEMPOOL_USE_SECOND_NODE_FOR_MINFEE:=false}
|
__MEMPOOL_USE_SECOND_NODE_FOR_MINFEE__=${MEMPOOL_USE_SECOND_NODE_FOR_MINFEE:=false}
|
||||||
__MEMPOOL_EXTERNAL_ASSETS__=${MEMPOOL_EXTERNAL_ASSETS:=[]}
|
__MEMPOOL_EXTERNAL_ASSETS__=${MEMPOOL_EXTERNAL_ASSETS:=[]}
|
||||||
__MEMPOOL_EXTERNAL_MAX_RETRY__=${MEMPOOL_EXTERNAL_MAX_RETRY:=1}
|
__MEMPOOL_EXTERNAL_MAX_RETRY__=${MEMPOOL_EXTERNAL_MAX_RETRY:=1}
|
||||||
|
@ -54,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}
|
||||||
|
@ -170,6 +171,7 @@ sed -i "s!__MEMPOOL_INITIAL_BLOCKS_AMOUNT__!${__MEMPOOL_INITIAL_BLOCKS_AMOUNT__}
|
||||||
sed -i "s!__MEMPOOL_MEMPOOL_BLOCKS_AMOUNT__!${__MEMPOOL_MEMPOOL_BLOCKS_AMOUNT__}!g" mempool-config.json
|
sed -i "s!__MEMPOOL_MEMPOOL_BLOCKS_AMOUNT__!${__MEMPOOL_MEMPOOL_BLOCKS_AMOUNT__}!g" mempool-config.json
|
||||||
sed -i "s!__MEMPOOL_INDEXING_BLOCKS_AMOUNT__!${__MEMPOOL_INDEXING_BLOCKS_AMOUNT__}!g" mempool-config.json
|
sed -i "s!__MEMPOOL_INDEXING_BLOCKS_AMOUNT__!${__MEMPOOL_INDEXING_BLOCKS_AMOUNT__}!g" mempool-config.json
|
||||||
sed -i "s!__MEMPOOL_BLOCKS_SUMMARIES_INDEXING__!${__MEMPOOL_BLOCKS_SUMMARIES_INDEXING__}!g" mempool-config.json
|
sed -i "s!__MEMPOOL_BLOCKS_SUMMARIES_INDEXING__!${__MEMPOOL_BLOCKS_SUMMARIES_INDEXING__}!g" mempool-config.json
|
||||||
|
sed -i "s!__MEMPOOL_GOGGLES_INDEXING__!${__MEMPOOL_GOGGLES_INDEXING__}!g" mempool-config.json
|
||||||
sed -i "s!__MEMPOOL_USE_SECOND_NODE_FOR_MINFEE__!${__MEMPOOL_USE_SECOND_NODE_FOR_MINFEE__}!g" mempool-config.json
|
sed -i "s!__MEMPOOL_USE_SECOND_NODE_FOR_MINFEE__!${__MEMPOOL_USE_SECOND_NODE_FOR_MINFEE__}!g" mempool-config.json
|
||||||
sed -i "s!__MEMPOOL_EXTERNAL_ASSETS__!${__MEMPOOL_EXTERNAL_ASSETS__}!g" mempool-config.json
|
sed -i "s!__MEMPOOL_EXTERNAL_ASSETS__!${__MEMPOOL_EXTERNAL_ASSETS__}!g" mempool-config.json
|
||||||
sed -i "s!__MEMPOOL_EXTERNAL_MAX_RETRY__!${__MEMPOOL_EXTERNAL_MAX_RETRY__}!g" mempool-config.json
|
sed -i "s!__MEMPOOL_EXTERNAL_MAX_RETRY__!${__MEMPOOL_EXTERNAL_MAX_RETRY__}!g" mempool-config.json
|
||||||
|
|
7
frontend/.gitignore
vendored
7
frontend/.gitignore
vendored
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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 }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
<div id="become-sponsor-container">
|
<div id="become-sponsor-container">
|
||||||
<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>
|
||||||
|
|
|
@ -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,8 @@ 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';
|
||||||
|
|
||||||
constructor(private enterpriseService: EnterpriseService) {
|
constructor(private enterpriseService: EnterpriseService) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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({
|
||||||
|
@ -213,4 +214,4 @@ export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges
|
||||||
onResize(): void {
|
onResize(): void {
|
||||||
this.isMobile = window.innerWidth <= 767.98;
|
this.isMobile = window.innerWidth <= 767.98;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
|
@ -66,15 +69,15 @@ export class AccelerationFeesGraphComponent implements OnInit, OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.seoService.setTitle($localize`:@@bcf34abc2d9ed8f45a2f65dd464c46694e9a181e:Acceleration Fees`);
|
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
if (this.widget) {
|
if (this.widget) {
|
||||||
this.miningWindowPreference = '1m';
|
this.miningWindowPreference = '1m';
|
||||||
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);
|
||||||
|
@ -86,6 +89,7 @@ export class AccelerationFeesGraphComponent implements OnInit, OnDestroy {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
this.seoService.setTitle($localize`:@@bcf34abc2d9ed8f45a2f65dd464c46694e9a181e:Acceleration Fees`);
|
||||||
this.miningWindowPreference = this.miningService.getDefaultTimespan('1w');
|
this.miningWindowPreference = this.miningService.getDefaultTimespan('1w');
|
||||||
this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference });
|
this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference });
|
||||||
this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference);
|
this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference);
|
||||||
|
@ -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,
|
||||||
|
|
|
@ -63,66 +63,82 @@ tr, td, th {
|
||||||
}
|
}
|
||||||
|
|
||||||
.txid {
|
.txid {
|
||||||
width: 25%;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
max-width: 30%;
|
|
||||||
@media (max-width: 1060px) and (min-width: 768px) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
@media (max-width: 500px) {
|
@media (max-width: 500px) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.fee-rate {
|
.fee, .block, .status {
|
||||||
width: 20%;
|
width: 15%;
|
||||||
@media (max-width: 1060px) and (min-width: 768px) {
|
|
||||||
text-align: start !important;
|
@media (max-width: 720px) {
|
||||||
}
|
width: 20%;
|
||||||
@media (max-width: 500px) {
|
|
||||||
text-align: start !important;
|
|
||||||
}
|
|
||||||
@media (max-width: 840px) and (min-width: 768px) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
@media (max-width: 410px) {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.bid {
|
.widget {
|
||||||
width: 30%;
|
.txid {
|
||||||
min-width: 150px;
|
width: 30%;
|
||||||
@media (max-width: 840px) and (min-width: 768px) {
|
overflow: hidden;
|
||||||
text-align: start !important;
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 30%;
|
||||||
|
@media (max-width: 1060px) and (min-width: 768px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 410px) {
|
|
||||||
text-align: start !important;
|
.fee-rate {
|
||||||
|
width: 20%;
|
||||||
|
@media (max-width: 1060px) and (min-width: 768px) {
|
||||||
|
text-align: start !important;
|
||||||
|
}
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
text-align: start !important;
|
||||||
|
}
|
||||||
|
@media (max-width: 840px) and (min-width: 768px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (max-width: 410px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.time {
|
.bid {
|
||||||
width: 25%;
|
width: 30%;
|
||||||
}
|
min-width: 150px;
|
||||||
|
@media (max-width: 840px) and (min-width: 768px) {
|
||||||
.fee {
|
text-align: start !important;
|
||||||
width: 35%;
|
}
|
||||||
@media (max-width: 1060px) and (min-width: 768px) {
|
@media (max-width: 410px) {
|
||||||
text-align: start !important;
|
text-align: start !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 500px) {
|
|
||||||
text-align: start !important;
|
.time {
|
||||||
|
width: 25%;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.block {
|
.fee {
|
||||||
width: 20%;
|
width: 30%;
|
||||||
}
|
@media (max-width: 1060px) and (min-width: 768px) {
|
||||||
|
text-align: start !important;
|
||||||
|
}
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
text-align: start !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.status {
|
.block {
|
||||||
width: 20%
|
width: 20%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
width: 20%
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tooltip text */
|
/* Tooltip text */
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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> </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 »</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 »</a></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -80,7 +93,7 @@
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="card list-card">
|
<div class="card list-card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<a class="title-link" href="" [routerLink]="['/acceleration-list' | relativeUrl]">
|
<a class="title-link" href="" [routerLink]="['/acceleration/list' | relativeUrl]">
|
||||||
<h5 class="card-title d-inline" i18n="dashboard.recent-accelerations">Recent Accelerations</h5>
|
<h5 class="card-title d-inline" i18n="dashboard.recent-accelerations">Recent Accelerations</h5>
|
||||||
<span> </span>
|
<span> </span>
|
||||||
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: 'text-top'; font-size: 13px; color: #4a68b9"></fa-icon>
|
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: 'text-top'; font-size: 13px; color: #4a68b9"></fa-icon>
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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[]) => {
|
||||||
this.lastTransactionTxId = transactions[transactions.length - 1].txid;
|
if (transactions && transactions.length) {
|
||||||
this.loadedConfirmedTxCount += transactions.length;
|
this.lastTransactionTxId = transactions[transactions.length - 1].txid;
|
||||||
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() {
|
||||||
|
|
|
@ -19,7 +19,7 @@
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<ng-template #default>
|
<ng-template #default>
|
||||||
‎{{ addPlus && satoshis >= 0 ? '+' : '' }}{{ satoshis / 100000000 | number : digitsInfo }}
|
‎{{ 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>
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -42,6 +42,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: 'and' | 'or' = '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}>();
|
||||||
|
@ -113,7 +114,7 @@ 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -121,8 +122,8 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On
|
||||||
setFilterFlags(flags?: bigint | null): void {
|
setFilterFlags(flags?: bigint | null): void {
|
||||||
this.activeFilterFlags = this.filterFlags || flags || null;
|
this.activeFilterFlags = this.filterFlags || flags || null;
|
||||||
if (this.scene) {
|
if (this.scene) {
|
||||||
if (flags != null) {
|
if (this.activeFilterFlags != null) {
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
@ -523,7 +524,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' && (tx.bigintFlags & flags) > 0n)) {
|
||||||
return defaultColorFunction(tx);
|
return defaultColorFunction(tx);
|
||||||
} else {
|
} else {
|
||||||
return defaultColorFunction(
|
return defaultColorFunction(
|
||||||
|
|
|
@ -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([])
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|
|
@ -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([])
|
||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
|
@ -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">
|
||||||
|
<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">
|
||||||
|
<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"
|
||||||
|
|
|
@ -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'">
|
||||||
|
|
|
@ -47,20 +47,30 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="item" *ngIf="showHalving">
|
<div class="item" *ngIf="showHalving">
|
||||||
<h5 class="card-title" i18n="difficulty-box.next-halving" i18n-ngbTooltip="difficulty-box.next-halving"
|
<h5 class="card-title" i18n="difficulty-box.next-halving">Next Halving</h5>
|
||||||
ngbTooltip="Next Halving" placement="bottom" #averagefee [disableTooltip]="!isEllipsisActive(averagefee)">Next Halving</h5>
|
<div class="card-text" i18n-ngbTooltip="mining.average-fee" [ngbTooltip]="halvingBlocksLeft" [tooltipContext]="{ epochData: epochData }" placement="bottom">
|
||||||
<div class="card-text">
|
<span>{{ timeUntilHalving | date }}</span>
|
||||||
<ng-container *ngTemplateOutlet="epochData.blocksUntilHalving === 1 ? blocksSingular : blocksPlural; context: {$implicit: epochData.blocksUntilHalving }"></ng-container>
|
<div class="symbol" *ngIf="blocksUntilHalving === 1; else approxTime">
|
||||||
<ng-template #blocksPlural let-i i18n="shared.blocks">{{ i }} <span class="shared-block">blocks</span></ng-template>
|
<app-time kind="until" [time]="epochData.timeAvg + now" [fastRender]="false" [fixedRender]="true" [precision]="1" minUnit="minute"></app-time>
|
||||||
<ng-template #blocksSingular let-i i18n="shared.block">{{ i }} <span class="shared-block">block</span></ng-template>
|
</div>
|
||||||
|
<ng-template #approxTime>
|
||||||
|
<div class="symbol">
|
||||||
|
<app-time kind="until" [time]="timeUntilHalving" [fastRender]="false" [fixedRender]="true" [precision]="0" [numUnits]="2" [units]="['year', 'day', 'hour', 'minute']"></app-time>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="symbol"><app-time kind="until" [time]="epochData.timeUntilHalving" [fastRender]="true" [precision]="1"></app-time></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ng-template #halvingBlocksLeft let-epochData="epochData">
|
||||||
|
<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>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
<ng-template #loadingDifficulty>
|
<ng-template #loadingDifficulty>
|
||||||
<div class="difficulty-skeleton loading-container">
|
<div class="difficulty-skeleton loading-container">
|
||||||
<div class="item">
|
<div class="item">
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
|
||||||
import { combineLatest, Observable, timer } from 'rxjs';
|
import { combineLatest, Observable } from 'rxjs';
|
||||||
import { map, switchMap } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
import { StateService } from '../../services/state.service';
|
import { StateService } from '../../services/state.service';
|
||||||
|
|
||||||
interface EpochProgress {
|
interface EpochProgress {
|
||||||
|
@ -15,6 +15,7 @@ interface EpochProgress {
|
||||||
previousRetarget: number;
|
previousRetarget: number;
|
||||||
blocksUntilHalving: number;
|
blocksUntilHalving: number;
|
||||||
timeUntilHalving: number;
|
timeUntilHalving: number;
|
||||||
|
timeAvg: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
@ -26,6 +27,9 @@ interface EpochProgress {
|
||||||
export class DifficultyMiningComponent implements OnInit {
|
export class DifficultyMiningComponent implements OnInit {
|
||||||
isLoadingWebSocket$: Observable<boolean>;
|
isLoadingWebSocket$: Observable<boolean>;
|
||||||
difficultyEpoch$: Observable<EpochProgress>;
|
difficultyEpoch$: Observable<EpochProgress>;
|
||||||
|
blocksUntilHalving: number | null = null;
|
||||||
|
timeUntilHalving = 0;
|
||||||
|
now = new Date().getTime();
|
||||||
|
|
||||||
@Input() showProgress = true;
|
@Input() showProgress = true;
|
||||||
@Input() showHalving = false;
|
@Input() showHalving = false;
|
||||||
|
@ -64,8 +68,9 @@ export class DifficultyMiningComponent implements OnInit {
|
||||||
colorPreviousAdjustments = '#ffffff66';
|
colorPreviousAdjustments = '#ffffff66';
|
||||||
}
|
}
|
||||||
|
|
||||||
const blocksUntilHalving = 210000 - (maxHeight % 210000);
|
this.blocksUntilHalving = 210000 - (maxHeight % 210000);
|
||||||
const timeUntilHalving = new Date().getTime() + (blocksUntilHalving * 600000);
|
this.timeUntilHalving = new Date().getTime() + (this.blocksUntilHalving * 600000);
|
||||||
|
this.now = new Date().getTime();
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
base: `${da.progressPercent.toFixed(2)}%`,
|
base: `${da.progressPercent.toFixed(2)}%`,
|
||||||
|
@ -77,8 +82,9 @@ export class DifficultyMiningComponent implements OnInit {
|
||||||
newDifficultyHeight: da.nextRetargetHeight,
|
newDifficultyHeight: da.nextRetargetHeight,
|
||||||
estimatedRetargetDate: da.estimatedRetargetDate,
|
estimatedRetargetDate: da.estimatedRetargetDate,
|
||||||
previousRetarget: da.previousRetarget,
|
previousRetarget: da.previousRetarget,
|
||||||
blocksUntilHalving,
|
blocksUntilHalving: this.blocksUntilHalving,
|
||||||
timeUntilHalving,
|
timeUntilHalving: this.timeUntilHalving,
|
||||||
|
timeAvg: da.timeAvg,
|
||||||
};
|
};
|
||||||
return data;
|
return data;
|
||||||
})
|
})
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -57,8 +57,6 @@
|
||||||
}
|
}
|
||||||
.chart-widget {
|
.chart-widget {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
|
||||||
height: 240px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pool-distribution {
|
.pool-distribution {
|
||||||
|
|
|
@ -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,28 +87,32 @@ export class HashrateChartComponent implements OnInit {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.hashrateObservable$ = merge(
|
this.hashrateObservable$ = combineLatest(
|
||||||
this.radioGroupForm.get('dateSpan').valueChanges
|
merge(
|
||||||
.pipe(
|
this.radioGroupForm.get('dateSpan').valueChanges
|
||||||
startWith(this.radioGroupForm.controls.dateSpan.value),
|
|
||||||
switchMap((timespan) => {
|
|
||||||
if (!this.widget && !firstRun) {
|
|
||||||
this.storageService.setValue('miningWindowPreference', timespan);
|
|
||||||
}
|
|
||||||
this.timespan = timespan;
|
|
||||||
firstRun = false;
|
|
||||||
this.miningWindowPreference = timespan;
|
|
||||||
this.isLoading = true;
|
|
||||||
return this.apiService.getHistoricalHashrate$(this.timespan);
|
|
||||||
})
|
|
||||||
),
|
|
||||||
this.stateService.chainTip$
|
|
||||||
.pipe(
|
.pipe(
|
||||||
switchMap(() => {
|
startWith(this.radioGroupForm.controls.dateSpan.value),
|
||||||
|
switchMap((timespan) => {
|
||||||
|
if (!this.widget && !firstRun) {
|
||||||
|
this.storageService.setValue('miningWindowPreference', timespan);
|
||||||
|
}
|
||||||
|
this.timespan = timespan;
|
||||||
|
firstRun = false;
|
||||||
|
this.miningWindowPreference = timespan;
|
||||||
|
this.isLoading = true;
|
||||||
return this.apiService.getHistoricalHashrate$(this.timespan);
|
return this.apiService.getHistoricalHashrate$(this.timespan);
|
||||||
})
|
})
|
||||||
)
|
),
|
||||||
|
this.stateService.chainTip$
|
||||||
|
.pipe(
|
||||||
|
switchMap(() => {
|
||||||
|
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,
|
||||||
|
|
|
@ -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,56 +95,9 @@ 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 (!grouped.hasOwnProperty(hashrate.poolName)) {
|
|
||||||
grouped[hashrate.poolName] = [];
|
|
||||||
}
|
|
||||||
grouped[hashrate.poolName].push(hashrate);
|
|
||||||
}
|
|
||||||
|
|
||||||
const series = [];
|
|
||||||
const legends = [];
|
|
||||||
for (const name in grouped) {
|
|
||||||
series.push({
|
|
||||||
zlevel: 0,
|
|
||||||
stack: 'Total',
|
|
||||||
name: name,
|
|
||||||
showSymbol: false,
|
|
||||||
symbol: 'none',
|
|
||||||
data: grouped[name].map((val) => [val.timestamp * 1000, val.share * 100]),
|
|
||||||
type: 'line',
|
|
||||||
lineStyle: { width: 0 },
|
|
||||||
areaStyle: { opacity: 1 },
|
|
||||||
smooth: true,
|
|
||||||
color: poolsColor[name.replace(/[^a-zA-Z0-9]/g, '').toLowerCase()],
|
|
||||||
emphasis: {
|
|
||||||
disabled: true,
|
|
||||||
scale: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
legends.push({
|
|
||||||
name: name,
|
|
||||||
inactiveColor: 'rgb(110, 112, 121)',
|
|
||||||
textStyle: {
|
|
||||||
color: 'white',
|
|
||||||
},
|
|
||||||
icon: 'roundRect',
|
|
||||||
itemStyle: {
|
|
||||||
color: poolsColor[name.replace(/[^a-zA-Z0-9]/g, "").toLowerCase()],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.prepareChartOptions({
|
|
||||||
legends: legends,
|
|
||||||
series: series,
|
|
||||||
});
|
|
||||||
this.isLoading = false;
|
|
||||||
|
|
||||||
if (series.length === 0) {
|
if (series.length === 0) {
|
||||||
this.cd.markForCheck();
|
this.cd.markForCheck();
|
||||||
throw new Error();
|
throw new Error();
|
||||||
|
@ -156,6 +117,77 @@ export class HashrateChartPoolsComponent implements OnInit {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 legends = [];
|
||||||
|
for (const name of sortedPools) {
|
||||||
|
const data = sortedTimes.map(({ time, hashrates }) => {
|
||||||
|
return [time * 1000, (hashrates[name]?.share || 0) * 100];
|
||||||
|
});
|
||||||
|
series.push({
|
||||||
|
zlevel: 0,
|
||||||
|
stack: 'Total',
|
||||||
|
name: name,
|
||||||
|
showSymbol: false,
|
||||||
|
symbol: 'none',
|
||||||
|
data,
|
||||||
|
type: 'line',
|
||||||
|
lineStyle: { width: 0 },
|
||||||
|
areaStyle: { opacity: 1 },
|
||||||
|
smooth: true,
|
||||||
|
color: poolsColor[name.replace(/[^a-zA-Z0-9]/g, '').toLowerCase()],
|
||||||
|
emphasis: {
|
||||||
|
disabled: true,
|
||||||
|
scale: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
legends.push({
|
||||||
|
name: name,
|
||||||
|
inactiveColor: 'rgb(110, 112, 121)',
|
||||||
|
textStyle: {
|
||||||
|
color: 'white',
|
||||||
|
},
|
||||||
|
icon: 'roundRect',
|
||||||
|
itemStyle: {
|
||||||
|
color: poolsColor[name.replace(/[^a-zA-Z0-9]/g, "").toLowerCase()],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.prepareChartOptions({
|
||||||
|
legends: legends,
|
||||||
|
series: series,
|
||||||
|
});
|
||||||
|
this.isLoading = false;
|
||||||
|
|
||||||
|
return series;
|
||||||
|
}
|
||||||
|
|
||||||
prepareChartOptions(data) {
|
prepareChartOptions(data) {
|
||||||
let title: object;
|
let title: object;
|
||||||
if (data.series.length === 0) {
|
if (data.series.length === 0) {
|
||||||
|
@ -256,6 +288,7 @@ export class HashrateChartPoolsComponent implements OnInit {
|
||||||
},
|
},
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
|
this.cd.markForCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
onChartInit(ec) {
|
onChartInit(ec) {
|
||||||
|
|
|
@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -78,6 +78,9 @@
|
||||||
<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 class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" id="btn-audit">
|
||||||
|
<a class="nav-link" [routerLink]="['/audit']" (click)="collapse()"><fa-icon [icon]="['fas', 'scale-balanced']" [fixedWidth]="true" i18n-title="master-page.btc-reserves-audit" title="BTC Reserves Audit"></fa-icon></a>
|
||||||
|
</li>
|
||||||
<li [hidden]="isMobile" class="nav-item mr-2" routerLinkActive="active" id="btn-docs">
|
<li [hidden]="isMobile" 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>
|
||||||
|
|
|
@ -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>
|
|
@ -0,0 +1,45 @@
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 160px;
|
||||||
|
}
|
||||||
|
.address.widget {
|
||||||
|
width: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount {
|
||||||
|
width: 25%;
|
||||||
|
}
|
||||||
|
.amount.widget {
|
||||||
|
width: 40%;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
<div *ngIf="(federationAddresses$ | async) as federationAddresses; 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> <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">{{ federationAddresses.length }} <span i18n="shared.addresses">addresses</span></div>
|
||||||
|
<span class="fiat" *ngIf="(federationAddressesOneMonthAgo$ | async) as federationAddressesOneMonthAgo; else loadingSkeleton" i18n-ngbTooltip="liquid.percentage-change-last-month" ngbTooltip="Percentage change past month" placement="bottom">
|
||||||
|
<app-change [current]="federationAddresses.length" [previous]="federationAddressesOneMonthAgo.addresses_count_one_month"></app-change>
|
||||||
|
</span>
|
||||||
|
</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> <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: 2px; margin-bottom: 5px;"></div>
|
||||||
|
</ng-template>
|
|
@ -0,0 +1,75 @@
|
||||||
|
.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: 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-link, .title-link:hover, .title-link:focus, .title-link:active {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { FederationAddress } from '../../../interfaces/node-api.interface';
|
||||||
|
|
||||||
|
@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() federationAddresses$: Observable<FederationAddress[]>;
|
||||||
|
@Input() federationAddressesOneMonthAgo$: Observable<any>;
|
||||||
|
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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">
|
||||||
|
‎{{ 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>
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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>
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,139 @@
|
||||||
|
<div class="container-xl">
|
||||||
|
<div [ngClass]="{'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="recentPegs$ | 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 | slice:(page - 1) * pageSize:page * pageSize">
|
||||||
|
<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">
|
||||||
|
‎{{ 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: 140px"></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: 140px"></span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
</ng-template>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<ngb-pagination *ngIf="!widget && recentPegs$ | async as pegs" class="pagination-container float-right mt-2" [class]="isLoading ? 'disabled' : ''"
|
||||||
|
[collectionSize]="pegs.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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<ng-template #noRedeem>
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
</ng-template>
|
|
@ -0,0 +1,120 @@
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 500px) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,154 @@
|
||||||
|
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, 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() recentPegIns$: Observable<RecentPeg[]> = of([]);
|
||||||
|
@Input() recentPegOuts$: Observable<RecentPeg[]> = of([]);
|
||||||
|
|
||||||
|
env: Env;
|
||||||
|
isLoading = true;
|
||||||
|
page = 1;
|
||||||
|
pageSize = 15;
|
||||||
|
maxSize = window.innerWidth <= 767.98 ? 3 : 5;
|
||||||
|
skeletonLines: number[] = [];
|
||||||
|
auditStatus$: Observable<AuditStatus>;
|
||||||
|
auditUpdated$: Observable<boolean>;
|
||||||
|
federationUtxos$: Observable<FederationUtxo[]>;
|
||||||
|
recentPegs$: Observable<RecentPeg[]>;
|
||||||
|
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,
|
||||||
|
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.federationUtxos$ = this.auditUpdated$.pipe(
|
||||||
|
filter(auditUpdated => auditUpdated === true),
|
||||||
|
throttleTime(40000),
|
||||||
|
switchMap(_ => this.apiService.federationUtxos$()),
|
||||||
|
share()
|
||||||
|
);
|
||||||
|
|
||||||
|
this.recentPegIns$ = this.federationUtxos$.pipe(
|
||||||
|
map(federationUtxos => federationUtxos.filter(utxo => utxo.pegtxid).map(utxo => {
|
||||||
|
return {
|
||||||
|
txid: utxo.pegtxid,
|
||||||
|
txindex: utxo.pegindex,
|
||||||
|
amount: utxo.amount,
|
||||||
|
bitcoinaddress: utxo.bitcoinaddress,
|
||||||
|
bitcointxid: utxo.txid,
|
||||||
|
bitcoinindex: utxo.txindex,
|
||||||
|
blocktime: utxo.pegblocktime,
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
share()
|
||||||
|
);
|
||||||
|
|
||||||
|
this.recentPegOuts$ = this.auditUpdated$.pipe(
|
||||||
|
filter(auditUpdated => auditUpdated === true),
|
||||||
|
throttleTime(40000),
|
||||||
|
switchMap(_ => this.apiService.recentPegOuts$()),
|
||||||
|
share()
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
this.recentPegs$ = combineLatest([
|
||||||
|
this.recentPegIns$,
|
||||||
|
this.recentPegOuts$
|
||||||
|
]).pipe(
|
||||||
|
map(([recentPegIns, recentPegOuts]) => {
|
||||||
|
return [
|
||||||
|
...recentPegIns,
|
||||||
|
...recentPegOuts
|
||||||
|
].sort((a, b) => {
|
||||||
|
return b.blocktime - a.blocktime;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
filter(recentPegs => recentPegs.length > 0),
|
||||||
|
tap(_ => this.isLoading = false),
|
||||||
|
share()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroy$.next(1);
|
||||||
|
this.destroy$.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
pageChange(page: number): void {
|
||||||
|
this.page = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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> <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> <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>
|
|
@ -0,0 +1,79 @@
|
||||||
|
.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: 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;
|
||||||
|
}
|
|
@ -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 {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,98 @@
|
||||||
|
<div class="container-xl dashboard-container" *ngIf="(auditStatus$ | async)?.isAuditSynced; else auditInProgress">
|
||||||
|
|
||||||
|
<div class="row row-cols-1 row-cols-md-2">
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<app-reserves-supply-stats [currentPeg$]="currentPeg$" [currentReserves$]="currentReserves$"></app-reserves-supply-stats>
|
||||||
|
<app-reserves-ratio [currentPeg]="currentPeg$ | async" [currentReserves]="currentReserves$ | async"></app-reserves-ratio>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col" style="margin-bottom: 1.47rem">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">
|
||||||
|
<app-reserves-ratio-stats [fullHistory$]="fullHistory$"></app-reserves-ratio-stats>
|
||||||
|
</div>
|
||||||
|
<div class="card-body pl-0" style="padding-top: 10px;">
|
||||||
|
<app-reserves-ratio-graph [data]="fullHistory$ | async"></app-reserves-ratio-graph>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<app-recent-pegs-stats [pegsVolume$]="pegsVolume$"></app-recent-pegs-stats>
|
||||||
|
<app-recent-pegs-list [recentPegIns$]="recentPegIns$" [recentPegOuts$]="recentPegOuts$"[widget]="true"></app-recent-pegs-list>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col" style="margin-bottom: 1.47rem">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<app-federation-addresses-stats [federationAddresses$]="federationAddresses$" [federationAddressesOneMonthAgo$]="federationAddressesOneMonthAgo$"></app-federation-addresses-stats>
|
||||||
|
<app-federation-addresses-list [federationAddresses$]="federationAddresses$" [widget]="true"></app-federation-addresses-list>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<ng-template #loadingSkeleton>
|
||||||
|
<div class="container-xl dashboard-container">
|
||||||
|
|
||||||
|
<div class="row row-cols-1 row-cols-md-2">
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<app-reserves-supply-stats></app-reserves-supply-stats>
|
||||||
|
<app-reserves-ratio></app-reserves-ratio>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col" style="margin-bottom: 1.47rem">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">
|
||||||
|
<app-reserves-ratio-stats></app-reserves-ratio-stats>
|
||||||
|
</div>
|
||||||
|
<div class="card-body pl-0" style="padding-top: 10px;">
|
||||||
|
<app-reserves-ratio-graph></app-reserves-ratio-graph>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<app-recent-pegs-stats></app-recent-pegs-stats>
|
||||||
|
<app-recent-pegs-list [widget]="true"></app-recent-pegs-list>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col" style="margin-bottom: 1.47rem">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<app-federation-addresses-stats></app-federation-addresses-stats>
|
||||||
|
<app-federation-addresses-list [widget]="true"></app-federation-addresses-list>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template #auditInProgress>
|
||||||
|
<ng-container *ngIf="(auditStatus$ | async) as auditStatus; else loadingSkeleton">
|
||||||
|
<div class="in-progress-message" *ngIf="auditStatus.lastBlockAudit && auditStatus.bitcoinHeaders; else loadingSkeleton">
|
||||||
|
<span i18n="liquid.audit-in-progress">Audit in progress: Bitcoin block height #{{ auditStatus.lastBlockAudit }} / #{{ auditStatus.bitcoinHeaders }}</span>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</ng-template>
|
|
@ -0,0 +1,138 @@
|
||||||
|
.dashboard-container {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
.col {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background-color: #1d1f31;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body.pool-ranking {
|
||||||
|
padding: 1.25rem 0.25rem 0.75rem 0.25rem;
|
||||||
|
}
|
||||||
|
.card-text {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blockchain-container {
|
||||||
|
position: relative;
|
||||||
|
overflow-x: scroll;
|
||||||
|
overflow-y: hidden;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blockchain-container::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-border {
|
||||||
|
-webkit-mask-image: linear-gradient(to right, transparent 0%, black 10%, black 80%, transparent 100%)
|
||||||
|
}
|
||||||
|
|
||||||
|
.in-progress-message {
|
||||||
|
position: relative;
|
||||||
|
color: #ffffff91;
|
||||||
|
margin-top: 20px;
|
||||||
|
text-align: center;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-padding {
|
||||||
|
padding: 24px 20px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-wrapper {
|
||||||
|
.card {
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
.card-body {
|
||||||
|
display: flex;
|
||||||
|
flex: inherit;
|
||||||
|
text-align: center;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-around;
|
||||||
|
padding: 22px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-loader {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
&:first-child {
|
||||||
|
max-width: 90px;
|
||||||
|
margin: 15px auto 3px;
|
||||||
|
}
|
||||||
|
&:last-child {
|
||||||
|
margin: 10px auto 3px;
|
||||||
|
max-width: 55px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-text {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-link, .title-link:hover, .title-link:focus, .title-link:active {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lastest-blocks-table {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
tr, td, th {
|
||||||
|
border: 0px;
|
||||||
|
padding-top: 0.65rem !important;
|
||||||
|
padding-bottom: 0.8rem !important;
|
||||||
|
}
|
||||||
|
.table-cell-height {
|
||||||
|
width: 25%;
|
||||||
|
}
|
||||||
|
.table-cell-fee {
|
||||||
|
width: 25%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.table-cell-pool {
|
||||||
|
text-align: left;
|
||||||
|
width: 30%;
|
||||||
|
|
||||||
|
@media (max-width: 875px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pool-name {
|
||||||
|
margin-left: 1em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.table-cell-acceleration-count {
|
||||||
|
text-align: right;
|
||||||
|
width: 20%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
height: 385px;
|
||||||
|
}
|
||||||
|
.list-card {
|
||||||
|
height: 410px;
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mempool-block-wrapper {
|
||||||
|
max-height: 380px;
|
||||||
|
max-width: 380px;
|
||||||
|
margin: auto;
|
||||||
|
}
|
|
@ -0,0 +1,212 @@
|
||||||
|
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
|
||||||
|
import { SeoService } from '../../../services/seo.service';
|
||||||
|
import { WebsocketService } from '../../../services/websocket.service';
|
||||||
|
import { StateService } from '../../../services/state.service';
|
||||||
|
import { Observable, Subject, combineLatest, delayWhen, filter, interval, map, of, share, shareReplay, startWith, switchMap, takeUntil, tap, throttleTime, timer } from 'rxjs';
|
||||||
|
import { ApiService } from '../../../services/api.service';
|
||||||
|
import { AuditStatus, CurrentPegs, FederationAddress, FederationUtxo, PegsVolume, RecentPeg } from '../../../interfaces/node-api.interface';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-reserves-audit-dashboard',
|
||||||
|
templateUrl: './reserves-audit-dashboard.component.html',
|
||||||
|
styleUrls: ['./reserves-audit-dashboard.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class ReservesAuditDashboardComponent implements OnInit {
|
||||||
|
auditStatus$: Observable<AuditStatus>;
|
||||||
|
auditUpdated$: Observable<boolean>;
|
||||||
|
currentPeg$: Observable<CurrentPegs>;
|
||||||
|
currentReserves$: Observable<CurrentPegs>;
|
||||||
|
federationUtxos$: Observable<FederationUtxo[]>;
|
||||||
|
recentPegIns$: Observable<RecentPeg[]>;
|
||||||
|
recentPegOuts$: Observable<RecentPeg[]>;
|
||||||
|
pegsVolume$: Observable<PegsVolume[]>;
|
||||||
|
federationAddresses$: Observable<FederationAddress[]>;
|
||||||
|
federationAddressesOneMonthAgo$: Observable<any>;
|
||||||
|
liquidPegsMonth$: Observable<any>;
|
||||||
|
liquidReservesMonth$: Observable<any>;
|
||||||
|
fullHistory$: Observable<any>;
|
||||||
|
isLoad: boolean = true;
|
||||||
|
private lastPegBlockUpdate: number = 0;
|
||||||
|
private lastPegAmount: string = '';
|
||||||
|
private lastReservesBlockUpdate: number = 0;
|
||||||
|
|
||||||
|
private destroy$ = new Subject();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private seoService: SeoService,
|
||||||
|
private websocketService: WebsocketService,
|
||||||
|
private apiService: ApiService,
|
||||||
|
private stateService: StateService,
|
||||||
|
) {
|
||||||
|
this.seoService.setTitle($localize`:@@liquid.reserves-audit:Reserves Audit Dashboard`);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.websocketService.want(['blocks', 'mempool-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.lastPegAmount = currentPegAmount;
|
||||||
|
return of(blockAuditCheck || amountCheck);
|
||||||
|
}),
|
||||||
|
share()
|
||||||
|
);
|
||||||
|
|
||||||
|
this.currentReserves$ = this.auditUpdated$.pipe(
|
||||||
|
filter(auditUpdated => auditUpdated === true),
|
||||||
|
throttleTime(40000),
|
||||||
|
switchMap(_ =>
|
||||||
|
this.apiService.liquidReserves$().pipe(
|
||||||
|
filter((currentReserves) => currentReserves.lastBlockUpdate >= this.lastReservesBlockUpdate),
|
||||||
|
tap((currentReserves) => {
|
||||||
|
this.lastReservesBlockUpdate = currentReserves.lastBlockUpdate;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
|
share()
|
||||||
|
);
|
||||||
|
|
||||||
|
this.federationUtxos$ = this.auditUpdated$.pipe(
|
||||||
|
filter(auditUpdated => auditUpdated === true),
|
||||||
|
throttleTime(40000),
|
||||||
|
switchMap(_ => this.apiService.federationUtxos$()),
|
||||||
|
share()
|
||||||
|
);
|
||||||
|
|
||||||
|
this.recentPegIns$ = this.federationUtxos$.pipe(
|
||||||
|
map(federationUtxos => federationUtxos.filter(utxo => utxo.pegtxid).map(utxo => {
|
||||||
|
return {
|
||||||
|
txid: utxo.pegtxid,
|
||||||
|
txindex: utxo.pegindex,
|
||||||
|
amount: utxo.amount,
|
||||||
|
bitcoinaddress: utxo.bitcoinaddress,
|
||||||
|
bitcointxid: utxo.txid,
|
||||||
|
bitcoinindex: utxo.txindex,
|
||||||
|
blocktime: utxo.pegblocktime,
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
share()
|
||||||
|
);
|
||||||
|
|
||||||
|
this.recentPegOuts$ = this.auditUpdated$.pipe(
|
||||||
|
filter(auditUpdated => auditUpdated === true),
|
||||||
|
throttleTime(40000),
|
||||||
|
switchMap(_ => this.apiService.recentPegOuts$()),
|
||||||
|
share()
|
||||||
|
);
|
||||||
|
|
||||||
|
this.pegsVolume$ = this.auditUpdated$.pipe(
|
||||||
|
filter(auditUpdated => auditUpdated === true),
|
||||||
|
throttleTime(40000),
|
||||||
|
switchMap(_ => this.apiService.pegsVolume$()),
|
||||||
|
share()
|
||||||
|
);
|
||||||
|
|
||||||
|
this.federationAddresses$ = this.auditUpdated$.pipe(
|
||||||
|
filter(auditUpdated => auditUpdated === true),
|
||||||
|
throttleTime(40000),
|
||||||
|
switchMap(_ => this.apiService.federationAddresses$()),
|
||||||
|
share()
|
||||||
|
);
|
||||||
|
|
||||||
|
this.federationAddressesOneMonthAgo$ = interval(60 * 60 * 1000)
|
||||||
|
.pipe(
|
||||||
|
startWith(0),
|
||||||
|
switchMap(() => this.apiService.federationAddressesOneMonthAgo$())
|
||||||
|
);
|
||||||
|
|
||||||
|
this.liquidPegsMonth$ = interval(60 * 60 * 1000)
|
||||||
|
.pipe(
|
||||||
|
startWith(0),
|
||||||
|
switchMap(() => this.apiService.listLiquidPegsMonth$()),
|
||||||
|
map((pegs) => {
|
||||||
|
const labels = pegs.map(stats => stats.date);
|
||||||
|
const series = pegs.map(stats => parseFloat(stats.amount) / 100000000);
|
||||||
|
series.reduce((prev, curr, i) => series[i] = prev + curr, 0);
|
||||||
|
return {
|
||||||
|
series,
|
||||||
|
labels
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
share(),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.liquidReservesMonth$ = interval(60 * 60 * 1000).pipe(
|
||||||
|
startWith(0),
|
||||||
|
switchMap(() => this.apiService.listLiquidReservesMonth$()),
|
||||||
|
map(reserves => {
|
||||||
|
const labels = reserves.map(stats => stats.date);
|
||||||
|
const series = reserves.map(stats => parseFloat(stats.amount) / 100000000);
|
||||||
|
return {
|
||||||
|
series,
|
||||||
|
labels
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
share()
|
||||||
|
);
|
||||||
|
|
||||||
|
this.fullHistory$ = combineLatest([this.liquidPegsMonth$, this.currentPeg$, this.liquidReservesMonth$, this.currentReserves$])
|
||||||
|
.pipe(
|
||||||
|
map(([liquidPegs, currentPeg, liquidReserves, currentReserves]) => {
|
||||||
|
liquidPegs.series[liquidPegs.series.length - 1] = parseFloat(currentPeg.amount) / 100000000;
|
||||||
|
|
||||||
|
if (liquidPegs.series.length === liquidReserves?.series.length) {
|
||||||
|
liquidReserves.series[liquidReserves.series.length - 1] = parseFloat(currentReserves?.amount) / 100000000;
|
||||||
|
} else if (liquidPegs.series.length === liquidReserves?.series.length + 1) {
|
||||||
|
liquidReserves.series.push(parseFloat(currentReserves?.amount) / 100000000);
|
||||||
|
liquidReserves.labels.push(liquidPegs.labels[liquidPegs.labels.length - 1]);
|
||||||
|
} else {
|
||||||
|
liquidReserves = {
|
||||||
|
series: [],
|
||||||
|
labels: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
liquidPegs,
|
||||||
|
liquidReserves
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
share()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroy$.next(1);
|
||||||
|
this.destroy$.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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.toFixed(5) }}
|
||||||
|
</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>
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
<div class="echarts" echarts [initOpts]="ratioHistoryChartInitOptions" [options]="ratioHistoryChartOptions" (chartRendered)="rendered()"></div>
|
||||||
|
<div class="text-center loadingGraphs" *ngIf="isLoading">
|
||||||
|
<div class="spinner-border text-light"></div>
|
||||||
|
</div>
|
|
@ -0,0 +1,6 @@
|
||||||
|
.loadingGraphs {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: calc(50% - 16px);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
|
@ -0,0 +1,195 @@
|
||||||
|
import { Component, Inject, LOCALE_ID, ChangeDetectionStrategy, Input, OnChanges, OnInit } from '@angular/core';
|
||||||
|
import { formatDate, formatNumber } from '@angular/common';
|
||||||
|
import { EChartsOption } from '../../../graphs/echarts';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-reserves-ratio-graph',
|
||||||
|
templateUrl: './reserves-ratio-graph.component.html',
|
||||||
|
styleUrls: ['./reserves-ratio-graph.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class ReservesRatioGraphComponent implements OnInit, OnChanges {
|
||||||
|
@Input() data: any;
|
||||||
|
ratioHistoryChartOptions: EChartsOption;
|
||||||
|
ratioSeries: number[] = [];
|
||||||
|
|
||||||
|
height: number | string = '200';
|
||||||
|
right: number | string = '10';
|
||||||
|
top: number | string = '20';
|
||||||
|
left: number | string = '50';
|
||||||
|
template: ('widget' | 'advanced') = 'widget';
|
||||||
|
isLoading = true;
|
||||||
|
|
||||||
|
ratioHistoryChartInitOptions = {
|
||||||
|
renderer: 'svg'
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(LOCALE_ID) private locale: string,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.isLoading = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges() {
|
||||||
|
if (!this.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Compute the ratio series: the ratio of the reserves to the pegs
|
||||||
|
this.ratioSeries = this.data.liquidReserves.series.map((value: number, index: number) => value / this.data.liquidPegs.series[index]);
|
||||||
|
// Truncate the ratio series and labels series to last 3 years
|
||||||
|
this.ratioSeries = this.ratioSeries.slice(Math.max(this.ratioSeries.length - 36, 0));
|
||||||
|
this.data.liquidPegs.labels = this.data.liquidPegs.labels.slice(Math.max(this.data.liquidPegs.labels.length - 36, 0));
|
||||||
|
// Cut the values that are too high or too low
|
||||||
|
this.ratioSeries = this.ratioSeries.map((value: number) => Math.min(Math.max(value, 0.995), 1.005));
|
||||||
|
this.ratioHistoryChartOptions = this.createChartOptions(this.ratioSeries, this.data.liquidPegs.labels);
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered() {
|
||||||
|
if (!this.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
createChartOptions(ratioSeries: number[], labels: string[]): EChartsOption {
|
||||||
|
return {
|
||||||
|
grid: {
|
||||||
|
height: this.height,
|
||||||
|
right: this.right,
|
||||||
|
top: this.top,
|
||||||
|
left: this.left,
|
||||||
|
},
|
||||||
|
animation: false,
|
||||||
|
dataZoom: [{
|
||||||
|
type: 'inside',
|
||||||
|
realtime: true,
|
||||||
|
zoomOnMouseWheel: (this.template === 'advanced') ? true : false,
|
||||||
|
maxSpan: 100,
|
||||||
|
minSpan: 10,
|
||||||
|
}, {
|
||||||
|
show: (this.template === 'advanced') ? true : false,
|
||||||
|
type: 'slider',
|
||||||
|
brushSelect: false,
|
||||||
|
realtime: true,
|
||||||
|
selectedDataBackground: {
|
||||||
|
lineStyle: {
|
||||||
|
color: '#fff',
|
||||||
|
opacity: 0.45,
|
||||||
|
},
|
||||||
|
areaStyle: {
|
||||||
|
opacity: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
position: (pos, params, el, elRect, size) => {
|
||||||
|
const obj = { top: -20 };
|
||||||
|
obj[['left', 'right'][+(pos[0] < size.viewSize[0] / 2)]] = 80;
|
||||||
|
return obj;
|
||||||
|
},
|
||||||
|
extraCssText: `width: ${(this.template === 'widget') ? '125px' : '135px'};
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;`,
|
||||||
|
axisPointer: {
|
||||||
|
type: 'line',
|
||||||
|
},
|
||||||
|
formatter: (params: any) => {
|
||||||
|
const colorSpan = (color: string) => `<span class="indicator" style="background-color: ${color};"></span>`;
|
||||||
|
let itemFormatted = '<div class="title">' + params[0].axisValue + '</div>';
|
||||||
|
const item = params[0];
|
||||||
|
const formattedValue = formatNumber(item.value, this.locale, '1.5-5');
|
||||||
|
const symbol = (item.value === 1.005) ? '≥ ' : (item.value === 0.995) ? '≤ ' : '';
|
||||||
|
itemFormatted += `<div class="item">
|
||||||
|
<div class="indicator-container">${colorSpan(item.color)}</div>
|
||||||
|
<div style="margin-right: 5px"></div>
|
||||||
|
<div class="value">${symbol}${formattedValue}</div>
|
||||||
|
</div>`;
|
||||||
|
return `<div class="tx-wrapper-tooltip-chart ${(this.template === 'advanced') ? 'tx-wrapper-tooltip-chart-advanced' : ''}">${itemFormatted}</div>`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
axisLabel: {
|
||||||
|
align: 'center',
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 12
|
||||||
|
},
|
||||||
|
boundaryGap: false,
|
||||||
|
data: labels.map((value: any) => `${formatDate(value, 'MMM\ny', this.locale)}`),
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
axisLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
type: 'dotted',
|
||||||
|
color: '#ffffff66',
|
||||||
|
opacity: 0.25,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
min: 0.995,
|
||||||
|
max: 1.005,
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
data: ratioSeries,
|
||||||
|
name: '',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: false,
|
||||||
|
lineStyle: {
|
||||||
|
width: 3,
|
||||||
|
|
||||||
|
},
|
||||||
|
markLine: {
|
||||||
|
silent: true,
|
||||||
|
symbol: 'none',
|
||||||
|
lineStyle: {
|
||||||
|
color: '#fff',
|
||||||
|
opacity: 1,
|
||||||
|
width: 1,
|
||||||
|
},
|
||||||
|
data: [{
|
||||||
|
yAxis: 1,
|
||||||
|
label: {
|
||||||
|
show: false,
|
||||||
|
color: '#ffffff',
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
visualMap: {
|
||||||
|
show: false,
|
||||||
|
top: 50,
|
||||||
|
right: 10,
|
||||||
|
pieces: [{
|
||||||
|
gt: 0,
|
||||||
|
lte: 0.999,
|
||||||
|
color: '#D81B60'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
gt: 0.999,
|
||||||
|
lte: 1.001,
|
||||||
|
color: '#FDD835'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
gt: 1.001,
|
||||||
|
lte: 2,
|
||||||
|
color: '#7CB342'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
outOfRange: {
|
||||||
|
color: '#999'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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>
|
|
@ -0,0 +1,6 @@
|
||||||
|
.loadingGraphs {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: calc(50% - 16px);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
|
@ -0,0 +1,126 @@
|
||||||
|
import { Component, ChangeDetectionStrategy, Input, OnChanges, OnInit } 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() {
|
||||||
|
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 {
|
||||||
|
return {
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'gauge',
|
||||||
|
startAngle: 180,
|
||||||
|
endAngle: 0,
|
||||||
|
center: ['50%', '70%'],
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
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: '50%',
|
||||||
|
width: 16,
|
||||||
|
offsetCenter: [0, '-27%'],
|
||||||
|
itemStyle: {
|
||||||
|
color: 'auto'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisTick: {
|
||||||
|
length: 12,
|
||||||
|
lineStyle: {
|
||||||
|
color: 'auto',
|
||||||
|
width: 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
length: 20,
|
||||||
|
lineStyle: {
|
||||||
|
color: 'auto',
|
||||||
|
width: 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
show: true,
|
||||||
|
offsetCenter: [0, '-117.5%'],
|
||||||
|
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).toFixed(5);
|
||||||
|
},
|
||||||
|
color: 'inherit'
|
||||||
|
},
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
value: parseFloat(currentReserves.amount) / parseFloat(currentPeg.amount),
|
||||||
|
name: 'Peg-O-Meter'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,44 @@
|
||||||
|
<div *ngIf="(currentPeg$ | async) as currentPeg; else loadingData">
|
||||||
|
<div *ngIf="(currentReserves$ | async) as currentReserves; else loadingData">
|
||||||
|
<div class="fee-estimation-container">
|
||||||
|
<div class="item">
|
||||||
|
<h5 class="card-title" i18n="dashboard.lbtc-pegs-in-circulation">L-BTC in circulation</h5>
|
||||||
|
<div class="card-text">
|
||||||
|
<div class="fee-text">{{ (+currentPeg.amount) / 100000000 | number: '1.2-2' }} <span>L-BTC</span></div>
|
||||||
|
<span class="fiat">
|
||||||
|
<span>As of block <a [routerLink]="['/block', currentPeg.hash]">{{ currentPeg.lastBlockUpdate }}</a></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<h5 class="card-title" i18n="dashboard.btc-reserves">BTC Reserves</h5>
|
||||||
|
<div 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>As of block <a href="{{ env.MEMPOOL_WEBSITE_URL + '/block/' + currentReserves.hash }}" target="_blank" style="color:#b86d12">{{ currentReserves.lastBlockUpdate }}</a></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-template #loadingData>
|
||||||
|
<div class="fee-estimation-container loading-container">
|
||||||
|
<div class="item">
|
||||||
|
<h5 class="card-title" i18n="dashboard.lbtc-pegs-in-circulation">L-BTC in circulation</h5>
|
||||||
|
<div class="card-text">
|
||||||
|
<div class="skeleton-loader"></div>
|
||||||
|
<div class="skeleton-loader"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<h5 class="card-title" i18n="dashboard.btc-reserves">BTC Reserves</h5>
|
||||||
|
<div class="card-text">
|
||||||
|
<div class="skeleton-loader"></div>
|
||||||
|
<div class="skeleton-loader"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
|
@ -0,0 +1,73 @@
|
||||||
|
.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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -53,7 +53,10 @@
|
||||||
<a class="nav-link" [routerLink]="['/' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'tachometer-alt']" [fixedWidth]="true" i18n-title="master-page.dashboard" title="Dashboard"></fa-icon></a>
|
<a class="nav-link" [routerLink]="['/' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'tachometer-alt']" [fixedWidth]="true" i18n-title="master-page.dashboard" title="Dashboard"></fa-icon></a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" id="btn-home" *ngIf="stateService.env.ACCELERATOR">
|
<li class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" id="btn-home" *ngIf="stateService.env.ACCELERATOR">
|
||||||
<a class="nav-link" [routerLink]="['/acceleration' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'rocket']" [fixedWidth]="true" i18n-title="master-page.accelerator-dashboard" title="Accelerator Dashboard"></fa-icon></a>
|
<a class="nav-link" [routerLink]="['/acceleration' | relativeUrl]" (click)="collapse()">
|
||||||
|
<fa-icon [icon]="['fas', 'rocket']" [fixedWidth]="true" i18n-title="master-page.accelerator-dashboard" title="Accelerator Dashboard"></fa-icon>
|
||||||
|
<span class="badge badge-pill badge-warning beta" i18n="beta">beta</span>
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" id="btn-pools" *ngIf="stateService.env.MINING_DASHBOARD">
|
<li class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" id="btn-pools" *ngIf="stateService.env.MINING_DASHBOARD">
|
||||||
<a class="nav-link" [routerLink]="['/mining' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'hammer']" [fixedWidth]="true" i18n-title="mining.mining-dashboard" title="Mining Dashboard"></fa-icon></a>
|
<a class="nav-link" [routerLink]="['/mining' | relativeUrl]" (click)="collapse()"><fa-icon [icon]="['fas', 'hammer']" [fixedWidth]="true" i18n-title="mining.mining-dashboard" title="Mining Dashboard"></fa-icon></a>
|
||||||
|
|
|
@ -211,7 +211,15 @@ nav {
|
||||||
margin: 24px 0px 0px -15px;
|
margin: 24px 0px 0px -15px;
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
@media (max-width: 767.98px) {
|
@media (max-width: 767.98px) {
|
||||||
margin: 33px 0px 0px -19px;
|
margin: 30px 0px 0px -19px;
|
||||||
|
font-size: 7px;
|
||||||
|
}
|
||||||
|
@media (max-width: 3429px) {
|
||||||
|
margin: 25px 0px 0px -19px;
|
||||||
|
font-size: 7px;
|
||||||
|
}
|
||||||
|
@media (max-width: 369px) {
|
||||||
|
margin: 20px 0px 0px -19px;
|
||||||
font-size: 7px;
|
font-size: 7px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -18,8 +18,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: 'and' | 'or' = 'and';
|
||||||
@Output() txPreviewEvent = new EventEmitter<TransactionStripped | void>();
|
@Output() txPreviewEvent = new EventEmitter<TransactionStripped | void>();
|
||||||
|
|
||||||
@ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent;
|
@ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent;
|
||||||
|
|
|
@ -2,8 +2,19 @@
|
||||||
<div class="d-flex menu-click">
|
<div class="d-flex menu-click">
|
||||||
|
|
||||||
<nav class="scrollable menu-click">
|
<nav class="scrollable menu-click">
|
||||||
<span *ngIf="userAuth" class="menu-click">
|
<span *ngIf="user$ | async as user" class="menu-click">
|
||||||
<strong class="menu-click text-nowrap ellipsis">@ {{ userAuth.user.username }}</strong>
|
<span class="menu-click text-nowrap ellipsis">
|
||||||
|
<strong>
|
||||||
|
<span *ngIf="user.username.includes('@'); else usernamenospace">{{ user.username }}</span>
|
||||||
|
<ng-template #usernamenospace>@{{ user.username }}</ng-template>
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
<span class="badge mr-1 badge-og" *ngIf="user.ogRank">
|
||||||
|
OG #{{ user.ogRank }}
|
||||||
|
</span>
|
||||||
|
<span class="badge mr-1 badge-default" [class]="'badge-' + user.subscription_tag" *ngIf="user.subscription_tag !== 'free'">
|
||||||
|
{{ user.subscription_tag.toUpperCase() }}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<a *ngIf="!userAuth" class="d-flex justify-content-center align-items-center nav-link m-0 menu-click" routerLink="/login" role="tab" (click)="onLinkClick('/login')">
|
<a *ngIf="!userAuth" class="d-flex justify-content-center align-items-center nav-link m-0 menu-click" routerLink="/login" role="tab" (click)="onLinkClick('/login')">
|
||||||
<fa-icon class="menu-click" [icon]="['fas', 'user-circle']" [fixedWidth]="true" style="font-size: 25px;margin-right: 15px;"></fa-icon>
|
<fa-icon class="menu-click" [icon]="['fas', 'user-circle']" [fixedWidth]="true" style="font-size: 25px;margin-right: 15px;"></fa-icon>
|
||||||
|
|
|
@ -55,4 +55,36 @@
|
||||||
|
|
||||||
@media screen and (max-height: 450px) {
|
@media screen and (max-height: 450px) {
|
||||||
.sidenav a {font-size: 18px;}
|
.sidenav a {font-size: 18px;}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-default {
|
||||||
|
background-color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-og {
|
||||||
|
background-color: #4a68b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-pleb {
|
||||||
|
background-color: #3ccbe3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-chad {
|
||||||
|
background-color: #957d0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-whale {
|
||||||
|
background-color: #653b9c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-silver {
|
||||||
|
background-color: #95a5a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-gold {
|
||||||
|
background-color: #f1c40f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-platinium {
|
||||||
|
background-color: #653b9c;
|
||||||
}
|
}
|
|
@ -1,10 +1,10 @@
|
||||||
import { Component, OnInit, Input, Output, EventEmitter, HostListener, OnDestroy } from '@angular/core';
|
import { Component, OnInit, Input, Output, EventEmitter, HostListener, OnDestroy } from '@angular/core';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { ApiService } from '../../services/api.service';
|
|
||||||
import { MenuGroup } from '../../interfaces/services.interface';
|
import { MenuGroup } from '../../interfaces/services.interface';
|
||||||
import { StorageService } from '../../services/storage.service';
|
import { StorageService } from '../../services/storage.service';
|
||||||
import { Router, NavigationStart } from '@angular/router';
|
import { Router, NavigationStart } from '@angular/router';
|
||||||
import { StateService } from '../../services/state.service';
|
import { StateService } from '../../services/state.service';
|
||||||
|
import { IUser, ServicesApiServices } from '../../services/services-api.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-menu',
|
selector: 'app-menu',
|
||||||
|
@ -18,11 +18,12 @@ export class MenuComponent implements OnInit, OnDestroy {
|
||||||
@Output() menuToggled = new EventEmitter<boolean>();
|
@Output() menuToggled = new EventEmitter<boolean>();
|
||||||
|
|
||||||
userMenuGroups$: Observable<MenuGroup[]> | undefined;
|
userMenuGroups$: Observable<MenuGroup[]> | undefined;
|
||||||
|
user$: Observable<IUser | null>;
|
||||||
userAuth: any | undefined;
|
userAuth: any | undefined;
|
||||||
isServicesPage = false;
|
isServicesPage = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private apiService: ApiService,
|
private servicesApiServices: ServicesApiServices,
|
||||||
private storageService: StorageService,
|
private storageService: StorageService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private stateService: StateService
|
private stateService: StateService
|
||||||
|
@ -32,7 +33,8 @@ export class MenuComponent implements OnInit, OnDestroy {
|
||||||
this.userAuth = this.storageService.getAuth();
|
this.userAuth = this.storageService.getAuth();
|
||||||
|
|
||||||
if (this.stateService.env.GIT_COMMIT_HASH_MEMPOOL_SPACE) {
|
if (this.stateService.env.GIT_COMMIT_HASH_MEMPOOL_SPACE) {
|
||||||
this.userMenuGroups$ = this.apiService.getUserMenuGroups$();
|
this.userMenuGroups$ = this.servicesApiServices.getUserMenuGroups$();
|
||||||
|
this.user$ = this.servicesApiServices.userSubject$;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isServicesPage = this.router.url.includes('/services/');
|
this.isServicesPage = this.router.url.includes('/services/');
|
||||||
|
@ -55,10 +57,10 @@ export class MenuComponent implements OnInit, OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
logout(): void {
|
logout(): void {
|
||||||
this.apiService.logout$().subscribe(() => {
|
this.servicesApiServices.logout$().subscribe(() => {
|
||||||
this.loggedOut.emit(true);
|
this.loggedOut.emit(true);
|
||||||
if (this.stateService.env.GIT_COMMIT_HASH_MEMPOOL_SPACE) {
|
if (this.stateService.env.GIT_COMMIT_HASH_MEMPOOL_SPACE) {
|
||||||
this.userMenuGroups$ = this.apiService.getUserMenuGroups$();
|
this.userMenuGroups$ = this.servicesApiServices.getUserMenuGroups$();
|
||||||
this.router.navigateByUrl('/');
|
this.router.navigateByUrl('/');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -26,9 +26,11 @@
|
||||||
|
|
||||||
<!-- pool distribution -->
|
<!-- pool distribution -->
|
||||||
<div class="col" style="margin-bottom: 1.47rem">
|
<div class="col" style="margin-bottom: 1.47rem">
|
||||||
<div class="card graph-card">
|
<div class="card">
|
||||||
<div class="card-body pl-2 pr-2">
|
<div class="card-body pl-2 pr-2">
|
||||||
<app-pool-ranking [attr.data-cy]="'pool-distribution'" [widget]=true></app-pool-ranking>
|
<div class="mempool-graph">
|
||||||
|
<app-pool-ranking [height]="graphHeight" [attr.data-cy]="'pool-distribution'" [widget]=true></app-pool-ranking>
|
||||||
|
</div>
|
||||||
<div class="mt-1"><a [attr.data-cy]="'pool-distribution-view-more'" [routerLink]="['/graphs/mining/pools' | relativeUrl]" i18n="dashboard.view-more">View more »</a></div>
|
<div class="mt-1"><a [attr.data-cy]="'pool-distribution-view-more'" [routerLink]="['/graphs/mining/pools' | relativeUrl]" i18n="dashboard.view-more">View more »</a></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -38,7 +40,9 @@
|
||||||
<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">
|
||||||
<app-hashrate-chart [attr.data-cy]="'hashrate-graph'" [widget]="true"></app-hashrate-chart>
|
<div class="fixed-mempool-graph">
|
||||||
|
<app-hashrate-chart [height]="graphHeight" [attr.data-cy]="'hashrate-graph'" [widget]="true"></app-hashrate-chart>
|
||||||
|
</div>
|
||||||
<div class="mt-1"><a [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" fragment="1y" i18n="dashboard.view-more">View more »</a></div>
|
<div class="mt-1"><a [routerLink]="['/graphs/mining/hashrate-difficulty' | relativeUrl]" fragment="1y" i18n="dashboard.view-more">View more »</a></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue