Compare commits

...

117 Commits

Author SHA1 Message Date
Mononaut
6bb9ffd21a Extend esplora api to support returning origin, improve stats pause logic 2023-09-14 22:44:24 +00:00
Mononaut
9a8e5b7896 Avoid logging statistics while affected by esplora failover 2023-09-14 18:33:11 +00:00
softsimon
83c285e17d Merge pull request #4199 from mempool/mononaut/fix-diff-skeleton-mouseover
Fix js error on mouseover on difficulty skeleton
2023-09-13 13:26:10 +04:00
softsimon
5fabf892ba Merge branch 'master' into mononaut/fix-diff-skeleton-mouseover 2023-09-13 13:22:19 +04:00
wiz
827b0f6ad1 ops: Make install script wait longer for mysql to start 2023-08-31 16:52:56 +09:00
wiz
e8d85613b4 Merge pull request #4224 from mempool/mononaut/failover-timeout
Bump failover timeout to 5s
2023-08-31 02:38:10 +09:00
wiz
ce3b599bab ops: Add /api/v1/services route for new backend 2023-08-31 02:36:22 +09:00
Mononaut
47a7564cfc Bump failover timeout to 5s 2023-08-31 02:25:28 +09:00
wiz
7744146ef7 Merge pull request #4188 from mempool/nymkappa/menu
User menu + integrated accelerator if available
2023-08-31 01:55:52 +09:00
wiz
9cb72cff31 Set apple-mobile-web-app-capable to yes 2023-08-31 01:35:10 +09:00
nymkappa
98119b3e54 Merge remote-tracking branch 'origin/nymkappa/menu' into nymkappa/menu 2023-08-30 18:32:56 +02:00
nymkappa
f5a3e78cbe [css] fix spacing at the top x2 2023-08-30 18:32:14 +02:00
wiz
515d2656e7 Set apple-mobile-web-app-status-bar-style to black 2023-08-31 01:23:07 +09:00
wiz
339a21caaa Merge branch 'master' into nymkappa/menu 2023-08-31 01:01:00 +09:00
wiz
a7c0d33de8 Merge pull request #4219 from mempool/mononaut/accelerator-preview-concept
Accelerator preview concept
2023-08-31 00:58:24 +09:00
Mononaut
d1ef1afc9c Fix default min/max/default userbid 2023-08-31 00:32:54 +09:00
wiz
bf4a1fcd7a Merge pull request #4221 from hunicus/add-meta-descriptions
Add meta descriptions
2023-08-31 00:29:46 +09:00
Mononaut
d6044331e1 Hide balance unless insufficient for max cost 2023-08-31 00:15:09 +09:00
wiz
9fdb164b64 Merge branch 'master' into add-meta-descriptions 2023-08-31 00:09:57 +09:00
hunicus
d691bf2714 Add meta titles+descriptions to pages missing them 2023-08-30 23:59:51 +09:00
hunicus
cd366177ba Add meta descriptions for mempool and liquid 2023-08-30 21:10:59 +09:00
hunicus
6312884234 Add meta descriptions for bisq 2023-08-30 20:47:57 +09:00
hunicus
d1f26f0491 Add meta descriptions to docs page
Also move all meta tag setting to ngDoCheck()
because page titles were not updating when
user switched docs tabs.
2023-08-30 20:47:57 +09:00
Mononaut
ed12e30517 Hide fee diagram on mobile 2023-08-30 17:16:39 +09:00
Mononaut
cb363aca23 Simplify acceleration quote 2023-08-30 16:55:24 +09:00
Mononaut
c753a8e92a Accelerator fee diagram concept 2023-08-30 16:55:24 +09:00
nymkappa
c44276027c [auto scroll] fix documention anchor scrolling 2023-08-29 15:51:17 +02:00
nymkappa
91fbd0864b [css] fix footer position 2023-08-29 14:34:56 +02:00
nymkappa
c2c4047ffd [css] fix spacing at the top 2023-08-29 14:19:02 +02:00
nymkappa
9427ba96a2 Merge branch 'master' into nymkappa/menu 2023-08-29 10:53:09 +02:00
nymkappa
7c11f0a3da [tx] fix eta css 2023-08-29 10:50:13 +02:00
softsimon
6780ba82d0 Merge pull request #4213 from mempool/mononaut/live-cpfp-updates
Send cpfp/effective fee rate changes to subscribed websocket clients
2023-08-29 10:17:33 +02:00
softsimon
6d643604d0 Merge branch 'master' into mononaut/live-cpfp-updates 2023-08-29 10:12:55 +02:00
nymkappa
7ae4b451e4 [auth] remove auto logout when imageMd5 no in localstorage 2023-08-29 09:31:18 +02:00
nymkappa
bbd1f088d1 [footer] only show cta if official && ACCELERATOR 2023-08-28 16:20:30 +02:00
nymkappa
3060aecddb [status badge] reposition a bit higher 2023-08-28 15:57:06 +02:00
nymkappa
7a765cecd9 [footer] only show cta if official 2023-08-28 15:41:45 +02:00
nymkappa
03e9592c79 Merge pull request #4216 from mempool/mononaut/fix-menu-blockchain-offset
Fix bad blockchain offset after services -> dash
2023-08-28 10:56:18 +02:00
Mononaut
c7b89f31dd Fix bad blockchain offset after services -> dash 2023-08-28 17:42:32 +09:00
nymkappa
899b230760 Merge remote-tracking branch 'origin/nymkappa/menu' into nymkappa/menu 2023-08-28 10:24:52 +02:00
nymkappa
d787ef99c4 [typo] the Mempool Accelerator -> Mempool Accelerator 2023-08-28 10:24:44 +02:00
wiz
043ab008db Merge branch 'master' into nymkappa/menu 2023-08-28 16:43:03 +09:00
nymkappa
4abc4e96a8 [typo] satss -> sats 2023-08-28 09:34:50 +02:00
nymkappa
7fd8790750 [auth] fix blinking profile picture 2023-08-28 09:29:13 +02:00
nymkappa
8ba4a7b421 [ui] polish x2 2023-08-27 19:17:03 +02:00
nymkappa
78ea9cbd16 [ui] polish x1 2023-08-27 12:52:58 +02:00
softsimon
727937b8ae Merge pull request #4135 from mempool/hunicus/citadel-link
Change citadel link on about page
2023-08-27 11:42:52 +02:00
nymkappa
5f4add3e22 [tx] fix css when accel not available 2023-08-27 09:07:47 +02:00
wiz
c4f8afbaf7 Merge pull request #4195 from mempool/junderw/rusttoolchain 2023-08-27 13:19:15 +09:00
Mononaut
528877f43f Send cpfp/effective fee rate changes to subscribed ws clients 2023-08-27 00:30:55 +09:00
nymkappa
a3d61fa525 [accelerator] show payment preview when not logged in 2023-08-26 15:07:05 +02:00
nymkappa
c0308fbc0d Merge branch 'master' into nymkappa/menu 2023-08-26 14:01:07 +02:00
softsimon
24696d0408 Merge pull request #4183 from mempool/mononaut/attitude-adjustment
Don't overload core with mempool tx requests
2023-08-26 12:28:26 +02:00
nymkappa
b9838fda8d Merge branch 'master' into mononaut/attitude-adjustment 2023-08-26 11:48:21 +02:00
nymkappa
726bd51abb [debug] update versioning print 2023-08-26 11:27:45 +02:00
nymkappa
1fe08d1234 [accelerator] fix overflow in tx page integrated accel 2023-08-26 10:05:04 +02:00
nymkappa
1ca136fa75 Merge pull request #4212 from mempool/nymkappa/accelerate-preview
Nymkappa/accelerate preview
2023-08-26 09:53:29 +02:00
nymkappa
fcecbe4967 [tx] integrated accelerator 2023-08-26 09:52:55 +02:00
hunicus
d42a3f74ec Add description to index html and seo service 2023-08-26 14:18:55 +09:00
wiz
dc44f1b618 ops: Fix WebGL for unfurler 2023-08-26 04:40:12 +09:00
wiz
0fde6dd908 ops: Bump prod NodeJS version to v20.5.1 2023-08-25 23:34:50 +09:00
wiz
4eb494baec ops: Increase FreeBSD bitcoin node shutdown timeout to 600 2023-08-25 23:29:03 +09:00
wiz
c1b2f1f2c7 ops: Disable tor in prod install script 2023-08-25 23:24:51 +09:00
wiz
a6d2887847 Merge branch 'master' into nymkappa/menu 2023-08-25 23:13:32 +09:00
nymkappa
f9895a492c Merge branch 'nymkappa/menu' into nymkappa/accelerate-preview 2023-08-24 18:23:30 +02:00
nymkappa
ac56f70f6f Merge pull request #4176 from mempool/nymkappa/fix-eta
[tx] fix eta css with accelerate button
2023-08-24 18:21:27 +02:00
nymkappa
c89e283c8e [tx] start adding acceleration preview 2023-08-24 14:17:31 +02:00
nymkappa
3aa938a94b Merge pull request #4202 from mempool/mononaut/dynamic-width-chain
Dynamic width chain
2023-08-24 10:42:46 +02:00
Mononaut
20fff97804 restore window resize listener 2023-08-24 17:28:21 +09:00
Mononaut
d1f7026804 hambuger 2023-08-24 17:28:19 +09:00
Mononaut
2da31c4d4a Slide blockchain to compensate for menu 2023-08-24 17:27:31 +09:00
Mononaut
975ba653a2 Dynamically resize blockchain to fit container 2023-08-24 17:27:31 +09:00
nymkappa
c8100712e8 [footer] polish breakpoints in /services/* 2023-08-23 16:17:07 +02:00
nymkappa
a7b2c39f51 [footer] re-add missing tagline 2023-08-23 15:02:36 +02:00
nymkappa
3d9fa5076b Merge remote-tracking branch 'origin/nymkappa/menu' into nymkappa/menu 2023-08-23 14:40:29 +02:00
nymkappa
9fa715e403 [footer] re-add main CTA with better design 2023-08-23 14:40:16 +02:00
nymkappa
9ee11b64e9 [menu] disable hamburger icon when not logged in 2023-08-23 14:39:58 +02:00
nymkappa
8c90d4ba98 Merge branch 'master' into nymkappa/menu 2023-08-23 12:21:23 +02:00
nymkappa
b333c211f7 [menu] fix non clickable hamburger when logged out 2023-08-23 11:50:47 +02:00
nymkappa
8fb566858f [css] make sure <main> cannot be larger than viewport 2023-08-22 15:44:32 +02:00
nymkappa
379e1470fd [menu] fix menu close trigger, fix menu scroll on mobile safari 2023-08-22 14:59:23 +02:00
nymkappa
c5f0608b46 [debug] footer version same color, inline version in about page 2023-08-22 14:51:05 +02:00
nymkappa
7544357826 [ui] redesign UX for navbar using sticky 2023-08-22 08:35:10 +02:00
nymkappa
5005817529 [ui] tweak profile image 2023-08-21 23:18:55 +02:00
nymkappa
bfc3fbe397 [ui] hide the navbar on scroll 2023-08-21 22:19:05 +02:00
nymkappa
30be7e0f7a [ui] redesign UX for navbar 2023-08-21 22:08:25 +02:00
junderw
18eb6600b0 Fix README to include workaround for legacy versions of npm 2023-08-21 12:53:20 -07:00
junderw
88c0d04c18 Match CI toolchain with rust-toolchain 2023-08-21 12:53:20 -07:00
Mononaut
8ea7bb907c Fix js error on mouseover on difficulty skeleton 2023-08-22 02:40:00 +09:00
junderw
b595002c25 Revert "Add a missing [workspace] tag in rust-gbt Cargo to build in git"
This reverts commit fadc46f3b5.
2023-08-21 10:01:40 -07:00
softsimon
7674bee5ad Animating menu 2023-08-21 17:06:12 +02:00
nymkappa
0ee28a335f [header] show anon image if user did not set a picture (to improve later) 2023-08-21 17:04:17 +02:00
nymkappa
285485c69e [about page] /api/v1/about-page -> /api/v1/services/sponsors 2023-08-21 15:27:55 +02:00
nymkappa
b8222bff43 [auth] fix auth not being cleared after logout 2023-08-21 15:01:31 +02:00
nymkappa
3321ae0f1f [debug] always show git hashes in footer 2023-08-21 11:36:40 +02:00
nymkappa
4010047344 Merge remote-tracking branch 'origin/nymkappa/menu' into nymkappa/menu 2023-08-21 11:20:20 +02:00
nymkappa
ce290a449c [debug] show more git hashes in about and footer 2023-08-21 11:19:54 +02:00
Jonathan Underwood
b9c151f549 Update rust-toolchain 2023-08-21 17:47:58 +09:00
nymkappa
2ff3e4acb9 Merge branch 'master' into nymkappa/menu 2023-08-21 09:14:24 +02:00
nymkappa
a1e2d2fd74 [debug] SERVICES -> GIT_COMMIT_HASH_MEMPOOL_SPACE in env 2023-08-21 08:57:27 +02:00
nymkappa
e82b43e340 [footer] fix footer sizing in /services 2023-08-20 22:54:12 +02:00
nymkappa
22886cb32d [debug] show services backend version in /services global footer, show services global frontend build in /about 2023-08-20 22:53:33 +02:00
nymkappa
ef554ad67b Merge branch 'master' into nymkappa/menu 2023-08-20 08:21:38 +02:00
nymkappa
b64b6fb3c7 [menu] fix json.parse on missing auth in localstorage 2023-08-20 08:11:55 +02:00
Mononaut
2819cea509 Reduce core mempool tx sync to 8 concurrent requests 2023-08-19 19:02:30 +09:00
junderw
2a8a403da7 Use p-limit to limit concurrent requests 2023-08-19 18:53:32 +09:00
Mononaut
e4fcadf39b More verbose comments on $getMempoolTransactionsExtended 2023-08-19 04:47:19 +09:00
nymkappa
c4f2f4ca66 [menu] handle logout without reload, show signin in sidebar when not logged in 2023-08-18 18:33:09 +02:00
nymkappa
0c1221dc07 [menu] only show on official mainnet with accelerator enabled 2023-08-18 18:12:45 +02:00
nymkappa
d1c9e8b56e [menu] show username at the top of the menu 2023-08-18 18:04:40 +02:00
Mononaut
1b2122cd35 Don't overload core with mempool tx requests 2023-08-19 01:02:27 +09:00
nymkappa
5aff2c74e6 [menu] show hamburger when logged out, fix menu scrolling on small screen 2023-08-18 17:56:07 +02:00
nymkappa
1bdbb1b908 [menu] logout is a special item 2023-08-17 22:13:06 +02:00
nymkappa
91e3943a74 [menu] call services api to fetch user menu 2023-08-17 18:59:27 +02:00
nymkappa
23b871631a [menu] write menu component with hardcoded values 2023-08-17 18:51:39 +02:00
nymkappa
ab911d5c9e [tx] fix eta css with accelerate button 2023-08-17 14:28:33 +02:00
hunicus
45ba50e82c Change citadel link on about page 2023-08-10 16:26:07 +09:00
128 changed files with 2269 additions and 271 deletions

View File

@@ -27,8 +27,17 @@ jobs:
node-version: ${{ matrix.node }}
registry-url: "https://registry.npmjs.org"
- name: Install 1.63.x Rust toolchain
uses: dtolnay/rust-toolchain@1.63
- name: Read rust-toolchain file from repository
id: gettoolchain
run: echo "::set-output name=toolchain::$(cat rust-toolchain)"
working-directory: ${{ matrix.node }}/${{ matrix.flavor }}
- name: Install ${{ steps.gettoolchain.outputs.toolchain }} Rust toolchain
# Latest version available on this commit is 1.71.1
# Commit date is Aug 3, 2023
uses: dtolnay/rust-toolchain@f361669954a8ecfc00a3443f35f9ac8e610ffc06
with:
toolchain: ${{ steps.gettoolchain.outputs.toolchain }}
- name: Install
if: ${{ matrix.flavor == 'dev'}}

View File

@@ -85,7 +85,7 @@ Install dependencies with `npm` and build the backend:
```
cd backend
npm install
npm install --no-install-links # npm@9.4.2 and later can omit the --no-install-links
npm run build
```

View File

@@ -6,8 +6,6 @@ authors = ["mononaut"]
edition = "2021"
publish = false
[workspace]
[lib]
crate-type = ["cdylib"]

View File

@@ -1,7 +1,7 @@
import { IEsploraApi } from './esplora-api.interface';
export interface AbstractBitcoinApi {
$getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]>;
$getRawMempool(): Promise<{ txids: IEsploraApi.Transaction['txid'][], local: boolean}>;
$getRawTransaction(txId: string, skipConversion?: boolean, addPrevout?: boolean, lazyPrevouts?: boolean): Promise<IEsploraApi.Transaction>;
$getMempoolTransactions(txids: string[]): Promise<IEsploraApi.Transaction[]>;
$getAllMempoolTransactions(lastTxid: string);
@@ -25,6 +25,7 @@ export interface AbstractBitcoinApi {
$getBatchedOutspends(txId: string[]): Promise<IEsploraApi.Outspend[][]>;
startHealthChecks(): void;
isFailedOver(): boolean;
}
export interface BitcoinRpcCredentials {
host: string;

View File

@@ -137,8 +137,12 @@ class BitcoinApi implements AbstractBitcoinApi {
throw new Error('Method getScriptHashTransactions not supported by the Bitcoin RPC API.');
}
$getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]> {
return this.bitcoindClient.getRawMemPool();
async $getRawMempool(): Promise<{ txids: IEsploraApi.Transaction['txid'][], local: boolean}> {
const txids = await this.bitcoindClient.getRawMemPool();
return {
txids,
local: true,
};
}
$getAddressPrefix(prefix: string): string[] {
@@ -356,6 +360,9 @@ class BitcoinApi implements AbstractBitcoinApi {
}
public startHealthChecks(): void {};
public isFailedOver(): boolean {
return false;
}
}
export default BitcoinApi;

View File

@@ -638,8 +638,8 @@ class BitcoinRoutes {
private async getMempoolTxIds(req: Request, res: Response) {
try {
const rawMempool = await bitcoinApi.$getRawMempool();
res.send(rawMempool);
const { txids } = await bitcoinApi.$getRawMempool();
res.send(txids);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}

View File

@@ -4,6 +4,7 @@ import http from 'http';
import { AbstractBitcoinApi } from './bitcoin-api-abstract-factory';
import { IEsploraApi } from './esplora-api.interface';
import logger from '../../logger';
import mempool from '../mempool';
interface FailoverHost {
host: string,
@@ -17,6 +18,8 @@ interface FailoverHost {
}
class FailoverRouter {
isFailedOver: boolean = false;
preferredHost: FailoverHost;
activeHost: FailoverHost;
fallbackHost: FailoverHost;
hosts: FailoverHost[];
@@ -46,6 +49,7 @@ class FailoverRouter {
socket: !!config.ESPLORA.UNIX_SOCKET_PATH,
preferred: true,
};
this.preferredHost = this.activeHost;
this.fallbackHost = this.activeHost;
this.hosts.unshift(this.activeHost);
this.multihost = this.hosts.length > 1;
@@ -75,9 +79,9 @@ class FailoverRouter {
const results = await Promise.allSettled(this.hosts.map(async (host) => {
if (host.socket) {
return this.pollConnection.get<number>('/blocks/tip/height', { socketPath: host.host, timeout: 2000 });
return this.pollConnection.get<number>('/blocks/tip/height', { socketPath: host.host, timeout: 5000 });
} else {
return this.pollConnection.get<number>(host.host + '/blocks/tip/height', { timeout: 2000 });
return this.pollConnection.get<number>(host.host + '/blocks/tip/height', { timeout: 5000 });
}
}));
const maxHeight = results.reduce((max, result) => Math.max(max, result.status === 'fulfilled' ? result.value?.data || 0 : 0), 0);
@@ -151,6 +155,7 @@ class FailoverRouter {
this.sortHosts();
this.activeHost = this.hosts[0];
logger.warn(`Switching esplora host to ${this.activeHost.host}`);
this.isFailedOver = this.activeHost !== this.preferredHost;
}
private addFailure(host: FailoverHost): FailoverHost {
@@ -164,7 +169,7 @@ class FailoverRouter {
}
}
private async $query<T>(method: 'get'| 'post', path, data: any, responseType = 'json', host = this.activeHost, retry: boolean = true): Promise<T> {
private async $query<T>(method: 'get'| 'post', path, data: any, responseType = 'json', host = this.activeHost, retry: boolean = true, withSource = false): Promise<T | { data: T, host: FailoverHost }> {
let axiosConfig;
let url;
if (host.socket) {
@@ -177,8 +182,17 @@ class FailoverRouter {
return (method === 'post'
? this.requestConnection.post<T>(url, data, axiosConfig)
: this.requestConnection.get<T>(url, axiosConfig)
).then((response) => { host.failures = Math.max(0, host.failures - 1); return response.data; })
.catch((e) => {
).then((response) => {
host.failures = Math.max(0, host.failures - 1);
if (withSource) {
return {
data: response.data,
host,
};
} else {
return response.data;
}
}).catch((e) => {
let fallbackHost = this.fallbackHost;
if (e?.response?.status !== 404) {
logger.warn(`esplora request failed ${e?.response?.status || 500} ${host.host}${path}`);
@@ -186,7 +200,7 @@ class FailoverRouter {
}
if (retry && e?.code === 'ECONNREFUSED' && this.multihost) {
// Retry immediately
return this.$query(method, path, data, responseType, fallbackHost, false);
return this.$query(method, path, data, responseType, fallbackHost, false, withSource);
} else {
throw e;
}
@@ -194,19 +208,27 @@ class FailoverRouter {
}
public async $get<T>(path, responseType = 'json'): Promise<T> {
return this.$query<T>('get', path, null, responseType);
return this.$query<T>('get', path, null, responseType, this.activeHost, true) as Promise<T>;
}
public async $post<T>(path, data: any, responseType = 'json'): Promise<T> {
return this.$query<T>('post', path, data, responseType);
return this.$query<T>('post', path, data, responseType) as Promise<T>;
}
public async $getWithSource<T>(path, responseType = 'json'): Promise<{ data: T, host: FailoverHost }> {
return this.$query<T>('get', path, null, responseType, this.activeHost, true, true) as Promise<{ data: T, host: FailoverHost }>;
}
}
class ElectrsApi implements AbstractBitcoinApi {
private failoverRouter = new FailoverRouter();
$getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]> {
return this.failoverRouter.$get<IEsploraApi.Transaction['txid'][]>('/mempool/txids');
async $getRawMempool(): Promise<{ txids: IEsploraApi.Transaction['txid'][], local: boolean}> {
const result = await this.failoverRouter.$getWithSource<IEsploraApi.Transaction['txid'][]>('/mempool/txids', 'json');
return {
txids: result.data,
local: result.host === this.failoverRouter.preferredHost,
};
}
$getRawTransaction(txId: string): Promise<IEsploraApi.Transaction> {
@@ -302,6 +324,10 @@ class ElectrsApi implements AbstractBitcoinApi {
public startHealthChecks(): void {
this.failoverRouter.startHealthChecks();
}
public isFailedOver(): boolean {
return this.failoverRouter.isFailedOver;
}
}
export default ElectrsApi;

View File

@@ -451,6 +451,7 @@ class MempoolBlocks {
private processBlockTemplates(mempool: { [txid: string]: MempoolTransactionExtended }, blocks: string[][], blockWeights: number[] | null, rates: [string, number][], clusters: string[][], accelerations, accelerationPool, saveResults): MempoolBlockWithTransactions[] {
for (const [txid, rate] of rates) {
if (txid in mempool) {
mempool[txid].cpfpDirty = (rate !== mempool[txid].effectiveFeePerVsize);
mempool[txid].effectiveFeePerVsize = rate;
mempool[txid].cpfpChecked = false;
}
@@ -494,6 +495,9 @@ class MempoolBlocks {
}
}
});
if (mempoolTx.ancestors?.length !== ancestors.length || mempoolTx.descendants?.length !== descendants.length) {
mempoolTx.cpfpDirty = true;
}
Object.assign(mempoolTx, {ancestors, descendants, bestDescendant: null, cpfpChecked: true});
}
}
@@ -531,12 +535,21 @@ class MempoolBlocks {
const acceleration = accelerations[txid];
if (isAccelerated[txid] || (acceleration && (!accelerationPool || acceleration.pools.includes(accelerationPool)))) {
if (!mempoolTx.acceleration) {
mempoolTx.cpfpDirty = true;
}
mempoolTx.acceleration = true;
for (const ancestor of mempoolTx.ancestors || []) {
if (!mempool[ancestor.txid].acceleration) {
mempool[ancestor.txid].cpfpDirty = true;
}
mempool[ancestor.txid].acceleration = true;
isAccelerated[ancestor.txid] = true;
}
} else {
if (mempoolTx.acceleration) {
mempoolTx.cpfpDirty = true;
}
delete mempoolTx.acceleration;
}

View File

@@ -26,6 +26,9 @@ class Mempool {
private accelerations: { [txId: string]: Acceleration } = {};
private failoverTimes: number[] = [];
private statisticsPaused: boolean = false;
private txPerSecondArray: number[] = [];
private txPerSecond: number = 0;
@@ -164,6 +167,15 @@ class Mempool {
return this.mempoolInfo;
}
public getStatisticsIsPaused(): boolean {
return this.statisticsPaused;
}
public logFailover(): void {
this.failoverTimes.push(Date.now());
this.statisticsPaused = true;
}
public getTxPerSecond(): number {
return this.txPerSecond;
}
@@ -242,6 +254,10 @@ class Mempool {
logger.debug(`fetched ${txs.length} transactions`);
this.updateTimerProgress(timer, 'fetched new transactions');
if (bitcoinApi.isFailedOver()) {
this.failoverTimes.push(Date.now());
}
for (const transaction of txs) {
this.mempoolCache[transaction.txid] = transaction;
if (this.inSync) {
@@ -259,6 +275,10 @@ class Mempool {
}
}
if (bitcoinApi.isFailedOver()) {
this.failoverTimes.push(Date.now());
}
if (txs.length < slice.length) {
const missing = slice.length - txs.length;
if (config.MEMPOOL.BACKEND === 'esplora') {
@@ -491,6 +511,10 @@ class Mempool {
private updateTxPerSecond() {
const nowMinusTimeSpan = new Date().getTime() - (1000 * config.STATISTICS.TX_PER_SECOND_SAMPLE_PERIOD);
this.failoverTimes = this.failoverTimes.filter((unixTime) => unixTime > nowMinusTimeSpan);
this.statisticsPaused = this.failoverTimes.length > 0;
this.txPerSecondArray = this.txPerSecondArray.filter((unixTime) => unixTime > nowMinusTimeSpan);
this.txPerSecond = this.txPerSecondArray.length / config.STATISTICS.TX_PER_SECOND_SAMPLE_PERIOD || 0;

View File

@@ -29,9 +29,10 @@ class Statistics {
}
private async runStatistics(): Promise<void> {
if (!memPool.isInSync()) {
if (!memPool.isInSync() || memPool.getStatisticsIsPaused()) {
return;
}
const currentMempool = memPool.getMempool();
const txPerSecond = memPool.getTxPerSecond();
const vBytesPerSecond = memPool.getVBytesPerSecond();

View File

@@ -5,6 +5,7 @@ import bitcoinApi, { bitcoinCoreApi } from './bitcoin/bitcoin-api-factory';
import * as bitcoinjs from 'bitcoinjs-lib';
import logger from '../logger';
import config from '../config';
import pLimit from '../utils/p-limit';
class TransactionUtils {
constructor() { }
@@ -74,8 +75,12 @@ class TransactionUtils {
public async $getMempoolTransactionsExtended(txids: string[], addPrevouts = false, lazyPrevouts = false, forceCore = false): Promise<MempoolTransactionExtended[]> {
if (forceCore || config.MEMPOOL.BACKEND !== 'esplora') {
const results = await Promise.allSettled(txids.map(txid => this.$getTransactionExtended(txid, addPrevouts, lazyPrevouts, forceCore, true)));
return (results.filter(r => r.status === 'fulfilled') as PromiseFulfilledResult<MempoolTransactionExtended>[]).map(r => r.value);
const limiter = pLimit(8); // Run 8 requests at a time
const results = await Promise.allSettled(txids.map(
txid => limiter(() => this.$getMempoolTransactionExtended(txid, addPrevouts, lazyPrevouts, forceCore))
));
return results.filter(reply => reply.status === 'fulfilled')
.map(r => (r as PromiseFulfilledResult<MempoolTransactionExtended>).value);
} else {
const transactions = await bitcoinApi.$getMempoolTransactions(txids);
return transactions.map(transaction => {

View File

@@ -71,9 +71,8 @@ class WebsocketHandler {
private updateSocketData(): void {
const _blocks = blocks.getBlocks().slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT);
const da = difficultyAdjustment.getDifficultyAdjustment();
this.updateSocketDataFields({
const socketData = {
'mempoolInfo': memPool.getMempoolInfo(),
'vBytesPerSecond': memPool.getVBytesPerSecond(),
'blocks': _blocks,
'conversions': priceUpdater.getLatestPrices(),
'mempool-blocks': mempoolBlocks.getMempoolBlocks(),
@@ -82,7 +81,11 @@ class WebsocketHandler {
'loadingIndicators': loadingIndicators.getLoadingIndicators(),
'da': da?.previousTime ? da : undefined,
'fees': feeApi.getRecommendedFee(),
});
};
if (!memPool.getStatisticsIsPaused()) {
socketData['vBytesPerSecond'] = memPool.getVBytesPerSecond();
}
this.updateSocketDataFields(socketData);
}
public getSerializedInitData(): string {
@@ -414,7 +417,7 @@ class WebsocketHandler {
const mBlocks = mempoolBlocks.getMempoolBlocks();
const mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas();
const mempoolInfo = memPool.getMempoolInfo();
const vBytesPerSecond = memPool.getVBytesPerSecond();
const vBytesPerSecond = memPool.getStatisticsIsPaused() ? null : memPool.getVBytesPerSecond();
const rbfTransactions = Common.findRbfTransactions(newTransactions, deletedTransactions);
const da = difficultyAdjustment.getDifficultyAdjustment();
memPool.handleRbfTransactions(rbfTransactions);
@@ -440,13 +443,15 @@ class WebsocketHandler {
// update init data
const socketDataFields = {
'mempoolInfo': mempoolInfo,
'vBytesPerSecond': vBytesPerSecond,
'mempool-blocks': mBlocks,
'transactions': latestTransactions,
'loadingIndicators': loadingIndicators.getLoadingIndicators(),
'da': da?.previousTime ? da : undefined,
'fees': recommendedFees,
};
if (vBytesPerSecond != null) {
socketDataFields['vBytesPerSecond'] = vBytesPerSecond;
}
if (rbfSummary) {
socketDataFields['rbfSummary'] = rbfSummary;
}
@@ -496,7 +501,9 @@ class WebsocketHandler {
if (client['want-stats']) {
response['mempoolInfo'] = getCachedResponse('mempoolInfo', mempoolInfo);
response['vBytesPerSecond'] = getCachedResponse('vBytesPerSecond', vBytesPerSecond);
if (vBytesPerSecond != null) {
response['vBytesPerSecond'] = getCachedResponse('vBytesPerSecond', vBytesPerSecond);
}
response['transactions'] = getCachedResponse('transactions', latestTransactions);
if (da?.previousTime) {
response['da'] = getCachedResponse('da', da);
@@ -586,13 +593,25 @@ class WebsocketHandler {
const mempoolTx = newMempool[trackTxid];
if (mempoolTx && mempoolTx.position) {
response['txPosition'] = JSON.stringify({
const positionData = {
txid: trackTxid,
position: {
...mempoolTx.position,
accelerated: mempoolTx.acceleration || undefined,
}
});
};
if (mempoolTx.cpfpDirty) {
positionData['cpfp'] = {
ancestors: mempoolTx.ancestors,
bestDescendant: mempoolTx.bestDescendant || null,
descendants: mempoolTx.descendants || null,
effectiveFeePerVsize: mempoolTx.effectiveFeePerVsize || null,
sigops: mempoolTx.sigops,
adjustedVsize: mempoolTx.adjustedVsize,
acceleration: mempoolTx.acceleration
};
}
response['txPosition'] = JSON.stringify(positionData);
}
}
@@ -772,7 +791,9 @@ class WebsocketHandler {
if (client['want-stats']) {
response['mempoolInfo'] = getCachedResponse('mempoolInfo', mempoolInfo);
response['vBytesPerSecond'] = getCachedResponse('vBytesPerSecond', memPool.getVBytesPerSecond());
if (!memPool.getStatisticsIsPaused()) {
response['vBytesPerSecond'] = getCachedResponse('vBytesPerSecond', memPool.getVBytesPerSecond());
}
response['fees'] = getCachedResponse('fees', fees);
if (da?.previousTime) {

View File

@@ -191,10 +191,14 @@ class Server {
logger.debug(msg);
}
}
const newMempool = await bitcoinApi.$getRawMempool();
const { txids: newMempool, local: fromLocalNode } = await bitcoinApi.$getRawMempool();
const numHandledBlocks = await blocks.$updateBlocks();
const pollRate = config.MEMPOOL.POLL_RATE_MS * (indexer.indexerRunning ? 10 : 1);
if (numHandledBlocks === 0) {
if (!fromLocalNode) {
memPool.logFailover();
}
await memPool.$updateMempool(newMempool, pollRate);
}
indexer.$run();

View File

@@ -104,6 +104,7 @@ export interface MempoolTransactionExtended extends TransactionExtended {
adjustedFeePerVsize: number;
inputs?: number[];
lastBoosted?: number;
cpfpDirty?: boolean;
}
export interface AuditTransaction {

View File

@@ -0,0 +1,179 @@
/*
MIT License
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
Permission is hereby granted, free of charge, to any person obtaining a copy of this
software and associated documentation files (the "Software"), to deal in the Software
without restriction, including without limitation the rights to use, copy, modify,
merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be included in all copies
or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
/*
How it works:
`this._head` is an instance of `Node` which keeps track of its current value and nests
another instance of `Node` that keeps the value that comes after it. When a value is
provided to `.enqueue()`, the code needs to iterate through `this._head`, going deeper
and deeper to find the last value. However, iterating through every single item is slow.
This problem is solved by saving a reference to the last value as `this._tail` so that
it can reference it to add a new value.
*/
class Node {
value;
next;
constructor(value) {
this.value = value;
}
}
class Queue {
private _head;
private _tail;
private _size;
constructor() {
this.clear();
}
enqueue(value) {
const node = new Node(value);
if (this._head) {
this._tail.next = node;
this._tail = node;
} else {
this._head = node;
this._tail = node;
}
this._size++;
}
dequeue() {
const current = this._head;
if (!current) {
return;
}
this._head = this._head.next;
this._size--;
return current.value;
}
clear() {
this._head = undefined;
this._tail = undefined;
this._size = 0;
}
get size() {
return this._size;
}
*[Symbol.iterator]() {
let current = this._head;
while (current) {
yield current.value;
current = current.next;
}
}
}
interface LimitFunction {
readonly activeCount: number;
readonly pendingCount: number;
clearQueue: () => void;
<Arguments extends unknown[], ReturnType>(
fn: (...args: Arguments) => PromiseLike<ReturnType> | ReturnType,
...args: Arguments
): Promise<ReturnType>;
}
export default function pLimit(concurrency: number): LimitFunction {
if (
!(
(Number.isInteger(concurrency) ||
concurrency === Number.POSITIVE_INFINITY) &&
concurrency > 0
)
) {
throw new TypeError('Expected `concurrency` to be a number from 1 and up');
}
const queue = new Queue();
let activeCount = 0;
const next = () => {
activeCount--;
if (queue.size > 0) {
queue.dequeue()();
}
};
const run = async (fn, resolve, args) => {
activeCount++;
const result = (async () => fn(...args))();
resolve(result);
try {
await result;
} catch {}
next();
};
const enqueue = (fn, resolve, args) => {
queue.enqueue(run.bind(undefined, fn, resolve, args));
(async () => {
// This function needs to wait until the next microtask before comparing
// `activeCount` to `concurrency`, because `activeCount` is updated asynchronously
// when the run function is dequeued and called. The comparison in the if-statement
// needs to happen asynchronously as well to get an up-to-date value for `activeCount`.
await Promise.resolve();
if (activeCount < concurrency && queue.size > 0) {
queue.dequeue()();
}
})();
};
const generator = (fn, ...args) =>
new Promise((resolve) => {
enqueue(fn, resolve, args);
});
Object.defineProperties(generator, {
activeCount: {
get: () => activeCount,
},
pendingCount: {
get: () => queue.size,
},
clearQueue: {
value: () => {
queue.clear();
},
},
});
return generator as any;
}

