Compare commits

..

238 Commits

Author SHA1 Message Date
Stephan Oeste
8bde2d519a save mainnet mempool every hour to disk 2024-08-14 20:11:08 +02:00
softsimon
db10ab9aae pull from transifex 2024-08-13 10:28:42 +02:00
wiz
2ee7b9531a Merge pull request #5454 from mempool/simon/add-croatian
Add Croatian language
2024-08-13 13:36:00 +09:00
wiz
5f6af83944 Merge pull request #5453 from mempool/mononaut/acceleration-sparkles
acceleration sparkles
2024-08-13 13:33:25 +09:00
softsimon
8d2204a53f Merge pull request #5457 from mempool/mononaut/flow-output-indices
flow diagram zero-indexed inputs & outputs
2024-08-12 23:04:34 +02:00
Mononaut
96bec279a9 flow diagram zero-indexed inputs & outputs 2024-08-12 14:54:51 +00:00
softsimon
5178ae43f6 Add Croatian language 2024-08-12 00:07:48 +02:00
softsimon
ca26154426 pull from transifex 2024-08-11 23:51:16 +02:00
Mononaut
021f0b32a1 sparklier sparkles 2024-08-11 20:52:26 +00:00
Mononaut
b8cfeb579b make accelerations magical again 2024-08-11 20:38:54 +00:00
softsimon
fc5b99f93f Merge pull request #5452 from mempool/mononaut/tx-v1-audit
Implement v1 audit in tx audit API
2024-08-11 00:18:09 +02:00
Mononaut
ce4b0ed0f3 Implement v1 audit in tx audit API 2024-08-10 21:57:31 +00:00
softsimon
b1a43abc0e Merge pull request #5444 from mempool/natsoni/fix-pool-pie-position
Fix pool pie position on safari
2024-08-08 22:20:09 +02:00
softsimon
3e50a3c9e7 pull from transifex 2024-08-08 18:58:59 +02:00
natsoni
132d6204c3 Fix pool pie position on safari 2024-08-08 11:23:56 +02:00
wiz
77c6ad5576 Merge pull request #5438 from mempool/natsoni/hide-fee-delta-on-confirmed
Hide fee delta on accelerated tx if bid boost is 0
2024-08-07 19:38:04 -04:00
wiz
4d35845c18 Merge pull request #5441 from mempool/simon/remove-testnet4-beta
remove testnet4 beta tag
2024-08-07 19:36:55 -04:00
wiz
3d8a4a85f7 Merge pull request #5433 from mempool/simon/remove-mempool-goggles-beta
remove mempool googles [beta] tag
2024-08-07 19:36:45 -04:00
wiz
1545347a45 Merge pull request #5443 from mempool/simon/fix-broken-sponsor-image-proxy
fix broken sponsor image proxy
2024-08-07 18:11:59 -04:00
softsimon
9facf28ba5 pull from transifex 2024-08-07 23:57:11 +02:00
softsimon
d6eb98561b Merge pull request #5439 from mempool/natsoni/purple-acc-fee-rate
Consistent purple accelerated fee rate
2024-08-07 23:42:37 +02:00
softsimon
0f688e8347 fix broken sponsor image proxy 2024-08-07 23:41:53 +02:00
wiz
7f53741a7b Merge pull request #5442 from mempool/simon/timesincepaid-sorry-time-increase
increasing time since paid sorry message
2024-08-07 17:19:11 -04:00
softsimon
d5672691e1 increasing time since paid sorry message 2024-08-07 22:06:22 +02:00
softsimon
e6049c707b remove testnet4 beta tag 2024-08-07 22:03:47 +02:00
natsoni
91e74e769c Purple accelerated fee rate everywhere 2024-08-07 14:19:59 +02:00
natsoni
7f252f06b7 Hide fee delta on accelerated tx with bidBoost=0 2024-08-07 11:11:31 +02:00
softsimon
1b9d3f669d remove mempool googles [beta] tag 2024-08-07 00:30:20 +02:00
wiz
ef0ba9a77a ops: Add mempool-update-repo script 2024-08-06 18:30:11 -04:00
wiz
15f10736e2 Merge pull request #5432 from mempool/simon/match-any-onion
match any onion
2024-08-06 18:26:31 -04:00
softsimon
924399df46 match any onion 2024-08-07 00:24:23 +02:00
wiz
cddff129b3 Merge pull request #5431 from mempool/simon/fix-services-onion
fix services onion url
2024-08-06 17:29:15 -04:00
softsimon
7c90e8ae06 fix services onion url 2024-08-06 23:27:59 +02:00
wiz
34d996c7cb Merge pull request #5430 from mempool/natsoni/dynamically-show-oob-fee
Immediately show oob fee on accelerated transaction
2024-08-06 15:00:49 -04:00
wiz
641a2ae3ae Remove testnet4 not-yet-finalized warning now that BIP is merged 2024-08-05 15:37:29 -04:00
natsoni
11a849ef28 Immediately show oob fee on accelerated transaction 2024-08-05 21:01:33 +02:00
wiz
7b0347e846 Bump version string to 3.0.0-beta 2024-08-05 14:55:59 -04:00
wiz
bb1352ed58 Merge pull request #5427 from mempool/mononaut/fix-other-liquid-migration
fix db migration 75 on liquid
2024-08-05 14:09:36 -04:00
wiz
fa6456b92c Merge pull request #5422 from mempool/natsoni/purple-accelerated-rate
Purple accelerated fee rate
2024-08-05 14:09:14 -04:00
wiz
bfea19238b Merge pull request #5420 from mempool/natsoni/add-tooltip-acc-fee
Add tooltip to acceleration fee
2024-08-05 14:04:42 -04:00
wiz
36b91cfdfd Merge pull request #5421 from mempool/mononaut/rbf-tracker-redirect
Fix broken pizza rbf link to /tracker
2024-08-05 14:04:20 -04:00
wiz
7b56212064 Merge pull request #5425 from mempool/natsoni/clear-mining-cache-network-change
Clear mining service cache on network change
2024-08-05 14:03:14 -04:00
wiz
d4be3c2c4c Merge pull request #5428 from mempool/mononaut/payment-method-click
[accelerator] fix click binding on payment method buttons
2024-08-05 14:02:51 -04:00
wiz
3a9f06f651 Merge pull request #5429 from mempool/mononaut/fix-block-unfurl-loading
Fix stray loading spinner in block unfurl
2024-08-05 14:02:34 -04:00
Mononaut
e652eb339d Fix stray loading spinner in block unfurl 2024-08-05 17:08:44 +00:00
softsimon
96435c329f Merge pull request #5426 from mempool/mononaut/fix-liquid-migration
fix liquid db migration
2024-08-05 18:47:26 +02:00
Mononaut
1c69613d65 [accelerator] fix click binding on payment method buttons 2024-08-05 16:15:53 +00:00
Mononaut
3707763e30 fix db migration 75 on liquid 2024-08-05 16:03:22 +00:00
Mononaut
b6ce8229f0 fix liquid db migration 2024-08-05 15:49:16 +00:00
natsoni
b62ae9b6f6 Clear mining service cache on network change 2024-08-05 15:45:39 +02:00
Mononaut
f61ace2f92 Fix broken pizza rbf link to /tracker 2024-08-05 10:31:01 +00:00
natsoni
2b572f2494 Purple accelerated fee rate 2024-08-05 12:20:49 +02:00
natsoni
c0e4c1efe1 Fix text wrap 2024-08-05 11:53:01 +02:00
natsoni
8078caaa89 Add tooltip to acceleration fee 2024-08-05 11:36:35 +02:00
wiz
5eb117165f Revert "ops: Remove potentially dangerous env var in rust build process"
This reverts commit a2dcf0d545.
2024-08-04 21:19:44 -04:00
wiz
a2dcf0d545 ops: Remove potentially dangerous env var in rust build process 2024-08-04 21:06:36 -04:00
wiz
212d58f917 Change accelerate checkout redirect from /tracker/ to /tx/ 2024-08-04 20:52:49 -04:00
wiz
d1eec80afb Delete redirect to /tracker/ for cash.app 2024-08-04 20:45:05 -04:00
wiz
05c6709926 Merge pull request #5417 from mempool/simon/hide-fiat-buttons-correctly
hide fiat buttons correctly
2024-08-04 19:45:07 -04:00
softsimon
f1a48db9ee hide fiat buttons correctly 2024-08-05 01:43:58 +02:00
wiz
76ce43d289 Merge pull request #5416 from mempool/simon/enable-acc-button-enterprise
enable accelerator button on enterprise instances
2024-08-04 19:22:33 -04:00
softsimon
51f5b728f3 enable accelerator button on enterprise instances 2024-08-05 01:20:40 +02:00
wiz
8fa1863aff Merge pull request #5415 from mempool/simon/fix-invalid-json-response-requestAcceleration
fix invalid json response from requestAcceleration
2024-08-04 19:12:06 -04:00
softsimon
e3d1d9c1c0 fix invalid json response from requestAcceleration 2024-08-05 01:11:18 +02:00
wiz
f2f8d91e10 Merge pull request #5410 from mempool/natsoni/show-oob-fee
Show oob fees on tx details
2024-08-04 19:02:20 -04:00
wiz
11ef090846 Merge pull request #5414 from mempool/simon/only-enable-fiat-prod-staging
only enable fiat on prod and staging
2024-08-04 19:01:54 -04:00
softsimon
5cacd2635e only enable fiat on prod, staging and dev 2024-08-05 01:00:58 +02:00
softsimon
1b4780c25b Merge pull request #5409 from mempool/natsoni/fix-truncated-link
Fix truncated link to not refresh full window
2024-08-05 00:16:15 +02:00
softsimon
5ea44f2e7d Merge pull request #5408 from mempool/natsoni/pizza-tracker-fix-crash
Pizza tracker: handle transaction not yet in mempool
2024-08-04 23:39:39 +02:00
softsimon
261c794817 pull from transifex 2024-08-04 23:36:03 +02:00
natsoni
de9fae5cd7 Show oob fees on tx details 2024-08-04 17:07:38 +02:00
natsoni
439c52af30 Fix truncated link to not refresh full window 2024-08-04 14:36:42 +02:00
softsimon
4dbcd6ca18 pull from transifex 2024-08-04 10:18:18 +02:00
softsimon
2921c94520 Merge pull request #5354 from mempool/mononaut/v1-audits
v1 audits
2024-08-03 21:55:19 +02:00
softsimon
ab4a258be3 Merge pull request #5406 from mempool/simon/fix-mobile-rbf-test
fix mobile rbf test
2024-08-03 19:07:49 +02:00
softsimon
21b0d50947 fix mobile rbf test 2024-08-03 18:58:55 +02:00
softsimon
233ec112c2 Merge pull request #5405 from TechMiX/fix/more-accel-rtl-issues
fix: acceleration rtl layout issues
2024-08-03 18:24:22 +02:00
TechMiX
6f0a5c9b44 fix: accelerationn rtl layout issues 2024-08-03 14:39:50 +02:00
softsimon
2bc243b115 accel list i18n fixes 2024-08-03 00:26:37 +02:00
softsimon
154f8e65a7 accel status i18n fix 2024-08-03 00:20:38 +02:00
wiz
cc9855aa65 Merge pull request #5404 from mempool/nymkappa/square-loading
[square] use mirror to serve square.js and load it sooner
2024-08-02 17:52:44 -04:00
nymkappa
7b45d922bc [square] i'm an idiot 2024-08-02 23:35:30 +02:00
nymkappa
9488ca50a3 [square] use mirror to serve square.js and load it sooner 2024-08-02 23:27:40 +02:00
wiz
9f559248cc Merge pull request #5403 from mempool/nymkappa/square-loading
[square] retry web sdk faster
2024-08-02 17:24:00 -04:00
nymkappa
067aac4d06 [square] retry web sdk faster 2024-08-02 23:04:15 +02:00
wiz
37eb17cc22 Merge pull request #5402 from mempool/nymkappa/square-loading
[square] fix web sdk retry logic
2024-08-02 16:50:41 -04:00
nymkappa
c7382a1c6c [square] fix web sdk retry logic 2024-08-02 22:48:04 +02:00
softsimon
f782693b26 Merge pull request #5401 from mempool/mononaut/acc-pie-colors
adjust pie colors to handle more pools
2024-08-02 21:40:36 +02:00
wiz
908988d06e Merge pull request #5398 from mempool/natsoni/fix-accelerator-loader
Fix accelerator skeleton loader
2024-08-02 14:41:29 -04:00
wiz
a0c21e4120 Merge pull request #5395 from mempool/nymkappa/accel-status
[accelerator] fix accel status in widget
2024-08-02 14:25:54 -04:00
wiz
bd96cbd701 Merge pull request #5399 from mempool/natsoni/fix-mobile-tx-routing
Remove query param redirect on tx page
2024-08-02 14:23:54 -04:00
wiz
9cccdcf70f Merge pull request #5400 from mempool/natsoni/pizza-tracker-loader
Align pizza tracker skeleton loader
2024-08-02 14:23:21 -04:00
natsoni
2489fc4902 Fix need to double click on previous to go back 2024-08-02 19:00:52 +02:00
natsoni
e378df4158 Fix pizza tracker crash on unseen transaction 2024-08-02 18:52:07 +02:00
Mononaut
fa5f758875 adjust pie colors to handle more pools 2024-08-02 16:44:36 +00:00
natsoni
a46f15e7ec Align pizza tracker skeleton loader 2024-08-02 17:37:53 +02:00
natsoni
46ecd2a51f Fix accelerator skeleton loader 2024-08-02 16:53:34 +02:00
softsimon
c1aaf2f61a pull from transifex 2024-08-02 14:04:49 +02:00
Mononaut
d2b57c8d4f added/prioritized dual audit status 2024-08-02 10:46:07 +00:00
softsimon
f27a5700fa pull from transifex 2024-08-02 02:43:34 +02:00
nymkappa
17522cca6e [accelerator] 2024-08-02 00:24:50 +02:00
nymkappa
3f2581886d [accelerator] fix accel status in widget 2024-08-02 00:20:34 +02:00
wiz
c5beee3a40 ops: Fix nginx routing for /api/v1/accelerations 2024-08-01 17:04:00 -04:00
wiz
94e223e8da Merge pull request #5010 from mempool/mononaut/tracker-tx-routing
Transparently redirect direct mobile tx visits to pizza tracker
2024-08-01 14:30:56 -04:00
wiz
416e1bb4cb Merge pull request #5393 from mempool/mononaut/accelerate-anchor
[accelerator] add anchor element
2024-08-01 14:19:22 -04:00
wiz
564dc08509 Merge pull request #5392 from mempool/orangesurf/privacy-policy
Update PP
2024-08-01 14:19:01 -04:00
Mononaut
b9334c93f5 Handle accelerations in v1 audit 2024-08-01 14:07:19 +00:00
Mononaut
67761230e3 frontend support for v1 block audits 2024-08-01 14:07:19 +00:00
Mononaut
7cc01af631 Implement v1 block audits 2024-08-01 14:07:19 +00:00
Mononaut
96e2e6060b Migrate audits from v0 to v1 2024-08-01 14:07:19 +00:00
Mononaut
0723778e7c Update audit schema version 2024-08-01 14:07:19 +00:00
orangesurf
39d7e4ac3e Merge branch 'master' into orangesurf/privacy-policy 2024-08-01 13:44:34 +02:00
softsimon
1869368a49 pull from transifex 2024-08-01 10:50:01 +02:00
orangesurf
b83f19f186 Merge branch 'master' into orangesurf/privacy-policy 2024-07-31 23:50:58 +02:00
Mononaut
b248aad6d9 [accelerator] add anchor element 2024-07-31 17:08:39 +00:00
softsimon
87c9f920c1 Merge pull request #5391 from mempool/natsoni/add-opacity-pools-logo
Set pool logos opacity to 0.3
2024-07-31 18:34:09 +02:00
orangesurf
ea0e339d60 Update PP 2024-07-31 16:02:26 +02:00
orangesurf
0a0b5e52fe Update PP 2024-07-31 13:55:21 +02:00
natsoni
5314f35974 Make logos 0.3 opacity 2024-07-31 12:22:49 +02:00
softsimon
87d2f6cf90 Merge pull request #5389 from TechMiX/fix/acc-rtl-issues
fix: acceleration rtl issues
2024-07-31 11:16:29 +02:00
softsimon
4266bdcbb6 pull from transifex 2024-07-31 09:29:55 +02:00
softsimon
73e68534f1 Merge pull request #5390 from mempool/dependabot/npm_and_yarn/backend/babel/core-7.25.2
Bump @babel/core from 7.24.0 to 7.25.2 in /backend
2024-07-31 02:56:37 -04:00
dependabot[bot]
ee5b383a46 Bump @babel/core from 7.24.0 to 7.25.2 in /backend
Bumps [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) from 7.24.0 to 7.25.2.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.25.2/packages/babel-core)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-31 02:27:25 +00:00
TechMiX
fa4b445dca fix acc rtl issues 2024-07-31 01:28:31 +02:00
softsimon
06eaadd517 pull from transifex 2024-07-30 16:17:21 -04:00
softsimon
ebdc1dbf6d Merge pull request #5385 from mempool/mononaut/fix-ffee-detection
Fix effective fee rate detection on tx page
2024-07-30 16:13:21 -04:00
softsimon
184903670a Merge pull request #5386 from mempool/natsoni/fix-timeline-tooltip
Fix missing accelerated fee rate in timeline tooltip
2024-07-30 14:49:06 -05:00
softsimon
878561244a Merge pull request #5387 from mempool/natsoni/fix-timeline-centering
Fix accelerated time centering
2024-07-30 14:48:19 -05:00
softsimon
a0e9b33199 pull from i18n 2024-07-30 15:46:29 -04:00
natsoni
0a4513c8fb Fix 'Just now' capitalization in timeline of mined transactions 2024-07-30 17:40:58 +02:00
softsimon
77b9277da5 pull from i18n 2024-07-30 10:38:05 -05:00
natsoni
009fac183f Fix accelerated time centering 2024-07-30 17:31:34 +02:00
natsoni
83a6df4d04 Fix missing accelerated fee rate line 2024-07-30 17:22:57 +02:00
Mononaut
474d384b0c fix wildcard routing clash 2024-07-30 14:48:47 +00:00
Mononaut
5870782abf Fix effective fee rate detection on tx page 2024-07-30 11:49:52 +00:00
softsimon
fb335f62db pull from i18n 2024-07-29 22:16:15 -05:00
softsimon
346ef0028d Merge pull request #5384 from mempool/dependabot/npm_and_yarn/backend/redis-4.7.0
Bump redis from 4.6.6 to 4.7.0 in /backend
2024-07-29 21:21:43 -05:00
dependabot[bot]
916cae2a8f Bump redis from 4.6.6 to 4.7.0 in /backend
Bumps [redis](https://github.com/redis/node-redis) from 4.6.6 to 4.7.0.
- [Release notes](https://github.com/redis/node-redis/releases)
- [Changelog](https://github.com/redis/node-redis/blob/master/CHANGELOG.md)
- [Commits](https://github.com/redis/node-redis/compare/redis@4.6.6...redis@4.7.0)

---
updated-dependencies:
- dependency-name: redis
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-30 02:12:44 +00:00
softsimon
550e667a2f Merge pull request #5383 from mempool/nymkappa/accel-history-list-update
[accelerator] use "mined" when accelerated tx is mined by non participating pool
2024-07-29 11:33:34 -05:00
softsimon
eb4ebf6786 pull from transifex 2024-07-29 11:09:12 -05:00
softsimon
7515348997 Merge pull request #5381 from TechMiX/hotfix/rtl-layout
fix: various rtl issues
2024-07-29 10:39:59 -05:00
nymkappa
41441bb958 [accelerator] use "mined" when accelerated tx is mined by non participating pool 2024-07-29 11:11:29 +02:00
softsimon
98c49918f7 Merge pull request #5382 from mempool/dependabot/npm_and_yarn/backend/mysql2-3.11.0
Bump mysql2 from 3.10.0 to 3.11.0 in /backend
2024-07-28 21:14:52 -05:00
dependabot[bot]
5855e09663 Bump mysql2 from 3.10.0 to 3.11.0 in /backend
Bumps [mysql2](https://github.com/sidorares/node-mysql2) from 3.10.0 to 3.11.0.
- [Release notes](https://github.com/sidorares/node-mysql2/releases)
- [Changelog](https://github.com/sidorares/node-mysql2/blob/master/Changelog.md)
- [Commits](https://github.com/sidorares/node-mysql2/compare/v3.10.0...v3.11.0)

---
updated-dependencies:
- dependency-name: mysql2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-29 02:07:00 +00:00
softsimon
558b876d47 pull from transifex 2024-07-28 19:48:15 -05:00
TechMiX
fb63af5070 fix various rtl issues 2024-07-29 02:17:09 +02:00
wiz
d02a2ccf65 Merge pull request #5380 from mempool/simon/1w-1m
fixes crash with missing pool partner
2024-07-28 14:40:50 -05:00
softsimon
e3bb812203 fixes crash with missing pool partner
fixes #5379
2024-07-28 14:10:50 -05:00
softsimon
e3af4ea61c pull from transifex 2024-07-28 13:20:58 -05:00
softsimon
7d10b32861 pull from transifex 2024-07-26 16:43:17 -05:00
wiz
adea897e93 Merge branch 'master' into mononaut/tracker-tx-routing 2024-07-26 11:29:31 -05:00
wiz
17ec50dba0 Merge pull request #5371 from mempool/natsoni/timeline-tooltip
Add tooltip to acceleration timeline
2024-07-26 11:12:15 -05:00
Mononaut
53a36d042f Fix /tx redirect merge conflicts 2024-07-26 16:11:59 +00:00
softsimon
7531a53b2e correct i18n 2024-07-26 11:06:36 -05:00
Mononaut
d59bc085e5 Redirect direct mobile tx visits to pizza tracker 2024-07-26 15:54:22 +00:00
wiz
5d37e08c64 Merge branch 'master' into natsoni/timeline-tooltip 2024-07-26 10:44:01 -05:00
wiz
b5d89b83fa Merge pull request #5368 from mempool/nymkappa/google-pay
[accelerator] add support for Google Pay payment
2024-07-26 10:43:50 -05:00
nymkappa
1245673575 [accelerator] polish UI 2024-07-26 17:39:58 +02:00
softsimon
62a72755c7 pull from transifex 2024-07-26 10:25:29 -05:00
wiz
5b8ec8925a Merge pull request #5373 from mempool/mononaut/no-405
[accelerator] remove dumb log request 405 response
2024-07-26 10:22:38 -05:00
softsimon
262866c15b Merge pull request #5369 from mempool/nymkappa/getjwtemptynonofficial
[services] getJWT call returns nothing if non official
2024-07-26 06:36:21 -05:00
softsimon
aec7eb57c2 Merge pull request #5370 from mempool/natsoni/fix-loading-spinner
Fix loading spinner z-index
2024-07-26 05:10:25 -05:00
Mononaut
8d3b4733f5 [accelerator] remove dumb 405 log request response 2024-07-26 09:49:42 +00:00
nymkappa
3e4debdf7a Merge branch 'master' into nymkappa/google-pay 2024-07-26 11:38:47 +02:00
nymkappa
b719b76999 Merge pull request #5372 from mempool/mononaut/google-play-minor-fixes
minor accelerator checkout fixes
2024-07-26 11:37:54 +02:00
Mononaut
6adbda5185 Fix accelerator checkout linting & type errors 2024-07-26 09:27:18 +00:00
natsoni
01311d0ba1 Add tooltip to timeline 2024-07-26 11:21:24 +02:00
Mononaut
6081daacef [accelerator] add missing getters etc 2024-07-26 09:06:47 +00:00
natsoni
54c9970386 Merge branch 'master' into natsoni/timeline-tooltip 2024-07-26 11:04:17 +02:00
softsimon
1e4a599055 fix accelerations row height 2024-07-25 21:05:32 -05:00
softsimon
b051861d61 Merge pull request #5365 from mempool/natsoni/fix-mining-data
Get all pools in accelerations list
2024-07-25 21:03:52 -05:00
natsoni
3c7deafffd Fix loading spinner z-index 2024-07-26 00:00:14 +02:00
nymkappa
845123fc63 [services] getJWT call returns nothing if non official 2024-07-25 23:04:53 +02:00
nymkappa
d3e3650cac [accelerator] avoid premature square setup call 2024-07-25 21:49:48 +02:00
softsimon
008cc385da pull from transifex 2024-07-25 13:01:37 -05:00
softsimon
817a6bef6e Merge pull request #5367 from mempool/knorrium/change_default_var
Set the default value to the SERVICES_API variable
2024-07-25 12:59:40 -05:00
Felipe Knorr Kuhn
8dd8fe5fb1 Fix string value 2024-07-25 12:57:36 -05:00
softsimon
c734a81f08 pull from transifex 2024-07-25 12:27:32 -05:00
Felipe Knorr Kuhn
a0f4c260ab Set the default URL for the backend 2024-07-25 12:20:01 -05:00
Felipe Knorr Kuhn
000a989055 Set the default value to the SERVICES_API variable 2024-07-25 11:29:17 -05:00
natsoni
90331e2c1b Get all pools in accelerations list 2024-07-25 17:45:08 +02:00
wiz
78ac0137b3 Merge pull request #5350 from mempool/orangesurf/2024-07-19
Update webserver line
2024-07-25 10:21:53 -05:00
natsoni
3d9133c47e Add fee delta to acceleration data 2024-07-25 16:52:10 +02:00
nymkappa
481859bc8f [accelerator] add support for Google Pay payment 2024-07-25 15:54:24 +02:00
orangesurf
811feec145 Merge branch 'master' into orangesurf/2024-07-19 2024-07-25 18:47:27 +09:00
softsimon
b3e59c06e9 add new accelerator button config to sample 2024-07-25 04:07:18 -05:00
softsimon
67d44e3d6f Merge pull request #5359 from mempool/natsoni/fix-loop-calling-transactionTimes
Prevent never ending loop of calls to transactionTimes
2024-07-25 03:38:06 -05:00
softsimon
ca4b1943a8 moving code block 2024-07-25 03:35:17 -05:00
natsoni
df0f244bd1 Prevent never ending loop of calls to transactionTimes 2024-07-25 00:53:13 +02:00
softsimon
fe1ad86885 Merge pull request #5362 from mempool/natsoni/more-data-acc-table
Add fee delta and pool name to acceleration list
2024-07-24 17:42:28 -05:00
natsoni
aee2454a98 Add pool name to acceleration list 2024-07-25 00:24:08 +02:00
softsimon
5c814d9c22 pending balance -> pending 2024-07-24 17:21:55 -05:00
wiz
2c5d7fbc9f Merge pull request #5363 from mempool/nymkappa/apple-pay-hotfix
[accelerator] hide fiat payment method section if none available
2024-07-24 17:11:01 -05:00
nymkappa
570f7841ce [accelerator] hide fiat payment method section if none available 2024-07-25 00:10:27 +02:00
wiz
117c066425 Merge pull request #5353 from mempool/nymkappa/apple-pay
[accelerator] add support for acceleration with apple pay
2024-07-24 17:01:23 -05:00
softsimon
96f9f66e7f changing unconfirmed to pending balance/utxo 2024-07-24 16:56:30 -05:00
wiz
ce2742ff9c Merge branch 'master' into nymkappa/apple-pay 2024-07-24 16:47:57 -05:00
softsimon
4547a2757c Merge pull request #5351 from mempool/natsoni/fix-recursion-search
Fix recursion loop in search bar
2024-07-24 15:42:51 -05:00
nymkappa
4d44ee55fc [accelerator] add missing getters for applepay 2024-07-24 22:20:52 +02:00
nymkappa
29875e0095 Merge branch 'master' into nymkappa/apple-pay 2024-07-24 22:04:44 +02:00
softsimon
bc498733fc Merge pull request #5355 from mempool/natsoni/fix-blockchain-scroll
Fix unwanted blockchain scroll on screen resize
2024-07-24 11:44:36 -07:00
wiz
dc09e75783 Merge pull request #5361 from mempool/mononaut/fosscelerator
On-demand acceleration polling
2024-07-24 13:44:04 -05:00
wiz
544261eafe ops: Add /api/v1/accelerations to nginx hot cache 2024-07-24 13:31:56 -05:00
softsimon
58c0c060d5 Merge pull request #5358 from mempool/natsoni/fix-miner-loading
Fix miner loading forever
2024-07-24 11:25:48 -07:00
Mononaut
af7a962a0b [accelerator] accelerator_button config 2024-07-24 17:32:44 +00:00
Mononaut
7b3cc6372b [accelerator] frontend on-demand polling support 2024-07-24 17:32:44 +00:00
Mononaut
b49a6c4cac [accelerator] on-demand polling support 2024-07-24 17:32:43 +00:00
wiz
b0db348605 Merge pull request #5357 from mempool/nymkappa/update-api-key-header
[doc] update api key header
2024-07-24 12:27:03 -05:00
wiz
b1aa4f50bd Change X-Mempool-Authorization to X-Mempool-Auth 2024-07-24 12:25:35 -05:00
orangesurf
301f1821ae Merge branch 'master' into orangesurf/2024-07-19 2024-07-23 20:48:03 +09:00
natsoni
b9a053387f Add txConfirmed subscription variable to fix miner loading forever 2024-07-23 11:44:50 +02:00
softsimon
82c271267a extracting i18n's 2024-07-23 00:28:32 +08:00
softsimon
06affa60cc Merge pull request #5352 from mempool/simon/replace-crypto-uuid-func
Fix crypto lib call crash with custom function
2024-07-22 22:38:05 +08:00
nymkappa
4ef4e5b98a [doc] update api key header 2024-07-22 15:17:56 +02:00
wiz
6bc52dcc82 Merge pull request #5356 from mempool/simon/fix-payment-waiting-retry
fix btc payment waiting reply
2024-07-22 21:16:00 +09:00
softsimon
1f357408ac fix btc payment waiting reply 2024-07-22 20:13:13 +08:00
natsoni
a7be59df3e Fix buggy blockchain scroll on resize 2024-07-22 13:36:36 +02:00
orangesurf
82360f5525 Merge branch 'master' into orangesurf/2024-07-19 2024-07-22 17:08:47 +09:00
nymkappa
8762ccaa09 [accelerator] remove attempt to align fiat payment methods 2024-07-21 23:22:11 +02:00
nymkappa
3f7a24fb52 [accelerator] only show apple pay if available 2024-07-21 23:08:08 +02:00
nymkappa
09b09710e4 [accelerator] fix cashapp acceleration on mobile 2024-07-21 23:07:55 +02:00
nymkappa
08d3beed72 [accelerator] on mobile, autoscroll after selection cashapp or applepay 2024-07-21 22:38:49 +02:00
nymkappa
920f225e6c [accelerator] add support for acceleration with apple pay 2024-07-21 22:17:47 +02:00
softsimon
9c2d010516 rename to insecureRandomUUID 2024-07-21 23:55:28 +08:00
softsimon
743c7e8bfb Fix crypto lib call crash with custom function 2024-07-21 22:54:58 +08:00
softsimon
8116b50d50 fix spelling error 2024-07-21 20:34:20 +08:00
softsimon
df977926e2 Merge pull request #5349 from mempool/simon/add-fee-to-cpfp
Add fee to Cpfp API
2024-07-21 19:05:55 +08:00
softsimon
b0fac806d0 Merge pull request #4905 from mempool/mononaut/mini-miner-cpfp
Mini miner cpfp
2024-07-21 19:05:35 +08:00
Mononaut
398593828f Implement CPFP reindexing using mini-miner method (not activated) 2024-07-20 12:08:52 +00:00
Mononaut
9aac0ddce7 Fix merge conflict 2024-07-20 12:08:52 +00:00
Mononaut
79eb9635c2 Fix cpfp vsize rounding & goggles bugs 2024-07-20 12:08:52 +00:00
Mononaut
41c373c39d Mini-miner based block cpfp calculations 2024-07-20 12:08:52 +00:00
Mononaut
27374bd131 Refactor cpfp & single-block gbt code into mini-miner module 2024-07-20 12:08:52 +00:00
natsoni
8c07e3c31a Fix recursion loop in search bar 2024-07-19 16:12:54 +02:00
orangesurf
84ba721407 Update webserver line 2024-07-19 11:48:47 +02:00
softsimon
cdaf42797f Add fee to Cpfp API 2024-07-18 16:44:00 +08:00
wiz
0a116804e8 Merge pull request #5347 from mempool/junderw/fix-docker-aarch64
Bump Rust version to 1.79
2024-07-18 15:08:22 +09:00
junderw
68edf4306c Bump Rust version to 1.79
Maintaining an old MSRV is not a priority for this project.
If you would like to keep an old MSRV active, please maintain your own patch/fork.
2024-07-18 01:07:05 +09:00
171 changed files with 18575 additions and 10791 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "mempool-backend",
"version": "3.0.0-dev",
"version": "3.0.0-beta",
"description": "Bitcoin mempool visualizer and blockchain explorer backend",
"license": "GNU Affero General Public License v3.0",
"homepage": "https://mempool.space",
@@ -39,7 +39,7 @@
"prettier": "./node_modules/.bin/prettier --write \"src/**/*.{js,ts}\""
},
"dependencies": {
"@babel/core": "^7.24.0",
"@babel/core": "^7.25.2",
"@mempool/electrum-client": "1.1.9",
"@types/node": "^18.15.3",
"axios": "~1.7.2",
@@ -47,16 +47,16 @@
"crypto-js": "~4.2.0",
"express": "~4.19.2",
"maxmind": "~4.3.11",
"mysql2": "~3.10.0",
"mysql2": "~3.11.0",
"rust-gbt": "file:./rust-gbt",
"redis": "^4.6.6",
"redis": "^4.7.0",
"socks-proxy-agent": "~7.0.0",
"typescript": "~4.9.3",
"ws": "~8.18.0"
},
"devDependencies": {
"@babel/code-frame": "^7.18.6",
"@babel/core": "^7.24.0",
"@babel/core": "^7.25.2",
"@types/compression": "^1.7.2",
"@types/crypto-js": "^4.1.1",
"@types/express": "^4.17.17",

View File

@@ -70,7 +70,7 @@ class AboutRoutes {
res.status(500).end();
}
})
.get(config.MEMPOOL.API_URL_PREFIX + 'services/account/images/:username', async (req, res) => {
.get(config.MEMPOOL.API_URL_PREFIX + 'services/account/images/:username/:md5', async (req, res) => {
const url = `${config.MEMPOOL_SERVICES.API}/${req.originalUrl.replace('/api/v1/services/', '')}`;
try {
const response = await axios.get(url, { responseType: 'stream', timeout: 10000 });

View File

@@ -1,15 +1,14 @@
import logger from '../../logger';
import { MempoolTransactionExtended } from '../../mempool.interfaces';
import { IEsploraApi } from '../bitcoin/esplora-api.interface';
import { GraphTx, getSameBlockRelatives, initializeRelatives, makeBlockTemplate, mempoolComparator, removeAncestors, setAncestorScores } from '../mini-miner';
const BLOCK_WEIGHT_UNITS = 4_000_000;
const BLOCK_SIGOPS = 80_000;
const MAX_RELATIVE_GRAPH_SIZE = 200;
const BID_BOOST_WINDOW = 40_000;
const BID_BOOST_MIN_OFFSET = 10_000;
const BID_BOOST_MAX_OFFSET = 400_000;
type Acceleration = {
export type Acceleration = {
txid: string;
max_bid: number;
};
@@ -28,31 +27,6 @@ export interface AccelerationInfo {
cost: number; // additional cost to accelerate ((cost + txSummary.effectiveFee) / txSummary.effectiveVsize) >= targetFeeRate
}
interface GraphTx {
txid: string;
vsize: number;
weight: number;
fees: {
base: number; // in sats
};
depends: string[];
spentby: string[];
}
interface MempoolTx extends GraphTx {
ancestorcount: number;
ancestorsize: number;
fees: { // in sats
base: number;
ancestor: number;
};
ancestors: Map<string, MempoolTx>,
ancestorRate: number;
individualRate: number;
score: number;
}
class AccelerationCosts {
/**
* Takes a list of accelerations and verbose block data
@@ -61,7 +35,7 @@ class AccelerationCosts {
* @param accelerationsx
* @param verboseBlock
*/
public calculateBoostRate(accelerations: Acceleration[], blockTxs: IEsploraApi.Transaction[]): number {
public calculateBoostRate(accelerations: Acceleration[], blockTxs: MempoolTransactionExtended[]): number {
// Run GBT ourselves to calculate accurate effective fee rates
// the list of transactions comes from a mined block, so we already know everything fits within consensus limits
const template = makeBlockTemplate(blockTxs, accelerations, 1, Infinity, Infinity);
@@ -170,108 +144,28 @@ class AccelerationCosts {
/**
* Takes an accelerated mined txid and a target rate
* Returns the total vsize, fees and acceleration cost (in sats) of the tx and all same-block ancestors
*
* @param txid
* @param medianFeeRate
*
* @param txid
* @param medianFeeRate
*/
public getAccelerationInfo(tx: MempoolTransactionExtended, targetFeeRate: number, transactions: MempoolTransactionExtended[]): AccelerationInfo {
// Get same-block transaction ancestors
const allRelatives = this.getSameBlockRelatives(tx, transactions);
const relativesMap = this.initializeRelatives(allRelatives);
const rootTx = relativesMap.get(tx.txid) as MempoolTx;
const allRelatives = getSameBlockRelatives(tx, transactions);
const relativesMap = initializeRelatives(allRelatives);
const rootTx = relativesMap.get(tx.txid) as GraphTx;
// Calculate cost to boost
return this.calculateAccelerationAncestors(rootTx, relativesMap, targetFeeRate);
}
/**
* Takes a raw transaction, and builds a graph of same-block relatives,
* and returns as a MempoolTx
*
* @param tx
*/
private getSameBlockRelatives(tx: MempoolTransactionExtended, transactions: MempoolTransactionExtended[]): Map<string, GraphTx> {
const blockTxs = new Map<string, MempoolTransactionExtended>(); // map of txs in this block
const spendMap = new Map<string, string>(); // map of outpoints to spending txids
for (const tx of transactions) {
blockTxs.set(tx.txid, tx);
for (const vin of tx.vin) {
spendMap.set(`${vin.txid}:${vin.vout}`, tx.txid);
}
}
const relatives: Map<string, GraphTx> = new Map();
const stack: string[] = [tx.txid];
// build set of same-block ancestors
while (stack.length > 0) {
const nextTxid = stack.pop();
const nextTx = nextTxid ? blockTxs.get(nextTxid) : null;
if (!nextTx || relatives.has(nextTx.txid)) {
continue;
}
const mempoolTx = this.convertToGraphTx(nextTx);
mempoolTx.fees.base = nextTx.fee || 0;
mempoolTx.depends = nextTx.vin.map(vin => vin.txid).filter(inTxid => inTxid && blockTxs.has(inTxid)) as string[];
mempoolTx.spentby = nextTx.vout.map((vout, index) => spendMap.get(`${nextTx.txid}:${index}`)).filter(outTxid => outTxid && blockTxs.has(outTxid)) as string[];
for (const txid of [...mempoolTx.depends, ...mempoolTx.spentby]) {
if (txid) {
stack.push(txid);
}
}
relatives.set(mempoolTx.txid, mempoolTx);
}
return relatives;
}
/**
* Takes a raw transaction and converts it to MempoolTx format
* fee and ancestor data is initialized with dummy/null values
*
* @param tx
*/
private convertToGraphTx(tx: MempoolTransactionExtended): GraphTx {
return {
txid: tx.txid,
vsize: Math.ceil(tx.weight / 4),
weight: tx.weight,
fees: {
base: 0, // dummy
},
depends: [], // dummy
spentby: [], //dummy
};
}
private convertGraphToMempoolTx(tx: GraphTx): MempoolTx {
return {
...tx,
fees: {
base: tx.fees.base,
ancestor: tx.fees.base,
},
ancestorcount: 1,
ancestorsize: Math.ceil(tx.weight / 4),
ancestors: new Map<string, MempoolTx>(),
ancestorRate: 0,
individualRate: 0,
score: 0,
};
}
/**
* Given a root transaction, a list of in-mempool ancestors, and a target fee rate,
* Calculate the minimum set of transactions to fee-bump, their total vsize + fees
*
*
* @param tx
* @param ancestors
*/
private calculateAccelerationAncestors(tx: MempoolTx, relatives: Map<string, MempoolTx>, targetFeeRate: number): AccelerationInfo {
private calculateAccelerationAncestors(tx: GraphTx, relatives: Map<string, GraphTx>, targetFeeRate: number): AccelerationInfo {
// add root tx to the ancestor map
relatives.set(tx.txid, tx);
@@ -283,12 +177,12 @@ class AccelerationCosts {
});
// Initialize individual & ancestor fee rates
relatives.forEach(entry => this.setAncestorScores(entry));
relatives.forEach(entry => setAncestorScores(entry));
// Sort by descending ancestor score
let sortedRelatives = Array.from(relatives.values()).sort(this.mempoolComparator);
let sortedRelatives = Array.from(relatives.values()).sort(mempoolComparator);
let includedInCluster: Map<string, MempoolTx> | null = null;
let includedInCluster: Map<string, GraphTx> | null = null;
// While highest score >= targetFeeRate
let maxIterations = MAX_RELATIVE_GRAPH_SIZE;
@@ -297,17 +191,17 @@ class AccelerationCosts {
// Grab the highest scoring entry
const best = sortedRelatives.shift();
if (best) {
const cluster = new Map<string, MempoolTx>(best.ancestors?.entries() || []);
const cluster = new Map<string, GraphTx>(best.ancestors?.entries() || []);
if (best.ancestors.has(tx.txid)) {
includedInCluster = cluster;
}
cluster.set(best.txid, best);
// Remove this cluster (it already pays over the target rate, so doesn't need to be boosted)
// and update scores, ancestor totals and dependencies for the survivors
this.removeAncestors(cluster, relatives);
removeAncestors(cluster, relatives);
// re-sort
sortedRelatives = Array.from(relatives.values()).sort(this.mempoolComparator);
sortedRelatives = Array.from(relatives.values()).sort(mempoolComparator);
}
}
@@ -345,394 +239,6 @@ class AccelerationCosts {
nextBlockFee: Math.ceil(tx.ancestorsize * targetFeeRate),
};
}
/**
* Recursively traverses an in-mempool dependency graph, and sets a Map of in-mempool ancestors
* for each transaction.
*
* @param tx
* @param all
*/
private setAncestors(tx: MempoolTx, all: Map<string, MempoolTx>, visited: Map<string, Map<string, MempoolTx>>, depth: number = 0): Map<string, MempoolTx> {
// sanity check for infinite recursion / too many ancestors (should never happen)
if (depth >= 100) {
logger.warn('acceleration dependency calculation failed: setAncestors reached depth of 100, unable to proceed', `Accelerator`);
throw new Error('invalid_tx_dependencies');
}
// initialize the ancestor map for this tx
tx.ancestors = new Map<string, MempoolTx>();
tx.depends.forEach(parentId => {
const parent = all.get(parentId);
if (parent) {
// add the parent
tx.ancestors?.set(parentId, parent);
// check for a cached copy of this parent's ancestors
let ancestors = visited.get(parent.txid);
if (!ancestors) {
// recursively fetch the parent's ancestors
ancestors = this.setAncestors(parent, all, visited, depth + 1);
}
// and add to this tx's map
ancestors.forEach((ancestor, ancestorId) => {
tx.ancestors?.set(ancestorId, ancestor);
});
}
});
visited.set(tx.txid, tx.ancestors);
return tx.ancestors;
}
/**
* Efficiently sets a Map of in-mempool ancestors for each member of an expanded relative graph
* by running setAncestors on each leaf, and caching intermediate results.
* then initializes ancestor data for each transaction
*
* @param all
*/
private initializeRelatives(all: Map<string, GraphTx>): Map<string, MempoolTx> {
const mempoolTxs = new Map<string, MempoolTx>();
all.forEach(entry => {
mempoolTxs.set(entry.txid, this.convertGraphToMempoolTx(entry));
});
const visited: Map<string, Map<string, MempoolTx>> = new Map();
const leaves: MempoolTx[] = Array.from(mempoolTxs.values()).filter(entry => entry.spentby.length === 0);
for (const leaf of leaves) {
this.setAncestors(leaf, mempoolTxs, visited);
}
mempoolTxs.forEach(entry => {
entry.ancestors?.forEach(ancestor => {
entry.ancestorcount++;
entry.ancestorsize += ancestor.vsize;
entry.fees.ancestor += ancestor.fees.base;
});
this.setAncestorScores(entry);
});
return mempoolTxs;
}
/**
* Remove a cluster of transactions from an in-mempool dependency graph
* and update the survivors' scores and ancestors
*
* @param cluster
* @param ancestors
*/
private removeAncestors(cluster: Map<string, MempoolTx>, all: Map<string, MempoolTx>): void {
// remove
cluster.forEach(tx => {
all.delete(tx.txid);
});
// update survivors
all.forEach(tx => {
cluster.forEach(remove => {
if (tx.ancestors?.has(remove.txid)) {
// remove as dependency
tx.ancestors.delete(remove.txid);
tx.depends = tx.depends.filter(parent => parent !== remove.txid);
// update ancestor sizes and fees
tx.ancestorsize -= remove.vsize;
tx.fees.ancestor -= remove.fees.base;
}
});
// recalculate fee rates
this.setAncestorScores(tx);
});
}
/**
* Take a mempool transaction, and set the fee rates and ancestor score
*
* @param tx
*/
private setAncestorScores(tx: MempoolTx): void {
tx.individualRate = tx.fees.base / tx.vsize;
tx.ancestorRate = tx.fees.ancestor / tx.ancestorsize;
tx.score = Math.min(tx.individualRate, tx.ancestorRate);
}
// Sort by descending score
private mempoolComparator(a, b): number {
return b.score - a.score;
}
}
export default new AccelerationCosts;
interface TemplateTransaction {
txid: string;
order: number;
weight: number;
adjustedVsize: number; // sigop-adjusted vsize, rounded up to the nearest integer
sigops: number;
fee: number;
feeDelta: number;
ancestors: string[];
cluster: string[];
effectiveFeePerVsize: number;
}
interface MinerTransaction extends TemplateTransaction {
inputs: string[];
feePerVsize: number;
relativesSet: boolean;
ancestorMap: Map<string, MinerTransaction>;
children: Set<MinerTransaction>;
ancestorFee: number;
ancestorVsize: number;
ancestorSigops: number;
score: number;
used: boolean;
modified: boolean;
dependencyRate: number;
}
/*
* Build a block using an approximation of the transaction selection algorithm from Bitcoin Core
* (see BlockAssembler in https://github.com/bitcoin/bitcoin/blob/master/src/node/miner.cpp)
*/
export function makeBlockTemplate(candidates: IEsploraApi.Transaction[], accelerations: Acceleration[], maxBlocks: number = 8, weightLimit: number = BLOCK_WEIGHT_UNITS, sigopLimit: number = BLOCK_SIGOPS): TemplateTransaction[] {
const auditPool: Map<string, MinerTransaction> = new Map();
const mempoolArray: MinerTransaction[] = [];
candidates.forEach(tx => {
// initializing everything up front helps V8 optimize property access later
const adjustedVsize = Math.ceil(Math.max(tx.weight / 4, 5 * (tx.sigops || 0)));
const feePerVsize = (tx.fee / adjustedVsize);
auditPool.set(tx.txid, {
txid: tx.txid,
order: txidToOrdering(tx.txid),
fee: tx.fee,
feeDelta: 0,
weight: tx.weight,
adjustedVsize,
feePerVsize: feePerVsize,
effectiveFeePerVsize: feePerVsize,
dependencyRate: feePerVsize,
sigops: tx.sigops || 0,
inputs: (tx.vin?.map(vin => vin.txid) || []) as string[],
relativesSet: false,
ancestors: [],
cluster: [],
ancestorMap: new Map<string, MinerTransaction>(),
children: new Set<MinerTransaction>(),
ancestorFee: 0,
ancestorVsize: 0,
ancestorSigops: 0,
score: 0,
used: false,
modified: false,
});
mempoolArray.push(auditPool.get(tx.txid) as MinerTransaction);
});
// set accelerated effective fee
for (const acceleration of accelerations) {
const tx = auditPool.get(acceleration.txid);
if (tx) {
tx.feeDelta = acceleration.max_bid;
tx.feePerVsize = ((tx.fee + tx.feeDelta) / tx.adjustedVsize);
tx.effectiveFeePerVsize = tx.feePerVsize;
tx.dependencyRate = tx.feePerVsize;
}
}
// Build relatives graph & calculate ancestor scores
for (const tx of mempoolArray) {
if (!tx.relativesSet) {
setRelatives(tx, auditPool);
}
}
// Sort by descending ancestor score
mempoolArray.sort(priorityComparator);
// Build blocks by greedily choosing the highest feerate package
// (i.e. the package rooted in the transaction with the best ancestor score)
const blocks: number[][] = [];
let blockWeight = 0;
let blockSigops = 0;
const transactions: MinerTransaction[] = [];
let modified: MinerTransaction[] = [];
const overflow: MinerTransaction[] = [];
let failures = 0;
while (mempoolArray.length || modified.length) {
// skip invalid transactions
while (mempoolArray[0].used || mempoolArray[0].modified) {
mempoolArray.shift();
}
// Select best next package
let nextTx;
const nextPoolTx = mempoolArray[0];
const nextModifiedTx = modified[0];
if (nextPoolTx && (!nextModifiedTx || (nextPoolTx.score || 0) > (nextModifiedTx.score || 0))) {
nextTx = nextPoolTx;
mempoolArray.shift();
} else {
modified.shift();
if (nextModifiedTx) {
nextTx = nextModifiedTx;
}
}
if (nextTx && !nextTx?.used) {
// Check if the package fits into this block
if (blocks.length >= (maxBlocks - 1) || ((blockWeight + (4 * nextTx.ancestorVsize) < weightLimit) && (blockSigops + nextTx.ancestorSigops <= sigopLimit))) {
const ancestors: MinerTransaction[] = Array.from(nextTx.ancestorMap.values());
// sort ancestors by dependency graph (equivalent to sorting by ascending ancestor count)
const sortedTxSet = [...ancestors.sort((a, b) => { return (a.ancestorMap.size || 0) - (b.ancestorMap.size || 0); }), nextTx];
const clusterTxids = sortedTxSet.map(tx => tx.txid);
const effectiveFeeRate = Math.min(nextTx.dependencyRate || Infinity, nextTx.ancestorFee / nextTx.ancestorVsize);
const used: MinerTransaction[] = [];
while (sortedTxSet.length) {
const ancestor = sortedTxSet.pop();
if (!ancestor) {
continue;
}
ancestor.used = true;
ancestor.usedBy = nextTx.txid;
// update this tx with effective fee rate & relatives data
if (ancestor.effectiveFeePerVsize !== effectiveFeeRate) {
ancestor.effectiveFeePerVsize = effectiveFeeRate;
}
ancestor.cluster = clusterTxids;
transactions.push(ancestor);
blockWeight += ancestor.weight;
blockSigops += ancestor.sigops;
used.push(ancestor);
}
// remove these as valid package ancestors for any descendants remaining in the mempool
if (used.length) {
used.forEach(tx => {
modified = updateDescendants(tx, auditPool, modified, effectiveFeeRate);
});
}
failures = 0;
} else {
// hold this package in an overflow list while we check for smaller options
overflow.push(nextTx);
failures++;
}
}
// this block is full
const exceededPackageTries = failures > 1000 && blockWeight > (weightLimit - 4000);
const queueEmpty = !mempoolArray.length && !modified.length;
if (exceededPackageTries || queueEmpty) {
break;
}
}
for (const tx of transactions) {
tx.ancestors = Object.values(tx.ancestorMap);
}
return transactions;
}
// traverse in-mempool ancestors
// recursion unavoidable, but should be limited to depth < 25 by mempool policy
function setRelatives(
tx: MinerTransaction,
mempool: Map<string, MinerTransaction>,
): void {
for (const parent of tx.inputs) {
const parentTx = mempool.get(parent);
if (parentTx && !tx.ancestorMap?.has(parent)) {
tx.ancestorMap.set(parent, parentTx);
parentTx.children.add(tx);
// visit each node only once
if (!parentTx.relativesSet) {
setRelatives(parentTx, mempool);
}
parentTx.ancestorMap.forEach((ancestor) => {
tx.ancestorMap.set(ancestor.txid, ancestor);
});
}
};
tx.ancestorFee = (tx.fee + tx.feeDelta);
tx.ancestorVsize = tx.adjustedVsize || 0;
tx.ancestorSigops = tx.sigops || 0;
tx.ancestorMap.forEach((ancestor) => {
tx.ancestorFee += (ancestor.fee + ancestor.feeDelta);
tx.ancestorVsize += ancestor.adjustedVsize;
tx.ancestorSigops += ancestor.sigops;
});
tx.score = tx.ancestorFee / tx.ancestorVsize;
tx.relativesSet = true;
}
// iterate over remaining descendants, removing the root as a valid ancestor & updating the ancestor score
// avoids recursion to limit call stack depth
function updateDescendants(
rootTx: MinerTransaction,
mempool: Map<string, MinerTransaction>,
modified: MinerTransaction[],
clusterRate: number,
): MinerTransaction[] {
const descendantSet: Set<MinerTransaction> = new Set();
// stack of nodes left to visit
const descendants: MinerTransaction[] = [];
let descendantTx: MinerTransaction | undefined;
rootTx.children.forEach(childTx => {
if (!descendantSet.has(childTx)) {
descendants.push(childTx);
descendantSet.add(childTx);
}
});
while (descendants.length) {
descendantTx = descendants.pop();
if (descendantTx && descendantTx.ancestorMap && descendantTx.ancestorMap.has(rootTx.txid)) {
// remove tx as ancestor
descendantTx.ancestorMap.delete(rootTx.txid);
descendantTx.ancestorFee -= (rootTx.fee + rootTx.feeDelta);
descendantTx.ancestorVsize -= rootTx.adjustedVsize;
descendantTx.ancestorSigops -= rootTx.sigops;
descendantTx.score = descendantTx.ancestorFee / descendantTx.ancestorVsize;
descendantTx.dependencyRate = descendantTx.dependencyRate ? Math.min(descendantTx.dependencyRate, clusterRate) : clusterRate;
if (!descendantTx.modified) {
descendantTx.modified = true;
modified.push(descendantTx);
}
// add this node's children to the stack
descendantTx.children.forEach(childTx => {
// visit each node only once
if (!descendantSet.has(childTx)) {
descendants.push(childTx);
descendantSet.add(childTx);
}
});
}
}
// return new, resorted modified list
return modified.sort(priorityComparator);
}
// Used to sort an array of MinerTransactions by descending ancestor score
function priorityComparator(a: MinerTransaction, b: MinerTransaction): number {
if (b.score === a.score) {
// tie-break by txid for stability
return a.order - b.order;
} else {
return b.score - a.score;
}
}
// returns the most significant 4 bytes of the txid as an integer
function txidToOrdering(txid: string): number {
return parseInt(
txid.substring(62, 64) +
txid.substring(60, 62) +
txid.substring(58, 60) +
txid.substring(56, 58),
16
);
}
export default new AccelerationCosts;

View File

@@ -6,20 +6,22 @@ import rbfCache from './rbf-cache';
const PROPAGATION_MARGIN = 180; // in seconds, time since a transaction is first seen after which it is assumed to have propagated to all miners
class Audit {
auditBlock(transactions: MempoolTransactionExtended[], projectedBlocks: MempoolBlockWithTransactions[], mempool: { [txId: string]: MempoolTransactionExtended }, useAccelerations: boolean = false)
: { censored: string[], added: string[], prioritized: string[], fresh: string[], sigop: string[], fullrbf: string[], accelerated: string[], score: number, similarity: number } {
auditBlock(height: number, transactions: MempoolTransactionExtended[], projectedBlocks: MempoolBlockWithTransactions[], mempool: { [txId: string]: MempoolTransactionExtended })
: { unseen: string[], censored: string[], added: string[], prioritized: string[], fresh: string[], sigop: string[], fullrbf: string[], accelerated: string[], score: number, similarity: number } {
if (!projectedBlocks?.[0]?.transactionIds || !mempool) {
return { censored: [], added: [], prioritized: [], fresh: [], sigop: [], fullrbf: [], accelerated: [], score: 1, similarity: 1 };
return { unseen: [], censored: [], added: [], prioritized: [], fresh: [], sigop: [], fullrbf: [], accelerated: [], score: 1, similarity: 1 };
}
const matches: string[] = []; // present in both mined block and template
const added: string[] = []; // present in mined block, not in template
const prioritized: string[] = [] // present in the mined block, not in the template, but further down in the mempool
const unseen: string[] = []; // present in the mined block, not in our mempool
const prioritized: string[] = []; // higher in the block than would be expected by in-band feerate alone
const fresh: string[] = []; // missing, but firstSeen or lastBoosted within PROPAGATION_MARGIN
const rbf: string[] = []; // either missing or present, and either part of a full-rbf replacement, or a conflict with the mined block
const accelerated: string[] = []; // prioritized by the mempool accelerator
const isCensored = {}; // missing, without excuse
const isDisplaced = {};
const isAccelerated = {};
let displacedWeight = 0;
let matchedWeight = 0;
let projectedWeight = 0;
@@ -32,6 +34,7 @@ class Audit {
inBlock[tx.txid] = tx;
if (mempool[tx.txid] && mempool[tx.txid].acceleration) {
accelerated.push(tx.txid);
isAccelerated[tx.txid] = true;
}
}
// coinbase is always expected
@@ -113,11 +116,16 @@ class Audit {
} else {
if (rbfCache.has(tx.txid)) {
rbf.push(tx.txid);
} else if (!isDisplaced[tx.txid]) {
if (!mempool[tx.txid] && !rbfCache.getReplacedBy(tx.txid)) {
unseen.push(tx.txid);
}
} else {
if (mempool[tx.txid]) {
prioritized.push(tx.txid);
if (isDisplaced[tx.txid]) {
added.push(tx.txid);
}
} else {
added.push(tx.txid);
unseen.push(tx.txid);
}
}
overflowWeight += tx.weight;
@@ -125,6 +133,24 @@ class Audit {
totalWeight += tx.weight;
}
// identify "prioritized" transactions
let lastEffectiveRate = 0;
// Iterate over the mined template from bottom to top (excluding the coinbase)
// Transactions should appear in ascending order of mining priority.
for (let i = transactions.length - 1; i > 0; i--) {
const blockTx = transactions[i];
// If a tx has a lower in-band effective fee rate than the previous tx,
// it must have been prioritized out-of-band (in order to have a higher mining priority)
// so exclude from the analysis.
if ((blockTx.effectiveFeePerVsize || 0) < lastEffectiveRate) {
prioritized.push(blockTx.txid);
// accelerated txs may or may not have their prioritized fee rate applied, so don't use them as a reference
} else if (!isAccelerated[blockTx.txid]) {
lastEffectiveRate = blockTx.effectiveFeePerVsize || 0;
}
}
// transactions missing from near the end of our template are probably not being censored
let overflowWeightRemaining = overflowWeight - (config.MEMPOOL.BLOCK_WEIGHT_UNITS - totalWeight);
let maxOverflowRate = 0;
@@ -165,6 +191,7 @@ class Audit {
const similarity = projectedWeight ? matchedWeight / projectedWeight : 1;
return {
unseen,
censored: Object.keys(isCensored),
added,
prioritized,

View File

@@ -19,7 +19,7 @@ import bitcoinClient from './bitcoin-client';
import difficultyAdjustment from '../difficulty-adjustment';
import transactionRepository from '../../repositories/TransactionRepository';
import rbfCache from '../rbf-cache';
import { calculateCpfp } from '../cpfp';
import { calculateMempoolTxCpfp } from '../cpfp';
class BitcoinRoutes {
public initRoutes(app: Application) {
@@ -160,15 +160,17 @@ class BitcoinRoutes {
descendants: tx.descendants || null,
effectiveFeePerVsize: tx.effectiveFeePerVsize || null,
sigops: tx.sigops,
fee: tx.fee,
adjustedVsize: tx.adjustedVsize,
acceleration: tx.acceleration,
acceleratedBy: tx.acceleratedBy || undefined,
acceleratedAt: tx.acceleratedAt || undefined,
feeDelta: tx.feeDelta || undefined,
});
return;
}
const cpfpInfo = calculateCpfp(tx, mempool.getMempool());
const cpfpInfo = calculateMempoolTxCpfp(tx, mempool.getMempool());
res.json(cpfpInfo);
return;

View File

@@ -30,6 +30,10 @@ import redisCache from './redis-cache';
import rbfCache from './rbf-cache';
import { calcBitsDifference } from './difficulty-adjustment';
import AccelerationRepository from '../repositories/AccelerationRepository';
import { calculateFastBlockCpfp, calculateGoodBlockCpfp } from './cpfp';
import mempool from './mempool';
import CpfpRepository from '../repositories/CpfpRepository';
import accelerationApi from './services/acceleration';
class Blocks {
private blocks: BlockExtended[] = [];
@@ -436,7 +440,7 @@ class Blocks {
if (config.MEMPOOL.BACKEND === 'esplora') {
const txs = (await bitcoinApi.$getTxsForBlock(block.hash)).map(tx => transactionUtils.extendTransaction(tx));
const txs = (await bitcoinApi.$getTxsForBlock(block.hash)).map(tx => transactionUtils.extendMempoolTransaction(tx));
const cpfpSummary = await this.$indexCPFP(block.hash, block.height, txs);
if (cpfpSummary) {
await this.$getStrippedBlockTransactions(block.hash, true, true, cpfpSummary, block.height); // This will index the block summary
@@ -567,8 +571,11 @@ class Blocks {
const blockchainInfo = await bitcoinClient.getBlockchainInfo();
const currentBlockHeight = blockchainInfo.blocks;
const unclassifiedBlocksList = await BlocksSummariesRepository.$getSummariesWithVersion(0);
const unclassifiedTemplatesList = await BlocksSummariesRepository.$getTemplatesWithVersion(0);
const targetSummaryVersion: number = 1;
const targetTemplateVersion: number = 1;
const unclassifiedBlocksList = await BlocksSummariesRepository.$getSummariesBelowVersion(targetSummaryVersion);
const unclassifiedTemplatesList = await BlocksSummariesRepository.$getTemplatesBelowVersion(targetTemplateVersion);
// nothing to do
if (!unclassifiedBlocksList?.length && !unclassifiedTemplatesList?.length) {
@@ -601,16 +608,24 @@ class Blocks {
for (let height = currentBlockHeight; height >= 0; height--) {
try {
let txs: TransactionExtended[] | null = null;
let txs: MempoolTransactionExtended[] | null = null;
if (unclassifiedBlocks[height]) {
const blockHash = unclassifiedBlocks[height];
// fetch transactions
txs = (await bitcoinApi.$getTxsForBlock(blockHash)).map(tx => transactionUtils.extendTransaction(tx)) || [];
txs = (await bitcoinApi.$getTxsForBlock(blockHash)).map(tx => transactionUtils.extendMempoolTransaction(tx)) || [];
// add CPFP
const cpfpSummary = Common.calculateCpfp(height, txs, true);
const cpfpSummary = calculateGoodBlockCpfp(height, txs, []);
// classify
const { transactions: classifiedTxs } = this.summarizeBlockTransactions(blockHash, cpfpSummary.transactions);
await BlocksSummariesRepository.$saveTransactions(height, blockHash, classifiedTxs, 1);
await BlocksSummariesRepository.$saveTransactions(height, blockHash, classifiedTxs, 2);
if (unclassifiedBlocks[height].version < 2 && targetSummaryVersion === 2) {
const cpfpClusters = await CpfpRepository.$getClustersAt(height);
if (!cpfpRepository.compareClusters(cpfpClusters, cpfpSummary.clusters)) {
// CPFP clusters changed - update the compact_cpfp tables
await CpfpRepository.$deleteClustersAt(height);
await this.$saveCpfp(blockHash, height, cpfpSummary);
}
}
await Common.sleep$(250);
}
if (unclassifiedTemplates[height]) {
@@ -636,7 +651,7 @@ class Blocks {
}
templateTxs.push(tx || templateTx);
}
const cpfpSummary = Common.calculateCpfp(height, templateTxs?.filter(tx => tx['effectiveFeePerVsize'] != null) as TransactionExtended[], true);
const cpfpSummary = calculateGoodBlockCpfp(height, templateTxs?.filter(tx => tx['effectiveFeePerVsize'] != null) as MempoolTransactionExtended[], []);
// classify
const { transactions: classifiedTxs } = this.summarizeBlockTransactions(blockHash, cpfpSummary.transactions);
const classifiedTxMap: { [txid: string]: TransactionClassified } = {};
@@ -890,7 +905,12 @@ class Blocks {
}
}
const cpfpSummary: CpfpSummary = Common.calculateCpfp(block.height, transactions);
let accelerations = Object.values(mempool.getAccelerations());
if (accelerations?.length > 0) {
const pool = await this.$findBlockMiner(transactionUtils.stripCoinbaseTransaction(transactions[0]));
accelerations = accelerations.filter(a => a.pools.includes(pool.uniqueId));
}
const cpfpSummary: CpfpSummary = calculateGoodBlockCpfp(block.height, transactions, accelerations.map(a => ({ txid: a.txid, max_bid: a.feeDelta })));
const blockExtended: BlockExtended = await this.$getBlockExtended(block, cpfpSummary.transactions);
const blockSummary: BlockSummary = this.summarizeBlockTransactions(block.id, cpfpSummary.transactions);
this.updateTimerProgress(timer, `got block data for ${this.currentBlockHeight}`);
@@ -913,12 +933,12 @@ class Blocks {
const newBlock = await this.$indexBlock(lastBlock.height - i);
this.blocks.push(newBlock);
this.updateTimerProgress(timer, `reindexed block`);
let cpfpSummary;
let newCpfpSummary;
if (config.MEMPOOL.CPFP_INDEXING) {
cpfpSummary = await this.$indexCPFP(newBlock.id, lastBlock.height - i);
newCpfpSummary = await this.$indexCPFP(newBlock.id, lastBlock.height - i);
this.updateTimerProgress(timer, `reindexed block cpfp`);
}
await this.$getStrippedBlockTransactions(newBlock.id, true, true, cpfpSummary, newBlock.height);
await this.$getStrippedBlockTransactions(newBlock.id, true, true, newCpfpSummary, newBlock.height);
this.updateTimerProgress(timer, `reindexed block summary`);
}
await mining.$indexDifficultyAdjustments();
@@ -967,7 +987,7 @@ class Blocks {
// start async callbacks
this.updateTimerProgress(timer, `starting async callbacks for ${this.currentBlockHeight}`);
const callbackPromises = this.newAsyncBlockCallbacks.map((cb) => cb(blockExtended, txIds, transactions));
const callbackPromises = this.newAsyncBlockCallbacks.map((cb) => cb(blockExtended, txIds, cpfpSummary.transactions));
if (block.height % 2016 === 0) {
if (Common.indexingEnabled()) {
@@ -1149,7 +1169,7 @@ class Blocks {
transactions: cpfpSummary.transactions.map(tx => {
let flags: number = 0;
try {
flags = tx.flags || Common.getTransactionFlags(tx);
flags = Common.getTransactionFlags(tx);
} catch (e) {
logger.warn('Failed to classify transaction: ' + (e instanceof Error ? e.message : e));
}
@@ -1164,7 +1184,7 @@ class Blocks {
};
}),
};
summaryVersion = 1;
summaryVersion = cpfpSummary.version;
} else {
if (config.MEMPOOL.BACKEND === 'esplora') {
const txs = (await bitcoinApi.$getTxsForBlock(hash)).map(tx => transactionUtils.extendTransaction(tx));
@@ -1383,11 +1403,11 @@ class Blocks {
return this.currentBlockHeight;
}
public async $indexCPFP(hash: string, height: number, txs?: TransactionExtended[]): Promise<CpfpSummary | null> {
public async $indexCPFP(hash: string, height: number, txs?: MempoolTransactionExtended[]): Promise<CpfpSummary | null> {
let transactions = txs;
if (!transactions) {
if (config.MEMPOOL.BACKEND === 'esplora') {
transactions = (await bitcoinApi.$getTxsForBlock(hash)).map(tx => transactionUtils.extendTransaction(tx));
transactions = (await bitcoinApi.$getTxsForBlock(hash)).map(tx => transactionUtils.extendMempoolTransaction(tx));
}
if (!transactions) {
const block = await bitcoinClient.getBlock(hash, 2);
@@ -1399,7 +1419,7 @@ class Blocks {
}
if (transactions?.length != null) {
const summary = Common.calculateCpfp(height, transactions as TransactionExtended[]);
const summary = calculateFastBlockCpfp(height, transactions);
await this.$saveCpfp(hash, height, summary);

View File

@@ -1,6 +1,6 @@
import * as bitcoinjs from 'bitcoinjs-lib';
import { Request } from 'express';
import { CpfpInfo, CpfpSummary, CpfpCluster, EffectiveFeeStats, MempoolBlockWithTransactions, TransactionExtended, MempoolTransactionExtended, TransactionStripped, WorkingEffectiveFeeStats, TransactionClassified, TransactionFlags } from '../mempool.interfaces';
import { EffectiveFeeStats, MempoolBlockWithTransactions, TransactionExtended, MempoolTransactionExtended, TransactionStripped, WorkingEffectiveFeeStats, TransactionClassified, TransactionFlags } from '../mempool.interfaces';
import config from '../config';
import { NodeSocket } from '../repositories/NodesSocketsRepository';
import { isIP } from 'net';
@@ -419,12 +419,15 @@ export class Common {
let flags = tx.flags ? BigInt(tx.flags) : 0n;
// Update variable flags (CPFP, RBF)
flags &= ~TransactionFlags.cpfp_child;
if (tx.ancestors?.length) {
flags |= TransactionFlags.cpfp_child;
}
flags &= ~TransactionFlags.cpfp_parent;
if (tx.descendants?.length) {
flags |= TransactionFlags.cpfp_parent;
}
flags &= ~TransactionFlags.replacement;
if (tx.replacement) {
flags |= TransactionFlags.replacement;
}
@@ -806,96 +809,6 @@ export class Common {
}
}
static calculateCpfp(height: number, transactions: TransactionExtended[], saveRelatives: boolean = false): CpfpSummary {
const clusters: CpfpCluster[] = []; // list of all cpfp clusters in this block
const clusterMap: { [txid: string]: CpfpCluster } = {}; // map transactions to their cpfp cluster
let clusterTxs: TransactionExtended[] = []; // working list of elements of the current cluster
let ancestors: { [txid: string]: boolean } = {}; // working set of ancestors of the current cluster root
const txMap: { [txid: string]: TransactionExtended } = {};
// initialize the txMap
for (const tx of transactions) {
txMap[tx.txid] = tx;
}
// reverse pass to identify CPFP clusters
for (let i = transactions.length - 1; i >= 0; i--) {
const tx = transactions[i];
if (!ancestors[tx.txid]) {
let totalFee = 0;
let totalVSize = 0;
clusterTxs.forEach(tx => {
totalFee += tx?.fee || 0;
totalVSize += (tx.weight / 4);
});
const effectiveFeePerVsize = totalFee / totalVSize;
let cluster: CpfpCluster;
if (clusterTxs.length > 1) {
cluster = {
root: clusterTxs[0].txid,
height,
txs: clusterTxs.map(tx => { return { txid: tx.txid, weight: tx.weight, fee: tx.fee || 0 }; }),
effectiveFeePerVsize,
};
clusters.push(cluster);
}
clusterTxs.forEach(tx => {
txMap[tx.txid].effectiveFeePerVsize = effectiveFeePerVsize;
if (cluster) {
clusterMap[tx.txid] = cluster;
}
});
// reset working vars
clusterTxs = [];
ancestors = {};
}
clusterTxs.push(tx);
tx.vin.forEach(vin => {
ancestors[vin.txid] = true;
});
}
// forward pass to enforce ancestor rate caps
for (const tx of transactions) {
let minAncestorRate = tx.effectiveFeePerVsize;
for (const vin of tx.vin) {
if (txMap[vin.txid]?.effectiveFeePerVsize) {
minAncestorRate = Math.min(minAncestorRate, txMap[vin.txid].effectiveFeePerVsize);
}
}
// check rounded values to skip cases with almost identical fees
const roundedMinAncestorRate = Math.ceil(minAncestorRate);
const roundedEffectiveFeeRate = Math.floor(tx.effectiveFeePerVsize);
if (roundedMinAncestorRate < roundedEffectiveFeeRate) {
tx.effectiveFeePerVsize = minAncestorRate;
if (!clusterMap[tx.txid]) {
// add a single-tx cluster to record the dependent rate
const cluster = {
root: tx.txid,
height,
txs: [{ txid: tx.txid, weight: tx.weight, fee: tx.fee || 0 }],
effectiveFeePerVsize: minAncestorRate,
};
clusterMap[tx.txid] = cluster;
clusters.push(cluster);
} else {
// update the existing cluster with the dependent rate
clusterMap[tx.txid].effectiveFeePerVsize = minAncestorRate;
}
}
}
if (saveRelatives) {
for (const cluster of clusters) {
cluster.txs.forEach((member, index) => {
txMap[member.txid].descendants = cluster.txs.slice(0, index).reverse();
txMap[member.txid].ancestors = cluster.txs.slice(index + 1).reverse();
txMap[member.txid].effectiveFeePerVsize = cluster.effectiveFeePerVsize;
});
}
}
return {
transactions,
clusters,
};
}
static calcEffectiveFeeStatistics(transactions: { weight: number, fee: number, effectiveFeePerVsize?: number, txid: string, acceleration?: boolean }[]): EffectiveFeeStats {
const sortedTxs = transactions.map(tx => { return { txid: tx.txid, weight: tx.weight, rate: tx.effectiveFeePerVsize || ((tx.fee || 0) / (tx.weight / 4)) }; }).sort((a, b) => a.rate - b.rate);

View File

@@ -1,29 +1,174 @@
import { CpfpInfo, MempoolTransactionExtended } from '../mempool.interfaces';
import { Ancestor, CpfpCluster, CpfpInfo, CpfpSummary, MempoolTransactionExtended, TransactionExtended } from '../mempool.interfaces';
import { GraphTx, convertToGraphTx, expandRelativesGraph, initializeRelatives, makeBlockTemplate, mempoolComparator, removeAncestors, setAncestorScores } from './mini-miner';
import memPool from './mempool';
import { Acceleration } from './acceleration/acceleration';
const CPFP_UPDATE_INTERVAL = 60_000; // update CPFP info at most once per 60s per transaction
const MAX_GRAPH_SIZE = 50; // the maximum number of in-mempool relatives to consider
const MAX_CLUSTER_ITERATIONS = 100;
interface GraphTx extends MempoolTransactionExtended {
depends: string[];
spentby: string[];
ancestorMap: Map<string, GraphTx>;
fees: {
base: number;
ancestor: number;
export function calculateFastBlockCpfp(height: number, transactions: MempoolTransactionExtended[], saveRelatives: boolean = false): CpfpSummary {
const clusters: CpfpCluster[] = []; // list of all cpfp clusters in this block
const clusterMap: { [txid: string]: CpfpCluster } = {}; // map transactions to their cpfp cluster
let clusterTxs: TransactionExtended[] = []; // working list of elements of the current cluster
let ancestors: { [txid: string]: boolean } = {}; // working set of ancestors of the current cluster root
const txMap: { [txid: string]: TransactionExtended } = {};
// initialize the txMap
for (const tx of transactions) {
txMap[tx.txid] = tx;
}
// reverse pass to identify CPFP clusters
for (let i = transactions.length - 1; i >= 0; i--) {
const tx = transactions[i];
if (!ancestors[tx.txid]) {
let totalFee = 0;
let totalVSize = 0;
clusterTxs.forEach(tx => {
totalFee += tx?.fee || 0;
totalVSize += (tx.weight / 4);
});
const effectiveFeePerVsize = totalFee / totalVSize;
let cluster: CpfpCluster;
if (clusterTxs.length > 1) {
cluster = {
root: clusterTxs[0].txid,
height,
txs: clusterTxs.map(tx => { return { txid: tx.txid, weight: tx.weight, fee: tx.fee || 0 }; }),
effectiveFeePerVsize,
};
clusters.push(cluster);
}
clusterTxs.forEach(tx => {
txMap[tx.txid].effectiveFeePerVsize = effectiveFeePerVsize;
if (cluster) {
clusterMap[tx.txid] = cluster;
}
});
// reset working vars
clusterTxs = [];
ancestors = {};
}
clusterTxs.push(tx);
tx.vin.forEach(vin => {
ancestors[vin.txid] = true;
});
}
// forward pass to enforce ancestor rate caps
for (const tx of transactions) {
let minAncestorRate = tx.effectiveFeePerVsize;
for (const vin of tx.vin) {
if (txMap[vin.txid]?.effectiveFeePerVsize) {
minAncestorRate = Math.min(minAncestorRate, txMap[vin.txid].effectiveFeePerVsize);
}
}
// check rounded values to skip cases with almost identical fees
const roundedMinAncestorRate = Math.ceil(minAncestorRate);
const roundedEffectiveFeeRate = Math.floor(tx.effectiveFeePerVsize);
if (roundedMinAncestorRate < roundedEffectiveFeeRate) {
tx.effectiveFeePerVsize = minAncestorRate;
if (!clusterMap[tx.txid]) {
// add a single-tx cluster to record the dependent rate
const cluster = {
root: tx.txid,
height,
txs: [{ txid: tx.txid, weight: tx.weight, fee: tx.fee || 0 }],
effectiveFeePerVsize: minAncestorRate,
};
clusterMap[tx.txid] = cluster;
clusters.push(cluster);
} else {
// update the existing cluster with the dependent rate
clusterMap[tx.txid].effectiveFeePerVsize = minAncestorRate;
}
}
}
if (saveRelatives) {
for (const cluster of clusters) {
cluster.txs.forEach((member, index) => {
txMap[member.txid].descendants = cluster.txs.slice(0, index).reverse();
txMap[member.txid].ancestors = cluster.txs.slice(index + 1).reverse();
txMap[member.txid].effectiveFeePerVsize = cluster.effectiveFeePerVsize;
});
}
}
return {
transactions,
clusters,
version: 1,
};
}
export function calculateGoodBlockCpfp(height: number, transactions: MempoolTransactionExtended[], accelerations: Acceleration[]): CpfpSummary {
const txMap: { [txid: string]: MempoolTransactionExtended } = {};
for (const tx of transactions) {
txMap[tx.txid] = tx;
}
const template = makeBlockTemplate(transactions, accelerations, 1, Infinity, Infinity);
const clusters = new Map<string, string[]>();
for (const tx of template) {
const cluster = tx.cluster || [];
const root = cluster.length ? cluster[cluster.length - 1] : null;
if (cluster.length > 1 && root && !clusters.has(root)) {
clusters.set(root, cluster);
}
txMap[tx.txid].effectiveFeePerVsize = tx.effectiveFeePerVsize;
}
const clusterArray: CpfpCluster[] = [];
for (const cluster of clusters.values()) {
for (const txid of cluster) {
const mempoolTx = txMap[txid];
if (mempoolTx) {
const ancestors: Ancestor[] = [];
const descendants: Ancestor[] = [];
let matched = false;
cluster.forEach(relativeTxid => {
if (relativeTxid === txid) {
matched = true;
} else {
const relative = {
txid: relativeTxid,
fee: txMap[relativeTxid].fee,
weight: (txMap[relativeTxid].adjustedVsize * 4) || txMap[relativeTxid].weight,
};
if (matched) {
descendants.push(relative);
} else {
ancestors.push(relative);
}
}
});
if (mempoolTx.ancestors?.length !== ancestors.length || mempoolTx.descendants?.length !== descendants.length) {
mempoolTx.cpfpDirty = true;
}
Object.assign(mempoolTx, { ancestors, descendants, bestDescendant: null, cpfpChecked: true });
}
}
const root = cluster[cluster.length - 1];
clusterArray.push({
root: root,
height,
txs: cluster.reverse().map(txid => ({
txid,
fee: txMap[txid].fee,
weight: (txMap[txid].adjustedVsize * 4) || txMap[txid].weight,
})),
effectiveFeePerVsize: txMap[root].effectiveFeePerVsize,
});
}
return {
transactions: transactions.map(tx => txMap[tx.txid]),
clusters: clusterArray,
version: 2,
};
ancestorcount: number;
ancestorsize: number;
ancestorRate: number;
individualRate: number;
score: number;
}
/**
* Takes a mempool transaction and a copy of the current mempool, and calculates the CPFP data for
* that transaction (and all others in the same cluster)
*/
export function calculateCpfp(tx: MempoolTransactionExtended, mempool: { [txid: string]: MempoolTransactionExtended }): CpfpInfo {
export function calculateMempoolTxCpfp(tx: MempoolTransactionExtended, mempool: { [txid: string]: MempoolTransactionExtended }): CpfpInfo {
if (tx.cpfpUpdated && Date.now() < (tx.cpfpUpdated + CPFP_UPDATE_INTERVAL)) {
tx.cpfpDirty = false;
return {
@@ -32,30 +177,31 @@ export function calculateCpfp(tx: MempoolTransactionExtended, mempool: { [txid:
descendants: tx.descendants || [],
effectiveFeePerVsize: tx.effectiveFeePerVsize || tx.adjustedFeePerVsize || tx.feePerVsize,
sigops: tx.sigops,
fee: tx.fee,
adjustedVsize: tx.adjustedVsize,
acceleration: tx.acceleration
};
}
const ancestorMap = new Map<string, GraphTx>();
const graphTx = mempoolToGraphTx(tx);
const graphTx = convertToGraphTx(tx, memPool.getSpendMap());
ancestorMap.set(tx.txid, graphTx);
const allRelatives = expandRelativesGraph(mempool, ancestorMap);
const allRelatives = expandRelativesGraph(mempool, ancestorMap, memPool.getSpendMap());
const relativesMap = initializeRelatives(allRelatives);
const cluster = calculateCpfpCluster(tx.txid, relativesMap);
let totalVsize = 0;
let totalFee = 0;
for (const tx of cluster.values()) {
totalVsize += tx.adjustedVsize;
totalFee += tx.fee;
totalVsize += tx.vsize;
totalFee += tx.fees.base;
}
const effectiveFeePerVsize = totalFee / totalVsize;
for (const tx of cluster.values()) {
mempool[tx.txid].effectiveFeePerVsize = effectiveFeePerVsize;
mempool[tx.txid].ancestors = Array.from(tx.ancestorMap.values()).map(tx => ({ txid: tx.txid, weight: tx.weight, fee: tx.fee }));
mempool[tx.txid].descendants = Array.from(cluster.values()).filter(entry => entry.txid !== tx.txid && !tx.ancestorMap.has(entry.txid)).map(tx => ({ txid: tx.txid, weight: tx.weight, fee: tx.fee }));
mempool[tx.txid].ancestors = Array.from(tx.ancestors.values()).map(tx => ({ txid: tx.txid, weight: tx.weight, fee: tx.fees.base }));
mempool[tx.txid].descendants = Array.from(cluster.values()).filter(entry => entry.txid !== tx.txid && !tx.ancestors.has(entry.txid)).map(tx => ({ txid: tx.txid, weight: tx.weight, fee: tx.fees.base }));
mempool[tx.txid].bestDescendant = null;
mempool[tx.txid].cpfpChecked = true;
mempool[tx.txid].cpfpDirty = true;
@@ -70,88 +216,12 @@ export function calculateCpfp(tx: MempoolTransactionExtended, mempool: { [txid:
descendants: tx.descendants || [],
effectiveFeePerVsize: tx.effectiveFeePerVsize || tx.adjustedFeePerVsize || tx.feePerVsize,
sigops: tx.sigops,
fee: tx.fee,
adjustedVsize: tx.adjustedVsize,
acceleration: tx.acceleration
};
}
function mempoolToGraphTx(tx: MempoolTransactionExtended): GraphTx {
return {
...tx,
depends: tx.vin.map(v => v.txid),
spentby: tx.vout.map((v, i) => memPool.getFromSpendMap(tx.txid, i)).map(tx => tx?.txid).filter(txid => txid != null) as string[],
ancestorMap: new Map(),
fees: {
base: tx.fee,
ancestor: tx.fee,
},
ancestorcount: 1,
ancestorsize: tx.adjustedVsize,
ancestorRate: 0,
individualRate: 0,
score: 0,
};
}
/**
* Takes a map of transaction ancestors, and expands it into a full graph of up to MAX_GRAPH_SIZE in-mempool relatives
*/
function expandRelativesGraph(mempool: { [txid: string]: MempoolTransactionExtended }, ancestors: Map<string, GraphTx>): Map<string, GraphTx> {
const relatives: Map<string, GraphTx> = new Map();
const stack: GraphTx[] = Array.from(ancestors.values());
while (stack.length > 0) {
if (relatives.size > MAX_GRAPH_SIZE) {
return relatives;
}
const nextTx = stack.pop();
if (!nextTx) {
continue;
}
relatives.set(nextTx.txid, nextTx);
for (const relativeTxid of [...nextTx.depends, ...nextTx.spentby]) {
if (relatives.has(relativeTxid)) {
// already processed this tx
continue;
}
let mempoolTx = ancestors.get(relativeTxid);
if (!mempoolTx && mempool[relativeTxid]) {
mempoolTx = mempoolToGraphTx(mempool[relativeTxid]);
}
if (mempoolTx) {
stack.push(mempoolTx);
}
}
}
return relatives;
}
/**
* Efficiently sets a Map of in-mempool ancestors for each member of an expanded relative graph
* by running setAncestors on each leaf, and caching intermediate results.
* then initializes ancestor data for each transaction
*
* @param all
*/
function initializeRelatives(mempoolTxs: Map<string, GraphTx>): Map<string, GraphTx> {
const visited: Map<string, Map<string, GraphTx>> = new Map();
const leaves: GraphTx[] = Array.from(mempoolTxs.values()).filter(entry => entry.spentby.length === 0);
for (const leaf of leaves) {
setAncestors(leaf, mempoolTxs, visited);
}
mempoolTxs.forEach(entry => {
entry.ancestorMap?.forEach(ancestor => {
entry.ancestorcount++;
entry.ancestorsize += ancestor.adjustedVsize;
entry.fees.ancestor += ancestor.fees.base;
});
setAncestorScores(entry);
});
return mempoolTxs;
}
/**
* Given a root transaction and a list of in-mempool ancestors,
* Calculate the CPFP cluster
@@ -172,10 +242,10 @@ function calculateCpfpCluster(txid: string, graph: Map<string, GraphTx>): Map<st
let sortedRelatives = Array.from(graph.values()).sort(mempoolComparator);
// Iterate until we reach a cluster that includes our target tx
let maxIterations = MAX_GRAPH_SIZE;
let maxIterations = MAX_CLUSTER_ITERATIONS;
let best = sortedRelatives.shift();
let bestCluster = new Map<string, GraphTx>(best?.ancestorMap?.entries() || []);
while (sortedRelatives.length && best && (best.txid !== tx.txid && !best.ancestorMap.has(tx.txid)) && maxIterations > 0) {
let bestCluster = new Map<string, GraphTx>(best?.ancestors?.entries() || []);
while (sortedRelatives.length && best && (best.txid !== tx.txid && !best.ancestors.has(tx.txid)) && maxIterations > 0) {
maxIterations--;
if ((best && best.txid === tx.txid) || (bestCluster && bestCluster.has(tx.txid))) {
break;
@@ -190,7 +260,7 @@ function calculateCpfpCluster(txid: string, graph: Map<string, GraphTx>): Map<st
// Grab the next highest scoring entry
best = sortedRelatives.shift();
if (best) {
bestCluster = new Map<string, GraphTx>(best?.ancestorMap?.entries() || []);
bestCluster = new Map<string, GraphTx>(best?.ancestors?.entries() || []);
bestCluster.set(best?.txid, best);
}
}
@@ -199,88 +269,4 @@ function calculateCpfpCluster(txid: string, graph: Map<string, GraphTx>): Map<st
bestCluster.set(tx.txid, tx);
return bestCluster;
}
/**
* Remove a cluster of transactions from an in-mempool dependency graph
* and update the survivors' scores and ancestors
*
* @param cluster
* @param ancestors
*/
function removeAncestors(cluster: Map<string, GraphTx>, all: Map<string, GraphTx>): void {
// remove
cluster.forEach(tx => {
all.delete(tx.txid);
});
// update survivors
all.forEach(tx => {
cluster.forEach(remove => {
if (tx.ancestorMap?.has(remove.txid)) {
// remove as dependency
tx.ancestorMap.delete(remove.txid);
tx.depends = tx.depends.filter(parent => parent !== remove.txid);
// update ancestor sizes and fees
tx.ancestorsize -= remove.adjustedVsize;
tx.fees.ancestor -= remove.fees.base;
}
});
// recalculate fee rates
setAncestorScores(tx);
});
}
/**
* Recursively traverses an in-mempool dependency graph, and sets a Map of in-mempool ancestors
* for each transaction.
*
* @param tx
* @param all
*/
function setAncestors(tx: GraphTx, all: Map<string, GraphTx>, visited: Map<string, Map<string, GraphTx>>, depth: number = 0): Map<string, GraphTx> {
// sanity check for infinite recursion / too many ancestors (should never happen)
if (depth > MAX_GRAPH_SIZE) {
return tx.ancestorMap;
}
// initialize the ancestor map for this tx
tx.ancestorMap = new Map<string, GraphTx>();
tx.depends.forEach(parentId => {
const parent = all.get(parentId);
if (parent) {
// add the parent
tx.ancestorMap?.set(parentId, parent);
// check for a cached copy of this parent's ancestors
let ancestors = visited.get(parent.txid);
if (!ancestors) {
// recursively fetch the parent's ancestors
ancestors = setAncestors(parent, all, visited, depth + 1);
}
// and add to this tx's map
ancestors.forEach((ancestor, ancestorId) => {
tx.ancestorMap?.set(ancestorId, ancestor);
});
}
});
visited.set(tx.txid, tx.ancestorMap);
return tx.ancestorMap;
}
/**
* Take a mempool transaction, and set the fee rates and ancestor score
*
* @param tx
*/
function setAncestorScores(tx: GraphTx): GraphTx {
tx.individualRate = (tx.fees.base * 100_000_000) / tx.adjustedVsize;
tx.ancestorRate = (tx.fees.ancestor * 100_000_000) / tx.ancestorsize;
tx.score = Math.min(tx.individualRate, tx.ancestorRate);
return tx;
}
// Sort by descending score
function mempoolComparator(a: GraphTx, b: GraphTx): number {
return b.score - a.score;
}

View File

@@ -7,7 +7,7 @@ import cpfpRepository from '../repositories/CpfpRepository';
import { RowDataPacket } from 'mysql2';
class DatabaseMigration {
private static currentVersion = 80;
private static currentVersion = 81;
private queryTimeout = 3600_000;
private statisticsAddedIndexed = false;
private uniqueLogs: string[] = [];
@@ -653,9 +653,11 @@ class DatabaseMigration {
await this.$executeQuery('ALTER TABLE `prices` ADD `TRY` float DEFAULT "-1"');
await this.$executeQuery('ALTER TABLE `prices` ADD `ZAR` float DEFAULT "-1"');
await this.$executeQuery('TRUNCATE hashrates');
await this.$executeQuery('TRUNCATE difficulty_adjustments');
await this.$executeQuery(`UPDATE state SET string = NULL WHERE name = 'pools_json_sha'`);
if (isBitcoin === true) {
await this.$executeQuery('TRUNCATE hashrates');
await this.$executeQuery('TRUNCATE difficulty_adjustments');
await this.$executeQuery(`UPDATE state SET string = NULL WHERE name = 'pools_json_sha'`);
}
await this.updateToSchemaVersion(75);
}
@@ -691,6 +693,13 @@ class DatabaseMigration {
await this.$executeQuery('ALTER TABLE `blocks` ADD coinbase_addresses JSON DEFAULT NULL');
await this.updateToSchemaVersion(80);
}
if (databaseSchemaVersion < 81 && isBitcoin === true) {
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD version INT NOT NULL DEFAULT 0');
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD INDEX `version` (`version`)');
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD unseen_txs JSON DEFAULT "[]"');
await this.updateToSchemaVersion(81);
}
}
/**

View File

@@ -453,6 +453,7 @@ class MempoolBlocks {
mempoolTx.acceleration = true;
mempoolTx.acceleratedBy = isAcceleratedBy[txid] || acceleration?.pools;
mempoolTx.acceleratedAt = acceleration?.added;
mempoolTx.feeDelta = acceleration?.feeDelta;
for (const ancestor of mempoolTx.ancestors || []) {
if (!mempool[ancestor.txid].acceleration) {
mempool[ancestor.txid].cpfpDirty = true;
@@ -460,6 +461,7 @@ class MempoolBlocks {
mempool[ancestor.txid].acceleration = true;
mempool[ancestor.txid].acceleratedBy = mempoolTx.acceleratedBy;
mempool[ancestor.txid].acceleratedAt = mempoolTx.acceleratedAt;
mempool[ancestor.txid].feeDelta = mempoolTx.feeDelta;
isAcceleratedBy[ancestor.txid] = mempoolTx.acceleratedBy;
}
} else {

View File

@@ -396,10 +396,6 @@ class Mempool {
}
public $updateAccelerations(newAccelerations: Acceleration[]): string[] {
if (!config.MEMPOOL_SERVICES.ACCELERATIONS) {
return [];
}
try {
const changed: string[] = [];

View File

@@ -0,0 +1,515 @@
import { Acceleration } from './acceleration/acceleration';
import { MempoolTransactionExtended } from '../mempool.interfaces';
import logger from '../logger';
const BLOCK_WEIGHT_UNITS = 4_000_000;
const BLOCK_SIGOPS = 80_000;
const MAX_RELATIVE_GRAPH_SIZE = 100;
export interface GraphTx {
txid: string;
vsize: number;
weight: number;
depends: string[];
spentby: string[];
ancestorcount: number;
ancestorsize: number;
fees: { // in sats
base: number;
ancestor: number;
};
ancestors: Map<string, GraphTx>,
ancestorRate: number;
individualRate: number;
score: number;
}
interface TemplateTransaction {
txid: string;
order: number;
weight: number;
adjustedVsize: number; // sigop-adjusted vsize, rounded up to the nearest integer
sigops: number;
fee: number;
feeDelta: number;
ancestors: string[];
cluster: string[];
effectiveFeePerVsize: number;
}
interface MinerTransaction extends TemplateTransaction {
inputs: string[];
feePerVsize: number;
relativesSet: boolean;
ancestorMap: Map<string, MinerTransaction>;
children: Set<MinerTransaction>;
ancestorFee: number;
ancestorVsize: number;
ancestorSigops: number;
score: number;
used: boolean;
modified: boolean;
dependencyRate: number;
}
/**
* Takes a raw transaction, and builds a graph of same-block relatives,
* and returns as a GraphTx
*
* @param tx
*/
export function getSameBlockRelatives(tx: MempoolTransactionExtended, transactions: MempoolTransactionExtended[]): Map<string, GraphTx> {
const blockTxs = new Map<string, MempoolTransactionExtended>(); // map of txs in this block
const spendMap = new Map<string, string>(); // map of outpoints to spending txids
for (const tx of transactions) {
blockTxs.set(tx.txid, tx);
for (const vin of tx.vin) {
spendMap.set(`${vin.txid}:${vin.vout}`, tx.txid);
}
}
const relatives: Map<string, GraphTx> = new Map();
const stack: string[] = [tx.txid];
// build set of same-block ancestors
while (stack.length > 0) {
const nextTxid = stack.pop();
const nextTx = nextTxid ? blockTxs.get(nextTxid) : null;
if (!nextTx || relatives.has(nextTx.txid)) {
continue;
}
const mempoolTx = convertToGraphTx(nextTx, spendMap);
for (const txid of [...mempoolTx.depends, ...mempoolTx.spentby]) {
if (txid) {
stack.push(txid);
}
}
relatives.set(mempoolTx.txid, mempoolTx);
}
return relatives;
}
/**
* Takes a raw transaction and converts it to GraphTx format
* fee and ancestor data is initialized with dummy/null values
*
* @param tx
*/
export function convertToGraphTx(tx: MempoolTransactionExtended, spendMap?: Map<string, MempoolTransactionExtended | string>): GraphTx {
return {
txid: tx.txid,
vsize: Math.max(tx.sigops * 5, Math.ceil(tx.weight / 4)),
weight: tx.weight,
fees: {
base: tx.fee || 0,
ancestor: tx.fee || 0,
},
depends: (tx.vin.map(vin => vin.txid).filter(depend => depend) as string[]),
spentby: spendMap ? (tx.vout.map((vout, index) => { const spend = spendMap.get(`${tx.txid}:${index}`); return (spend?.['txid'] || spend); }).filter(spent => spent) as string[]) : [],
ancestorcount: 1,
ancestorsize: Math.max(tx.sigops * 5, Math.ceil(tx.weight / 4)),
ancestors: new Map<string, GraphTx>(),
ancestorRate: 0,
individualRate: 0,
score: 0,
};
}
/**
* Takes a map of transaction ancestors, and expands it into a full graph of up to MAX_GRAPH_SIZE in-mempool relatives
*/
export function expandRelativesGraph(mempool: { [txid: string]: MempoolTransactionExtended }, ancestors: Map<string, GraphTx>, spendMap: Map<string, MempoolTransactionExtended>): Map<string, GraphTx> {
const relatives: Map<string, GraphTx> = new Map();
const stack: GraphTx[] = Array.from(ancestors.values());
while (stack.length > 0) {
if (relatives.size > MAX_RELATIVE_GRAPH_SIZE) {
return relatives;
}
const nextTx = stack.pop();
if (!nextTx) {
continue;
}
relatives.set(nextTx.txid, nextTx);
for (const relativeTxid of [...nextTx.depends, ...nextTx.spentby]) {
if (relatives.has(relativeTxid)) {
// already processed this tx
continue;
}
let ancestorTx = ancestors.get(relativeTxid);
if (!ancestorTx && relativeTxid in mempool) {
const mempoolTx = mempool[relativeTxid];
ancestorTx = convertToGraphTx(mempoolTx, spendMap);
}
if (ancestorTx) {
stack.push(ancestorTx);
}
}
}
return relatives;
}
/**
* Recursively traverses an in-mempool dependency graph, and sets a Map of in-mempool ancestors
* for each transaction.
*
* @param tx
* @param all
*/
function setAncestors(tx: GraphTx, all: Map<string, GraphTx>, visited: Map<string, Map<string, GraphTx>>, depth: number = 0): Map<string, GraphTx> {
// sanity check for infinite recursion / too many ancestors (should never happen)
if (depth > MAX_RELATIVE_GRAPH_SIZE) {
logger.warn('cpfp dependency calculation failed: setAncestors reached depth of 100, unable to proceed');
return tx.ancestors;
}
// initialize the ancestor map for this tx
tx.ancestors = new Map<string, GraphTx>();
tx.depends.forEach(parentId => {
const parent = all.get(parentId);
if (parent) {
// add the parent
tx.ancestors?.set(parentId, parent);
// check for a cached copy of this parent's ancestors
let ancestors = visited.get(parent.txid);
if (!ancestors) {
// recursively fetch the parent's ancestors
ancestors = setAncestors(parent, all, visited, depth + 1);
}
// and add to this tx's map
ancestors.forEach((ancestor, ancestorId) => {
tx.ancestors?.set(ancestorId, ancestor);
});
}
});
visited.set(tx.txid, tx.ancestors);
return tx.ancestors;
}
/**
* Efficiently sets a Map of in-mempool ancestors for each member of an expanded relative graph
* by running setAncestors on each leaf, and caching intermediate results.
* then initializes ancestor data for each transaction
*
* @param all
*/
export function initializeRelatives(mempoolTxs: Map<string, GraphTx>): Map<string, GraphTx> {
const visited: Map<string, Map<string, GraphTx>> = new Map();
const leaves: GraphTx[] = Array.from(mempoolTxs.values()).filter(entry => entry.spentby.length === 0);
for (const leaf of leaves) {
setAncestors(leaf, mempoolTxs, visited);
}
mempoolTxs.forEach(entry => {
entry.ancestors?.forEach(ancestor => {
entry.ancestorcount++;
entry.ancestorsize += ancestor.vsize;
entry.fees.ancestor += ancestor.fees.base;
});
setAncestorScores(entry);
});
return mempoolTxs;
}
/**
* Remove a cluster of transactions from an in-mempool dependency graph
* and update the survivors' scores and ancestors
*
* @param cluster
* @param ancestors
*/
export function removeAncestors(cluster: Map<string, GraphTx>, all: Map<string, GraphTx>): void {
// remove
cluster.forEach(tx => {
all.delete(tx.txid);
});
// update survivors
all.forEach(tx => {
cluster.forEach(remove => {
if (tx.ancestors?.has(remove.txid)) {
// remove as dependency
tx.ancestors.delete(remove.txid);
tx.depends = tx.depends.filter(parent => parent !== remove.txid);
// update ancestor sizes and fees
tx.ancestorsize -= remove.vsize;
tx.fees.ancestor -= remove.fees.base;
}
});
// recalculate fee rates
setAncestorScores(tx);
});
}
/**
* Take a mempool transaction, and set the fee rates and ancestor score
*
* @param tx
*/
export function setAncestorScores(tx: GraphTx): void {
tx.individualRate = tx.fees.base / tx.vsize;
tx.ancestorRate = tx.fees.ancestor / tx.ancestorsize;
tx.score = Math.min(tx.individualRate, tx.ancestorRate);
}
// Sort by descending score
export function mempoolComparator(a: GraphTx, b: GraphTx): number {
return b.score - a.score;
}
/*
* Build a block using an approximation of the transaction selection algorithm from Bitcoin Core
* (see BlockAssembler in https://github.com/bitcoin/bitcoin/blob/master/src/node/miner.cpp)
*/
export function makeBlockTemplate(candidates: MempoolTransactionExtended[], accelerations: Acceleration[], maxBlocks: number = 8, weightLimit: number = BLOCK_WEIGHT_UNITS, sigopLimit: number = BLOCK_SIGOPS): TemplateTransaction[] {
const auditPool: Map<string, MinerTransaction> = new Map();
const mempoolArray: MinerTransaction[] = [];
candidates.forEach(tx => {
// initializing everything up front helps V8 optimize property access later
const adjustedVsize = Math.ceil(Math.max(tx.weight / 4, 5 * (tx.sigops || 0)));
const feePerVsize = (tx.fee / adjustedVsize);
auditPool.set(tx.txid, {
txid: tx.txid,
order: txidToOrdering(tx.txid),
fee: tx.fee,
feeDelta: 0,
weight: tx.weight,
adjustedVsize,
feePerVsize: feePerVsize,
effectiveFeePerVsize: feePerVsize,
dependencyRate: feePerVsize,
sigops: tx.sigops || 0,
inputs: (tx.vin?.map(vin => vin.txid) || []) as string[],
relativesSet: false,
ancestors: [],
cluster: [],
ancestorMap: new Map<string, MinerTransaction>(),
children: new Set<MinerTransaction>(),
ancestorFee: 0,
ancestorVsize: 0,
ancestorSigops: 0,
score: 0,
used: false,
modified: false,
});
mempoolArray.push(auditPool.get(tx.txid) as MinerTransaction);
});
// set accelerated effective fee
for (const acceleration of accelerations) {
const tx = auditPool.get(acceleration.txid);
if (tx) {
tx.feeDelta = acceleration.max_bid;
tx.feePerVsize = ((tx.fee + tx.feeDelta) / tx.adjustedVsize);
tx.effectiveFeePerVsize = tx.feePerVsize;
tx.dependencyRate = tx.feePerVsize;
}
}
// Build relatives graph & calculate ancestor scores
for (const tx of mempoolArray) {
if (!tx.relativesSet) {
setRelatives(tx, auditPool);
}
}
// Sort by descending ancestor score
mempoolArray.sort(priorityComparator);
// Build blocks by greedily choosing the highest feerate package
// (i.e. the package rooted in the transaction with the best ancestor score)
const blocks: number[][] = [];
let blockWeight = 0;
let blockSigops = 0;
const transactions: MinerTransaction[] = [];
let modified: MinerTransaction[] = [];
const overflow: MinerTransaction[] = [];
let failures = 0;
while (mempoolArray.length || modified.length) {
// skip invalid transactions
while (mempoolArray[0]?.used || mempoolArray[0]?.modified) {
mempoolArray.shift();
}
// Select best next package
let nextTx;
const nextPoolTx = mempoolArray[0];
const nextModifiedTx = modified[0];
if (nextPoolTx && (!nextModifiedTx || (nextPoolTx.score || 0) > (nextModifiedTx.score || 0))) {
nextTx = nextPoolTx;
mempoolArray.shift();
} else {
modified.shift();
if (nextModifiedTx) {
nextTx = nextModifiedTx;
}
}
if (nextTx && !nextTx?.used) {
// Check if the package fits into this block
if (blocks.length >= (maxBlocks - 1) || ((blockWeight + (4 * nextTx.ancestorVsize) < weightLimit) && (blockSigops + nextTx.ancestorSigops <= sigopLimit))) {
const ancestors: MinerTransaction[] = Array.from(nextTx.ancestorMap.values());
// sort ancestors by dependency graph (equivalent to sorting by ascending ancestor count)
const sortedTxSet = [...ancestors.sort((a, b) => { return (a.ancestorMap.size || 0) - (b.ancestorMap.size || 0); }), nextTx];
const clusterTxids = sortedTxSet.map(tx => tx.txid);
const effectiveFeeRate = Math.min(nextTx.dependencyRate || Infinity, nextTx.ancestorFee / nextTx.ancestorVsize);
const used: MinerTransaction[] = [];
while (sortedTxSet.length) {
const ancestor = sortedTxSet.pop();
if (!ancestor) {
continue;
}
ancestor.used = true;
ancestor.usedBy = nextTx.txid;
// update this tx with effective fee rate & relatives data
if (ancestor.effectiveFeePerVsize !== effectiveFeeRate) {
ancestor.effectiveFeePerVsize = effectiveFeeRate;
}
ancestor.cluster = clusterTxids;
transactions.push(ancestor);
blockWeight += ancestor.weight;
blockSigops += ancestor.sigops;
used.push(ancestor);
}
// remove these as valid package ancestors for any descendants remaining in the mempool
if (used.length) {
used.forEach(tx => {
modified = updateDescendants(tx, auditPool, modified, effectiveFeeRate);
});
}
failures = 0;
} else {
// hold this package in an overflow list while we check for smaller options
overflow.push(nextTx);
failures++;
}
}
// this block is full
const exceededPackageTries = failures > 1000 && blockWeight > (weightLimit - 4000);
const queueEmpty = !mempoolArray.length && !modified.length;
if (exceededPackageTries || queueEmpty) {
break;
}
}
for (const tx of transactions) {
tx.ancestors = Object.values(tx.ancestorMap);
}
return transactions;
}
// traverse in-mempool ancestors
// recursion unavoidable, but should be limited to depth < 25 by mempool policy
function setRelatives(
tx: MinerTransaction,
mempool: Map<string, MinerTransaction>,
): void {
for (const parent of tx.inputs) {
const parentTx = mempool.get(parent);
if (parentTx && !tx.ancestorMap?.has(parent)) {
tx.ancestorMap.set(parent, parentTx);
parentTx.children.add(tx);
// visit each node only once
if (!parentTx.relativesSet) {
setRelatives(parentTx, mempool);
}
parentTx.ancestorMap.forEach((ancestor) => {
tx.ancestorMap.set(ancestor.txid, ancestor);
});
}
};
tx.ancestorFee = (tx.fee + tx.feeDelta);
tx.ancestorVsize = tx.adjustedVsize || 0;
tx.ancestorSigops = tx.sigops || 0;
tx.ancestorMap.forEach((ancestor) => {
tx.ancestorFee += (ancestor.fee + ancestor.feeDelta);
tx.ancestorVsize += ancestor.adjustedVsize;
tx.ancestorSigops += ancestor.sigops;
});
tx.score = tx.ancestorFee / tx.ancestorVsize;
tx.relativesSet = true;
}
// iterate over remaining descendants, removing the root as a valid ancestor & updating the ancestor score
// avoids recursion to limit call stack depth
function updateDescendants(
rootTx: MinerTransaction,
mempool: Map<string, MinerTransaction>,
modified: MinerTransaction[],
clusterRate: number,
): MinerTransaction[] {
const descendantSet: Set<MinerTransaction> = new Set();
// stack of nodes left to visit
const descendants: MinerTransaction[] = [];
let descendantTx: MinerTransaction | undefined;
rootTx.children.forEach(childTx => {
if (!descendantSet.has(childTx)) {
descendants.push(childTx);
descendantSet.add(childTx);
}
});
while (descendants.length) {
descendantTx = descendants.pop();
if (descendantTx && descendantTx.ancestorMap && descendantTx.ancestorMap.has(rootTx.txid)) {
// remove tx as ancestor
descendantTx.ancestorMap.delete(rootTx.txid);
descendantTx.ancestorFee -= (rootTx.fee + rootTx.feeDelta);
descendantTx.ancestorVsize -= rootTx.adjustedVsize;
descendantTx.ancestorSigops -= rootTx.sigops;
descendantTx.score = descendantTx.ancestorFee / descendantTx.ancestorVsize;
descendantTx.dependencyRate = descendantTx.dependencyRate ? Math.min(descendantTx.dependencyRate, clusterRate) : clusterRate;
if (!descendantTx.modified) {
descendantTx.modified = true;
modified.push(descendantTx);
}
// add this node's children to the stack
descendantTx.children.forEach(childTx => {
// visit each node only once
if (!descendantSet.has(childTx)) {
descendants.push(childTx);
descendantSet.add(childTx);
}
});
}
}
// return new, resorted modified list
return modified.sort(priorityComparator);
}
// Used to sort an array of MinerTransactions by descending ancestor score
function priorityComparator(a: MinerTransaction, b: MinerTransaction): number {
if (b.score === a.score) {
// tie-break by txid for stability
return a.order - b.order;
} else {
return b.score - a.score;
}
}
// returns the most significant 4 bytes of the txid as an integer
function txidToOrdering(txid: string): number {
return parseInt(
txid.substring(62, 64) +
txid.substring(60, 62) +
txid.substring(58, 60) +
txid.substring(56, 58),
16
);
}

View File

@@ -9,6 +9,7 @@ import bitcoinClient from '../bitcoin/bitcoin-client';
import mining from "./mining";
import PricesRepository from '../../repositories/PricesRepository';
import AccelerationRepository from '../../repositories/AccelerationRepository';
import accelerationApi from '../services/acceleration';
class MiningRoutes {
public initRoutes(app: Application) {
@@ -41,6 +42,8 @@ class MiningRoutes {
.get(config.MEMPOOL.API_URL_PREFIX + 'accelerations/block/:height', this.$getAccelerationsByHeight)
.get(config.MEMPOOL.API_URL_PREFIX + 'accelerations/recent/:interval', this.$getRecentAccelerations)
.get(config.MEMPOOL.API_URL_PREFIX + 'accelerations/total', this.$getAccelerationTotals)
.get(config.MEMPOOL.API_URL_PREFIX + 'accelerations', this.$getActiveAccelerations)
.post(config.MEMPOOL.API_URL_PREFIX + 'acceleration/request/:txid', this.$requestAcceleration)
;
}
@@ -445,6 +448,33 @@ class MiningRoutes {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $getActiveAccelerations(req: Request, res: Response): Promise<void> {
try {
res.header('Pragma', 'public');
res.header('Cache-control', 'public');
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
if (!config.MEMPOOL_SERVICES.ACCELERATIONS || ['testnet', 'signet', 'liquidtestnet', 'liquid'].includes(config.MEMPOOL.NETWORK)) {
res.status(400).send('Acceleration data is not available.');
return;
}
res.status(200).send(accelerationApi.accelerations || []);
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
private async $requestAcceleration(req: Request, res: Response): Promise<void> {
res.setHeader('Pragma', 'no-cache');
res.setHeader('Cache-control', 'private, no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0');
res.setHeader('expires', -1);
try {
accelerationApi.accelerationRequested(req.params.txid);
res.status(200).send();
} catch (e) {
res.status(500).send(e instanceof Error ? e.message : e);
}
}
}
export default new MiningRoutes();

View File

@@ -1,8 +1,10 @@
import config from '../../config';
import logger from '../../logger';
import { BlockExtended, PoolTag } from '../../mempool.interfaces';
import { BlockExtended } from '../../mempool.interfaces';
import axios from 'axios';
type MyAccelerationStatus = 'requested' | 'accelerating' | 'done';
export interface Acceleration {
txid: string,
added: number,
@@ -35,18 +37,91 @@ export interface AccelerationHistory {
};
class AccelerationApi {
public async $fetchAccelerations(): Promise<Acceleration[] | null> {
if (config.MEMPOOL_SERVICES.ACCELERATIONS) {
try {
const response = await axios.get(`${config.MEMPOOL_SERVICES.API}/accelerator/accelerations`, { responseType: 'json', timeout: 10000 });
return response.data as Acceleration[];
} catch (e) {
logger.warn('Failed to fetch current accelerations from the mempool services backend: ' + (e instanceof Error ? e.message : e));
return null;
private onDemandPollingEnabled = !config.MEMPOOL_SERVICES.ACCELERATIONS;
private apiPath = config.MEMPOOL.OFFICIAL ? (config.MEMPOOL_SERVICES.API + '/accelerator/accelerations') : (config.EXTERNAL_DATA_SERVER.MEMPOOL_API + '/accelerations');
private _accelerations: Acceleration[] | null = null;
private lastPoll = 0;
private forcePoll = false;
private myAccelerations: Record<string, { status: MyAccelerationStatus, added: number, acceleration?: Acceleration }> = {};
public get accelerations(): Acceleration[] | null {
return this._accelerations;
}
public countMyAccelerationsWithStatus(filter: MyAccelerationStatus): number {
return Object.values(this.myAccelerations).reduce((count, {status}) => { return count + (status === filter ? 1 : 0); }, 0);
}
public accelerationRequested(txid: string): void {
if (this.onDemandPollingEnabled) {
this.myAccelerations[txid] = { status: 'requested', added: Date.now() };
}
}
public accelerationConfirmed(): void {
this.forcePoll = true;
}
private async $fetchAccelerations(): Promise<Acceleration[] | null> {
try {
const response = await axios.get(this.apiPath, { responseType: 'json', timeout: 10000 });
return response?.data || [];
} catch (e) {
logger.warn('Failed to fetch current accelerations from the mempool services backend: ' + (e instanceof Error ? e.message : e));
return null;
}
}
public async $updateAccelerations(): Promise<Acceleration[] | null> {
if (!this.onDemandPollingEnabled) {
const accelerations = await this.$fetchAccelerations();
if (accelerations) {
this._accelerations = accelerations;
return this._accelerations;
}
} else {
return [];
return this.$updateAccelerationsOnDemand();
}
return null;
}
private async $updateAccelerationsOnDemand(): Promise<Acceleration[] | null> {
const shouldUpdate = this.forcePoll
|| this.countMyAccelerationsWithStatus('requested') > 0
|| (this.countMyAccelerationsWithStatus('accelerating') > 0 && this.lastPoll < (Date.now() - (10 * 60 * 1000)));
// update accelerations if necessary
if (shouldUpdate) {
const accelerations = await this.$fetchAccelerations();
this.lastPoll = Date.now();
this.forcePoll = false;
if (accelerations) {
const latestAccelerations: Record<string, Acceleration> = {};
// set relevant accelerations to 'accelerating'
for (const acc of accelerations) {
if (this.myAccelerations[acc.txid]) {
latestAccelerations[acc.txid] = acc;
this.myAccelerations[acc.txid] = { status: 'accelerating', added: Date.now(), acceleration: acc };
}
}
// txs that are no longer accelerating are either confirmed or canceled, so mark for expiry
for (const [txid, { status, acceleration }] of Object.entries(this.myAccelerations)) {
if (status === 'accelerating' && !latestAccelerations[txid]) {
this.myAccelerations[txid] = { status: 'done', added: Date.now(), acceleration };
}
}
}
}
// clear expired accelerations (confirmed / failed / not accepted) after 10 minutes
for (const [txid, { status, added }] of Object.entries(this.myAccelerations)) {
if (['requested', 'done'].includes(status) && added < (Date.now() - (1000 * 60 * 10))) {
delete this.myAccelerations[txid];
}
}
this._accelerations = Object.values(this.myAccelerations).map(({ acceleration }) => acceleration).filter(acc => acc) as Acceleration[];
return this._accelerations;
}
public async $fetchAccelerationHistory(page?: number, status?: string): Promise<AccelerationHistory[] | null> {

View File

@@ -103,7 +103,7 @@ class TransactionUtils {
}
const feePerVbytes = (transaction.fee || 0) / (transaction.weight / 4);
const transactionExtended: TransactionExtended = Object.assign({
vsize: Math.round(transaction.weight / 4),
vsize: transaction.weight / 4,
feePerVsize: feePerVbytes,
effectiveFeePerVsize: feePerVbytes,
}, transaction);
@@ -123,7 +123,7 @@ class TransactionUtils {
const adjustedFeePerVsize = (transaction.fee || 0) / adjustedVsize;
const transactionExtended: MempoolTransactionExtended = Object.assign(transaction, {
order: this.txidToOrdering(transaction.txid),
vsize: Math.round(transaction.weight / 4),
vsize,
adjustedVsize,
sigops,
feePerVsize: feePerVbytes,

View File

@@ -3,7 +3,7 @@ import * as WebSocket from 'ws';
import {
BlockExtended, TransactionExtended, MempoolTransactionExtended, WebsocketResponse,
OptimizedStatistic, ILoadingIndicators, GbtCandidates, TxTrackingInfo,
MempoolBlockDelta, MempoolDelta, MempoolDeltaTxids
MempoolDelta, MempoolDeltaTxids
} from '../mempool.interfaces';
import blocks from './blocks';
import memPool from './mempool';
@@ -33,7 +33,7 @@ interface AddressTransactions {
removed: MempoolTransactionExtended[],
}
import bitcoinSecondClient from './bitcoin/bitcoin-second-client';
import { calculateCpfp } from './cpfp';
import { calculateMempoolTxCpfp } from './cpfp';
// valid 'want' subscriptions
const wantable = [
@@ -538,9 +538,9 @@ class WebsocketHandler {
}
if (config.MEMPOOL.RUST_GBT) {
await mempoolBlocks.$rustUpdateBlockTemplates(transactionIds, newMempool, added, removed, candidates, config.MEMPOOL_SERVICES.ACCELERATIONS);
await mempoolBlocks.$rustUpdateBlockTemplates(transactionIds, newMempool, added, removed, candidates, true);
} else {
await mempoolBlocks.$updateBlockTemplates(transactionIds, newMempool, added, removed, candidates, accelerationDelta, true, config.MEMPOOL_SERVICES.ACCELERATIONS);
await mempoolBlocks.$updateBlockTemplates(transactionIds, newMempool, added, removed, candidates, accelerationDelta, true, true);
}
const mBlocks = mempoolBlocks.getMempoolBlocks();
@@ -823,11 +823,12 @@ class WebsocketHandler {
accelerated: mempoolTx.acceleration || undefined,
acceleratedBy: mempoolTx.acceleratedBy || undefined,
acceleratedAt: mempoolTx.acceleratedAt || undefined,
feeDelta: mempoolTx.feeDelta || undefined,
},
accelerationPositions: memPool.getAccelerationPositions(mempoolTx.txid),
};
if (!mempoolTx.cpfpChecked && !mempoolTx.acceleration) {
calculateCpfp(mempoolTx, newMempool);
calculateMempoolTxCpfp(mempoolTx, newMempool);
}
if (mempoolTx.cpfpDirty) {
positionData['cpfp'] = {
@@ -864,9 +865,10 @@ class WebsocketHandler {
accelerated: mempoolTx.acceleration || undefined,
acceleratedBy: mempoolTx.acceleratedBy || undefined,
acceleratedAt: mempoolTx.acceleratedAt || undefined,
feeDelta: mempoolTx.feeDelta || undefined,
};
if (!mempoolTx.cpfpChecked) {
calculateCpfp(mempoolTx, newMempool);
calculateMempoolTxCpfp(mempoolTx, newMempool);
}
if (mempoolTx.cpfpDirty) {
txInfo.cpfp = {
@@ -931,6 +933,8 @@ class WebsocketHandler {
throw new Error('No WebSocket.Server have been set');
}
const blockTransactions = structuredClone(transactions);
this.printLogs();
await statistics.runStatistics();
@@ -940,7 +944,7 @@ class WebsocketHandler {
let transactionIds: string[] = (memPool.limitGBT) ? Object.keys(candidates?.txs || {}) : Object.keys(_memPool);
const accelerations = Object.values(mempool.getAccelerations());
await accelerationRepository.$indexAccelerationsForBlock(block, accelerations, transactions);
await accelerationRepository.$indexAccelerationsForBlock(block, accelerations, structuredClone(transactions));
const rbfTransactions = Common.findMinedRbfTransactions(transactions, memPool.getSpendMap());
memPool.handleMinedRbfTransactions(rbfTransactions);
@@ -949,22 +953,18 @@ class WebsocketHandler {
if (config.MEMPOOL.AUDIT && memPool.isInSync()) {
let projectedBlocks;
const auditMempool = _memPool;
const isAccelerated = config.MEMPOOL_SERVICES.ACCELERATIONS && accelerationApi.isAcceleratedBlock(block, Object.values(mempool.getAccelerations()));
const isAccelerated = accelerationApi.isAcceleratedBlock(block, Object.values(mempool.getAccelerations()));
if ((config.MEMPOOL_SERVICES.ACCELERATIONS)) {
if (config.MEMPOOL.RUST_GBT) {
const added = memPool.limitGBT ? (candidates?.added || []) : [];
const removed = memPool.limitGBT ? (candidates?.removed || []) : [];
projectedBlocks = await mempoolBlocks.$rustUpdateBlockTemplates(transactionIds, auditMempool, added, removed, candidates, isAccelerated, block.extras.pool.id);
} else {
projectedBlocks = await mempoolBlocks.$makeBlockTemplates(transactionIds, auditMempool, candidates, false, isAccelerated, block.extras.pool.id);
}
if (config.MEMPOOL.RUST_GBT) {
const added = memPool.limitGBT ? (candidates?.added || []) : [];
const removed = memPool.limitGBT ? (candidates?.removed || []) : [];
projectedBlocks = await mempoolBlocks.$rustUpdateBlockTemplates(transactionIds, auditMempool, added, removed, candidates, isAccelerated, block.extras.pool.id);
} else {
projectedBlocks = mempoolBlocks.getMempoolBlocksWithTransactions();
projectedBlocks = await mempoolBlocks.$makeBlockTemplates(transactionIds, auditMempool, candidates, false, isAccelerated, block.extras.pool.id);
}
if (Common.indexingEnabled()) {
const { censored, added, prioritized, fresh, sigop, fullrbf, accelerated, score, similarity } = Audit.auditBlock(transactions, projectedBlocks, auditMempool);
const { unseen, censored, added, prioritized, fresh, sigop, fullrbf, accelerated, score, similarity } = Audit.auditBlock(block.height, blockTransactions, projectedBlocks, auditMempool);
const matchRate = Math.round(score * 100 * 100) / 100;
const stripped = projectedBlocks[0]?.transactions ? projectedBlocks[0].transactions : [];
@@ -986,9 +986,11 @@ class WebsocketHandler {
});
BlocksAuditsRepository.$saveAudit({
version: 1,
time: block.timestamp,
height: block.height,
hash: block.id,
unseenTxs: unseen,
addedTxs: added,
prioritizedTxs: prioritized,
missingTxs: censored,
@@ -1040,7 +1042,7 @@ class WebsocketHandler {
const removed = memPool.limitGBT ? (candidates?.removed || []) : transactions;
await mempoolBlocks.$rustUpdateBlockTemplates(transactionIds, _memPool, added, removed, candidates, true);
} else {
await mempoolBlocks.$makeBlockTemplates(transactionIds, _memPool, candidates, true, config.MEMPOOL_SERVICES.ACCELERATIONS);
await mempoolBlocks.$makeBlockTemplates(transactionIds, _memPool, candidates, true, true);
}
const mBlocks = mempoolBlocks.getMempoolBlocks();
const mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas();
@@ -1142,6 +1144,7 @@ class WebsocketHandler {
accelerated: mempoolTx.acceleration || undefined,
acceleratedBy: mempoolTx.acceleratedBy || undefined,
acceleratedAt: mempoolTx.acceleratedAt || undefined,
feeDelta: mempoolTx.feeDelta || undefined,
},
accelerationPositions: memPool.getAccelerationPositions(mempoolTx.txid),
});
@@ -1164,6 +1167,7 @@ class WebsocketHandler {
accelerated: mempoolTx.acceleration || undefined,
acceleratedBy: mempoolTx.acceleratedBy || undefined,
acceleratedAt: mempoolTx.acceleratedAt || undefined,
feeDelta: mempoolTx.feeDelta || undefined,
};
}
}

View File

@@ -229,7 +229,7 @@ class Server {
const newMempool = await bitcoinApi.$getRawMempool();
const minFeeMempool = memPool.limitGBT ? await bitcoinSecondClient.getRawMemPool() : null;
const minFeeTip = memPool.limitGBT ? await bitcoinSecondClient.getBlockCount() : -1;
const newAccelerations = await accelerationApi.$fetchAccelerations();
const newAccelerations = await accelerationApi.$updateAccelerations();
const numHandledBlocks = await blocks.$updateBlocks();
const pollRate = config.MEMPOOL.POLL_RATE_MS * (indexer.indexerIsRunning() ? 10 : 1);
if (numHandledBlocks === 0) {

View File

@@ -10,6 +10,7 @@ import config from './config';
import auditReplicator from './replication/AuditReplication';
import statisticsReplicator from './replication/StatisticsReplication';
import AccelerationRepository from './repositories/AccelerationRepository';
import BlocksAuditsRepository from './repositories/BlocksAuditsRepository';
export interface CoreIndex {
name: string;
@@ -192,6 +193,7 @@ class Indexer {
await auditReplicator.$sync();
await statisticsReplicator.$sync();
await AccelerationRepository.$indexPastAccelerations();
await BlocksAuditsRepository.$migrateAuditsV0toV1();
// do not wait for classify blocks to finish
blocks.$classifyBlocks();
} catch (e) {

View File

@@ -29,9 +29,11 @@ export interface PoolStats extends PoolInfo {
}
export interface BlockAudit {
version: number,
time: number,
height: number,
hash: string,
unseenTxs: string[],
missingTxs: string[],
freshTxs: string[],
sigopTxs: string[],
@@ -126,6 +128,7 @@ export interface TransactionExtended extends IEsploraApi.Transaction {
acceleration?: boolean;
acceleratedBy?: number[];
acceleratedAt?: number;
feeDelta?: number;
replacement?: boolean;
uid?: number;
flags?: number;
@@ -223,6 +226,7 @@ export interface CpfpInfo {
sigops?: number;
adjustedVsize?: number,
acceleration?: boolean,
fee?: number;
}
export interface TransactionStripped {
@@ -381,8 +385,9 @@ export interface CpfpCluster {
}
export interface CpfpSummary {
transactions: TransactionExtended[];
transactions: MempoolTransactionExtended[];
clusters: CpfpCluster[];
version: number;
}
export interface Statistic {
@@ -448,7 +453,7 @@ export interface OptimizedStatistic {
export interface TxTrackingInfo {
replacedBy?: string,
position?: { block: number, vsize: number, accelerated?: boolean, acceleratedBy?: number[], acceleratedAt?: number },
position?: { block: number, vsize: number, accelerated?: boolean, acceleratedBy?: number[], acceleratedAt?: number, feeDelta?: number },
cpfp?: {
ancestors?: Ancestor[],
bestDescendant?: Ancestor | null,
@@ -461,6 +466,7 @@ export interface TxTrackingInfo {
accelerated?: boolean,
acceleratedBy?: number[],
acceleratedAt?: number,
feeDelta?: number,
confirmed?: boolean
}

View File

@@ -31,11 +31,11 @@ class AuditReplication {
const missingAudits = await this.$getMissingAuditBlocks();
logger.debug(`Fetching missing audit data for ${missingAudits.length} blocks from trusted servers`, 'Replication');
let totalSynced = 0;
let totalMissed = 0;
let loggerTimer = Date.now();
// process missing audits in batches of
// process missing audits in batches of BATCH_SIZE
for (let i = 0; i < missingAudits.length; i += BATCH_SIZE) {
const slice = missingAudits.slice(i, i + BATCH_SIZE);
const results = await Promise.all(slice.map(hash => this.$syncAudit(hash)));
@@ -109,9 +109,11 @@ class AuditReplication {
version: 1,
});
await blocksAuditsRepository.$saveAudit({
version: auditSummary.version || 0,
hash: blockHash,
height: auditSummary.height,
time: auditSummary.timestamp || auditSummary.time,
unseenTxs: auditSummary.unseenTxs || [],
missingTxs: auditSummary.missingTxs || [],
addedTxs: auditSummary.addedTxs || [],
prioritizedTxs: auditSummary.prioritizedTxs || [],

View File

@@ -1,4 +1,4 @@
import { AccelerationInfo, makeBlockTemplate } from '../api/acceleration/acceleration';
import { AccelerationInfo } from '../api/acceleration/acceleration';
import { RowDataPacket } from 'mysql2';
import DB from '../database';
import logger from '../logger';
@@ -11,6 +11,7 @@ import accelerationCosts from '../api/acceleration/acceleration';
import bitcoinApi from '../api/bitcoin/bitcoin-api-factory';
import transactionUtils from '../api/transaction-utils';
import { BlockExtended, MempoolTransactionExtended } from '../mempool.interfaces';
import { makeBlockTemplate } from '../api/mini-miner';
export interface PublicAcceleration {
txid: string,
@@ -191,6 +192,7 @@ class AccelerationRepository {
}
}
// modifies block transactions
public async $indexAccelerationsForBlock(block: BlockExtended, accelerations: Acceleration[], transactions: MempoolTransactionExtended[]): Promise<void> {
const blockTxs: { [txid: string]: MempoolTransactionExtended } = {};
for (const tx of transactions) {
@@ -212,6 +214,15 @@ class AccelerationRepository {
this.$saveAcceleration(accelerationInfo, block, block.extras.pool.id, successfulAccelerations);
}
}
let anyConfirmed = false;
for (const acc of accelerations) {
if (blockTxs[acc.txid]) {
anyConfirmed = true;
}
}
if (anyConfirmed) {
accelerationApi.accelerationConfirmed();
}
const lastSyncedHeight = await this.$getLastSyncedHeight();
// if we've missed any blocks, let the indexer catch up from the last synced height on the next run
if (block.height === lastSyncedHeight + 1) {

View File

@@ -1,13 +1,24 @@
import blocks from '../api/blocks';
import DB from '../database';
import logger from '../logger';
import { BlockAudit, AuditScore, TransactionAudit } from '../mempool.interfaces';
import bitcoinApi from '../api/bitcoin/bitcoin-api-factory';
import { BlockAudit, AuditScore, TransactionAudit, TransactionStripped } from '../mempool.interfaces';
interface MigrationAudit {
version: number,
height: number,
id: string,
timestamp: number,
prioritizedTxs: string[],
acceleratedTxs: string[],
template: TransactionStripped[],
transactions: TransactionStripped[],
}
class BlocksAuditRepositories {
public async $saveAudit(audit: BlockAudit): Promise<void> {
try {
await DB.query(`INSERT INTO blocks_audits(time, height, hash, missing_txs, added_txs, prioritized_txs, fresh_txs, sigop_txs, fullrbf_txs, accelerated_txs, match_rate, expected_fees, expected_weight)
VALUE (FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [audit.time, audit.height, audit.hash, JSON.stringify(audit.missingTxs),
await DB.query(`INSERT INTO blocks_audits(version, time, height, hash, unseen_txs, missing_txs, added_txs, prioritized_txs, fresh_txs, sigop_txs, fullrbf_txs, accelerated_txs, match_rate, expected_fees, expected_weight)
VALUE (?, FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [audit.version, audit.time, audit.height, audit.hash, JSON.stringify(audit.unseenTxs), JSON.stringify(audit.missingTxs),
JSON.stringify(audit.addedTxs), JSON.stringify(audit.prioritizedTxs), JSON.stringify(audit.freshTxs), JSON.stringify(audit.sigopTxs), JSON.stringify(audit.fullrbfTxs), JSON.stringify(audit.acceleratedTxs), audit.matchRate, audit.expectedFees, audit.expectedWeight]);
} catch (e: any) {
if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart
@@ -62,24 +73,30 @@ class BlocksAuditRepositories {
public async $getBlockAudit(hash: string): Promise<BlockAudit | null> {
try {
const [rows]: any[] = await DB.query(
`SELECT blocks_audits.height, blocks_audits.hash as id, UNIX_TIMESTAMP(blocks_audits.time) as timestamp,
template,
missing_txs as missingTxs,
added_txs as addedTxs,
prioritized_txs as prioritizedTxs,
fresh_txs as freshTxs,
sigop_txs as sigopTxs,
fullrbf_txs as fullrbfTxs,
accelerated_txs as acceleratedTxs,
match_rate as matchRate,
expected_fees as expectedFees,
expected_weight as expectedWeight
`SELECT
blocks_audits.version,
blocks_audits.height,
blocks_audits.hash as id,
UNIX_TIMESTAMP(blocks_audits.time) as timestamp,
template,
unseen_txs as unseenTxs,
missing_txs as missingTxs,
added_txs as addedTxs,
prioritized_txs as prioritizedTxs,
fresh_txs as freshTxs,
sigop_txs as sigopTxs,
fullrbf_txs as fullrbfTxs,
accelerated_txs as acceleratedTxs,
match_rate as matchRate,
expected_fees as expectedFees,
expected_weight as expectedWeight
FROM blocks_audits
JOIN blocks_templates ON blocks_templates.id = blocks_audits.hash
WHERE blocks_audits.hash = ?
`, [hash]);
if (rows.length) {
rows[0].unseenTxs = JSON.parse(rows[0].unseenTxs);
rows[0].missingTxs = JSON.parse(rows[0].missingTxs);
rows[0].addedTxs = JSON.parse(rows[0].addedTxs);
rows[0].prioritizedTxs = JSON.parse(rows[0].prioritizedTxs);
@@ -101,7 +118,7 @@ class BlocksAuditRepositories {
public async $getBlockTxAudit(hash: string, txid: string): Promise<TransactionAudit | null> {
try {
const blockAudit = await this.$getBlockAudit(hash);
if (blockAudit) {
const isAdded = blockAudit.addedTxs.includes(txid);
const isPrioritized = blockAudit.prioritizedTxs.includes(txid);
@@ -115,16 +132,17 @@ class BlocksAuditRepositories {
firstSeen = tx.time;
}
});
const wasSeen = blockAudit.version === 1 ? !blockAudit.unseenTxs.includes(txid) : (isExpected || isPrioritized || isAccelerated);
return {
seen: isExpected || isPrioritized || isAccelerated,
seen: wasSeen,
expected: isExpected,
added: isAdded,
added: isAdded && (blockAudit.version === 0 || !wasSeen),
prioritized: isPrioritized,
conflict: isConflict,
accelerated: isAccelerated,
firstSeen,
}
};
}
return null;
} catch (e: any) {
@@ -186,6 +204,96 @@ class BlocksAuditRepositories {
throw e;
}
}
/**
* [INDEXING] Migrate audits from v0 to v1
*/
public async $migrateAuditsV0toV1(): Promise<void> {
try {
let done = false;
let processed = 0;
let lastHeight;
while (!done) {
const [toMigrate]: MigrationAudit[][] = await DB.query(
`SELECT
blocks_audits.height as height,
blocks_audits.hash as id,
UNIX_TIMESTAMP(blocks_audits.time) as timestamp,
blocks_summaries.transactions as transactions,
blocks_templates.template as template,
blocks_audits.prioritized_txs as prioritizedTxs,
blocks_audits.accelerated_txs as acceleratedTxs
FROM blocks_audits
JOIN blocks_summaries ON blocks_summaries.id = blocks_audits.hash
JOIN blocks_templates ON blocks_templates.id = blocks_audits.hash
WHERE blocks_audits.version = 0
AND blocks_summaries.version = 2
ORDER BY blocks_audits.height DESC
LIMIT 100
`) as any[];
if (toMigrate.length <= 0 || lastHeight === toMigrate[0].height) {
done = true;
break;
}
lastHeight = toMigrate[0].height;
logger.info(`migrating ${toMigrate.length} audits to version 1`);
for (const audit of toMigrate) {
// unpack JSON-serialized transaction lists
audit.transactions = JSON.parse((audit.transactions as any as string) || '[]');
audit.template = JSON.parse((audit.template as any as string) || '[]');
// we know transactions in the template, or marked "prioritized" or "accelerated"
// were seen in our mempool before the block was mined.
const isSeen = new Set<string>();
for (const tx of audit.template) {
isSeen.add(tx.txid);
}
for (const txid of audit.prioritizedTxs) {
isSeen.add(txid);
}
for (const txid of audit.acceleratedTxs) {
isSeen.add(txid);
}
const unseenTxs = audit.transactions.slice(0).map(tx => tx.txid).filter(txid => !isSeen.has(txid));
// identify "prioritized" transactions
const prioritizedTxs: string[] = [];
let lastEffectiveRate = 0;
// Iterate over the mined template from bottom to top (excluding the coinbase)
// Transactions should appear in ascending order of mining priority.
for (let i = audit.transactions.length - 1; i > 0; i--) {
const blockTx = audit.transactions[i];
// If a tx has a lower in-band effective fee rate than the previous tx,
// it must have been prioritized out-of-band (in order to have a higher mining priority)
// so exclude from the analysis.
if ((blockTx.rate || 0) < lastEffectiveRate) {
prioritizedTxs.push(blockTx.txid);
} else {
lastEffectiveRate = blockTx.rate || 0;
}
}
// Update audit in the database
await DB.query(`
UPDATE blocks_audits SET
version = ?,
unseen_txs = ?,
prioritized_txs = ?
WHERE hash = ?
`, [1, JSON.stringify(unseenTxs), JSON.stringify(prioritizedTxs), audit.id]);
}
processed += toMigrate.length;
}
logger.info(`migrated ${processed} audits to version 1`);
} catch (e: any) {
logger.err(`Error while migrating audits from v0 to v1. Will try again later. Reason: ` + (e instanceof Error ? e.message : e));
}
}
}
export default new BlocksAuditRepositories();

View File

@@ -114,6 +114,43 @@ class BlocksSummariesRepository {
return [];
}
public async $getSummariesBelowVersion(version: number): Promise<{ height: number, id: string, version: number }[]> {
try {
const [rows]: any[] = await DB.query(`
SELECT
height,
id,
version
FROM blocks_summaries
WHERE version < ?
ORDER BY height DESC;`, [version]);
return rows;
} catch (e) {
logger.err(`Cannot get block summaries below version. Reason: ` + (e instanceof Error ? e.message : e));
}
return [];
}
public async $getTemplatesBelowVersion(version: number): Promise<{ height: number, id: string, version: number }[]> {
try {
const [rows]: any[] = await DB.query(`
SELECT
blocks_summaries.height as height,
blocks_templates.id as id,
blocks_templates.version as version
FROM blocks_templates
JOIN blocks_summaries ON blocks_templates.id = blocks_summaries.id
WHERE blocks_templates.version < ?
ORDER BY height DESC;`, [version]);
return rows;
} catch (e) {
logger.err(`Cannot get block summaries below version. Reason: ` + (e instanceof Error ? e.message : e));
}
return [];
}
/**
* Get the fee percentiles if the block has already been indexed, [] otherwise
*

View File

@@ -91,6 +91,26 @@ class CpfpRepository {
return;
}
public async $getClustersAt(height: number): Promise<CpfpCluster[]> {
const [clusterRows]: any = await DB.query(
`
SELECT *
FROM compact_cpfp_clusters
WHERE height = ?
`,
[height]
);
return clusterRows.map(cluster => {
if (cluster?.txs) {
cluster.effectiveFeePerVsize = cluster.fee_rate;
cluster.txs = this.unpack(cluster.txs);
return cluster;
} else {
return null;
}
}).filter(cluster => cluster !== null);
}
public async $deleteClustersFrom(height: number): Promise<void> {
logger.info(`Delete newer cpfp clusters from height ${height} from the database`);
try {
@@ -122,6 +142,37 @@ class CpfpRepository {
}
}
public async $deleteClustersAt(height: number): Promise<void> {
logger.info(`Delete cpfp clusters at height ${height} from the database`);
try {
const [rows] = await DB.query(
`
SELECT txs, height, root from compact_cpfp_clusters
WHERE height = ?
`,
[height]
) as RowDataPacket[][];
if (rows?.length) {
for (const clusterToDelete of rows) {
const txs = this.unpack(clusterToDelete?.txs);
for (const tx of txs) {
await transactionRepository.$removeTransaction(tx.txid);
}
}
}
await DB.query(
`
DELETE from compact_cpfp_clusters
WHERE height = ?
`,
[height]
);
} catch (e: any) {
logger.err(`Cannot delete cpfp clusters from db. Reason: ` + (e instanceof Error ? e.message : e));
throw e;
}
}
// insert a dummy row to mark that we've indexed as far as this block
public async $insertProgressMarker(height: number): Promise<void> {
try {
@@ -190,6 +241,32 @@ class CpfpRepository {
return [];
}
}
// returns `true` if two sets of CPFP clusters are deeply identical
public compareClusters(clustersA: CpfpCluster[], clustersB: CpfpCluster[]): boolean {
if (clustersA.length !== clustersB.length) {
return false;
}
clustersA = clustersA.sort((a,b) => a.root.localeCompare(b.root));
clustersB = clustersB.sort((a,b) => a.root.localeCompare(b.root));
for (let i = 0; i < clustersA.length; i++) {
if (clustersA[i].root !== clustersB[i].root) {
return false;
}
if (clustersA[i].txs.length !== clustersB[i].txs.length) {
return false;
}
for (let j = 0; j < clustersA[i].txs.length; j++) {
if (clustersA[i].txs[j].txid !== clustersB[i].txs[j].txid) {
return false;
}
}
}
return true;
}
}
export default new CpfpRepository();

View File

@@ -144,7 +144,7 @@ __REPLICATION_STATISTICS_START_TIME__=${REPLICATION_STATISTICS_START_TIME:=14819
__REPLICATION_SERVERS__=${REPLICATION_SERVERS:=[]}
# MEMPOOL_SERVICES
__MEMPOOL_SERVICES_API__=${MEMPOOL_SERVICES_API:=""}
__MEMPOOL_SERVICES_API__=${MEMPOOL_SERVICES_API:="https://mempool.space/api/v1/services"}
__MEMPOOL_SERVICES_ACCELERATIONS__=${MEMPOOL_SERVICES_ACCELERATIONS:=false}
# REDIS

View File

@@ -40,7 +40,8 @@ __MAINNET_BLOCK_AUDIT_START_HEIGHT__=${MAINNET_BLOCK_AUDIT_START_HEIGHT:=0}
__TESTNET_BLOCK_AUDIT_START_HEIGHT__=${TESTNET_BLOCK_AUDIT_START_HEIGHT:=0}
__SIGNET_BLOCK_AUDIT_START_HEIGHT__=${SIGNET_BLOCK_AUDIT_START_HEIGHT:=0}
__ACCELERATOR__=${ACCELERATOR:=false}
__SERVICES_API__=${SERVICES_API:=false}
__ACCELERATOR_BUTTON__=${ACCELERATOR_BUTTON:=true}
__SERVICES_API__=${SERVICES_API:=https://mempool.space/api/v1/services}
__PUBLIC_ACCELERATIONS__=${PUBLIC_ACCELERATIONS:=false}
__HISTORICAL_PRICE__=${HISTORICAL_PRICE:=true}
__ADDITIONAL_CURRENCIES__=${ADDITIONAL_CURRENCIES:=false}
@@ -70,6 +71,7 @@ export __MAINNET_BLOCK_AUDIT_START_HEIGHT__
export __TESTNET_BLOCK_AUDIT_START_HEIGHT__
export __SIGNET_BLOCK_AUDIT_START_HEIGHT__
export __ACCELERATOR__
export __ACCELERATOR_BUTTON__
export __SERVICES_API__
export __PUBLIC_ACCELERATIONS__
export __HISTORICAL_PRICE__

View File

@@ -543,16 +543,7 @@ describe('Mainnet', () => {
}
});
cy.get('.alert').should('be.visible');
cy.get('.alert').invoke('css', 'width').then((alertWidth) => {
cy.get('.container-xl > :nth-child(3)').invoke('css', 'width').should('equal', alertWidth);
});
cy.get('.btn-warning').then(getRectangle).then((rectA) => {
cy.get('.alert').then(getRectangle).then((rectB) => {
expect(areOverlapping(rectA, rectB), 'Confirmations box and RBF alert are overlapping').to.be.false;
});
});
cy.get('.alert-replaced').should('be.visible');
});
it('shows RBF transactions properly (desktop)', () => {

View File

@@ -750,7 +750,7 @@
},
"backendInfo": {
"hostname": "node205.tk7.mempool.space",
"version": "3.0.0-dev",
"version": "3.0.0-beta",
"gitCommit": "abbc8a134",
"lightning": false
},

View File

@@ -25,6 +25,7 @@
"HISTORICAL_PRICE": true,
"ADDITIONAL_CURRENCIES": false,
"ACCELERATOR": false,
"ACCELERATOR_BUTTON": true,
"PUBLIC_ACCELERATIONS": false,
"SERVICES_API": "https://mempool.space/api/v1/services"
}

View File

@@ -1,12 +1,12 @@
{
"name": "mempool-frontend",
"version": "3.0.0-dev",
"version": "3.0.0-beta",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "mempool-frontend",
"version": "3.0.0-dev",
"version": "3.0.0-beta",
"license": "GNU Affero General Public License v3.0",
"dependencies": {
"@angular-devkit/build-angular": "^17.3.1",

View File

@@ -1,6 +1,6 @@
{
"name": "mempool-frontend",
"version": "3.0.0-dev",
"version": "3.0.0-beta",
"description": "Bitcoin mempool visualizer and blockchain explorer backend",
"license": "GNU Affero General Public License v3.0",
"homepage": "https://mempool.space",

View File

@@ -9,6 +9,7 @@ import { StatusViewComponent } from './components/status-view/status-view.compon
import { AddressGroupComponent } from './components/address-group/address-group.component';
import { TrackerComponent } from './components/tracker/tracker.component';
import { AccelerateCheckout } from './components/accelerate-checkout/accelerate-checkout.component';
import { TrackerGuard } from './route-guards';
const browserWindow = window || {};
// @ts-ignore
@@ -140,16 +141,17 @@ let routes: Routes = [
loadChildren: () => import('./bitcoin-graphs.module').then(m => m.BitcoinGraphsModule),
data: { preload: true },
},
{
path: 'tx',
canMatch: [TrackerGuard],
runGuardsAndResolvers: 'always',
loadChildren: () => import('./components/tracker/tracker.module').then(m => m.TrackerModule),
},
{
path: '',
loadChildren: () => import('./master-page.module').then(m => m.MasterPageModule),
data: { preload: true },
},
{
path: 'tracker',
data: { networkSpecific: true },
loadChildren: () => import('./components/tracker/tracker.module').then(m => m.TrackerModule),
},
{
path: 'wallet',
children: [],
@@ -213,10 +215,6 @@ let routes: Routes = [
loadChildren: () => import('./bitcoin-graphs.module').then(m => m.BitcoinGraphsModule),
data: { preload: true },
},
{
path: '**',
redirectTo: ''
},
];
if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') {
@@ -301,13 +299,16 @@ if (browserWindowEnv && browserWindowEnv.BASE_MODULE === 'liquid') {
loadChildren: () => import('./liquid/liquid-graphs.module').then(m => m.LiquidGraphsModule),
data: { preload: true },
},
{
path: '**',
redirectTo: ''
},
];
}
if (!window['isMempoolSpaceBuild']) {
routes.push({
path: '**',
redirectTo: ''
});
}
@NgModule({
imports: [RouterModule.forRoot(routes, {
initialNavigation: 'enabledBlocking',

View File

@@ -151,7 +151,7 @@ export const languages: Language[] = [
{ code: 'fr', name: 'Français' }, // French
// { code: 'gl', name: 'Galego' }, // Galician
{ code: 'ko', name: '한국어' }, // Korean
// { code: 'hr', name: 'Hrvatski' }, // Croatian
{ code: 'hr', name: 'Hrvatski' }, // Croatian
// { code: 'id', name: 'Bahasa Indonesia' },// Indonesian
{ code: 'hi', name: 'हिन्दी' }, // Hindi
{ code: 'ne', name: 'नेपाली' }, // Nepalese

View File

@@ -389,16 +389,30 @@
</div>
}
</div>
@if (canPayWithCashapp) {
@if (canPayWithCashapp || canPayWithApplePay || canPayWithGooglePay) {
<div class="col-sm text-center flex-grow-0 d-flex flex-column justify-content-center align-items-center">
<p class="text-nowrap">&mdash;<span i18n="or">OR</span>&mdash;</p>
</div>
}
}
@if (canPayWithCashapp) {
@if (canPayWithCashapp || canPayWithApplePay || canPayWithGooglePay) {
<div class="col-sm text-center d-flex flex-column justify-content-center align-items-center">
<p><ng-container i18n="transaction.pay|Pay button label">Pay</ng-container>&nbsp;<app-fiat [value]="cost"></app-fiat> with</p>
<img class="paymentMethod mx-2" src="/resources/cash-app.svg" height=55 (click)="moveToStep('cashapp')">
@if (canPayWithCashapp) {
<img class="paymentMethod mx-2" style="width: 200px" src="/resources/cash-app.svg" height=55 (click)="moveToStep('cashapp')">
}
@if (canPayWithApplePay) {
@if (canPayWithCashapp) { <span class="mt-1 mb-1"></span> }
<div class="paymentMethod mx-2" style="width: 200px; height: 55px" (click)="moveToStep('applepay')">
<img src="/resources/apple-pay.png" height=37>
</div>
}
@if (canPayWithGooglePay) {
@if (canPayWithCashapp || canPayWithApplePay) { <span class="mt-1 mb-1"></span> }
<div class="paymentMethod mx-2" style="width: 200px; height: 55px" (click)="moveToStep('googlepay')">
<img src="/resources/google-pay.png" height=37>
</div>
}
</div>
}
</div>
@@ -421,9 +435,9 @@
<button type="button" class="mt-1 btn btn-secondary btn-sm rounded-pill align-self-center" style="width: 200px" (click)="moveToStep('summary')" i18n="go-back">Go back</button>
</div>
</div>
} @else if (step === 'cashapp') {
} @else if (step === 'cashapp' || step === 'applepay' || step === 'googlepay') {
<!-- Show checkout page -->
<div class="row mb-md-1 text-center">
<div class="row mb-md-1 text-center" id="confirm-title">
<div class="col-sm" id="confirm-payment-title">
<h1 style="font-size: larger;"><ng-content select="[slot='checkout-title']"></ng-content><span class="default-slot" i18n="accelerator.confirm-your-payment">Confirm your payment</span></h1>
</div>
@@ -437,7 +451,7 @@
</div>
</div>
@if (!loadingCashapp) {
@if (step === 'cashapp' && !loadingCashapp || step === 'applepay' && !loadingApplePay || step === 'googlepay' && !loadingGooglePay) {
<div class="row text-center mt-1">
<div class="col-sm">
<div class="form-group w-100">
@@ -456,8 +470,14 @@
<div class="row text-center mt-1">
<div class="col-sm">
<div class="form-group w-100">
<div id="cash-app-pay" class="d-inline-block" [style]="loadingCashapp ? 'opacity: 0; width: 0px; height: 0px; pointer-events: none;' : ''"></div>
@if (loadingCashapp) {
@if (step === 'applepay') {
<div id="apple-pay-button" class="apple-pay-button apple-pay-button-black" style="height: 50px" [style]="loadingApplePay ? 'opacity: 0; width: 0px; height: 0px; pointer-events: none;' : ''"></div>
} @else if (step === 'cashapp') {
<div id="cash-app-pay" class="d-inline-block" style="height: 50px" [style]="loadingCashapp ? 'opacity: 0; width: 0px; height: 0px; pointer-events: none;' : ''"></div>
} @else if (step === 'googlepay') {
<div id="google-pay-button" class="d-inline-block" style="height: 50px" [style]="loadingGooglePay ? 'opacity: 0; width: 0px; height: 0px; pointer-events: none;' : ''"></div>
}
@if (loadingCashapp || loadingApplePay || loadingGooglePay) {
<div display="d-flex flex-row justify-content-center">
<span i18n="accelerator.loading-payment-method">Loading payment method...</span>
<div class="ml-2 spinner-border text-light" style="width: 25px; height: 25px"></div>
@@ -505,7 +525,7 @@
<div class="col-sm">
<div class="d-flex flex-row flex-column justify-content-center align-items-center">
<span i18n="accelerator.confirming-acceleration-with-miners">Confirming your acceleration with our mining pool partners...</span>
@if (timeSincePaid > 20000) {
@if (timeSincePaid > 30000) {
<span i18n="accelerator.confirming-acceleration-with-miners">...sorry, this is taking longer than expected...</span>
}
<div class="m-2 spinner-border text-light" style="width: 25px; height: 25px"></div>
@@ -549,7 +569,7 @@
<button type="button" *ngIf="advancedEnabled" class="btn btn-sm btn-outline-info btn-small-height ml-2" (click)="moveToStep('quote')" i18n="accelerator.customize">customize</button>
</ng-template>
<ng-template #accelerateTo let-x i18n="accelerator.accelerate-to-x">Accelerate to ~{{ x | number : '1.0-0' }} sat/vB</ng-template>
<ng-template id="accelerate-to" #accelerateTo let-x i18n="accelerator.accelerate-to-x">Accelerate to ~{{ x | number : '1.0-0' }} sat/vB</ng-template>
<ng-template #accelerateButton>
<div class="position-relative">

View File

@@ -11,8 +11,7 @@
.paymentMethod {
padding: 10px;
background-color: var(--secondary);
border-radius: 15px;
border: 2px solid var(--bg);
border-radius: 10px;
cursor: pointer;
}
@@ -202,4 +201,19 @@
.btn-error-wrapper {
height: 26px;
}
.apple-pay-button {
display: inline-block;
-webkit-appearance: -apple-pay-button;
-apple-pay-button-type: plain; /* Use any supported button type. */
}
.apple-pay-button-black {
-apple-pay-button-style: black;
}
.apple-pay-button-white {
-apple-pay-button-style: white;
}
.apple-pay-button-white-with-line {
-apple-pay-button-style: white-outline;
}

View File

@@ -1,7 +1,8 @@
/* eslint-disable no-console */
import { Component, OnInit, OnDestroy, Output, EventEmitter, Input, ChangeDetectorRef, SimpleChanges, HostListener } from '@angular/core';
import { Subscription, tap, of, catchError, Observable, switchMap } from 'rxjs';
import { ServicesApiServices } from '../../services/services-api.service';
import { nextRoundNumber } from '../../shared/common.utils';
import { md5, insecureRandomUUID } from '../../shared/common.utils';
import { StateService } from '../../services/state.service';
import { AudioService } from '../../services/audio.service';
import { ETA, EtaService } from '../../services/eta.service';
@@ -9,8 +10,10 @@ import { Transaction } from '../../interfaces/electrs.interface';
import { MiningStats } from '../../services/mining.service';
import { IAuth, AuthServiceMempool } from '../../services/auth.service';
import { EnterpriseService } from '../../services/enterprise.service';
import { ApiService } from '../../services/api.service';
import { isDevMode } from '@angular/core';
export type PaymentMethod = 'balance' | 'bitcoin' | 'cashapp';
export type PaymentMethod = 'balance' | 'bitcoin' | 'cashapp' | 'applePay' | 'googlePay';
export type AccelerationEstimate = {
hasAccess: boolean;
@@ -23,7 +26,7 @@ export type AccelerationEstimate = {
mempoolBaseFee: number;
vsizeFee: number;
pools: number[];
availablePaymentMethods: {[method: string]: {min: number, max: number}};
availablePaymentMethods: Record<PaymentMethod, {min: number, max: number}>;
unavailable?: boolean;
options: { // recommended bid options
fee: number; // recommended userBid in sats
@@ -46,7 +49,7 @@ export const MIN_BID_RATIO = 1;
export const DEFAULT_BID_RATIO = 2;
export const MAX_BID_RATIO = 4;
type CheckoutStep = 'quote' | 'summary' | 'checkout' | 'cashapp' | 'processing' | 'paid' | 'success';
type CheckoutStep = 'quote' | 'summary' | 'checkout' | 'cashapp' | 'applepay' | 'googlepay' | 'processing' | 'paid' | 'success';
@Component({
selector: 'app-accelerate-checkout',
@@ -60,6 +63,8 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
@Input() eta: ETA;
@Input() scrollEvent: boolean;
@Input() cashappEnabled: boolean = true;
@Input() applePayEnabled: boolean = false;
@Input() googlePayEnabled: boolean = true;
@Input() advancedEnabled: boolean = false;
@Input() forceMobile: boolean = false;
@Input() showDetails: boolean = false;
@@ -78,17 +83,22 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
timePaid: number = 0; // time acceleration requested
math = Math;
isMobile: boolean = window.innerWidth <= 767.98;
isProdDomain = ['mempool.space',
'mempool-staging.va1.mempool.space',
'mempool-staging.fmt.mempool.space',
'mempool-staging.fra.mempool.space',
'mempool-staging.tk7.mempool.space',
'mempool-staging.sg1.mempool.space'
].indexOf(document.location.hostname) > -1;
private _step: CheckoutStep = 'summary';
simpleMode: boolean = true;
paymentMethod: 'cashapp' | 'btcpay';
timeoutTimer: any;
authSubscription$: Subscription;
auth: IAuth | null = null;
// accelerator stuff
square: { appId: string, locationId: string};
accelerationUUID: string;
accelerationSubscription: Subscription;
difficultySubscription: Subscription;
@@ -109,20 +119,22 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
// square
loadingCashapp = false;
cashappError = false;
cashappSubmit: any;
loadingApplePay = false;
loadingGooglePay = false;
payments: any;
cashAppPay: any;
cashAppSubscription: Subscription;
applePay: any;
googlePay: any;
conversionsSubscription: Subscription;
conversions: any;
conversions: Record<string, number>;
// btcpay
loadingBtcpayInvoice = false;
invoice = undefined;
constructor(
public stateService: StateService,
private apiService: ApiService,
private servicesApiService: ServicesApiServices,
private etaService: EtaService,
private audioService: AudioService,
@@ -130,10 +142,16 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
private authService: AuthServiceMempool,
private enterpriseService: EnterpriseService,
) {
this.accelerationUUID = window.crypto.randomUUID();
this.accelerationUUID = insecureRandomUUID();
// Check if Apple Pay available
// https://developer.apple.com/documentation/apple_pay_on_the_web/apple_pay_js_api/checking_for_apple_pay_availability#overview
if (window['ApplePaySession']) {
this.applePayEnabled = true;
}
}
ngOnInit() {
ngOnInit(): void {
this.authSubscription$ = this.authService.getAuth$().subscribe((auth) => {
if (this.auth?.user?.userId !== auth?.user?.userId) {
this.auth = auth;
@@ -158,13 +176,6 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.moveToStep('summary');
}
this.servicesApiService.setupSquare$().subscribe(ids => {
this.square = {
appId: ids.squareAppId,
locationId: ids.squareLocationId
};
});
this.conversionsSubscription = this.stateService.conversions$.subscribe(
async (conversions) => {
this.conversions = conversions;
@@ -172,7 +183,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
);
}
ngOnDestroy() {
ngOnDestroy(): void {
if (this.estimateSubscription) {
this.estimateSubscription.unsubscribe();
}
@@ -192,7 +203,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
}
}
moveToStep(step: CheckoutStep) {
moveToStep(step: CheckoutStep): void {
this._step = step;
if (this.timeoutTimer) {
clearTimeout(this.timeoutTimer);
@@ -201,6 +212,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.fetchEstimate();
}
if (this._step === 'checkout') {
this.insertSquare();
this.enterpriseService.goal(8);
}
if (this._step === 'checkout' && this.canPayWithBitcoin) {
@@ -210,15 +222,23 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.requestBTCPayInvoice();
} else if (this._step === 'cashapp' && this.cashappEnabled) {
this.loadingCashapp = true;
this.insertSquare();
this.setupSquare();
this.scrollToElementWithTimeout('confirm-title', 'center', 100);
} else if (this._step === 'applepay' && this.applePayEnabled) {
this.loadingApplePay = true;
this.setupSquare();
this.scrollToElementWithTimeout('confirm-title', 'center', 100);
} else if (this._step === 'googlepay' && this.googlePayEnabled) {
this.loadingGooglePay = true;
this.setupSquare();
this.scrollToElementWithTimeout('confirm-title', 'center', 100);
} else if (this._step === 'paid') {
this.timePaid = Date.now();
this.timeoutTimer = setTimeout(() => {
if (this.step === 'paid') {
this.accelerateError = 'internal_server_error';
}
}, 120000)
}, 120000);
}
this.hasDetails.emit(this._step === 'quote');
}
@@ -229,14 +249,14 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
}
/**
* Scroll to element id with or without setTimeout
*/
* Scroll to element id with or without setTimeout
*/
scrollToElementWithTimeout(id: string, position: ScrollLogicalPosition, timeout: number = 1000): void {
setTimeout(() => {
this.scrollToElement(id, position);
}, timeout);
}
scrollToElement(id: string, position: ScrollLogicalPosition) {
scrollToElement(id: string, position: ScrollLogicalPosition): void {
const acceleratePreviewAnchor = document.getElementById(id);
if (acceleratePreviewAnchor) {
this.cd.markForCheck();
@@ -251,7 +271,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
/**
* Accelerator
*/
fetchEstimate() {
fetchEstimate(): void {
if (this.estimateSubscription) {
this.estimateSubscription.unsubscribe();
}
@@ -315,7 +335,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
}
}),
catchError((response) => {
catchError(() => {
this.estimate = undefined;
this.quoteError = `cannot_accelerate_tx`;
this.estimateSubscription.unsubscribe();
@@ -370,10 +390,11 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
this.accelerationUUID
).subscribe({
next: () => {
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
this.audioService.playSound('ascend-chime-cartoon');
this.showSuccess = true;
this.estimateSubscription.unsubscribe();
this.moveToStep('paid')
this.moveToStep('paid');
},
error: (response) => {
this.accelerateError = response.error;
@@ -385,57 +406,248 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
* Square
*/
insertSquare(): void {
//@ts-ignore
if (window.Square) {
if (!this.isProdDomain && !isDevMode()) {
return;
}
if (window['Square']) {
return;
}
let statsUrl = 'https://sandbox.web.squarecdn.com/v1/square.js';
if (document.location.hostname === 'mempool-staging.fmt.mempool.space' ||
document.location.hostname === 'mempool-staging.va1.mempool.space' ||
document.location.hostname === 'mempool-staging.fra.mempool.space' ||
document.location.hostname === 'mempool-staging.tk7.mempool.space' ||
document.location.hostname === 'mempool.space') {
statsUrl = 'https://web.squarecdn.com/v1/square.js';
if (this.isProdDomain) {
statsUrl = '/square/v1/square.js';
}
(function() {
(function(): void {
const d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
// @ts-ignore
g.type='text/javascript'; g.src=statsUrl; s.parentNode.insertBefore(g, s);
})();
}
setupSquare() {
const init = () => {
setupSquare(): void {
if (!this.isProdDomain && !isDevMode()) {
return;
}
const init = (): void => {
this.initSquare();
};
//@ts-ignore
if (!window.Square) {
console.debug('Square.js failed to load properly. Retrying in 1 second.');
setTimeout(init, 1000);
if (!window['Square']) {
console.debug('Square.js failed to load properly. Retrying.');
setTimeout(this.setupSquare.bind(this), 100);
} else {
init();
}
}
async initSquare(): Promise<void> {
try {
//@ts-ignore
this.payments = window.Square.payments(this.square.appId, this.square.locationId)
await this.requestCashAppPayment();
this.servicesApiService.setupSquare$().subscribe({
next: async (ids) => {
this.payments = window['Square'].payments(ids.squareAppId, ids.squareLocationId);
const urlParams = new URLSearchParams(window.location.search);
if (this._step === 'cashapp' || urlParams.get('cash_request_id')) {
await this.requestCashAppPayment();
} else if (this._step === 'applepay') {
await this.requestApplePayPayment();
} else if (this._step === 'googlepay') {
await this.requestGooglePayPayment();
}
},
error: () => {
console.debug('Error loading Square Payments');
this.accelerateError = 'cannot_setup_square';
}
});
} catch (e) {
console.debug('Error loading Square Payments', e);
this.cashappError = true;
return;
this.accelerateError = 'cannot_setup_square';
}
}
async requestCashAppPayment() {
if (this.cashAppSubscription) {
this.cashAppSubscription.unsubscribe();
}
/**
* APPLE PAY
*/
async requestApplePayPayment(): Promise<void> {
if (this.conversionsSubscription) {
this.conversionsSubscription.unsubscribe();
}
this.conversionsSubscription = this.stateService.conversions$.subscribe(
async (conversions) => {
this.conversions = conversions;
if (this.applePay) {
this.applePay.destroy();
}
const costUSD = this.cost / 100_000_000 * conversions.USD;
const paymentRequest = this.payments.paymentRequest({
countryCode: 'US',
currencyCode: 'USD',
total: {
amount: costUSD.toFixed(2),
label: 'Total',
},
});
try {
this.applePay = await this.payments.applePay(paymentRequest);
const applePayButton = document.getElementById('apple-pay-button');
if (!applePayButton) {
console.error(`Unable to find apple pay button id='apple-pay-button'`);
// Try again
setTimeout(this.requestApplePayPayment.bind(this), 500);
return;
}
this.loadingApplePay = false;
applePayButton.addEventListener('click', async event => {
event.preventDefault();
const tokenResult = await this.applePay.tokenize();
if (tokenResult?.status === 'OK') {
const card = tokenResult.details?.card;
if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) {
console.error(`Cannot retreive payment card details`);
this.accelerateError = 'apple_pay_no_card_details';
return;
}
const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase());
this.servicesApiService.accelerateWithApplePay$(
this.tx.txid,
tokenResult.token,
cardTag,
`accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
this.accelerationUUID
).subscribe({
next: () => {
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
this.audioService.playSound('ascend-chime-cartoon');
if (this.applePay) {
this.applePay.destroy();
}
setTimeout(() => {
this.moveToStep('paid');
}, 1000);
},
error: (response) => {
this.accelerateError = response.error;
if (!(response.status === 403 && response.error === 'not_available')) {
setTimeout(() => {
// Reset everything by reloading the page :D, can be improved
const urlParams = new URLSearchParams(window.location.search);
window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``));
}, 3000);
}
}
});
} else {
let errorMessage = `Tokenization failed with status: ${tokenResult.status}`;
if (tokenResult.errors) {
errorMessage += ` and errors: ${JSON.stringify(
tokenResult.errors,
)}`;
}
throw new Error(errorMessage);
}
});
} catch (e) {
console.error(e);
}
}
);
}
/**
* GOOGLE PAY
*/
async requestGooglePayPayment(): Promise<void> {
if (this.conversionsSubscription) {
this.conversionsSubscription.unsubscribe();
}
this.conversionsSubscription = this.stateService.conversions$.subscribe(
async (conversions) => {
this.conversions = conversions;
if (this.googlePay) {
this.googlePay.destroy();
}
const costUSD = this.cost / 100_000_000 * conversions.USD;
const paymentRequest = this.payments.paymentRequest({
countryCode: 'US',
currencyCode: 'USD',
total: {
amount: costUSD.toFixed(2),
label: 'Total'
}
});
this.googlePay = await this.payments.googlePay(paymentRequest , {
referenceId: `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
});
await this.googlePay.attach(`#google-pay-button`, {
buttonType: 'pay',
buttonSizeMode: 'fill',
});
this.loadingGooglePay = false;
document.getElementById('google-pay-button').addEventListener('click', async event => {
event.preventDefault();
const tokenResult = await this.googlePay.tokenize();
if (tokenResult?.status === 'OK') {
const card = tokenResult.details?.card;
if (!card || !card.brand || !card.expMonth || !card.expYear || !card.last4) {
console.error(`Cannot retreive payment card details`);
this.accelerateError = 'apple_pay_no_card_details';
return;
}
const cardTag = md5(`${card.brand}${card.expMonth}${card.expYear}${card.last4}`.toLowerCase());
this.servicesApiService.accelerateWithGooglePay$(
this.tx.txid,
tokenResult.token,
cardTag,
`accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
this.accelerationUUID
).subscribe({
next: () => {
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
this.audioService.playSound('ascend-chime-cartoon');
if (this.googlePay) {
this.googlePay.destroy();
}
setTimeout(() => {
this.moveToStep('paid');
}, 1000);
},
error: (response) => {
this.accelerateError = response.error;
if (!(response.status === 403 && response.error === 'not_available')) {
setTimeout(() => {
// Reset everything by reloading the page :D, can be improved
const urlParams = new URLSearchParams(window.location.search);
window.location.assign(window.location.toString().replace(`?cash_request_id=${urlParams.get('cash_request_id')}`, ``));
}, 3000);
}
}
});
} else {
let errorMessage = `Tokenization failed with status: ${tokenResult.status}`;
if (tokenResult.errors) {
errorMessage += ` and errors: ${JSON.stringify(
tokenResult.errors,
)}`;
}
throw new Error(errorMessage);
}
});
}
);
}
/**
* CASHAPP
*/
async requestCashAppPayment(): Promise<void> {
if (this.conversionsSubscription) {
this.conversionsSubscription.unsubscribe();
}
this.conversionsSubscription = this.stateService.conversions$.subscribe(
async (conversions) => {
this.conversions = conversions;
@@ -449,41 +661,37 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
countryCode: 'US',
currencyCode: 'USD',
total: {
amount: costUSD.toString(),
amount: costUSD.toFixed(2),
label: 'Total',
pending: true,
productUrl: `${redirectHostname}/tracker/${this.tx.txid}`,
},
button: { shape: 'semiround', size: 'small', theme: 'light'}
productUrl: `${redirectHostname}/tx/${this.tx.txid}`,
}
});
this.cashAppPay = await this.payments.cashAppPay(paymentRequest, {
redirectURL: `${redirectHostname}/tracker/${this.tx.txid}`,
referenceId: `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`,
button: { shape: 'semiround', size: 'small', theme: 'light'}
redirectURL: `${redirectHostname}/tx/${this.tx.txid}`,
referenceId: `accelerator-${this.tx.txid.substring(0, 15)}-${Math.round(new Date().getTime() / 1000)}`
});
if (this.step === 'cashapp') {
await this.cashAppPay.attach(`#cash-app-pay`, { theme: 'light', size: 'small', shape: 'semiround' })
}
await this.cashAppPay.attach(`#cash-app-pay`, { theme: 'dark' });
this.loadingCashapp = false;
const that = this;
this.cashAppPay.addEventListener('ontokenization', function (event) {
this.cashAppPay.addEventListener('ontokenization', event => {
const { tokenResult, error } = event.detail;
if (error) {
this.accelerateError = error;
} else if (tokenResult.status === 'OK') {
that.servicesApiService.accelerateWithCashApp$(
that.tx.txid,
this.servicesApiService.accelerateWithCashApp$(
this.tx.txid,
tokenResult.token,
tokenResult.details.cashAppPay.cashtag,
tokenResult.details.cashAppPay.referenceId,
that.accelerationUUID
this.accelerationUUID
).subscribe({
next: () => {
that.audioService.playSound('ascend-chime-cartoon');
if (that.cashAppPay) {
that.cashAppPay.destroy();
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
this.audioService.playSound('ascend-chime-cartoon');
if (this.cashAppPay) {
this.cashAppPay.destroy();
}
setTimeout(() => {
this.moveToStep('paid');
@@ -494,7 +702,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
}, 1000);
},
error: (response) => {
that.accelerateError = response.error;
this.accelerateError = response.error;
if (!(response.status === 403 && response.error === 'not_available')) {
setTimeout(() => {
// Reset everything by reloading the page :D, can be improved
@@ -513,7 +721,7 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
/**
* BTCPay
*/
async requestBTCPayInvoice() {
async requestBTCPayInvoice(): Promise<void> {
this.servicesApiService.generateBTCPayAcceleratorInvoice$(this.tx.txid, this.userBid).pipe(
switchMap(response => {
return this.servicesApiService.retreiveInvoice$(response.btcpayInvoiceId);
@@ -530,9 +738,10 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
}
bitcoinPaymentCompleted(): void {
this.apiService.logAccelerationRequest$(this.tx.txid).subscribe();
this.audioService.playSound('ascend-chime-cartoon');
this.estimateSubscription.unsubscribe();
this.moveToStep('paid')
this.moveToStep('paid');
}
isLoggedIn(): boolean {
@@ -542,47 +751,61 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
/**
* UI events
*/
selectedOptionChanged(event) {
selectedOptionChanged(event): void {
this.selectedOption = event.target.id;
}
get step() {
get step(): CheckoutStep {
return this._step;
}
get paymentMethods() {
return Object.keys(this.estimate?.availablePaymentMethods || {});
get paymentMethods(): PaymentMethod[] {
return Object.keys(this.estimate?.availablePaymentMethods || {}) as PaymentMethod[];
}
get couldPayWithBitcoin() {
get couldPayWithBitcoin(): boolean {
return !!this.estimate?.availablePaymentMethods?.bitcoin;
}
get couldPayWithCashapp() {
get couldPayWithCashapp(): boolean {
if (!this.cashappEnabled) {
return false;
}
return !!this.estimate?.availablePaymentMethods?.cashapp;
}
get couldPayWithBalance() {
get couldPayWithApplePay(): boolean {
if (!this.applePayEnabled) {
return false;
}
return !!this.estimate?.availablePaymentMethods?.applePay;
}
get couldPayWithGooglePay(): boolean {
if (!this.googlePayEnabled) {
return false;
}
return !!this.estimate?.availablePaymentMethods?.googlePay;
}
get couldPayWithBalance(): boolean {
if (!this.hasAccessToBalanceMode) {
return false;
}
return !!this.estimate?.availablePaymentMethods?.balance;
}
get couldPay() {
return this.couldPayWithBalance || this.couldPayWithBitcoin || this.couldPayWithCashapp;
get couldPay(): boolean {
return this.couldPayWithBalance || this.couldPayWithBitcoin || this.couldPayWithCashapp || this.couldPayWithApplePay || this.couldPayWithGooglePay;
}
get canPayWithBitcoin() {
get canPayWithBitcoin(): boolean {
const paymentMethod = this.estimate?.availablePaymentMethods?.bitcoin;
return paymentMethod && this.cost >= paymentMethod.min && this.cost <= paymentMethod.max;
}
get canPayWithCashapp() {
if (!this.cashappEnabled || !this.conversions) {
get canPayWithCashapp(): boolean {
if (!this.cashappEnabled || !this.conversions || (!this.isProdDomain && !isDevMode())) {
return false;
}
@@ -593,11 +816,43 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
return true;
}
}
return false;
}
get canPayWithBalance() {
get canPayWithApplePay(): boolean {
if (!this.applePayEnabled || !this.conversions || (!this.isProdDomain && !isDevMode())) {
return false;
}
const paymentMethod = this.estimate?.availablePaymentMethods?.applePay;
if (paymentMethod) {
const costUSD = (this.cost / 100_000_000 * this.conversions.USD);
if (costUSD >= paymentMethod.min && costUSD <= paymentMethod.max) {
return true;
}
}
return false;
}
get canPayWithGooglePay(): boolean {
if (!this.googlePayEnabled || !this.conversions || (!this.isProdDomain && !isDevMode())) {
return false;
}
const paymentMethod = this.estimate?.availablePaymentMethods?.googlePay;
if (paymentMethod) {
const costUSD = (this.cost / 100_000_000 * this.conversions.USD);
if (costUSD >= paymentMethod.min && costUSD <= paymentMethod.max) {
return true;
}
}
return false;
}
get canPayWithBalance(): boolean {
if (!this.hasAccessToBalanceMode) {
return false;
}
@@ -605,11 +860,11 @@ export class AccelerateCheckout implements OnInit, OnDestroy {
return paymentMethod && this.cost >= paymentMethod.min && this.cost <= paymentMethod.max && this.cost <= this.estimate?.userBalance;
}
get canPay() {
return this.canPayWithBalance || this.canPayWithBitcoin || this.canPayWithCashapp;
get canPay(): boolean {
return this.canPayWithBalance || this.canPayWithBitcoin || this.canPayWithCashapp || this.canPayWithApplePay || this.canPayWithGooglePay;
}
get hasAccessToBalanceMode() {
get hasAccessToBalanceMode(): boolean {
return this.isLoggedIn() && this.estimate?.hasAccess;
}

View File

@@ -0,0 +1,62 @@
<div
#tooltip
*ngIf="accelerationInfo && tooltipPosition !== null"
class="acceleration-tooltip"
[style.left]="tooltipPosition.x + 'px'"
[style.top]="tooltipPosition.y + 'px'"
>
<table>
<tbody>
<tr>
<td class="label" i18n="transaction.status|Transaction Status">Status</td>
<td class="value">
@if (accelerationInfo.status === 'seen') {
<span class="badge badge-primary" i18n="transaction.first-seen|Transaction first seen">First seen</span>
} @else if (accelerationInfo.status === 'accelerated') {
<span class="badge badge-accelerated" i18n="transaction.audit.accelerated">Accelerated</span>
} @else if (accelerationInfo.status === 'mined') {
<span class="badge badge-success" i18n="transaction.rbf.mined">Mined</span>
}
</td>
</tr>
<tr *ngIf="accelerationInfo.fee">
<td class="label" i18n="transaction.fee|Transaction fee">Fee</td>
<td class="value">{{ accelerationInfo.fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span></td>
</tr>
<tr *ngIf="accelerationInfo.bidBoost >= 0 || accelerationInfo.feeDelta">
<td class="label" i18n="transaction.out-of-band-fees">Out-of-band fees</td>
@if (accelerationInfo.status === 'accelerated') {
<td class="value oobFees">{{ accelerationInfo.feeDelta | number }} <span class="symbol" i18n="shared.sat|sat">sat</span></td>
} @else {
<td class="value oobFees">{{ accelerationInfo.bidBoost | number }} <span class="symbol" i18n="shared.sat|sat">sat</span></td>
}
</tr>
<tr *ngIf="accelerationInfo.fee && accelerationInfo.weight">
@if (accelerationInfo.status === 'seen') {
<td class="label" i18n="transaction.fee-rate|Transaction fee rate">Fee rate</td>
<td class="value"><app-fee-rate [fee]="accelerationInfo.fee" [weight]="accelerationInfo.weight"></app-fee-rate></td>
} @else if (accelerationInfo.status === 'accelerated' || accelerationInfo.status === 'mined') {
<td class="label" i18n="transaction.accelerated-fee-rate|Accelerated transaction fee rate">Accelerated fee rate</td>
@if (accelerationInfo.status === 'accelerated') {
<td class="value oobFees"><app-fee-rate [fee]="accelerationInfo.fee + (accelerationInfo.feeDelta || 0)" [weight]="accelerationInfo.weight"></app-fee-rate></td>
} @else {
<td class="value oobFees"><app-fee-rate [fee]="accelerationInfo.fee + (accelerationInfo.bidBoost || 0)" [weight]="accelerationInfo.weight"></app-fee-rate></td>
}
}
</tr>
<tr *ngIf="['accelerated', 'mined'].includes(accelerationInfo.status) && hasPoolsData()">
<td class="label" i18n="transaction.accelerated-by-hashrate|Accelerated to hashrate">Accelerated by</td>
<td class="value" *ngIf="accelerationInfo.pools">
<ng-container *ngFor="let pool of accelerationInfo.pools">
<img *ngIf="accelerationInfo.poolsData[pool]"
class="pool-logo"
[style.opacity]="accelerationInfo?.minedByPoolUniqueId && pool !== accelerationInfo?.minedByPoolUniqueId ? '0.3' : '1'"
[src]="'/resources/mining-pools/' + accelerationInfo.poolsData[pool].slug + '.svg'"
onError="this.src = '/resources/mining-pools/default.svg'"
[alt]="'Logo of ' + pool.name + ' mining pool'">
</ng-container>
</td>
</tr>
</tbody>
</table>
</div>

View File

@@ -0,0 +1,39 @@
.acceleration-tooltip {
position: fixed;
z-index: 3;
background: color-mix(in srgb, var(--active-bg) 95%, transparent);
border-radius: 4px;
box-shadow: 1px 1px 10px rgba(0,0,0,0.5);
color: var(--tooltip-grey);
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 10px 15px;
text-align: left;
pointer-events: none;
.badge.badge-accelerated {
background-color: var(--tertiary);
color: white;
}
.value {
text-align: end;
}
.label {
padding-right: 30px;
}
.pool-logo {
width: 22px;
height: 22px;
position: relative;
top: -1px;
margin-right: 3px;
}
.oobFees {
color: #905cf4;
}
}

View File

@@ -0,0 +1,38 @@
import { Component, ElementRef, ViewChild, Input, OnChanges } from '@angular/core';
@Component({
selector: 'app-acceleration-timeline-tooltip',
templateUrl: './acceleration-timeline-tooltip.component.html',
styleUrls: ['./acceleration-timeline-tooltip.component.scss'],
})
export class AccelerationTimelineTooltipComponent implements OnChanges {
@Input() accelerationInfo: any;
@Input() cursorPosition: { x: number, y: number };
tooltipPosition: any = null;
@ViewChild('tooltip') tooltipElement: ElementRef<HTMLCanvasElement>;
constructor() {}
ngOnChanges(changes): void {
if (changes.cursorPosition && changes.cursorPosition.currentValue) {
let x = Math.max(10, changes.cursorPosition.currentValue.x - 50);
let y = changes.cursorPosition.currentValue.y + 20;
if (this.tooltipElement) {
const elementBounds = this.tooltipElement.nativeElement.getBoundingClientRect();
if ((x + elementBounds.width) > (window.innerWidth - 10)) {
x = Math.max(0, window.innerWidth - elementBounds.width - 10);
}
if (y + elementBounds.height > (window.innerHeight - 20)) {
y = y - elementBounds.height - 20;
}
}
this.tooltipPosition = { x, y };
}
}
hasPoolsData(): boolean {
return Object.keys(this.accelerationInfo.poolsData).length > 0;
}
}

View File

@@ -26,7 +26,7 @@
<div class="node" [id]="'confirmed'">
<div class="acc-to-confirmed left go-faster"></div>
<div class="shape-border waiting">
<div class="shape animate"></div>
<div class="shape"></div>
</div>
<div class="status"><span class="badge badge-waiting" i18n="transaction.rbf.mined">Mined</span></div>
</div>
@@ -58,7 +58,7 @@
<div class="nodes">
<div class="node" [id]="'first-seen'">
<div class="seen-to-acc right"></div>
<div class="shape-border">
<div class="shape-border hovering" (pointerover)="onHover($event, 'seen');" (pointerout)="onBlur($event);">
<div class="shape"></div>
</div>
<div class="status"><span class="badge badge-primary" i18n="transaction.first-seen|Transaction first seen">First seen</span></div>
@@ -80,7 +80,7 @@
} @else {
<div class="seen-to-acc right"></div>
}
<div class="shape-border">
<div class="shape-border hovering" (pointerover)="onHover($event, 'accelerated');" (pointerout)="onBlur($event);">
<div class="shape"></div>
@if (!tx.status.confirmed) {
<div class="connector down loading"></div>
@@ -89,14 +89,14 @@
@if (tx.status.confirmed) {
<div class="status"><span class="badge badge-accelerated" i18n="transaction.audit.accelerated">Accelerated</span></div>
}
<div class="time offset-left" [class.no-margin]="!tx.status.confirmed">
<div class="time" [class.no-margin]="!tx.status.confirmed" [class.offset-left]="!tx.status.confirmed">
@if (!tx.status.confirmed) {
<span i18n="transaction.audit.accelerated">Accelerated</span>{{ "" }}
}
@if (useAbsoluteTime) {
<span>{{ acceleratedAt * 1000 | date }}</span>
} @else {
<app-time kind="since" [time]="acceleratedAt" [lowercaseStart]="true"></app-time>
<app-time kind="since" [time]="acceleratedAt" [lowercaseStart]="!tx.status.confirmed"></app-time>
}
</div>
</div>
@@ -113,7 +113,10 @@
} @else {
<div class="seen-to-acc left"></div>
}
<div class="shape-border" [class.waiting]="!tx.status.confirmed">
<div class="shape-border"
[ngClass]="{'waiting': !tx.status.confirmed, 'hovering': tx.status.confirmed}"
(pointerover)="onHover($event, tx.status.confirmed ? 'mined' : null)"
(pointerout)="onBlur($event);">
<div class="shape"></div>
</div>
@if (tx.status.confirmed) {
@@ -130,4 +133,10 @@
</div>
</div>
</div>
<app-acceleration-timeline-tooltip
[accelerationInfo]="hoverInfo"
[cursorPosition]="tooltipPosition"
></app-acceleration-timeline-tooltip>
</div>

View File

@@ -152,9 +152,16 @@
margin-bottom: -8px;
transform: translateY(-50%);
border-radius: 50%;
cursor: pointer;
padding: 4px;
background: transparent;
transition: background-color 300ms, padding 300ms;
&.hovering {
cursor: pointer;
&:hover {
padding: 0px;
}
}
.shape {
position: relative;

View File

@@ -1,6 +1,8 @@
import { Component, Input, OnInit, OnChanges } from '@angular/core';
import { Component, Input, OnInit, OnChanges, HostListener } from '@angular/core';
import { ETA } from '../../services/eta.service';
import { Transaction } from '../../interfaces/electrs.interface';
import { Acceleration, SinglePoolStats } from '../../interfaces/node-api.interface';
import { MiningService } from '../../services/mining.service';
@Component({
selector: 'app-acceleration-timeline',
@@ -10,6 +12,7 @@ import { Transaction } from '../../interfaces/electrs.interface';
export class AccelerationTimelineComponent implements OnInit, OnChanges {
@Input() transactionTime: number;
@Input() tx: Transaction;
@Input() accelerationInfo: Acceleration;
@Input() eta: ETA;
// A mined transaction has standard ETA and accelerated ETA undefined
// A transaction in mempool has either standardETA defined (if accelerated) or acceleratedETA defined (if not accelerated yet)
@@ -22,13 +25,25 @@ export class AccelerationTimelineComponent implements OnInit, OnChanges {
useAbsoluteTime: boolean = false;
interval: number;
constructor() {}
tooltipPosition = null;
hoverInfo: any = null;
poolsData: { [id: number]: SinglePoolStats } = {};
constructor(
private miningService: MiningService,
) {}
ngOnInit(): void {
this.acceleratedAt = this.tx.acceleratedAt ?? new Date().getTime() / 1000;
this.now = Math.floor(new Date().getTime() / 1000);
this.useAbsoluteTime = this.tx.status.block_time < this.now - 7 * 24 * 3600;
this.miningService.getPools().subscribe(pools => {
for (const pool of pools) {
this.poolsData[pool.unique_id] = pool;
}
});
this.interval = window.setInterval(() => {
this.now = Math.floor(new Date().getTime() / 1000);
this.useAbsoluteTime = this.tx.status.block_time < this.now - 7 * 24 * 3600;
@@ -52,4 +67,42 @@ export class AccelerationTimelineComponent implements OnInit, OnChanges {
ngOnDestroy(): void {
clearInterval(this.interval);
}
onHover(event, status: string): void {
if (status === 'seen') {
this.hoverInfo = {
status,
fee: this.tx.fee,
weight: this.tx.weight
};
} else if (status === 'accelerated') {
this.hoverInfo = {
status,
fee: this.accelerationInfo?.effectiveFee || this.tx.fee,
weight: this.tx.weight,
feeDelta: this.accelerationInfo?.feeDelta || this.tx.feeDelta,
pools: this.tx.acceleratedBy || this.accelerationInfo?.pools,
poolsData: this.poolsData
};
} else if (status === 'mined') {
this.hoverInfo = {
status,
fee: this.accelerationInfo?.effectiveFee,
weight: this.tx.weight,
bidBoost: this.accelerationInfo?.bidBoost,
minedByPoolUniqueId: this.accelerationInfo?.minedByPoolUniqueId,
pools: this.tx.acceleratedBy || this.accelerationInfo?.pools,
poolsData: this.poolsData
};
}
}
onBlur(event): void {
this.hoverInfo = null;
}
@HostListener('pointermove', ['$event'])
onPointerMove(event) {
this.tooltipPosition = { x: event.clientX, y: event.clientY };
}
}

View File

@@ -23,7 +23,7 @@ import { RelativeUrlPipe } from '../../../shared/pipes/relative-url/relative-url
position: absolute;
top: 50%;
left: calc(50% - 15px);
z-index: 100;
z-index: 99;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -4,8 +4,8 @@
<div class="clearfix"></div>
<div class="acceleration-list" *ngIf="accelerationList$ | async as accelerations">
<table *ngIf="!accelerations || accelerations.length; else noData" class="table table-borderless table-fixed">
<div class="acceleration-list">
<table *ngIf="nonEmptyAccelerations; else noData" class="table table-borderless table-fixed">
<thead>
<th class="txid text-left" i18n="dashboard.latest-transactions.txid">TXID</th>
<ng-container *ngIf="pending">
@@ -16,11 +16,12 @@
<ng-container *ngIf="!pending">
<th class="fee text-right" i18n="transaction.bid-boost|Bid Boost">Bid Boost</th>
<th class="block text-right" i18n="shared.block-title">Block</th>
<th class="pool text-right" i18n="mining.pool-name" *ngIf="!this.widget">Pool</th>
<th class="status text-right" i18n="transaction.status|Transaction Status">Status</th>
<th class="date text-right" i18n="accelerator.requested" *ngIf="!this.widget">Requested</th>
</ng-container>
</thead>
<tbody *ngIf="accelerations; else skeleton" [style]="isLoading ? 'opacity: 0.75' : ''">
<tbody *ngIf="accelerationList$ | async as accelerations; else skeleton" [style]="isLoading ? 'opacity: 0.75' : ''">
<tr *ngFor="let acceleration of accelerations; let i= index;">
<td class="txid text-left">
<a [routerLink]="['/tx' | relativeUrl, acceleration.txid]">
@@ -49,10 +50,21 @@
<a *ngIf="acceleration.blockHeight" [routerLink]="['/block' | relativeUrl, acceleration.blockHeight]">{{ acceleration.blockHeight }}</a>
<span *ngIf="!acceleration.blockHeight">~</span>
</td>
<td class="pool text-right" *ngIf="!this.widget">
@if (acceleration.minedByPoolUniqueId && pools[acceleration.minedByPoolUniqueId]) {
<a placement="bottom" [routerLink]="['/mining/pool' | relativeUrl, pools[acceleration.minedByPoolUniqueId].slug]" class="badge" style="color: #FFF;padding:0;">
<img class="pool-logo" [src]="'/resources/mining-pools/' + pools[acceleration.minedByPoolUniqueId].slug + '.svg'" onError="this.src = '/resources/mining-pools/default.svg'" [alt]="'Logo of ' + pools[acceleration.minedByPoolUniqueId].name + ' mining pool'">
{{ pools[acceleration.minedByPoolUniqueId].name }}
</a>
} @else {
~
}
</td>
<td class="status text-right">
<span *ngIf="acceleration.status === 'accelerating'" class="badge badge-warning" i18n="accelerator.pending">Pending</span>
<span *ngIf="acceleration.status.includes('completed')" class="badge badge-success" i18n="">Completed <span *ngIf="acceleration.status === 'completed_provisional'">🔄</span></span>
<span *ngIf="acceleration.status.includes('failed')" class="badge badge-danger" i18n="accelerator.canceled">Failed <span *ngIf="acceleration.status === 'failed_provisional'">🔄</span></span>
<span *ngIf="acceleration.status.includes('completed') && acceleration.minedByPoolUniqueId && pools[acceleration.minedByPoolUniqueId]" class="badge badge-success"><ng-container i18n="accelerator.completed">Completed</ng-container><span *ngIf="acceleration.status === 'completed_provisional'">&nbsp;</span></span>
<span *ngIf="acceleration.status.includes('completed') && (!acceleration.minedByPoolUniqueId || !pools[acceleration.minedByPoolUniqueId])" class="badge badge-success"><ng-container i18n="transaction.rbf.mined">Mined</ng-container><span *ngIf="acceleration.status === 'completed_provisional'">&nbsp;</span></span>
<span *ngIf="acceleration.status.includes('failed')" class="badge badge-danger"><ng-container i18n="accelerator.canceled">Failed</ng-container><span *ngIf="acceleration.status === 'failed_provisional'">&nbsp;</span></span>
</td>
<td class="date text-right" *ngIf="!this.widget">
<app-time kind="since" [time]="acceleration.added" [fastRender]="true" [showTooltip]="true"></app-time>
@@ -61,22 +73,47 @@
</tr>
</tbody>
<ng-template #skeleton>
<tbody>
<tr *ngFor="let item of skeletonLines">
<td class="txid text-left">
<span class="skeleton-loader" style="max-width: 75px"></span>
</td>
<td class="fee text-right">
<span class="skeleton-loader" style="max-width: 75px"></span>
</td>
<td class="fee-delta text-right">
<span class="skeleton-loader" style="max-width: 75px"></span>
</td>
<td class="status text-right">
<span class="skeleton-loader" style="max-width: 75px"></span>
</td>
</tr>
</tbody>
@if (!pending) {
<tbody>
<tr *ngFor="let item of skeletonLines">
<td class="txid text-left">
<span class="skeleton-loader" style="max-width: 200px"></span>
</td>
<td class="fee text-right">
<span class="skeleton-loader" style="max-width: 100px"></span>
</td>
<td class="block text-right">
<span class="skeleton-loader" style="max-width: 100px"></span>
</td>
<td class="pool text-right" *ngIf="!this.widget">
<span class="skeleton-loader" style="max-width: 100px"></span>
</td>
<td class="status text-right">
<span class="skeleton-loader" style="max-width: 100px"></span>
</td>
<td class="date text-right" *ngIf="!this.widget">
<span class="skeleton-loader" style="max-width: 100px"></span>
</td>
</tr>
</tbody>
} @else {
<tbody>
<tr *ngFor="let item of skeletonLines">
<td class="txid text-left">
<span class="skeleton-loader" style="max-width: 100px"></span>
</td>
<td class="fee-rate text-right">
<span class="skeleton-loader" style="max-width: 100px"></span>
</td>
<td class="bid text-right">
<span class="skeleton-loader" style="max-width: 100px"></span>
</td>
<td class="time text-right">
<span class="skeleton-loader" style="max-width: 100px"></span>
</td>
</tr>
</tbody>
}
</ng-template>
</table>

View File

@@ -12,7 +12,7 @@
padding-bottom: 0px;
}
.container-xl.legacy {
max-width: 1140px;
max-width: 1200px;
}
.container-xl.widget-container {
min-height: 335px;
@@ -72,9 +72,25 @@ tr, td, th {
.block {
width: 15%;
@media (max-width: 900px) {
display: none;
}
}
.pool {
width: 15%;
@media (max-width: 700px) {
display: none;
}
.pool-logo {
width: 18px;
height: 18px;
position: relative;
top: -1px;
margin-right: 2px;
}
}
.status {

View File

@@ -1,11 +1,12 @@
import { Component, OnInit, ChangeDetectionStrategy, Input, ChangeDetectorRef, OnDestroy, Inject, LOCALE_ID } from '@angular/core';
import { BehaviorSubject, Observable, Subscription, catchError, filter, of, switchMap, tap, throttleTime } from 'rxjs';
import { Acceleration, BlockExtended } from '../../../interfaces/node-api.interface';
import { Acceleration, BlockExtended, SinglePoolStats } from '../../../interfaces/node-api.interface';
import { StateService } from '../../../services/state.service';
import { WebsocketService } from '../../../services/websocket.service';
import { ServicesApiServices } from '../../../services/services-api.service';
import { SeoService } from '../../../services/seo.service';
import { ActivatedRoute, Router } from '@angular/router';
import { MiningService } from '../../../services/mining.service';
@Component({
selector: 'app-accelerations-list',
@@ -30,11 +31,14 @@ export class AccelerationsListComponent implements OnInit, OnDestroy {
keyNavigationSubscription: Subscription;
dir: 'rtl' | 'ltr' = 'ltr';
paramSubscription: Subscription;
pools: { [id: number]: SinglePoolStats } = {};
nonEmptyAccelerations: boolean = true;
constructor(
private servicesApiService: ServicesApiServices,
private websocketService: WebsocketService,
public stateService: StateService,
private miningService: MiningService,
private cd: ChangeDetectorRef,
private seoService: SeoService,
private route: ActivatedRoute,
@@ -47,6 +51,12 @@ export class AccelerationsListComponent implements OnInit, OnDestroy {
}
ngOnInit(): void {
this.miningService.getPools().subscribe(pools => {
for (const pool of pools) {
this.pools[pool.unique_id] = pool;
}
});
if (!this.widget) {
this.websocketService.want(['blocks']);
this.seoService.setTitle($localize`:@@02573b6980a2d611b4361a2595a4447e390058cd:Accelerations`);
@@ -106,6 +116,7 @@ export class AccelerationsListComponent implements OnInit, OnDestroy {
for (const acc of accelerations) {
acc.boost = acc.boostCost != null ? acc.boostCost : acc.bidBoost;
}
this.nonEmptyAccelerations = accelerations.length > 0;
if (this.widget) {
return of(accelerations.slice(0, 6));
} else {

View File

@@ -1,7 +1,7 @@
@if (chartOnly) {
<ng-container *ngTemplateOutlet="pieChart"></ng-container>
} @else {
<table>
<table style="width: 100%;">
<tbody>
<tr>
<td class="td-width field-label" [class]="chartPositionLeft ? 'chart-left' : ''" i18n="transaction.accelerated-to-feerate|Accelerated to feerate">Accelerated to</td>
@@ -11,9 +11,9 @@
<td class="field-value" [class]="chartPositionLeft ? 'chart-left' : ''">
<div class="effective-fee-container">
@if (accelerationInfo?.acceleratedFeeRate && (!tx.effectiveFeePerVsize || accelerationInfo.acceleratedFeeRate >= tx.effectiveFeePerVsize)) {
<app-fee-rate [fee]="accelerationInfo.acceleratedFeeRate"></app-fee-rate>
<app-fee-rate class="oobFees" [fee]="accelerationInfo.acceleratedFeeRate"></app-fee-rate>
} @else {
<app-fee-rate [fee]="tx.effectiveFeePerVsize"></app-fee-rate>
<app-fee-rate class="oobFees" [fee]="tx.effectiveFeePerVsize"></app-fee-rate>
}
</div>
</td>

View File

@@ -61,4 +61,8 @@
& > div, & > div > svg {
overflow: visible !important;
}
}
.oobFees {
color: #905cf4;
}

View File

@@ -67,12 +67,13 @@ export class ActiveAccelerationBox implements OnChanges {
const acceleratingPools = (poolList || []).filter(id => pools[id]).sort((a,b) => pools[a].lastEstimatedHashrate - pools[b].lastEstimatedHashrate);
const totalAcceleratedHashrate = acceleratingPools.reduce((total, pool) => total + pools[pool].lastEstimatedHashrate, 0);
const lightenStep = acceleratingPools.length ? (0.48 / acceleratingPools.length) : 0;
acceleratingPools.forEach((poolId, index) => {
const pool = pools[poolId];
const poolShare = ((pool.lastEstimatedHashrate / this.miningStats.lastEstimatedHashrate) * 100).toFixed(1);
data.push(getDataItem(
pool.lastEstimatedHashrate,
toRGB(lighten({ r: 147, g: 57, b: 244 }, index * .08)),
toRGB(lighten({ r: 147, g: 57, b: 244 }, index * lightenStep)),
`<b style="color: white">${pool.name} (${poolShare}%)</b>`,
true,
) as PieSeriesOption);

View File

@@ -0,0 +1,5 @@
<div class="sparkles" #sparkleAnchor>
<div *ngFor="let sparkle of sparkles" class="sparkle" [style]="sparkle.style">
<span class="inner-sparkle" [style]="sparkle.rotation">+</span>
</div>
</div>

View File

@@ -0,0 +1,45 @@
.sparkles {
position: absolute;
top: var(--block-size);
height: 50px;
right: 0;
}
.sparkle {
position: absolute;
color: rgba(152, 88, 255, 0.75);
opacity: 0;
transform: scale(0.8) rotate(0deg);
animation: pop ease 2000ms forwards, sparkle ease 500ms infinite;
}
.inner-sparkle {
display: block;
}
@keyframes pop {
0% {
transform: scale(0.8) rotate(0deg);
opacity: 0;
}
20% {
transform: scale(1) rotate(72deg);
opacity: 1;
}
100% {
transform: scale(0) rotate(360deg);
opacity: 0;
}
}
@keyframes sparkle {
0% {
color: rgba(152, 88, 255, 0.75);
}
50% {
color: rgba(198, 162, 255, 0.75);
}
100% {
color: rgba(152, 88, 255, 0.75);
}
}

View File

@@ -0,0 +1,73 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Input, OnChanges, SimpleChanges, ViewChild } from '@angular/core';
@Component({
selector: 'app-acceleration-sparkles',
templateUrl: './acceleration-sparkles.component.html',
styleUrls: ['./acceleration-sparkles.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AccelerationSparklesComponent implements OnChanges {
@Input() arrow: ElementRef<HTMLDivElement>;
@Input() run: boolean = false;
@ViewChild('sparkleAnchor')
sparkleAnchor: ElementRef<HTMLDivElement>;
constructor(
private cd: ChangeDetectorRef,
) {}
endTimeout: any;
lastSparkle: number = 0;
sparkleWidth: number = 0;
sparkles: any[] = [];
ngOnChanges(changes: SimpleChanges): void {
if (changes.run) {
if (this.endTimeout) {
clearTimeout(this.endTimeout);
this.endTimeout = null;
}
if (this.run) {
this.doSparkle();
} else {
this.endTimeout = setTimeout(() => {
this.sparkles = [];
}, 2000);
}
}
}
doSparkle(): void {
if (this.run) {
const now = performance.now();
if (now - this.lastSparkle > 20) {
this.lastSparkle = now;
if (this.arrow?.nativeElement && this.sparkleAnchor?.nativeElement) {
const anchor = this.sparkleAnchor.nativeElement.getBoundingClientRect().right;
const right = this.arrow.nativeElement.getBoundingClientRect().right;
const dx = (anchor - right) + 30;
const numSparkles = Math.ceil(Math.random() * 3);
for (let i = 0; i < numSparkles; i++) {
this.sparkles.push({
style: {
right: (dx + (Math.random() * 10)) + 'px',
top: (15 + (Math.random() * 30)) + 'px',
},
rotation: {
transform: `rotate(${Math.random() * 360}deg)`,
}
});
}
while (this.sparkles.length > 200) {
this.sparkles.shift();
}
this.cd.markForCheck();
}
}
requestAnimationFrame(() => {
this.doSparkle();
});
}
}
}

View File

@@ -30,7 +30,7 @@ const periodSeconds = {
position: absolute;
top: 50%;
left: calc(50% - 15px);
z-index: 100;
z-index: 99;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -201,7 +201,7 @@
<span i18n="address.error.loading-address-data">Error loading address data.</span>
<br>
<ng-container i18n="Electrum server limit exceeded error">
<i>There many transactions on this address, more than your backend can handle. See more on <a href="/docs/faq#address-lookup-issues">setting up a stronger backend</a>.</i>
<i>There are too many transactions on this address, more than your backend can handle. See more on <a href="/docs/faq#address-lookup-issues">setting up a stronger backend</a>.</i>
<br><br>
Consider viewing this address on the official Mempool website instead:
</ng-container>
@@ -249,7 +249,7 @@
</ng-template>
<ng-template #pendingBalanceRow>
<td i18n="address.unconfirmed-balance" class="font-italic">Unconfirmed balance</td>
<td i18n="accelerator.pending-state" class="font-italic">Pending</td>
<td *ngIf="mempoolStats.funded_txo_sum !== undefined; else confidentialTd" class="font-italic wrap-cell"><app-amount [satoshis]="mempoolStats.balance" [noFiat]="true" [addPlus]="true"></app-amount> <span class="fiat"><app-fiat [value]="mempoolStats.balance"></app-fiat></span></td>
</ng-template>
@@ -259,7 +259,7 @@
</ng-template>
<ng-template #pendingUtxoRow>
<td i18n="address.unconfirmed-utxos" class="font-italic">Unconfirmed UTXOs</td>
<td i18n="address.pending-utxos" class="font-italic">Pending UTXOs</td>
<td class="font-italic wrap-cell">{{ mempoolStats.utxos > 0 ? '+' : ''}}{{ mempoolStats.utxos }}</td>
</ng-template>

View File

@@ -3,7 +3,7 @@ import { FormBuilder, FormGroup } from '@angular/forms';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import { ActivatedRoute } from '@angular/router';
import { Subscription, of, timer } from 'rxjs';
import { retry, switchMap, tap } from 'rxjs/operators';
import { filter, repeat, retry, switchMap, take, tap } from 'rxjs/operators';
import { ServicesApiServices } from '../../services/services-api.service';
@Component({
@@ -73,11 +73,11 @@ export class BitcoinInvoiceComponent implements OnInit, OnChanges, OnDestroy {
this.paymentStatus = 4;
}
this.paymentStatusSubscription = this.apiService.getPaymentStatus$(this.invoice.btcpayInvoiceId).pipe(
retry({ delay: () => timer(2000)})
).subscribe((response) => {
if (response.status === 204 || response.status === 404) {
return;
}
retry({ delay: () => timer(2000)}),
repeat({delay: 2000}),
filter((response) => response.status !== 204 && response.status !== 404),
take(1),
).subscribe(() => {
this.paymentStatus = 3;
this.completed.emit();
});

View File

@@ -23,7 +23,7 @@ import { ActivatedRoute, Router } from '@angular/router';
position: absolute;
top: 50%;
left: calc(50% - 15px);
z-index: 100;
z-index: 99;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -23,7 +23,7 @@ import { StateService } from '../../services/state.service';
position: absolute;
top: 50%;
left: calc(50% - 15px);
z-index: 100;
z-index: 99;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -24,7 +24,7 @@ import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pi
position: absolute;
top: 50%;
left: calc(50% - 15px);
z-index: 100;
z-index: 99;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -1,6 +1,5 @@
<div class="block-filters" [class.filters-active]="activeFilters.length > 0" [class.any-mode]="filterMode === 'or'" [class.menu-open]="menuOpen" [class.small]="cssWidth < 500" [class.vsmall]="cssWidth < 400" [class.tiny]="cssWidth < 200">
<a *ngIf="menuOpen" [routerLink]="['/docs/faq' | relativeUrl]" fragment="how-do-mempool-goggles-work" class="info-badges" i18n-ngbTooltip="Mempool Goggles&trade; tooltip" ngbTooltip="select filter categories to highlight matching transactions">
<span class="badge badge-pill badge-warning beta" i18n="beta">beta</span>
<a *ngIf="menuOpen" [routerLink]="['/docs/faq' | relativeUrl]" fragment="how-do-mempool-goggles-work" class="info-badges float-right" i18n-ngbTooltip="Mempool Goggles&trade; tooltip" ngbTooltip="select filter categories to highlight matching transactions">
<fa-icon [icon]="['fas', 'info-circle']" [fixedWidth]="true" size="lg"></fa-icon>
</a>
<div class="filter-bar">

View File

@@ -24,7 +24,6 @@
display: flex;
flex-direction: row;
align-items: center;
float: right;
&:hover, &:active {
text-decoration: none;

View File

@@ -21,7 +21,7 @@ import { StateService } from '../../services/state.service';
position: absolute;
top: 50%;
left: calc(50% - 15px);
z-index: 100;
z-index: 99;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -2,10 +2,12 @@
<div class="graph-alignment" [class.grid-align]="!autofit" [style.gridTemplateColumns]="'repeat(auto-fit, ' + resolution + 'px)'">
<div class="block-overview-graph">
<canvas *browserOnly class="block-overview-canvas" [class.clickable]="!!hoverTx" #blockCanvas></canvas>
<div class="loader-wrapper" [class.hidden]="(!isLoading || disableSpinner) && !unavailable">
<div *ngIf="!unavailable" class="spinner-border ml-3 loading" role="status"></div>
<div *ngIf="!isLoading && unavailable" class="ml-3" i18n="block.not-available">not available</div>
</div>
@if (!disableSpinner) {
<div class="loader-wrapper" [class.hidden]="!isLoading && !unavailable">
<div *ngIf="!unavailable" class="spinner-border ml-3 loading" role="status"></div>
<div *ngIf="!isLoading && unavailable" class="ml-3" i18n="block.not-available">not available</div>
</div>
}
<app-block-overview-tooltip
[tx]="selectedTx || hoverTx"
[cursorPosition]="tooltipPosition"

View File

@@ -18,6 +18,7 @@ const unmatchedAuditColors = {
censored: setOpacity(defaultAuditColors.censored, unmatchedOpacity),
missing: setOpacity(defaultAuditColors.missing, unmatchedOpacity),
added: setOpacity(defaultAuditColors.added, unmatchedOpacity),
added_prioritized: setOpacity(defaultAuditColors.added_prioritized, unmatchedOpacity),
prioritized: setOpacity(defaultAuditColors.prioritized, unmatchedOpacity),
accelerated: setOpacity(defaultAuditColors.accelerated, unmatchedOpacity),
};
@@ -25,6 +26,7 @@ const unmatchedContrastAuditColors = {
censored: setOpacity(contrastAuditColors.censored, unmatchedOpacity),
missing: setOpacity(contrastAuditColors.missing, unmatchedOpacity),
added: setOpacity(contrastAuditColors.added, unmatchedOpacity),
added_prioritized: setOpacity(contrastAuditColors.added_prioritized, unmatchedOpacity),
prioritized: setOpacity(contrastAuditColors.prioritized, unmatchedOpacity),
accelerated: setOpacity(contrastAuditColors.accelerated, unmatchedOpacity),
};

View File

@@ -33,7 +33,7 @@ export default class TxView implements TransactionStripped {
flags: number;
bigintFlags?: bigint | null = 0b00000100_00000000_00000000_00000000n;
time?: number;
status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'freshcpfp' | 'added' | 'prioritized' | 'censored' | 'selected' | 'rbf' | 'accelerated';
status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'freshcpfp' | 'added' | 'added_prioritized' | 'prioritized' | 'censored' | 'selected' | 'rbf' | 'accelerated';
context?: 'projected' | 'actual';
scene?: BlockScene;

View File

@@ -71,6 +71,7 @@ export const defaultAuditColors = {
censored: hexToColor('f344df'),
missing: darken(desaturate(hexToColor('f344df'), 0.3), 0.7),
added: hexToColor('0099ff'),
added_prioritized: darken(desaturate(hexToColor('0099ff'), 0.15), 0.85),
prioritized: darken(desaturate(hexToColor('0099ff'), 0.3), 0.7),
accelerated: hexToColor('8f5ff6'),
};
@@ -101,6 +102,7 @@ export const contrastAuditColors = {
censored: hexToColor('ffa8ff'),
missing: darken(desaturate(hexToColor('ffa8ff'), 0.3), 0.7),
added: hexToColor('00bb98'),
added_prioritized: darken(desaturate(hexToColor('00bb98'), 0.15), 0.85),
prioritized: darken(desaturate(hexToColor('00bb98'), 0.3), 0.7),
accelerated: hexToColor('8f5ff6'),
};
@@ -136,6 +138,8 @@ export function defaultColorFunction(
return auditColors.missing;
case 'added':
return auditColors.added;
case 'added_prioritized':
return auditColors.added_prioritized;
case 'prioritized':
return auditColors.prioritized;
case 'selected':

View File

@@ -51,7 +51,7 @@
<tr *ngIf="hasEffectiveRate && effectiveRate != null">
<td *ngIf="!this.acceleration" class="label" i18n="transaction.effective-fee-rate|Effective transaction fee rate">Effective fee rate</td>
<td *ngIf="this.acceleration" class="label" i18n="transaction.accelerated-fee-rate|Accelerated transaction fee rate">Accelerated fee rate</td>
<td class="value">
<td class="value" [class.oobFees]="this.acceleration">
<app-fee-rate [fee]="effectiveRate"></app-fee-rate>
</td>
</tr>
@@ -75,6 +75,10 @@
<span *ngSwitchCase="'freshcpfp'" class="badge badge-warning" i18n="transaction.audit.recently-cpfped">Recently CPFP'd</span>
<span *ngSwitchCase="'added'" class="badge badge-warning" i18n="tx-features.tag.added|Added">Added</span>
<span *ngSwitchCase="'prioritized'" class="badge badge-warning" i18n="tx-features.tag.prioritized|Prioritized">Prioritized</span>
<ng-container *ngSwitchCase="'added_prioritized'">
<span class="badge badge-warning" i18n="tx-features.tag.added|Added">Added</span>
<span class="badge badge-warning ml-1" i18n="tx-features.tag.prioritized|Prioritized">Prioritized</span>
</ng-container>
<span *ngSwitchCase="'selected'" class="badge badge-warning" i18n="transaction.audit.marginal">Marginal fee rate</span>
<span *ngSwitchCase="'rbf'" class="badge badge-warning" i18n="tx-features.tag.conflict|Conflict">Conflict</span>
<span *ngSwitchCase="'accelerated'" class="badge badge-accelerated" i18n="transaction.audit.accelerated">Accelerated</span>

View File

@@ -27,6 +27,9 @@ th, td {
width: 70%;
text-align: end;
}
&.oobFees {
color: #905cf4;
}
}
.badge.badge-accelerated {

View File

@@ -68,7 +68,7 @@ export class BlockOverviewTooltipComponent implements OnChanges {
this.effectiveRate = this.tx.rate;
const txFlags = BigInt(this.tx.flags) || 0n;
this.acceleration = this.tx.acc || (txFlags & TransactionFlags.acceleration);
this.hasEffectiveRate = this.tx.acc || Math.abs((this.fee / this.vsize) - this.effectiveRate) > 0.05
this.hasEffectiveRate = this.tx.acc || !(Math.abs((this.fee / this.vsize) - this.effectiveRate) <= 0.1 && Math.abs((this.fee / Math.ceil(this.vsize)) - this.effectiveRate) <= 0.1)
|| (txFlags && (txFlags & (TransactionFlags.cpfp_child | TransactionFlags.cpfp_parent)) > 0n);
this.filters = this.tx.flags ? toFilters(txFlags).filter(f => f.tooltip) : [];
this.activeFilters = {}

View File

@@ -23,7 +23,7 @@ import { StateService } from '../../services/state.service';
position: absolute;
top: 50%;
left: calc(50% - 15px);
z-index: 100;
z-index: 99;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -21,7 +21,7 @@ import { StateService } from '../../services/state.service';
position: absolute;
top: 50%;
left: calc(50% - 15px);
z-index: 100;
z-index: 99;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -521,6 +521,7 @@ export class BlockComponent implements OnInit, OnDestroy {
if (transactions && blockAudit) {
const inTemplate = {};
const inBlock = {};
const isUnseen = {};
const isAdded = {};
const isPrioritized = {};
const isCensored = {};
@@ -543,6 +544,9 @@ export class BlockComponent implements OnInit, OnDestroy {
for (const tx of transactions) {
inBlock[tx.txid] = true;
}
for (const txid of blockAudit.unseenTxs || []) {
isUnseen[txid] = true;
}
for (const txid of blockAudit.addedTxs) {
isAdded[txid] = true;
}
@@ -592,18 +596,27 @@ export class BlockComponent implements OnInit, OnDestroy {
tx.status = 'accelerated';
}
}
for (const [index, tx] of transactions.entries()) {
let anySeen = false;
for (let index = transactions.length - 1; index >= 0; index--) {
const tx = transactions[index];
tx.context = 'actual';
if (index === 0) {
tx.status = null;
} else if (isAdded[tx.txid]) {
tx.status = 'added';
} else if (isPrioritized[tx.txid]) {
tx.status = 'prioritized';
if (isAdded[tx.txid] || (blockAudit.version > 0 && isUnseen[tx.txid])) {
tx.status = 'added_prioritized';
} else {
tx.status = 'prioritized';
}
} else if (isAdded[tx.txid] && (blockAudit.version === 0 || isUnseen[tx.txid])) {
tx.status = 'added';
} else if (inTemplate[tx.txid]) {
anySeen = true;
tx.status = 'found';
} else if (isRbf[tx.txid]) {
tx.status = 'rbf';
} else if (isUnseen[tx.txid] && anySeen) {
tx.status = 'added';
} else {
tx.status = 'selected';
isSelected[tx.txid] = true;

View File

@@ -15,7 +15,7 @@ import { StateService } from '../../services/state.service';
position: absolute;
top: 50%;
left: calc(50% - 15px);
z-index: 100;
z-index: 99;
}
`],
})

View File

@@ -23,7 +23,7 @@ import { seoDescriptionNetwork } from '../../shared/common.utils';
position: absolute;
top: 50%;
left: calc(50% - 15px);
z-index: 100;
z-index: 99;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -59,7 +59,7 @@
position: absolute;
top: 50%;
left: calc(50% - 15px);
z-index: 100;
z-index: 99;
}
.loadingGraphs.widget {
top: 75%;

View File

@@ -28,7 +28,7 @@ interface Hashrate {
position: absolute;
top: 50%;
left: calc(50% - 15px);
z-index: 100;
z-index: 99;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -17,7 +17,7 @@ const OUTLIERS_MEDIAN_MULTIPLIER = 4;
position: absolute;
top: 50%;
left: calc(50% - 16px);
z-index: 100;
z-index: 99;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -11,7 +11,7 @@ import { StateService } from '../../services/state.service';
position: absolute;
top: 50%;
left: calc(50% - 16px);
z-index: 100;
z-index: 99;
}
`],
templateUrl: './lbtc-pegs-graph.component.html',

View File

@@ -52,7 +52,7 @@
<a [href]="env.MEMPOOL_WEBSITE_URL + urlLanguage + (networkPaths['mainnet'] || '')" ngbDropdownItem class="mainnet"><app-svg-images name="bitcoin" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Mainnet</a>
<a [href]="env.MEMPOOL_WEBSITE_URL + urlLanguage + (networkPaths['signet'] || '/signet')" ngbDropdownItem *ngIf="env.SIGNET_ENABLED" class="signet"><app-svg-images name="signet" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Signet</a>
<a [href]="env.MEMPOOL_WEBSITE_URL + urlLanguage + (networkPaths['testnet'] || '/testnet')" ngbDropdownItem *ngIf="env.TESTNET_ENABLED" class="testnet"><app-svg-images name="testnet" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Testnet3</a>
<a [href]="env.MEMPOOL_WEBSITE_URL + urlLanguage + (networkPaths['testnet4'] || '/testnet4')" ngbDropdownItem *ngIf="env.TESTNET4_ENABLED" class="testnet"><app-svg-images name="testnet4" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Testnet4 <span class="badge badge-pill badge-warning beta-network" i18n="beta">beta</span></a>
<a [href]="env.MEMPOOL_WEBSITE_URL + urlLanguage + (networkPaths['testnet4'] || '/testnet4')" ngbDropdownItem *ngIf="env.TESTNET4_ENABLED" class="testnet"><app-svg-images name="testnet4" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Testnet4</a>
<h6 class="dropdown-header" i18n="master-page.layer2-networks-header">Layer 2 Networks</h6>
<a ngbDropdownItem class="liquid mr-1" [class.active]="network.val === 'liquid'" [routerLink]="networkPaths['liquid'] || '/'"><app-svg-images name="liquid" width="22" height="22" viewBox="0 0 125 125" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Liquid</a>
<a ngbDropdownItem *ngIf="env.LIQUID_TESTNET_ENABLED" class="liquidtestnet" [class.active]="network.val === 'liquidtestnet'" [routerLink]="networkPaths['liquidtestnet'] || '/testnet'"><app-svg-images name="liquidtestnet" width="22" height="22" viewBox="0 0 125 125" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Liquid Testnet</a>

View File

@@ -2,5 +2,5 @@
position: absolute;
top: 50%;
left: calc(50% - 16px);
z-index: 100;
z-index: 99;
}

View File

@@ -1,7 +1,7 @@
.sticky-loading {
position: absolute;
right: 10px;
z-index: 100;
z-index: 99;
font-size: 14px;
@media (width >= 992px) {
left: 32px;

View File

@@ -70,7 +70,7 @@
<a ngbDropdownItem *ngIf="env.MAINNET_ENABLED" class="mainnet" [routerLink]="networkPaths['mainnet'] || '/'"><app-svg-images name="bitcoin" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Mainnet</a>
<a ngbDropdownItem *ngIf="env.SIGNET_ENABLED" class="signet" [class.active]="network.val === 'signet'" [routerLink]="networkPaths['signet'] || '/signet'"><app-svg-images name="signet" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Signet</a>
<a ngbDropdownItem *ngIf="env.TESTNET_ENABLED" class="testnet" [class.active]="network.val === 'testnet'" [routerLink]="networkPaths['testnet'] || '/testnet'"><app-svg-images name="testnet" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Testnet3</a>
<a ngbDropdownItem *ngIf="env.TESTNET4_ENABLED" class="testnet4" [class.active]="network.val === 'testnet4'" [routerLink]="networkPaths['testnet4'] || '/testnet4'"><app-svg-images name="testnet4" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Testnet4 <span class="badge badge-pill badge-warning beta-network" i18n="beta">beta</span></a>
<a ngbDropdownItem *ngIf="env.TESTNET4_ENABLED" class="testnet4" [class.active]="network.val === 'testnet4'" [routerLink]="networkPaths['testnet4'] || '/testnet4'"><app-svg-images name="testnet4" width="22" height="22" viewBox="0 0 65 65" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Testnet4</a>
<a [href]="env.LIQUID_WEBSITE_URL + urlLanguage + (networkPaths['liquid'] || '')" ngbDropdownItem *ngIf="env.LIQUID_ENABLED" class="liquid" [class.active]="network.val === 'liquid'"><app-svg-images name="liquid" width="22" height="22" viewBox="0 0 125 125" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Liquid</a>
<a [href]="env.LIQUID_WEBSITE_URL + urlLanguage + (networkPaths['liquidtestnet'] || '/testnet')" ngbDropdownItem *ngIf="env.LIQUID_TESTNET_ENABLED" class="liquidtestnet" [class.active]="network.val === 'liquid'"><app-svg-images name="liquidtestnet" width="22" height="22" viewBox="0 0 125 125" style="width: 25px; height: 25px;" class="mainnet mr-1"></app-svg-images> Liquid Testnet</a>
</div>

View File

@@ -51,7 +51,8 @@
</div>
</ng-template>
</div>
<div *ngIf="arrowVisible" id="arrow-up" [ngStyle]="{'right': rightPosition + (blockWidth * 0.3) + containerOffset + 'px', transition: transition }" [class.blink]="txPosition?.accelerated"></div>
<app-acceleration-sparkles [style]="{ position: 'absolute', right: 0}" [arrow]="arrowElement" [run]="acceleratingArrow"></app-acceleration-sparkles>
<div *ngIf="arrowVisible" #arrowUp id="arrow-up" [ngStyle]="{'right': rightPosition + (blockWidth * 0.3) + containerOffset + 'px', transition: transition }" [class.blink]="txPosition?.accelerated"></div>
</div>
</ng-container>

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef, HostListener, Input, OnChanges, SimpleChanges, Output, EventEmitter } from '@angular/core';
import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef, HostListener, Input, OnChanges, SimpleChanges, Output, EventEmitter, ViewChild, ElementRef } from '@angular/core';
import { Subscription, Observable, of, combineLatest } from 'rxjs';
import { MempoolBlock } from '../../interfaces/websocket.interface';
import { StateService } from '../../services/state.service';
@@ -77,6 +77,9 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy {
maxArrowPosition = 0;
rightPosition = 0;
transition = 'background 2s, right 2s, transform 1s';
@ViewChild('arrowUp')
arrowElement: ElementRef<HTMLDivElement>;
acceleratingArrow: boolean = false;
markIndex: number;
txPosition: MempoolPosition;
@@ -201,6 +204,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy {
this.markBlocksSubscription = this.stateService.markBlock$
.subscribe((state) => {
const oldTxPosition = this.txPosition;
this.markIndex = undefined;
this.txPosition = undefined;
this.txFeePerVSize = undefined;
@@ -209,6 +213,12 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy {
}
if (state.mempoolPosition) {
this.txPosition = state.mempoolPosition;
if (this.txPosition.accelerated && !oldTxPosition.accelerated) {
this.acceleratingArrow = true;
setTimeout(() => {
this.acceleratingArrow = false;
}, 2000);
}
}
if (state.txFeePerVSize) {
this.txFeePerVSize = state.txFeePerVSize;

View File

@@ -18,7 +18,7 @@ import { download, formatterXAxis, formatterXAxisLabel } from '../../shared/grap
position: absolute;
top: 50%;
left: calc(50% - 16px);
z-index: 100;
z-index: 99;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -60,7 +60,7 @@
position: absolute;
top: 50%;
left: calc(50% - 15px);
z-index: 100;
z-index: 99;
}
.pool-distribution {

View File

@@ -167,7 +167,7 @@ div.scrollable {
.loadingGraphs {
position: absolute;
left: calc(50% - 15px);
z-index: 100;
z-index: 99;
top: 475px;
@media (max-width: 992px) {
top: 600px;

View File

@@ -5,7 +5,7 @@
<br><br>
<h2>Privacy Policy</h2>
<h6>Updated: July 10, 2024</h6>
<h6>Updated: July 31, 2024</h6>
<br><br>
@@ -27,7 +27,7 @@
<br>
<h4>General</h4>
<h4>USING THIS WEBSITE</h4>
<p *ngIf="officialMempoolSpace">Out of respect for the Bitcoin community, this Website does not use any third-party analytics, third-party trackers, or third-party cookies, and we do not share any private user data with third-parties. Additionally, to mitigate the risk of surveillance by malicious third-parties, we self-host this Website on our own hardware and network infrastructure, so there are no "hosting companies" or "cloud providers" involved with the operation of this Website.</p>
@@ -35,7 +35,7 @@
<ul>
<li>We use basic webserver logging (nginx) for sysadmin purposes, which collects your IP address along with the requests you make. These logs are deleted after 10 days, and we do not share this data with any third-party. To conceal your IP address from our webserver logs, we recommend that you use Tor Browser with our Tor v3 hidden service onion hostname.</li>
<li>We use basic webserver logging (nginx) for sysadmin purposes, which collects your IP address along with the requests you make. These logs are deleted after 10 days, and we do not share this data with any third-party.</li>
<br>
@@ -49,7 +49,7 @@
<p *ngIf="officialMempoolSpace">If you use Mempool Accelerator&trade; your acceleration request will be sent to us and relayed to Mempool's mining pool partners. We will store the TXID of the transactions you accelerate with us. We share this information with our mining pool partners, and publicly display accelerated transaction details on our website and APIs. No personal information or account identifiers will be shared with any third party including mining pool partners.</p>
<p *ngIf="!officialMempoolSpace">If you click the accelerate button on a transaction you will load acceleration pricing information from Mempool. If you make an acceleration request, the TXID and your maximum bid will be sent to Mempool who will store and share this information with their mining pool partners, and publicly display accelerated transaction details on mempool.space and via Mempool's APIs. No personal information or account identifiers will be shared with any third party including mining pool partners.</p>
<p *ngIf="!officialMempoolSpace">When using Mempool Accelerator&trade; the mempool.space privacy policy will apply: <a href="https://mempool.space/privacy-policy">https://mempool.space/privacy-policy</a>.</p>
<br>
@@ -74,22 +74,38 @@
<br>
</ng-container>
<ng-container *ngIf="officialMempoolSpace">
<h4>PAYMENTS AND DONATIONS</h4>
<p>If you make any payment to Mempool or donation to The Mempool Open Source Project&reg;, we may collect the following:</p>
<p>If you make any payment to Mempool or donation to The Mempool Open Source Project&reg;, we may collect the following:</p>
<ul>
<ul>
<li>Your e-mail address and/or country; we may use this information to manage your user account, for billing purposes, or to update you about our services. We will not share this with any third-party, except as necessary for our fiat payment processor.</li>
<li>Your e-mail address and/or country; we may use this information to manage your user account, for billing purposes, or to update you about our services. We will not share this with any third-party, except as necessary for our fiat payment processor.</li>
<li>If you make a payment using Bitcoin, we will process your payment using our self-hosted BTCPay Server instance. We will not share your payment details with any third-party. For payments made over the Lightning network, we may utilize third party LSPs / lightning liquidity providers.</li>
<li>If you make a payment using Bitcoin, we will process your payment using our self-hosted BTCPay Server instance. We will not share your payment details with any third-party. For payments made over the Lightning network, we may utilize third party LSPs / lightning liquidity providers.</li>
<li>If you make a payment using Fiat we will collect your payment details. We will share your payment details with our fiat payment processor Square (Block, Inc.),. - Please see "Information we collect about customers" on Square's website at https://squareup.com/us/en/legal/general/privacy.</li>
<li>If you make a payment using Fiat we will collect your payment details. We will share your payment details with our fiat payment processor Square (Block, Inc.) - Please see "Information we collect about customers" on Square's website at https://squareup.com/us/en/legal/general/privacy.</li>
</ul>
</ul>
<br>
<br>
</ng-container>
<ng-container *ngIf="officialMempoolSpace">
<h4>DATA RETENTION AND ACCOUNT INACTIVITY</h4>
<p>We aim to retain your data only as long as necessary:</p>
<ul>
<li>An account is considered inactive if all of the following conditions are met: a) No login activity within the past 6 months, b) No active subscriptions associated with the account, c) No Mempool Accelerator™ Pro account credit</li>
<li>If an account meets the criteria for inactivity as defined above, we will automatically delete the associated account data after a period of 6 months of continuous inactivity, except in the case of payment disputes or account irregularities.</li>
</ul>
</ng-container>
<p>EOF</p>

View File

@@ -303,7 +303,6 @@ export class SearchFormComponent implements OnInit {
(error) => { console.log(error); this.isSearching = false; }
);
} else {
this.searchResults.searchButtonClick();
this.isSearching = false;
}
}

View File

@@ -234,7 +234,7 @@ export class StartComponent implements OnInit, AfterViewChecked, OnDestroy {
this.minScrollWidth = 40 + (8 * this.blockWidth) + (this.pageWidth * 2);
if (firstVisibleBlock != null) {
this.scrollToBlock(firstVisibleBlock, offset);
this.scrollToBlock(firstVisibleBlock, offset + (this.isMobile ? this.blockWidth : 0));
} else {
this.updatePages();
}

View File

@@ -46,7 +46,7 @@
@if (replaced) {
<div class="alert-replaced" role="alert">
<span i18n="transaction.rbf.replacement|RBF replacement">This transaction has been replaced by:</span>
<app-truncate [text]="latestReplacement" [lastChars]="12" [link]="['/tracker/' | relativeUrl, latestReplacement]"></app-truncate>
<app-truncate [text]="latestReplacement" [lastChars]="12" [link]="['/tx/' | relativeUrl, latestReplacement]" [queryParams]="{mode: 'status'}"></app-truncate>
</div>
} @else {
<div class="tracker-bar">
@@ -61,7 +61,7 @@
@if (transactionTime > 0) {
<i><app-time kind="since" [time]="transactionTime" [fastRender]="true" [showTooltip]="true"></app-time></i>
} @else {
<span class="skeleton-loader" style="max-width: 50%;"></span>
<span class="skeleton-loader" style="max-width: 200px;"></span>
}
</div>
</div>
@@ -78,7 +78,7 @@
</span>
</ng-container>
<ng-template #etaSkeleton>
<span class="skeleton-loader"></span>
<span class="skeleton-loader" style="max-width: 200px;"></span>
</ng-template>
</div>
</div>
@@ -118,7 +118,7 @@
</div>
<span class="explainer">&nbsp;</span>
} @else {
@if (!tx.status?.confirmed && showAccelerationSummary) {
@if (tx && !tx.status?.confirmed && showAccelerationSummary) {
<ng-container *ngIf="(ETA$ | async) as eta;">
<app-accelerate-checkout
*ngIf="(da$ | async) as da;"
@@ -135,7 +135,7 @@
></app-accelerate-checkout>
</ng-container>
}
<div class="status-panel d-flex flex-column h-100 w-100 justify-content-center align-items-center" [class.small-status]="!tx.status?.confirmed && showAccelerationSummary">
<div class="status-panel d-flex flex-column h-100 w-100 justify-content-center align-items-center" [class.small-status]="tx && !tx.status?.confirmed && showAccelerationSummary">
@if (tx?.acceleration && !tx.status?.confirmed) {
<div class="progress-icon">
<fa-icon [icon]="['fas', 'wand-magic-sparkles']" [fixedWidth]="true"></fa-icon>
@@ -185,7 +185,11 @@
}
</div>
<div class="footer-link" [routerLink]="['/tx' | relativeUrl, tx?.txid]">
<div class="footer-link"
[routerLink]="['/tx' | relativeUrl, tx?.txid || txId]"
[queryParams]="{ mode: 'details' }"
queryParamsHandling="merge"
>
<span><ng-container i18n="accelerator.show-more-details">See more details</ng-container>&nbsp;<fa-icon [icon]="['fas', 'arrow-alt-circle-right']"></fa-icon></span>
</div>
</div>

View File

@@ -49,7 +49,7 @@
position: relative;
background: var(--nav-bg);
box-shadow: 0 -5px 15px #000;
z-index: 100;
z-index: 99;
align-items: center;
justify-content: space-between;

View File

@@ -31,6 +31,8 @@ import { TrackerStage } from './tracker-bar.component';
import { MiningService, MiningStats } from '../../services/mining.service';
import { ETA, EtaService } from '../../services/eta.service';
import { getTransactionFlags, getUnacceleratedFeeRate } from '../../shared/transaction.utils';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
interface Pool {
id: number;
@@ -140,6 +142,7 @@ export class TrackerComponent implements OnInit, OnDestroy {
private priceService: PriceService,
private enterpriseService: EnterpriseService,
private miningService: MiningService,
private router: Router,
private cd: ChangeDetectorRef,
private zone: NgZone,
@Inject(ZONE_SERVICE) private zoneService: any,

View File

@@ -62,6 +62,8 @@
</div>
}
<span id="accelerate"></span>
<ng-template [ngIf]="!isLoadingTx && !error">
<!-- CPFP Details -->
@@ -167,7 +169,7 @@
<h2 id="acceleration-timeline" i18n="transaction.acceleration-timeline|Acceleration Timeline">Acceleration Timeline</h2>
</div>
<div class="clearfix"></div>
<app-acceleration-timeline [transactionTime]="transactionTime" [tx]="tx" [eta]="(ETA$ | async)" [standardETA]="(standardETA$ | async)?.time"></app-acceleration-timeline>
<app-acceleration-timeline [transactionTime]="transactionTime" [tx]="tx" [accelerationInfo]="accelerationInfo" [eta]="(ETA$ | async)" [standardETA]="(standardETA$ | async)?.time"></app-acceleration-timeline>
<br>
</ng-container>
@@ -550,19 +552,19 @@
<td>
<ng-container *ngIf="(ETA$ | async) as eta; else etaSkeleton">
@if (eta.blocks >= 7) {
<span [class]="(!tx?.acceleration && acceleratorAvailable && accelerateCtaType === 'button' && !showAccelerationSummary && eligibleForAcceleration) ? 'etaDeepMempool d-flex justify-content-end align-items-center' : ''">
<span [class]="(!tx?.acceleration && acceleratorAvailable && accelerateCtaType === 'button' && !showAccelerationSummary && eligibleForAcceleration) ? 'etaDeepMempool justify-content-end align-items-center' : ''">
<span i18n="transaction.eta.not-any-time-soon|Transaction ETA mot any time soon">Not any time soon</span>
@if (!tx?.acceleration && acceleratorAvailable && accelerateCtaType === 'button' && !showAccelerationSummary && eligibleForAcceleration) {
<a class="btn btn-sm accelerateDeepMempool btn-small-height" i18n="transaction.accelerate|Accelerate button label" (click)="onAccelerateClicked()">Accelerate</a>
<a class="btn btn-sm accelerateDeepMempool btn-small-height float-right" i18n="transaction.accelerate|Accelerate button label" (click)="onAccelerateClicked()">Accelerate</a>
}
</span>
} @else if (network === 'liquid' || network === 'liquidtestnet') {
<app-time kind="until" [time]="eta.time" [fastRender]="false" [fixedRender]="true"></app-time>
} @else {
<span [class]="(!tx?.acceleration && acceleratorAvailable && accelerateCtaType === 'button' && !showAccelerationSummary && eligibleForAcceleration) ? 'etaDeepMempool d-flex justify-content-end align-items-center' : ''">
<span [class]="(!tx?.acceleration && acceleratorAvailable && accelerateCtaType === 'button' && !showAccelerationSummary && eligibleForAcceleration) ? 'etaDeepMempool justify-content-end align-items-center' : ''">
<app-time kind="until" [time]="eta.time" [fastRender]="false" [fixedRender]="true"></app-time>
@if (!tx?.acceleration && acceleratorAvailable && accelerateCtaType === 'button' && !showAccelerationSummary && eligibleForAcceleration) {
<a class="btn btn-sm accelerateDeepMempool btn-small-height" i18n="transaction.accelerate|Accelerate button label" (click)="onAccelerateClicked()">Accelerate</a>
<a class="btn btn-sm accelerateDeepMempool btn-small-height float-right" i18n="transaction.accelerate|Accelerate button label" (click)="onAccelerateClicked()">Accelerate</a>
}
</span>
<span class="eta justify-content-end">
@@ -604,7 +606,17 @@
@if (!isLoadingTx) {
<tr>
<td class="td-width" i18n="transaction.fee|Transaction fee">Fee</td>
<td>{{ tx.fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span> <span class="fiat"><app-fiat [blockConversion]="tx.price" [value]="tx.fee"></app-fiat></span></td>
<td class="text-wrap">{{ tx.fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span>
@if (accelerationInfo?.bidBoost) {
<span class="oobFees" i18n-ngbTooltip="Acceleration Fees" ngbTooltip="Acceleration fees paid out-of-band"> +{{ accelerationInfo.bidBoost | number }} </span><span class="symbol" i18n="shared.sat|sat">sat</span>
<span class="fiat"><app-fiat [blockConversion]="tx.price" [value]="tx.fee + accelerationInfo.bidBoost"></app-fiat></span>
} @else if (tx.feeDelta && !accelerationInfo) {
<span class="oobFees" i18n-ngbTooltip="Acceleration Fees" ngbTooltip="Acceleration fees paid out-of-band"> +{{ tx.feeDelta | number }} </span><span class="symbol" i18n="shared.sat|sat">sat</span>
<span class="fiat"><app-fiat [blockConversion]="tx.price" [value]="tx.fee + tx.feeDelta"></app-fiat></span>
} @else {
<span class="fiat"><app-fiat [blockConversion]="tx.price" [value]="tx.fee"></app-fiat></span>
}
</td>
</tr>
} @else {
<ng-container *ngTemplateOutlet="skeletonDetailsRow"></ng-container>
@@ -640,9 +652,9 @@
<td>
<div class="effective-fee-container">
@if (accelerationInfo?.acceleratedFeeRate && (!tx.effectiveFeePerVsize || accelerationInfo.acceleratedFeeRate >= tx.effectiveFeePerVsize || tx.acceleration)) {
<app-fee-rate [fee]="accelerationInfo.acceleratedFeeRate"></app-fee-rate>
<app-fee-rate [class.oobFees]="isAcceleration" [fee]="accelerationInfo.acceleratedFeeRate"></app-fee-rate>
} @else {
<app-fee-rate [fee]="tx.effectiveFeePerVsize"></app-fee-rate>
<app-fee-rate [class.oobFees]="isAcceleration" [fee]="tx.effectiveFeePerVsize"></app-fee-rate>
}
@if (tx?.status?.confirmed && !tx.acceleration && !accelerationInfo && tx.fee && tx.effectiveFeePerVsize) {

View File

@@ -297,7 +297,6 @@
}
.etaDeepMempool {
display: flex !important;
justify-content: flex-end;
flex-wrap: wrap;
align-content: center;
@@ -333,3 +332,7 @@
top: -1px;
margin-right: 2px;
}
.oobFees {
color: #905cf4;
}

View File

@@ -11,7 +11,9 @@ import {
tap,
map,
retry,
startWith
startWith,
repeat,
take
} from 'rxjs/operators';
import { Transaction } from '../../interfaces/electrs.interface';
import { of, merge, Subscription, Observable, Subject, from, throwError, combineLatest, BehaviorSubject } from 'rxjs';
@@ -76,6 +78,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
transactionTime = -1;
subscription: Subscription;
fetchCpfpSubscription: Subscription;
transactionTimesSubscription: Subscription;
fetchRbfSubscription: Subscription;
fetchCachedTxSubscription: Subscription;
fetchAccelerationSubscription: Subscription;
@@ -88,6 +91,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
blocksSubscription: Subscription;
miningSubscription: Subscription;
auditSubscription: Subscription;
txConfirmedSubscription: Subscription;
currencyChangeSubscription: Subscription;
fragmentParams: URLSearchParams;
rbfTransaction: undefined | Transaction;
@@ -106,6 +110,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
showCpfpDetails = false;
miningStats: MiningStats;
fetchCpfp$ = new Subject<string>();
transactionTimes$ = new Subject<string>();
fetchRbfHistory$ = new Subject<string>();
fetchCachedTx$ = new Subject<string>();
fetchAcceleration$ = new Subject<number>();
@@ -141,7 +146,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
taprootEnabled: boolean;
hasEffectiveFeeRate: boolean;
accelerateCtaType: 'alert' | 'button' = 'button';
acceleratorAvailable: boolean = this.stateService.env.ACCELERATOR && this.stateService.network === '';
acceleratorAvailable: boolean = this.stateService.env.ACCELERATOR_BUTTON && this.stateService.network === '';
eligibleForAcceleration: boolean = false;
forceAccelerationSummary = false;
hideAccelerationSummary = false;
@@ -195,7 +200,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.stateService.networkChanged$.subscribe(
(network) => {
this.network = network;
this.acceleratorAvailable = this.stateService.env.ACCELERATOR && this.stateService.network === '';
this.acceleratorAvailable = this.stateService.env.ACCELERATOR_BUTTON && this.stateService.network === '';
}
);
@@ -225,6 +230,25 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.latestBlock = blocks[0];
});
this.transactionTimesSubscription = this.transactionTimes$.pipe(
tap(() => {
this.isLoadingFirstSeen = true;
}),
switchMap((txid) => this.apiService.getTransactionTimes$([txid]).pipe(
retry({ count: 2, delay: 2000 }),
// Try again until we either get a valid response, or the transaction is confirmed
repeat({ delay: 2000 }),
filter((transactionTimes) => transactionTimes?.length && transactionTimes[0] > 0 && !this.tx.status?.confirmed),
take(1),
)),
)
.subscribe((transactionTimes) => {
this.isLoadingFirstSeen = false;
if (transactionTimes?.length && transactionTimes[0]) {
this.transactionTime = transactionTimes[0];
}
});
this.fetchCpfpSubscription = this.fetchCpfp$
.pipe(
switchMap((txId) =>
@@ -387,10 +411,11 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
const isConflict = audit.fullrbfTxs.includes(txid);
const isExpected = audit.template.some(tx => tx.txid === txid);
const firstSeen = audit.template.find(tx => tx.txid === txid)?.time;
const wasSeen = audit.version === 1 ? !audit.unseenTxs.includes(txid) : (isExpected || isPrioritized || isAccelerated);
return {
seen: isExpected || isPrioritized || isAccelerated,
seen: wasSeen,
expected: isExpected,
added: isAdded,
added: isAdded && (audit.version === 0 || !wasSeen),
prioritized: isPrioritized,
conflict: isConflict,
accelerated: isAccelerated,
@@ -437,9 +462,23 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
if (txPosition.position.acceleratedBy) {
txPosition.cpfp.acceleratedBy = txPosition.position.acceleratedBy;
}
if (txPosition.position.acceleratedAt) {
txPosition.cpfp.acceleratedAt = txPosition.position.acceleratedAt;
}
if (txPosition.position.feeDelta) {
txPosition.cpfp.feeDelta = txPosition.position.feeDelta;
}
this.setCpfpInfo(txPosition.cpfp);
} else if ((this.tx?.acceleration && txPosition.position.acceleratedBy)) {
this.tx.acceleratedBy = txPosition.position.acceleratedBy;
} else if ((this.tx?.acceleration)) {
if (txPosition.position.acceleratedBy) {
this.tx.acceleratedBy = txPosition.position.acceleratedBy;
}
if (txPosition.position.acceleratedAt) {
this.tx.acceleratedAt = txPosition.position.acceleratedAt;
}
if (txPosition.position.feeDelta) {
this.tx.feeDelta = txPosition.position.feeDelta;
}
}
if (this.stateService.network === '') {
@@ -494,6 +533,14 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
});
}
}
if (window.innerWidth <= 767.98) {
this.router.navigate([this.relativeUrlPipe.transform('/tx'), this.txId], {
queryParamsHandling: 'merge',
preserveFragment: true,
queryParams: { mode: 'details' },
replaceUrl: true,
});
}
this.seoService.setTitle(
$localize`:@@bisq.transaction.browser-title:Transaction: ${this.txId}:INTERPOLATION:`
);
@@ -572,7 +619,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
if (tx.firstSeen) {
this.transactionTime = tx.firstSeen;
} else {
this.getTransactionTime();
this.transactionTimes$.next(tx.txid);
}
} else {
this.fetchAcceleration$.next(tx.status.block_height);
@@ -598,8 +645,8 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
ancestors: tx.ancestors,
bestDescendant: tx.bestDescendant,
});
const hasRelatives = !!(tx.ancestors?.length || tx.bestDescendant);
this.hasEffectiveFeeRate = hasRelatives || (tx.effectiveFeePerVsize && (Math.abs(tx.effectiveFeePerVsize - tx.feePerVsize) > 0.01));
const hasRelatives = !!(tx.ancestors?.length || tx.bestDescendant || tx.descendants);
this.hasEffectiveFeeRate = hasRelatives || (tx.effectiveFeePerVsize && tx.effectiveFeePerVsize !== (this.tx.fee / (this.tx.weight / 4)) && tx.effectiveFeePerVsize !== (tx.fee / Math.ceil(tx.weight / 4)));
} else {
this.fetchCpfp$.next(this.tx.txid);
}
@@ -625,7 +672,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
}
);
this.stateService.txConfirmed$.subscribe(([txConfirmed, block]) => {
this.txConfirmedSubscription = this.stateService.txConfirmed$.subscribe(([txConfirmed, block]) => {
if (txConfirmed && this.tx && !this.tx.status.confirmed && txConfirmed === this.tx.txid) {
if (this.tx.acceleration) {
this.waitingForAccelerationInfo = true;
@@ -729,7 +776,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.accelerationPositions,
);
})
)
);
}
ngAfterViewInit(): void {
@@ -763,28 +810,6 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
return of(false);
}
getTransactionTime() {
this.isLoadingFirstSeen = true;
this.apiService
.getTransactionTimes$([this.tx.txid])
.pipe(
retry({ count: 2, delay: 2000 }),
catchError(() => {
this.isLoadingFirstSeen = false;
return throwError(() => new Error(''));
})
)
.subscribe((transactionTimes) => {
if (transactionTimes?.length && transactionTimes[0]) {
this.transactionTime = transactionTimes[0];
} else {
setTimeout(() => {
this.getTransactionTime();
}, 2000);
}
});
}
setCpfpInfo(cpfpInfo: CpfpInfo): void {
if (!cpfpInfo || !this.tx) {
this.cpfpInfo = null;
@@ -814,6 +839,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.tx.acceleration = cpfpInfo.acceleration;
this.tx.acceleratedBy = cpfpInfo.acceleratedBy;
this.tx.acceleratedAt = cpfpInfo.acceleratedAt;
this.tx.feeDelta = cpfpInfo.feeDelta;
this.setIsAccelerated(firstCpfp);
}
@@ -828,8 +854,8 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.sigops = this.cpfpInfo.sigops;
this.adjustedVsize = this.cpfpInfo.adjustedVsize;
}
this.hasCpfp =!!(this.cpfpInfo && (this.cpfpInfo.bestDescendant || this.cpfpInfo.descendants?.length || this.cpfpInfo.ancestors?.length));
this.hasEffectiveFeeRate = hasRelatives || (this.tx.effectiveFeePerVsize && (Math.abs(this.tx.effectiveFeePerVsize - this.tx.feePerVsize) > 0.01));
this.hasCpfp =!!(this.cpfpInfo && relatives.length);
this.hasEffectiveFeeRate = hasRelatives || (this.tx.effectiveFeePerVsize && this.tx.effectiveFeePerVsize !== (this.tx.fee / (this.tx.weight / 4)) && this.tx.effectiveFeePerVsize !== (this.tx.fee / Math.ceil(this.tx.weight / 4)));
}
setIsAccelerated(initialState: boolean = false) {
@@ -841,7 +867,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
}
if (this.isAcceleration) {
// this immediately returns cached stats if we fetched them recently
this.miningService.getMiningStats('1w').subscribe(stats => {
this.miningService.getMiningStats('1m').subscribe(stats => {
this.miningStats = stats;
this.isAccelerated$.next(this.isAcceleration); // hack to trigger recalculation of ETA without adding another source observable
});
@@ -1057,6 +1083,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
ngOnDestroy() {
this.subscription.unsubscribe();
this.fetchCpfpSubscription.unsubscribe();
this.transactionTimesSubscription.unsubscribe();
this.fetchRbfSubscription.unsubscribe();
this.fetchCachedTxSubscription.unsubscribe();
this.fetchAccelerationSubscription.unsubscribe();
@@ -1070,6 +1097,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy {
this.blocksSubscription.unsubscribe();
this.miningSubscription?.unsubscribe();
this.auditSubscription?.unsubscribe();
this.txConfirmedSubscription?.unsubscribe();
this.currencyChangeSubscription?.unsubscribe();
this.leaveTransaction();
}

View File

@@ -43,7 +43,7 @@
<span *ngSwitchCase="'output'" i18n="transaction.output">Output</span>
<span *ngSwitchCase="'fee'" i18n="transaction.fee|Transaction fee">Fee</span>
</ng-container>
<span *ngIf="line.type !== 'fee'"> #{{ line.index + 1 }}</span>
<span *ngIf="line.type !== 'fee'"> #{{ line.index }}</span>
<ng-container [ngSwitch]="line.type">
<span *ngSwitchCase="'input'">
<ng-container *ngIf="line.status?.block_height">
@@ -73,7 +73,7 @@
<app-truncate [text]="line.txid"></app-truncate>
</p>
<ng-container [ngSwitch]="line.type">
<p *ngSwitchCase="'input'"><span i18n="transaction.output">Output</span>&nbsp; #{{ line.vout + 1 }}
<p *ngSwitchCase="'input'"><span i18n="transaction.output">Output</span>&nbsp; #{{ line.vout }}
<ng-container *ngIf="line.status?.block_height">
<ng-container *ngIf="line.blockHeight; else noBlockHeight">
<ng-container *ngTemplateOutlet="nBlocksEarlier; context:{n: line.blockHeight - line?.status?.block_height, connector: true}"></ng-container>
@@ -83,7 +83,7 @@
</ng-template>
</ng-container>
</p>
<p *ngSwitchCase="'output'"><span i18n="transaction.input">Input</span>&nbsp; #{{ line.vin + 1 }}
<p *ngSwitchCase="'output'"><span i18n="transaction.input">Input</span>&nbsp; #{{ line.vin }}
<ng-container *ngIf="line.blockHeight">
<ng-container *ngIf="line?.status?.block_height; else noBlockHeight">
<ng-container *ngTemplateOutlet="nBlocksLater; context:{n: line?.status?.block_height - line.blockHeight, connector: true}"></ng-container>

View File

@@ -8993,7 +8993,7 @@ export const restApiDocsData = [
fragment: "accelerator-estimate",
title: "POST Calculate Estimated Costs",
description: {
default: "<p>Returns estimated costs to accelerate a transaction. Optionally set the <code>api_key</code> header to get customized estimation.</p>"
default: "<p>Returns estimated costs to accelerate a transaction. Optionally set the <code>X-Mempool-Auth</code> header to get customized estimation.</p>"
},
urlString: "/v1/services/accelerator/estimate",
showConditions: [""],
@@ -9009,7 +9009,7 @@ export const restApiDocsData = [
esModule: [],
commonJS: [],
curl: ["txInput=ee13ebb99632377c15c94980357f674d285ac413452050031ea6dcd3e9b2dc29"],
headers: "api_key: stacksats",
headers: "X-Mempool-Auth: stacksats",
response: `{
"txSummary": {
"txid": "ee13ebb99632377c15c94980357f674d285ac413452050031ea6dcd3e9b2dc29",
@@ -9240,7 +9240,7 @@ export const restApiDocsData = [
esModule: [],
commonJS: [],
curl: [],
headers: "api_key: stacksats",
headers: "X-Mempool-Auth: stacksats",
response: `[
{
"type": "Bitcoin",
@@ -9288,7 +9288,7 @@ export const restApiDocsData = [
esModule: [],
commonJS: [],
curl: [],
headers: "api_key: stacksats",
headers: "X-Mempool-Auth: stacksats",
response: `{
"balance": 99900000,
"hold": 101829,
@@ -9322,7 +9322,7 @@ export const restApiDocsData = [
esModule: [],
commonJS: [],
curl: ["txInput=ee13ebb99632377c15c94980357f674d285ac413452050031ea6dcd3e9b2dc29&userBid=21000000"],
headers: "api_key: stacksats",
headers: "X-Mempool-Auth: stacksats",
response: `HTTP/1.1 200 OK`,
},
}
@@ -9352,7 +9352,7 @@ export const restApiDocsData = [
esModule: [],
commonJS: [],
curl: [],
headers: "api_key: stacksats",
headers: "X-Mempool-Auth: stacksats",
response: `[
{
"id": 89,

View File

@@ -17,11 +17,13 @@ export interface Transaction {
feePerVsize?: number;
effectiveFeePerVsize?: number;
ancestors?: Ancestor[];
descendants?: Ancestor[];
bestDescendant?: BestDescendant | null;
cpfpChecked?: boolean;
acceleration?: boolean;
acceleratedBy?: number[];
acceleratedAt?: number;
feeDelta?: number;
deleteAfter?: number;
_unblinded?: any;
_deduced?: boolean;

View File

@@ -31,6 +31,7 @@ export interface CpfpInfo {
acceleration?: boolean;
acceleratedBy?: number[];
acceleratedAt?: number;
feeDelta?: number;
}
export interface RbfInfo {
@@ -210,6 +211,8 @@ export interface BlockExtended extends Block {
}
export interface BlockAudit extends BlockExtended {
version: number,
unseenTxs?: string[],
missingTxs: string[],
addedTxs: string[],
prioritizedTxs: string[],
@@ -236,7 +239,7 @@ export interface TransactionStripped {
acc?: boolean;
flags?: number | null;
time?: number;
status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'freshcpfp' | 'added' | 'prioritized' | 'censored' | 'selected' | 'rbf' | 'accelerated';
status?: 'found' | 'missing' | 'sigop' | 'fresh' | 'freshcpfp' | 'added' | 'added_prioritized' | 'prioritized' | 'censored' | 'selected' | 'rbf' | 'accelerated';
context?: 'projected' | 'actual';
}
@@ -250,6 +253,8 @@ export interface MempoolPosition {
vsize: number,
accelerated?: boolean,
acceleratedBy?: number[],
acceleratedAt?: number,
feeDelta?: number,
}
export interface AccelerationPosition extends MempoolPosition {
@@ -408,6 +413,7 @@ export interface Acceleration {
bidBoost?: number;
boostCost?: number;
boostRate?: number;
minedByPoolUniqueId?: number;
}
export interface AccelerationHistoryParams {

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