View File

@@ -112,6 +112,14 @@ PROXY_CONFIG.push(...[
"^/testnet": ""
},
},
{
context: ['/api/v1/services/**'],
target: `http://localhost:9000`,
secure: false,
ws: true,
changeOrigin: true,
proxyTimeout: 30000,
},
{
context: ['/api/v1/**'],
target: `http://127.0.0.1:8999`,

View File

@@ -112,6 +112,14 @@ PROXY_CONFIG.push(...[
"^/testnet": ""
},
},
{
context: ['/api/v1/services/**'],
target: `http://localhost:9000`,
secure: false,
ws: true,
changeOrigin: true,
proxyTimeout: 30000,
},
{
context: ['/api/v1/**'],
target: `http://localhost:8999`,

View File

@@ -95,6 +95,14 @@ if (configContent && configContent.BASE_MODULE === 'bisq') {
}
PROXY_CONFIG.push(...[
{
context: ['/api/v1/services/**'],
target: `http://localhost:9000`,
secure: false,
ws: true,
changeOrigin: true,
proxyTimeout: 30000,
},
{
context: ['/api/v1/**'],
target: `http://localhost:8999`,

View File

@@ -41,6 +41,7 @@ export class BisqAddressComponent implements OnInit, OnDestroy {
document.body.scrollTo(0, 0);
this.addressString = params.get('id') || '';
this.seoService.setTitle($localize`:@@bisq-address.component.browser-title:Address: ${this.addressString}:INTERPOLATION:`);
this.seoService.setDescription($localize`:@@meta.description.bisq.address:See current balance, pending transactions, and history of confirmed transactions for BSQ address ${this.addressString}:INTERPOLATION:.`);
return this.bisqApiService.getAddress$(this.addressString)
.pipe(

View File

@@ -88,6 +88,7 @@ export class BisqBlockComponent implements OnInit, OnDestroy {
this.isLoading = false;
this.blockHeight = block.height;
this.seoService.setTitle($localize`:@@bisq-block.component.browser-title:Block ${block.height}:BLOCK_HEIGHT:: ${block.hash}:BLOCK_HASH:`);
this.seoService.setDescription($localize`:@@meta.description.bisq.block:See all BSQ transactions in Bitcoin block ${block.height}:BLOCK_HEIGHT: (block hash ${block.hash}:BLOCK_HASH:).`);
this.block = block;
});
}

View File

@@ -36,6 +36,7 @@ export class BisqBlocksComponent implements OnInit {
ngOnInit(): void {
this.websocketService.want(['blocks']);
this.seoService.setTitle($localize`:@@8a7b4bd44c0ac71b2e72de0398b303257f7d2f54:Blocks`);
this.seoService.setDescription($localize`:@@meta.description.bisq.blocks:See a list of recent Bitcoin blocks with BSQ transactions, total BSQ sent per block, and more.`);
this.itemsPerPage = Math.max(Math.round(this.contentSpace / this.fiveItemsPxSize) * 5, 10);
this.loadingItems = Array(this.itemsPerPage);
if (document.body.clientWidth < 670) {

View File

@@ -29,7 +29,8 @@ export class BisqDashboardComponent implements OnInit {
) { }
ngOnInit(): void {
this.seoService.setTitle(`Markets`);
this.seoService.setTitle($localize`:@@meta.title.bisq.markets:Markets`);
this.seoService.setDescription($localize`:@@meta.description.bisq.markets:Explore the full Bitcoin ecosystem with The Mempool Open Project™. See Bisq market prices, trading activity, and more.`);
this.websocketService.want(['blocks']);
this.volumes$ = this.bisqApiService.getAllVolumesDay$()

View File

@@ -34,6 +34,7 @@ export class BisqMainDashboardComponent implements OnInit {
ngOnInit(): void {
this.seoService.resetTitle();
this.seoService.resetDescription();
this.websocketService.want(['blocks']);
this.usdPrice$ = this.stateService.conversions$.asObservable().pipe(

View File

@@ -48,7 +48,8 @@ export class BisqMarketComponent implements OnInit, OnDestroy {
map(([markets, routeParams]) => {
const pair = routeParams.get('pair');
const pairUpperCase = pair.replace('_', '/').toUpperCase();
this.seoService.setTitle(`Bisq market: ${pairUpperCase}`);
this.seoService.setTitle($localize`:@@meta.title.bisq.market:Bisq market: ${pairUpperCase}`);
this.seoService.setDescription($localize`:@@meta.description.bisq.market:See price history, current buy/sell offers, and latest trades for the ${pairUpperCase} market on Bisq.`);
return {
pair: pairUpperCase,

View File

@@ -26,6 +26,7 @@ export class BisqStatsComponent implements OnInit {
this.websocketService.want(['blocks']);
this.seoService.setTitle($localize`:@@2a30a4cdb123a03facc5ab8c5b3e6d8b8dbbc3d4:BSQ statistics`);
this.seoService.setDescription($localize`:@@meta.description.bisq.stats:See high-level stats on the BSQ economy: supply metrics, number of addresses, BSQ price, market cap, and more.`);
this.stateService.bsqPrice$
.subscribe((bsqPrice) => {
this.price = bsqPrice;

View File

@@ -48,6 +48,7 @@ export class BisqTransactionComponent implements OnInit, OnDestroy {
document.body.scrollTo(0, 0);
this.txId = params.get('id') || '';
this.seoService.setTitle($localize`:@@bisq.transaction.browser-title:Transaction: ${this.txId}:INTERPOLATION:`);
this.seoService.setDescription($localize`:@@meta.description.bisq.transaction:See inputs, outputs, transaction type, burnt amount, and more for transaction with txid ${this.txId}:INTERPOLATION:.`);
if (history.state.data) {
return of(history.state.data);
}

View File

@@ -79,6 +79,7 @@ export class BisqTransactionsComponent implements OnInit, OnDestroy {
ngOnInit(): void {
this.websocketService.want(['blocks']);
this.seoService.setTitle($localize`:@@add4cd82e3e38a3110fe67b3c7df56e9602644ee:Transactions`);
this.seoService.setDescription($localize`:@@meta.description.bisq.transactions:See recent BSQ transactions: amount, txid, associated Bitcoin block, transaction type, and more.`);
this.radioGroupForm = this.formBuilder.group({
txTypes: [this.txTypesDefaultChecked],

View File

@@ -4,7 +4,8 @@
<span style="margin-left: auto; margin-right: -20px; margin-bottom: -20px">&reg;</span>
<img class="logo" src="/resources/mempool-logo-bigger.png" />
<div class="version">
v{{ packetJsonVersion }} [<a href="https://github.com/mempool/mempool/commit/{{ frontendGitCommitHash }}">{{ frontendGitCommitHash }}</a>]
<span>v{{ packetJsonVersion }} [<a href="https://github.com/mempool/mempool/commit/{{ frontendGitCommitHash }}">{{ frontendGitCommitHash }}</a>]</span>
<span *ngIf="stateService.env.GIT_COMMIT_HASH_MEMPOOL_SPACE">[{{ stateService.env.GIT_COMMIT_HASH_MEMPOOL_SPACE }}]</span>
</div>
</div>
@@ -242,7 +243,7 @@
<img class="image" src="/resources/profile/ronindojo.png" />
<span>RoninDojo</span>
</a>
<a href="https://github.com/runcitadel/core" target="_blank" title="Citadel">
<a href="https://github.com/runcitadel" target="_blank" title="Citadel">
<img class="image" src="/resources/profile/runcitadel.svg" />
<span>Citadel</span>
</a>

View File

@@ -22,6 +22,7 @@
.intro {
margin: 25px auto 30px;
margin-top: 25px;
width: 250px;
display: flex;
flex-direction: column;

View File

@@ -43,6 +43,7 @@ export class AboutComponent implements OnInit {
ngOnInit() {
this.backendInfo$ = this.stateService.backendInfo$;
this.seoService.setTitle($localize`:@@004b222ff9ef9dd4771b777950ca1d0e4cd4348a:About`);
this.seoService.setDescription($localize`:@@meta.description.about:Learn more about The Mempool Open Source Project™\: enterprise sponsors, individual sponsors, integrations, who contributes, FOSS licensing, and more.`);
this.websocketService.want(['blocks']);
this.profiles$ = this.apiService.getAboutPageProfiles$().pipe(

View File

@@ -0,0 +1,21 @@
<div class="fee-graph" *ngIf="tx && estimate">
<div class="column">
<ng-container *ngFor="let bar of bars">
<div class="bar {{ bar.class }}" [class.active]="bar.active" [style]="bar.style" (click)="onClick($event, bar);">
<div class="fill"></div>
<div class="line">
<p class="fee-rate">
<span class="label">{{ bar.label }}</span>
<span class="rate">
<app-fee-rate [fee]="bar.rate"></app-fee-rate>
</span>
</p>
</div>
<div class="spacer"></div>
<span class="fee">{{ bar.class === 'tx' ? '' : '+' }} {{ bar.fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span></span>
<div class="spacer"></div>
<div class="spacer"></div>
</div>
</ng-container>
</div>
</div>

View File

@@ -0,0 +1,157 @@
.fee-graph {
height: 100%;
min-width: 120px;
width: 120px;
max-height: 90vh;
margin-left: 4em;
margin-right: 1.5em;
padding-bottom: 63px;
.column {
width: 100%;
height: 100%;
position: relative;
background: #181b2d;
.bar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.fill {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
opacity: 0.75;
pointer-events: none;
}
.fee {
font-size: 0.9em;
opacity: 0;
pointer-events: none;
}
.spacer {
width: 100%;
height: 1px;
flex-grow: 1;
pointer-events: none;
}
.line {
position: absolute;
right: 0;
top: 0;
left: -4.5em;
border-top: dashed white 1.5px;
.fee-rate {
width: 100%;
position: absolute;
left: 0;
right: 0.2em;
font-size: 0.8em;
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
margin: 0;
.label {
margin-right: .2em;
}
.rate .symbol {
color: white;
}
}
}
&.tx {
.fill {
background: #3bcc49;
}
.line {
.fee-rate {
top: 0;
}
}
.fee {
position: absolute;
opacity: 1;
z-index: 11;
}
}
&.target {
.fill {
background: #653b9c;
}
.fee {
position: absolute;
opacity: 1;
z-index: 11;
}
.line .fee-rate {
bottom: 2px;
}
}
&.max {
cursor: pointer;
.line .fee-rate {
.label {
opacity: 0;
}
bottom: 2px;
}
&.active, &:hover {
.fill {
background: #105fb0;
}
.line {
.fee-rate .label {
opacity: 1;
}
}
}
}
&:hover {
.fill {
z-index: 10;
}
.line {
z-index: 11;
}
.fee {
opacity: 1;
z-index: 12;
}
}
}
&:hover > .bar:not(:hover) {
&.target, &.max {
.fee {
opacity: 0;
}
.line .fee-rate .label {
opacity: 0;
}
}
&.max {
.fill {
background: none;
}
}
}
}
}

View File

@@ -0,0 +1,96 @@
import { Component, OnInit, Input, Output, OnChanges, EventEmitter, HostListener, Inject, LOCALE_ID } from '@angular/core';
import { StateService } from '../../services/state.service';
import { Outspend, Transaction, Vin, Vout } from '../../interfaces/electrs.interface';
import { Router } from '@angular/router';
import { ReplaySubject, merge, Subscription, of } from 'rxjs';
import { tap, switchMap } from 'rxjs/operators';
import { ApiService } from '../../services/api.service';
import { AccelerationEstimate, RateOption } from './accelerate-preview.component';
interface GraphBar {
rate: number;
style: any;
class: 'tx' | 'target' | 'max';
label: string;
active?: boolean;
rateIndex?: number;
fee?: number;
}
@Component({
selector: 'app-accelerate-fee-graph',
templateUrl: './accelerate-fee-graph.component.html',
styleUrls: ['./accelerate-fee-graph.component.scss'],
})
export class AccelerateFeeGraphComponent implements OnInit, OnChanges {
@Input() tx: Transaction;
@Input() estimate: AccelerationEstimate;
@Input() maxRateOptions: RateOption[] = [];
@Input() maxRateIndex: number = 0;
@Output() setUserBid = new EventEmitter<{ fee: number, index: number }>();
bars: GraphBar[] = [];
tooltipPosition = { x: 0, y: 0 };
ngOnInit(): void {
this.initGraph();
}
ngOnChanges(): void {
this.initGraph();
}
initGraph(): void {
if (!this.tx || !this.estimate) {
return;
}
const maxRate = Math.max(...this.maxRateOptions.map(option => option.rate));
const baseRate = this.estimate.txSummary.effectiveFee / this.estimate.txSummary.effectiveVsize;
const baseHeight = baseRate / maxRate;
const bars: GraphBar[] = this.maxRateOptions.slice().reverse().map(option => {
return {
rate: option.rate,
style: this.getStyle(option.rate, maxRate, baseHeight),
class: 'max',
label: 'maximum',
active: option.index === this.maxRateIndex,
rateIndex: option.index,
fee: option.fee,
}
});
bars.push({
rate: this.estimate.targetFeeRate,
style: this.getStyle(this.estimate.targetFeeRate, maxRate, baseHeight),
class: 'target',
label: 'next block',
fee: this.estimate.nextBlockFee - this.estimate.txSummary.effectiveFee
});
bars.push({
rate: baseRate,
style: this.getStyle(baseRate, maxRate, 0),
class: 'tx',
label: '',
fee: this.estimate.txSummary.effectiveFee,
});
this.bars = bars;
}
getStyle(rate, maxRate, base) {
const top = (rate / maxRate);
return {
height: `${(top - base) * 100}%`,
bottom: base ? `${base * 100}%` : '0',
}
}
onClick(event, bar): void {
if (bar.rateIndex != null) {
this.setUserBid.emit({ fee: bar.fee, index: bar.rateIndex });
}
}
@HostListener('pointermove', ['$event'])
onPointerMove(event) {
this.tooltipPosition = { x: event.offsetX, y: event.offsetY };
}
}

View File

@@ -0,0 +1,262 @@
<div class="row" *ngIf="showSuccess">
<div class="col" id="successAlert">
<div class="alert alert-success">
Transaction has now been submitted to mining pools for acceleration. You can track the progress <a class="alert-link" routerLink="/services/accelerator/history">here</a>.
</div>
</div>
</div>
<div class="row" *ngIf="error">
<div class="col" id="mempoolError">
<app-mempool-error [error]="error"></app-mempool-error>
</div>
</div>
<div class="accelerate-cols">
<ng-container *ngIf="!isMobile">
<app-accelerate-fee-graph
[tx]="tx"
[estimate]="estimate"
[maxRateOptions]="maxRateOptions"
[maxRateIndex]="selectFeeRateIndex"
(setUserBid)="setUserBid($event)"
></app-accelerate-fee-graph>
</ng-container>
<ng-container *ngIf="estimate">
<div [class]="{estimateDisabled: error}">
<h5>Your transaction</h5>
<div class="row">
<div class="col">
<small *ngIf="hasAncestors" class="form-text text-muted mb-2">
Plus {{ estimate.txSummary.ancestorCount - 1 }} unconfirmed ancestor{{ estimate.txSummary.ancestorCount > 2 ? 's' : ''}}.
</small>
<table class="table table-borderless table-border table-dark table-accelerator">
<tbody>
<tr class="group-first">
<td class="item">
Virtual size
</td>
<td class="units" [innerHTML]="'&lrm;' + (estimate.txSummary.effectiveVsize | vbytes: 2)"></td>
</tr>
<tr class="info">
<td class="info">
<i><small>Size in vbytes of this transaction<span *ngIf="hasAncestors"> and its unconfirmed ancestors</span></small></i>
</td>
</tr>
<tr>
<td class="item">
In-band fees
</td>
<td class="units">
{{ estimate.txSummary.effectiveFee | number : '1.0-0' }} <span class="symbol" i18n="shared.sats|sats">sats</span>
</td>
</tr>
<tr class="info group-last">
<td class="info">
<i><small>Fees already paid by this transaction<span *ngIf="hasAncestors"> and its unconfirmed ancestors</span></small></i>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<br>
<h5>How much more are you willing to pay?</h5>
<div class="row">
<div class="col">
<small class="form-text text-muted mb-2">
Choose the maximum extra transaction fee you're willing to pay to get into the next block.<br>
If the estimated next block rate rises beyond this limit, we will automatically cancel your acceleration request.
</small>
<div class="form-group">
<div class="fee-card">
<div class="d-flex mb-0">
<ng-container *ngFor="let option of maxRateOptions">
<button type="button" class="btn btn-primary flex-grow-1 btn-border btn-sm feerate" [class]="{active: selectFeeRateIndex === option.index}" (click)="setUserBid(option)">
<span class="fee">{{ option.fee | number }} <span class="symbol" i18n="shared.sats|sats">sats</span></span>
<span class="rate">~ <app-fee-rate [fee]="option.rate" rounding="1.0-0"></app-fee-rate></span>
</button>
</ng-container>
</div>
</div>
</div>
</div>
</div>
<h5>Acceleration summary</h5>
<div class="row mb-3">
<div class="col">
<div class="table-toggle btn-group btn-group-toggle">
<div class="btn btn-primary btn-sm" [class.active]="showTable === 'estimated'" (click)="showTable = 'estimated'">
<span>Estimated cost</span>
</div>
<div class="btn btn-primary btn-sm" [class.active]="showTable === 'maximum'" (click)="showTable = 'maximum'">
<span>Maximum cost</span>
</div>
</div>
<table class="table table-borderless table-border table-dark table-accelerator">
<tbody>
<!-- ESTIMATED FEE -->
<ng-container *ngIf="showTable === 'estimated'">
<tr class="group-first">
<td class="item">
Next block market rate
</td>
<td class="amt" style="font-size: 20px">
{{ estimate.targetFeeRate | number : '1.0-0' }}
</td>
<td class="units"><span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></td>
</tr>
<tr class="info">
<td class="info">
<i><small>Estimated extra fee required</small></i>
</td>
<td class="amt">
{{ math.max(0, estimate.nextBlockFee - estimate.txSummary.effectiveFee) | number }}
</td>
<td class="units">
<span class="symbol" i18n="shared.sats|sats">sats</span>
<span class="fiat"><app-fiat [value]="math.max(0, estimate.nextBlockFee - estimate.txSummary.effectiveFee)"></app-fiat></span>
</td>
</tr>
</ng-container>
<!-- USER MAX BID -->
<ng-container *ngIf="showTable === 'maximum'">
<tr class="group-first">
<td class="item">
Your maximum
</td>
<td class="amt" style="width: 45%; font-size: 20px">
~{{ ((estimate.txSummary.effectiveFee + userBid) / estimate.txSummary.effectiveVsize) | number : '1.0-0' }}
</td>
<td class="units"><span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></td>
</tr>
<tr class="info">
<td class="info">
<i><small>The maximum extra transaction fee you could pay</small></i>
</td>
<td class="amt">
<span>
{{ userBid | number }}
</span>
</td>
<td class="units">
<span class="symbol" i18n="shared.sats|sats">sats</span>
<span class="fiat"><app-fiat [value]="userBid"></app-fiat></span>
</td>
</tr>
</ng-container>
<!-- MEMPOOL BASE FEE -->
<tr>
<td class="item">
Mempool Accelerator™ fees
</td>
</tr>
<tr class="info">
<td class="info">
<i><small>mempool.space fee</small></i>
</td>
<td class="amt">
+{{ estimate.mempoolBaseFee | number }}
</td>
<td class="units">
<span class="symbol" i18n="shared.sats|sats">sats</span>
<span class="fiat"><app-fiat [value]="estimate.mempoolBaseFee"></app-fiat></span>
</td>
</tr>
<tr class="info group-last" style="border-bottom: 1px solid lightgrey">
<td class="info">
<i><small>Transaction vsize fee</small></i>
</td>
<td class="amt">
+{{ estimate.vsizeFee | number }}
</td>
<td class="units">
<span class="symbol" i18n="shared.sats|sats">sats</span>
<span class="fiat"><app-fiat [value]="estimate.vsizeFee"></app-fiat></span>
</td>
</tr>
<!-- NEXT BLOCK ESTIMATE -->
<ng-container *ngIf="showTable === 'estimated'">
<tr class="group-first">
<td class="item">
<b style="background-color: #5E35B1" class="p-1 pl-0">Estimated acceleration cost</b>
</td>
<td class="amt">
<span style="background-color: #5E35B1" class="p-1 pl-0">
{{ estimate.cost + estimate.mempoolBaseFee + estimate.vsizeFee | number }}
</span>
</td>
<td class="units">
<span class="symbol" i18n="shared.sats|sats">sats</span>
<span class="fiat"><app-fiat [value]="estimate.cost + estimate.mempoolBaseFee + estimate.vsizeFee"></app-fiat></span>
</td>
</tr>
<tr class="info group-last">
<td class="info">
<i><small>If your tx is accelerated to </small><small>{{ estimate.targetFeeRate | number : '1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></small></i>
</td>
</tr>
</ng-container>
<!-- MAX COST -->
<ng-container *ngIf="showTable === 'maximum'">
<tr class="group-first">
<td class="item">
<b style="background-color: #105fb0;" class="p-1 pl-0">Maximum acceleration cost</b>
</td>
<td class="amt">
<span style="background-color: #105fb0" class="p-1 pl-0">
{{ maxCost | number }}
</span>
</td>
<td class="units">
<span class="symbol" i18n="shared.sats|sats">sats</span>
<span class="fiat">
<app-fiat [value]="maxCost" [colorClass]="estimate.userBalance < maxCost ? 'red-color' : 'green-color'"></app-fiat>
</span>
</td>
</tr>
<tr class="info group-last">
<td class="info">
<i><small>If your tx is accelerated to </small><small>~{{ ((estimate.txSummary.effectiveFee + userBid) / estimate.txSummary.effectiveVsize) | number : '1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></small></i>
</td>
</tr>
</ng-container>
<!-- USER BALANCE -->
<ng-container *ngIf="estimate.userBalance < maxCost">
<tr class="group-first group-last" style="border-top: 1px dashed grey">
<td class="item">
Available balance
</td>
<td class="amt">
{{ estimate.userBalance | number }}
</td>
<td class="units">
<span class="symbol" i18n="shared.sats|sats">sats</span>
<span class="fiat">
<app-fiat [value]="estimate.userBalance" [colorClass]="estimate.userBalance < maxCost ? 'red-color' : 'green-color'"></app-fiat>
</span>
</td>
</tr>
</ng-container>
</tbody>
</table>
</div>
</div>
<div class="row mb-3" *ngIf="isLoggedIn()">
<div class="col">
<div class="d-flex justify-content-end">
<button class="btn btn-sm btn-primary btn-success" style="width: 150px" (click)="accelerate()">Accelerate</button>
</div>
</div>
</div>
</div>
</ng-container>
</div>

View File

@@ -0,0 +1,88 @@
.fee-card {
padding: 15px;
background-color: #1d1f31;
.feerate {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.fee {
font-size: 1.2em;
}
.rate {
font-size: 0.9em;
.symbol {
color: white;
}
}
}
}
.btn-border {
border: solid 1px black;
background-color: #0c4a87;
}
.feerate.active {
background-color: #105fb0 !important;
opacity: 1;
border: 1px solid white !important;
}
.estimateDisabled {
opacity: 0.5;
pointer-events: none;
}
.table-toggle {
width: 100%;
margin-top: 0.5em;
}
.table-accelerator {
tr {
text-wrap: wrap;
td {
padding-top: 0;
padding-bottom: 0;
vertical-align: baseline;
}
&.group-first {
td {
padding-top: 0.75rem;
}
}
&.group-last {
td {
padding-bottom: 0.75rem;
}
}
}
td {
&:first-child {
width: 100vw;
}
&.info {
color: #6c757d;
}
&.amt {
text-align: right;
padding-right: 0.2em;
}
&.units {
padding-left: 0.2em;
white-space: nowrap;
}
}
}
.accelerate-cols {
display: flex;
flex-direction: row;
align-items: stretch;
margin-top: 1em;
}

View File

@@ -0,0 +1,205 @@
import { Component, OnInit, Input, OnDestroy, OnChanges, SimpleChanges, HostListener } from '@angular/core';
import { ApiService } from '../../services/api.service';
import { Subscription, catchError, of, tap } from 'rxjs';
import { StorageService } from '../../services/storage.service';
import { Transaction } from '../../interfaces/electrs.interface';
import { nextRoundNumber } from '../../shared/common.utils';
export type AccelerationEstimate = {
txSummary: TxSummary;
nextBlockFee: number;
targetFeeRate: number;
userBalance: number;
enoughBalance: boolean;
cost: number;
mempoolBaseFee: number;
vsizeFee: number;
}
export type TxSummary = {
txid: string; // txid of the current transaction
effectiveVsize: number; // Total vsize of the dependency tree
effectiveFee: number; // Total fee of the dependency tree in sats
ancestorCount: number; // Number of ancestors
}
export interface RateOption {
fee: number;
rate: number;
index: number;
}
export const MIN_BID_RATIO = 1;
export const DEFAULT_BID_RATIO = 2;
export const MAX_BID_RATIO = 4;
@Component({
selector: 'app-accelerate-preview',
templateUrl: 'accelerate-preview.component.html',
styleUrls: ['accelerate-preview.component.scss']
})
export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges {
@Input() tx: Transaction | undefined;
@Input() scrollEvent: boolean;
math = Math;
error = '';
showSuccess = false;
estimateSubscription: Subscription;
accelerationSubscription: Subscription;
estimate: any;
hasAncestors: boolean = false;
minExtraCost = 0;
minBidAllowed = 0;
maxBidAllowed = 0;
defaultBid = 0;
maxCost = 0;
userBid = 0;
selectFeeRateIndex = 1;
showTable: 'estimated' | 'maximum' = 'maximum';
isMobile: boolean = window.innerWidth <= 767.98;
maxRateOptions: RateOption[] = [];
constructor(
private apiService: ApiService,
private storageService: StorageService
) { }
ngOnDestroy(): void {
if (this.estimateSubscription) {
this.estimateSubscription.unsubscribe();
}
}
ngOnChanges(changes: SimpleChanges): void {
if (changes.scrollEvent) {
this.scrollToPreview('acceleratePreviewAnchor', 'center');
}
}
ngOnInit() {
this.estimateSubscription = this.apiService.estimate$(this.tx.txid).pipe(
tap((response) => {
if (response.status === 204) {
this.estimate = undefined;
this.error = `cannot_accelerate_tx`;
this.scrollToPreviewWithTimeout('mempoolError', 'center');
this.estimateSubscription.unsubscribe();
} else {
this.estimate = response.body;
if (!this.estimate) {
this.error = `cannot_accelerate_tx`;
this.scrollToPreviewWithTimeout('mempoolError', 'center');
this.estimateSubscription.unsubscribe();
}
if (this.estimate.userBalance <= 0) {
if (this.isLoggedIn()) {
this.error = `not_enough_balance`;
this.scrollToPreviewWithTimeout('mempoolError', 'center');
}
}
this.hasAncestors = this.estimate.txSummary.ancestorCount > 1;
// Make min extra fee at least 50% of the current tx fee
this.minExtraCost = nextRoundNumber(Math.max(this.estimate.cost * 2, this.estimate.txSummary.effectiveFee));
this.maxRateOptions = [1, 2, 4].map((multiplier, index) => {
return {
fee: this.minExtraCost * multiplier,
rate: (this.estimate.txSummary.effectiveFee + (this.minExtraCost * multiplier)) / this.estimate.txSummary.effectiveVsize,
index,
};
});
this.minBidAllowed = this.minExtraCost * MIN_BID_RATIO;
this.defaultBid = this.minExtraCost * DEFAULT_BID_RATIO;
this.maxBidAllowed = this.minExtraCost * MAX_BID_RATIO;
this.userBid = this.defaultBid;
if (this.userBid < this.minBidAllowed) {
this.userBid = this.minBidAllowed;
} else if (this.userBid > this.maxBidAllowed) {
this.userBid = this.maxBidAllowed;
}
this.maxCost = this.userBid + this.estimate.mempoolBaseFee + this.estimate.vsizeFee;
if (!this.error) {
this.scrollToPreview('acceleratePreviewAnchor', 'center');
}
}
}),
catchError((response) => {
this.estimate = undefined;
this.error = response.error;
this.scrollToPreviewWithTimeout('mempoolError', 'center');
this.estimateSubscription.unsubscribe();
return of(null);
})
).subscribe();
}
/**
* User changed his bid
*/
setUserBid({ fee, index }: { fee: number, index: number}) {
if (this.estimate) {
this.selectFeeRateIndex = index;
this.userBid = Math.max(0, fee);
this.maxCost = this.userBid + this.estimate.mempoolBaseFee + this.estimate.vsizeFee;
}
}
/**
* Scroll to element id with or without setTimeout
*/
scrollToPreviewWithTimeout(id: string, position: ScrollLogicalPosition) {
setTimeout(() => {
this.scrollToPreview(id, position);
}, 100);
}
scrollToPreview(id: string, position: ScrollLogicalPosition) {
const acceleratePreviewAnchor = document.getElementById(id);
if (acceleratePreviewAnchor) {
acceleratePreviewAnchor.scrollIntoView({
behavior: 'smooth',
inline: position,
block: position,
});
}
}
/**
* Send acceleration request
*/
accelerate() {
if (this.accelerationSubscription) {
this.accelerationSubscription.unsubscribe();
}
this.accelerationSubscription = this.apiService.accelerate$(
this.tx.txid,
this.userBid
).subscribe({
next: () => {
this.showSuccess = true;
this.scrollToPreviewWithTimeout('successAlert', 'center');
this.estimateSubscription.unsubscribe();
},
error: (response) => {
this.error = response.error;
this.scrollToPreviewWithTimeout('mempoolError', 'center');
}
});
}
isLoggedIn() {
const auth = this.storageService.getAuth();
return auth !== null;
}
@HostListener('window:resize', ['$event'])
onResize(): void {
this.isMobile = window.innerWidth <= 767.98;
}
}

View File

@@ -9,6 +9,7 @@ import { AudioService } from '../../services/audio.service';
import { ApiService } from '../../services/api.service';
import { of, merge, Subscription, Observable } from 'rxjs';
import { SeoService } from '../../services/seo.service';
import { seoDescriptionNetwork } from '../../shared/common.utils';
import { AddressInformation } from '../../interfaces/node-api.interface';
@Component({
@@ -68,6 +69,7 @@ export class AddressPreviewComponent implements OnInit, OnDestroy {
this.addressString = this.addressString.toLowerCase();
}
this.seoService.setTitle($localize`:@@address.component.browser-title:Address: ${this.addressString}:INTERPOLATION:`);
this.seoService.setDescription($localize`:@@meta.description.bitcoin.address:See mempool transactions, confirmed transactions, balance, and more for ${this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet'?'Liquid':'Bitcoin'}${seoDescriptionNetwork(this.stateService.network)} address ${this.addressString}:INTERPOLATION:.`);
return (this.addressString.match(/04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}/)
? this.electrsApiService.getPubKeyAddress$(this.addressString)

View File

@@ -9,6 +9,7 @@ import { AudioService } from '../../services/audio.service';
import { ApiService } from '../../services/api.service';
import { of, merge, Subscription, Observable } from 'rxjs';
import { SeoService } from '../../services/seo.service';
import { seoDescriptionNetwork } from '../../shared/common.utils';
import { AddressInformation } from '../../interfaces/node-api.interface';
@Component({
@@ -76,6 +77,7 @@ export class AddressComponent implements OnInit, OnDestroy {
this.addressString = this.addressString.toLowerCase();
}
this.seoService.setTitle($localize`:@@address.component.browser-title:Address: ${this.addressString}:INTERPOLATION:`);
this.seoService.setDescription($localize`:@@meta.description.bitcoin.address:See mempool transactions, confirmed transactions, balance, and more for ${this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet'?'Liquid':'Bitcoin'}${seoDescriptionNetwork(this.stateService.network)} address ${this.addressString}:INTERPOLATION:.`);
return merge(
of(true),

View File

@@ -40,6 +40,7 @@ export class AssetsNavComponent implements OnInit {
ngOnInit(): void {
this.seoService.setTitle($localize`:@@ee8f8008bae6ce3a49840c4e1d39b4af23d4c263:Assets`);
this.seoService.setDescription($localize`:@@meta.description.liquid.assets:Explore all the assets issued on the Liquid network like L-BTC, L-CAD, USDT, and more.`);
this.typeaheadSearchFn = this.typeaheadSearch;
this.searchForm = this.formBuilder.group({

View File

@@ -64,6 +64,7 @@ export class BlockFeeRatesGraphComponent implements OnInit {
ngOnInit(): void {
this.seoService.setTitle($localize`:@@ed8e33059967f554ff06b4f5b6049c465b92d9b3:Block Fee Rates`);
this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.block-fee-rates:See Bitcoin feerates visualized over time, including minimum and maximum feerates per block along with feerates at various percentiles.`);
this.miningWindowPreference = this.miningService.getDefaultTimespan('24h');
this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference });
this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference);

View File

@@ -65,6 +65,7 @@ export class BlockFeesGraphComponent implements OnInit {
ngOnInit(): void {
this.seoService.setTitle($localize`:@@6c453b11fd7bd159ae30bc381f367bc736d86909:Block Fees`);
this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.block-fees:See the average mining fees earned per Bitcoin block visualized in BTC and USD over time.`);
this.miningWindowPreference = this.miningService.getDefaultTimespan('1m');
this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference });
this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference);
@@ -192,7 +193,7 @@ export class BlockFeesGraphComponent implements OnInit {
{
name: 'Fees ' + this.currency,
inactiveColor: 'rgb(110, 112, 121)',
textStyle: {
textStyle: {
color: 'white',
},
icon: 'roundRect',

View File

@@ -61,6 +61,7 @@ export class BlockHealthGraphComponent implements OnInit {
ngOnInit(): void {
this.seoService.setTitle($localize`:@@d7d5fcf50179ad70c938491c517efb82de2c8146:Block Health`);
this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.block-health:See Bitcoin block health visualized over time. Block health is a measure of how many expected transactions were included in an actual mined block. Expected transactions are determined using Mempool's re-implementation of Bitcoin Core's transaction selection algorithm.`);
this.miningWindowPreference = '24h';//this.miningService.getDefaultTimespan('24h');
this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference });
this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference);

View File

@@ -63,6 +63,7 @@ export class BlockRewardsGraphComponent implements OnInit {
ngOnInit(): void {
this.seoService.setTitle($localize`:@@8ba8fe810458280a83df7fdf4c614dfc1a826445:Block Rewards`);
this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.block-rewards:See Bitcoin block rewards in BTC and USD visualized over time. Block rewards are the total funds miners earn from the block subsidy and fees.`);
this.miningWindowPreference = this.miningService.getDefaultTimespan('3m');
this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference });
this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference);
@@ -191,7 +192,7 @@ export class BlockRewardsGraphComponent implements OnInit {
{
name: 'Rewards ' + this.currency,
inactiveColor: 'rgb(110, 112, 121)',
textStyle: {
textStyle: {
color: 'white',
},
icon: 'roundRect',

View File

@@ -60,6 +60,7 @@ export class BlockSizesWeightsGraphComponent implements OnInit {
let firstRun = true;
this.seoService.setTitle($localize`:@@56fa1cd221491b6478998679cba2dc8d55ba330d:Block Sizes and Weights`);
this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.block-sizes:See Bitcoin block sizes (MB) and block weights (weight units) visualized over time.`);
this.miningWindowPreference = this.miningService.getDefaultTimespan('24h');
this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference });
this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference);

View File

@@ -8,6 +8,7 @@ import { SeoService } from '../../services/seo.service';
import { OpenGraphService } from '../../services/opengraph.service';
import { BlockExtended, TransactionStripped } from '../../interfaces/node-api.interface';
import { ApiService } from '../../services/api.service';
import { seoDescriptionNetwork } from '../../shared/common.utils';
import { BlockOverviewGraphComponent } from '../../components/block-overview-graph/block-overview-graph.component';
@Component({
@@ -97,6 +98,11 @@ export class BlockPreviewComponent implements OnInit, OnDestroy {
this.blockHeight = block.height;
this.seoService.setTitle($localize`:@@block.component.browser-title:Block ${block.height}:BLOCK_HEIGHT:: ${block.id}:BLOCK_ID:`);
if( this.stateService.network === 'liquid' || this.stateService.network === 'liquidtestnet' ) {
this.seoService.setDescription($localize`:@@meta.description.liquid.block:See size, weight, fee range, included transactions, and more for Liquid${seoDescriptionNetwork(this.stateService.network)} block ${block.height}:BLOCK_HEIGHT: (${block.id}:BLOCK_ID:).`);
} else {
this.seoService.setDescription($localize`:@@meta.description.bitcoin.block:See size, weight, fee range, included transactions, audit (expected v actual), and more for Bitcoin${seoDescriptionNetwork(this.stateService.network)} block ${block.height}:BLOCK_HEIGHT: (${block.id}:BLOCK_ID:).`);
}
this.isLoadingBlock = false;
this.setBlockSubsidy();
if (block?.extras?.reward !== undefined) {

View File

@@ -13,6 +13,7 @@ import { BlockAudit, BlockExtended, TransactionStripped } from '../../interfaces
import { ApiService } from '../../services/api.service';
import { BlockOverviewGraphComponent } from '../../components/block-overview-graph/block-overview-graph.component';
import { detectWebGL } from '../../shared/graphs.utils';
import { seoDescriptionNetwork } from '../../shared/common.utils';
import { PriceService, Price } from '../../services/price.service';
import { CacheService } from '../../services/cache.service';
@@ -261,6 +262,11 @@ export class BlockComponent implements OnInit, OnDestroy {
this.setNextAndPreviousBlockLink();
this.seoService.setTitle($localize`:@@block.component.browser-title:Block ${block.height}:BLOCK_HEIGHT:: ${block.id}:BLOCK_ID:`);
if( this.stateService.network === 'liquid' || this.stateService.network === 'liquidtestnet' ) {
this.seoService.setDescription($localize`:@@meta.description.liquid.block:See size, weight, fee range, included transactions, and more for Liquid${seoDescriptionNetwork(this.stateService.network)} block ${block.height}:BLOCK_HEIGHT: (${block.id}:BLOCK_ID:).`);
} else {
this.seoService.setDescription($localize`:@@meta.description.bitcoin.block:See size, weight, fee range, included transactions, audit (expected v actual), and more for Bitcoin${seoDescriptionNetwork(this.stateService.network)} block ${block.height}:BLOCK_HEIGHT: (${block.id}:BLOCK_ID:).`);
}
this.isLoadingBlock = false;
this.setBlockSubsidy();
if (block?.extras?.reward !== undefined) {
@@ -325,7 +331,7 @@ export class BlockComponent implements OnInit, OnDestroy {
]);
})
)
.subscribe(([transactions, blockAudit]) => {
.subscribe(([transactions, blockAudit]) => {
if (transactions) {
this.strippedTransactions = transactions;
} else {
@@ -680,7 +686,7 @@ export class BlockComponent implements OnInit, OnDestroy {
this.setAuditAvailable(false);
}
}
isAuditAvailableFromBlockHeight(blockHeight: number): boolean {
if (!this.auditSupported) {
return false;
@@ -729,4 +735,4 @@ export class BlockComponent implements OnInit, OnDestroy {
this.block.canonical = block.id;
}
}
}
}

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, Input, Output, EventEmitter, HostListener, ChangeDetectorRef } from '@angular/core';
import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, Input, Output, EventEmitter, HostListener, ChangeDetectorRef, OnChanges, SimpleChanges } from '@angular/core';
import { firstValueFrom, Subscription } from 'rxjs';
import { StateService } from '../../services/state.service';
@@ -8,12 +8,13 @@ import { StateService } from '../../services/state.service';
styleUrls: ['./blockchain.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BlockchainComponent implements OnInit, OnDestroy {
export class BlockchainComponent implements OnInit, OnDestroy, OnChanges {
@Input() pages: any[] = [];
@Input() pageIndex: number;
@Input() blocksPerPage: number = 8;
@Input() minScrollWidth: number = 0;
@Input() scrollableMempool: boolean = false;
@Input() containerWidth: number;
@Output() mempoolOffsetChange: EventEmitter<number> = new EventEmitter();
@@ -85,19 +86,25 @@ export class BlockchainComponent implements OnInit, OnDestroy {
this.mempoolOffsetChange.emit(this.mempoolOffset);
}
@HostListener('window:resize', ['$event'])
ngOnChanges(changes: SimpleChanges): void {
if (changes.containerWidth) {
this.onResize();
}
}
onResize(): void {
if (window.innerWidth >= 768) {
const width = this.containerWidth || window.innerWidth;
if (width >= 768) {
if (this.stateService.isLiquid()) {
this.dividerOffset = 420;
} else {
this.dividerOffset = window.innerWidth * 0.5;
this.dividerOffset = width * 0.5;
}
} else {
if (this.stateService.isLiquid()) {
this.dividerOffset = window.innerWidth * 0.5;
this.dividerOffset = width * 0.5;
} else {
this.dividerOffset = window.innerWidth * 0.95;
this.dividerOffset = width * 0.95;
}
}
this.cd.markForCheck();

View File

@@ -5,6 +5,8 @@ import { BlockExtended } from '../../interfaces/node-api.interface';
import { ApiService } from '../../services/api.service';
import { StateService } from '../../services/state.service';
import { WebsocketService } from '../../services/websocket.service';
import { SeoService } from '../../services/seo.service';
import { seoDescriptionNetwork } from '../../shared/common.utils';
@Component({
selector: 'app-blocks-list',
@@ -35,6 +37,7 @@ export class BlocksList implements OnInit {
private websocketService: WebsocketService,
public stateService: StateService,
private cd: ChangeDetectorRef,
private seoService: SeoService,
) {
}
@@ -50,6 +53,14 @@ export class BlocksList implements OnInit {
this.skeletonLines = this.widget === true ? [...Array(6).keys()] : [...Array(15).keys()];
this.paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5;
this.seoService.setTitle($localize`:@@meta.title.blocks-list:Blocks`);
if( this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet' ) {
this.seoService.setDescription($localize`:@@meta.description.liquid.blocks:See the most recent Liquid${seoDescriptionNetwork(this.stateService.network)} blocks along with basic stats such as block height, block size, and more.`);
} else {
this.seoService.setDescription($localize`:@@meta.description.bitcoin.blocks:See the most recent Bitcoin${seoDescriptionNetwork(this.stateService.network)} blocks along with basic stats such as block height, block reward, block size, and more.`);
}
this.blocks$ = combineLatest([
this.fromHeightSubject.pipe(
switchMap((fromBlockHeight) => {
@@ -129,4 +140,4 @@ export class BlocksList implements OnInit {
isEllipsisActive(e): boolean {
return (e.offsetWidth < e.scrollWidth);
}
}
}

View File

@@ -194,7 +194,7 @@ export class DifficultyComponent implements OnInit {
@HostListener('pointerdown', ['$event'])
onPointerDown(event): void {
if (this.epochSvgElement.nativeElement?.contains(event.target)) {
if (this.epochSvgElement?.nativeElement?.contains(event.target)) {
this.onPointerMove(event);
event.preventDefault();
}
@@ -202,7 +202,7 @@ export class DifficultyComponent implements OnInit {
@HostListener('pointermove', ['$event'])
onPointerMove(event): void {
if (this.epochSvgElement.nativeElement?.contains(event.target)) {
if (this.epochSvgElement?.nativeElement?.contains(event.target)) {
this.tooltipPosition = { x: event.clientX, y: event.clientY };
this.cd.markForCheck();
}

View File

@@ -1,5 +1,5 @@
<div [formGroup]="fiatForm" class="text-small text-center">
<select formControlName="fiat" class="custom-select custom-select-sm form-control-secondary form-control mx-auto" style="width: 150px;" (change)="changeFiat()">
<option *ngFor="let currency of currencies" [value]="currency[1].code">{{ currency[1].name + " (" + currency[1].code + ")" }}</option>
<select formControlName="fiat" class="custom-select custom-select-sm form-control-secondary form-control mx-auto" style="width: 85px;" (change)="changeFiat()">
<option *ngFor="let currency of currencies" [value]="currency[1].code">{{ currency[1].code }}</option>
</select>
</div>

View File

@@ -12,6 +12,7 @@ import { MiningService } from '../../services/mining.service';
import { download } from '../../shared/graphs.utils';
import { ActivatedRoute } from '@angular/router';
import { StateService } from '../../services/state.service';
import { seoDescriptionNetwork } from '../../shared/common.utils';
@Component({
selector: 'app-hashrate-chart',
@@ -71,6 +72,7 @@ export class HashrateChartComponent implements OnInit {
this.miningWindowPreference = '1y';
} else {
this.seoService.setTitle($localize`:@@3510fc6daa1d975f331e3a717bdf1a34efa06dff:Hashrate & Difficulty`);
this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.hashrate:See hashrate and difficulty for the Bitcoin${seoDescriptionNetwork(this.network)} network visualized over time.`);
this.miningWindowPreference = this.miningService.getDefaultTimespan('3m');
}
this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference });
@@ -256,7 +258,7 @@ export class HashrateChartComponent implements OnInit {
let difficultyPowerOfTen = hashratePowerOfTen;
let difficulty = tick.data[1];
if (difficulty === null) {
difficultyString = `${tick.marker} ${tick.seriesName}: No data<br>`;
difficultyString = `${tick.marker} ${tick.seriesName}: No data<br>`;
} else {
if (this.isMobile()) {
difficultyPowerOfTen = selectPowerOfTen(tick.data[1]);

View File

@@ -1,5 +1,5 @@
<div [formGroup]="languageForm" class="text-small text-center">
<select formControlName="language" class="custom-select custom-select-sm form-control-secondary form-control mx-auto" style="width: 100px;" (change)="changeLanguage()">
<select formControlName="language" class="custom-select custom-select-sm form-control-secondary form-control mx-auto" style="width: 95px;" (change)="changeLanguage()">
<option *ngFor="let lang of languages" [value]="lang.code">{{ lang.name }}</option>
</select>
</div>

View File

@@ -1,6 +1,20 @@
<ng-container *ngIf="{ val: network$ | async } as network">
<header *ngIf="headerVisible">
<header *ngIf="headerVisible" class="sticky-header">
<nav class="navbar navbar-expand-md navbar-dark bg-dark">
<!-- Hamburger -->
<ng-container *ngIf="servicesEnabled">
<div *ngIf="user" class="profile_image_container" [class]="{'anon': !user.imageMd5}" (click)="hamburgerClick($event)">
<img *ngIf="user.imageMd5" [src]="'/api/v1/services/account/image/' + user.username + '?md5=' + user.imageMd5" class="profile_image">
<app-svg-images style="color: lightgrey; fill: lightgray" *ngIf="!user.imageMd5" name="anon"></app-svg-images>
</div>
<div *ngIf="false && user === null" class="profile_image_container" (click)="hamburgerClick($event)">
<app-svg-images name="hamburger" height="40"></app-svg-images>
</div>
<!-- Empty placeholder -->
<div *ngIf="user === undefined" class="profile_image_container"></div>
</ng-container>
<a class="navbar-brand" [ngClass]="{'dual-logos': subdomain}" [routerLink]="['/' | relativeUrl]" (click)="brandClick($event)">
<ng-template [ngIf]="subdomain">
<div class="subdomain_container">
@@ -62,11 +76,19 @@
</nav>
</header>
<app-testnet-alert *ngIf="network.val === 'testnet' || network.val === 'signet'"></app-testnet-alert>
<div class="d-flex" style="overflow: clip">
<app-menu *ngIf="servicesEnabled" [navOpen]="menuOpen" (loggedOut)="onLoggedOut()" (menuToggled)="menuToggled($event)"></app-menu>
<main>
<router-outlet></router-outlet>
</main>
<div class="flex-grow-1 d-flex flex-column">
<app-testnet-alert *ngIf="network.val === 'testnet' || network.val === 'signet'"></app-testnet-alert>
<main style="min-width: 375px" [style]="menuOpen ? 'max-width: calc(100vw - 225px)' : 'max-width: 100vw'">
<router-outlet></router-outlet>
</main>
<div class="flex-grow-1"></div>
<app-global-footer *ngIf="footerVisible"></app-global-footer>
</div>
</div>
<app-global-footer *ngIf="footerVisible"></app-global-footer>
</ng-container>

View File

@@ -1,3 +1,11 @@
.sticky-header {
position: sticky;
position: -webkit-sticky;
top: 0;
width: 100%;
z-index: 100;
}
li.nav-item.active {
background-color: #653b9c;
}
@@ -86,7 +94,6 @@ li.nav-item {
.navbar-brand {
position: relative;
height: 65px;
}
.navbar-brand.dual-logos {
@@ -102,7 +109,7 @@ nav {
.connection-badge {
position: absolute;
top: 22px;
top: 12px;
width: 100%;
}
@@ -209,4 +216,26 @@ nav {
margin-left: 5px;
margin-right: 0px;
}
}
.profile_image_container {
width: 35px;
margin-right: 15px;
text-align: center;
align-self: center;
cursor: pointer;
&.anon {
border: 1.5px solid lightgrey;
color: lightgrey;
border-radius: 5px;
}
}
.profile_image {
height: 35px;
border-radius: 5px;
}
main {
transition: 0.2s;
transition-property: max-width;
}

View File

@@ -1,9 +1,13 @@
import { Component, OnInit, Input } from '@angular/core';
import { Component, OnInit, Input, ViewChild } from '@angular/core';
import { Router } from '@angular/router';
import { Env, StateService } from '../../services/state.service';
import { Observable, merge, of } from 'rxjs';
import { LanguageService } from '../../services/language.service';
import { EnterpriseService } from '../../services/enterprise.service';
import { NavigationService } from '../../services/navigation.service';
import { MenuComponent } from '../menu/menu.component';
import { StorageService } from '../../services/storage.service';
import { ApiService } from '../../services/api.service';
@Component({
selector: 'app-master-page',
@@ -25,12 +29,21 @@ export class MasterPageComponent implements OnInit {
networkPaths: { [network: string]: string };
networkPaths$: Observable<Record<string, string>>;
footerVisible = true;
user: any = undefined;
servicesEnabled = false;
menuOpen = false;
@ViewChild(MenuComponent)
public menuComponent!: MenuComponent;
constructor(
public stateService: StateService,
private languageService: LanguageService,
private enterpriseService: EnterpriseService,
private navigationService: NavigationService,
private storageService: StorageService,
private apiService: ApiService,
private router: Router,
) { }
ngOnInit(): void {
@@ -51,17 +64,47 @@ export class MasterPageComponent implements OnInit {
this.footerVisible = this.footerVisibleOverride;
}
});
this.servicesEnabled = this.officialMempoolSpace && this.stateService.env.ACCELERATOR === true && this.stateService.network === '';
this.refreshAuth();
const isServicesPage = this.router.url.includes('/services/');
this.menuOpen = isServicesPage && !this.isSmallScreen();
}
collapse(): void {
this.navCollapsed = !this.navCollapsed;
}
isSmallScreen() {
return window.innerWidth <= 767.98;
}
onResize(): void {
this.isMobile = window.innerWidth <= 767.98;
this.isMobile = this.isSmallScreen();
}
brandClick(e): void {
this.stateService.resetScroll$.next(true);
}
onLoggedOut(): void {
this.refreshAuth();
}
refreshAuth(): void {
this.user = this.storageService.getAuth()?.user ?? null;
}
hamburgerClick(event): void {
if (this.menuComponent) {
this.menuComponent.hamburgerClick();
this.menuOpen = this.menuComponent.navOpen;
event.stopPropagation();
}
}
menuToggled(isOpen: boolean): void {
this.menuOpen = isOpen;
}
}

View File

@@ -5,6 +5,7 @@ import { switchMap, map, tap, filter } from 'rxjs/operators';
import { MempoolBlock, TransactionStripped } from '../../interfaces/websocket.interface';
import { Observable, BehaviorSubject } from 'rxjs';
import { SeoService } from '../../services/seo.service';
import { seoDescriptionNetwork } from '../../shared/common.utils';
import { WebsocketService } from '../../services/websocket.service';
@Component({
@@ -54,6 +55,7 @@ export class MempoolBlockComponent implements OnInit, OnDestroy {
const ordinal = this.getOrdinal(mempoolBlocks[this.mempoolBlockIndex]);
this.ordinal$.next(ordinal);
this.seoService.setTitle(ordinal);
this.seoService.setDescription($localize`:@@meta.description.mempool-block:See stats for ${this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet'?'Liquid':'Bitcoin'}${seoDescriptionNetwork(this.stateService.network)} transactions in the mempool: fee range, aggregate size, and more. Mempool blocks are updated in real-time as the network receives new transactions.`);
mempoolBlocks[this.mempoolBlockIndex].isStack = mempoolBlocks[this.mempoolBlockIndex].blockVSize > this.stateService.blockVSize;
return mempoolBlocks[this.mempoolBlockIndex];
})

View File

@@ -0,0 +1,31 @@
<div class="sidenav menu-click" [class]="navOpen ? 'open': ''">
<div class="d-flex menu-click">
<nav class="scrollable menu-click">
<span *ngIf="userAuth" class="menu-click">
<strong class="menu-click">@ {{ userAuth.user.username }}</strong>
</span>
<a *ngIf="!userAuth" class="d-flex justify-content-center align-items-center nav-link m-0 menu-click" routerLink="/login" role="tab" (click)="onLinkClick('/login')">
<fa-icon class="menu-click" [icon]="['fas', 'user-circle']" [fixedWidth]="true" style="font-size: 25px;margin-right: 15px;"></fa-icon>
<span class="menu-click" style="font-size: 20px;">Sign in</span>
</a>
<ng-container *ngIf="userMenuGroups$ | async as menuGroups">
<div class="menu-click" *ngFor="let group of menuGroups" style="height: max-content;">
<h6 class="d-flex justify-content-between align-items-center mt-4 mb-2 text-uppercase menu-click">
<span class="menu-click">{{ group.title }}</span>
</h6>
<ul class="nav flex-column menu-click" *ngFor="let item of group.items" (click)="onLinkClick(item.link)">
<li class="nav-item d-flex justify-content-start align-items-center menu-click">
<fa-icon class="menu-click" [icon]="['fas', item.faIcon]" [fixedWidth]="true"></fa-icon>
<button *ngIf="item.link === 'logout'" class="btn nav-link menu-click" role="tab" (click)="logout()">{{ item.title }}</button>
<a *ngIf="item.title !== 'Logout'" class="nav-link menu-click" [routerLink]="[item.link]" role="tab">{{ item.title }}</a>
</li>
</ul>
</div>
</ng-container>
</nav>
</div>
</div>

View File

@@ -0,0 +1,48 @@
.sidenav {
z-index: 1;
background-color: transparent;
width: 225px;
height: calc(100vh - 65px);
position: sticky;
top: 65px;
transition: 0.25s;
margin-left: -250px;
box-shadow: 5px 0px 30px 0px #000;
padding-bottom: 20px;
}
.scrollable {
overflow-x: hidden;
overflow-y: scroll;
}
.sidenav.open {
margin-left: 0px;
left: 0px;
display: block;
}
.sidenav a, button{
text-decoration: none;
color: lightgray;
margin-left: 20px;
}
.sidenav a:hover {
color: white;
}
.sidenav nav {
width: 100%;
height: calc(100vh - 65px);
background-color: #1d1f31;
padding-left: 20px;
padding-right: 20px;
padding-top: 20px;
padding-bottom: 20px;
@media (max-width: 991px) {
padding-bottom: 200px;
}
}
@media screen and (max-height: 450px) {
.sidenav a {font-size: 18px;}
}

View File

@@ -0,0 +1,101 @@
import { Component, OnInit, Input, Output, EventEmitter, HostListener, OnDestroy } from '@angular/core';
import { Observable } from 'rxjs';
import { ApiService } from '../../services/api.service';
import { MenuGroup } from '../../interfaces/services.interface';
import { StorageService } from '../../services/storage.service';
import { Router, NavigationStart } from '@angular/router';
import { StateService } from '../../services/state.service';
@Component({
selector: 'app-menu',
templateUrl: './menu.component.html',
styleUrls: ['./menu.component.scss']
})
export class MenuComponent implements OnInit, OnDestroy {
@Input() navOpen: boolean = false;
@Output() loggedOut = new EventEmitter<boolean>();
@Output() menuToggled = new EventEmitter<boolean>();
userMenuGroups$: Observable<MenuGroup[]> | undefined;
userAuth: any | undefined;
isServicesPage = false;
constructor(
private apiService: ApiService,
private storageService: StorageService,
private router: Router,
private stateService: StateService
) {}
ngOnInit(): void {
this.userAuth = this.storageService.getAuth();
if (this.stateService.env.GIT_COMMIT_HASH_MEMPOOL_SPACE) {
this.userMenuGroups$ = this.apiService.getUserMenuGroups$();
}
this.isServicesPage = this.router.url.includes('/services/');
this.router.events.subscribe((event) => {
if (event instanceof NavigationStart) {
if (!this.isServicesPage) {
this.toggleMenu(false);
}
}
});
}
toggleMenu(toggled: boolean) {
this.navOpen = toggled;
this.menuToggled.emit(toggled);
}
isSmallScreen() {
return window.innerWidth <= 767.98;
}
logout(): void {
this.apiService.logout$().subscribe(() => {
this.loggedOut.emit(true);
if (this.stateService.env.GIT_COMMIT_HASH_MEMPOOL_SPACE) {
this.userMenuGroups$ = this.apiService.getUserMenuGroups$();
this.router.navigateByUrl('/');
}
});
}
onLinkClick(link) {
if (!this.isServicesPage || this.isSmallScreen()) {
this.toggleMenu(false);
}
this.router.navigateByUrl(link);
}
hamburgerClick() {
this.toggleMenu(!this.navOpen);
this.stateService.menuOpen$.next(this.navOpen);
}
@HostListener('window:click', ['$event'])
onClick(event) {
const isServicesPageOnMobile = this.isServicesPage && this.isSmallScreen();
const cssClasses = event.target.className;
if (!cssClasses.indexOf) { // Click on chart or non html thingy, close the menu
if (!this.isServicesPage || isServicesPageOnMobile) {
this.toggleMenu(false);
}
return;
}
const isHamburger = cssClasses.indexOf('profile_image') !== -1;
const isMenu = cssClasses.indexOf('menu-click') !== -1;
if (!isHamburger && !isMenu && (!this.isServicesPage || isServicesPageOnMobile)) {
this.toggleMenu(false);
}
}
ngOnDestroy(): void {
this.stateService.menuOpen$.next(false);
}
}

View File

@@ -18,6 +18,7 @@ export class MiningDashboardComponent implements OnInit, AfterViewInit {
private router: Router
) {
this.seoService.setTitle($localize`:@@a681a4e2011bb28157689dbaa387de0dd0aa0c11:Mining Dashboard`);
this.seoService.setDescription($localize`:@@meta.description.mining.dashboard:Get real-time Bitcoin mining stats like hashrate, difficulty adjustment, block rewards, pool dominance, and more.`);
}
ngOnInit(): void {
@@ -29,7 +30,7 @@ export class MiningDashboardComponent implements OnInit, AfterViewInit {
this.router.events.subscribe((e: NavigationStart) => {
if (e.type === EventType.NavigationStart) {
if (e.url.indexOf('graphs') === -1) { // The mining dashboard and the graph component are part of the same module so we can't use ngAfterViewInit in graphs.component.ts to blur the input
this.stateService.focusSearchInputDesktop();
this.stateService.focusSearchInputDesktop();
}
}
});

View File

@@ -56,6 +56,7 @@ export class PoolRankingComponent implements OnInit {
this.miningWindowPreference = '1w';
} else {
this.seoService.setTitle($localize`:@@mining.mining-pools:Mining Pools`);
this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.pool-ranking:See the top Bitcoin mining pools ranked by number of blocks mined, over your desired timeframe.`);
this.miningWindowPreference = this.miningService.getDefaultTimespan('24h');
}
this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference });
@@ -116,7 +117,7 @@ export class PoolRankingComponent implements OnInit {
} else if (this.widget) {
poolShareThreshold = 1;
}
const data: object[] = [];
let totalShareOther = 0;
let totalBlockOther = 0;

View File

@@ -83,6 +83,7 @@ export class PoolPreviewComponent implements OnInit {
}
this.seoService.setTitle(poolStats.pool.name);
this.seoService.setDescription($localize`:@@meta.description.mining.pool:See mining pool stats for ${poolStats.pool.name}\: most recent mined blocks, hashrate over time, total block reward to date, known coinbase addresses, and more.`);
let regexes = '"';
for (const regex of poolStats.pool.regexes) {
regexes += regex + '", "';

View File

@@ -83,6 +83,7 @@ export class PoolComponent implements OnInit {
}),
map((poolStats) => {
this.seoService.setTitle(poolStats.pool.name);
this.seoService.setDescription($localize`:@@meta.description.mining.pool:See mining pool stats for ${poolStats.pool.name}\: most recent mined blocks, hashrate over time, total block reward to date, known coinbase addresses, and more.`);
let regexes = '"';
for (const regex of poolStats.pool.regexes) {
regexes += regex + '", "';

View File

@@ -1,5 +1,6 @@
import { Component } from '@angular/core';
import { Env, StateService } from '../../services/state.service';
import { SeoService } from '../../services/seo.service';
@Component({
selector: 'app-privacy-policy',
@@ -11,5 +12,11 @@ export class PrivacyPolicyComponent {
constructor(
private stateService: StateService,
private seoService: SeoService,
) { }
ngOnInit(): void {
this.seoService.setTitle('Privacy Policy');
this.seoService.setDescription('Trusted third parties are security holes, as are trusted first parties...you should only trust your own self-hosted instance of The Mempool Open Source Project™.');
}
}

View File

@@ -1,6 +1,9 @@
import { Component, OnInit } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { ApiService } from '../../services/api.service';
import { StateService } from '../../services/state.service';
import { SeoService } from '../../services/seo.service';
import { seoDescriptionNetwork } from '../../shared/common.utils';
@Component({
selector: 'app-push-transaction',
@@ -16,12 +19,17 @@ export class PushTransactionComponent implements OnInit {
constructor(
private formBuilder: UntypedFormBuilder,
private apiService: ApiService,
public stateService: StateService,
private seoService: SeoService,
) { }
ngOnInit(): void {
this.pushTxForm = this.formBuilder.group({
txHash: ['', Validators.required],
});
this.seoService.setTitle($localize`:@@meta.title.push-tx:Broadcast Transaction`);
this.seoService.setDescription($localize`:@@meta.description.push-tx:Broadcast a transaction to the ${this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet'?'Liquid':'Bitcoin'}${seoDescriptionNetwork(this.stateService.network)} network using the transaction's hash.`);
}
postTx() {

View File

@@ -1,5 +1,5 @@
<div [formGroup]="rateUnitForm" class="text-small text-center">
<select formControlName="rateUnits" class="custom-select custom-select-sm form-control-secondary form-control mx-auto" style="width: 100px;" (change)="changeUnits()">
<select formControlName="rateUnits" class="custom-select custom-select-sm form-control-secondary form-control mx-auto" style="width: 95px;" (change)="changeUnits()">
<option *ngFor="let unit of units" [value]="unit.name">{{ unit.label }}</option>
</select>
</div>

View File

@@ -6,6 +6,8 @@ import { WebsocketService } from '../../services/websocket.service';
import { RbfTree } from '../../interfaces/node-api.interface';
import { ApiService } from '../../services/api.service';
import { StateService } from '../../services/state.service';
import { SeoService } from '../../services/seo.service';
import { seoDescriptionNetwork } from '../../shared/common.utils';
@Component({
selector: 'app-rbf-list',
@@ -26,6 +28,7 @@ export class RbfList implements OnInit, OnDestroy {
private apiService: ApiService,
public stateService: StateService,
private websocketService: WebsocketService,
private seoService: SeoService,
) { }
ngOnInit(): void {
@@ -51,9 +54,12 @@ export class RbfList implements OnInit, OnDestroy {
this.isLoading = false;
})
);
this.seoService.setTitle($localize`:@@meta.title.rbf-list:RBF Replacements`);
this.seoService.setDescription($localize`:@@meta.description.rbf-list:See the most recent RBF replacements on the Bitcoin${seoDescriptionNetwork(this.stateService.network)} network, updated in real-time.`);
}
ngOnDestroy(): void {
this.websocketService.stopTrackRbf();
}
}
}

View File

@@ -10,15 +10,25 @@
<div *ngIf="countdown > 0" class="warning-label">{{ eventName }} in {{ countdown | number }} block{{ countdown === 1 ? '' : 's' }}!</div>
<div class="blockchain-wrapper" [class.time-ltr]="timeLtr" [class.time-rtl]="!timeLtr">
<div class="blockchain-wrapper" [class.time-ltr]="timeLtr" [class.time-rtl]="!timeLtr" #blockchainWrapper>
<div id="blockchain-container" [dir]="timeLtr ? 'rtl' : 'ltr'" #blockchainContainer
[class.menu-open]="menuOpen"
[class.menu-closing]="menuSliding && !menuOpen"
(mousedown)="onMouseDown($event)"
(pointerdown)="onPointerDown($event)"
(touchmove)="onTouchMove($event)"
(dragstart)="onDragStart($event)"
(scroll)="onScroll($event)"
>
<app-blockchain [pageIndex]="pageIndex" [pages]="pages" [blocksPerPage]="blocksPerPage" [minScrollWidth]="minScrollWidth" [scrollableMempool]="true" (mempoolOffsetChange)="onMempoolOffsetChange($event)"></app-blockchain>
<app-blockchain
[containerWidth]="chainWidth"
[pageIndex]="pageIndex"
[pages]="pages"
[blocksPerPage]="blocksPerPage"
[minScrollWidth]="minScrollWidth"
[scrollableMempool]="true"
(mempoolOffsetChange)="onMempoolOffsetChange($event)"
></app-blockchain>
</div>
<div class="reset-scroll" [class.hidden]="pageIndex === 0" (click)="resetScroll()">
<fa-icon [icon]="['fas', 'circle-left']" [fixedWidth]="true"></fa-icon>

View File

@@ -6,6 +6,20 @@
overflow-y: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
width: calc(100% + 120px);
transform: translateX(0px);
transition: transform 0;
&.menu-open {
transform: translateX(-112.5px);
transition: transform 0.25s;
}
&.menu-closing {
transform: translateX(0px);
transition: transform 0.25s;
}
}
#blockchain-container::-webkit-scrollbar {

View File

@@ -28,8 +28,10 @@ export class StartComponent implements OnInit, OnDestroy, DoCheck {
lastMark: MarkBlockState;
markBlockSubscription: Subscription;
blockCounterSubscription: Subscription;
@ViewChild('blockchainWrapper', { static: true }) blockchainWrapper: ElementRef;
@ViewChild('blockchainContainer') blockchainContainer: ElementRef;
resetScrollSubscription: Subscription;
resetScrollSubscription: Subscription;
menuSubscription: Subscription;
isMobile: boolean = false;
isiOS: boolean = false;
@@ -49,6 +51,12 @@ export class StartComponent implements OnInit, OnDestroy, DoCheck {
velocity: number = 0;
mempoolOffset: number = 0;
private resizeObserver: ResizeObserver;
chainWidth: number = window.innerWidth;
menuOpen: boolean = false;
menuSliding: boolean = false;
menuTimeout: number;
constructor(
private stateService: StateService,
) {
@@ -151,6 +159,13 @@ export class StartComponent implements OnInit, OnDestroy, DoCheck {
this.stateService.resetScroll$.next(false);
}
});
this.menuSubscription = this.stateService.menuOpen$.subscribe((open) => {
if (this.menuOpen !== open) {
this.menuOpen = open;
this.applyMenuScroll(this.menuOpen);
}
});
}
onMempoolOffsetChange(offset): void {
@@ -171,9 +186,18 @@ export class StartComponent implements OnInit, OnDestroy, DoCheck {
}
}
applyMenuScroll(opening: boolean): void {
this.menuSliding = true;
window.clearTimeout(this.menuTimeout);
this.menuTimeout = window.setTimeout(() => {
this.menuSliding = false;
}, 300);
}
@HostListener('window:resize', ['$event'])
onResize(): void {
this.isMobile = window.innerWidth <= 767.98;
this.chainWidth = window.innerWidth;
this.isMobile = this.chainWidth <= 767.98;
let firstVisibleBlock;
let offset;
if (this.blockchainContainer?.nativeElement != null) {
@@ -188,7 +212,7 @@ export class StartComponent implements OnInit, OnDestroy, DoCheck {
});
}
this.blocksPerPage = Math.ceil(window.innerWidth / this.blockWidth);
this.blocksPerPage = Math.ceil(this.chainWidth / this.blockWidth);
this.pageWidth = this.blocksPerPage * this.blockWidth;
this.minScrollWidth = this.firstPageWidth + (this.pageWidth * 2);
@@ -295,7 +319,7 @@ export class StartComponent implements OnInit, OnDestroy, DoCheck {
onScroll(e) {
const middlePage = this.pageIndex === 0 ? this.pages[0] : this.pages[1];
// compensate for css transform
const translation = (this.isMobile ? window.innerWidth * 0.95 : window.innerWidth * 0.5);
const translation = (this.isMobile ? this.chainWidth * 0.95 : this.chainWidth * 0.5);
const backThreshold = middlePage.offset + (this.pageWidth * 0.5) + translation;
const forwardThreshold = middlePage.offset - (this.pageWidth * 0.5) + translation;
const scrollLeft = this.getConvertedScrollOffset();
@@ -414,10 +438,10 @@ export class StartComponent implements OnInit, OnDestroy, DoCheck {
blockInViewport(height: number): boolean {
const firstHeight = this.pages[0].height;
const translation = (this.isMobile ? window.innerWidth * 0.95 : window.innerWidth * 0.5);
const translation = (this.isMobile ? this.chainWidth * 0.95 : this.chainWidth * 0.5);
const firstX = this.pages[0].offset - this.getConvertedScrollOffset() + translation;
const xPos = firstX + ((firstHeight - height) * 155);
return xPos > -55 && xPos < (window.innerWidth - 100);
return xPos > -55 && xPos < (this.chainWidth - 100);
}
getConvertedScrollOffset(): number {
@@ -458,5 +482,6 @@ export class StartComponent implements OnInit, OnDestroy, DoCheck {
this.markBlockSubscription.unsubscribe();
this.blockCounterSubscription.unsubscribe();
this.resetScrollSubscription.unsubscribe();
this.menuSubscription.unsubscribe();
}
}

View File

@@ -62,6 +62,7 @@ export class StatisticsComponent implements OnInit {
this.inverted = this.storageService.getValue('inverted-graph') === 'true';
this.setFeeLevelDropdownData();
this.seoService.setTitle($localize`:@@5d4f792f048fcaa6df5948575d7cb325c9393383:Graphs`);
this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.mempool:See mempool size (in MvB) and transactions per second (in vB/s) visualized over time.`);
this.stateService.networkChanged$.subscribe((network) => this.network = network);
this.graphWindowPreference = this.storageService.getValue('graphWindowPreference') ? this.storageService.getValue('graphWindowPreference').trim() : '2h';

View File

@@ -74,6 +74,16 @@
<path fill="#FFFFFF" d="M128 768h256v64H128v-64z m320-384H128v64h320v-64z m128 192V448L384 640l192 192V704h320V576H576z m-288-64H128v64h160v-64zM128 704h160v-64H128v64z m576 64h64v128c-1 18-7 33-19 45s-27 18-45 19H64c-35 0-64-29-64-64V192c0-35 29-64 64-64h192C256 57 313 0 384 0s128 57 128 128h192c35 0 64 29 64 64v320h-64V320H64v576h640V768zM128 256h512c0-35-29-64-64-64h-64c-35 0-64-29-64-64s-29-64-64-64-64 29-64 64-29 64-64 64h-64c-35 0-64 29-64 64z" />
</svg>
</ng-container>
<ng-container *ngSwitchCase="'hamburger'">
<svg [attr.width]="width" [attr.height]="height" viewBox="0 0 10 10" xmlns="http://www.w3.org/2000/svg" stroke="currentColor">
<path stroke-width="0.5" stroke-linecap="round" d="M0.5 2.5 H7 M0.5 5 H5.5 M0.5 7.5 H7"></path>
</svg>
</ng-container>
<ng-container *ngSwitchCase="'anon'">
<svg [attr.width]="width" [attr.height]="height" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 9.5v-2a3 3 0 116 0v2c0 1.11-.603 2.08-1.5 2.599v1.224a1 1 0 00.629.928l2.05.82A3.693 3.693 0 0118.5 18.5h-13c0-1.51.92-2.868 2.321-3.428l2.05-.82a1 1 0 00.629-.929v-1.224A2.999 2.999 0 019 9.5z"></path>
</svg>
</ng-container>
</ng-container>
<ng-template #bitcoinLogo let-color let-width="width" let-height="height" let-viewBox="viewBox">

View File

@@ -37,6 +37,7 @@ export class TelevisionComponent implements OnInit, OnDestroy {
ngOnInit() {
this.seoService.setTitle($localize`:@@46ce8155c9ab953edeec97e8950b5a21e67d7c4e:TV view`);
this.seoService.setDescription($localize`:@@meta.description.tv:See Bitcoin blocks and mempool congestion in real-time in a simplified format perfect for a TV.`);
this.websocketService.want(['blocks', 'live-2h-chart', 'mempool-blocks']);
this.timeLtrSubscription = this.stateService.timeLtr.subscribe((ltr) => {

View File

@@ -1,5 +1,6 @@
import { Component } from '@angular/core';
import { Env, StateService } from '../../services/state.service';
import { SeoService } from '../../services/seo.service';
@Component({
selector: 'app-terms-of-service',
@@ -10,5 +11,11 @@ export class TermsOfServiceComponent {
constructor(
private stateService: StateService,
private seoService: SeoService,
) { }
ngOnInit(): void {
this.seoService.setTitle('Terms of Service');
this.seoService.setDescription('Out of respect for the Bitcoin community, the mempool.space website is Bitcoin Only and does not display any advertising.');
}
}

View File

@@ -1,5 +1,6 @@
import { Component } from '@angular/core';
import { Env, StateService } from '../../services/state.service';
import { SeoService } from '../../services/seo.service';
@Component({
selector: 'app-trademark-policy',
@@ -11,5 +12,11 @@ export class TrademarkPolicyComponent {
constructor(
private stateService: StateService,
private seoService: SeoService,
) { }
ngOnInit(): void {
this.seoService.setTitle('Trademark Policy');
this.seoService.setDescription('An overview of the trademarks registered by Mempool Space K.K. and The Mempool Open Source Project™ and what we consider to be lawful usage of those trademarks.');
}
}

View File

@@ -15,6 +15,7 @@ import { CacheService } from '../../services/cache.service';
import { OpenGraphService } from '../../services/opengraph.service';
import { ApiService } from '../../services/api.service';
import { SeoService } from '../../services/seo.service';
import { seoDescriptionNetwork } from '../../shared/common.utils';
import { CpfpInfo } from '../../interfaces/node-api.interface';
import { LiquidUnblinding } from './liquid-ublinding';
@@ -87,6 +88,7 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy {
this.seoService.setTitle(
$localize`:@@bisq.transaction.browser-title:Transaction: ${this.txId}:INTERPOLATION:`
);
this.seoService.setDescription($localize`:@@meta.description.bitcoin.transaction:Get real-time status, addresses, fees, script info, and more for ${this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet'?'Liquid':'Bitcoin'}${seoDescriptionNetwork(this.stateService.network)} transaction with txid {txid}.`);
this.resetTransaction();
return merge(
of(true),

View File

@@ -6,6 +6,13 @@
<app-truncate [text]="rbfTransaction.txid" [lastChars]="12" [link]="['/tx/' | relativeUrl, rbfTransaction.txid]"></app-truncate>
</div>
<div *ngIf="acceleratorAvailable && accelerateCtaType === 'alert' && !tx?.status?.confirmed && !tx?.acceleration" class="alert alert-mempool alert-dismissible" role="alert">
<span><a class="link accelerator" (click)="onAccelerateClicked()">Accelerate</a> this transaction using Mempool Accelerator &trade;</span>
<button type="button" class="close" aria-label="Close" (click)="dismissAccelAlert()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<ng-container *ngIf="!rbfTransaction || rbfTransaction?.size || tx">
<h1 i18n="shared.transaction">Transaction</h1>
@@ -66,12 +73,22 @@
<div class="col-sm">
<ng-container *ngTemplateOutlet="feeTable"></ng-container>
</div>
</div>
</div>
</ng-template>
<!-- Accelerator -->
<ng-container *ngIf="!tx?.status?.confirmed && showAccelerationSummary">
<div class="title mt-3" id="acceleratePreviewAnchor">
<h2>Accelerate</h2>
</div>
<div class="box">
<app-accelerate-preview [tx]="tx" [scrollEvent]="scrollIntoAccelPreview"></app-accelerate-preview>
</div>
</ng-container>
<ng-template #unconfirmedTemplate>
<div class="box">
@@ -92,16 +109,16 @@
</ng-template>
</ng-template>
<tr *ngIf="!replaced && !isCached">
<td class="td-width" i18n="transaction.eta|Transaction ETA">ETA</td>
<td class="td-width align-items-center align-middle" i18n="transaction.eta|Transaction ETA">ETA</td>
<td>
<ng-template [ngIf]="this.mempoolPosition?.block == null" [ngIfElse]="estimationTmpl">
<span class="skeleton-loader"></span>
</ng-template>
<ng-template #estimationTmpl>
<ng-template [ngIf]="this.mempoolPosition.block >= 7" [ngIfElse]="belowBlockLimit">
<span class="eta d-flex">
<span [class]="(acceleratorAvailable && accelerateCtaType === 'button') ? 'etaDeepMempool d-flex justify-content-end align-items-center' : ''">
<span i18n="transaction.eta.in-several-hours|Transaction ETA in several hours or more">In several hours (or more)</span>
<span class="ml-2"></span><a *ngIf="stateService.env.OFFICIAL_MEMPOOL_SPACE && stateService.env.ACCELERATOR && stateService.network === ''" [href]="'/services/accelerator/accelerate?txid=' + tx.txid" class="btn badge badge-primary accelerate ml-auto" i18n="transaction.accelerate|Accelerate button label">Accelerate</a>
<a *ngIf="acceleratorAvailable && accelerateCtaType === 'button' && !tx?.acceleration" [href]="'/services/accelerator/accelerate?txid=' + tx.txid" class="btn btn-sm accelerateDeepMempool" i18n="transaction.accelerate|Accelerate button label" (click)="onAccelerateClicked()">Accelerate</a>
</span>
</ng-template>
<ng-template #belowBlockLimit>
@@ -109,9 +126,9 @@
<app-time kind="until" [time]="(60 * 1000 * this.mempoolPosition.block) + now" [fastRender]="false" [fixedRender]="true"></app-time>
</ng-template>
<ng-template #timeEstimateDefault>
<span class="d-flex">
<span class="eta justify-content-end" [class]="(acceleratorAvailable && accelerateCtaType === 'button') ? 'd-flex align-items-center' : ''">
<app-time kind="until" *ngIf="(da$ | async) as da;" [time]="da.timeAvg * (this.mempoolPosition.block + 1) + now + da.timeOffset" [fastRender]="false" [fixedRender]="true"></app-time>
<span class="ml-2"></span><a *ngIf="stateService.env.OFFICIAL_MEMPOOL_SPACE && stateService.env.ACCELERATOR && stateService.network === ''" [href]="'/services/accelerator/accelerate?txid=' + tx.txid" class="btn badge badge-primary accelerate ml-auto" i18n="transaction.accelerate|Accelerate button label">Accelerate</a>
<a *ngIf="acceleratorAvailable && accelerateCtaType === 'button' && !tx?.acceleration" [href]="'/services/accelerator/accelerate?txid=' + tx.txid" class="btn btn-sm accelerate" i18n="transaction.accelerate|Accelerate button label" (click)="onAccelerateClicked()">Accelerate</a>
</span>
</ng-template>
</ng-template>

View File

@@ -130,7 +130,7 @@
}
.table {
tr td {
tr td {
padding: 0.75rem 0.5rem;
@media (min-width: 576px) {
padding: 0.75rem 0.75rem;
@@ -138,7 +138,7 @@
&:last-child {
text-align: right;
@media (min-width: 850px) {
text-align: left;
text-align: left;
}
}
.btn {
@@ -218,21 +218,52 @@
}
}
.link.accelerator {
cursor: pointer;
}
.eta {
display: flex;
justify-content: end;
flex-wrap: wrap;
align-content: center;
@media (min-width: 850px) {
justify-content: space-between;
justify-content: left !important;
}
}
.accelerate {
display: flex !important;
align-self: auto;
margin-top: 3px;
@media (min-width: 850px) {
justify-self: start;
margin-left: auto;
background-color: #653b9c;
@media (max-width: 849px) {
margin-left: 5px;
}
}
.etaDeepMempool {
display: flex !important;
justify-content: end;
flex-wrap: wrap;
align-content: center;
@media (max-width: 995px) {
justify-content: left !important;
}
@media (max-width: 849px) {
justify-content: right !important;
}
}
.accelerateDeepMempool {
align-self: auto;
margin-top: 3px;
margin-left: auto;
background-color: #653b9c;
@media (max-width: 995px) {
margin-left: 0px;
}
}
@media (max-width: 849px) {
margin-left: 5px;
}
}

View File

@@ -19,6 +19,8 @@ import { WebsocketService } from '../../services/websocket.service';
import { AudioService } from '../../services/audio.service';
import { ApiService } from '../../services/api.service';
import { SeoService } from '../../services/seo.service';
import { StorageService } from '../../services/storage.service';
import { seoDescriptionNetwork } from '../../shared/common.utils';
import { BlockExtended, CpfpInfo, RbfTree, MempoolPosition, DifficultyAdjustment } from '../../interfaces/node-api.interface';
import { LiquidUnblinding } from './liquid-ublinding';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
@@ -88,6 +90,10 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
rbfEnabled: boolean;
taprootEnabled: boolean;
hasEffectiveFeeRate: boolean;
accelerateCtaType: 'alert' | 'button' = 'alert';
acceleratorAvailable: boolean = this.stateService.env.OFFICIAL_MEMPOOL_SPACE && this.stateService.env.ACCELERATOR && this.stateService.network === '';
showAccelerationSummary = false;
scrollIntoAccelPreview = false;
@ViewChild('graphContainer')
graphContainer: ElementRef;
@@ -104,14 +110,22 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
private apiService: ApiService,
private seoService: SeoService,
private priceService: PriceService,
private storageService: StorageService
) {}
ngOnInit() {
this.acceleratorAvailable = this.stateService.env.OFFICIAL_MEMPOOL_SPACE && this.stateService.env.ACCELERATOR && this.stateService.network === '';
this.websocketService.want(['blocks', 'mempool-blocks']);
this.stateService.networkChanged$.subscribe(
(network) => (this.network = network)
(network) => {
this.network = network;
this.acceleratorAvailable = this.stateService.env.OFFICIAL_MEMPOOL_SPACE && this.stateService.env.ACCELERATOR && this.stateService.network === '';
}
);
this.accelerateCtaType = (this.storageService.getValue('accel-cta-type') as 'alert' | 'button') ?? 'alert';
this.setFlowEnabled();
this.flowPrefSubscription = this.stateService.hideFlow.subscribe((hide) => {
this.hideFlow = !!hide;
@@ -161,34 +175,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
})
)
.subscribe((cpfpInfo) => {
if (!cpfpInfo || !this.tx) {
this.cpfpInfo = null;
this.hasEffectiveFeeRate = false;
return;
}
// merge ancestors/descendants
const relatives = [...(cpfpInfo.ancestors || []), ...(cpfpInfo.descendants || [])];
if (cpfpInfo.bestDescendant && !cpfpInfo.descendants?.length) {
relatives.push(cpfpInfo.bestDescendant);
}
const hasRelatives = !!relatives.length;
if (!cpfpInfo.effectiveFeePerVsize && hasRelatives) {
let totalWeight =
this.tx.weight +
relatives.reduce((prev, val) => prev + val.weight, 0);
let totalFees =
this.tx.fee +
relatives.reduce((prev, val) => prev + val.fee, 0);
this.tx.effectiveFeePerVsize = totalFees / (totalWeight / 4);
} else {
this.tx.effectiveFeePerVsize = cpfpInfo.effectiveFeePerVsize;
}
if (cpfpInfo.acceleration) {
this.tx.acceleration = cpfpInfo.acceleration;
}
this.cpfpInfo = cpfpInfo;
this.hasEffectiveFeeRate = hasRelatives || (this.tx.effectiveFeePerVsize && (Math.abs(this.tx.effectiveFeePerVsize - this.tx.feePerVsize) > 0.01));
this.setCpfpInfo(cpfpInfo);
});
this.fetchRbfSubscription = this.fetchRbfHistory$
@@ -259,6 +246,10 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
mempoolPosition: this.mempoolPosition
});
this.txInBlockIndex = this.mempoolPosition.block;
if (txPosition.cpfp !== undefined) {
this.setCpfpInfo(txPosition.cpfp);
}
}
} else {
this.mempoolPosition = null;
@@ -297,6 +288,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.seoService.setTitle(
$localize`:@@bisq.transaction.browser-title:Transaction: ${this.txId}:INTERPOLATION:`
);
this.seoService.setDescription($localize`:@@meta.description.bitcoin.transaction:Get real-time status, addresses, fees, script info, and more for ${this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet'?'Liquid':'Bitcoin'}${seoDescriptionNetwork(this.stateService.network)} transaction with txid {txid}.`);
this.resetTransaction();
return merge(
of(true),
@@ -399,7 +391,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.blockConversion = price;
})
).subscribe();
setTimeout(() => { this.applyFragment(); }, 0);
},
(error) => {
@@ -486,6 +478,20 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.setGraphSize();
}
dismissAccelAlert(): void {
this.storageService.setValue('accel-cta-type', 'button');
this.accelerateCtaType = 'button';
}
onAccelerateClicked() {
if (!this.txId) {
return;
}
this.showAccelerationSummary = true && this.acceleratorAvailable;
this.scrollIntoAccelPreview = !this.scrollIntoAccelPreview;
return false;
}
handleLoadElectrsTransactionError(error: any): Observable<any> {
if (error.status === 404 && /^[a-fA-F0-9]{64}$/.test(this.txId)) {
this.websocketService.startMultiTrackTransaction(this.txId);
@@ -507,6 +513,37 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
});
}
setCpfpInfo(cpfpInfo: CpfpInfo): void {
if (!cpfpInfo || !this.tx) {
this.cpfpInfo = null;
this.hasEffectiveFeeRate = false;
return;
}
// merge ancestors/descendants
const relatives = [...(cpfpInfo.ancestors || []), ...(cpfpInfo.descendants || [])];
if (cpfpInfo.bestDescendant && !cpfpInfo.descendants?.length) {
relatives.push(cpfpInfo.bestDescendant);
}
const hasRelatives = !!relatives.length;
if (!cpfpInfo.effectiveFeePerVsize && hasRelatives) {
const totalWeight =
this.tx.weight +
relatives.reduce((prev, val) => prev + val.weight, 0);
const totalFees =
this.tx.fee +
relatives.reduce((prev, val) => prev + val.fee, 0);
this.tx.effectiveFeePerVsize = totalFees / (totalWeight / 4);
} else {
this.tx.effectiveFeePerVsize = cpfpInfo.effectiveFeePerVsize;
}
if (cpfpInfo.acceleration) {
this.tx.acceleration = cpfpInfo.acceleration;
}
this.cpfpInfo = cpfpInfo;
this.hasEffectiveFeeRate = hasRelatives || (this.tx.effectiveFeePerVsize && (Math.abs(this.tx.effectiveFeePerVsize - this.tx.feePerVsize) > 0.01));
}
setFeatures(): void {
if (this.tx) {
this.segwitEnabled = !this.tx.status.confirmed || isFeatureActive(this.stateService.network, this.tx.status.block_height, 'segwit');

View File

@@ -69,6 +69,7 @@ export class DashboardComponent implements OnInit, OnDestroy, AfterViewInit {
ngOnInit(): void {
this.isLoadingWebSocket$ = this.stateService.isLoadingWebSocket$;
this.seoService.resetTitle();
this.seoService.resetDescription();
this.websocketService.want(['blocks', 'stats', 'mempool-blocks', 'live-2h-chart']);
this.websocketService.startTrackRbfSummary();
this.network$ = merge(of(''), this.stateService.networkChanged$);

View File

@@ -155,7 +155,7 @@ ul.no-bull.block-audit code{
#doc-nav-desktop.fixed {
float: unset;
position: fixed;
top: 20px;
top: 80px;
overflow-y: auto;
height: calc(100vh - 50px);
scrollbar-color: #2d3348 #11131f;

View File

@@ -43,7 +43,7 @@ export class ApiDocsComponent implements OnInit, AfterViewInit {
if (this.faqTemplates) {
this.faqTemplates.forEach((x) => this.dict[x.type] = x.template);
}
this.desktopDocsNavPosition = ( window.pageYOffset > 182 ) ? "fixed" : "relative";
this.desktopDocsNavPosition = ( window.pageYOffset > 115 ) ? "fixed" : "relative";
this.mobileViewport = window.innerWidth <= 992;
}
@@ -113,7 +113,7 @@ export class ApiDocsComponent implements OnInit, AfterViewInit {
}
onDocScroll() {
this.desktopDocsNavPosition = ( window.pageYOffset > 182 ) ? "fixed" : "relative";
this.desktopDocsNavPosition = ( window.pageYOffset > 115 ) ? "fixed" : "relative";
}
anchorLinkClick( event: any ) {

View File

@@ -28,21 +28,6 @@ export class DocsComponent implements OnInit {
ngOnInit(): void {
this.websocket.want(['blocks']);
const url = this.route.snapshot.url;
if (url[0].path === "faq" ) {
this.activeTab = 0;
this.seoService.setTitle($localize`:@@docs.faq.button-title:FAQ`);
} else if( url[1].path === "rest" ) {
this.activeTab = 1;
this.seoService.setTitle($localize`:@@e351b40b3869a5c7d19c3d4918cb1ac7aaab95c4:API`);
} else if( url[1].path === "websocket" ) {
this.activeTab = 2;
this.seoService.setTitle($localize`:@@e351b40b3869a5c7d19c3d4918cb1ac7aaab95c4:API`);
} else {
this.activeTab = 3;
this.seoService.setTitle($localize`:@@e351b40b3869a5c7d19c3d4918cb1ac7aaab95c4:API`);
}
this.env = this.stateService.env;
this.showWebSocketTab = ( ! ( ( this.stateService.network === "bisq" ) || ( this.stateService.network === "liquidtestnet" ) ) );
this.showFaqTab = ( this.env.BASE_MODULE === 'mempool' ) ? true : false;
@@ -51,6 +36,40 @@ export class DocsComponent implements OnInit {
document.querySelector<HTMLElement>( "html" ).style.scrollBehavior = "smooth";
}
ngDoCheck(): void {
const url = this.route.snapshot.url;
if (url[0].path === "faq" ) {
this.activeTab = 0;
this.seoService.setTitle($localize`:@@meta.title.docs.faq:FAQ`);
this.seoService.setDescription($localize`:@@meta.description.docs.faq:Get answers to common questions like: What is a mempool? Why isn't my transaction confirming? How can I run my own instance of The Mempool Open Source Project? And more.`);
} else if( url[1].path === "rest" ) {
this.activeTab = 1;
this.seoService.setTitle($localize`:@@meta.title.docs.rest:REST API`);
if( this.stateService.network === 'liquid' || this.stateService.network === 'liquidtestnet' ) {
this.seoService.setDescription($localize`:@@meta.description.docs.rest-liquid:Documentation for the liquid.network REST API service: get info on addresses, transactions, assets, blocks, and more.`);
} else if( this.stateService.network === 'bisq' ) {
this.seoService.setDescription($localize`:@@meta.description.docs.rest-bisq:Documentation for the bisq.markets REST API service: get info on recent trades, current offers, transactions, network state, and more.`);
} else {
this.seoService.setDescription($localize`:@@meta.description.docs.rest-bitcoin:Documentation for the mempool.space REST API service: get info on addresses, transactions, blocks, fees, mining, the Lightning network, and more.`);
}
} else if( url[1].path === "websocket" ) {
this.activeTab = 2;
this.seoService.setTitle($localize`:@@meta.title.docs.websocket:WebSocket API`);
if( this.stateService.network === 'liquid' || this.stateService.network === 'liquidtestnet' ) {
this.seoService.setDescription($localize`:@@meta.description.docs.websocket-liquid:Documentation for the liquid.network WebSocket API service: get real-time info on blocks, mempools, transactions, addresses, and more.`);
} else {
this.seoService.setDescription($localize`:@@meta.description.docs.websocket-bitcoin:Documentation for the mempool.space WebSocket API service: get real-time info on blocks, mempools, transactions, addresses, and more.`);
}
} else {
this.activeTab = 3;
this.seoService.setTitle($localize`:@@meta.title.docs.websocket:Electrum RPC`);
this.seoService.setDescription($localize`:@@meta.description.docs.electrumrpc:Documentation for our Electrum RPC interface: get instant, convenient, and reliable access to an Esplora instance.`);
}
}
ngOnDestroy(): void {
document.querySelector<HTMLElement>( "html" ).style.scrollBehavior = "auto";
}

View File

@@ -19,7 +19,7 @@ export interface Transaction {
ancestors?: Ancestor[];
bestDescendant?: BestDescendant | null;
cpfpChecked?: boolean;
acceleration?: number;
acceleration?: boolean;
deleteAfter?: number;
_unblinded?: any;
_deduced?: boolean;

View File

@@ -27,7 +27,7 @@ export interface CpfpInfo {
effectiveFeePerVsize?: number;
sigops?: number;
adjustedVsize?: number;
acceleration?: number;
acceleration?: boolean;
}
export interface RbfInfo {

View File

@@ -0,0 +1,13 @@
import { IconName } from '@fortawesome/fontawesome-common-types';
export type MenuItem = {
title: string;
i18n: string;
faIcon: IconName;
link: string;
};
export type MenuGroup = {
title: string;
i18n: string;
items: MenuItem[];
}

View File

@@ -95,7 +95,7 @@ export interface TransactionStripped {
}
export interface IBackendInfo {
hostname: string;
hostname?: string;
gitCommit: string;
version: string;
}

View File

@@ -34,6 +34,7 @@ export class ChannelPreviewComponent implements OnInit {
this.openGraphService.waitFor('channel-data-' + this.shortId);
this.error = null;
this.seoService.setTitle(`Channel: ${params.get('short_id')}`);
this.seoService.setDescription($localize`:@@meta.description.lightning.channel:Overview for Lightning channel ${params.get('short_id')}. See channel capacity, the Lightning nodes involved, related on-chain transactions, and more.`);
return this.lightningApiService.getChannel$(params.get('short_id'))
.pipe(
tap((data) => {

View File

@@ -35,6 +35,7 @@ export class ChannelComponent implements OnInit {
.pipe(
tap((value) => {
this.seoService.setTitle($localize`Channel: ${value.short_id}`);
this.seoService.setDescription($localize`:@@meta.description.lightning.channel:Overview for Lightning channel ${value.short_id}. See channel capacity, the Lightning nodes involved, related on-chain transactions, and more.`);
}),
catchError((err) => {
this.error = err;

View File

@@ -31,6 +31,7 @@ export class GroupPreviewComponent implements OnInit {
ngOnInit(): void {
this.seoService.setTitle(`Mempool.Space Lightning Nodes`);
this.seoService.setDescription(`See all Lightning nodes run by mempool.space -- these are the nodes that provide the data on the mempool.space Lightning dashboard.`);
this.nodes$ = this.activatedRoute.paramMap
.pipe(

View File

@@ -39,6 +39,7 @@ export class GroupComponent implements OnInit {
});
this.seoService.setTitle(`Mempool.space Lightning Nodes`);
this.seoService.setDescription(`See all Lightning nodes run by mempool.space -- these are the nodes that provide the data on the mempool.space Lightning dashboard.`);
this.nodes$ = this.lightningApiService.getNodGroupNodes$('mempool.space')
.pipe(

View File

@@ -25,6 +25,7 @@ export class LightningDashboardComponent implements OnInit, AfterViewInit {
ngOnInit(): void {
this.seoService.setTitle($localize`:@@142e923d3b04186ac6ba23387265d22a2fa404e0:Lightning Explorer`);
this.seoService.setDescription($localize`:@@meta.description.lightning.dashboard:Get stats on the Lightning network (aggregate capacity, connectivity, etc) and Lightning nodes (channels, liquidity, etc) and Lightning channels (status, fees, etc).`);
this.nodesRanking$ = this.lightningApiService.getNodesRanking$().pipe(share());
this.statistics$ = this.lightningApiService.getLatestStatistics$().pipe(share());

View File

@@ -49,6 +49,7 @@ export class NodePreviewComponent implements OnInit {
}),
map((node) => {
this.seoService.setTitle(`Node: ${node.alias}`);
this.seoService.setDescription($localize`:@@meta.description.lightning.node:Overview for the Lightning network node named ${node.alias}. See channels, capacity, location, fee stats, and more.`);
const socketsObject = [];
const socketTypesMap = {};

View File

@@ -60,6 +60,7 @@ export class NodeComponent implements OnInit {
}),
map((node) => {
this.seoService.setTitle($localize`Node: ${node.alias}`);
this.seoService.setDescription($localize`:@@meta.description.lightning.node:Overview for the Lightning network node named ${node.alias}. See channels, capacity, location, fee stats, and more.`);
this.clearnetSocketCount = 0;
this.torSocketCount = 0;

View File

@@ -26,7 +26,7 @@ export class NodesChannelsMap implements OnInit {
@Input() disableSpinner = false;
@Output() readyEvent = new EventEmitter();
channelsObservable: Observable<any>;
channelsObservable: Observable<any>;
center: number[] | undefined;
zoom: number | undefined;
@@ -41,7 +41,7 @@ export class NodesChannelsMap implements OnInit {
chartOptions: EChartsOption = {};
chartInitOptions = {
renderer: 'canvas',
};
};
constructor(
private seoService: SeoService,
@@ -64,15 +64,16 @@ export class NodesChannelsMap implements OnInit {
this.zoom = 1.4;
this.center = [0, 10];
}
if (this.style === 'graph') {
this.seoService.setTitle($localize`Lightning Nodes Channels World Map`);
this.seoService.setDescription($localize`:@@meta.description.lightning.node-map:See the channels of non-Tor Lightning network nodes visualized on a world map. Hover/tap on points on the map for node names and details.`);
}
if (['nodepage', 'channelpage'].includes(this.style)) {
this.nodeSize = 8;
}
this.channelsObservable = this.activatedRoute.paramMap
.pipe(
delay(100),
@@ -81,7 +82,7 @@ export class NodesChannelsMap implements OnInit {
if (this.style === 'channelpage' && this.channel.length === 0 || !this.hasLocation) {
this.isLoading = false;
}
return zip(
this.assetsService.getWorldMapJson$,
this.style !== 'channelpage' ? this.apiService.getChannelsGeo$(params.get('public_key') ?? undefined, this.style) : [''],
@@ -140,7 +141,7 @@ export class NodesChannelsMap implements OnInit {
// on top of each other
let random = Math.random() * 2 * Math.PI;
let random2 = Math.random() * 0.01;
if (!nodesPubkeys[node1UniqueId]) {
nodes.push([
channel[node1GpsLat] + random2 * Math.cos(random),
@@ -167,7 +168,7 @@ export class NodesChannelsMap implements OnInit {
}
const channelLoc = [];
channelLoc.push(nodesPubkeys[node1UniqueId].slice(0, 2));
channelLoc.push(nodesPubkeys[node1UniqueId].slice(0, 2));
channelLoc.push(nodesPubkeys[node2UniqueId].slice(0, 2));
channelsLoc.push(channelLoc);
}
@@ -326,7 +327,7 @@ export class NodesChannelsMap implements OnInit {
this.chartInstance.on('finished', () => {
this.isLoading = false;
});
if (this.style === 'widget') {
this.chartInstance.getZr().on('click', (e) => {
this.zone.run(() => {
@@ -335,7 +336,7 @@ export class NodesChannelsMap implements OnInit {
});
});
}
this.chartInstance.on('click', (e) => {
if (e.data) {
this.zone.run(() => {

View File

@@ -48,6 +48,7 @@ export class NodesMap implements OnInit, OnChanges {
ngOnInit(): void {
if (!this.widget) {
this.seoService.setTitle($localize`:@@af8560ca50882114be16c951650f83bca73161a7:Lightning Nodes World Map`);
this.seoService.setDescription($localize`:@@meta.description.lightning.node-channel-map:See the locations of non-Tor Lightning network nodes visualized on a world map. Hover/tap on points on the map for node names and details.`);
}
if (!this.inputNodes$) {

View File

@@ -65,6 +65,7 @@ export class NodesNetworksChartComponent implements OnInit {
this.miningWindowPreference = '3y';
} else {
this.seoService.setTitle($localize`:@@b420668a91f8ebaf6e6409c4ba87f1d45961d2bd:Lightning Nodes Per Network`);
this.seoService.setDescription($localize`:@@meta.description.lightning.nodes-network:See the number of Lightning network nodes visualized over time by network: clearnet only (IPv4, IPv6), darknet (Tor, I2p, cjdns), and both.`);
this.miningWindowPreference = this.miningService.getDefaultTimespan('all');
}
this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference });
@@ -375,7 +376,7 @@ export class NodesNetworksChartComponent implements OnInit {
// We create dummy duplicated series so when we use the data zoom, the y axis
// both scales properly
const invisibleSerie = {...serie};
invisibleSerie.name = 'ignored' + Math.random().toString();
invisibleSerie.name = 'ignored' + Math.random().toString();
invisibleSerie.stack = 'ignored';
invisibleSerie.yAxisIndex = 1;
invisibleSerie.lineStyle = {

View File

@@ -44,7 +44,7 @@ export class NodesPerCountryChartComponent implements OnInit {
ngOnInit(): void {
this.seoService.setTitle($localize`:@@9d3ad4c6623870d96b65fb7a708fed6ce7c20044:Lightning Nodes Per Country`);
this.seoService.setDescription($localize`:@@meta.description.lightning.nodes-country-overview:See a geographical breakdown of the Lightning network: how many Lightning nodes are hosted in countries around the world, aggregate BTC capacity for each country, and more.`);
this.nodesPerCountryObservable$ = this.apiService.getNodesPerCountry$()
.pipe(
map(data => {

View File

@@ -33,6 +33,7 @@ export class NodesPerCountry implements OnInit {
.pipe(
map(response => {
this.seoService.setTitle($localize`Lightning nodes in ${response.country.en}`);
this.seoService.setDescription($localize`:@@meta.description.lightning.nodes-country:Explore all the Lightning nodes hosted in ${response.country.en} and see an overview of each node's capacity, number of open channels, and more.`);
this.country = {
name: response.country.en,
@@ -47,7 +48,7 @@ export class NodesPerCountry implements OnInit {
iso: response.nodes[i].iso_code,
};
}
const sumLiquidity = response.nodes.reduce((partialSum, a) => partialSum + a.capacity, 0);
const sumChannels = response.nodes.reduce((partialSum, a) => partialSum + a.channels, 0);
const isps = {};
@@ -70,14 +71,14 @@ export class NodesPerCountry implements OnInit {
isps[node.isp].asns.push(node.as_number);
}
isps[node.isp].count++;
if (isps[node.isp].count > topIsp.count) {
topIsp.count = isps[node.isp].count;
topIsp.id = isps[node.isp].asns.join(',');
topIsp.name = node.isp;
}
}
return {
nodes: response.nodes,
sumLiquidity: sumLiquidity,

View File

@@ -42,6 +42,7 @@ export class NodesPerISPPreview implements OnInit {
id: this.route.snapshot.params.isp.split(',').join(', ')
};
this.seoService.setTitle($localize`Lightning nodes on ISP: ${response.isp} [AS${this.route.snapshot.params.isp}]`);
this.seoService.setDescription($localize`:@@meta.description.lightning.nodes-isp:Browse all Bitcoin Lightning nodes using the ${response.isp} [AS${this.route.snapshot.params.isp}] ISP and see aggregate stats like total number of nodes, total capacity, and more for the ISP.`);
for (const i in response.nodes) {
response.nodes[i].geolocation = <GeolocationData>{

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