Compare commits
2325 Commits
v2.5.0-bet
...
v3.0.0-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
975588a862 | ||
|
|
093649136e | ||
|
|
018d4230e2 | ||
|
|
6c66da5426 | ||
|
|
5b9b0d0968 | ||
|
|
ddf1bc0654 | ||
|
|
419b40884a | ||
|
|
1f05e7b366 | ||
|
|
a9d94bdafd | ||
|
|
18b1805ff3 | ||
|
|
4044ca3877 | ||
|
|
66386f2bcc | ||
|
|
d0bf26bcef | ||
|
|
e918e1fdab | ||
|
|
968a26d2cd | ||
|
|
43fde86e9d | ||
|
|
587c7f6d59 | ||
|
|
f431ca742f | ||
|
|
7e16c5e8c4 | ||
|
|
8701ba8546 | ||
|
|
42912ff007 | ||
|
|
6d8a72301c | ||
|
|
1cdbde680c | ||
|
|
ec21b3d06f | ||
|
|
e8e43c074b | ||
|
|
dc50b40b69 | ||
|
|
e41d6bc2e6 | ||
|
|
cb7938baa1 | ||
|
|
4d6b62c7de | ||
|
|
7bf053afe5 | ||
|
|
d8900d40c7 | ||
|
|
841f18f481 | ||
|
|
b0d34d4e48 | ||
|
|
c684834c42 | ||
|
|
46e989ab27 | ||
|
|
2c19e44fbf | ||
|
|
dab72f669d | ||
|
|
16537e8018 | ||
|
|
a3b713cc74 | ||
|
|
17697e669a | ||
|
|
035068a72e | ||
|
|
086ae6978a | ||
|
|
951b946be9 | ||
|
|
5fc2205b54 | ||
|
|
18e412939d | ||
|
|
69dc21d232 | ||
|
|
521d2fc650 | ||
|
|
ac250ebe6c | ||
|
|
596aada169 | ||
|
|
c4df05b581 | ||
|
|
7fb699a02b | ||
|
|
e8fc1e8522 | ||
|
|
5f14b32a06 | ||
|
|
e2d7c82553 | ||
|
|
7259a00c8d | ||
|
|
7c6c1187bb | ||
|
|
9f4c351e3d | ||
|
|
329c1330f4 | ||
|
|
d55d5db01d | ||
|
|
55cd2f5678 | ||
|
|
69747a0028 | ||
|
|
45c1e05ebc | ||
|
|
8713d33d1a | ||
|
|
512f632475 | ||
|
|
6dedecad70 | ||
|
|
99aa6c9ed3 | ||
|
|
d24d643d70 | ||
|
|
000524691a | ||
|
|
290de43df0 | ||
|
|
cc2f42e814 | ||
|
|
a40727b194 | ||
|
|
6933d3c395 | ||
|
|
4fc5a6197a | ||
|
|
fbabd93b5e | ||
|
|
2c22200820 | ||
|
|
d921c3fdc2 | ||
|
|
a1bd84ea6d | ||
|
|
740f9af9af | ||
|
|
be183ada0a | ||
|
|
a2b01587b1 | ||
|
|
5f698dfbbb | ||
|
|
46f2509ca0 | ||
|
|
8bd4a8a9ee | ||
|
|
e00bc86bd1 | ||
|
|
ade256efc7 | ||
|
|
33ac4056d8 | ||
|
|
4ef1df47a3 | ||
|
|
e23de97e0f | ||
|
|
346c024ddf | ||
|
|
bb43599493 | ||
|
|
e6266ecedc | ||
|
|
d4db628c3b | ||
|
|
1e56ac094c | ||
|
|
ddee5f927c | ||
|
|
42bd08c744 | ||
|
|
dfbec0ceef | ||
|
|
3b3081f884 | ||
|
|
925d51f40e | ||
|
|
b3dbe1215c | ||
|
|
dd89c398cc | ||
|
|
7de4a03f1b | ||
|
|
1121136a5e | ||
|
|
e5f0544cc2 | ||
|
|
3456d2737a | ||
|
|
efdc83d4bb | ||
|
|
d745bf3f9e | ||
|
|
9d6231b6e5 | ||
|
|
fdad3d1fd5 | ||
|
|
1e3650594c | ||
|
|
2dc2a22edb | ||
|
|
dcccc3c8a2 | ||
|
|
49553b7bae | ||
|
|
01e019245b | ||
|
|
01066eae64 | ||
|
|
b88d6bc7b7 | ||
|
|
a51d82bcfb | ||
|
|
999994c701 | ||
|
|
0a7c91a4e9 | ||
|
|
510dd91328 | ||
|
|
3ceb583127 | ||
|
|
d7be5a4737 | ||
|
|
3039481686 | ||
|
|
f14cd5ee2b | ||
|
|
e268a6a033 | ||
|
|
ca2c5d3628 | ||
|
|
ce7a007b62 | ||
|
|
ba0a3d004d | ||
|
|
29e4a581e7 | ||
|
|
4f98a413d1 | ||
|
|
0ab1183048 | ||
|
|
03d4375b17 | ||
|
|
e86d0dc868 | ||
|
|
e9a7ea3623 | ||
|
|
1e568e5b4c | ||
|
|
b56350dca1 | ||
|
|
20614213a7 | ||
|
|
582eca1fdd | ||
|
|
df7f1cc86b | ||
|
|
0acb797494 | ||
|
|
195eeaa7b9 | ||
|
|
48c2317e20 | ||
|
|
95233f0ef3 | ||
|
|
29c6efa9ed | ||
|
|
624cbf05c0 | ||
|
|
1547c2c477 | ||
|
|
3959f52d19 | ||
|
|
dfdd286f75 | ||
|
|
fea115bcbc | ||
|
|
f004705896 | ||
|
|
87a613e4dc | ||
|
|
b561648082 | ||
|
|
602c87bea0 | ||
|
|
b453898b1d | ||
|
|
c4c886b584 | ||
|
|
517d36783f | ||
|
|
f964888c47 | ||
|
|
c58115a96c | ||
|
|
fc415372bf | ||
|
|
47d221fd3b | ||
|
|
7e5e85c8b9 | ||
|
|
9c72d1df3b | ||
|
|
933bc47b44 | ||
|
|
a15729c38f | ||
|
|
229e122daf | ||
|
|
16846d526c | ||
|
|
19a4c10860 | ||
|
|
ae38c3bf81 | ||
|
|
575c407098 | ||
|
|
03e4a21bce | ||
|
|
7bfc649042 | ||
|
|
e478fb2279 | ||
|
|
9494d2fb0b | ||
|
|
b44ec76130 | ||
|
|
dd7bd9e397 | ||
|
|
0bf0d79ee4 | ||
|
|
788a8693ee | ||
|
|
163c0b6d78 | ||
|
|
7ab5b2a2b7 | ||
|
|
f3fd70a846 | ||
|
|
770b6dfd19 | ||
|
|
db8ba7c938 | ||
|
|
e4d92c8fe0 | ||
|
|
1e15428a63 | ||
|
|
f4de3a44e0 | ||
|
|
e69ba7e884 | ||
|
|
b4906a5e8a | ||
|
|
73e6045549 | ||
|
|
5f3f483546 | ||
|
|
ba8ea7d0d1 | ||
|
|
e458196bff | ||
|
|
2637636f27 | ||
|
|
639fc3dd5f | ||
|
|
451a61e5fc | ||
|
|
b2a130bb17 | ||
|
|
c39ca0270e | ||
|
|
b6e5a059c9 | ||
|
|
a3055e20f4 | ||
|
|
3ac70b3860 | ||
|
|
71f73835da | ||
|
|
59128c8ca0 | ||
|
|
36d3734d55 | ||
|
|
c9c477c88f | ||
|
|
8806b8332b | ||
|
|
c7a38c78d2 | ||
|
|
2d189ca3f6 | ||
|
|
4b798730b1 | ||
|
|
bbb0aea0d1 | ||
|
|
57c7df4f1c | ||
|
|
87910a6eb7 | ||
|
|
ea1b74dcef | ||
|
|
09e38ac177 | ||
|
|
5b93d39d73 | ||
|
|
4e5ebabc54 | ||
|
|
a79e1aec1c | ||
|
|
085d40a4e3 | ||
|
|
057abbb6c1 | ||
|
|
1a2844ffdf | ||
|
|
7102a72f84 | ||
|
|
87e328504f | ||
|
|
520e79aec4 | ||
|
|
ee6422c982 | ||
|
|
e8ca8e7ec1 | ||
|
|
b110f86b48 | ||
|
|
5a7a78cddc | ||
|
|
c7862e19c2 | ||
|
|
3610908171 | ||
|
|
87c336734d | ||
|
|
bad3992397 | ||
|
|
edb4c8d9ad | ||
|
|
565a881f3b | ||
|
|
d1d8f77b29 | ||
|
|
c957692726 | ||
|
|
16f0830f2c | ||
|
|
b6d2008e97 | ||
|
|
a6b584d964 | ||
|
|
e81d1e273a | ||
|
|
dbe7ebe530 | ||
|
|
4e007acd84 | ||
|
|
4fe745817c | ||
|
|
09ce1bd458 | ||
|
|
09a727c00d | ||
|
|
f7f1eb067b | ||
|
|
74d94579f9 | ||
|
|
885331fe4a | ||
|
|
8ca2b2b5c0 | ||
|
|
58f143f867 | ||
|
|
a19ffb884f | ||
|
|
2faca121d3 | ||
|
|
8f99d2ba58 | ||
|
|
585732e438 | ||
|
|
0cddfcfa64 | ||
|
|
aa0948a154 | ||
|
|
8f7895cb2e | ||
|
|
77a526b91c | ||
|
|
ccd9642a01 | ||
|
|
8642956dc2 | ||
|
|
57c3861ca6 | ||
|
|
82920f621f | ||
|
|
de7db08ae3 | ||
|
|
431f60d9c0 | ||
|
|
da545dbcf1 | ||
|
|
b7feb0d43d | ||
|
|
cef060be2d | ||
|
|
c570cc1e73 | ||
|
|
6339cc8d59 | ||
|
|
fe079a72be | ||
|
|
c789797d2d | ||
|
|
479007da55 | ||
|
|
7405cf8336 | ||
|
|
f955c1b0f4 | ||
|
|
99f7201ec5 | ||
|
|
0c9c79c86c | ||
|
|
1507003c19 | ||
|
|
5ecdf1988e | ||
|
|
5b622c7786 | ||
|
|
392ea35d51 | ||
|
|
552ba98de2 | ||
|
|
7a2f8bcce2 | ||
|
|
d53b5e4a68 | ||
|
|
c2bd6419bb | ||
|
|
8f7cd70882 | ||
|
|
fa90eb84fc | ||
|
|
00613fe613 | ||
|
|
0663cc2cfa | ||
|
|
81a09e9dba | ||
|
|
cd713c61b3 | ||
|
|
752eba767a | ||
|
|
de2842b62a | ||
|
|
a89af41c48 | ||
|
|
c751e10da8 | ||
|
|
2750f1789c | ||
|
|
aaf378ed42 | ||
|
|
bc5e03deb5 | ||
|
|
8759d49daf | ||
|
|
4b10e32e73 | ||
|
|
6671746fc5 | ||
|
|
10bb6caf97 | ||
|
|
b52946e6f5 | ||
|
|
aac094b965 | ||
|
|
8ca14e7f19 | ||
|
|
84182b2512 | ||
|
|
a45e4a6ec5 | ||
|
|
a5eeaaa91a | ||
|
|
59e30027a5 | ||
|
|
7fd8ae375c | ||
|
|
9e240e55ce | ||
|
|
d82a5281fc | ||
|
|
8054e2d79a | ||
|
|
af7d437d09 | ||
|
|
f6d7790512 | ||
|
|
e373f36765 | ||
|
|
8cb49ba8e3 | ||
|
|
d2270ffc26 | ||
|
|
5009ca909c | ||
|
|
6a189c484a | ||
|
|
ed9826b2d8 | ||
|
|
0225455784 | ||
|
|
ee721b3e1c | ||
|
|
f161f7e8e2 | ||
|
|
b38bbb9513 | ||
|
|
f095913538 | ||
|
|
91fad7ff65 | ||
|
|
00b8f001b0 | ||
|
|
73d4ea75de | ||
|
|
0722c221c7 | ||
|
|
693d8c4b3b | ||
|
|
2e5bb000ab | ||
|
|
203e2040d7 | ||
|
|
a987983413 | ||
|
|
227c597cb6 | ||
|
|
a8442a3467 | ||
|
|
65236c7b08 | ||
|
|
e4d7034d81 | ||
|
|
2ca6dc51da | ||
|
|
fdd14fd6dc | ||
|
|
cafcb4f9a6 | ||
|
|
a06bf86b95 | ||
|
|
5c43c17261 | ||
|
|
3b8de5057c | ||
|
|
0ec40eafb4 | ||
|
|
9a7e22806d | ||
|
|
d546f2eba5 | ||
|
|
46fdb5b30d | ||
|
|
a23088458b | ||
|
|
b86b38398e | ||
|
|
600cbae739 | ||
|
|
7f62a0f9b5 | ||
|
|
978e0bff24 | ||
|
|
5291828e6a | ||
|
|
ecb696661e | ||
|
|
e9a67adf4f | ||
|
|
5389d19850 | ||
|
|
439177a78f | ||
|
|
367f70c3b3 | ||
|
|
6553de53b8 | ||
|
|
8336a00aea | ||
|
|
f694d5333a | ||
|
|
aad288c0d9 | ||
|
|
30d58d9971 | ||
|
|
89d37f0058 | ||
|
|
7cd0c4f352 | ||
|
|
7de3d789b3 | ||
|
|
f9dfb3b2b1 | ||
|
|
17427b7970 | ||
|
|
6da4f34656 | ||
|
|
0230f95001 | ||
|
|
d5c5ae0e09 | ||
|
|
fc8eca4c26 | ||
|
|
9485dfe2ce | ||
|
|
5f66a95402 | ||
|
|
d665d2a12c | ||
|
|
5f1180118a | ||
|
|
f7e7072dbc | ||
|
|
a0d0650e2a | ||
|
|
f73350f7d6 | ||
|
|
bfdab0ac48 | ||
|
|
b9776263dc | ||
|
|
56dce6b28c | ||
|
|
c1785993a8 | ||
|
|
0c956be136 | ||
|
|
a498f9205b | ||
|
|
09041c50ef | ||
|
|
dd31d5a665 | ||
|
|
3e1b85e32c | ||
|
|
51bfffbf0e | ||
|
|
9b6e60bfba | ||
|
|
8492007a48 | ||
|
|
bd34d71d8b | ||
|
|
23ececca95 | ||
|
|
fb61fddcc4 | ||
|
|
24ec12e7a1 | ||
|
|
681f9a1c69 | ||
|
|
fab82551cf | ||
|
|
6cea6ceb7e | ||
|
|
118eef6f0c | ||
|
|
626f95d802 | ||
|
|
ab3ce5ece0 | ||
|
|
d2a5adbd9d | ||
|
|
93b074edf0 | ||
|
|
577b1a9f66 | ||
|
|
d9c28643f4 | ||
|
|
3f305207f9 | ||
|
|
2fb3e4e4e5 | ||
|
|
40f2d553e2 | ||
|
|
8e9a187a15 | ||
|
|
0370cc896c | ||
|
|
d6291ecd52 | ||
|
|
9d82926d6f | ||
|
|
cd964d37e8 | ||
|
|
ec4c418c22 | ||
|
|
07f9410d45 | ||
|
|
c3675d5b1c | ||
|
|
f471541907 | ||
|
|
4f3852bbba | ||
|
|
fc36e04dc0 | ||
|
|
7f488f5b01 | ||
|
|
1d74a99d2f | ||
|
|
bd07773b8d | ||
|
|
59f8a7199c | ||
|
|
03b78015ba | ||
|
|
f72683dc19 | ||
|
|
75b3bc6249 | ||
|
|
f915ff3f91 | ||
|
|
01131360c2 | ||
|
|
30da419fb4 | ||
|
|
e8c48f6f23 | ||
|
|
ca7167870a | ||
|
|
7bdb2f41a5 | ||
|
|
3e505d37c9 | ||
|
|
78857102f8 | ||
|
|
8dbf879c37 | ||
|
|
bc89fd5b7c | ||
|
|
a8fd2ac9dc | ||
|
|
c2f52ac1f3 | ||
|
|
472a86ffd4 | ||
|
|
21ad37dbec | ||
|
|
10a50ac49f | ||
|
|
b49e01bf64 | ||
|
|
cc54538937 | ||
|
|
6014469513 | ||
|
|
9e88f5ecf8 | ||
|
|
041f4f6695 | ||
|
|
70ca75ac12 | ||
|
|
b3a68a0db2 | ||
|
|
99b57bbec0 | ||
|
|
eae044cf66 | ||
|
|
8238edb721 | ||
|
|
0ac0a56ae5 | ||
|
|
445d600633 | ||
|
|
e4751a8274 | ||
|
|
100936e551 | ||
|
|
acff9782cb | ||
|
|
b9c60b0820 | ||
|
|
2e531413fa | ||
|
|
3eb6241c98 | ||
|
|
16b9ca6c40 | ||
|
|
b0c02b16ff | ||
|
|
512589dc79 | ||
|
|
464cffb5c1 | ||
|
|
ce195c9133 | ||
|
|
1fb3de9dc3 | ||
|
|
7cb58ed988 | ||
|
|
97053ab5cf | ||
|
|
39c5792e6f | ||
|
|
c019355c9f | ||
|
|
5777561744 | ||
|
|
24dbe5d4ee | ||
|
|
e12f43e741 | ||
|
|
173bc127cb | ||
|
|
14e440da6b | ||
|
|
93c61f5ed3 | ||
|
|
7a77e86ab2 | ||
|
|
2013dc6d8b | ||
|
|
d3f7190b7f | ||
|
|
590188fe6d | ||
|
|
38479db5ca | ||
|
|
ed215aa4cd | ||
|
|
df55497747 | ||
|
|
6d5a723ff4 | ||
|
|
9f6f210c38 | ||
|
|
cb1d9753c8 | ||
|
|
05259e9f81 | ||
|
|
e5df1571da | ||
|
|
88d2a3a50d | ||
|
|
a4810b8be0 | ||
|
|
5ac432556b | ||
|
|
f03acce324 | ||
|
|
aff8f2c41f | ||
|
|
88f617611f | ||
|
|
2cddc5fdd8 | ||
|
|
d38381af8b | ||
|
|
aff44d90d5 | ||
|
|
42f6f0c122 | ||
|
|
8599876b26 | ||
|
|
e34ee3a34d | ||
|
|
47de84f03a | ||
|
|
f071d5e76a | ||
|
|
5a55ba2d28 | ||
|
|
2efd474357 | ||
|
|
472a76560f | ||
|
|
f26b630aab | ||
|
|
b0e73466fa | ||
|
|
7019e6ec03 | ||
|
|
f409c67125 | ||
|
|
6cf40489e5 | ||
|
|
14ad0c8ecb | ||
|
|
f86645b50c | ||
|
|
f23bcec10d | ||
|
|
6f97a2ef74 | ||
|
|
42a3a380d5 | ||
|
|
bbcc683a3c | ||
|
|
9406103ed1 | ||
|
|
dc06387347 | ||
|
|
5a2da751fa | ||
|
|
83db6f6428 | ||
|
|
4169e1053f | ||
|
|
a46e779d0c | ||
|
|
51102004bb | ||
|
|
08e9142d57 | ||
|
|
b7c4cd43fc | ||
|
|
08a35b85a8 | ||
|
|
51a28b2e01 | ||
|
|
966adf5963 | ||
|
|
ae7b17c4fd | ||
|
|
5b4bc9fe19 | ||
|
|
89eb7ec90b | ||
|
|
a646c5f22f | ||
|
|
c345f2164b | ||
|
|
9791ee018d | ||
|
|
7c83b85e3e | ||
|
|
63942af720 | ||
|
|
4da5910e0b | ||
|
|
025788e78c | ||
|
|
88a9524064 | ||
|
|
c216e849e6 | ||
|
|
c86768edc9 | ||
|
|
4283d30ce0 | ||
|
|
e2eeaebfc7 | ||
|
|
90d87aaaca | ||
|
|
b2e9275ad9 | ||
|
|
181e816c1a | ||
|
|
758ca4e93a | ||
|
|
a33f915c7a | ||
|
|
75c6d006ff | ||
|
|
1fe983a299 | ||
|
|
cd8e3e2604 | ||
|
|
dfc4309d9e | ||
|
|
0a70273456 | ||
|
|
62c9a88235 | ||
|
|
851e07b50b | ||
|
|
e1bbec074b | ||
|
|
9fcafeeeb0 | ||
|
|
e986cfd30b | ||
|
|
2584a1f2b0 | ||
|
|
1c92394563 | ||
|
|
36398ca57a | ||
|
|
6b978f9262 | ||
|
|
12cf130c00 | ||
|
|
ba933a81c3 | ||
|
|
7de7081d67 | ||
|
|
6b933c202f | ||
|
|
f1525b7df5 | ||
|
|
62c4af1a0c | ||
|
|
4ac98bc483 | ||
|
|
ed07daebca | ||
|
|
f8bbf7783c | ||
|
|
fac2fab16a | ||
|
|
76de4e34d8 | ||
|
|
6109f25aa9 | ||
|
|
86303175e9 | ||
|
|
a670131f40 | ||
|
|
c5ce3167f3 | ||
|
|
9913254a5c | ||
|
|
fbb4ba39c2 | ||
|
|
22bf0fe928 | ||
|
|
3b30765070 | ||
|
|
63fde7d0b6 | ||
|
|
cf338970bf | ||
|
|
08e046ea2a | ||
|
|
5697710c23 | ||
|
|
b9e050e24b | ||
|
|
ff819673e5 | ||
|
|
2e2801a4ab | ||
|
|
f52b17ca7a | ||
|
|
3d25235705 | ||
|
|
2cbc6783a4 | ||
|
|
406bf5d3b7 | ||
|
|
f5bf883de9 | ||
|
|
c06cade1ca | ||
|
|
c23a872887 | ||
|
|
cd5a51098b | ||
|
|
0b449347c1 | ||
|
|
d3f8876818 | ||
|
|
d2da81e039 | ||
|
|
66d88abdc5 | ||
|
|
be2b9a9c2e | ||
|
|
63ba273dbe | ||
|
|
8ae4c75c1a | ||
|
|
1cece1037a | ||
|
|
478873302c | ||
|
|
3cff01b21e | ||
|
|
f67848b043 | ||
|
|
f0c88ff6cc | ||
|
|
da5adc3e4e | ||
|
|
bebd2ea028 | ||
|
|
e7e25e1632 | ||
|
|
c4130fd5b9 | ||
|
|
dcad18b297 | ||
|
|
d380aad98c | ||
|
|
85c9c79699 | ||
|
|
107e0be59f | ||
|
|
e654170d0b | ||
|
|
652100f774 | ||
|
|
0f04f751e1 | ||
|
|
d3055dab54 | ||
|
|
643402c046 | ||
|
|
e95d5a7982 | ||
|
|
3103ef15e5 | ||
|
|
5d5e9e8219 | ||
|
|
99fd4500e4 | ||
|
|
69081ed647 | ||
|
|
c8bcd4f04f | ||
|
|
7f4fd83ad2 | ||
|
|
70722dfc9c | ||
|
|
407b4c53a6 | ||
|
|
c11551de7b | ||
|
|
2d5964b81e | ||
|
|
98e3c7b9cf | ||
|
|
2de57a8074 | ||
|
|
8badacf123 | ||
|
|
40b387a1e0 | ||
|
|
e5d2788736 | ||
|
|
f57436f511 | ||
|
|
75cc844676 | ||
|
|
5d05dd7089 | ||
|
|
35e108aa1c | ||
|
|
3f06b38767 | ||
|
|
9844c3d275 | ||
|
|
04eeb19bb9 | ||
|
|
671540af78 | ||
|
|
1a732f18fc | ||
|
|
729fb3bb9d | ||
|
|
fc56f273d4 | ||
|
|
642be969a3 | ||
|
|
d69cdacd5e | ||
|
|
ec8fc53dcb | ||
|
|
108d1762d6 | ||
|
|
879039ca8c | ||
|
|
047d1463e7 | ||
|
|
8fc60fa086 | ||
|
|
bce68ee37f | ||
|
|
2f25c128c1 | ||
|
|
a76a600d3f | ||
|
|
afdb419beb | ||
|
|
8bd2aa3dd3 | ||
|
|
a3f2c42b8e | ||
|
|
dbcd900056 | ||
|
|
5a6d6fae41 | ||
|
|
6bb666ba5e | ||
|
|
8dc80eadf3 | ||
|
|
505532f812 | ||
|
|
89eb02dad0 | ||
|
|
94a7b710c5 | ||
|
|
1e01f88c15 | ||
|
|
d471616a24 | ||
|
|
9b8f70a0ae | ||
|
|
ab079e9372 | ||
|
|
b77a16233b | ||
|
|
894075493b | ||
|
|
954512cd8e | ||
|
|
deaf6ad6a5 | ||
|
|
98443b48ba | ||
|
|
a7dc8793c2 | ||
|
|
84b8e5d472 | ||
|
|
1eb425897e | ||
|
|
e768072799 | ||
|
|
206706180f | ||
|
|
0e3b3a0e00 | ||
|
|
28733c1e97 | ||
|
|
7e74a26de2 | ||
|
|
6d920e0ed3 | ||
|
|
88e5f8a6af | ||
|
|
ae82ac8368 | ||
|
|
15cba58144 | ||
|
|
2c820f1cc0 | ||
|
|
4070492584 | ||
|
|
82a43e25e0 | ||
|
|
5e45d8f3bc | ||
|
|
e5709235f3 | ||
|
|
9d0bfdfa88 | ||
|
|
59c513f2a5 | ||
|
|
76a07315f3 | ||
|
|
5723f167df | ||
|
|
6ac328c979 | ||
|
|
85e52d24c3 | ||
|
|
7717c15666 | ||
|
|
61d7fd490a | ||
|
|
ccf952983b | ||
|
|
602d7ce207 | ||
|
|
64a51803c4 | ||
|
|
a63e68e9e3 | ||
|
|
d4d17fa167 | ||
|
|
4cd8d70de5 | ||
|
|
dc26c6f105 | ||
|
|
365954f5b4 | ||
|
|
ff38073280 | ||
|
|
eeebfde33c | ||
|
|
fa12233667 | ||
|
|
19477c4ee3 | ||
|
|
e4b56bac88 | ||
|
|
d390fa8671 | ||
|
|
f9f9c62608 | ||
|
|
d9966143c1 | ||
|
|
57ab82ae7a | ||
|
|
756f6d8abe | ||
|
|
f0840a51d9 | ||
|
|
eac8f8c2c6 | ||
|
|
20f61fc6a0 | ||
|
|
6454892d48 | ||
|
|
35d7c55c1d | ||
|
|
86fe6a802b | ||
|
|
d4568b631d | ||
|
|
2d30c0b588 | ||
|
|
1aea3fcac5 | ||
|
|
5cfd599018 | ||
|
|
d8f2462ff0 | ||
|
|
85091e1f3a | ||
|
|
3a8d19062f | ||
|
|
6913946079 | ||
|
|
fdd18317f9 | ||
|
|
a7c64c0df3 | ||
|
|
f2fb2f98f1 | ||
|
|
7aad664112 | ||
|
|
fa040ca19f | ||
|
|
00887bc24b | ||
|
|
ab8b557e73 | ||
|
|
5c0a59d2f6 | ||
|
|
29cbdf6cd5 | ||
|
|
08b68ef8ba | ||
|
|
1ae34e069c | ||
|
|
5bad829afc | ||
|
|
562cd5683a | ||
|
|
cbf2395009 | ||
|
|
c393483590 | ||
|
|
cbe1ec4e72 | ||
|
|
c6a92083a8 | ||
|
|
8c4b488251 | ||
|
|
3639dcc92a | ||
|
|
28113d1141 | ||
|
|
12b49abfdd | ||
|
|
68e0dfe736 | ||
|
|
29299e622e | ||
|
|
4ac0a6dad2 | ||
|
|
a510b4992c | ||
|
|
72e19f674a | ||
|
|
ea2a7e7505 | ||
|
|
74a2cedc7a | ||
|
|
c0a481acbe | ||
|
|
5998b54fec | ||
|
|
a2f6ea7b3a | ||
|
|
6a877c2a2e | ||
|
|
ee90a96d22 | ||
|
|
4ea7989aec | ||
|
|
fc52514c31 | ||
|
|
b9217da453 | ||
|
|
70badaf461 | ||
|
|
995acb238d | ||
|
|
5bee54a2bf | ||
|
|
7ec7ae7b95 | ||
|
|
09f208484a | ||
|
|
8aa51c4e80 | ||
|
|
823f06451c | ||
|
|
9d60c39aeb | ||
|
|
031e14f302 | ||
|
|
156b5d0b3c | ||
|
|
38909cfc42 | ||
|
|
3de779ea13 | ||
|
|
2339a0771e | ||
|
|
4972f00a96 | ||
|
|
502a1c021e | ||
|
|
d16773bfa0 | ||
|
|
511b827bf5 | ||
|
|
90634c4343 | ||
|
|
1e69f3b331 | ||
|
|
4273276422 | ||
|
|
fd25713746 | ||
|
|
37605d5732 | ||
|
|
df2967d576 | ||
|
|
9ab85ab799 | ||
|
|
1b6f039341 | ||
|
|
0abf4f415f | ||
|
|
b5a5f0f608 | ||
|
|
ffd2685922 | ||
|
|
9845567c2f | ||
|
|
6740ab61f3 | ||
|
|
3f0c3c1952 | ||
|
|
7142d69dda | ||
|
|
e3e248d601 | ||
|
|
a0a4ae611c | ||
|
|
0e420d8196 | ||
|
|
06d1bbdf03 | ||
|
|
87b034fa8c | ||
|
|
874d2b371d | ||
|
|
6134376964 | ||
|
|
eba5560caa | ||
|
|
e08665dee5 | ||
|
|
b8b341431a | ||
|
|
9e6072c371 | ||
|
|
af7b9c0dc8 | ||
|
|
1e3babe714 | ||
|
|
a663cca3d0 | ||
|
|
3580d060ce | ||
|
|
31ead371dc | ||
|
|
897cd7edeb | ||
|
|
683b72387b | ||
|
|
db8ed5b705 | ||
|
|
80bf2f9ebd | ||
|
|
854a9dd057 | ||
|
|
ae02ff5b0d | ||
|
|
0cf898a19f | ||
|
|
b5a8687b6a | ||
|
|
27077dcd6c | ||
|
|
80ecf77270 | ||
|
|
8d6225d6c5 | ||
|
|
8dcd372b99 | ||
|
|
c4d37392dc | ||
|
|
4ed3b65d12 | ||
|
|
86458927d6 | ||
|
|
49f189f257 | ||
|
|
9cc0aaeac2 | ||
|
|
2df99ef7ed | ||
|
|
450bda7d04 | ||
|
|
d18b8881d0 | ||
|
|
1b3ceed707 | ||
|
|
a33dc74c88 | ||
|
|
79fb0a34dc | ||
|
|
40f79e64b3 | ||
|
|
5f034d34b3 | ||
|
|
6d336c010c | ||
|
|
14c87e2f03 | ||
|
|
6ef15bddcd | ||
|
|
fbc6c078ef | ||
|
|
717601342f | ||
|
|
939fe951d4 | ||
|
|
a9a3623539 | ||
|
|
27966ad8ec | ||
|
|
5d1ebc6d31 | ||
|
|
a68c2a2be6 | ||
|
|
7145856d98 | ||
|
|
f305dba50c | ||
|
|
5523c77a04 | ||
|
|
83fde600f1 | ||
|
|
d7c60d9b11 | ||
|
|
4ca273c8c1 | ||
|
|
59a92d0363 | ||
|
|
26f5776d6d | ||
|
|
abee175f4f | ||
|
|
9ca41a7608 | ||
|
|
6efeeb1d64 | ||
|
|
2bc3352785 | ||
|
|
50460d4025 | ||
|
|
1bc02fd216 | ||
|
|
413941f5ac | ||
|
|
68854d56cc | ||
|
|
a78376a1c5 | ||
|
|
a95f1e583d | ||
|
|
0783507821 | ||
|
|
76621464f1 | ||
|
|
e9e507af69 | ||
|
|
af3d6eccfb | ||
|
|
21ede8671d | ||
|
|
e27cf33981 | ||
|
|
02c892aa07 | ||
|
|
a35c8be25c | ||
|
|
5302b27d55 | ||
|
|
5b357eb6b5 | ||
|
|
7d3f82eca0 | ||
|
|
20026f974f | ||
|
|
3d964fcdfa | ||
|
|
9e8b2957d0 | ||
|
|
c04e92a686 | ||
|
|
2dd6dd9233 | ||
|
|
e9b72776ed | ||
|
|
23df1f012c | ||
|
|
10be2c8176 | ||
|
|
e25e99456c | ||
|
|
e6236d8cfd | ||
|
|
75c50416f5 | ||
|
|
0808640ec0 | ||
|
|
7dad00523f | ||
|
|
63a4810b11 | ||
|
|
0e69f28e58 | ||
|
|
77f8999f1e | ||
|
|
0e4e0bff78 | ||
|
|
504840c15a | ||
|
|
b7919afd2f | ||
|
|
7c09dd1b70 | ||
|
|
679e967241 | ||
|
|
f21a31ce86 | ||
|
|
003f3cf8b1 | ||
|
|
3ec8a6955c | ||
|
|
fba4759ba1 | ||
|
|
ea2f39b18a | ||
|
|
ad77b3a6c9 | ||
|
|
097db4edcd | ||
|
|
e9e2c2f7b5 | ||
|
|
420fbf3d6f | ||
|
|
7a1cd0ff6a | ||
|
|
f83fed62ce | ||
|
|
72fdcb749f | ||
|
|
21a78e37d1 | ||
|
|
feed25b641 | ||
|
|
5e95d8d6ce | ||
|
|
6de8cbe9b9 | ||
|
|
9cf2db77b4 | ||
|
|
372fb38722 | ||
|
|
c35f8a3f08 | ||
|
|
6773af92ed | ||
|
|
4ed629a8ea | ||
|
|
67b2aa2ff0 | ||
|
|
72750267d0 | ||
|
|
36dc5e9a9b | ||
|
|
928d3282a2 | ||
|
|
a567f44b3c | ||
|
|
363b9f5be2 | ||
|
|
52725df296 | ||
|
|
e3cd7dbf34 | ||
|
|
593e72f905 | ||
|
|
80afcae645 | ||
|
|
f3fc774c2d | ||
|
|
8c2d72ad58 | ||
|
|
d0696628b2 | ||
|
|
368ab1dc66 | ||
|
|
b6f66a6a28 | ||
|
|
bda361cf57 | ||
|
|
1a6a0c12ec | ||
|
|
b645ad3fd8 | ||
|
|
b979594a04 | ||
|
|
6aceb898df | ||
|
|
c80201e3db | ||
|
|
ef5d2606b7 | ||
|
|
1e648a2dce | ||
|
|
11058b469c | ||
|
|
e79c32010d | ||
|
|
5e4742e51b | ||
|
|
38d30d23e5 | ||
|
|
47dd2cbd5d | ||
|
|
6b8db33a9c | ||
|
|
14a41b3108 | ||
|
|
81d1a809d2 | ||
|
|
441b505aa3 | ||
|
|
9b17c16325 | ||
|
|
c1f0eac8f8 | ||
|
|
83c285e17d | ||
|
|
5fabf892ba | ||
|
|
a000438277 | ||
|
|
e3fffd8fb2 | ||
|
|
0ba4e98f52 | ||
|
|
a1b75c3772 | ||
|
|
0712195de4 | ||
|
|
a133fa8d41 | ||
|
|
4d6c5a7ce7 | ||
|
|
5dfd1a495e | ||
|
|
a463fc289f | ||
|
|
a8e6d9b4b9 | ||
|
|
9ed7e80c44 | ||
|
|
596d55e413 | ||
|
|
71d4aa9ddd | ||
|
|
d630e7217a | ||
|
|
0e1a9d8619 | ||
|
|
d09668aaa6 | ||
|
|
827b0f6ad1 | ||
|
|
e8d85613b4 | ||
|
|
ce3b599bab | ||
|
|
47a7564cfc | ||
|
|
93aa08b60d | ||
|
|
4664e55513 | ||
|
|
7744146ef7 | ||
|
|
9cb72cff31 | ||
|
|
98119b3e54 | ||
|
|
f5a3e78cbe | ||
|
|
515d2656e7 | ||
|
|
339a21caaa | ||
|
|
a7c0d33de8 | ||
|
|
d1ef1afc9c | ||
|
|
bf4a1fcd7a | ||
|
|
d6044331e1 | ||
|
|
9fdb164b64 | ||
|
|
d691bf2714 | ||
|
|
cd366177ba | ||
|
|
6312884234 | ||
|
|
d1f26f0491 | ||
|
|
ed12e30517 | ||
|
|
cb363aca23 | ||
|
|
c753a8e92a | ||
|
|
c44276027c | ||
|
|
91fbd0864b | ||
|
|
c2c4047ffd | ||
|
|
9427ba96a2 | ||
|
|
7c11f0a3da | ||
|
|
6780ba82d0 | ||
|
|
6d643604d0 | ||
|
|
7ae4b451e4 | ||
|
|
bbd1f088d1 | ||
|
|
3060aecddb | ||
|
|
7a765cecd9 | ||
|
|
03e9592c79 | ||
|
|
c7b89f31dd | ||
|
|
899b230760 | ||
|
|
d787ef99c4 | ||
|
|
043ab008db | ||
|
|
4abc4e96a8 | ||
|
|
7fd8790750 | ||
|
|
8ba4a7b421 | ||
|
|
78ea9cbd16 | ||
|
|
727937b8ae | ||
|
|
5f4add3e22 | ||
|
|
c4f8afbaf7 | ||
|
|
528877f43f | ||
|
|
a3d61fa525 | ||
|
|
c0308fbc0d | ||
|
|
24696d0408 | ||
|
|
b9838fda8d | ||
|
|
726bd51abb | ||
|
|
1fe08d1234 | ||
|
|
1ca136fa75 | ||
|
|
fcecbe4967 | ||
|
|
d42a3f74ec | ||
|
|
dc44f1b618 | ||
|
|
0fde6dd908 | ||
|
|
4eb494baec | ||
|
|
c1b2f1f2c7 | ||
|
|
a6d2887847 | ||
|
|
f9895a492c | ||
|
|
ac56f70f6f | ||
|
|
f295392dca | ||
|
|
c89e283c8e | ||
|
|
3aa938a94b | ||
|
|
20fff97804 | ||
|
|
d1f7026804 | ||
|
|
2da31c4d4a | ||
|
|
975ba653a2 | ||
|
|
f12fabe030 | ||
|
|
25d75efa09 | ||
|
|
c8100712e8 | ||
|
|
a7b2c39f51 | ||
|
|
3d9fa5076b | ||
|
|
9fa715e403 | ||
|
|
9ee11b64e9 | ||
|
|
8c90d4ba98 | ||
|
|
b333c211f7 | ||
|
|
ab8386adea | ||
|
|
b180fe694f | ||
|
|
b7474b29e4 | ||
|
|
6c7d33f681 | ||
|
|
6946bc9da9 | ||
|
|
8fb566858f | ||
|
|
379e1470fd | ||
|
|
c5f0608b46 | ||
|
|
063d7e96a1 | ||
|
|
7544357826 | ||
|
|
5005817529 | ||
|
|
bfc3fbe397 | ||
|
|
30be7e0f7a | ||
|
|
18eb6600b0 | ||
|
|
88c0d04c18 | ||
|
|
8ea7bb907c | ||
|
|
0d2df72621 | ||
|
|
91c0a3e689 | ||
|
|
b595002c25 | ||
|
|
9ba7ab9975 | ||
|
|
7674bee5ad | ||
|
|
0ee28a335f | ||
|
|
285485c69e | ||
|
|
b8222bff43 | ||
|
|
3321ae0f1f | ||
|
|
4010047344 | ||
|
|
ce290a449c | ||
|
|
b9c151f549 | ||
|
|
2ff3e4acb9 | ||
|
|
ead32a4a65 | ||
|
|
a1e2d2fd74 | ||
|
|
122a721d7a | ||
|
|
6088fffc09 | ||
|
|
fadc46f3b5 | ||
|
|
e82b43e340 | ||
|
|
22886cb32d | ||
|
|
ef554ad67b | ||
|
|
b64b6fb3c7 | ||
|
|
8e73e76312 | ||
|
|
9d978ead6d | ||
|
|
655eb31107 | ||
|
|
15bd7bc068 | ||
|
|
65847547b9 | ||
|
|
ed9d31686e | ||
|
|
7f2a459575 | ||
|
|
126e87a746 | ||
|
|
2819cea509 | ||
|
|
2a8a403da7 | ||
|
|
3bda5537d7 | ||
|
|
df8b6cd53c | ||
|
|
e4fcadf39b | ||
|
|
c4f2f4ca66 | ||
|
|
0c1221dc07 | ||
|
|
d1c9e8b56e | ||
|
|
1b2122cd35 | ||
|
|
5aff2c74e6 | ||
|
|
f36ee36576 | ||
|
|
08a09bf371 | ||
|
|
1bdbb1b908 | ||
|
|
f1f97320df | ||
|
|
52cc3022c6 | ||
|
|
91e3943a74 | ||
|
|
23b871631a | ||
|
|
e975bacaa1 | ||
|
|
ab911d5c9e | ||
|
|
2c9e20dd87 | ||
|
|
487d5de0ef | ||
|
|
1653f003bc | ||
|
|
21ea6f82fc | ||
|
|
ca682d5879 | ||
|
|
cd3b11407b | ||
|
|
004208e9c8 | ||
|
|
44d2ea1263 | ||
|
|
27797b8f1d | ||
|
|
9aedccdb32 | ||
|
|
b1e1601ccf | ||
|
|
1dd66e6695 | ||
|
|
681d4bffd7 | ||
|
|
8f2a2d1c81 | ||
|
|
0bfd01e732 | ||
|
|
83a92a7e3a | ||
|
|
dbdbe29281 | ||
|
|
a2f67990b5 | ||
|
|
5511795fbb | ||
|
|
13f6f9f9e5 | ||
|
|
8c21d106fc | ||
|
|
e9165e5dd8 | ||
|
|
0a918b8fa8 | ||
|
|
102579baa9 | ||
|
|
b3f90e2981 | ||
|
|
2e8a9c75ee | ||
|
|
e8e245cc75 | ||
|
|
6199216c54 | ||
|
|
b988a4c526 | ||
|
|
45ba50e82c | ||
|
|
b6da116dfc | ||
|
|
a9e92d0593 | ||
|
|
db90e77a32 | ||
|
|
c2cad416e2 | ||
|
|
7191ca6915 | ||
|
|
fb3cb127b0 | ||
|
|
9f007d78f8 | ||
|
|
c5bdcec164 | ||
|
|
d4f9600ff1 | ||
|
|
8b260ce21c | ||
|
|
89be841e64 | ||
|
|
fc73dcc344 | ||
|
|
8d7c04bcda | ||
|
|
e4c17e5011 | ||
|
|
b03c3745a2 | ||
|
|
41eecfa7df | ||
|
|
38e9021e8c | ||
|
|
adc46b6ae5 | ||
|
|
8912bac0ac | ||
|
|
e4fca3c2b7 | ||
|
|
2855fff702 | ||
|
|
8dbf9f29cf | ||
|
|
cbebbd40f1 | ||
|
|
9aa778e44e | ||
|
|
6fc645a454 | ||
|
|
b977c4332f | ||
|
|
a79e214a6a | ||
|
|
6f11defea2 | ||
|
|
53ba48de9f | ||
|
|
3fb097bfff | ||
|
|
9138c3b676 | ||
|
|
2ceafcacc6 | ||
|
|
85935d8f90 | ||
|
|
ae5e1e6d29 | ||
|
|
bcfc704f7a | ||
|
|
e512feef74 | ||
|
|
b11e31e54b | ||
|
|
b1ed05e95e | ||
|
|
2095f90262 | ||
|
|
794a4ded9c | ||
|
|
cf4f779e59 | ||
|
|
c1c69d7272 | ||
|
|
a4e7219214 | ||
|
|
af8e5b60ee | ||
|
|
6ff7a59bfb | ||
|
|
95341806c3 | ||
|
|
e994aac162 | ||
|
|
ea926660fe | ||
|
|
223f6df371 | ||
|
|
869a879676 | ||
|
|
5959c426f3 | ||
|
|
33a6f81265 | ||
|
|
367bcbda83 | ||
|
|
e3114144e1 | ||
|
|
da4f891e2f | ||
|
|
e4a43fcca5 | ||
|
|
3ec676ca90 | ||
|
|
9da9c2750d | ||
|
|
d2641cc927 | ||
|
|
b026f5a481 | ||
|
|
45ddab519d | ||
|
|
0535e8c5f9 | ||
|
|
b4ec69ce7a | ||
|
|
989e4832cc | ||
|
|
0d777c24c5 | ||
|
|
101de3bac7 | ||
|
|
c3f83f74ce | ||
|
|
39e36936bc | ||
|
|
11248821c5 | ||
|
|
3074d814e7 | ||
|
|
22aa45f055 | ||
|
|
07f95acc29 | ||
|
|
2892bfa1d8 | ||
|
|
356ab9c6ae | ||
|
|
851d030878 | ||
|
|
f7ec5ca82e | ||
|
|
813f3dc09d | ||
|
|
172c77328b | ||
|
|
b213f43a91 | ||
|
|
284d39baa8 | ||
|
|
af4d0b4d3f | ||
|
|
83a487ecae | ||
|
|
bcb3b39bd8 | ||
|
|
ae59f95ba9 | ||
|
|
b2d4000b2d | ||
|
|
864d9239ce | ||
|
|
09c23b1241 | ||
|
|
22665f149b | ||
|
|
683d775c7e | ||
|
|
cb5b96485c | ||
|
|
4ee703325a | ||
|
|
3d6a8a501d | ||
|
|
0111d8806b | ||
|
|
d06fe83bd9 | ||
|
|
8936273aeb | ||
|
|
99a60ab22a | ||
|
|
7ec5d8265f | ||
|
|
1458e89f3a | ||
|
|
5846862d55 | ||
|
|
727d170c9c | ||
|
|
6c2c62ba2e | ||
|
|
f2ae858097 | ||
|
|
d236d89717 | ||
|
|
cac2a984ab | ||
|
|
e0b2ffa527 | ||
|
|
2c7919ace6 | ||
|
|
e9cd41722b | ||
|
|
ca0c6b5e6e | ||
|
|
b1d5ba890f | ||
|
|
9373fb3dd1 | ||
|
|
f8ca53fdf0 | ||
|
|
ddd5baf44e | ||
|
|
b365ad3ba4 | ||
|
|
63993b01aa | ||
|
|
ab784cede2 | ||
|
|
648a732352 | ||
|
|
e5b8b73077 | ||
|
|
8fe78fa12b | ||
|
|
f166cb7974 | ||
|
|
d3532eb734 | ||
|
|
4cb379bb0f | ||
|
|
316028fe66 | ||
|
|
543357f1db | ||
|
|
c93f52f3a8 | ||
|
|
9bf334a22d | ||
|
|
22e57ae95c | ||
|
|
a1af41804a | ||
|
|
b0080a5859 | ||
|
|
5c36692799 | ||
|
|
97877053bf | ||
|
|
f9a44a5fbb | ||
|
|
6b5bcaa279 | ||
|
|
8c396978a8 | ||
|
|
a51d2a6aec | ||
|
|
a863c17408 | ||
|
|
0924bb6ac0 | ||
|
|
910e67ff36 | ||
|
|
aa17f8203c | ||
|
|
a23cd5ad29 | ||
|
|
b7b6548cce | ||
|
|
17f1cb8648 | ||
|
|
d5dca95fbe | ||
|
|
bed7c1b283 | ||
|
|
0d25ef0b5b | ||
|
|
6b7d8d95f7 | ||
|
|
bafc0bd9cf | ||
|
|
7a87f74b22 | ||
|
|
49db63d888 | ||
|
|
363fc1b00b | ||
|
|
36a26fc2ce | ||
|
|
73b71c4914 | ||
|
|
dcfab218fb | ||
|
|
c79a597c96 | ||
|
|
a393f42b5e | ||
|
|
6ac58f2da7 | ||
|
|
a9f8bbbcce | ||
|
|
d65bddd30b | ||
|
|
b6cb539470 | ||
|
|
aea2b1ec6b | ||
|
|
5138f9a254 | ||
|
|
8cfa4ef1a1 | ||
|
|
16401044f6 | ||
|
|
c88b7ddc77 | ||
|
|
945a8ce92e | ||
|
|
8b012a96f3 | ||
|
|
8b6bb54efb | ||
|
|
2670589293 | ||
|
|
91eef1c4d9 | ||
|
|
562a5f6878 | ||
|
|
5c20fd71e1 | ||
|
|
adf093eca5 | ||
|
|
d14d286f24 | ||
|
|
ecd80aad6a | ||
|
|
1b248c24f1 | ||
|
|
f35f630695 | ||
|
|
8172ec9245 | ||
|
|
14c86b84b8 | ||
|
|
1f8f40011a | ||
|
|
2719be9075 | ||
|
|
354c119e99 | ||
|
|
f8faccd502 | ||
|
|
cc27c0159e | ||
|
|
b1bdb52851 | ||
|
|
d52e2cd585 | ||
|
|
2c613195cc | ||
|
|
1ca99e9967 | ||
|
|
f9ddc3cc5f | ||
|
|
63ccecf410 | ||
|
|
5b2470955d | ||
|
|
74b87b6006 | ||
|
|
9b65fbd98c | ||
|
|
3f3f0db2f2 | ||
|
|
48e3be875d | ||
|
|
c993ee51cc | ||
|
|
395f47516a | ||
|
|
589adb95c3 | ||
|
|
1fd5b975f1 | ||
|
|
67cff804a6 | ||
|
|
cde4af5930 | ||
|
|
928a8be846 | ||
|
|
7c641544b2 | ||
|
|
2a2aee21fb | ||
|
|
3838d947b1 | ||
|
|
ffc2b6c53c | ||
|
|
6494f890fe | ||
|
|
ba54bc9d15 | ||
|
|
083bfdba06 | ||
|
|
20b3ceab1e | ||
|
|
c246db1cf9 | ||
|
|
aa24f6a84d | ||
|
|
e489f713eb | ||
|
|
7edd40246c | ||
|
|
e15c0c6c7a | ||
|
|
a13c424869 | ||
|
|
8ee9f52634 | ||
|
|
b58abe4779 | ||
|
|
6d5be78dd0 | ||
|
|
07b0f24cf1 | ||
|
|
d7b874ac49 | ||
|
|
0a0978f7d7 | ||
|
|
25925751eb | ||
|
|
0ebfd6f017 | ||
|
|
81d1c0a4d5 | ||
|
|
36fe5627c7 | ||
|
|
9e43dadad8 | ||
|
|
2d463326e0 | ||
|
|
a6edfcc272 | ||
|
|
de4265a6d1 | ||
|
|
dc43a81899 | ||
|
|
e59c961f25 | ||
|
|
db715a1dba | ||
|
|
202d4122b4 | ||
|
|
f62f2341f4 | ||
|
|
e2fdacfddd | ||
|
|
c84a444f79 | ||
|
|
ee2d8f8c5a | ||
|
|
7db391d762 | ||
|
|
da4a20cb85 | ||
|
|
44f2217a68 | ||
|
|
6ce3c1d75d | ||
|
|
caa8cfbc0e | ||
|
|
02f361af73 | ||
|
|
a1e05c0c37 | ||
|
|
05affa5ad4 | ||
|
|
5e91af168b | ||
|
|
ae183210e0 | ||
|
|
56127dce6a | ||
|
|
0376467e6c | ||
|
|
48b55eed46 | ||
|
|
0ce043cca9 | ||
|
|
65dbafd2ec | ||
|
|
597073c9b6 | ||
|
|
d7ac326f92 | ||
|
|
3f36a30d1d | ||
|
|
b021746e9e | ||
|
|
975ec772fa | ||
|
|
442a4ff6e0 | ||
|
|
cea218b81a | ||
|
|
7970df27ad | ||
|
|
a24d2ce547 | ||
|
|
95707de8ec | ||
|
|
eb37066d5d | ||
|
|
f0983844c1 | ||
|
|
a0bd4e0f63 | ||
|
|
141ab8076f | ||
|
|
267f3d4877 | ||
|
|
460a41644d | ||
|
|
ca69d19bf7 | ||
|
|
d91fa5c6ef | ||
|
|
3610aa2e20 | ||
|
|
7a6da07a61 | ||
|
|
0f77fb88bf | ||
|
|
1bd19e1d8d | ||
|
|
61eeb82694 | ||
|
|
135adfecbd | ||
|
|
20b2017908 | ||
|
|
b1345038bd | ||
|
|
7ba627e243 | ||
|
|
6b453ef018 | ||
|
|
943dc6f5e6 | ||
|
|
4192869593 | ||
|
|
e066bb1e9d | ||
|
|
6cdc97848f | ||
|
|
ade7908229 | ||
|
|
9a2ab7fe21 | ||
|
|
213800f563 | ||
|
|
2c59992d3f | ||
|
|
87e39b8389 | ||
|
|
d825143b35 | ||
|
|
3d900a3849 | ||
|
|
0a51b752e6 | ||
|
|
c0d3f295ee | ||
|
|
1339b98281 | ||
|
|
bc508b6621 | ||
|
|
709783280a | ||
|
|
09b7c4021a | ||
|
|
f9aa1b5b35 | ||
|
|
2fffd8b43c | ||
|
|
741571a93a | ||
|
|
07a424e6f1 | ||
|
|
943d05f680 | ||
|
|
57aa2c69ac | ||
|
|
61e28a33e0 | ||
|
|
4055128d7f | ||
|
|
7c29e51bbb | ||
|
|
fabd420586 | ||
|
|
a12316f4dc | ||
|
|
548611f13a | ||
|
|
3397edecb4 | ||
|
|
b041a145b1 | ||
|
|
7046c3d6c3 | ||
|
|
67f58a4491 | ||
|
|
d74e4b1876 | ||
|
|
e757b74c87 | ||
|
|
4b41730636 | ||
|
|
23ecf9cf41 | ||
|
|
f8cfa35552 | ||
|
|
c1d0e802d9 | ||
|
|
bde7fad1c4 | ||
|
|
8007f5a3d3 | ||
|
|
83f955e469 | ||
|
|
29c53a7852 | ||
|
|
3825a1c359 | ||
|
|
ac82a25fa2 | ||
|
|
85e071049c | ||
|
|
ae22b7b444 | ||
|
|
9aba4d4357 | ||
|
|
17866f80bd | ||
|
|
5e46176c4e | ||
|
|
fa48c6f025 | ||
|
|
69c54d7207 | ||
|
|
7e06c97f51 | ||
|
|
c02eef352b | ||
|
|
924782a4d1 | ||
|
|
5c6e3dfd5c | ||
|
|
89dac1a77c | ||
|
|
6a16759e20 | ||
|
|
c355602924 | ||
|
|
08ad81f4b5 | ||
|
|
2c1b9b9095 | ||
|
|
965270dc7f | ||
|
|
4309bfd519 | ||
|
|
6ab3b89884 | ||
|
|
7a059ba294 | ||
|
|
8af64900d9 | ||
|
|
a59a7fe25e | ||
|
|
2fc8e2997d | ||
|
|
ede961a34a | ||
|
|
77764e1c77 | ||
|
|
8e114917a1 | ||
|
|
be599ca624 | ||
|
|
2c39e1e203 | ||
|
|
bf5a16b043 | ||
|
|
a7ec9138c3 | ||
|
|
c0f33e6b52 | ||
|
|
565336df21 | ||
|
|
6fe32cdd19 | ||
|
|
8fb67a914c | ||
|
|
6336c529ed | ||
|
|
556eb65320 | ||
|
|
4d41d36fe7 | ||
|
|
32d46ad7ac | ||
|
|
1f003cc292 | ||
|
|
2d4bc9dbd6 | ||
|
|
b33ea4679d | ||
|
|
b6a6fcd4e2 | ||
|
|
b03f2185ce | ||
|
|
a52d5faf4f | ||
|
|
b39f01471a | ||
|
|
73d9b4ef28 | ||
|
|
cda6567c4c | ||
|
|
a372b479b4 | ||
|
|
992196c91f | ||
|
|
9ffd4cc38d | ||
|
|
0bcaa5209c | ||
|
|
23dffb4ca2 | ||
|
|
98be07f5ef | ||
|
|
120c27d120 | ||
|
|
67a998c69f | ||
|
|
e3ddde9c90 | ||
|
|
0ec98d03e5 | ||
|
|
8680e5f06e | ||
|
|
23151ec3db | ||
|
|
7f0218e343 | ||
|
|
f8fc0439f8 | ||
|
|
1abd2a23cc | ||
|
|
e59a9d38ff | ||
|
|
bccc6b3680 | ||
|
|
7f6d17fc0e | ||
|
|
736b997104 | ||
|
|
69e6b164b9 | ||
|
|
fa48791c59 | ||
|
|
9a6565cd92 | ||
|
|
3cca6f6b8b | ||
|
|
f15f0570d4 | ||
|
|
240afbed95 | ||
|
|
548ea0f4b4 | ||
|
|
756fac7270 | ||
|
|
ba6f41fa1b | ||
|
|
d60709deff | ||
|
|
e9c618849d | ||
|
|
8477600859 | ||
|
|
15a8c8d420 | ||
|
|
5ef592f53e | ||
|
|
cff2022baf | ||
|
|
a6dc4fa38c | ||
|
|
8b681f3ba0 | ||
|
|
5575a37f9b | ||
|
|
15e58035e5 | ||
|
|
da4f7a3aba | ||
|
|
7542d95bc5 | ||
|
|
6a2a9bda06 | ||
|
|
222b34993b | ||
|
|
df70ea05c6 | ||
|
|
43d41fca95 | ||
|
|
95a8752a0a | ||
|
|
21a47a7b4b | ||
|
|
31336d47e2 | ||
|
|
3287c62f91 | ||
|
|
cf13c43637 | ||
|
|
eccbcbe53b | ||
|
|
04bc43e188 | ||
|
|
1e69ea2f1d | ||
|
|
886a099a2f | ||
|
|
7230b65dc3 | ||
|
|
842ac8ce39 | ||
|
|
e8c703fdbc | ||
|
|
9cf961c667 | ||
|
|
93b1c64482 | ||
|
|
faf85d0c82 | ||
|
|
d01fa85927 | ||
|
|
7ad1ace8dc | ||
|
|
132923e7db | ||
|
|
55cc3a0c07 | ||
|
|
415b70da14 | ||
|
|
243fd5e9dd | ||
|
|
7c1c8e877e | ||
|
|
95e50ddf02 | ||
|
|
b6b9ab1a87 | ||
|
|
4144746e14 | ||
|
|
f871300bfb | ||
|
|
2b559ffcce | ||
|
|
ef771beb28 | ||
|
|
09b966e507 | ||
|
|
4b9d0d1d31 | ||
|
|
3ece5acd59 | ||
|
|
0dd9867a1f | ||
|
|
ffca2f174d | ||
|
|
00282b79b8 | ||
|
|
e66f14a5c3 | ||
|
|
05f85c5201 | ||
|
|
2570357bec | ||
|
|
ca2830d6d8 | ||
|
|
795e6753eb | ||
|
|
ad0a007e8c | ||
|
|
168cc9c1bf | ||
|
|
11d1a68f78 | ||
|
|
a01336d8ac | ||
|
|
01bd9dd957 | ||
|
|
94c0222efe | ||
|
|
ad9d9c839b | ||
|
|
5b9d43032c | ||
|
|
bde8fbac98 | ||
|
|
013ad803d0 | ||
|
|
c29558db20 | ||
|
|
a45f1fde1c | ||
|
|
e81839e7ed | ||
|
|
6942a6fd6a | ||
|
|
385cb087d3 | ||
|
|
f1966768a7 | ||
|
|
4ba552fe1b | ||
|
|
ec918d57b2 | ||
|
|
408c86963b | ||
|
|
f8e910f0a4 | ||
|
|
8ad4b952ea | ||
|
|
925ebf08d4 | ||
|
|
eae5f6078e | ||
|
|
464587cec5 | ||
|
|
0c7c1dd0a8 | ||
|
|
22d357c53c | ||
|
|
cc695dc910 | ||
|
|
850752e0ea | ||
|
|
7b01f54fc6 | ||
|
|
9f2e94d9cd | ||
|
|
824c6f97e4 | ||
|
|
390c4a7706 | ||
|
|
efcb58a4a6 | ||
|
|
9e5d10b15f | ||
|
|
8fdc44aa89 | ||
|
|
cfa2363743 | ||
|
|
897c667f17 | ||
|
|
078bc1d914 | ||
|
|
d16d961cb2 | ||
|
|
23d487b904 | ||
|
|
af6de9b72c | ||
|
|
0ddfa94b59 | ||
|
|
ccbed8ec58 | ||
|
|
552818607a | ||
|
|
db8c34ae61 | ||
|
|
0886e850f9 | ||
|
|
5065fa42d0 | ||
|
|
2838b068f7 | ||
|
|
77c83a6a13 | ||
|
|
79a10ee833 | ||
|
|
10beb76585 | ||
|
|
71f150b587 | ||
|
|
6650541b2d | ||
|
|
702c4c123e | ||
|
|
1688b7d24e | ||
|
|
75fd4ff5e1 | ||
|
|
ac8f88da38 | ||
|
|
7873f1c26a | ||
|
|
a66c0c88ce | ||
|
|
f5e0662517 | ||
|
|
4334b9eac1 | ||
|
|
6e7a525d12 | ||
|
|
4a15cd7abe | ||
|
|
5d48ae1eec | ||
|
|
a71f931d9f | ||
|
|
6829e67e15 | ||
|
|
fc504012d5 | ||
|
|
8f675c7062 | ||
|
|
1ed0f86ed9 | ||
|
|
2dfef5a003 | ||
|
|
609df31e0c | ||
|
|
e61ae59e53 | ||
|
|
e3f4c33f03 | ||
|
|
af4919a98b | ||
|
|
939d2230d2 | ||
|
|
59b19eefe3 | ||
|
|
3c652bdcbc | ||
|
|
ded2352cf8 | ||
|
|
8cfda1a546 | ||
|
|
5f161e73c7 | ||
|
|
dfe24ed9d7 | ||
|
|
4661bea2f0 | ||
|
|
152d2c364b | ||
|
|
2d4963c2df | ||
|
|
0f1f151d65 | ||
|
|
d2a47b92c8 | ||
|
|
920232be4c | ||
|
|
83bf9229e7 | ||
|
|
178bb960e9 | ||
|
|
891acf30bf | ||
|
|
0e00881826 | ||
|
|
1d51b01bd1 | ||
|
|
52bb8b4a4d | ||
|
|
3d33233e51 | ||
|
|
4cd7561af8 | ||
|
|
52c813bcc7 | ||
|
|
c20c7ae11f | ||
|
|
5f582195ad | ||
|
|
a567a28c5c | ||
|
|
59713e2132 | ||
|
|
1e83a99bae | ||
|
|
b5fdb6d64f | ||
|
|
1411b701f9 | ||
|
|
f80257c5ea | ||
|
|
23cd063402 | ||
|
|
2bda12e5f9 | ||
|
|
2e285c8d86 | ||
|
|
70854de6ec | ||
|
|
f5cf5c7cc3 | ||
|
|
d939391e62 | ||
|
|
85e33e689d | ||
|
|
ca1a1228a9 | ||
|
|
d4ed238ae1 | ||
|
|
22baf4186e | ||
|
|
58b8052530 | ||
|
|
9d606d0006 | ||
|
|
d848ab4bef | ||
|
|
eaad63a082 | ||
|
|
ca6ddd609d | ||
|
|
eca40f94c9 | ||
|
|
86f51e3902 | ||
|
|
9f2b98b246 | ||
|
|
e4f3642082 | ||
|
|
48d62a1396 | ||
|
|
d9ed02a033 | ||
|
|
f87bf4df77 | ||
|
|
31ae382f75 | ||
|
|
c89b15fdbc | ||
|
|
5f787db30d | ||
|
|
da3c39c7d0 | ||
|
|
dbf759fc76 | ||
|
|
e88cf70719 | ||
|
|
1f442b9ea6 | ||
|
|
9ff5ce0d37 | ||
|
|
329c635da5 | ||
|
|
e18f3800be | ||
|
|
9bfc2c9413 | ||
|
|
4fbab08586 | ||
|
|
32490bfdb7 | ||
|
|
0d69ad43ab | ||
|
|
dc491a5984 | ||
|
|
e9386ec003 | ||
|
|
e5efc2957a | ||
|
|
20d7e56de2 | ||
|
|
0586e04d67 | ||
|
|
39bde61538 | ||
|
|
0fb92e6ebb | ||
|
|
c8d3653ef3 | ||
|
|
a5575c0876 | ||
|
|
1872e5d12f | ||
|
|
176f5e1377 | ||
|
|
c0e235c01a | ||
|
|
2faeb1071e | ||
|
|
e8aea38320 | ||
|
|
5f3fd85834 | ||
|
|
618ba56c42 | ||
|
|
d955dbff55 | ||
|
|
ee39283241 | ||
|
|
c42670259e | ||
|
|
fe6da62dab | ||
|
|
22a491717a | ||
|
|
816f410855 | ||
|
|
bb61ff97fa | ||
|
|
73d629d319 | ||
|
|
c630d705df | ||
|
|
c5bf167e36 | ||
|
|
e8420853e2 | ||
|
|
a8a5b733e5 | ||
|
|
30f8d5cf96 | ||
|
|
f09939a201 | ||
|
|
a99515e94a | ||
|
|
c4f7b99978 | ||
|
|
dcf73ec3f3 | ||
|
|
bbe0579cdd | ||
|
|
4390ffe3b6 | ||
|
|
6b93e61b56 | ||
|
|
c79d031c86 | ||
|
|
048399574e | ||
|
|
ae5a0312be | ||
|
|
0b2ffb3e91 | ||
|
|
d009edbbf3 | ||
|
|
1fbdf97639 | ||
|
|
a36303e1fb | ||
|
|
ce950d63cb | ||
|
|
27a3a1575d | ||
|
|
93d24d1cf7 | ||
|
|
bfb842d7ea | ||
|
|
5b62966863 | ||
|
|
3013386ca5 | ||
|
|
aedaf53137 | ||
|
|
7157efcf79 | ||
|
|
57ac1486a0 | ||
|
|
9a99ee6486 | ||
|
|
fd30bff9c6 | ||
|
|
3c022ad755 | ||
|
|
ca9b48283d | ||
|
|
c8fc416c88 | ||
|
|
804640216f | ||
|
|
2191bf2a22 | ||
|
|
7d92cdf015 | ||
|
|
386037d1db | ||
|
|
f2e216b9ac | ||
|
|
35d80eec1c | ||
|
|
eb8c38e4e8 | ||
|
|
74b2014dff | ||
|
|
689319437a | ||
|
|
9883e59f12 | ||
|
|
30396c5dca | ||
|
|
ec0d5e0c23 | ||
|
|
9e1de656c1 | ||
|
|
3c0bb11208 | ||
|
|
37dd95a4a0 | ||
|
|
6076eeed46 | ||
|
|
8b1dff6d15 | ||
|
|
9d4b58604b | ||
|
|
c49626aefc | ||
|
|
baeb200e8b | ||
|
|
11669849ab | ||
|
|
b171ed6dd0 | ||
|
|
0b74cf1d89 | ||
|
|
c558c85f36 | ||
|
|
0ac76e12c4 | ||
|
|
ee1ec414ed | ||
|
|
0e5dc21854 | ||
|
|
bf7df08305 | ||
|
|
ec63c822db | ||
|
|
09e4e44e88 | ||
|
|
c5e6821ad4 | ||
|
|
70fa78b987 | ||
|
|
8c78e35063 | ||
|
|
ea51ab8d0b | ||
|
|
62169cee3f | ||
|
|
e7e7b30807 | ||
|
|
107bdbc209 | ||
|
|
0b4615cbf0 | ||
|
|
126a75ed45 | ||
|
|
0703690190 | ||
|
|
7f56f0295b | ||
|
|
27154da191 | ||
|
|
0fb61fded3 | ||
|
|
ce00814bbf | ||
|
|
c758a3538b | ||
|
|
60fd2df4e6 | ||
|
|
05e38b43b9 | ||
|
|
9fa1eebc02 | ||
|
|
79ea1ee257 | ||
|
|
add405481e | ||
|
|
c382e03e4a | ||
|
|
ac73de31b5 | ||
|
|
ccd403b771 | ||
|
|
f28c460bb4 | ||
|
|
5711c939d7 | ||
|
|
ef3ce6d187 | ||
|
|
410e604dce | ||
|
|
8ffe2f3156 | ||
|
|
d27febf878 | ||
|
|
038f9659bb | ||
|
|
aeb896e200 | ||
|
|
349ba613dd | ||
|
|
714208a366 | ||
|
|
ad4c7d7c0b | ||
|
|
264c02eca1 | ||
|
|
ea42077320 | ||
|
|
125b9e4525 | ||
|
|
92ffb441a1 | ||
|
|
725526d733 | ||
|
|
31ea2e1d4b | ||
|
|
c81509d762 | ||
|
|
07f16165ff | ||
|
|
536f29eb0d | ||
|
|
e88e56421b | ||
|
|
369db7a63c | ||
|
|
81ec54fcb3 | ||
|
|
64d6bda728 | ||
|
|
7ab05d815d | ||
|
|
50a865e54e | ||
|
|
2156fb2a83 | ||
|
|
4e1087801a | ||
|
|
4dec152df0 | ||
|
|
720c2b8807 | ||
|
|
b00e0b6a73 | ||
|
|
6333f3aa47 | ||
|
|
7b127ebe8b | ||
|
|
585309f9f0 | ||
|
|
184ca3c662 | ||
|
|
bfea535df1 | ||
|
|
65189d6f3e | ||
|
|
de434bb398 | ||
|
|
804485a526 | ||
|
|
3f8fefcb0e | ||
|
|
178da3df26 | ||
|
|
aa9fd845ef | ||
|
|
e41ce16bbb | ||
|
|
f12403747d | ||
|
|
68e9fb0882 | ||
|
|
da3c3e8f5c | ||
|
|
a447887901 | ||
|
|
02b5fadf44 | ||
|
|
ea52d40a10 | ||
|
|
cbd4ea1aa8 | ||
|
|
f95c16a78e | ||
|
|
fa1cf7abb9 | ||
|
|
6a5afd7f95 | ||
|
|
d3bd434255 | ||
|
|
0ddd9b2487 | ||
|
|
ee1e75f978 | ||
|
|
6e83bee23f | ||
|
|
49e057e726 | ||
|
|
7f3e4eb534 | ||
|
|
97c8ace8f7 | ||
|
|
a3ca79e995 | ||
|
|
50a96ce340 | ||
|
|
962c9543d0 | ||
|
|
52bcef54be | ||
|
|
22030027b4 | ||
|
|
53a9093a70 | ||
|
|
6ef793f202 | ||
|
|
b227ff8e1b | ||
|
|
38162e1af2 | ||
|
|
db6b9ffa6e | ||
|
|
5a0c809fa7 | ||
|
|
f6caed3ffd | ||
|
|
6141516eb2 | ||
|
|
b0478f6604 | ||
|
|
6cae3256a9 | ||
|
|
3b295b5482 | ||
|
|
adc395fc3d | ||
|
|
0c52898010 | ||
|
|
4fe6a74d20 | ||
|
|
7fca18d7be | ||
|
|
208756bdd2 | ||
|
|
82a072bd87 | ||
|
|
6d76d11837 | ||
|
|
982f1e007a | ||
|
|
a4bf545993 | ||
|
|
fe51f6a504 | ||
|
|
f5d311ca44 | ||
|
|
b81dd99825 | ||
|
|
f08457aba7 | ||
|
|
c601e5dcb4 | ||
|
|
11b82b3459 | ||
|
|
d322665789 | ||
|
|
49529627f8 | ||
|
|
107746feec | ||
|
|
cb9d09a930 | ||
|
|
e5bef55d47 | ||
|
|
17dd02ed4e | ||
|
|
7bb34fe090 | ||
|
|
abbaee0274 | ||
|
|
3d1cd3193a | ||
|
|
be53cd8b48 | ||
|
|
4b20ea7232 | ||
|
|
ffd7831efc | ||
|
|
f8636d20c2 | ||
|
|
3b4dd7e633 | ||
|
|
ea101e65bb | ||
|
|
8a713c3880 | ||
|
|
42d5650bc0 | ||
|
|
ee2bc2282a | ||
|
|
34565dc675 | ||
|
|
6e57de3220 | ||
|
|
033e78c0a7 | ||
|
|
5257716e1a | ||
|
|
47b95af8ae | ||
|
|
f20bfb025b | ||
|
|
9671259f5c | ||
|
|
07dddd857b | ||
|
|
19353fc1d0 | ||
|
|
1fccd70379 | ||
|
|
fdb0cf509d | ||
|
|
056d61a28d | ||
|
|
d3a7950e78 | ||
|
|
3ddd51d4cb | ||
|
|
f879a34021 | ||
|
|
61531171c9 | ||
|
|
3b459b6857 | ||
|
|
be5882edb3 | ||
|
|
428d4fc6ab | ||
|
|
07d9315bbe | ||
|
|
ee05a6852e | ||
|
|
64b3e7ad50 | ||
|
|
822934828a | ||
|
|
8866923efa | ||
|
|
06ef114395 | ||
|
|
918a3ef115 | ||
|
|
5e9e570b99 | ||
|
|
3e6c672a1e | ||
|
|
387a38a1c8 | ||
|
|
78e86c7c55 | ||
|
|
de1e6d3b27 | ||
|
|
9c9837d722 | ||
|
|
494ceca44e | ||
|
|
cb324733bf | ||
|
|
ad6d817f32 | ||
|
|
e5ae2f6ef6 | ||
|
|
3425f2e390 | ||
|
|
3d0f7d6855 | ||
|
|
d322c6b5b5 | ||
|
|
a7dff0effe | ||
|
|
f456912679 | ||
|
|
0d204426e6 | ||
|
|
4c5ece8249 | ||
|
|
e7ae9049bb | ||
|
|
d40344aa92 | ||
|
|
e3e273a688 | ||
|
|
e9a0be4941 | ||
|
|
9ca9ab63f5 | ||
|
|
261241fcc8 | ||
|
|
3c108a271d | ||
|
|
e5f97ace8b | ||
|
|
e807b3ca74 | ||
|
|
90154aec83 | ||
|
|
d7333ec858 | ||
|
|
e6e90799ef | ||
|
|
8fd9c1a292 | ||
|
|
489470639a | ||
|
|
b79377d5a1 | ||
|
|
a22703d547 | ||
|
|
3b8bcc4da5 | ||
|
|
a5b764fb66 | ||
|
|
9bd968f6c3 | ||
|
|
e3f1fced99 | ||
|
|
12ae940ed6 | ||
|
|
b325f8c524 | ||
|
|
ca7c8906a5 | ||
|
|
a3b0c56182 | ||
|
|
f29a56f3fe | ||
|
|
36c7697c2b | ||
|
|
3355419761 | ||
|
|
3cb96b32a6 | ||
|
|
8d57aa1f06 | ||
|
|
f2749c67f3 | ||
|
|
f95da34fd1 | ||
|
|
7e9cfa0858 | ||
|
|
6fb4adc27d | ||
|
|
723212c918 | ||
|
|
086b41d958 | ||
|
|
c064ef6ace | ||
|
|
f46296a2bb | ||
|
|
7b2a1cfd10 | ||
|
|
1b843da785 | ||
|
|
8db7326a5a | ||
|
|
3f49944c05 | ||
|
|
2f0d4d6068 | ||
|
|
dd68572603 | ||
|
|
03ee5c7c31 | ||
|
|
4d5662fee4 | ||
|
|
565aa9616b | ||
|
|
62ef1768ec | ||
|
|
c659adb4be | ||
|
|
3691ba8242 | ||
|
|
32c39f7af9 | ||
|
|
3748102bb0 | ||
|
|
a87b604153 | ||
|
|
4597bfa5d7 | ||
|
|
f30cf70226 | ||
|
|
ba4253da79 | ||
|
|
58b08f2c33 | ||
|
|
ac240398ef | ||
|
|
1f2d05c5a4 | ||
|
|
e05f2198d5 | ||
|
|
95df317f56 | ||
|
|
f61f520a4b | ||
|
|
864225a0dc | ||
|
|
5628da2f80 | ||
|
|
000c46bf57 | ||
|
|
66919a1aba | ||
|
|
7b9fd8ac63 | ||
|
|
6ac1a43d44 | ||
|
|
99af881cdd | ||
|
|
008ec104b6 | ||
|
|
b0859f91b2 | ||
|
|
22ee9916dd | ||
|
|
1df2c89cdb | ||
|
|
2fbe2b2fa6 | ||
|
|
71935e29c8 | ||
|
|
d293d637b5 | ||
|
|
04a8249883 | ||
|
|
123b853205 | ||
|
|
a196c276c9 | ||
|
|
dfe2cf631f | ||
|
|
d6913b6439 | ||
|
|
bad73c1805 | ||
|
|
6602bddb2b | ||
|
|
32cd8bb3cb | ||
|
|
5950034f53 | ||
|
|
d18ebdfc59 | ||
|
|
604c3ba266 | ||
|
|
b8d063a4f7 | ||
|
|
3c30415982 | ||
|
|
c5252dc27d | ||
|
|
6016db2533 | ||
|
|
b23f14b798 | ||
|
|
09d52f9fbe | ||
|
|
c23e529f0a | ||
|
|
ab7cb5f681 | ||
|
|
db27e5a92c | ||
|
|
66109afb0d | ||
|
|
b6f1fd5a4a | ||
|
|
44a0913b81 | ||
|
|
4c569c0ded | ||
|
|
3d5c156776 | ||
|
|
6c81dcdc76 | ||
|
|
906f24f0ee | ||
|
|
bca35600ff | ||
|
|
a9dc5e9be4 | ||
|
|
90fa4a8f77 | ||
|
|
d325734c16 | ||
|
|
aa882aa36a | ||
|
|
bdbb1dcf8e | ||
|
|
2ada9dcd40 | ||
|
|
95cc74c076 | ||
|
|
d59a31a65a | ||
|
|
38e4832b6a | ||
|
|
6b6dc9fb24 | ||
|
|
a1b6fc5a7b | ||
|
|
6ac0e887f7 | ||
|
|
bdb7e62921 | ||
|
|
445e376675 | ||
|
|
bedbd9c5d5 | ||
|
|
34236fca7c | ||
|
|
f74d651b85 | ||
|
|
41a93af89e | ||
|
|
e5b1615c61 | ||
|
|
2ef340712f | ||
|
|
3841d1e7b8 | ||
|
|
675ecc608c | ||
|
|
3625e41e97 | ||
|
|
ff8fecbd05 | ||
|
|
a91a8d2a4b | ||
|
|
83c03474a9 | ||
|
|
f55aac46f1 | ||
|
|
f1b5ee2a5f | ||
|
|
97008b9caa | ||
|
|
b3038e557c | ||
|
|
61e29bcff9 | ||
|
|
a512884b65 | ||
|
|
46fbd6aa49 | ||
|
|
fc29943d0f | ||
|
|
482a609d84 | ||
|
|
b7d869ad23 | ||
|
|
321161ede9 | ||
|
|
b5ad0895ac | ||
|
|
427cef9f9d | ||
|
|
816fb3bf01 | ||
|
|
44bbb472d3 | ||
|
|
aba49897f9 | ||
|
|
96121a86f8 | ||
|
|
ea2193a42d | ||
|
|
3db1486bfb | ||
|
|
9e4fe40ca3 | ||
|
|
d9b4ad64bb | ||
|
|
7562407a0c | ||
|
|
0bc244b9f1 | ||
|
|
7ab373ecac | ||
|
|
14e0d80042 | ||
|
|
5555916de3 | ||
|
|
ef09912d1b | ||
|
|
c675d1c498 | ||
|
|
5977251a20 | ||
|
|
a4c027dc48 | ||
|
|
9f40cba914 | ||
|
|
5ba2c181b0 | ||
|
|
2fc404a55c | ||
|
|
2baa10dcef | ||
|
|
d08a318a2c | ||
|
|
96f3218ec6 | ||
|
|
57eddac7f0 | ||
|
|
af115b49aa | ||
|
|
332f9a2f5e | ||
|
|
2b3d132db6 | ||
|
|
f1361a698d | ||
|
|
34eef3553b | ||
|
|
9e4ce42b6a | ||
|
|
4c4a91ae95 | ||
|
|
93d46d5c5b | ||
|
|
8788d4f898 | ||
|
|
e28650c46c | ||
|
|
855c11f02c | ||
|
|
3f8e91bd46 | ||
|
|
6722e45109 | ||
|
|
414383638d | ||
|
|
2575b79c05 | ||
|
|
c7cab4c877 | ||
|
|
85c2f0ba30 | ||
|
|
edfbede704 | ||
|
|
5f60cb821a | ||
|
|
8486c1117d | ||
|
|
ad3785ff41 | ||
|
|
61f24562fd | ||
|
|
28de93d0ff | ||
|
|
1fd85b729d | ||
|
|
5681ae3f5c | ||
|
|
9d9e0976ae | ||
|
|
6180837636 | ||
|
|
17beaf7d4f | ||
|
|
ce8f471b27 | ||
|
|
b3e36fdd99 | ||
|
|
f971ddf1fa | ||
|
|
c0c37922c3 | ||
|
|
1eb9e58331 | ||
|
|
f8a35a110c | ||
|
|
c4d13fb5b7 | ||
|
|
53a44853b3 | ||
|
|
29aa3617d8 | ||
|
|
addf3e2521 | ||
|
|
5826f8fa1e | ||
|
|
965d89fd91 | ||
|
|
ed69591bcf | ||
|
|
f1f6c48128 | ||
|
|
f8bd062aa2 | ||
|
|
77835bcb9d | ||
|
|
bf5821c8c8 | ||
|
|
a2e23014f4 | ||
|
|
811c14a6bd | ||
|
|
a34d87148b | ||
|
|
a45a8db479 | ||
|
|
672f71c515 | ||
|
|
2c16bbb0e9 | ||
|
|
63f7709e82 | ||
|
|
15b13ef4a4 | ||
|
|
75303c7a34 | ||
|
|
1a6048f0ab | ||
|
|
ae6a408c05 | ||
|
|
1015cbfa94 | ||
|
|
876feef53f | ||
|
|
f5f0329d39 | ||
|
|
80a7b6d8d5 | ||
|
|
f72e17c12e | ||
|
|
f570b2762f | ||
|
|
e2fda99578 | ||
|
|
d7d45146c8 | ||
|
|
45dbc6c6f6 | ||
|
|
d76e3a5939 | ||
|
|
cb8fdb5e8d | ||
|
|
d337bf3ee2 | ||
|
|
758e4d4f4c | ||
|
|
ccab8b16bf | ||
|
|
cd2bda4b49 | ||
|
|
493ea0641d | ||
|
|
ca1b6553c9 | ||
|
|
d479715d8e | ||
|
|
e3109a8fec | ||
|
|
e6bc5bef33 | ||
|
|
d82a7169b7 | ||
|
|
ba48b6f7ce | ||
|
|
8e1cf997f7 | ||
|
|
70d8548c92 | ||
|
|
cce7dd917f | ||
|
|
3dafb284a9 | ||
|
|
6a599a9a30 | ||
|
|
74fb292633 | ||
|
|
c6e063ea2f | ||
|
|
81d563381a | ||
|
|
870e895144 | ||
|
|
343d1345e2 | ||
|
|
517cf613c1 | ||
|
|
d54bcc898b | ||
|
|
704e1741ed | ||
|
|
ad5ce6dba4 | ||
|
|
1ed20a95df | ||
|
|
1718ddd4c3 | ||
|
|
32c2db2153 | ||
|
|
0abb9cbb7c | ||
|
|
e2e71c7a46 | ||
|
|
e27bdd3e2b | ||
|
|
5839ed428e | ||
|
|
af6d115dbb | ||
|
|
194968d16f | ||
|
|
30686bd322 | ||
|
|
175c645777 | ||
|
|
587a259843 | ||
|
|
64749ca726 | ||
|
|
8f2493dadb | ||
|
|
7d8ea075d9 | ||
|
|
328327e5dc | ||
|
|
fd1816d451 | ||
|
|
72f25b873c | ||
|
|
994656953c | ||
|
|
ed46232b83 | ||
|
|
dca18a1c66 | ||
|
|
c291ee1789 | ||
|
|
caf15351f7 | ||
|
|
6be9f23790 | ||
|
|
adc51f6217 | ||
|
|
ec8a46ede6 | ||
|
|
b78fdf5a23 | ||
|
|
7c2493f3fa | ||
|
|
b0a0ad11b4 | ||
|
|
377f71eb52 | ||
|
|
eefe343973 | ||
|
|
41a6674fad | ||
|
|
effba92729 | ||
|
|
c9f4bdda17 | ||
|
|
b5c2073414 | ||
|
|
25aacb5046 | ||
|
|
c24724dcdf | ||
|
|
64ab14f995 | ||
|
|
4a64c0dfa5 | ||
|
|
0ebe0a5dc9 | ||
|
|
c683a52a01 | ||
|
|
5fbdd0bb2a | ||
|
|
599881366b | ||
|
|
4c294b010d | ||
|
|
418c32e334 | ||
|
|
12b605e5cc | ||
|
|
cdfde05452 | ||
|
|
7bf8fea9f2 | ||
|
|
a5dd141934 | ||
|
|
7970f4ae88 | ||
|
|
96a41400f4 | ||
|
|
6cc2f20638 | ||
|
|
730c1ae2d7 | ||
|
|
105cccf9b0 | ||
|
|
2f3e498906 | ||
|
|
82a808529b | ||
|
|
477f3bd70a | ||
|
|
a874cdfb56 | ||
|
|
f493da4eac | ||
|
|
e7ad857cc9 | ||
|
|
f968faeaf9 | ||
|
|
c3c44713ef | ||
|
|
f6cae729a7 | ||
|
|
b1e32ed55f | ||
|
|
2f27d9279d |
31
.github/dependabot.yml
vendored
31
.github/dependabot.yml
vendored
@@ -1,24 +1,42 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
versioning-strategy: increase
|
||||
directory: "/backend"
|
||||
schedule:
|
||||
interval: daily
|
||||
open-pull-requests-limit: 10
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
update-types:
|
||||
["version-update:semver-major", "version-update:semver-patch"]
|
||||
allow:
|
||||
- dependency-type: "production"
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: "/frontend"
|
||||
versioning-strategy: increase
|
||||
groups:
|
||||
frontend-angular-dependencies:
|
||||
patterns:
|
||||
- "@angular*"
|
||||
- "@ng-*"
|
||||
- "ngx-*"
|
||||
frontend-jest-dependencies:
|
||||
patterns:
|
||||
- "@types/jest"
|
||||
- "jest"
|
||||
frontend-eslint-dependencies:
|
||||
patterns:
|
||||
- "@typescript-eslint*"
|
||||
- "eslint"
|
||||
schedule:
|
||||
interval: daily
|
||||
open-pull-requests-limit: 10
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
update-types:
|
||||
["version-update:semver-major", "version-update:semver-patch"]
|
||||
allow:
|
||||
- dependency-type: "production"
|
||||
|
||||
@@ -28,7 +46,8 @@ updates:
|
||||
interval: weekly
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
update-types:
|
||||
["version-update:semver-major", "version-update:semver-patch"]
|
||||
|
||||
- package-ecosystem: docker
|
||||
directory: "/docker/frontend"
|
||||
@@ -36,7 +55,8 @@ updates:
|
||||
interval: weekly
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
update-types:
|
||||
["version-update:semver-major", "version-update:semver-patch"]
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
@@ -44,4 +64,5 @@ updates:
|
||||
interval: weekly
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
update-types:
|
||||
["version-update:semver-major", "version-update:semver-patch"]
|
||||
|
||||
276
.github/workflows/ci.yml
vendored
276
.github/workflows/ci.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')"
|
||||
strategy:
|
||||
matrix:
|
||||
node: ["16.16.0", "18.14.1"]
|
||||
node: ["20", "21"]
|
||||
flavor: ["dev", "prod"]
|
||||
fail-fast: false
|
||||
runs-on: "ubuntu-latest"
|
||||
@@ -27,6 +27,18 @@ jobs:
|
||||
node-version: ${{ matrix.node }}
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Read rust-toolchain file from repository
|
||||
id: gettoolchain
|
||||
run: echo "::set-output name=toolchain::$(cat rust-toolchain)"
|
||||
working-directory: ${{ matrix.node }}/${{ matrix.flavor }}
|
||||
|
||||
- name: Install ${{ steps.gettoolchain.outputs.toolchain }} Rust toolchain
|
||||
# Latest version available on this commit is 1.71.1
|
||||
# Commit date is Aug 3, 2023
|
||||
uses: dtolnay/rust-toolchain@be73d7920c329f220ce78e0234b8f96b7ae60248
|
||||
with:
|
||||
toolchain: ${{ steps.gettoolchain.outputs.toolchain }}
|
||||
|
||||
- name: Install
|
||||
if: ${{ matrix.flavor == 'dev'}}
|
||||
run: npm ci
|
||||
@@ -44,18 +56,111 @@ jobs:
|
||||
|
||||
- name: Unit Tests
|
||||
if: ${{ matrix.flavor == 'dev'}}
|
||||
run: npm run test
|
||||
run: npm run test:ci
|
||||
working-directory: ${{ matrix.node }}/${{ matrix.flavor }}/backend
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
working-directory: ${{ matrix.node }}/${{ matrix.flavor }}/backend
|
||||
|
||||
|
||||
cache:
|
||||
name: "Cache assets for builds"
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
path: assets
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Install (Prod dependencies only)
|
||||
run: npm ci --omit=dev --omit=optional
|
||||
working-directory: assets/frontend
|
||||
|
||||
- name: Restore cached mining pool assets
|
||||
continue-on-error: true
|
||||
id: cache-mining-pool-restore
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: |
|
||||
mining-pool-assets.zip
|
||||
key: mining-pool-assets-cache
|
||||
|
||||
- name: Restore promo video assets
|
||||
continue-on-error: true
|
||||
id: cache-promo-video-restore
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: |
|
||||
promo-video-assets.zip
|
||||
key: promo-video-assets-cache
|
||||
|
||||
- name: Unzip assets before building (src/resources)
|
||||
continue-on-error: true
|
||||
run: unzip -o mining-pool-assets.zip -d assets/frontend/src/resources/mining-pools
|
||||
|
||||
- name: Unzip assets before building (src/resources)
|
||||
continue-on-error: true
|
||||
run: unzip -o promo-video-assets.zip -d assets/frontend/src/resources/promo-video
|
||||
|
||||
# - name: Unzip assets before building (dist)
|
||||
# continue-on-error: true
|
||||
# run: unzip assets.zip -d assets/frontend/dist/mempool/browser/resources
|
||||
|
||||
- name: Sync-assets
|
||||
run: npm run sync-assets-dev
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
MEMPOOL_CDN: 1
|
||||
VERBOSE: 1
|
||||
working-directory: assets/frontend
|
||||
|
||||
- name: Zip mining-pool assets
|
||||
run: zip -jrq mining-pool-assets.zip assets/frontend/src/resources/mining-pools/*
|
||||
|
||||
- name: Zip promo-video assets
|
||||
run: zip -jrq promo-video-assets.zip assets/frontend/src/resources/promo-video/*
|
||||
|
||||
- name: Upload mining pool assets as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mining-pool-assets
|
||||
path: mining-pool-assets.zip
|
||||
|
||||
- name: Upload promo video assets as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: promo-video-assets
|
||||
path: promo-video-assets.zip
|
||||
|
||||
- name: Save mining pool assets cache
|
||||
id: cache-mining-pool-save
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: |
|
||||
mining-pool-assets.zip
|
||||
key: mining-pool-assets-cache
|
||||
|
||||
- name: Save promo video assets cache
|
||||
id: cache-promo-video-save
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: |
|
||||
promo-video-assets.zip
|
||||
key: promo-video-assets-cache
|
||||
|
||||
frontend:
|
||||
needs: cache
|
||||
if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')"
|
||||
strategy:
|
||||
matrix:
|
||||
node: ["16.16.0", "18.14.1"]
|
||||
node: ["20", "21"]
|
||||
flavor: ["dev", "prod"]
|
||||
fail-fast: false
|
||||
runs-on: "ubuntu-latest"
|
||||
@@ -91,6 +196,171 @@ jobs:
|
||||
# - name: Test
|
||||
# run: npm run test
|
||||
|
||||
- name: Restore cached mining pool assets
|
||||
continue-on-error: true
|
||||
id: cache-mining-pool-restore
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: |
|
||||
mining-pool-assets.zip
|
||||
key: mining-pool-assets-cache
|
||||
|
||||
- name: Restore promo video assets
|
||||
continue-on-error: true
|
||||
id: cache-promo-video-restore
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: |
|
||||
promo-video-assets.zip
|
||||
key: promo-video-assets-cache
|
||||
|
||||
- name: Download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: mining-pool-assets
|
||||
|
||||
- name: Unzip assets before building (src/resources)
|
||||
run: unzip -o mining-pool-assets.zip -d ${{ matrix.node }}/${{ matrix.flavor }}/frontend/src/resources/mining-pools
|
||||
|
||||
- name: Download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: promo-video-assets
|
||||
|
||||
- name: Unzip assets before building (src/resources)
|
||||
run: unzip -o promo-video-assets.zip -d ${{ matrix.node }}/${{ matrix.flavor }}/frontend/src/resources/promo-video
|
||||
|
||||
# - name: Unzip assets before building (dist)
|
||||
# run: unzip assets.zip -d ${{ matrix.node }}/${{ matrix.flavor }}/frontend/dist/mempool/browser/resources
|
||||
|
||||
- name: Display resulting source tree
|
||||
run: ls -R
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
working-directory: ${{ matrix.node }}/${{ matrix.flavor }}/frontend
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
MEMPOOL_CDN: 1
|
||||
VERBOSE: 1
|
||||
|
||||
e2e:
|
||||
if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')"
|
||||
runs-on: "ubuntu-latest"
|
||||
needs: frontend
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# module: ["mempool", "liquid", "bisq"] Disabling bisq support for now
|
||||
module: ["mempool", "liquid"]
|
||||
include:
|
||||
- module: "mempool"
|
||||
spec: |
|
||||
cypress/e2e/mainnet/*.spec.ts
|
||||
cypress/e2e/signet/*.spec.ts
|
||||
cypress/e2e/testnet/*.spec.ts
|
||||
- module: "liquid"
|
||||
spec: |
|
||||
cypress/e2e/liquid/liquid.spec.ts
|
||||
cypress/e2e/liquidtestnet/liquidtestnet.spec.ts
|
||||
# - module: "bisq"
|
||||
# spec: |
|
||||
# cypress/e2e/bisq/bisq.spec.ts
|
||||
|
||||
name: E2E tests for ${{ matrix.module }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
path: ${{ matrix.module }}
|
||||
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "npm"
|
||||
cache-dependency-path: ${{ matrix.module }}/frontend/package-lock.json
|
||||
|
||||
- name: Restore cached mining pool assets
|
||||
continue-on-error: true
|
||||
id: cache-mining-pool-restore
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: |
|
||||
mining-pool-assets.zip
|
||||
key: mining-pool-assets-cache
|
||||
|
||||
- name: Restore cached promo video assets
|
||||
continue-on-error: true
|
||||
id: cache-promo-video-restore
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: |
|
||||
promo-video-assets.zip
|
||||
key: promo-video-assets-cache
|
||||
|
||||
- name: Download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: mining-pool-assets
|
||||
|
||||
- name: Unzip assets before building (src/resources)
|
||||
run: unzip -o mining-pool-assets.zip -d ${{ matrix.module }}/frontend/src/resources/mining-pools
|
||||
|
||||
- name: Download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: promo-video-assets
|
||||
|
||||
- name: Unzip assets before building (src/resources)
|
||||
run: unzip -o promo-video-assets.zip -d ${{ matrix.module }}/frontend/src/resources/promo-video
|
||||
|
||||
- name: Chrome browser tests (${{ matrix.module }})
|
||||
uses: cypress-io/github-action@v5
|
||||
with:
|
||||
tag: ${{ github.event_name }}
|
||||
working-directory: ${{ matrix.module }}/frontend
|
||||
build: npm run config:defaults:${{ matrix.module }}
|
||||
start: npm run start:local-staging
|
||||
wait-on: "http://localhost:4200"
|
||||
wait-on-timeout: 120
|
||||
record: true
|
||||
parallel: true
|
||||
spec: ${{ matrix.spec }}
|
||||
group: Tests on Chrome (${{ matrix.module }})
|
||||
browser: "chrome"
|
||||
ci-build-id: "${{ github.sha }}-${{ github.workflow }}-${{ github.event_name }}"
|
||||
env:
|
||||
COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title }}
|
||||
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
|
||||
|
||||
validate_docker_json:
|
||||
if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')"
|
||||
runs-on: "ubuntu-latest"
|
||||
name: Validate generated backend Docker JSON
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
path: docker
|
||||
|
||||
- name: Install jq
|
||||
run: sudo apt-get install jq -y
|
||||
|
||||
- name: Create new start script to run on CI
|
||||
run: |
|
||||
sed '$d' start.sh > start_ci.sh
|
||||
working-directory: docker/docker/backend
|
||||
|
||||
- name: Run the script to generate the sample JSON
|
||||
run: |
|
||||
sh start_ci.sh
|
||||
working-directory: docker/docker/backend
|
||||
|
||||
- name: Validate JSON syntax
|
||||
run: |
|
||||
cat mempool-config.json | jq
|
||||
working-directory: docker/docker/backend
|
||||
|
||||
64
.github/workflows/cypress.yml
vendored
64
.github/workflows/cypress.yml
vendored
@@ -1,64 +0,0 @@
|
||||
name: Cypress Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
|
||||
jobs:
|
||||
cypress:
|
||||
if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')"
|
||||
runs-on: "ubuntu-latest"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
module: ["mempool", "liquid", "bisq"]
|
||||
include:
|
||||
- module: "mempool"
|
||||
spec: |
|
||||
cypress/e2e/mainnet/*.spec.ts
|
||||
cypress/e2e/signet/*.spec.ts
|
||||
cypress/e2e/testnet/*.spec.ts
|
||||
- module: "liquid"
|
||||
spec: |
|
||||
cypress/e2e/liquid/liquid.spec.ts
|
||||
cypress/e2e/liquidtestnet/liquidtestnet.spec.ts
|
||||
- module: "bisq"
|
||||
spec: |
|
||||
cypress/e2e/bisq/bisq.spec.ts
|
||||
|
||||
name: E2E tests for ${{ matrix.module }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
path: ${{ matrix.module }}
|
||||
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16.15.0
|
||||
cache: "npm"
|
||||
cache-dependency-path: ${{ matrix.module }}/frontend/package-lock.json
|
||||
|
||||
- name: Chrome browser tests (${{ matrix.module }})
|
||||
uses: cypress-io/github-action@v5
|
||||
with:
|
||||
tag: ${{ github.event_name }}
|
||||
working-directory: ${{ matrix.module }}/frontend
|
||||
build: npm run config:defaults:${{ matrix.module }}
|
||||
start: npm run start:local-staging
|
||||
wait-on: "http://localhost:4200"
|
||||
wait-on-timeout: 120
|
||||
record: true
|
||||
parallel: true
|
||||
spec: ${{ matrix.spec }}
|
||||
group: Tests on Chrome (${{ matrix.module }})
|
||||
browser: "chrome"
|
||||
ci-build-id: "${{ github.sha }}-${{ github.workflow }}-${{ github.event_name }}"
|
||||
env:
|
||||
COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title }}
|
||||
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
|
||||
19
.github/workflows/get_backend_hash.yml
vendored
Normal file
19
.github/workflows/get_backend_hash.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
name: 'Print backend hashes'
|
||||
|
||||
on: [workflow_dispatch]
|
||||
|
||||
jobs:
|
||||
print-backend-sha:
|
||||
runs-on: 'ubuntu-latest'
|
||||
name: Print backend hashes
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
path: repo
|
||||
|
||||
- name: Run script
|
||||
working-directory: repo
|
||||
run: |
|
||||
chmod +x ./scripts/get_backend_hash.sh
|
||||
sh ./scripts/get_backend_hash.sh
|
||||
26
.github/workflows/get_image_digest.yml
vendored
Normal file
26
.github/workflows/get_image_digest.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: 'Print images digest'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Image Version'
|
||||
required: false
|
||||
default: 'latest'
|
||||
type: string
|
||||
jobs:
|
||||
print-images-sha:
|
||||
runs-on: 'ubuntu-latest'
|
||||
name: Print digest for images
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
path: digest
|
||||
|
||||
- name: Run script
|
||||
working-directory: digest
|
||||
run: |
|
||||
sh ./docker/scripts/get_image_digest.sh $VERSION
|
||||
env:
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
8
.github/workflows/on-tag.yml
vendored
8
.github/workflows/on-tag.yml
vendored
@@ -68,17 +68,17 @@ jobs:
|
||||
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Checkout project
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Init repo for Dockerization
|
||||
run: docker/init.sh "$TAG"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
id: qemu
|
||||
|
||||
- name: Setup Docker buildx action
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
id: buildx
|
||||
|
||||
- name: Available platforms
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
docker buildx build \
|
||||
--cache-from "type=local,src=/tmp/.buildx-cache" \
|
||||
--cache-to "type=local,dest=/tmp/.buildx-cache" \
|
||||
--platform linux/amd64,linux/arm64,linux/arm/v7 \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--tag ${{ secrets.DOCKER_HUB_USER }}/${{ matrix.service }}:$TAG \
|
||||
--tag ${{ secrets.DOCKER_HUB_USER }}/${{ matrix.service }}:latest \
|
||||
--output "type=registry" ./${{ matrix.service }}/ \
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -5,3 +5,5 @@ backend/mempool-config.json
|
||||
*.swp
|
||||
frontend/src/resources/config.template.js
|
||||
frontend/src/resources/config.js
|
||||
target
|
||||
docker/backend/start_ci.sh
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"editor.tabSize": 2,
|
||||
"typescript.preferences.importModuleSpecifier": "relative",
|
||||
"typescript.tsdk": "./backend/node_modules/typescript/lib"
|
||||
"typescript.tsdk": "./backend/node_modules/typescript/lib",
|
||||
"rust-analyzer.procMacro.ignored": { "napi-derive": ["napi"] }
|
||||
}
|
||||
533
Cargo.lock
generated
Normal file
533
Cargo.lock
generated
Normal file
@@ -0,0 +1,533 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67fc08ce920c31afb70f013dcce1bfc3a3195de6a228474e45e1f145b36f8d04"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbe3c979c178231552ecba20214a8272df4e09f232a87aef4320cf06539aded"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17febce684fd15d89027105661fec94afb475cb995fbc59d2865198446ba2eea"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctor"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1586fa608b1dab41f667475b4a41faec5ba680aee428bfa5de4ea520fdc6e901"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gbt"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"bytes",
|
||||
"napi",
|
||||
"napi-build",
|
||||
"napi-derive",
|
||||
"priority-queue",
|
||||
"tracing",
|
||||
"tracing-log",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.146"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f92be4933c13fd498862a9e02a3055f8a8d9c039ce33db97306fd5a6caa7f29b"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4"
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558"
|
||||
dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
|
||||
|
||||
[[package]]
|
||||
name = "napi"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ede2d12cd6fce44da537a4be1f5510c73be2506c2e32dfaaafd1f36968f3a0e"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"ctor",
|
||||
"napi-derive",
|
||||
"napi-sys",
|
||||
"once_cell",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-build"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "882a73d9ef23e8dc2ebbffb6a6ae2ef467c0f18ac10711e4cc59c5485d41df0e"
|
||||
|
||||
[[package]]
|
||||
name = "napi-derive"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da1c6a8fa84d549aa8708fcd062372bf8ec6e849de39016ab921067d21bde367"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"convert_case",
|
||||
"napi-derive-backend",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-derive-backend"
|
||||
version = "1.0.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20bbc7c69168d06a848f925ec5f0e0997f98e8c8d4f2cc30157f0da51c009e17"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"once_cell",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"semver",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-sys"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "166b5ef52a3ab5575047a9fe8d4a030cdd0f63c96f071cd6907674453b07bae3"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.46.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
|
||||
dependencies = [
|
||||
"overload",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_cpus"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fac9e2da13b5eb447a6ce3d392f23a29d8694bff781bf03a16cd9ac8697593b"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
|
||||
|
||||
[[package]]
|
||||
name = "overload"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116"
|
||||
|
||||
[[package]]
|
||||
name = "priority-queue"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fff39edfcaec0d64e8d0da38564fad195d2d51b680940295fcc307366e101e61"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.60"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dec2b086b7a862cf4de201096214fa870344cf922b2b30c167badb3af3195406"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b9ab9c7eadfd8df19006f1cf1a4aed13540ed5cbc047010ece5826e10825488"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81ca098a9821bd52d6b24fd8b10bd081f47d39c22778cafaa75a2857a62c6390"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax 0.7.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
|
||||
dependencies = [
|
||||
"regex-syntax 0.6.29",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.6.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "436b050e76ed2903236f032a59761c1eb99e1b0aead2c257922771dab1fc8c78"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bebd363326d05ec3e2f532ab7660680f3b02130d780c299bca73469d521bc0ed"
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "900fba806f70c630b0a382d0d825e17a0f19fcd059a2ade1ff237bcddf446b31"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fcb8d4cebc40aa517dfb69618fa647a346562e67228e2236ae0042ee6ac14775"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.28.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94d7b1cfd2aa4011f2de74c2c4c63665e27a71006b0a192dcd2710272e73dfa2"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"num_cpus",
|
||||
"pin-project-lite",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f4f31f56159e98206da9efd823404b79b6ef3143b4a7ab76e67b1751b25a4ab"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0955b8137a1df6f1a2e9a37d8a6656291ff0297c1a97c24e0d8425fe2312f79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78ddad33d2d10b1ed7eb9d1f518a5674713876e97e5bb9b7345a7984fbb4f922"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"log",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30a651bc37f915e81f087d86e62a18eec5f79550c7faff886f7090b4ea757c77"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b15811caf2415fb889178633e7724bad2509101cde276048e013b9def5e51fa0"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36"
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a"
|
||||
8
Cargo.toml
Normal file
8
Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"./backend/rust-gbt",
|
||||
]
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
47
GNUmakefile
47
GNUmakefile
@@ -1,47 +0,0 @@
|
||||
# If you see pwd_unknown showing up check permissions
|
||||
PWD ?= pwd_unknown
|
||||
|
||||
# DATABASE DEPLOY FOLDER CONFIG - default ./data
|
||||
ifeq ($(data),)
|
||||
DATA := data
|
||||
export DATA
|
||||
else
|
||||
DATA := $(data)
|
||||
export DATA
|
||||
endif
|
||||
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo ''
|
||||
@echo ''
|
||||
@echo ' Usage: make [COMMAND]'
|
||||
@echo ''
|
||||
@echo ' make all # build init mempool and electrs'
|
||||
@echo ' make init # setup some useful configs'
|
||||
@echo ' make mempool # build q dockerized mempool.space'
|
||||
@echo ' make electrs # build a docker electrs image'
|
||||
@echo ''
|
||||
|
||||
.PHONY: init
|
||||
init:
|
||||
@echo ''
|
||||
mkdir -p $(DATA) $(DATA)/mysql $(DATA)/mysql/data
|
||||
#REF: https://github.com/mempool/mempool/blob/master/docker/README.md
|
||||
cat docker/docker-compose.yml > docker-compose.yml
|
||||
cat backend/mempool-config.sample.json > backend/mempool-config.json
|
||||
.PHONY: mempool
|
||||
mempool: init
|
||||
@echo ''
|
||||
docker-compose up --force-recreate --always-recreate-deps
|
||||
@echo ''
|
||||
.PHONY: electrs
|
||||
electrum:
|
||||
#REF: https://hub.docker.com/r/beli/electrum
|
||||
@echo ''
|
||||
docker build -f docker/electrum/Dockerfile .
|
||||
@echo ''
|
||||
.PHONY: all
|
||||
all: init
|
||||
make mempool
|
||||
#######################
|
||||
-include Makefile
|
||||
35
LICENSE
35
LICENSE
@@ -1,29 +1,28 @@
|
||||
The Mempool Open Source Project
|
||||
Copyright (c) 2019-2023 The Mempool Open Source Project Developers
|
||||
The Mempool Open Source Project®
|
||||
Copyright (c) 2019-2023 Mempool Space K.K. and other shadowy super-coders
|
||||
|
||||
This program is free software; you can redistribute it and/or modify it under
|
||||
the terms of (at your option) either:
|
||||
the terms of the GNU Affero General Public License as published by the Free
|
||||
Software Foundation, either version 3 of the License or any later version
|
||||
approved by a proxy statement published on <https://mempool.space/about>.
|
||||
|
||||
1) the GNU Affero General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License or any later version approved by a
|
||||
proxy statement published on <https://mempool.space/about>; or
|
||||
However, this copyright license does not include an implied right or license
|
||||
to use any trademarks, service marks, logos, or trade names of Mempool Space K.K.
|
||||
or any other contributor to The Mempool Open Source Project.
|
||||
|
||||
2) the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License or any later version approved by a
|
||||
proxy statement published on <https://mempool.space/about>.
|
||||
The Mempool Open Source Project®, Mempool Accelerator™, Mempool Enterprise®,
|
||||
Mempool Liquidity™, mempool.space®, Be your own explorer™, Explore the full
|
||||
Bitcoin ecosystem™, Mempool Goggles™, the mempool Logo, the mempool Square logo,
|
||||
the mempool Blocks logo, the mempool Blocks 3 | 2 logo, the mempool.space Vertical
|
||||
Logo, and the mempool.space Horizontal logo are registered trademarks or trademarks
|
||||
of Mempool Space K.K in Japan, the United States, and/or other countries.
|
||||
|
||||
However, this copyright license does not include an implied right or license to
|
||||
use our trademarks: The Mempool Open Source Project™, mempool.space™, the
|
||||
mempool Logo™, the mempool.space Vertical Logo™, the mempool.space Horizontal
|
||||
Logo™, the mempool Square Logo™, and the mempool Blocks logo™ are registered
|
||||
trademarks or trademarks of Mempool Space K.K in Japan, the United States,
|
||||
and/or other countries. See our full Trademark Policy and Guidelines for more
|
||||
details, published on <https://mempool.space/trademark-policy>.
|
||||
See our full Trademark Policy and Guidelines for more details, published on
|
||||
<https://mempool.space/trademark-policy>.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. See the full license terms for more details.
|
||||
|
||||
You should have received a copy of both the GNU Affero General Public License
|
||||
and the GNU General Public License along with this program. If not, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
675
LICENSE.GPL-3.md
675
LICENSE.GPL-3.md
@@ -1,675 +0,0 @@
|
||||
### GNU GENERAL PUBLIC LICENSE
|
||||
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc.
|
||||
<https://fsf.org/>
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this
|
||||
license document, but changing it is not allowed.
|
||||
|
||||
### Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom
|
||||
to share and change all versions of a program--to make sure it remains
|
||||
free software for all its users. We, the Free Software Foundation, use
|
||||
the GNU General Public License for most of our software; it applies
|
||||
also to any other work released this way by its authors. You can apply
|
||||
it to your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you
|
||||
have certain responsibilities if you distribute copies of the
|
||||
software, or if you modify it: responsibilities to respect the freedom
|
||||
of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the
|
||||
manufacturer can do so. This is fundamentally incompatible with the
|
||||
aim of protecting users' freedom to change the software. The
|
||||
systematic pattern of such abuse occurs in the area of products for
|
||||
individuals to use, which is precisely where it is most unacceptable.
|
||||
Therefore, we have designed this version of the GPL to prohibit the
|
||||
practice for those products. If such problems arise substantially in
|
||||
other domains, we stand ready to extend this provision to those
|
||||
domains in future versions of the GPL, as needed to protect the
|
||||
freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish
|
||||
to avoid the special danger that patents applied to a free program
|
||||
could make it effectively proprietary. To prevent this, the GPL
|
||||
assures that patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
### TERMS AND CONDITIONS
|
||||
|
||||
#### 0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds
|
||||
of works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of
|
||||
an exact copy. The resulting work is called a "modified version" of
|
||||
the earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user
|
||||
through a computer network, with no transfer of a copy, is not
|
||||
conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices" to
|
||||
the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
#### 1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work for
|
||||
making modifications to it. "Object code" means any non-source form of
|
||||
a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users can
|
||||
regenerate automatically from other parts of the Corresponding Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that same
|
||||
work.
|
||||
|
||||
#### 2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not convey,
|
||||
without conditions so long as your license otherwise remains in force.
|
||||
You may convey covered works to others for the sole purpose of having
|
||||
them make modifications exclusively for you, or provide you with
|
||||
facilities for running those works, provided that you comply with the
|
||||
terms of this License in conveying all material for which you do not
|
||||
control copyright. Those thus making or running the covered works for
|
||||
you must do so exclusively on your behalf, under your direction and
|
||||
control, on terms that prohibit them from making any copies of your
|
||||
copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under the
|
||||
conditions stated below. Sublicensing is not allowed; section 10 makes
|
||||
it unnecessary.
|
||||
|
||||
#### 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such
|
||||
circumvention is effected by exercising rights under this License with
|
||||
respect to the covered work, and you disclaim any intention to limit
|
||||
operation or modification of the work as a means of enforcing, against
|
||||
the work's users, your or third parties' legal rights to forbid
|
||||
circumvention of technological measures.
|
||||
|
||||
#### 4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
#### 5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these
|
||||
conditions:
|
||||
|
||||
- a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
- b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under
|
||||
section 7. This requirement modifies the requirement in section 4
|
||||
to "keep intact all notices".
|
||||
- c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
- d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
#### 6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms of
|
||||
sections 4 and 5, provided that you also convey the machine-readable
|
||||
Corresponding Source under the terms of this License, in one of these
|
||||
ways:
|
||||
|
||||
- a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
- b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the Corresponding
|
||||
Source from a network server at no charge.
|
||||
- c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
- d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
- e) Convey the object code using peer-to-peer transmission,
|
||||
provided you inform other peers where the object code and
|
||||
Corresponding Source of the work are being offered to the general
|
||||
public at no charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal,
|
||||
family, or household purposes, or (2) anything designed or sold for
|
||||
incorporation into a dwelling. In determining whether a product is a
|
||||
consumer product, doubtful cases shall be resolved in favor of
|
||||
coverage. For a particular product received by a particular user,
|
||||
"normally used" refers to a typical or common use of that class of
|
||||
product, regardless of the status of the particular user or of the way
|
||||
in which the particular user actually uses, or expects or is expected
|
||||
to use, the product. A product is a consumer product regardless of
|
||||
whether the product has substantial commercial, industrial or
|
||||
non-consumer uses, unless such uses represent the only significant
|
||||
mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to
|
||||
install and execute modified versions of a covered work in that User
|
||||
Product from a modified version of its Corresponding Source. The
|
||||
information must suffice to ensure that the continued functioning of
|
||||
the modified object code is in no case prevented or interfered with
|
||||
solely because modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or
|
||||
updates for a work that has been modified or installed by the
|
||||
recipient, or for the User Product in which it has been modified or
|
||||
installed. Access to a network may be denied when the modification
|
||||
itself materially and adversely affects the operation of the network
|
||||
or violates the rules and protocols for communication across the
|
||||
network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
#### 7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders
|
||||
of that material) supplement the terms of this License with terms:
|
||||
|
||||
- a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
- b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
- c) Prohibiting misrepresentation of the origin of that material,
|
||||
or requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
- d) Limiting the use for publicity purposes of names of licensors
|
||||
or authors of the material; or
|
||||
- e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
- f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions
|
||||
of it) with contractual assumptions of liability to the recipient,
|
||||
for any liability that these contractual assumptions directly
|
||||
impose on those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions; the
|
||||
above requirements apply either way.
|
||||
|
||||
#### 8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your license
|
||||
from a particular copyright holder is reinstated (a) provisionally,
|
||||
unless and until the copyright holder explicitly and finally
|
||||
terminates your license, and (b) permanently, if the copyright holder
|
||||
fails to notify you of the violation by some reasonable means prior to
|
||||
60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
#### 9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or run
|
||||
a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
#### 10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
#### 11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims owned
|
||||
or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within the
|
||||
scope of its coverage, prohibits the exercise of, or is conditioned on
|
||||
the non-exercise of one or more of the rights that are specifically
|
||||
granted under this License. You may not convey a covered work if you
|
||||
are a party to an arrangement with a third party that is in the
|
||||
business of distributing software, under which you make payment to the
|
||||
third party based on the extent of your activity of conveying the
|
||||
work, and under which the third party grants, to any of the parties
|
||||
who would receive the covered work from you, a discriminatory patent
|
||||
license (a) in connection with copies of the covered work conveyed by
|
||||
you (or copies made from those copies), or (b) primarily for and in
|
||||
connection with specific products or compilations that contain the
|
||||
covered work, unless you entered into that arrangement, or that patent
|
||||
license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
#### 12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under
|
||||
this License and any other pertinent obligations, then as a
|
||||
consequence you may not convey it at all. For example, if you agree to
|
||||
terms that obligate you to collect a royalty for further conveying
|
||||
from those to whom you convey the Program, the only way you could
|
||||
satisfy both those terms and this License would be to refrain entirely
|
||||
from conveying the Program.
|
||||
|
||||
#### 13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
#### 14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in
|
||||
detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies that a certain numbered version of the GNU General Public
|
||||
License "or any later version" applies to it, you have the option of
|
||||
following the terms and conditions either of that numbered version or
|
||||
of any later version published by the Free Software Foundation. If the
|
||||
Program does not specify a version number of the GNU General Public
|
||||
License, you may choose any version ever published by the Free
|
||||
Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future versions
|
||||
of the GNU General Public License can be used, that proxy's public
|
||||
statement of acceptance of a version permanently authorizes you to
|
||||
choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
#### 15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT
|
||||
WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND
|
||||
PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE
|
||||
DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR
|
||||
CORRECTION.
|
||||
|
||||
#### 16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR
|
||||
CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES
|
||||
ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT
|
||||
NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR
|
||||
LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM
|
||||
TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER
|
||||
PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
#### 17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
### How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these
|
||||
terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest to
|
||||
attach them to the start of each source file to most effectively state
|
||||
the exclusion of warranty; and each file should have at least the
|
||||
"copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper
|
||||
mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands \`show w' and \`show c' should show the
|
||||
appropriate parts of the General Public License. Of course, your
|
||||
program's commands might be different; for a GUI interface, you would
|
||||
use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. For more information on this, and how to apply and follow
|
||||
the GNU GPL, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your
|
||||
program into proprietary programs. If your program is a subroutine
|
||||
library, you may consider it more useful to permit linking proprietary
|
||||
applications with the library. If this is what you want to do, use the
|
||||
GNU Lesser General Public License instead of this License. But first,
|
||||
please read <https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
15
README.md
15
README.md
@@ -1,18 +1,20 @@
|
||||
# The Mempool Open Source Project™ [](https://dashboard.cypress.io/projects/ry4br7/runs)
|
||||
# The Mempool Open Source Project® [](https://dashboard.cypress.io/projects/ry4br7/runs)
|
||||
|
||||
https://user-images.githubusercontent.com/232186/222445818-234aa6c9-c233-4c52-b3f0-e32b8232893b.mp4
|
||||
https://user-images.githubusercontent.com/93150691/226236121-375ea64f-b4a1-4cc0-8fad-a6fb33226840.mp4
|
||||
|
||||
<br>
|
||||
|
||||
Mempool is the fully-featured mempool visualizer, explorer, and API service running at [mempool.space](https://mempool.space/).
|
||||
|
||||
It is an open-source project developed and operated for the benefit of the Bitcoin community, with a focus on the emerging transaction fee market that is evolving Bitcoin into a multi-layer ecosystem.
|
||||
|
||||

|
||||
|
||||
# Installation Methods
|
||||
|
||||
Mempool can be self-hosted on a wide variety of your own hardware, ranging from a simple one-click installation on a Raspberry Pi full-node distro all the way to a robust production instance on a powerful FreeBSD server.
|
||||
|
||||
**Most people should use a one-click install method.** Other install methods are meant for developers and others with experience managing servers.
|
||||
Most people should use a <a href="#one-click-installation">one-click install method</a>.
|
||||
|
||||
Other install methods are meant for developers and others with experience managing servers. If you want support for your own production instance of Mempool, or if you'd like to have your own instance of Mempool run by the mempool.space team on their own global ISP infrastructure—check out <a href="https://mempool.space/enterprise" target="_blank">Mempool Enterprise®</a>.
|
||||
|
||||
<a id="one-click-installation"></a>
|
||||
## One-Click Installation
|
||||
@@ -22,7 +24,8 @@ Mempool can be conveniently installed on the following full-node distros:
|
||||
- [RaspiBlitz](https://github.com/rootzoll/raspiblitz)
|
||||
- [RoninDojo](https://code.samourai.io/ronindojo/RoninDojo)
|
||||
- [myNode](https://github.com/mynodebtc/mynode)
|
||||
- [Start9](https://github.com/Start9Labs/embassy-os)
|
||||
- [StartOS](https://github.com/Start9Labs/start-os)
|
||||
- [nix-bitcoin](https://github.com/fort-nix/nix-bitcoin/blob/a1eacce6768ca4894f365af8f79be5bbd594e1c3/examples/configuration.nix#L129)
|
||||
|
||||
**We highly recommend you deploy your own Mempool instance this way.** No matter which option you pick, you'll be able to get your own fully-sovereign instance of Mempool up quickly without needing to fiddle with any settings.
|
||||
|
||||
|
||||
1
backend/.dockerignore
Normal file
1
backend/.dockerignore
Normal file
@@ -0,0 +1 @@
|
||||
Dockerfile
|
||||
9
backend/.gitignore
vendored
9
backend/.gitignore
vendored
@@ -7,6 +7,12 @@ mempool-config.json
|
||||
pools.json
|
||||
icons.json
|
||||
|
||||
# docker
|
||||
Dockerfile
|
||||
GeoIP
|
||||
start.sh
|
||||
wait-for-it.sh
|
||||
|
||||
# compiled output
|
||||
/dist
|
||||
/tmp
|
||||
@@ -45,3 +51,6 @@ testem.log
|
||||
#System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# package folder (npm run package output)
|
||||
/package
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
These instructions are mostly intended for developers.
|
||||
|
||||
If you choose to use these instructions for a production setup, be aware that you will still probably need to do additional configuration for your specific OS, environment, use-case, etc. We do our best here to provide a good starting point, but only proceed if you know what you're doing. Mempool does not provide support for custom setups.
|
||||
If you choose to use these instructions for a production setup, be aware that you will still probably need to do additional configuration for your specific OS, environment, use-case, etc. We do our best here to provide a good starting point, but only proceed if you know what you're doing. Mempool only provides support for custom setups to project sponsors through [Mempool Enterprise®](https://mempool.space/enterprise).
|
||||
|
||||
See other ways to set up Mempool on [the main README](/../../#installation-methods).
|
||||
|
||||
@@ -79,11 +79,13 @@ Query OK, 0 rows affected (0.00 sec)
|
||||
|
||||
_Make sure to use Node.js 16.10 and npm 7._
|
||||
|
||||
_The build process requires [Rust](https://www.rust-lang.org/tools/install) to be installed._
|
||||
|
||||
Install dependencies with `npm` and build the backend:
|
||||
|
||||
```
|
||||
cd backend
|
||||
npm install
|
||||
npm install --no-install-links # npm@9.4.2 and later can omit the --no-install-links
|
||||
npm run build
|
||||
```
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"API_URL_PREFIX": "/api/v1/",
|
||||
"POLL_RATE_MS": 2000,
|
||||
"CACHE_DIR": "./cache",
|
||||
"CACHE_ENABLED": true,
|
||||
"CLEAR_PROTECTION_MINUTES": 20,
|
||||
"RECOMMENDED_FEE_PERCENTILE": 50,
|
||||
"BLOCK_WEIGHT_UNITS": 4000000,
|
||||
@@ -15,6 +16,7 @@
|
||||
"MEMPOOL_BLOCKS_AMOUNT": 8,
|
||||
"INDEXING_BLOCKS_AMOUNT": 11000,
|
||||
"BLOCKS_SUMMARIES_INDEXING": false,
|
||||
"GOGGLES_INDEXING": false,
|
||||
"USE_SECOND_NODE_FOR_MINFEE": false,
|
||||
"EXTERNAL_ASSETS": [],
|
||||
"EXTERNAL_MAX_RETRY": 1,
|
||||
@@ -27,13 +29,22 @@
|
||||
"AUDIT": false,
|
||||
"ADVANCED_GBT_AUDIT": false,
|
||||
"ADVANCED_GBT_MEMPOOL": false,
|
||||
"CPFP_INDEXING": false
|
||||
"RUST_GBT": false,
|
||||
"CPFP_INDEXING": false,
|
||||
"DISK_CACHE_BLOCK_INTERVAL": 6,
|
||||
"MAX_PUSH_TX_SIZE_WEIGHT": 4000000,
|
||||
"ALLOW_UNREACHABLE": true,
|
||||
"PRICE_UPDATES_PER_HOUR": 1,
|
||||
"MAX_TRACKED_ADDRESSES": 100
|
||||
},
|
||||
"CORE_RPC": {
|
||||
"HOST": "127.0.0.1",
|
||||
"PORT": 8332,
|
||||
"USERNAME": "mempool",
|
||||
"PASSWORD": "mempool"
|
||||
"PASSWORD": "mempool",
|
||||
"TIMEOUT": 60000,
|
||||
"COOKIE": false,
|
||||
"COOKIE_PATH": "/path/to/bitcoin/.cookie"
|
||||
},
|
||||
"ELECTRUM": {
|
||||
"HOST": "127.0.0.1",
|
||||
@@ -41,13 +52,22 @@
|
||||
"TLS_ENABLED": true
|
||||
},
|
||||
"ESPLORA": {
|
||||
"REST_API_URL": "http://127.0.0.1:3000"
|
||||
"REST_API_URL": "http://127.0.0.1:3000",
|
||||
"UNIX_SOCKET_PATH": "/tmp/esplora-bitcoin-mainnet",
|
||||
"BATCH_QUERY_BASE_SIZE": 1000,
|
||||
"RETRY_UNIX_SOCKET_AFTER": 30000,
|
||||
"REQUEST_TIMEOUT": 10000,
|
||||
"FALLBACK_TIMEOUT": 5000,
|
||||
"FALLBACK": []
|
||||
},
|
||||
"SECOND_CORE_RPC": {
|
||||
"HOST": "127.0.0.1",
|
||||
"PORT": 8332,
|
||||
"USERNAME": "mempool",
|
||||
"PASSWORD": "mempool"
|
||||
"PASSWORD": "mempool",
|
||||
"TIMEOUT": 60000,
|
||||
"COOKIE": false,
|
||||
"COOKIE_PATH": "/path/to/bitcoin/.cookie"
|
||||
},
|
||||
"DATABASE": {
|
||||
"ENABLED": true,
|
||||
@@ -56,7 +76,9 @@
|
||||
"SOCKET": "/var/run/mysql/mysql.sock",
|
||||
"DATABASE": "mempool",
|
||||
"USERNAME": "mempool",
|
||||
"PASSWORD": "mempool"
|
||||
"PASSWORD": "mempool",
|
||||
"TIMEOUT": 180000,
|
||||
"PID_DIR": ""
|
||||
},
|
||||
"SYSLOG": {
|
||||
"ENABLED": true,
|
||||
@@ -91,7 +113,8 @@
|
||||
"LND": {
|
||||
"TLS_CERT_PATH": "tls.cert",
|
||||
"MACAROON_PATH": "readonly.macaroon",
|
||||
"REST_API_URL": "https://localhost:8080"
|
||||
"REST_API_URL": "https://localhost:8080",
|
||||
"TIMEOUT": 10000
|
||||
},
|
||||
"CLIGHTNING": {
|
||||
"SOCKET": "lightning-rpc"
|
||||
@@ -104,10 +127,6 @@
|
||||
"USERNAME": "",
|
||||
"PASSWORD": ""
|
||||
},
|
||||
"PRICE_DATA_SERVER": {
|
||||
"TOR_URL": "http://wizpriceje6q5tdrxkyiazsgu7irquiqjy2dptezqhrtu7l2qelqktid.onion/getAllMarketPrices",
|
||||
"CLEARNET_URL": "https://price.bisq.wiz.biz/getAllMarketPrices"
|
||||
},
|
||||
"EXTERNAL_DATA_SERVER": {
|
||||
"MEMPOOL_API": "https://mempool.space/api/v1",
|
||||
"MEMPOOL_ONION": "http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api/v1",
|
||||
@@ -115,5 +134,25 @@
|
||||
"LIQUID_ONION": "http://liquidmom47f6s3m53ebfxn47p76a6tlnxib3wp6deux7wuzotdr6cyd.onion/api/v1",
|
||||
"BISQ_URL": "https://bisq.markets/api",
|
||||
"BISQ_ONION": "http://bisqmktse2cabavbr2xjq7xw3h6g5ottemo5rolfcwt6aly6tp5fdryd.onion/api"
|
||||
},
|
||||
"REDIS": {
|
||||
"ENABLED": false,
|
||||
"UNIX_SOCKET_PATH": "/tmp/redis.sock",
|
||||
"BATCH_QUERY_BASE_SIZE": 5000
|
||||
},
|
||||
"REPLICATION": {
|
||||
"ENABLED": false,
|
||||
"AUDIT": false,
|
||||
"AUDIT_START_HEIGHT": 774000,
|
||||
"SERVERS": [
|
||||
"list",
|
||||
"of",
|
||||
"trusted",
|
||||
"servers"
|
||||
]
|
||||
},
|
||||
"MEMPOOL_SERVICES": {
|
||||
"API": "https://mempool.space/api",
|
||||
"ACCELERATIONS": false
|
||||
}
|
||||
}
|
||||
|
||||
17
backend/npm_package.sh
Executable file
17
backend/npm_package.sh
Executable file
@@ -0,0 +1,17 @@
|
||||
#/bin/sh
|
||||
set -e
|
||||
|
||||
# Remove previous dist folder
|
||||
rm -rf dist
|
||||
# Build new dist folder
|
||||
npm run build
|
||||
# Remove previous package folder
|
||||
rm -rf package
|
||||
# Move JS and deps
|
||||
mv dist package
|
||||
cp -R node_modules package
|
||||
# Remove symlink for rust-gbt and insert real folder
|
||||
rm package/node_modules/rust-gbt
|
||||
cp -R rust-gbt package/node_modules
|
||||
# Clean up deps
|
||||
npm run package-rm-build-deps
|
||||
12
backend/npm_package_rm_build_deps.sh
Executable file
12
backend/npm_package_rm_build_deps.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#/bin/sh
|
||||
set -e
|
||||
|
||||
# Cleaning up inside the node_modules folder
|
||||
cd package/node_modules
|
||||
rm -r \
|
||||
typescript \
|
||||
@typescript-eslint \
|
||||
@napi-rs \
|
||||
./rust-gbt/src \
|
||||
./rust-gbt/Cargo.toml \
|
||||
./rust-gbt/build.rs
|
||||
7522
backend/package-lock.json
generated
7522
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mempool-backend",
|
||||
"version": "2.5.0-dev",
|
||||
"version": "3.0.0-dev",
|
||||
"description": "Bitcoin mempool visualizer and blockchain explorer backend",
|
||||
"license": "GNU Affero General Public License v3.0",
|
||||
"homepage": "https://mempool.space",
|
||||
@@ -22,47 +22,53 @@
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"tsc": "./node_modules/typescript/bin/tsc -p tsconfig.build.json",
|
||||
"build": "npm run tsc && npm run create-resources",
|
||||
"build": "npm run rust-build && npm run tsc && npm run create-resources",
|
||||
"create-resources": "cp ./src/tasks/price-feeds/mtgox-weekly.json ./dist/tasks && node dist/api/fetch-version.js",
|
||||
"package": "npm run build && rm -rf package && mv dist package && mv node_modules package && npm run package-rm-build-deps",
|
||||
"package-rm-build-deps": "(cd package/node_modules; rm -r typescript @typescript-eslint)",
|
||||
"package": "./npm_package.sh",
|
||||
"package-rm-build-deps": "./npm_package_rm_build_deps.sh",
|
||||
"start": "node --max-old-space-size=2048 dist/index.js",
|
||||
"start-production": "node --max-old-space-size=16384 dist/index.js",
|
||||
"reindex-updated-pools": "npm run start-production --update-pools",
|
||||
"reindex-all-blocks": "npm run start-production --update-pools --reindex-blocks",
|
||||
"test": "./node_modules/.bin/jest --coverage",
|
||||
"test:ci": "CI=true ./node_modules/.bin/jest --coverage",
|
||||
"lint": "./node_modules/.bin/eslint . --ext .ts",
|
||||
"lint:fix": "./node_modules/.bin/eslint . --ext .ts --fix",
|
||||
"prettier": "./node_modules/.bin/prettier --write \"src/**/*.{js,ts}\""
|
||||
"prettier": "./node_modules/.bin/prettier --write \"src/**/*.{js,ts}\"",
|
||||
"rust-clean": "cd rust-gbt && rm -f *.node index.d.ts index.js && rm -rf target && cd ../",
|
||||
"rust-build": "npm run rust-clean && cd rust-gbt && npm run build-release"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.20.12",
|
||||
"@mempool/electrum-client": "^1.1.7",
|
||||
"@types/node": "^16.18.11",
|
||||
"axios": "~0.27.2",
|
||||
"bitcoinjs-lib": "~6.1.0",
|
||||
"crypto-js": "~4.1.1",
|
||||
"@babel/core": "^7.24.0",
|
||||
"@mempool/electrum-client": "1.1.9",
|
||||
"@types/node": "^18.15.3",
|
||||
"axios": "~1.6.1",
|
||||
"bitcoinjs-lib": "~6.1.3",
|
||||
"crypto-js": "~4.2.0",
|
||||
"express": "~4.18.2",
|
||||
"maxmind": "~4.3.8",
|
||||
"mysql2": "~2.3.3",
|
||||
"node-worker-threads-pool": "~1.5.1",
|
||||
"maxmind": "~4.3.11",
|
||||
"mysql2": "~3.9.1",
|
||||
"rust-gbt": "file:./rust-gbt",
|
||||
"redis": "^4.6.6",
|
||||
"socks-proxy-agent": "~7.0.0",
|
||||
"typescript": "~4.7.4",
|
||||
"ws": "~8.11.0"
|
||||
"typescript": "~4.9.3",
|
||||
"ws": "~8.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.7",
|
||||
"@babel/code-frame": "^7.18.6",
|
||||
"@babel/core": "^7.24.0",
|
||||
"@types/compression": "^1.7.2",
|
||||
"@types/crypto-js": "^4.1.1",
|
||||
"@types/express": "^4.17.15",
|
||||
"@types/jest": "^29.2.5",
|
||||
"@types/ws": "~8.5.4",
|
||||
"@typescript-eslint/eslint-plugin": "^5.48.1",
|
||||
"@typescript-eslint/parser": "^5.48.1",
|
||||
"eslint": "^8.31.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"jest": "^29.3.1",
|
||||
"prettier": "^2.8.2",
|
||||
"ts-jest": "^29.0.3",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jest": "^29.5.0",
|
||||
"@types/ws": "~8.5.5",
|
||||
"@typescript-eslint/eslint-plugin": "^5.55.0",
|
||||
"@typescript-eslint/parser": "^5.55.0",
|
||||
"eslint": "^8.36.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"jest": "^29.5.0",
|
||||
"prettier": "^3.0.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-node": "^10.9.1"
|
||||
}
|
||||
}
|
||||
|
||||
4
backend/rust-gbt/.gitignore
vendored
Normal file
4
backend/rust-gbt/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
*.node
|
||||
**/node_modules
|
||||
**/.DS_Store
|
||||
npm-debug.log*
|
||||
25
backend/rust-gbt/Cargo.toml
Normal file
25
backend/rust-gbt/Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "gbt"
|
||||
version = "1.0.0"
|
||||
description = "An efficient re-implementation of the getBlockTemplate algorithm in Rust"
|
||||
authors = ["mononaut"]
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
priority-queue = "1.3.2"
|
||||
bytes = "1.4.0"
|
||||
napi = { version = "2.13.2", features = ["napi8", "tokio_rt"] }
|
||||
napi-derive = "2.13.0"
|
||||
bytemuck = "1.13.1"
|
||||
tracing = "0.1.36"
|
||||
tracing-log = "0.1.3"
|
||||
tracing-subscriber = { version = "0.3.15", features = ["env-filter"]}
|
||||
|
||||
[build-dependencies]
|
||||
napi-build = "2.0.1"
|
||||
123
backend/rust-gbt/README.md
Normal file
123
backend/rust-gbt/README.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# gbt
|
||||
|
||||
**gbt:** rust implementation of the getBlockTemplate algorithm
|
||||
|
||||
This project was bootstrapped by [napi](https://www.npmjs.com/package/@napi-rs/cli).
|
||||
|
||||
## Installing gbt
|
||||
|
||||
Installing gbt requires a [supported version of Node and Rust](https://github.com/napi-rs/napi-rs#platform-support).
|
||||
|
||||
The build process also requires [Rust](https://www.rust-lang.org/tools/install) to be installed.
|
||||
|
||||
You can install the project with npm. In the project directory, run:
|
||||
|
||||
```sh
|
||||
$ npm install
|
||||
```
|
||||
|
||||
This fully installs the project, including installing any dependencies and running the build.
|
||||
|
||||
## Building gbt
|
||||
|
||||
If you have already installed the project and only want to run the build, run:
|
||||
|
||||
```sh
|
||||
$ npm run build
|
||||
```
|
||||
|
||||
This command uses the [napi build](https://www.npmjs.com/package/@napi-rs/cli) utility to run the Rust build and copy the built library into `./gbt.[TARGET_TRIPLE].node`.
|
||||
|
||||
## Exploring gbt
|
||||
|
||||
After building gbt, you can explore its exports at the Node REPL:
|
||||
|
||||
```sh
|
||||
$ npm install
|
||||
$ node
|
||||
> require('.').hello()
|
||||
"hello node"
|
||||
```
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm install`
|
||||
|
||||
Installs the project, including running `npm run build-release`.
|
||||
|
||||
### `npm build`
|
||||
|
||||
Builds the Node addon (`gbt.[TARGET_TRIPLE].node`) from source.
|
||||
|
||||
Additional [`cargo build`](https://doc.rust-lang.org/cargo/commands/cargo-build.html) arguments may be passed to `npm build` and `npm build-*` commands. For example, to enable a [cargo feature](https://doc.rust-lang.org/cargo/reference/features.html):
|
||||
|
||||
```
|
||||
npm run build -- --feature=beetle
|
||||
```
|
||||
|
||||
#### `npm build-debug`
|
||||
|
||||
Alias for `npm build`.
|
||||
|
||||
#### `npm build-release`
|
||||
|
||||
Same as [`npm build`](#npm-build) but, builds the module with the [`release`](https://doc.rust-lang.org/cargo/reference/profiles.html#release) profile. Release builds will compile slower, but run faster.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Runs the unit tests by calling `cargo test`. You can learn more about [adding tests to your Rust code](https://doc.rust-lang.org/book/ch11-01-writing-tests.html) from the [Rust book](https://doc.rust-lang.org/book/).
|
||||
|
||||
## Project Layout
|
||||
|
||||
The directory structure of this project is:
|
||||
|
||||
```
|
||||
gbt/
|
||||
├── Cargo.toml
|
||||
├── README.md
|
||||
├── gbt.[TARGET_TRIPLE].node
|
||||
├── package.json
|
||||
├── src/
|
||||
| └── lib.rs
|
||||
└── target/
|
||||
```
|
||||
|
||||
### Cargo.toml
|
||||
|
||||
The Cargo [manifest file](https://doc.rust-lang.org/cargo/reference/manifest.html), which informs the `cargo` command.
|
||||
|
||||
### README.md
|
||||
|
||||
This file.
|
||||
|
||||
### gbt.\[TARGET_TRIPLE\].node
|
||||
|
||||
The Node addon—i.e., a binary Node module—generated by building the project. This is the main module for this package, as dictated by the `"main"` key in `package.json`.
|
||||
|
||||
Under the hood, a [Node addon](https://nodejs.org/api/addons.html) is a [dynamically-linked shared object](https://en.wikipedia.org/wiki/Library_(computing)#Shared_libraries). The `"build"` script produces this file by copying it from within the `target/` directory, which is where the Rust build produces the shared object.
|
||||
|
||||
### package.json
|
||||
|
||||
The npm [manifest file](https://docs.npmjs.com/cli/v7/configuring-npm/package-json), which informs the `npm` command.
|
||||
|
||||
### src/
|
||||
|
||||
The directory tree containing the Rust source code for the project.
|
||||
|
||||
### src/lib.rs
|
||||
|
||||
The Rust library's main module.
|
||||
|
||||
### target/
|
||||
|
||||
Binary artifacts generated by the Rust build.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Neon, see the [Napi-RS documentation](https://napi.rs/docs/introduction/getting-started).
|
||||
|
||||
To learn more about Rust, see the [Rust documentation](https://www.rust-lang.org).
|
||||
|
||||
To learn more about Node, see the [Node documentation](https://nodejs.org).
|
||||
3
backend/rust-gbt/build.rs
Normal file
3
backend/rust-gbt/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
napi_build::setup();
|
||||
}
|
||||
50
backend/rust-gbt/index.d.ts
vendored
Normal file
50
backend/rust-gbt/index.d.ts
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
|
||||
/* auto-generated by NAPI-RS */
|
||||
|
||||
export interface ThreadTransaction {
|
||||
uid: number
|
||||
order: number
|
||||
fee: number
|
||||
weight: number
|
||||
sigops: number
|
||||
effectiveFeePerVsize: number
|
||||
inputs: Array<number>
|
||||
}
|
||||
export interface ThreadAcceleration {
|
||||
uid: number
|
||||
delta: number
|
||||
}
|
||||
export class GbtGenerator {
|
||||
constructor()
|
||||
/**
|
||||
* # Errors
|
||||
*
|
||||
* Rejects if the thread panics or if the Mutex is poisoned.
|
||||
*/
|
||||
make(mempool: Array<ThreadTransaction>, accelerations: Array<ThreadAcceleration>, maxUid: number): Promise<GbtResult>
|
||||
/**
|
||||
* # Errors
|
||||
*
|
||||
* Rejects if the thread panics or if the Mutex is poisoned.
|
||||
*/
|
||||
update(newTxs: Array<ThreadTransaction>, removeTxs: Array<number>, accelerations: Array<ThreadAcceleration>, maxUid: number): Promise<GbtResult>
|
||||
}
|
||||
/**
|
||||
* The result from calling the gbt function.
|
||||
*
|
||||
* This tuple contains the following:
|
||||
* blocks: A 2D Vector of transaction IDs (u32), the inner Vecs each represent a block.
|
||||
* block_weights: A Vector of total weights per block.
|
||||
* clusters: A 2D Vector of transaction IDs representing clusters of dependent mempool transactions
|
||||
* rates: A Vector of tuples containing transaction IDs (u32) and effective fee per vsize (f64)
|
||||
*/
|
||||
export class GbtResult {
|
||||
blocks: Array<Array<number>>
|
||||
blockWeights: Array<number>
|
||||
clusters: Array<Array<number>>
|
||||
rates: Array<Array<number>>
|
||||
overflow: Array<number>
|
||||
constructor(blocks: Array<Array<number>>, blockWeights: Array<number>, clusters: Array<Array<number>>, rates: Array<Array<number>>, overflow: Array<number>)
|
||||
}
|
||||
258
backend/rust-gbt/index.js
Normal file
258
backend/rust-gbt/index.js
Normal file
@@ -0,0 +1,258 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
|
||||
/* auto-generated by NAPI-RS */
|
||||
|
||||
const { existsSync, readFileSync } = require('fs')
|
||||
const { join } = require('path')
|
||||
|
||||
const { platform, arch } = process
|
||||
|
||||
let nativeBinding = null
|
||||
let localFileExisted = false
|
||||
let loadError = null
|
||||
|
||||
function isMusl() {
|
||||
// For Node 10
|
||||
if (!process.report || typeof process.report.getReport !== 'function') {
|
||||
try {
|
||||
const lddPath = require('child_process').execSync('which ldd').toString().trim()
|
||||
return readFileSync(lddPath, 'utf8').includes('musl')
|
||||
} catch (e) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
const { glibcVersionRuntime } = process.report.getReport().header
|
||||
return !glibcVersionRuntime
|
||||
}
|
||||
}
|
||||
|
||||
switch (platform) {
|
||||
case 'android':
|
||||
switch (arch) {
|
||||
case 'arm64':
|
||||
localFileExisted = existsSync(join(__dirname, 'gbt.android-arm64.node'))
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./gbt.android-arm64.node')
|
||||
} else {
|
||||
nativeBinding = require('gbt-android-arm64')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
case 'arm':
|
||||
localFileExisted = existsSync(join(__dirname, 'gbt.android-arm-eabi.node'))
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./gbt.android-arm-eabi.node')
|
||||
} else {
|
||||
nativeBinding = require('gbt-android-arm-eabi')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported architecture on Android ${arch}`)
|
||||
}
|
||||
break
|
||||
case 'win32':
|
||||
switch (arch) {
|
||||
case 'x64':
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'gbt.win32-x64-msvc.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./gbt.win32-x64-msvc.node')
|
||||
} else {
|
||||
nativeBinding = require('gbt-win32-x64-msvc')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
case 'ia32':
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'gbt.win32-ia32-msvc.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./gbt.win32-ia32-msvc.node')
|
||||
} else {
|
||||
nativeBinding = require('gbt-win32-ia32-msvc')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
case 'arm64':
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'gbt.win32-arm64-msvc.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./gbt.win32-arm64-msvc.node')
|
||||
} else {
|
||||
nativeBinding = require('gbt-win32-arm64-msvc')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported architecture on Windows: ${arch}`)
|
||||
}
|
||||
break
|
||||
case 'darwin':
|
||||
localFileExisted = existsSync(join(__dirname, 'gbt.darwin-universal.node'))
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./gbt.darwin-universal.node')
|
||||
} else {
|
||||
nativeBinding = require('gbt-darwin-universal')
|
||||
}
|
||||
break
|
||||
} catch {}
|
||||
switch (arch) {
|
||||
case 'x64':
|
||||
localFileExisted = existsSync(join(__dirname, 'gbt.darwin-x64.node'))
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./gbt.darwin-x64.node')
|
||||
} else {
|
||||
nativeBinding = require('gbt-darwin-x64')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
case 'arm64':
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'gbt.darwin-arm64.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./gbt.darwin-arm64.node')
|
||||
} else {
|
||||
nativeBinding = require('gbt-darwin-arm64')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported architecture on macOS: ${arch}`)
|
||||
}
|
||||
break
|
||||
case 'freebsd':
|
||||
if (arch !== 'x64') {
|
||||
throw new Error(`Unsupported architecture on FreeBSD: ${arch}`)
|
||||
}
|
||||
localFileExisted = existsSync(join(__dirname, 'gbt.freebsd-x64.node'))
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./gbt.freebsd-x64.node')
|
||||
} else {
|
||||
nativeBinding = require('gbt-freebsd-x64')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
case 'linux':
|
||||
switch (arch) {
|
||||
case 'x64':
|
||||
if (isMusl()) {
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'gbt.linux-x64-musl.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./gbt.linux-x64-musl.node')
|
||||
} else {
|
||||
nativeBinding = require('gbt-linux-x64-musl')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
} else {
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'gbt.linux-x64-gnu.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./gbt.linux-x64-gnu.node')
|
||||
} else {
|
||||
nativeBinding = require('gbt-linux-x64-gnu')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'arm64':
|
||||
if (isMusl()) {
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'gbt.linux-arm64-musl.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./gbt.linux-arm64-musl.node')
|
||||
} else {
|
||||
nativeBinding = require('gbt-linux-arm64-musl')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
} else {
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'gbt.linux-arm64-gnu.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./gbt.linux-arm64-gnu.node')
|
||||
} else {
|
||||
nativeBinding = require('gbt-linux-arm64-gnu')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'arm':
|
||||
localFileExisted = existsSync(
|
||||
join(__dirname, 'gbt.linux-arm-gnueabihf.node')
|
||||
)
|
||||
try {
|
||||
if (localFileExisted) {
|
||||
nativeBinding = require('./gbt.linux-arm-gnueabihf.node')
|
||||
} else {
|
||||
nativeBinding = require('gbt-linux-arm-gnueabihf')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError = e
|
||||
}
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported architecture on Linux: ${arch}`)
|
||||
}
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`)
|
||||
}
|
||||
|
||||
if (!nativeBinding) {
|
||||
if (loadError) {
|
||||
throw loadError
|
||||
}
|
||||
throw new Error(`Failed to load native binding`)
|
||||
}
|
||||
|
||||
const { GbtGenerator, GbtResult } = nativeBinding
|
||||
|
||||
module.exports.GbtGenerator = GbtGenerator
|
||||
module.exports.GbtResult = GbtResult
|
||||
34
backend/rust-gbt/package-lock.json
generated
Normal file
34
backend/rust-gbt/package-lock.json
generated
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "gbt",
|
||||
"version": "3.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gbt",
|
||||
"version": "3.0.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@napi-rs/cli": "2.16.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/cli": {
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-2.16.1.tgz",
|
||||
"integrity": "sha512-L0Gr5iEQIDEbvWdDr1HUaBOxBSHL1VZhWSk1oryawoT8qJIY+KGfLFelU+Qma64ivCPbxYpkfPoKYVG3rcoGIA==",
|
||||
"bin": {
|
||||
"napi": "scripts/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
33
backend/rust-gbt/package.json
Normal file
33
backend/rust-gbt/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "gbt",
|
||||
"version": "3.0.1",
|
||||
"description": "An efficient re-implementation of the getBlockTemplate algorithm in Rust",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"scripts": {
|
||||
"artifacts": "napi artifacts",
|
||||
"build": "napi build --platform",
|
||||
"build-debug": "npm run build",
|
||||
"build-release": "npm run build -- --release --strip",
|
||||
"install": "npm run build-release",
|
||||
"prepublishOnly": "napi prepublish -t npm",
|
||||
"test": "cargo test"
|
||||
},
|
||||
"author": "mononaut",
|
||||
"napi": {
|
||||
"name": "gbt",
|
||||
"triples": {
|
||||
"defaults": false,
|
||||
"additional": [
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"x86_64-unknown-freebsd"
|
||||
]
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@napi-rs/cli": "2.16.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
}
|
||||
225
backend/rust-gbt/src/audit_transaction.rs
Normal file
225
backend/rust-gbt/src/audit_transaction.rs
Normal file
@@ -0,0 +1,225 @@
|
||||
use crate::{
|
||||
u32_hasher_types::{u32hashset_new, U32HasherState},
|
||||
ThreadTransaction, thread_acceleration::ThreadAcceleration,
|
||||
};
|
||||
use std::{
|
||||
cmp::Ordering,
|
||||
collections::HashSet,
|
||||
hash::{Hash, Hasher},
|
||||
};
|
||||
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuditTransaction {
|
||||
pub uid: u32,
|
||||
order: u32,
|
||||
pub fee: u64,
|
||||
pub weight: u32,
|
||||
// exact sigop-adjusted weight
|
||||
pub sigop_adjusted_weight: u32,
|
||||
// sigop-adjusted vsize rounded up the the next integer
|
||||
pub sigop_adjusted_vsize: u32,
|
||||
pub sigops: u32,
|
||||
adjusted_fee_per_vsize: f64,
|
||||
pub effective_fee_per_vsize: f64,
|
||||
pub dependency_rate: f64,
|
||||
pub inputs: Vec<u32>,
|
||||
pub relatives_set_flag: bool,
|
||||
pub ancestors: HashSet<u32, U32HasherState>,
|
||||
pub children: HashSet<u32, U32HasherState>,
|
||||
ancestor_fee: u64,
|
||||
ancestor_sigop_adjusted_weight: u32,
|
||||
ancestor_sigop_adjusted_vsize: u32,
|
||||
ancestor_sigops: u32,
|
||||
// Safety: Must be private to prevent NaN breaking Ord impl.
|
||||
score: f64,
|
||||
pub used: bool,
|
||||
/// whether this transaction has been moved to the "modified" priority queue
|
||||
pub modified: bool,
|
||||
pub dirty: bool,
|
||||
}
|
||||
|
||||
impl Hash for AuditTransaction {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.uid.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for AuditTransaction {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.uid == other.uid
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for AuditTransaction {}
|
||||
|
||||
#[inline]
|
||||
pub fn partial_cmp_uid_score(a: (u32, u32, f64), b: (u32, u32, f64)) -> Option<Ordering> {
|
||||
// If either score is NaN, this is false,
|
||||
// and partial_cmp will return None
|
||||
if a.2 != b.2 {
|
||||
// compare by score (sorts by ascending score)
|
||||
a.2.partial_cmp(&b.2)
|
||||
} else if a.1 != b.1 {
|
||||
// tie-break by comparing partial txids (sorts by descending txid)
|
||||
Some(b.1.cmp(&a.1))
|
||||
} else {
|
||||
// tie-break partial txid collisions by comparing uids (sorts by descending uid)
|
||||
Some(b.0.cmp(&a.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for AuditTransaction {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
partial_cmp_uid_score(
|
||||
(self.uid, self.order, self.score),
|
||||
(other.uid, other.order, other.score),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for AuditTransaction {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
// Safety: The only possible values for score are f64
|
||||
// that are not NaN. This is because outside code can not
|
||||
// freely assign score. Also, calc_new_score guarantees no NaN.
|
||||
self.partial_cmp(other).expect("score will never be NaN")
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn calc_fee_rate(fee: u64, vsize: f64) -> f64 {
|
||||
(fee as f64) / (if vsize == 0.0 { 1.0 } else { vsize })
|
||||
}
|
||||
|
||||
impl AuditTransaction {
|
||||
pub fn from_thread_transaction(tx: &ThreadTransaction, maybe_acceleration: Option<Option<&ThreadAcceleration>>) -> Self {
|
||||
let fee_delta = match maybe_acceleration {
|
||||
Some(Some(acceleration)) => acceleration.delta,
|
||||
_ => 0.0
|
||||
};
|
||||
let fee = (tx.fee as u64) + (fee_delta as u64);
|
||||
// rounded up to the nearest integer
|
||||
let is_adjusted = tx.weight < (tx.sigops * 20);
|
||||
let sigop_adjusted_vsize = ((tx.weight + 3) / 4).max(tx.sigops * 5);
|
||||
let sigop_adjusted_weight = tx.weight.max(tx.sigops * 20);
|
||||
let effective_fee_per_vsize = if is_adjusted || fee_delta > 0.0 {
|
||||
calc_fee_rate(fee, f64::from(sigop_adjusted_weight) / 4.0)
|
||||
} else {
|
||||
tx.effective_fee_per_vsize
|
||||
};
|
||||
Self {
|
||||
uid: tx.uid,
|
||||
order: tx.order,
|
||||
fee,
|
||||
weight: tx.weight,
|
||||
sigop_adjusted_weight,
|
||||
sigop_adjusted_vsize,
|
||||
sigops: tx.sigops,
|
||||
adjusted_fee_per_vsize: calc_fee_rate(fee, f64::from(sigop_adjusted_vsize)),
|
||||
effective_fee_per_vsize,
|
||||
dependency_rate: f64::INFINITY,
|
||||
inputs: tx.inputs.clone(),
|
||||
relatives_set_flag: false,
|
||||
ancestors: u32hashset_new(),
|
||||
children: u32hashset_new(),
|
||||
ancestor_fee: fee,
|
||||
ancestor_sigop_adjusted_weight: sigop_adjusted_weight,
|
||||
ancestor_sigop_adjusted_vsize: sigop_adjusted_vsize,
|
||||
ancestor_sigops: tx.sigops,
|
||||
score: 0.0,
|
||||
used: false,
|
||||
modified: false,
|
||||
dirty: effective_fee_per_vsize != tx.effective_fee_per_vsize || fee_delta > 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub const fn score(&self) -> f64 {
|
||||
self.score
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub const fn order(&self) -> u32 {
|
||||
self.order
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub const fn ancestor_sigop_adjusted_vsize(&self) -> u32 {
|
||||
self.ancestor_sigop_adjusted_vsize
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub const fn ancestor_sigops(&self) -> u32 {
|
||||
self.ancestor_sigops
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn cluster_rate(&self) -> f64 {
|
||||
// Safety: self.ancestor_weight can never be 0.
|
||||
// Even if it could, as it approaches 0, the value inside the min() call
|
||||
// grows, so if we think of 0 as "grew infinitely" then dependency_rate would be
|
||||
// the smaller of the two. If either side is NaN, the other side is returned.
|
||||
self.dependency_rate.min(calc_fee_rate(
|
||||
self.ancestor_fee,
|
||||
f64::from(self.ancestor_sigop_adjusted_weight) / 4.0,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn set_dirty_if_different(&mut self, cluster_rate: f64) {
|
||||
if self.effective_fee_per_vsize != cluster_rate {
|
||||
self.effective_fee_per_vsize = cluster_rate;
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Safety: This function must NEVER set score to NaN.
|
||||
#[inline]
|
||||
fn calc_new_score(&mut self) {
|
||||
self.score = self.adjusted_fee_per_vsize.min(calc_fee_rate(
|
||||
self.ancestor_fee,
|
||||
f64::from(self.ancestor_sigop_adjusted_vsize),
|
||||
));
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn set_ancestors(
|
||||
&mut self,
|
||||
ancestors: HashSet<u32, U32HasherState>,
|
||||
total_fee: u64,
|
||||
total_sigop_adjusted_weight: u32,
|
||||
total_sigop_adjusted_vsize: u32,
|
||||
total_sigops: u32,
|
||||
) {
|
||||
self.ancestors = ancestors;
|
||||
self.ancestor_fee = self.fee + total_fee;
|
||||
self.ancestor_sigop_adjusted_weight =
|
||||
self.sigop_adjusted_weight + total_sigop_adjusted_weight;
|
||||
self.ancestor_sigop_adjusted_vsize = self.sigop_adjusted_vsize + total_sigop_adjusted_vsize;
|
||||
self.ancestor_sigops = self.sigops + total_sigops;
|
||||
self.calc_new_score();
|
||||
self.relatives_set_flag = true;
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn remove_root(
|
||||
&mut self,
|
||||
root_txid: u32,
|
||||
root_fee: u64,
|
||||
root_sigop_adjusted_weight: u32,
|
||||
root_sigop_adjusted_vsize: u32,
|
||||
root_sigops: u32,
|
||||
cluster_rate: f64,
|
||||
) -> f64 {
|
||||
let old_score = self.score();
|
||||
self.dependency_rate = self.dependency_rate.min(cluster_rate);
|
||||
if self.ancestors.remove(&root_txid) {
|
||||
self.ancestor_fee -= root_fee;
|
||||
self.ancestor_sigop_adjusted_weight -= root_sigop_adjusted_weight;
|
||||
self.ancestor_sigop_adjusted_vsize -= root_sigop_adjusted_vsize;
|
||||
self.ancestor_sigops -= root_sigops;
|
||||
self.calc_new_score();
|
||||
}
|
||||
old_score
|
||||
}
|
||||
}
|
||||
437
backend/rust-gbt/src/gbt.rs
Normal file
437
backend/rust-gbt/src/gbt.rs
Normal file
@@ -0,0 +1,437 @@
|
||||
use priority_queue::PriorityQueue;
|
||||
use std::{cmp::Ordering, collections::HashSet, mem::ManuallyDrop};
|
||||
use tracing::{info, trace};
|
||||
|
||||
use crate::{
|
||||
audit_transaction::{partial_cmp_uid_score, AuditTransaction},
|
||||
u32_hasher_types::{u32hashset_new, u32priority_queue_with_capacity, U32HasherState},
|
||||
GbtResult, ThreadTransactionsMap, thread_acceleration::ThreadAcceleration,
|
||||
};
|
||||
|
||||
const MAX_BLOCK_WEIGHT_UNITS: u32 = 4_000_000 - 4_000;
|
||||
const BLOCK_SIGOPS: u32 = 80_000;
|
||||
const BLOCK_RESERVED_WEIGHT: u32 = 4_000;
|
||||
const BLOCK_RESERVED_SIGOPS: u32 = 400;
|
||||
const MAX_BLOCKS: usize = 8;
|
||||
|
||||
type AuditPool = Vec<Option<ManuallyDrop<AuditTransaction>>>;
|
||||
type ModifiedQueue = PriorityQueue<u32, TxPriority, U32HasherState>;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TxPriority {
|
||||
uid: u32,
|
||||
order: u32,
|
||||
score: f64,
|
||||
}
|
||||
impl PartialEq for TxPriority {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.uid == other.uid
|
||||
}
|
||||
}
|
||||
impl Eq for TxPriority {}
|
||||
impl PartialOrd for TxPriority {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
partial_cmp_uid_score(
|
||||
(self.uid, self.order, self.score),
|
||||
(other.uid, other.order, other.score),
|
||||
)
|
||||
}
|
||||
}
|
||||
impl Ord for TxPriority {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.partial_cmp(other).expect("score will never be NaN")
|
||||
}
|
||||
}
|
||||
|
||||
/// Build projected mempool blocks using an approximation of the transaction selection algorithm from Bitcoin Core.
|
||||
///
|
||||
/// See `BlockAssembler` in Bitcoin Core's
|
||||
/// [miner.cpp](https://github.com/bitcoin/bitcoin/blob/master/src/node/miner.cpp).
|
||||
/// Ported from mempool backend's
|
||||
/// [tx-selection-worker.ts](https://github.com/mempool/mempool/blob/master/backend/src/api/tx-selection-worker.ts).
|
||||
//
|
||||
// TODO: Make gbt smaller to fix these lints.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
#[allow(clippy::cognitive_complexity)]
|
||||
pub fn gbt(mempool: &mut ThreadTransactionsMap, accelerations: &[ThreadAcceleration], max_uid: usize) -> GbtResult {
|
||||
let mut indexed_accelerations = Vec::with_capacity(max_uid + 1);
|
||||
indexed_accelerations.resize(max_uid + 1, None);
|
||||
for acceleration in accelerations {
|
||||
indexed_accelerations[acceleration.uid as usize] = Some(acceleration);
|
||||
}
|
||||
|
||||
info!("Initializing working vecs with uid capacity for {}", max_uid + 1);
|
||||
let mempool_len = mempool.len();
|
||||
let mut audit_pool: AuditPool = Vec::with_capacity(max_uid + 1);
|
||||
audit_pool.resize(max_uid + 1, None);
|
||||
let mut mempool_stack: Vec<u32> = Vec::with_capacity(mempool_len);
|
||||
let mut clusters: Vec<Vec<u32>> = Vec::new();
|
||||
let mut block_weights: Vec<u32> = Vec::new();
|
||||
|
||||
info!("Initializing working structs");
|
||||
for (uid, tx) in &mut *mempool {
|
||||
let acceleration = indexed_accelerations.get(*uid as usize);
|
||||
let audit_tx = AuditTransaction::from_thread_transaction(tx, acceleration.copied());
|
||||
// Safety: audit_pool and mempool_stack must always contain the same transactions
|
||||
audit_pool[*uid as usize] = Some(ManuallyDrop::new(audit_tx));
|
||||
mempool_stack.push(*uid);
|
||||
}
|
||||
|
||||
info!("Building relatives graph & calculate ancestor scores");
|
||||
for txid in &mempool_stack {
|
||||
set_relatives(*txid, &mut audit_pool);
|
||||
}
|
||||
trace!("Post relative graph Audit Pool: {:#?}", audit_pool);
|
||||
|
||||
info!("Sorting by descending ancestor score");
|
||||
let mut mempool_stack: Vec<(u32, u32, f64)> = mempool_stack
|
||||
.into_iter()
|
||||
.map(|txid| {
|
||||
let atx = audit_pool
|
||||
.get(txid as usize)
|
||||
.and_then(Option::as_ref)
|
||||
.expect("All txids are from audit_pool");
|
||||
(txid, atx.order(), atx.score())
|
||||
})
|
||||
.collect();
|
||||
mempool_stack.sort_unstable_by(|a, b| partial_cmp_uid_score(*a, *b).expect("Not NaN"));
|
||||
let mut mempool_stack: Vec<u32> = mempool_stack.into_iter().map(|(txid, _, _)| txid).collect();
|
||||
|
||||
info!("Building blocks by greedily choosing the highest feerate package");
|
||||
info!("(i.e. the package rooted in the transaction with the best ancestor score)");
|
||||
let mut blocks: Vec<Vec<u32>> = Vec::new();
|
||||
let mut block_weight: u32 = BLOCK_RESERVED_WEIGHT;
|
||||
let mut block_sigops: u32 = BLOCK_RESERVED_SIGOPS;
|
||||
// No need to be bigger than 4096 transactions for the per-block transaction Vec.
|
||||
let initial_txes_per_block: usize = 4096.min(mempool_len);
|
||||
let mut transactions: Vec<u32> = Vec::with_capacity(initial_txes_per_block);
|
||||
let mut modified: ModifiedQueue = u32priority_queue_with_capacity(mempool_len);
|
||||
let mut overflow: Vec<u32> = Vec::new();
|
||||
let mut failures = 0;
|
||||
while !mempool_stack.is_empty() || !modified.is_empty() {
|
||||
// This trace log storm is big, so to make scrolling through
|
||||
// Each iteration easier, leaving a bunch of empty rows
|
||||
// And a header of ======
|
||||
trace!("\n\n\n\n\n\n\n\n\n\n==================================");
|
||||
trace!("mempool_array: {:#?}", mempool_stack);
|
||||
trace!("clusters: {:#?}", clusters);
|
||||
trace!("modified: {:#?}", modified);
|
||||
trace!("audit_pool: {:#?}", audit_pool);
|
||||
trace!("blocks: {:#?}", blocks);
|
||||
trace!("block_weight: {:#?}", block_weight);
|
||||
trace!("block_sigops: {:#?}", block_sigops);
|
||||
trace!("transactions: {:#?}", transactions);
|
||||
trace!("overflow: {:#?}", overflow);
|
||||
trace!("failures: {:#?}", failures);
|
||||
trace!("\n==================================");
|
||||
|
||||
let next_from_stack = next_valid_from_stack(&mut mempool_stack, &audit_pool);
|
||||
let next_from_queue = next_valid_from_queue(&mut modified, &audit_pool);
|
||||
if next_from_stack.is_none() && next_from_queue.is_none() {
|
||||
info!("No transactions left! {:#?} in overflow", overflow.len());
|
||||
} else {
|
||||
let (next_tx, from_stack) = match (next_from_stack, next_from_queue) {
|
||||
(Some(stack_tx), Some(queue_tx)) => match queue_tx.cmp(stack_tx) {
|
||||
std::cmp::Ordering::Less => (stack_tx, true),
|
||||
_ => (queue_tx, false),
|
||||
},
|
||||
(Some(stack_tx), None) => (stack_tx, true),
|
||||
(None, Some(queue_tx)) => (queue_tx, false),
|
||||
(None, None) => unreachable!(),
|
||||
};
|
||||
|
||||
if from_stack {
|
||||
mempool_stack.pop();
|
||||
} else {
|
||||
modified.pop();
|
||||
}
|
||||
|
||||
if blocks.len() < (MAX_BLOCKS - 1)
|
||||
&& ((block_weight + (4 * next_tx.ancestor_sigop_adjusted_vsize())
|
||||
>= MAX_BLOCK_WEIGHT_UNITS)
|
||||
|| (block_sigops + next_tx.ancestor_sigops() > BLOCK_SIGOPS))
|
||||
{
|
||||
// hold this package in an overflow list while we check for smaller options
|
||||
overflow.push(next_tx.uid);
|
||||
failures += 1;
|
||||
} else {
|
||||
let mut package: Vec<(u32, u32, usize)> = Vec::new();
|
||||
let mut cluster: Vec<u32> = Vec::new();
|
||||
let is_cluster: bool = !next_tx.ancestors.is_empty();
|
||||
for ancestor_id in &next_tx.ancestors {
|
||||
if let Some(Some(ancestor)) = audit_pool.get(*ancestor_id as usize) {
|
||||
package.push((*ancestor_id, ancestor.order(), ancestor.ancestors.len()));
|
||||
}
|
||||
}
|
||||
package.sort_unstable_by(|a, b| -> Ordering {
|
||||
if a.2 != b.2 {
|
||||
// order by ascending ancestor count
|
||||
a.2.cmp(&b.2)
|
||||
} else if a.1 != b.1 {
|
||||
// tie-break by ascending partial txid
|
||||
a.1.cmp(&b.1)
|
||||
} else {
|
||||
// tie-break partial txid collisions by ascending uid
|
||||
a.0.cmp(&b.0)
|
||||
}
|
||||
});
|
||||
package.push((next_tx.uid, next_tx.order(), next_tx.ancestors.len()));
|
||||
|
||||
let cluster_rate = next_tx.cluster_rate();
|
||||
|
||||
for (txid, _, _) in &package {
|
||||
cluster.push(*txid);
|
||||
if let Some(Some(tx)) = audit_pool.get_mut(*txid as usize) {
|
||||
tx.used = true;
|
||||
tx.set_dirty_if_different(cluster_rate);
|
||||
transactions.push(tx.uid);
|
||||
block_weight += tx.weight;
|
||||
block_sigops += tx.sigops;
|
||||
}
|
||||
update_descendants(*txid, &mut audit_pool, &mut modified, cluster_rate);
|
||||
}
|
||||
|
||||
if is_cluster {
|
||||
clusters.push(cluster);
|
||||
}
|
||||
|
||||
failures = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// this block is full
|
||||
let exceeded_package_tries =
|
||||
failures > 1000 && block_weight > (MAX_BLOCK_WEIGHT_UNITS - BLOCK_RESERVED_WEIGHT);
|
||||
let queue_is_empty = mempool_stack.is_empty() && modified.is_empty();
|
||||
if (exceeded_package_tries || queue_is_empty) && blocks.len() < (MAX_BLOCKS - 1) {
|
||||
// finalize this block
|
||||
if transactions.is_empty() {
|
||||
info!("trying to push an empty block! breaking loop! mempool {:#?} | modified {:#?} | overflow {:#?}", mempool_stack.len(), modified.len(), overflow.len());
|
||||
break;
|
||||
}
|
||||
|
||||
blocks.push(transactions);
|
||||
block_weights.push(block_weight);
|
||||
|
||||
// reset for the next block
|
||||
transactions = Vec::with_capacity(initial_txes_per_block);
|
||||
block_weight = BLOCK_RESERVED_WEIGHT;
|
||||
block_sigops = BLOCK_RESERVED_SIGOPS;
|
||||
failures = 0;
|
||||
// 'overflow' packages didn't fit in this block, but are valid candidates for the next
|
||||
overflow.reverse();
|
||||
for overflowed in &overflow {
|
||||
if let Some(Some(overflowed_tx)) = audit_pool.get(*overflowed as usize) {
|
||||
if overflowed_tx.modified {
|
||||
modified.push(
|
||||
*overflowed,
|
||||
TxPriority {
|
||||
uid: *overflowed,
|
||||
order: overflowed_tx.order(),
|
||||
score: overflowed_tx.score(),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
mempool_stack.push(*overflowed);
|
||||
}
|
||||
}
|
||||
}
|
||||
overflow = Vec::new();
|
||||
}
|
||||
}
|
||||
info!("add the final unbounded block if it contains any transactions");
|
||||
if !transactions.is_empty() {
|
||||
blocks.push(transactions);
|
||||
block_weights.push(block_weight);
|
||||
}
|
||||
|
||||
info!("make a list of dirty transactions and their new rates");
|
||||
let mut rates: Vec<Vec<f64>> = Vec::new();
|
||||
for (uid, thread_tx) in mempool {
|
||||
// Takes ownership of the audit_tx and replaces with None
|
||||
if let Some(Some(audit_tx)) = audit_pool.get_mut(*uid as usize).map(Option::take) {
|
||||
trace!("txid: {}, is_dirty: {}", uid, audit_tx.dirty);
|
||||
if audit_tx.dirty {
|
||||
rates.push(vec![f64::from(*uid), audit_tx.effective_fee_per_vsize]);
|
||||
thread_tx.effective_fee_per_vsize = audit_tx.effective_fee_per_vsize;
|
||||
}
|
||||
// Drops the AuditTransaction manually
|
||||
// There are no audit_txs that are not in the mempool HashMap
|
||||
// So there is guaranteed to be no memory leaks.
|
||||
ManuallyDrop::into_inner(audit_tx);
|
||||
}
|
||||
}
|
||||
trace!("\n\n\n\n\n====================");
|
||||
trace!("blocks: {:#?}", blocks);
|
||||
trace!("clusters: {:#?}", clusters);
|
||||
trace!("rates: {:#?}\n====================\n\n\n\n\n", rates);
|
||||
|
||||
GbtResult {
|
||||
blocks,
|
||||
block_weights,
|
||||
clusters,
|
||||
rates,
|
||||
overflow,
|
||||
}
|
||||
}
|
||||
|
||||
fn next_valid_from_stack<'a>(
|
||||
mempool_stack: &mut Vec<u32>,
|
||||
audit_pool: &'a AuditPool,
|
||||
) -> Option<&'a AuditTransaction> {
|
||||
while let Some(next_txid) = mempool_stack.last() {
|
||||
match audit_pool.get(*next_txid as usize) {
|
||||
Some(Some(tx)) if !tx.used && !tx.modified => {
|
||||
return Some(tx);
|
||||
}
|
||||
_ => {
|
||||
mempool_stack.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn next_valid_from_queue<'a>(
|
||||
queue: &mut ModifiedQueue,
|
||||
audit_pool: &'a AuditPool,
|
||||
) -> Option<&'a AuditTransaction> {
|
||||
while let Some((next_txid, _)) = queue.peek() {
|
||||
match audit_pool.get(*next_txid as usize) {
|
||||
Some(Some(tx)) if !tx.used => {
|
||||
return Some(tx);
|
||||
}
|
||||
_ => {
|
||||
queue.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn set_relatives(txid: u32, audit_pool: &mut AuditPool) {
|
||||
let mut parents: HashSet<u32, U32HasherState> = u32hashset_new();
|
||||
if let Some(Some(tx)) = audit_pool.get(txid as usize) {
|
||||
if tx.relatives_set_flag {
|
||||
return;
|
||||
}
|
||||
for input in &tx.inputs {
|
||||
parents.insert(*input);
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut ancestors: HashSet<u32, U32HasherState> = u32hashset_new();
|
||||
for parent_id in &parents {
|
||||
set_relatives(*parent_id, audit_pool);
|
||||
|
||||
if let Some(Some(parent)) = audit_pool.get_mut(*parent_id as usize) {
|
||||
// Safety: ancestors must always contain only txes in audit_pool
|
||||
ancestors.insert(*parent_id);
|
||||
parent.children.insert(txid);
|
||||
for ancestor in &parent.ancestors {
|
||||
ancestors.insert(*ancestor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut total_fee: u64 = 0;
|
||||
let mut total_sigop_adjusted_weight: u32 = 0;
|
||||
let mut total_sigop_adjusted_vsize: u32 = 0;
|
||||
let mut total_sigops: u32 = 0;
|
||||
|
||||
for ancestor_id in &ancestors {
|
||||
if let Some(ancestor) = audit_pool
|
||||
.get(*ancestor_id as usize)
|
||||
.expect("audit_pool contains all ancestors")
|
||||
{
|
||||
total_fee += ancestor.fee;
|
||||
total_sigop_adjusted_weight += ancestor.sigop_adjusted_weight;
|
||||
total_sigop_adjusted_vsize += ancestor.sigop_adjusted_vsize;
|
||||
total_sigops += ancestor.sigops;
|
||||
} else { todo!() };
|
||||
}
|
||||
|
||||
if let Some(Some(tx)) = audit_pool.get_mut(txid as usize) {
|
||||
tx.set_ancestors(
|
||||
ancestors,
|
||||
total_fee,
|
||||
total_sigop_adjusted_weight,
|
||||
total_sigop_adjusted_vsize,
|
||||
total_sigops,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// iterate over remaining descendants, removing the root as a valid ancestor & updating the ancestor score
|
||||
fn update_descendants(
|
||||
root_txid: u32,
|
||||
audit_pool: &mut AuditPool,
|
||||
modified: &mut ModifiedQueue,
|
||||
cluster_rate: f64,
|
||||
) {
|
||||
let mut visited: HashSet<u32, U32HasherState> = u32hashset_new();
|
||||
let mut descendant_stack: Vec<u32> = Vec::new();
|
||||
let root_fee: u64;
|
||||
let root_sigop_adjusted_weight: u32;
|
||||
let root_sigop_adjusted_vsize: u32;
|
||||
let root_sigops: u32;
|
||||
if let Some(Some(root_tx)) = audit_pool.get(root_txid as usize) {
|
||||
for descendant_id in &root_tx.children {
|
||||
if !visited.contains(descendant_id) {
|
||||
descendant_stack.push(*descendant_id);
|
||||
visited.insert(*descendant_id);
|
||||
}
|
||||
}
|
||||
root_fee = root_tx.fee;
|
||||
root_sigop_adjusted_weight = root_tx.sigop_adjusted_weight;
|
||||
root_sigop_adjusted_vsize = root_tx.sigop_adjusted_vsize;
|
||||
root_sigops = root_tx.sigops;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
while let Some(next_txid) = descendant_stack.pop() {
|
||||
if let Some(Some(descendant)) = audit_pool.get_mut(next_txid as usize) {
|
||||
// remove root tx as ancestor
|
||||
let old_score = descendant.remove_root(
|
||||
root_txid,
|
||||
root_fee,
|
||||
root_sigop_adjusted_weight,
|
||||
root_sigop_adjusted_vsize,
|
||||
root_sigops,
|
||||
cluster_rate,
|
||||
);
|
||||
// add to priority queue or update priority if score has changed
|
||||
if descendant.score() < old_score {
|
||||
descendant.modified = true;
|
||||
modified.push_decrease(
|
||||
descendant.uid,
|
||||
TxPriority {
|
||||
uid: descendant.uid,
|
||||
order: descendant.order(),
|
||||
score: descendant.score(),
|
||||
},
|
||||
);
|
||||
} else if descendant.score() > old_score {
|
||||
descendant.modified = true;
|
||||
modified.push_increase(
|
||||
descendant.uid,
|
||||
TxPriority {
|
||||
uid: descendant.uid,
|
||||
order: descendant.order(),
|
||||
score: descendant.score(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// add this node's children to the stack
|
||||
for child_id in &descendant.children {
|
||||
if !visited.contains(child_id) {
|
||||
descendant_stack.push(*child_id);
|
||||
visited.insert(*child_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
184
backend/rust-gbt/src/lib.rs
Normal file
184
backend/rust-gbt/src/lib.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
#![warn(clippy::all)]
|
||||
#![warn(clippy::pedantic)]
|
||||
#![warn(clippy::nursery)]
|
||||
#![allow(clippy::cast_precision_loss)]
|
||||
#![allow(clippy::cast_possible_truncation)]
|
||||
#![allow(clippy::cast_sign_loss)]
|
||||
#![allow(clippy::float_cmp)]
|
||||
|
||||
use napi::bindgen_prelude::Result;
|
||||
use napi_derive::napi;
|
||||
use thread_transaction::ThreadTransaction;
|
||||
use thread_acceleration::ThreadAcceleration;
|
||||
use tracing::{debug, info, trace};
|
||||
use tracing_log::LogTracer;
|
||||
use tracing_subscriber::{EnvFilter, FmtSubscriber};
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
mod audit_transaction;
|
||||
mod gbt;
|
||||
mod thread_transaction;
|
||||
mod thread_acceleration;
|
||||
mod u32_hasher_types;
|
||||
|
||||
use u32_hasher_types::{u32hashmap_with_capacity, U32HasherState};
|
||||
|
||||
/// This is the initial capacity of the `GbtGenerator` struct's inner `HashMap`.
|
||||
///
|
||||
/// Note: This doesn't *have* to be a power of 2. (uwu)
|
||||
const STARTING_CAPACITY: usize = 1_048_576;
|
||||
|
||||
type ThreadTransactionsMap = HashMap<u32, ThreadTransaction, U32HasherState>;
|
||||
|
||||
#[napi]
|
||||
pub struct GbtGenerator {
|
||||
thread_transactions: Arc<Mutex<ThreadTransactionsMap>>,
|
||||
}
|
||||
|
||||
#[napi::module_init]
|
||||
fn init() {
|
||||
// Set all `tracing` logs to print to STDOUT
|
||||
// Note: Passing RUST_LOG env variable to the node process
|
||||
// will change the log level for the rust module.
|
||||
tracing::subscriber::set_global_default(
|
||||
FmtSubscriber::builder()
|
||||
.with_env_filter(EnvFilter::from_default_env())
|
||||
.with_ansi(
|
||||
// Default to no-color logs.
|
||||
// Setting RUST_LOG_COLOR to 1 or true|TRUE|True etc.
|
||||
// will enable color
|
||||
std::env::var("RUST_LOG_COLOR")
|
||||
.map(|s| ["1", "true"].contains(&&*s.to_lowercase()))
|
||||
.unwrap_or(false),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.expect("Logging subscriber failed");
|
||||
// Convert all `log` logs into `tracing` events
|
||||
LogTracer::init().expect("Legacy log subscriber failed");
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl GbtGenerator {
|
||||
#[napi(constructor)]
|
||||
#[allow(clippy::new_without_default)]
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
debug!("Created new GbtGenerator");
|
||||
Self {
|
||||
thread_transactions: Arc::new(Mutex::new(u32hashmap_with_capacity(STARTING_CAPACITY))),
|
||||
}
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
///
|
||||
/// Rejects if the thread panics or if the Mutex is poisoned.
|
||||
#[napi]
|
||||
pub async fn make(&self, mempool: Vec<ThreadTransaction>, accelerations: Vec<ThreadAcceleration>, max_uid: u32) -> Result<GbtResult> {
|
||||
trace!("make: Current State {:#?}", self.thread_transactions);
|
||||
run_task(
|
||||
Arc::clone(&self.thread_transactions),
|
||||
accelerations,
|
||||
max_uid as usize,
|
||||
move |map| {
|
||||
for tx in mempool {
|
||||
map.insert(tx.uid, tx);
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
///
|
||||
/// Rejects if the thread panics or if the Mutex is poisoned.
|
||||
#[napi]
|
||||
pub async fn update(
|
||||
&self,
|
||||
new_txs: Vec<ThreadTransaction>,
|
||||
remove_txs: Vec<u32>,
|
||||
accelerations: Vec<ThreadAcceleration>,
|
||||
max_uid: u32,
|
||||
) -> Result<GbtResult> {
|
||||
trace!("update: Current State {:#?}", self.thread_transactions);
|
||||
run_task(
|
||||
Arc::clone(&self.thread_transactions),
|
||||
accelerations,
|
||||
max_uid as usize,
|
||||
move |map| {
|
||||
for tx in new_txs {
|
||||
map.insert(tx.uid, tx);
|
||||
}
|
||||
for txid in &remove_txs {
|
||||
map.remove(txid);
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// The result from calling the gbt function.
|
||||
///
|
||||
/// This tuple contains the following:
|
||||
/// blocks: A 2D Vector of transaction IDs (u32), the inner Vecs each represent a block.
|
||||
/// block_weights: A Vector of total weights per block.
|
||||
/// clusters: A 2D Vector of transaction IDs representing clusters of dependent mempool transactions
|
||||
/// rates: A Vector of tuples containing transaction IDs (u32) and effective fee per vsize (f64)
|
||||
#[napi(constructor)]
|
||||
pub struct GbtResult {
|
||||
pub blocks: Vec<Vec<u32>>,
|
||||
pub block_weights: Vec<u32>,
|
||||
pub clusters: Vec<Vec<u32>>,
|
||||
pub rates: Vec<Vec<f64>>, // Tuples not supported. u32 fits inside f64
|
||||
pub overflow: Vec<u32>,
|
||||
}
|
||||
|
||||
/// All on another thread, this runs an arbitrary task in between
|
||||
/// taking the lock and running gbt.
|
||||
///
|
||||
/// Rather than filling / updating the `HashMap` on the main thread,
|
||||
/// this allows for `HashMap` modifying tasks to be run before running and returning gbt results.
|
||||
///
|
||||
/// `thread_transactions` is a cloned `Arc` of the `Mutex` for the `HashMap` state.
|
||||
/// `callback` is a `'static + Send` `FnOnce` closure/function that takes a mutable reference
|
||||
/// to the `HashMap` as the only argument. (A move closure is recommended to meet the bounds)
|
||||
async fn run_task<F>(
|
||||
thread_transactions: Arc<Mutex<ThreadTransactionsMap>>,
|
||||
accelerations: Vec<ThreadAcceleration>,
|
||||
max_uid: usize,
|
||||
callback: F,
|
||||
) -> Result<GbtResult>
|
||||
where
|
||||
F: FnOnce(&mut ThreadTransactionsMap) + Send + 'static,
|
||||
{
|
||||
debug!("Spawning thread...");
|
||||
let handle = napi::tokio::task::spawn_blocking(move || {
|
||||
debug!(
|
||||
"Getting lock for thread_transactions from thread {:?}...",
|
||||
std::thread::current().id()
|
||||
);
|
||||
let mut map = thread_transactions
|
||||
.lock()
|
||||
.map_err(|_| napi::Error::from_reason("THREAD_TRANSACTIONS Mutex poisoned"))?;
|
||||
callback(&mut map);
|
||||
|
||||
info!("Starting gbt algorithm for {} elements...", map.len());
|
||||
let result = gbt::gbt(&mut map, &accelerations, max_uid);
|
||||
info!("Finished gbt algorithm for {} elements...", map.len());
|
||||
|
||||
debug!(
|
||||
"Releasing lock for thread_transactions from thread {:?}...",
|
||||
std::thread::current().id()
|
||||
);
|
||||
drop(map);
|
||||
|
||||
Ok(result)
|
||||
});
|
||||
|
||||
handle
|
||||
.await
|
||||
.map_err(|_| napi::Error::from_reason("thread panicked"))?
|
||||
}
|
||||
8
backend/rust-gbt/src/thread_acceleration.rs
Normal file
8
backend/rust-gbt/src/thread_acceleration.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
use napi_derive::napi;
|
||||
|
||||
#[derive(Debug)]
|
||||
#[napi(object)]
|
||||
pub struct ThreadAcceleration {
|
||||
pub uid: u32,
|
||||
pub delta: f64, // fee delta
|
||||
}
|
||||
13
backend/rust-gbt/src/thread_transaction.rs
Normal file
13
backend/rust-gbt/src/thread_transaction.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use napi_derive::napi;
|
||||
|
||||
#[derive(Debug)]
|
||||
#[napi(object)]
|
||||
pub struct ThreadTransaction {
|
||||
pub uid: u32,
|
||||
pub order: u32,
|
||||
pub fee: f64,
|
||||
pub weight: u32,
|
||||
pub sigops: u32,
|
||||
pub effective_fee_per_vsize: f64,
|
||||
pub inputs: Vec<u32>,
|
||||
}
|
||||
132
backend/rust-gbt/src/u32_hasher_types.rs
Normal file
132
backend/rust-gbt/src/u32_hasher_types.rs
Normal file
@@ -0,0 +1,132 @@
|
||||
use priority_queue::PriorityQueue;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fmt::Debug,
|
||||
hash::{BuildHasher, Hasher},
|
||||
};
|
||||
|
||||
/// This is the only way to create a `HashMap` with the `U32HasherState` and capacity
|
||||
pub fn u32hashmap_with_capacity<V>(capacity: usize) -> HashMap<u32, V, U32HasherState> {
|
||||
HashMap::with_capacity_and_hasher(capacity, U32HasherState(()))
|
||||
}
|
||||
|
||||
/// This is the only way to create a `PriorityQueue` with the `U32HasherState` and capacity
|
||||
pub fn u32priority_queue_with_capacity<V: Ord>(
|
||||
capacity: usize,
|
||||
) -> PriorityQueue<u32, V, U32HasherState> {
|
||||
PriorityQueue::with_capacity_and_hasher(capacity, U32HasherState(()))
|
||||
}
|
||||
|
||||
/// This is the only way to create a `HashSet` with the `U32HasherState`
|
||||
pub fn u32hashset_new() -> HashSet<u32, U32HasherState> {
|
||||
HashSet::with_hasher(U32HasherState(()))
|
||||
}
|
||||
|
||||
/// A private unit type is contained so no one can make an instance of it.
|
||||
#[derive(Clone)]
|
||||
pub struct U32HasherState(());
|
||||
|
||||
impl Debug for U32HasherState {
|
||||
fn fmt(&self, _: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl BuildHasher for U32HasherState {
|
||||
type Hasher = U32Hasher;
|
||||
|
||||
fn build_hasher(&self) -> Self::Hasher {
|
||||
U32Hasher(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// This also can't be created outside this module due to private field.
|
||||
pub struct U32Hasher(u32);
|
||||
|
||||
impl Hasher for U32Hasher {
|
||||
fn finish(&self) -> u64 {
|
||||
// Safety: Two u32s next to each other will make a u64
|
||||
bytemuck::cast([self.0, 0])
|
||||
}
|
||||
|
||||
fn write(&mut self, bytes: &[u8]) {
|
||||
// Assert in debug builds (testing too) that only 4 byte keys (u32, i32, f32, etc.) run
|
||||
debug_assert!(bytes.len() == 4);
|
||||
// Safety: We know that the size of the key is 4 bytes
|
||||
// We also know that the only way to get an instance of HashMap using this "hasher"
|
||||
// is through the public functions in this module which set the key type to u32.
|
||||
self.0 = *bytemuck::from_bytes(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::U32HasherState;
|
||||
use priority_queue::PriorityQueue;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn test_hashmap() {
|
||||
let mut hm: HashMap<u32, String, U32HasherState> = HashMap::with_hasher(U32HasherState(()));
|
||||
|
||||
// Testing basic operations with the custom hasher
|
||||
hm.insert(0, String::from("0"));
|
||||
hm.insert(42, String::from("42"));
|
||||
hm.insert(256, String::from("256"));
|
||||
hm.insert(u32::MAX, String::from("MAX"));
|
||||
hm.insert(u32::MAX >> 2, String::from("MAX >> 2"));
|
||||
|
||||
assert_eq!(hm.get(&0), Some(&String::from("0")));
|
||||
assert_eq!(hm.get(&42), Some(&String::from("42")));
|
||||
assert_eq!(hm.get(&256), Some(&String::from("256")));
|
||||
assert_eq!(hm.get(&u32::MAX), Some(&String::from("MAX")));
|
||||
assert_eq!(hm.get(&(u32::MAX >> 2)), Some(&String::from("MAX >> 2")));
|
||||
assert_eq!(hm.get(&(u32::MAX >> 4)), None);
|
||||
assert_eq!(hm.get(&3), None);
|
||||
assert_eq!(hm.get(&43), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_priority_queue() {
|
||||
let mut pq: PriorityQueue<u32, i32, U32HasherState> =
|
||||
PriorityQueue::with_hasher(U32HasherState(()));
|
||||
|
||||
// Testing basic operations with the custom hasher
|
||||
assert_eq!(pq.push(1, 5), None);
|
||||
assert_eq!(pq.push(2, -10), None);
|
||||
assert_eq!(pq.push(3, 7), None);
|
||||
assert_eq!(pq.push(4, 20), None);
|
||||
assert_eq!(pq.push(u32::MAX, -42), None);
|
||||
|
||||
assert_eq!(pq.push_increase(1, 4), Some(4));
|
||||
assert_eq!(pq.push_increase(2, -8), Some(-10));
|
||||
assert_eq!(pq.push_increase(3, 5), Some(5));
|
||||
assert_eq!(pq.push_increase(4, 21), Some(20));
|
||||
assert_eq!(pq.push_increase(u32::MAX, -99), Some(-99));
|
||||
assert_eq!(pq.push_increase(42, 1337), None);
|
||||
|
||||
assert_eq!(pq.push_decrease(1, 4), Some(5));
|
||||
assert_eq!(pq.push_decrease(2, -10), Some(-8));
|
||||
assert_eq!(pq.push_decrease(3, 5), Some(7));
|
||||
assert_eq!(pq.push_decrease(4, 20), Some(21));
|
||||
assert_eq!(pq.push_decrease(u32::MAX, 100), Some(100));
|
||||
assert_eq!(pq.push_decrease(69, 420), None);
|
||||
|
||||
assert_eq!(pq.peek(), Some((&42, &1337)));
|
||||
assert_eq!(pq.pop(), Some((42, 1337)));
|
||||
assert_eq!(pq.peek(), Some((&69, &420)));
|
||||
assert_eq!(pq.pop(), Some((69, 420)));
|
||||
assert_eq!(pq.peek(), Some((&4, &20)));
|
||||
assert_eq!(pq.pop(), Some((4, 20)));
|
||||
assert_eq!(pq.peek(), Some((&3, &5)));
|
||||
assert_eq!(pq.pop(), Some((3, 5)));
|
||||
assert_eq!(pq.peek(), Some((&1, &4)));
|
||||
assert_eq!(pq.pop(), Some((1, 4)));
|
||||
assert_eq!(pq.peek(), Some((&2, &-10)));
|
||||
assert_eq!(pq.pop(), Some((2, -10)));
|
||||
assert_eq!(pq.peek(), Some((&u32::MAX, &-42)));
|
||||
assert_eq!(pq.pop(), Some((u32::MAX, -42)));
|
||||
assert_eq!(pq.peek(), None);
|
||||
assert_eq!(pq.pop(), None);
|
||||
}
|
||||
}
|
||||
@@ -4,37 +4,48 @@
|
||||
"NETWORK": "__MEMPOOL_NETWORK__",
|
||||
"BACKEND": "__MEMPOOL_BACKEND__",
|
||||
"BLOCKS_SUMMARIES_INDEXING": true,
|
||||
"GOGGLES_INDEXING": false,
|
||||
"HTTP_PORT": 1,
|
||||
"SPAWN_CLUSTER_PROCS": 2,
|
||||
"API_URL_PREFIX": "__MEMPOOL_API_URL_PREFIX__",
|
||||
"AUTOMATIC_BLOCK_REINDEXING": false,
|
||||
"POLL_RATE_MS": 3,
|
||||
"CACHE_DIR": "__MEMPOOL_CACHE_DIR__",
|
||||
"CACHE_ENABLED": true,
|
||||
"CLEAR_PROTECTION_MINUTES": 4,
|
||||
"RECOMMENDED_FEE_PERCENTILE": 5,
|
||||
"BLOCK_WEIGHT_UNITS": 6,
|
||||
"INITIAL_BLOCKS_AMOUNT": 7,
|
||||
"MEMPOOL_BLOCKS_AMOUNT": 8,
|
||||
"USE_SECOND_NODE_FOR_MINFEE": 10,
|
||||
"EXTERNAL_ASSETS": 11,
|
||||
"EXTERNAL_ASSETS": [],
|
||||
"EXTERNAL_MAX_RETRY": 12,
|
||||
"EXTERNAL_RETRY_INTERVAL": 13,
|
||||
"USER_AGENT": "__MEMPOOL_USER_AGENT__",
|
||||
"STDOUT_LOG_MIN_PRIORITY": "__MEMPOOL_STDOUT_LOG_MIN_PRIORITY__",
|
||||
"INDEXING_BLOCKS_AMOUNT": 14,
|
||||
"POOLS_JSON_TREE_URL": "__POOLS_JSON_TREE_URL__",
|
||||
"POOLS_JSON_URL": "__POOLS_JSON_URL__",
|
||||
"AUDIT": "__MEMPOOL_AUDIT__",
|
||||
"ADVANCED_GBT_AUDIT": "__MEMPOOL_ADVANCED_GBT_AUDIT__",
|
||||
"ADVANCED_GBT_MEMPOOL": "__MEMPOOL_ADVANCED_GBT_MEMPOOL__",
|
||||
"CPFP_INDEXING": "__MEMPOOL_CPFP_INDEXING__",
|
||||
"MAX_BLOCKS_BULK_QUERY": "__MEMPOOL_MAX_BLOCKS_BULK_QUERY__"
|
||||
"POOLS_JSON_TREE_URL": "__MEMPOOL_POOLS_JSON_TREE_URL__",
|
||||
"POOLS_JSON_URL": "__MEMPOOL_POOLS_JSON_URL__",
|
||||
"AUDIT": true,
|
||||
"ADVANCED_GBT_AUDIT": true,
|
||||
"ADVANCED_GBT_MEMPOOL": true,
|
||||
"RUST_GBT": false,
|
||||
"CPFP_INDEXING": true,
|
||||
"MAX_BLOCKS_BULK_QUERY": 999,
|
||||
"DISK_CACHE_BLOCK_INTERVAL": 999,
|
||||
"MAX_PUSH_TX_SIZE_WEIGHT": 4000000,
|
||||
"ALLOW_UNREACHABLE": true,
|
||||
"PRICE_UPDATES_PER_HOUR": 1,
|
||||
"MAX_TRACKED_ADDRESSES": 1
|
||||
},
|
||||
"CORE_RPC": {
|
||||
"HOST": "__CORE_RPC_HOST__",
|
||||
"PORT": 15,
|
||||
"USERNAME": "__CORE_RPC_USERNAME__",
|
||||
"PASSWORD": "__CORE_RPC_PASSWORD__"
|
||||
"PASSWORD": "__CORE_RPC_PASSWORD__",
|
||||
"TIMEOUT": 1000,
|
||||
"COOKIE": false,
|
||||
"COOKIE_PATH": "__CORE_RPC_COOKIE_PATH__"
|
||||
},
|
||||
"ELECTRUM": {
|
||||
"HOST": "__ELECTRUM_HOST__",
|
||||
@@ -42,13 +53,22 @@
|
||||
"TLS_ENABLED": true
|
||||
},
|
||||
"ESPLORA": {
|
||||
"REST_API_URL": "__ESPLORA_REST_API_URL__"
|
||||
"REST_API_URL": "__ESPLORA_REST_API_URL__",
|
||||
"UNIX_SOCKET_PATH": "__ESPLORA_UNIX_SOCKET_PATH__",
|
||||
"BATCH_QUERY_BASE_SIZE": 1000,
|
||||
"RETRY_UNIX_SOCKET_AFTER": 888,
|
||||
"REQUEST_TIMEOUT": 10000,
|
||||
"FALLBACK_TIMEOUT": 5000,
|
||||
"FALLBACK": []
|
||||
},
|
||||
"SECOND_CORE_RPC": {
|
||||
"HOST": "__SECOND_CORE_RPC_HOST__",
|
||||
"PORT": 17,
|
||||
"USERNAME": "__SECOND_CORE_RPC_USERNAME__",
|
||||
"PASSWORD": "__SECOND_CORE_RPC_PASSWORD__"
|
||||
"PASSWORD": "__SECOND_CORE_RPC_PASSWORD__",
|
||||
"TIMEOUT": 2000,
|
||||
"COOKIE": false,
|
||||
"COOKIE_PATH": "__SECOND_CORE_RPC_COOKIE_PATH__"
|
||||
},
|
||||
"DATABASE": {
|
||||
"ENABLED": false,
|
||||
@@ -57,7 +77,9 @@
|
||||
"PORT": 18,
|
||||
"DATABASE": "__DATABASE_DATABASE__",
|
||||
"USERNAME": "__DATABASE_USERNAME__",
|
||||
"PASSWORD": "__DATABASE_PASSWORD__"
|
||||
"PASSWORD": "__DATABASE_PASSWORD__",
|
||||
"PID_DIR": "__DATABASE_PID_FILE__",
|
||||
"TIMEOUT": 3000
|
||||
},
|
||||
"SYSLOG": {
|
||||
"ENABLED": false,
|
||||
@@ -82,10 +104,6 @@
|
||||
"USERNAME": "__SOCKS5PROXY_USERNAME__",
|
||||
"PASSWORD": "__SOCKS5PROXY_PASSWORD__"
|
||||
},
|
||||
"PRICE_DATA_SERVER": {
|
||||
"TOR_URL": "__PRICE_DATA_SERVER_TOR_URL__",
|
||||
"CLEARNET_URL": "__PRICE_DATA_SERVER_CLEARNET_URL__"
|
||||
},
|
||||
"EXTERNAL_DATA_SERVER": {
|
||||
"MEMPOOL_API": "__EXTERNAL_DATA_SERVER_MEMPOOL_API__",
|
||||
"MEMPOOL_ONION": "__EXTERNAL_DATA_SERVER_MEMPOOL_ONION__",
|
||||
@@ -95,21 +113,37 @@
|
||||
"BISQ_ONION": "__EXTERNAL_DATA_SERVER_BISQ_ONION__"
|
||||
},
|
||||
"LIGHTNING": {
|
||||
"ENABLED": "__LIGHTNING_ENABLED__",
|
||||
"ENABLED": true,
|
||||
"BACKEND": "__LIGHTNING_BACKEND__",
|
||||
"TOPOLOGY_FOLDER": "__LIGHTNING_TOPOLOGY_FOLDER__",
|
||||
"STATS_REFRESH_INTERVAL": 600,
|
||||
"GRAPH_REFRESH_INTERVAL": 600,
|
||||
"LOGGER_UPDATE_INTERVAL": 30,
|
||||
"FORENSICS_INTERVAL": 43200,
|
||||
"FORENSICS_RATE_LIMIT": "__FORENSICS_RATE_LIMIT__"
|
||||
"FORENSICS_RATE_LIMIT": 1234
|
||||
},
|
||||
"LND": {
|
||||
"TLS_CERT_PATH": "",
|
||||
"MACAROON_PATH": "",
|
||||
"REST_API_URL": "https://localhost:8080"
|
||||
"REST_API_URL": "https://localhost:8080",
|
||||
"TIMEOUT": 10000
|
||||
},
|
||||
"CLIGHTNING": {
|
||||
"SOCKET": "__CLIGHTNING_SOCKET__"
|
||||
},
|
||||
"REPLICATION": {
|
||||
"ENABLED": false,
|
||||
"AUDIT": false,
|
||||
"AUDIT_START_HEIGHT": 774000,
|
||||
"SERVERS": []
|
||||
},
|
||||
"MEMPOOL_SERVICES": {
|
||||
"API": "",
|
||||
"ACCELERATIONS": false
|
||||
},
|
||||
"REDIS": {
|
||||
"ENABLED": false,
|
||||
"UNIX_SOCKET_PATH": "/tmp/redis.sock",
|
||||
"BATCH_QUERY_BASE_SIZE": 5000
|
||||
}
|
||||
}
|
||||
|
||||
24
backend/src/__tests__/api/common.ts
Normal file
24
backend/src/__tests__/api/common.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Common } from '../../api/common';
|
||||
import { MempoolTransactionExtended } from '../../mempool.interfaces';
|
||||
|
||||
const randomTransactions = require('./test-data/transactions-random.json');
|
||||
const replacedTransactions = require('./test-data/transactions-replaced.json');
|
||||
const rbfTransactions = require('./test-data/transactions-rbfs.json');
|
||||
|
||||
describe('Mempool Utils', () => {
|
||||
test('should detect RBF transactions with fast method', () => {
|
||||
const newTransactions = rbfTransactions.concat(randomTransactions);
|
||||
const result: { [txid: string]: MempoolTransactionExtended[] } = Common.findRbfTransactions(newTransactions, replacedTransactions);
|
||||
expect(Object.values(result).length).toEqual(2);
|
||||
expect(result).toHaveProperty('7219d95161f3718335991ac6d967d24eedec370908c9879bb1e192e6d797d0a6');
|
||||
expect(result).toHaveProperty('5387881d695d4564d397026dc5f740f816f8390b4b2c5ec8c20309122712a875');
|
||||
});
|
||||
|
||||
test.only('should detect RBF transactions with scalable method', () => {
|
||||
const newTransactions = rbfTransactions.concat(randomTransactions);
|
||||
const result: { [txid: string]: MempoolTransactionExtended[] } = Common.findRbfTransactions(newTransactions, replacedTransactions, true);
|
||||
expect(Object.values(result).length).toEqual(2);
|
||||
expect(result).toHaveProperty('7219d95161f3718335991ac6d967d24eedec370908c9879bb1e192e6d797d0a6');
|
||||
expect(result).toHaveProperty('5387881d695d4564d397026dc5f740f816f8390b4b2c5ec8c20309122712a875');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import { calcDifficultyAdjustment, DifficultyAdjustment } from '../../api/difficulty-adjustment';
|
||||
import {
|
||||
calcBitsDifference,
|
||||
calcDifficultyAdjustment,
|
||||
DifficultyAdjustment,
|
||||
} from '../../api/difficulty-adjustment';
|
||||
|
||||
describe('Mempool Difficulty Adjustment', () => {
|
||||
test('should calculate Difficulty Adjustments properly', () => {
|
||||
@@ -7,32 +11,60 @@ describe('Mempool Difficulty Adjustment', () => {
|
||||
};
|
||||
|
||||
const vectors = [
|
||||
[ // Vector 1
|
||||
[ // Vector 1 (normal adjustment)
|
||||
[ // Inputs
|
||||
dt('2024-02-02T15:42:06.000Z'), // Last DA time (in seconds)
|
||||
dt('2024-02-08T14:43:05.000Z'), // timestamp of 504 blocks ago (in seconds)
|
||||
dt('2024-02-11T22:43:01.000Z'), // Current time (now) (in seconds)
|
||||
830027, // Current block height
|
||||
7.333505241141637, // Previous retarget % (Passed through)
|
||||
'mainnet', // Network (if testnet, next value is non-zero)
|
||||
0, // Latest block timestamp in seconds (only used if difficulty already locked in)
|
||||
],
|
||||
{ // Expected Result
|
||||
progressPercent: 71.97420634920636,
|
||||
difficultyChange: 8.512745140778843,
|
||||
estimatedRetargetDate: 1708004001715,
|
||||
remainingBlocks: 565,
|
||||
remainingTime: 312620715,
|
||||
previousRetarget: 7.333505241141637,
|
||||
previousTime: 1706888526,
|
||||
nextRetargetHeight: 830592,
|
||||
timeAvg: 553311,
|
||||
adjustedTimeAvg: 553311,
|
||||
timeOffset: 0,
|
||||
expectedBlocks: 1338.0916666666667,
|
||||
},
|
||||
],
|
||||
[ // Vector 2 (within quarter-epoch overlap)
|
||||
[ // Inputs
|
||||
dt('2022-08-18T11:07:00.000Z'), // Last DA time (in seconds)
|
||||
dt('2022-08-16T03:16:54.000Z'), // timestamp of 504 blocks ago (in seconds)
|
||||
dt('2022-08-19T14:03:53.000Z'), // Current time (now) (in seconds)
|
||||
750134, // Current block height
|
||||
0.6280047707459726, // Previous retarget % (Passed through)
|
||||
'mainnet', // Network (if testnet, next value is non-zero)
|
||||
0, // If not testnet, not used
|
||||
0, // Latest block timestamp in seconds (only used if difficulty already locked in)
|
||||
],
|
||||
{ // Expected Result
|
||||
progressPercent: 9.027777777777777,
|
||||
difficultyChange: 12.562233927411782,
|
||||
estimatedRetargetDate: 1661895424692,
|
||||
difficultyChange: 1.0420538959004633,
|
||||
estimatedRetargetDate: 1662009048328,
|
||||
remainingBlocks: 1834,
|
||||
remainingTime: 977591692,
|
||||
remainingTime: 1091215328,
|
||||
previousRetarget: 0.6280047707459726,
|
||||
previousTime: 1660820820,
|
||||
nextRetargetHeight: 751968,
|
||||
timeAvg: 533038,
|
||||
adjustedTimeAvg: 594992,
|
||||
timeOffset: 0,
|
||||
expectedBlocks: 161.68833333333333,
|
||||
},
|
||||
],
|
||||
[ // Vector 2 (testnet)
|
||||
[ // Vector 3 (testnet)
|
||||
[ // Inputs
|
||||
dt('2022-08-18T11:07:00.000Z'), // Last DA time (in seconds)
|
||||
dt('2022-08-16T03:16:54.000Z'), // timestamp of 504 blocks ago (in seconds)
|
||||
dt('2022-08-19T14:03:53.000Z'), // Current time (now) (in seconds)
|
||||
750134, // Current block height
|
||||
0.6280047707459726, // Previous retarget % (Passed through)
|
||||
@@ -41,26 +73,94 @@ describe('Mempool Difficulty Adjustment', () => {
|
||||
],
|
||||
{ // Expected Result is same other than timeOffset
|
||||
progressPercent: 9.027777777777777,
|
||||
difficultyChange: 12.562233927411782,
|
||||
estimatedRetargetDate: 1661895424692,
|
||||
difficultyChange: 1.0420538959004633,
|
||||
estimatedRetargetDate: 1662009048328,
|
||||
remainingBlocks: 1834,
|
||||
remainingTime: 977591692,
|
||||
remainingTime: 1091215328,
|
||||
previousTime: 1660820820,
|
||||
previousRetarget: 0.6280047707459726,
|
||||
nextRetargetHeight: 751968,
|
||||
timeAvg: 533038,
|
||||
adjustedTimeAvg: 594992,
|
||||
timeOffset: -667000, // 11 min 7 seconds since last block (testnet only)
|
||||
// If we add time avg to abs(timeOffset) it makes exactly 1200000 ms, or 20 minutes
|
||||
expectedBlocks: 161.68833333333333,
|
||||
},
|
||||
],
|
||||
] as [[number, number, number, number, string, number], DifficultyAdjustment][];
|
||||
[ // Vector 4 (mainnet lock-in (epoch ending 788255))
|
||||
[ // Inputs
|
||||
dt('2023-04-20T09:57:33.000Z'), // Last DA time (in seconds)
|
||||
dt('2022-08-16T03:16:54.000Z'), // timestamp of 504 blocks ago (in seconds)
|
||||
dt('2023-05-04T14:54:09.000Z'), // Current time (now) (in seconds)
|
||||
788255, // Current block height
|
||||
1.7220298879531821, // Previous retarget % (Passed through)
|
||||
'mainnet', // Network (if testnet, next value is non-zero)
|
||||
dt('2023-05-04T14:54:26.000Z'), // Latest block timestamp in seconds
|
||||
],
|
||||
{ // Expected Result
|
||||
progressPercent: 99.95039682539682,
|
||||
difficultyChange: -1.4512637555574193,
|
||||
estimatedRetargetDate: 1683212658129,
|
||||
remainingBlocks: 1,
|
||||
remainingTime: 609129,
|
||||
previousRetarget: 1.7220298879531821,
|
||||
previousTime: 1681984653,
|
||||
nextRetargetHeight: 788256,
|
||||
timeAvg: 609129,
|
||||
adjustedTimeAvg: 609129,
|
||||
timeOffset: 0,
|
||||
expectedBlocks: 2045.66,
|
||||
},
|
||||
],
|
||||
] as [[number, number, number, number, number, string, number], DifficultyAdjustment][];
|
||||
|
||||
for (const vector of vectors) {
|
||||
const result = calcDifficultyAdjustment(...vector[0]);
|
||||
// previousRetarget is passed through untouched
|
||||
expect(result.previousRetarget).toStrictEqual(vector[0][3]);
|
||||
expect(result.previousRetarget).toStrictEqual(vector[0][4]);
|
||||
expect(result).toStrictEqual(vector[1]);
|
||||
}
|
||||
});
|
||||
|
||||
test('should calculate Difficulty change from bits fields of two blocks', () => {
|
||||
// Check same exponent + check min max for output
|
||||
expect(calcBitsDifference(0x1d000200, 0x1d000100)).toEqual(100);
|
||||
expect(calcBitsDifference(0x1d000400, 0x1d000100)).toEqual(300);
|
||||
expect(calcBitsDifference(0x1d000800, 0x1d000100)).toEqual(300); // Actually 700
|
||||
expect(calcBitsDifference(0x1d000100, 0x1d000200)).toEqual(-50);
|
||||
expect(calcBitsDifference(0x1d000100, 0x1d000400)).toEqual(-75);
|
||||
expect(calcBitsDifference(0x1d000100, 0x1d000800)).toEqual(-75); // Actually -87.5
|
||||
// Check new higher exponent
|
||||
expect(calcBitsDifference(0x1c000200, 0x1d000001)).toEqual(100);
|
||||
expect(calcBitsDifference(0x1c000400, 0x1d000001)).toEqual(300);
|
||||
expect(calcBitsDifference(0x1c000800, 0x1d000001)).toEqual(300);
|
||||
expect(calcBitsDifference(0x1c000100, 0x1d000002)).toEqual(-50);
|
||||
expect(calcBitsDifference(0x1c000100, 0x1d000004)).toEqual(-75);
|
||||
expect(calcBitsDifference(0x1c000100, 0x1d000008)).toEqual(-75);
|
||||
// Check new lower exponent
|
||||
expect(calcBitsDifference(0x1d000002, 0x1c000100)).toEqual(100);
|
||||
expect(calcBitsDifference(0x1d000004, 0x1c000100)).toEqual(300);
|
||||
expect(calcBitsDifference(0x1d000008, 0x1c000100)).toEqual(300);
|
||||
expect(calcBitsDifference(0x1d000001, 0x1c000200)).toEqual(-50);
|
||||
expect(calcBitsDifference(0x1d000001, 0x1c000400)).toEqual(-75);
|
||||
expect(calcBitsDifference(0x1d000001, 0x1c000800)).toEqual(-75);
|
||||
// Check error when exponents are too far apart
|
||||
expect(() => calcBitsDifference(0x1d000001, 0x1a000800)).toThrow(
|
||||
/Impossible exponent difference/
|
||||
);
|
||||
// Check invalid inputs
|
||||
expect(() => calcBitsDifference(0x7f000001, 0x1a000800)).toThrow(
|
||||
/Invalid bits/
|
||||
);
|
||||
expect(() => calcBitsDifference(0, 0x1a000800)).toThrow(/Invalid bits/);
|
||||
expect(() => calcBitsDifference(100.2783, 0x1a000800)).toThrow(
|
||||
/Invalid bits/
|
||||
);
|
||||
expect(() => calcBitsDifference(0x00800000, 0x1a000800)).toThrow(
|
||||
/Invalid bits/
|
||||
);
|
||||
expect(() => calcBitsDifference(0x1c000000, 0x1a000800)).toThrow(
|
||||
/Invalid bits/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
277
backend/src/__tests__/api/test-data/transactions-random.json
Normal file
277
backend/src/__tests__/api/test-data/transactions-random.json
Normal file
@@ -0,0 +1,277 @@
|
||||
[
|
||||
{
|
||||
"txid": "13f007241d78e8b0b4e57d2ae3fd37bcfe3226534d7cadeba5a549860d960db0",
|
||||
"version": 2,
|
||||
"locktime": 0,
|
||||
"vin": [
|
||||
{
|
||||
"txid": "cb8f206f4e88bec97107089f3e9e61d50cde53d4541992ae19759b71103cf75c",
|
||||
"vout": 0,
|
||||
"prevout": {
|
||||
"scriptpubkey": "0014fd6d15ff832c12f1ff04a5ccd5039f7227b260bd",
|
||||
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 fd6d15ff832c12f1ff04a5ccd5039f7227b260bd",
|
||||
"scriptpubkey_type": "v0_p2wpkh",
|
||||
"scriptpubkey_address": "bc1ql4k3tlur9sf0rlcy5hxd2qulwgnmyc9akehvth",
|
||||
"value": 610677
|
||||
},
|
||||
"scriptsig": "",
|
||||
"scriptsig_asm": "",
|
||||
"witness": [
|
||||
"304302205c430b36ebd2bb327951d83440af1f58f127871b2baada4c4dde2bc0b6721f56021f3445099f1a40e35baeda32e8e3727b505ffba0d882b11f498c7762f4184e9901",
|
||||
"0236b5edd4fbbcfb045960e42ec8a9968944084785932e32940e8cd2583b37da67"
|
||||
],
|
||||
"is_coinbase": false,
|
||||
"sequence": 2147483648
|
||||
}
|
||||
],
|
||||
"vout": [
|
||||
{
|
||||
"scriptpubkey": "76a9149d32ef812385f3811634e0c0117dd153a5de10a488ac",
|
||||
"scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 9d32ef812385f3811634e0c0117dd153a5de10a4 OP_EQUALVERIFY OP_CHECKSIG",
|
||||
"scriptpubkey_type": "p2pkh",
|
||||
"scriptpubkey_address": "1FLC7Bag7okAkKPCyZbgZZg3Hh1EuGZ5Rd",
|
||||
"value": 344697
|
||||
},
|
||||
{
|
||||
"scriptpubkey": "00147dee8a7a38abbfb00dbfba365c8d6712934cc491",
|
||||
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 7dee8a7a38abbfb00dbfba365c8d6712934cc491",
|
||||
"scriptpubkey_type": "v0_p2wpkh",
|
||||
"scriptpubkey_address": "bc1q0hhg573c4wlmqrdlhgm9ert8z2f5e3y3lf9hvx",
|
||||
"value": 265396
|
||||
}
|
||||
],
|
||||
"size": 224,
|
||||
"weight": 572,
|
||||
"fee": 584,
|
||||
"status": {
|
||||
"confirmed": false
|
||||
},
|
||||
"order": 2953680397,
|
||||
"vsize": 143,
|
||||
"adjustedVsize": 143,
|
||||
"sigops": 5,
|
||||
"feePerVsize": 4.083916083916084,
|
||||
"adjustedFeePerVsize": 4.083916083916084,
|
||||
"effectiveFeePerVsize": 4.083916083916084,
|
||||
"firstSeen": 1691222538,
|
||||
"uid": 526973,
|
||||
"inputs": [
|
||||
526728
|
||||
],
|
||||
"position": {
|
||||
"block": 7,
|
||||
"vsize": 21429708.5
|
||||
},
|
||||
"bestDescendant": null,
|
||||
"cpfpChecked": true
|
||||
},
|
||||
{
|
||||
"txid": "8e89b20f8a7fadb0e4cdbe57a00eee224f5076bac5387fc276916724e7c4a16a",
|
||||
"version": 2,
|
||||
"locktime": 800571,
|
||||
"vin": [
|
||||
{
|
||||
"txid": "35e16762459539f3a8e52c5dee6a9ccaa9e9268efed33aa2c6e1b7805e849f24",
|
||||
"vout": 0,
|
||||
"prevout": {
|
||||
"scriptpubkey": "0014d4f16ef275b3e1c4a4ecbef55a164933e0f6460f",
|
||||
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 d4f16ef275b3e1c4a4ecbef55a164933e0f6460f",
|
||||
"scriptpubkey_type": "v0_p2wpkh",
|
||||
"scriptpubkey_address": "bc1q6nckaun4k0suff8vhm6459jfx0s0v3s0ff4ukl",
|
||||
"value": 1528924
|
||||
},
|
||||
"scriptsig": "",
|
||||
"scriptsig_asm": "",
|
||||
"witness": [
|
||||
"3044022019008b26e885bb43da25a11ffac147a057722072eedb68411f114f6e7eb82ebc02201b618264bb97756b88fc3bbc365b73044ac18b33b1067e31cfd5bcd0f50ed2c701",
|
||||
"039b71145070bd3e8af28e27fa577f2e12ab6bb4e212d3eeaef08b4bc39e8cbc13"
|
||||
],
|
||||
"is_coinbase": false,
|
||||
"sequence": 4294967293
|
||||
},
|
||||
{
|
||||
"txid": "67c27ed0f767526234bcd5f795a31fab8ec4d0251bf12c68f2746951f4110d90",
|
||||
"vout": 3,
|
||||
"prevout": {
|
||||
"scriptpubkey": "0014a7c3d613b321375054b2ac9b6114367bc034ad6f",
|
||||
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 a7c3d613b321375054b2ac9b6114367bc034ad6f",
|
||||
"scriptpubkey_type": "v0_p2wpkh",
|
||||
"scriptpubkey_address": "bc1q5lpavyanyym4q49j4jdkz9pk00qrftt0yqzvk3",
|
||||
"value": 436523
|
||||
},
|
||||
"scriptsig": "",
|
||||
"scriptsig_asm": "",
|
||||
"witness": [
|
||||
"304402204e67285fc656bc45ed082499b076d5dba2fa21d0d7e64a0ae52b19d69a11760002200f037d81ee540b74397844513b72b08ed92b06db76bd20b08f7a0a3b36ab13d501",
|
||||
"02a3ebae85f0225b6fbb5ff060afce683a4683507a57544605a29ee7d287e591b4"
|
||||
],
|
||||
"is_coinbase": false,
|
||||
"sequence": 4294967293
|
||||
},
|
||||
{
|
||||
"txid": "21c38fb9a2521e438c614f53b19ddd7a5594bcc4b77480e762fd4b702fad3374",
|
||||
"vout": 1,
|
||||
"prevout": {
|
||||
"scriptpubkey": "00149660e34ef88106536c816c037b5b28dd64a812e2",
|
||||
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 9660e34ef88106536c816c037b5b28dd64a812e2",
|
||||
"scriptpubkey_type": "v0_p2wpkh",
|
||||
"scriptpubkey_address": "bc1qjeswxnhcsyr9xmypdsphkkegm4j2syhztgzxv4",
|
||||
"value": 758149
|
||||
},
|
||||
"scriptsig": "",
|
||||
"scriptsig_asm": "",
|
||||
"witness": [
|
||||
"3044022021b556f0aa99329076bcc435338aceaf534963efcab306931b1b2b0461e16e0c02203a78942a3745c4da656bddfd8cf16b85dc04d652904e88682127cdd9ca63339001",
|
||||
"0298963be4a8f66aca9fcf1c6dc95547aeaa82347543190c91e094c2321142b9f0"
|
||||
],
|
||||
"is_coinbase": false,
|
||||
"sequence": 4294967293
|
||||
},
|
||||
{
|
||||
"txid": "aa998dbae65240a7386bf7d468459551d99c3de8e2f9057ff5f2d38e17daf788",
|
||||
"vout": 0,
|
||||
"prevout": {
|
||||
"scriptpubkey": "00147bb7413a39943b21ded98ad5e6ad7a222d273e17",
|
||||
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 7bb7413a39943b21ded98ad5e6ad7a222d273e17",
|
||||
"scriptpubkey_type": "v0_p2wpkh",
|
||||
"scriptpubkey_address": "bc1q0wm5zw3ejsajrhke3t27dtt6ygkjw0sh9lltg6",
|
||||
"value": 1067200
|
||||
},
|
||||
"scriptsig": "",
|
||||
"scriptsig_asm": "",
|
||||
"witness": [
|
||||
"304402205e2269f7d4ee0513b34354c38e920aef2dabac6f4350afb2dd105ff3ee43ae7b02202870322f2cb85cb0b2b0e38152f018bfff271dc3ec5aed0515854d0b259aaf3d01",
|
||||
"03b87320cf3263a644a0d3f89c1b4a7304d9dfda9eb8c891560716abcb73e88b99"
|
||||
],
|
||||
"is_coinbase": false,
|
||||
"sequence": 4294967293
|
||||
},
|
||||
{
|
||||
"txid": "230253d195d779d4688ba16993985cd27b2e7a687d8b889b3bc63f19ece36f20",
|
||||
"vout": 0,
|
||||
"prevout": {
|
||||
"scriptpubkey": "001439647bd997819d12dfc72b0fb9ff9ffcb84946f8",
|
||||
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 39647bd997819d12dfc72b0fb9ff9ffcb84946f8",
|
||||
"scriptpubkey_type": "v0_p2wpkh",
|
||||
"scriptpubkey_address": "bc1q89j8hkvhsxw39h789v8mnlulljuyj3hc9zve97",
|
||||
"value": 361950
|
||||
},
|
||||
"scriptsig": "",
|
||||
"scriptsig_asm": "",
|
||||
"witness": [
|
||||
"304402204f7ca868bb9b92a07fecdc6b9dd56e4e1d007ca1405952f98ed6bc416345b5f2022055320a97791417abf6628fcf6513ac5785b06c630f854d8595e96ea06c3841d301",
|
||||
"03a3ffe8e3ef2eea129b227e9658164bae0a6d21c17da6de9973ba34d9e04b21a0"
|
||||
],
|
||||
"is_coinbase": false,
|
||||
"sequence": 4294967293
|
||||
},
|
||||
{
|
||||
"txid": "670771e265a0b62dbd3c1fec2b865177eaf0bafd0ae49dd40a1c9fcd9a847a81",
|
||||
"vout": 0,
|
||||
"prevout": {
|
||||
"scriptpubkey": "0014d45d1b0022c7387e42c5452ced561bdb8fd4b521",
|
||||
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 d45d1b0022c7387e42c5452ced561bdb8fd4b521",
|
||||
"scriptpubkey_type": "v0_p2wpkh",
|
||||
"scriptpubkey_address": "bc1q63w3kqpzcuu8usk9g5kw64smmw8afdfpxmc2m0",
|
||||
"value": 453275
|
||||
},
|
||||
"scriptsig": "",
|
||||
"scriptsig_asm": "",
|
||||
"witness": [
|
||||
"3044022071312921800441903b2099e723add8702dd0f92ec11526ff87acf6967ec64cbd02203deabe7ed56d5daaa9a95c5a607b1ab705ff1c46bc6984a6dca120e63a91768601",
|
||||
"0257302ac8d9c4c8f9b1744f19bb432359326b9cc7bdddeeab9202749a6d92be58"
|
||||
],
|
||||
"is_coinbase": false,
|
||||
"sequence": 4294967293
|
||||
},
|
||||
{
|
||||
"txid": "0af82159eee2b69242f2ff032636e410b67ec1ace52e55fb0d20ed814cd64803",
|
||||
"vout": 0,
|
||||
"prevout": {
|
||||
"scriptpubkey": "001459e4d6bfefc6b45f955a69c4aeca26348e9d54ed",
|
||||
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 59e4d6bfefc6b45f955a69c4aeca26348e9d54ed",
|
||||
"scriptpubkey_type": "v0_p2wpkh",
|
||||
"scriptpubkey_address": "bc1qt8jdd0l0c669l926d8z2aj3xxj8f648dtyn7tc",
|
||||
"value": 439961
|
||||
},
|
||||
"scriptsig": "",
|
||||
"scriptsig_asm": "",
|
||||
"witness": [
|
||||
"3044022027540322e92c23c5513aa2587e7feb56a8ce82f879269d6b3cbd425634b44f8e022045572dee7262b02130bfe32d8aa8abbfaa64e101abfc819bba5380c78876692d01",
|
||||
"03fe02262d87f4a5289d3dd66e3d9a74cd49fa1cad0249284a7451896a827249a5"
|
||||
],
|
||||
"is_coinbase": false,
|
||||
"sequence": 4294967293
|
||||
},
|
||||
{
|
||||
"txid": "68cf9c784870a4f888f044755f7ce318557f652461db8ef887d279672f186018",
|
||||
"vout": 0,
|
||||
"prevout": {
|
||||
"scriptpubkey": "001454822b2d5d52597a78b630921cf439a41e32f2f9",
|
||||
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 54822b2d5d52597a78b630921cf439a41e32f2f9",
|
||||
"scriptpubkey_type": "v0_p2wpkh",
|
||||
"scriptpubkey_address": "bc1q2jpzkt2a2fvh579kxzfpeape5s0r9uhewhl5n4",
|
||||
"value": 227639
|
||||
},
|
||||
"scriptsig": "",
|
||||
"scriptsig_asm": "",
|
||||
"witness": [
|
||||
"304402203ad511d6a8730748b8828bc38897d360451adf620ebdc1d229c08c097c80bef202202f50c793d95b5200cf2258e03896a3be7720df0eb3b8c810c86db74341a7e83e01",
|
||||
"0294992e9f4546e6e119741f908411ae531e9d1ff732d69b4dff8172aaf2a4b216"
|
||||
],
|
||||
"is_coinbase": false,
|
||||
"sequence": 4294967293
|
||||
},
|
||||
{
|
||||
"txid": "793f01dfdb19bf41f958fd917c16d9c4dd5d5e1a5c0434bfdb367212659d1b5b",
|
||||
"vout": 0,
|
||||
"prevout": {
|
||||
"scriptpubkey": "0014f54edf8ae647b5300e2674523254e923d93d169f",
|
||||
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 f54edf8ae647b5300e2674523254e923d93d169f",
|
||||
"scriptpubkey_type": "v0_p2wpkh",
|
||||
"scriptpubkey_address": "bc1q748dlzhxg76nqr3xw3fry48fy0vn695lvhlkxv",
|
||||
"value": 227070
|
||||
},
|
||||
"scriptsig": "",
|
||||
"scriptsig_asm": "",
|
||||
"witness": [
|
||||
"304402206e807ab616f4f2887ba703ae744d856142d9aca8128698419bbb67fb4fad8177022060fc65c7cd66baa88ad1e1d317a6edd5f6cb52fe8bff6e5405ffa1acf9d945d901",
|
||||
"02a0ad0167c6e9edf62677404d74d3b80ea276e47e758ffaa6ca17bd65ac79f7aa"
|
||||
],
|
||||
"is_coinbase": false,
|
||||
"sequence": 4294967293
|
||||
}
|
||||
],
|
||||
"vout": [
|
||||
{
|
||||
"scriptpubkey": "00148a5c45ccfc29d209940d94525e2edb7743a1ad8a",
|
||||
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 8a5c45ccfc29d209940d94525e2edb7743a1ad8a",
|
||||
"scriptpubkey_type": "v0_p2wpkh",
|
||||
"scriptpubkey_address": "bc1q3fwytn8u98fqn9qdj3f9utkmwap6rtv2ym33zm",
|
||||
"value": 5500000
|
||||
}
|
||||
],
|
||||
"size": 1375,
|
||||
"weight": 2605,
|
||||
"fee": 691,
|
||||
"status": {
|
||||
"confirmed": false
|
||||
},
|
||||
"order": 1788986599,
|
||||
"vsize": 651,
|
||||
"adjustedVsize": 651.25,
|
||||
"sigops": 9,
|
||||
"feePerVsize": 1.0610364683301343,
|
||||
"adjustedFeePerVsize": 1.0610364683301343,
|
||||
"effectiveFeePerVsize": 1.0610364683301343,
|
||||
"firstSeen": 1691163298,
|
||||
"uid": 120494,
|
||||
"inputs": [],
|
||||
"position": {
|
||||
"block": 7,
|
||||
"vsize": 93780091.5
|
||||
},
|
||||
"bestDescendant": null,
|
||||
"cpfpChecked": true
|
||||
}
|
||||
]
|
||||
121
backend/src/__tests__/api/test-data/transactions-rbfs.json
Normal file
121
backend/src/__tests__/api/test-data/transactions-rbfs.json
Normal file
@@ -0,0 +1,121 @@
|
||||
[
|
||||
{
|
||||
"txid": "7219d95161f3718335991ac6d967d24eedec370908c9879bb1e192e6d797d0a6",
|
||||
"version": 1,
|
||||
"locktime": 0,
|
||||
"vin": [
|
||||
{
|
||||
"txid": "d863deb706de5a611028f7547e16ea81d7819e44beb640fb30a9ba30c585140f",
|
||||
"vout": 0,
|
||||
"prevout": {
|
||||
"scriptpubkey": "76a914cd5b6566b455d043558829f6932edaae5d8f0ad388ac",
|
||||
"scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 cd5b6566b455d043558829f6932edaae5d8f0ad3 OP_EQUALVERIFY OP_CHECKSIG",
|
||||
"scriptpubkey_type": "p2pkh",
|
||||
"scriptpubkey_address": "1Kiq1dyVBzYLWGrBPWjChvKyzB2H95x5RJ",
|
||||
"value": 799995000
|
||||
},
|
||||
"scriptsig": "483045022100aeeddfb9785c5a4b70e90d0445785c68b7a44e28853441134a70ddc4da39527602203dfe1ec1a377aaacb64ae65c7c944caf1398d2dc063f712251b4cf696d44d3cb01210314338e3e191aea3ac9e9292611faeedf0379bbe62c30fd76c7450722a1ac47c6",
|
||||
"scriptsig_asm": "OP_PUSHBYTES_72 3045022100aeeddfb9785c5a4b70e90d0445785c68b7a44e28853441134a70ddc4da39527602203dfe1ec1a377aaacb64ae65c7c944caf1398d2dc063f712251b4cf696d44d3cb01 OP_PUSHBYTES_33 0314338e3e191aea3ac9e9292611faeedf0379bbe62c30fd76c7450722a1ac47c6",
|
||||
"is_coinbase": false,
|
||||
"sequence": 4294967293
|
||||
}
|
||||
],
|
||||
"vout": [
|
||||
{
|
||||
"scriptpubkey": "6a4c5058325b8669baa9259e082f064005bc92274b559337ac317798f5d76f2d0577ed5a96042fce8c33d841b6c47a99f9597000ab04a10b34cd419fc19784d9e36f1a33fd7b000c3bce00b6000c1d1e00614b",
|
||||
"scriptpubkey_asm": "OP_RETURN OP_PUSHDATA1 58325b8669baa9259e082f064005bc92274b559337ac317798f5d76f2d0577ed5a96042fce8c33d841b6c47a99f9597000ab04a10b34cd419fc19784d9e36f1a33fd7b000c3bce00b6000c1d1e00614b",
|
||||
"scriptpubkey_type": "op_return",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"scriptpubkey": "a9144890aae025c84cb72a9730b49ca12595d6f6088d87",
|
||||
"scriptpubkey_asm": "OP_HASH160 OP_PUSHBYTES_20 4890aae025c84cb72a9730b49ca12595d6f6088d OP_EQUAL",
|
||||
"scriptpubkey_type": "p2sh",
|
||||
"scriptpubkey_address": "38Jht2bzmJL4EwoFvvyFzejhfEb4J7KxLb",
|
||||
"value": 155000
|
||||
},
|
||||
{
|
||||
"scriptpubkey": "76a91486e7dad6617303942a448b7f8afe9653e5624a5e88ac",
|
||||
"scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 86e7dad6617303942a448b7f8afe9653e5624a5e OP_EQUALVERIFY OP_CHECKSIG",
|
||||
"scriptpubkey_type": "p2pkh",
|
||||
"scriptpubkey_address": "1DJKJGApgX4W8BSQ8FRPLqX78UaCskT4r2",
|
||||
"value": 155000
|
||||
},
|
||||
{
|
||||
"scriptpubkey": "76a914cd5b6566b455d043558829f6932edaae5d8f0ad388ac",
|
||||
"scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 cd5b6566b455d043558829f6932edaae5d8f0ad3 OP_EQUALVERIFY OP_CHECKSIG",
|
||||
"scriptpubkey_type": "p2pkh",
|
||||
"scriptpubkey_address": "1Kiq1dyVBzYLWGrBPWjChvKyzB2H95x5RJ",
|
||||
"value": 799675549
|
||||
}
|
||||
],
|
||||
"size": 350,
|
||||
"weight": 1400,
|
||||
"fee": 9451,
|
||||
"status": {
|
||||
"confirmed": false
|
||||
},
|
||||
"order": 2798688215,
|
||||
"vsize": 350,
|
||||
"adjustedVsize": 350,
|
||||
"sigops": 8,
|
||||
"feePerVsize": 27.002857142857142,
|
||||
"adjustedFeePerVsize": 27.002857142857142,
|
||||
"effectiveFeePerVsize": 27.002857142857142,
|
||||
"firstSeen": 1691218536,
|
||||
"uid": 513598,
|
||||
"inputs": [],
|
||||
"position": {
|
||||
"block": 0,
|
||||
"vsize": 22166
|
||||
},
|
||||
"bestDescendant": null,
|
||||
"cpfpChecked": true
|
||||
},
|
||||
{
|
||||
"txid": "5387881d695d4564d397026dc5f740f816f8390b4b2c5ec8c20309122712a875",
|
||||
"version": 2,
|
||||
"locktime": 0,
|
||||
"vin": [
|
||||
{
|
||||
"txid": "b50225a04a1d6fbbfa7a2122bc0580396f614027b3957f476229633576f06130",
|
||||
"vout": 0,
|
||||
"prevout": {
|
||||
"scriptpubkey": "0014a24f913f8a9c30a4c302c2c78f2fd7addb08fd07",
|
||||
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 a24f913f8a9c30a4c302c2c78f2fd7addb08fd07",
|
||||
"scriptpubkey_type": "v0_p2wpkh",
|
||||
"scriptpubkey_address": "bc1q5f8ez0u2nsc2fsczctrc7t7h4hds3lg82ewqhz",
|
||||
"value": 612917
|
||||
},
|
||||
"scriptsig": "",
|
||||
"scriptsig_asm": "",
|
||||
"witness": [
|
||||
"3045022100a0c23953ace5d022b7a6d45d1ae1730bf20a4d594bb5d4fa7aa80e4881b44d320220008f9b144805bb91995fc0f452a56e09f4ad16fa149d71ae9b5d57c742e8e2cc01",
|
||||
"03dc2c7b687019b40a68d713322675206cc266e34e5340ec982c13ff0222c3b2b6"
|
||||
],
|
||||
"is_coinbase": false,
|
||||
"sequence": 2147483649
|
||||
}
|
||||
],
|
||||
"vout": [
|
||||
{
|
||||
"scriptpubkey": "0014199a98f9589364ffe5ef5bbae45ce5dfcbb873bd",
|
||||
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 199a98f9589364ffe5ef5bbae45ce5dfcbb873bd",
|
||||
"scriptpubkey_type": "v0_p2wpkh",
|
||||
"scriptpubkey_address": "bc1qrxdf372cjdj0le00twawgh89ml9msuaau62gk4",
|
||||
"value": 611909
|
||||
}
|
||||
],
|
||||
"size": 192,
|
||||
"weight": 438,
|
||||
"fee": 1008,
|
||||
"status": {
|
||||
"confirmed": false
|
||||
},
|
||||
"bestDescendant": null,
|
||||
"descendants": null,
|
||||
"adjustedFeePerVsize": 10.2283,
|
||||
"sigops": 1,
|
||||
"adjustedVsize": 109.5
|
||||
}
|
||||
]
|
||||
139
backend/src/__tests__/api/test-data/transactions-replaced.json
Normal file
139
backend/src/__tests__/api/test-data/transactions-replaced.json
Normal file
@@ -0,0 +1,139 @@
|
||||
[
|
||||
{
|
||||
"txid": "008592364e21c1e3d62ba9538ac78a81779897b52100af5707ab063df98964f2",
|
||||
"version": 1,
|
||||
"locktime": 0,
|
||||
"vin": [
|
||||
{
|
||||
"txid": "d863deb706de5a611028f7547e16ea81d7819e44beb640fb30a9ba30c585140f",
|
||||
"vout": 0,
|
||||
"prevout": {
|
||||
"scriptpubkey": "76a914cd5b6566b455d043558829f6932edaae5d8f0ad388ac",
|
||||
"scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 cd5b6566b455d043558829f6932edaae5d8f0ad3 OP_EQUALVERIFY OP_CHECKSIG",
|
||||
"scriptpubkey_type": "p2pkh",
|
||||
"scriptpubkey_address": "1Kiq1dyVBzYLWGrBPWjChvKyzB2H95x5RJ",
|
||||
"value": 799995000
|
||||
},
|
||||
"scriptsig": "483045022100c1fb331d155a7d299a0451d14fa1122b328e0e239afc9ba8dc2aff449ddc5a3a02201c1e19030d1efa432f5069cd369d7ad09a67f68501345e4db35f7b799605f55601210314338e3e191aea3ac9e9292611faeedf0379bbe62c30fd76c7450722a1ac47c6",
|
||||
"scriptsig_asm": "OP_PUSHBYTES_72 3045022100c1fb331d155a7d299a0451d14fa1122b328e0e239afc9ba8dc2aff449ddc5a3a02201c1e19030d1efa432f5069cd369d7ad09a67f68501345e4db35f7b799605f55601 OP_PUSHBYTES_33 0314338e3e191aea3ac9e9292611faeedf0379bbe62c30fd76c7450722a1ac47c6",
|
||||
"is_coinbase": false,
|
||||
"sequence": 4294967293
|
||||
}
|
||||
],
|
||||
"vout": [
|
||||
{
|
||||
"scriptpubkey": "6a4c5058325b78064160b631b5a15d9078d99c0db066449fb4c59bbfa4d987ba906e2990088b2fce8c33d841b6c47a99f9597000ab04a10b34cd419fc19784d9e36f1a33fd7b000c3bce00b6000c1d1e00614b",
|
||||
"scriptpubkey_asm": "OP_RETURN OP_PUSHDATA1 58325b78064160b631b5a15d9078d99c0db066449fb4c59bbfa4d987ba906e2990088b2fce8c33d841b6c47a99f9597000ab04a10b34cd419fc19784d9e36f1a33fd7b000c3bce00b6000c1d1e00614b",
|
||||
"scriptpubkey_type": "op_return",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"scriptpubkey": "a9144890aae025c84cb72a9730b49ca12595d6f6088d87",
|
||||
"scriptpubkey_asm": "OP_HASH160 OP_PUSHBYTES_20 4890aae025c84cb72a9730b49ca12595d6f6088d OP_EQUAL",
|
||||
"scriptpubkey_type": "p2sh",
|
||||
"scriptpubkey_address": "38Jht2bzmJL4EwoFvvyFzejhfEb4J7KxLb",
|
||||
"value": 155000
|
||||
},
|
||||
{
|
||||
"scriptpubkey": "76a91486e7dad6617303942a448b7f8afe9653e5624a5e88ac",
|
||||
"scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 86e7dad6617303942a448b7f8afe9653e5624a5e OP_EQUALVERIFY OP_CHECKSIG",
|
||||
"scriptpubkey_type": "p2pkh",
|
||||
"scriptpubkey_address": "1DJKJGApgX4W8BSQ8FRPLqX78UaCskT4r2",
|
||||
"value": 155000
|
||||
},
|
||||
{
|
||||
"scriptpubkey": "76a914cd5b6566b455d043558829f6932edaae5d8f0ad388ac",
|
||||
"scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 cd5b6566b455d043558829f6932edaae5d8f0ad3 OP_EQUALVERIFY OP_CHECKSIG",
|
||||
"scriptpubkey_type": "p2pkh",
|
||||
"scriptpubkey_address": "1Kiq1dyVBzYLWGrBPWjChvKyzB2H95x5RJ",
|
||||
"value": 799676250
|
||||
}
|
||||
],
|
||||
"size": 350,
|
||||
"weight": 1400,
|
||||
"fee": 8750,
|
||||
"status": {
|
||||
"confirmed": false
|
||||
},
|
||||
"order": 4066675193,
|
||||
"vsize": 350,
|
||||
"adjustedVsize": 350,
|
||||
"sigops": 8,
|
||||
"feePerVsize": 25,
|
||||
"adjustedFeePerVsize": 25,
|
||||
"effectiveFeePerVsize": 25,
|
||||
"firstSeen": 1691218516,
|
||||
"uid": 512584,
|
||||
"inputs": [],
|
||||
"position": {
|
||||
"block": 0,
|
||||
"vsize": 13846
|
||||
},
|
||||
"bestDescendant": null,
|
||||
"cpfpChecked": true
|
||||
},
|
||||
{
|
||||
"txid": "b7981a624e4261c11f1246314d41e74be56af82eb557bcd054a5e0f94c023668",
|
||||
"version": 2,
|
||||
"locktime": 0,
|
||||
"vin": [
|
||||
{
|
||||
"txid": "b50225a04a1d6fbbfa7a2122bc0580396f614027b3957f476229633576f06130",
|
||||
"vout": 0,
|
||||
"prevout": {
|
||||
"scriptpubkey": "0014a24f913f8a9c30a4c302c2c78f2fd7addb08fd07",
|
||||
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 a24f913f8a9c30a4c302c2c78f2fd7addb08fd07",
|
||||
"scriptpubkey_type": "v0_p2wpkh",
|
||||
"scriptpubkey_address": "bc1q5f8ez0u2nsc2fsczctrc7t7h4hds3lg82ewqhz",
|
||||
"value": 612917
|
||||
},
|
||||
"scriptsig": "",
|
||||
"scriptsig_asm": "",
|
||||
"witness": [
|
||||
"304402204dd10f14afa41bc76d8278140ff1ec3d3f87f2c207bbb5418cc76dab30d7f6a402207877cc9c6a2c724b6ea7a1c24ac00022469f194fd1a4bd8030bbca1787d3f5f301",
|
||||
"03dc2c7b687019b40a68d713322675206cc266e34e5340ec982c13ff0222c3b2b6"
|
||||
],
|
||||
"is_coinbase": false,
|
||||
"sequence": 2147483648
|
||||
}
|
||||
],
|
||||
"vout": [
|
||||
{
|
||||
"scriptpubkey": "76a9149d32ef812385f3811634e0c0117dd153a5de10a488ac",
|
||||
"scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 9d32ef812385f3811634e0c0117dd153a5de10a4 OP_EQUALVERIFY OP_CHECKSIG",
|
||||
"scriptpubkey_type": "p2pkh",
|
||||
"scriptpubkey_address": "1FLC7Bag7okAkKPCyZbgZZg3Hh1EuGZ5Rd",
|
||||
"value": 344697
|
||||
},
|
||||
{
|
||||
"scriptpubkey": "00144c2671336ca8761863b4c68d64d4672491fec1b9",
|
||||
"scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 4c2671336ca8761863b4c68d64d4672491fec1b9",
|
||||
"scriptpubkey_type": "v0_p2wpkh",
|
||||
"scriptpubkey_address": "bc1qfsn8zvmv4pmpsca5c6xkf4r8yjglasdesrawcx",
|
||||
"value": 267636
|
||||
}
|
||||
],
|
||||
"size": 225,
|
||||
"weight": 573,
|
||||
"fee": 584,
|
||||
"status": {
|
||||
"confirmed": false
|
||||
},
|
||||
"order": 1748369996,
|
||||
"vsize": 143,
|
||||
"adjustedVsize": 143.25,
|
||||
"sigops": 5,
|
||||
"feePerVsize": 4.076788830715532,
|
||||
"adjustedFeePerVsize": 4.076788830715532,
|
||||
"effectiveFeePerVsize": 4.076788830715532,
|
||||
"firstSeen": 1691222376,
|
||||
"uid": 526515,
|
||||
"inputs": [],
|
||||
"position": {
|
||||
"block": 7,
|
||||
"vsize": 22021095.5
|
||||
},
|
||||
"bestDescendant": null,
|
||||
"cpfpChecked": true
|
||||
}
|
||||
]
|
||||
@@ -17,12 +17,14 @@ describe('Mempool Backend Config', () => {
|
||||
NETWORK: 'mainnet',
|
||||
BACKEND: 'none',
|
||||
BLOCKS_SUMMARIES_INDEXING: false,
|
||||
GOGGLES_INDEXING: false,
|
||||
HTTP_PORT: 8999,
|
||||
SPAWN_CLUSTER_PROCS: 0,
|
||||
API_URL_PREFIX: '/api/v1/',
|
||||
AUTOMATIC_BLOCK_REINDEXING: false,
|
||||
POLL_RATE_MS: 2000,
|
||||
CACHE_DIR: './cache',
|
||||
CACHE_ENABLED: true,
|
||||
CLEAR_PROTECTION_MINUTES: 20,
|
||||
RECOMMENDED_FEE_PERCENTILE: 50,
|
||||
BLOCK_WEIGHT_UNITS: 4000000,
|
||||
@@ -40,26 +42,46 @@ describe('Mempool Backend Config', () => {
|
||||
AUDIT: false,
|
||||
ADVANCED_GBT_AUDIT: false,
|
||||
ADVANCED_GBT_MEMPOOL: false,
|
||||
RUST_GBT: false,
|
||||
CPFP_INDEXING: false,
|
||||
MAX_BLOCKS_BULK_QUERY: 0,
|
||||
DISK_CACHE_BLOCK_INTERVAL: 6,
|
||||
MAX_PUSH_TX_SIZE_WEIGHT: 400000,
|
||||
ALLOW_UNREACHABLE: true,
|
||||
PRICE_UPDATES_PER_HOUR: 1,
|
||||
MAX_TRACKED_ADDRESSES: 1,
|
||||
});
|
||||
|
||||
expect(config.ELECTRUM).toStrictEqual({ HOST: '127.0.0.1', PORT: 3306, TLS_ENABLED: true });
|
||||
|
||||
expect(config.ESPLORA).toStrictEqual({ REST_API_URL: 'http://127.0.0.1:3000' });
|
||||
expect(config.ESPLORA).toStrictEqual({
|
||||
REST_API_URL: 'http://127.0.0.1:3000',
|
||||
UNIX_SOCKET_PATH: null,
|
||||
BATCH_QUERY_BASE_SIZE: 1000,
|
||||
RETRY_UNIX_SOCKET_AFTER: 30000,
|
||||
REQUEST_TIMEOUT: 10000,
|
||||
FALLBACK_TIMEOUT: 5000,
|
||||
FALLBACK: [],
|
||||
});
|
||||
|
||||
expect(config.CORE_RPC).toStrictEqual({
|
||||
HOST: '127.0.0.1',
|
||||
PORT: 8332,
|
||||
USERNAME: 'mempool',
|
||||
PASSWORD: 'mempool'
|
||||
PASSWORD: 'mempool',
|
||||
TIMEOUT: 60000,
|
||||
COOKIE: false,
|
||||
COOKIE_PATH: '/bitcoin/.cookie'
|
||||
});
|
||||
|
||||
expect(config.SECOND_CORE_RPC).toStrictEqual({
|
||||
HOST: '127.0.0.1',
|
||||
PORT: 8332,
|
||||
USERNAME: 'mempool',
|
||||
PASSWORD: 'mempool'
|
||||
PASSWORD: 'mempool',
|
||||
TIMEOUT: 60000,
|
||||
COOKIE: false,
|
||||
COOKIE_PATH: '/bitcoin/.cookie'
|
||||
});
|
||||
|
||||
expect(config.DATABASE).toStrictEqual({
|
||||
@@ -69,7 +91,9 @@ describe('Mempool Backend Config', () => {
|
||||
PORT: 3306,
|
||||
DATABASE: 'mempool',
|
||||
USERNAME: 'mempool',
|
||||
PASSWORD: 'mempool'
|
||||
PASSWORD: 'mempool',
|
||||
TIMEOUT: 180000,
|
||||
PID_DIR: ''
|
||||
});
|
||||
|
||||
expect(config.SYSLOG).toStrictEqual({
|
||||
@@ -93,11 +117,6 @@ describe('Mempool Backend Config', () => {
|
||||
PASSWORD: ''
|
||||
});
|
||||
|
||||
expect(config.PRICE_DATA_SERVER).toStrictEqual({
|
||||
TOR_URL: 'http://wizpriceje6q5tdrxkyiazsgu7irquiqjy2dptezqhrtu7l2qelqktid.onion/getAllMarketPrices',
|
||||
CLEARNET_URL: 'https://price.bisq.wiz.biz/getAllMarketPrices'
|
||||
});
|
||||
|
||||
expect(config.EXTERNAL_DATA_SERVER).toStrictEqual({
|
||||
MEMPOOL_API: 'https://mempool.space/api/v1',
|
||||
MEMPOOL_ONION: 'http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api/v1',
|
||||
@@ -106,6 +125,31 @@ describe('Mempool Backend Config', () => {
|
||||
BISQ_URL: 'https://bisq.markets/api',
|
||||
BISQ_ONION: 'http://bisqmktse2cabavbr2xjq7xw3h6g5ottemo5rolfcwt6aly6tp5fdryd.onion/api'
|
||||
});
|
||||
|
||||
expect(config.MAXMIND).toStrictEqual({
|
||||
ENABLED: false,
|
||||
GEOLITE2_CITY: '/usr/local/share/GeoIP/GeoLite2-City.mmdb',
|
||||
GEOLITE2_ASN: '/usr/local/share/GeoIP/GeoLite2-ASN.mmdb',
|
||||
GEOIP2_ISP: '/usr/local/share/GeoIP/GeoIP2-ISP.mmdb'
|
||||
});
|
||||
|
||||
expect(config.REPLICATION).toStrictEqual({
|
||||
ENABLED: false,
|
||||
AUDIT: false,
|
||||
AUDIT_START_HEIGHT: 774000,
|
||||
SERVERS: []
|
||||
});
|
||||
|
||||
expect(config.MEMPOOL_SERVICES).toStrictEqual({
|
||||
API: "",
|
||||
ACCELERATIONS: false,
|
||||
});
|
||||
|
||||
expect(config.REDIS).toStrictEqual({
|
||||
ENABLED: false,
|
||||
UNIX_SOCKET_PATH: '',
|
||||
BATCH_QUERY_BASE_SIZE: 5000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,9 +180,110 @@ describe('Mempool Backend Config', () => {
|
||||
|
||||
expect(config.SOCKS5PROXY).toStrictEqual(fixture.SOCKS5PROXY);
|
||||
|
||||
expect(config.PRICE_DATA_SERVER).toStrictEqual(fixture.PRICE_DATA_SERVER);
|
||||
|
||||
expect(config.EXTERNAL_DATA_SERVER).toStrictEqual(fixture.EXTERNAL_DATA_SERVER);
|
||||
|
||||
expect(config.MEMPOOL_SERVICES).toStrictEqual(fixture.MEMPOOL_SERVICES);
|
||||
|
||||
expect(config.REDIS).toStrictEqual(fixture.REDIS);
|
||||
});
|
||||
});
|
||||
|
||||
test('should ensure the docker start.sh script has default values', () => {
|
||||
jest.isolateModules(() => {
|
||||
const startSh = fs.readFileSync(`${__dirname}/../../../docker/backend/start.sh`, 'utf-8');
|
||||
const fixture = JSON.parse(fs.readFileSync(`${__dirname}/../__fixtures__/mempool-config.template.json`, 'utf8'));
|
||||
|
||||
function parseJson(jsonObj, root?) {
|
||||
for (const [key, value] of Object.entries(jsonObj)) {
|
||||
// We have a few cases where we can't follow the pattern
|
||||
if (root === 'MEMPOOL' && key === 'HTTP_PORT') {
|
||||
if (process.env.CI) {
|
||||
console.log('skipping check for MEMPOOL_HTTP_PORT');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (root) {
|
||||
//The flattened string, i.e, __MEMPOOL_ENABLED__
|
||||
const replaceStr = `${root ? '__' + root + '_' : '__'}${key}__`;
|
||||
|
||||
//The string used as the environment variable, i.e, MEMPOOL_ENABLED
|
||||
const envVarStr = `${root ? root : ''}_${key}`;
|
||||
|
||||
let defaultEntry;
|
||||
//The string used as the default value, to be checked as a regex, i.e, __MEMPOOL_ENABLED__=${MEMPOOL_ENABLED:=(.*)}
|
||||
if (Array.isArray(value)) {
|
||||
defaultEntry = `${replaceStr}=\${${envVarStr}:=[]}`;
|
||||
if (process.env.CI) {
|
||||
console.log(`looking for ${defaultEntry} in the start.sh script`);
|
||||
}
|
||||
//Regex matching does not work with the array values
|
||||
expect(startSh).toContain(defaultEntry);
|
||||
} else {
|
||||
defaultEntry = replaceStr + '=' + '\\${' + envVarStr + ':=(.*)' + '}';
|
||||
if (process.env.CI) {
|
||||
console.log(`looking for ${defaultEntry} in the start.sh script`);
|
||||
}
|
||||
const re = new RegExp(defaultEntry);
|
||||
expect(startSh).toMatch(re);
|
||||
}
|
||||
|
||||
//The string that actually replaces the values in the config file
|
||||
const sedStr = 'sed -i "s!' + replaceStr + '!${' + replaceStr + '}!g" mempool-config.json';
|
||||
if (process.env.CI) {
|
||||
console.log(`looking for ${sedStr} in the start.sh script`);
|
||||
}
|
||||
expect(startSh).toContain(sedStr);
|
||||
}
|
||||
else {
|
||||
parseJson(value, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parseJson(fixture);
|
||||
});
|
||||
});
|
||||
|
||||
test('should ensure that the mempool-config.json Docker template has all the keys', () => {
|
||||
jest.isolateModules(() => {
|
||||
const fixture = JSON.parse(fs.readFileSync(`${__dirname}/../__fixtures__/mempool-config.template.json`, 'utf8'));
|
||||
const dockerJson = fs.readFileSync(`${__dirname}/../../../docker/backend/mempool-config.json`, 'utf-8');
|
||||
|
||||
function parseJson(jsonObj, root?) {
|
||||
for (const [key, value] of Object.entries(jsonObj)) {
|
||||
switch (typeof value) {
|
||||
case 'object': {
|
||||
if (Array.isArray(value)) {
|
||||
// numbers, arrays and booleans won't be enclosed by quotes
|
||||
const replaceStr = `${root ? '__' + root + '_' : '__'}${key}__`;
|
||||
expect(dockerJson).toContain(`"${key}": ${replaceStr}`);
|
||||
break;
|
||||
} else {
|
||||
//Check for top level config keys
|
||||
expect(dockerJson).toContain(`"${key}"`);
|
||||
parseJson(value, key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
case 'string': {
|
||||
// strings should be enclosed by quotes
|
||||
const replaceStr = `${root ? '__' + root + '_' : '__'}${key}__`;
|
||||
expect(dockerJson).toContain(`"${key}": "${replaceStr}"`);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// numbers, arrays and booleans won't be enclosed by quotes
|
||||
const replaceStr = `${root ? '__' + root + '_' : '__'}${key}__`;
|
||||
expect(dockerJson).toContain(`"${key}": ${replaceStr}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
parseJson(fixture);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
68
backend/src/__tests__/gbt/gbt-tests.ts
Normal file
68
backend/src/__tests__/gbt/gbt-tests.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import fs from 'fs';
|
||||
import { GbtGenerator, ThreadTransaction } from 'rust-gbt';
|
||||
import path from 'path';
|
||||
|
||||
const baseline = require('./test-data/target-template.json');
|
||||
const testVector = require('./test-data/test-data-ids.json');
|
||||
const vectorUidMap: Map<number, string> = new Map(testVector.map(x => [x[0], x[1]]));
|
||||
const vectorTxidMap: Map<string, number> = new Map(testVector.map(x => [x[1], x[0]]));
|
||||
// Note that this test buffer is specially constructed
|
||||
// such that uids are assigned in numerical txid order
|
||||
// so that ties break the same way as in Core's implementation
|
||||
const vectorBuffer: Buffer = fs.readFileSync(path.join(__dirname, './', './test-data/test-buffer.bin'));
|
||||
|
||||
describe('Rust GBT', () => {
|
||||
test('should produce the same template as getBlockTemplate from Bitcoin Core', async () => {
|
||||
const rustGbt = new GbtGenerator();
|
||||
const { mempool, maxUid } = mempoolFromArrayBuffer(vectorBuffer.buffer);
|
||||
const result = await rustGbt.make(mempool, [], maxUid);
|
||||
|
||||
const blocks: [string, number][][] = result.blocks.map(block => {
|
||||
return block.map(uid => [vectorUidMap.get(uid) || 'missing', uid]);
|
||||
});
|
||||
const template = baseline.map(tx => [tx.txid, vectorTxidMap.get(tx.txid)]);
|
||||
|
||||
expect(blocks[0].length).toEqual(baseline.length);
|
||||
expect(blocks[0]).toEqual(template);
|
||||
});
|
||||
});
|
||||
|
||||
function mempoolFromArrayBuffer(buf: ArrayBuffer): { mempool: ThreadTransaction[], maxUid: number } {
|
||||
let maxUid = 0;
|
||||
const view = new DataView(buf);
|
||||
const count = view.getUint32(0, false);
|
||||
const txs: ThreadTransaction[] = [];
|
||||
let offset = 4;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const uid = view.getUint32(offset, false);
|
||||
maxUid = Math.max(maxUid, uid);
|
||||
const tx: ThreadTransaction = {
|
||||
uid,
|
||||
order: txidToOrdering(vectorUidMap.get(uid) as string),
|
||||
fee: view.getFloat64(offset + 4, false),
|
||||
weight: view.getUint32(offset + 12, false),
|
||||
sigops: view.getUint32(offset + 16, false),
|
||||
// feePerVsize: view.getFloat64(offset + 20, false),
|
||||
effectiveFeePerVsize: view.getFloat64(offset + 28, false),
|
||||
inputs: [],
|
||||
};
|
||||
const numInputs = view.getUint32(offset + 36, false);
|
||||
offset += 40;
|
||||
for (let j = 0; j < numInputs; j++) {
|
||||
tx.inputs.push(view.getUint32(offset, false));
|
||||
offset += 4;
|
||||
}
|
||||
txs.push(tx);
|
||||
}
|
||||
return { mempool: txs, maxUid };
|
||||
}
|
||||
|
||||
function txidToOrdering(txid: string): number {
|
||||
return parseInt(
|
||||
txid.substr(62, 2) +
|
||||
txid.substr(60, 2) +
|
||||
txid.substr(58, 2) +
|
||||
txid.substr(56, 2),
|
||||
16
|
||||
);
|
||||
}
|
||||
7070
backend/src/__tests__/gbt/test-data/target-template.json
Normal file
7070
backend/src/__tests__/gbt/test-data/target-template.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
backend/src/__tests__/gbt/test-data/test-buffer.bin
Normal file
BIN
backend/src/__tests__/gbt/test-data/test-buffer.bin
Normal file
Binary file not shown.
1
backend/src/__tests__/gbt/test-data/test-data-ids.json
Normal file
1
backend/src/__tests__/gbt/test-data/test-data-ids.json
Normal file
File diff suppressed because one or more lines are too long
@@ -1,21 +1,27 @@
|
||||
import config from '../config';
|
||||
import { TransactionExtended, MempoolBlockWithTransactions } from '../mempool.interfaces';
|
||||
import logger from '../logger';
|
||||
import { MempoolTransactionExtended, MempoolBlockWithTransactions } from '../mempool.interfaces';
|
||||
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: TransactionExtended[], projectedBlocks: MempoolBlockWithTransactions[], mempool: { [txId: string]: TransactionExtended })
|
||||
: { censored: string[], added: string[], fresh: string[], score: number } {
|
||||
auditBlock(transactions: MempoolTransactionExtended[], projectedBlocks: MempoolBlockWithTransactions[], mempool: { [txId: string]: MempoolTransactionExtended }, useAccelerations: boolean = false)
|
||||
: { censored: string[], added: string[], fresh: string[], sigop: string[], fullrbf: string[], accelerated: string[], score: number, similarity: number } {
|
||||
if (!projectedBlocks?.[0]?.transactionIds || !mempool) {
|
||||
return { censored: [], added: [], fresh: [], score: 0 };
|
||||
return { censored: [], added: [], 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 fresh: string[] = []; // missing, but firstSeen within PROPAGATION_MARGIN
|
||||
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 = {};
|
||||
let displacedWeight = 0;
|
||||
let matchedWeight = 0;
|
||||
let projectedWeight = 0;
|
||||
|
||||
const inBlock = {};
|
||||
const inTemplate = {};
|
||||
@@ -23,6 +29,9 @@ class Audit {
|
||||
const now = Math.round((Date.now() / 1000));
|
||||
for (const tx of transactions) {
|
||||
inBlock[tx.txid] = tx;
|
||||
if (mempool[tx.txid] && mempool[tx.txid].acceleration) {
|
||||
accelerated.push(tx.txid);
|
||||
}
|
||||
}
|
||||
// coinbase is always expected
|
||||
if (transactions[0]) {
|
||||
@@ -31,18 +40,31 @@ class Audit {
|
||||
// look for transactions that were expected in the template, but missing from the mined block
|
||||
for (const txid of projectedBlocks[0].transactionIds) {
|
||||
if (!inBlock[txid]) {
|
||||
// tx is recent, may have reached the miner too late for inclusion
|
||||
if (mempool[txid]?.firstSeen != null && (now - (mempool[txid]?.firstSeen || 0)) <= PROPAGATION_MARGIN) {
|
||||
// allow missing transactions which either belong to a full rbf tree, or conflict with any transaction in the mined block
|
||||
if (rbfCache.has(txid) && (rbfCache.isFullRbf(txid) || rbfCache.anyInSameTree(txid, (tx) => inBlock[tx.txid]))) {
|
||||
rbf.push(txid);
|
||||
} else if (mempool[txid]?.firstSeen != null && (now - (mempool[txid]?.firstSeen || 0)) <= PROPAGATION_MARGIN) {
|
||||
// tx is recent, may have reached the miner too late for inclusion
|
||||
fresh.push(txid);
|
||||
} else if (mempool[txid]?.lastBoosted != null && (now - (mempool[txid]?.lastBoosted || 0)) <= PROPAGATION_MARGIN) {
|
||||
// tx was recently cpfp'd, miner may not have the latest effective rate
|
||||
fresh.push(txid);
|
||||
} else {
|
||||
isCensored[txid] = true;
|
||||
}
|
||||
displacedWeight += mempool[txid].weight;
|
||||
displacedWeight += mempool[txid]?.weight || 0;
|
||||
} else {
|
||||
matchedWeight += mempool[txid]?.weight || 0;
|
||||
}
|
||||
projectedWeight += mempool[txid]?.weight || 0;
|
||||
inTemplate[txid] = true;
|
||||
}
|
||||
|
||||
displacedWeight += (4000 - transactions[0].weight);
|
||||
if (transactions[0]) {
|
||||
displacedWeight += (4000 - transactions[0].weight);
|
||||
projectedWeight += transactions[0].weight;
|
||||
matchedWeight += transactions[0].weight;
|
||||
}
|
||||
|
||||
// we can expect an honest miner to include 'displaced' transactions in place of recent arrivals and censored txs
|
||||
// these displaced transactions should occupy the first N weight units of the next projected block
|
||||
@@ -52,19 +74,24 @@ class Audit {
|
||||
let failures = 0;
|
||||
while (projectedBlocks[1] && index < projectedBlocks[1].transactionIds.length && failures < 500) {
|
||||
const txid = projectedBlocks[1].transactionIds[index];
|
||||
const fits = (mempool[txid].weight - displacedWeightRemaining) < 4000;
|
||||
const feeMatches = mempool[txid].effectiveFeePerVsize >= lastFeeRate;
|
||||
if (fits || feeMatches) {
|
||||
isDisplaced[txid] = true;
|
||||
if (fits) {
|
||||
lastFeeRate = Math.min(lastFeeRate, mempool[txid].effectiveFeePerVsize);
|
||||
const tx = mempool[txid];
|
||||
if (tx) {
|
||||
const fits = (tx.weight - displacedWeightRemaining) < 4000;
|
||||
const feeMatches = tx.effectiveFeePerVsize >= lastFeeRate;
|
||||
if (fits || feeMatches) {
|
||||
isDisplaced[txid] = true;
|
||||
if (fits) {
|
||||
lastFeeRate = Math.min(lastFeeRate, tx.effectiveFeePerVsize);
|
||||
}
|
||||
if (tx.firstSeen == null || (now - (tx?.firstSeen || 0)) > PROPAGATION_MARGIN) {
|
||||
displacedWeightRemaining -= tx.weight;
|
||||
}
|
||||
failures = 0;
|
||||
} else {
|
||||
failures++;
|
||||
}
|
||||
if (mempool[txid].firstSeen == null || (now - (mempool[txid]?.firstSeen || 0)) > PROPAGATION_MARGIN) {
|
||||
displacedWeightRemaining -= mempool[txid].weight;
|
||||
}
|
||||
failures = 0;
|
||||
} else {
|
||||
failures++;
|
||||
logger.warn('projected transaction missing from mempool cache');
|
||||
}
|
||||
index++;
|
||||
}
|
||||
@@ -76,19 +103,11 @@ class Audit {
|
||||
if (inTemplate[tx.txid]) {
|
||||
matches.push(tx.txid);
|
||||
} else {
|
||||
if (!isDisplaced[tx.txid]) {
|
||||
if (rbfCache.has(tx.txid)) {
|
||||
rbf.push(tx.txid);
|
||||
} else if (!isDisplaced[tx.txid]) {
|
||||
added.push(tx.txid);
|
||||
} else {
|
||||
}
|
||||
let blockIndex = -1;
|
||||
let index = -1;
|
||||
projectedBlocks.forEach((block, bi) => {
|
||||
const i = block.transactionIds.indexOf(tx.txid);
|
||||
if (i >= 0) {
|
||||
blockIndex = bi;
|
||||
index = i;
|
||||
}
|
||||
});
|
||||
overflowWeight += tx.weight;
|
||||
}
|
||||
totalWeight += tx.weight;
|
||||
@@ -101,32 +120,47 @@ class Audit {
|
||||
index = projectedBlocks[0].transactionIds.length - 1;
|
||||
while (index >= 0) {
|
||||
const txid = projectedBlocks[0].transactionIds[index];
|
||||
if (overflowWeightRemaining > 0) {
|
||||
if (isCensored[txid]) {
|
||||
delete isCensored[txid];
|
||||
}
|
||||
if (mempool[txid].effectiveFeePerVsize > maxOverflowRate) {
|
||||
maxOverflowRate = mempool[txid].effectiveFeePerVsize;
|
||||
rateThreshold = (Math.ceil(maxOverflowRate * 100) / 100) + 0.005;
|
||||
}
|
||||
} else if (mempool[txid].effectiveFeePerVsize <= rateThreshold) { // tolerance of 0.01 sat/vb + rounding
|
||||
if (isCensored[txid]) {
|
||||
delete isCensored[txid];
|
||||
const tx = mempool[txid];
|
||||
if (tx) {
|
||||
if (overflowWeightRemaining > 0) {
|
||||
if (isCensored[txid]) {
|
||||
delete isCensored[txid];
|
||||
}
|
||||
if (tx.effectiveFeePerVsize > maxOverflowRate) {
|
||||
maxOverflowRate = tx.effectiveFeePerVsize;
|
||||
rateThreshold = (Math.ceil(maxOverflowRate * 100) / 100) + 0.005;
|
||||
}
|
||||
} else if (tx.effectiveFeePerVsize <= rateThreshold) { // tolerance of 0.01 sat/vb + rounding
|
||||
if (isCensored[txid]) {
|
||||
delete isCensored[txid];
|
||||
}
|
||||
}
|
||||
overflowWeightRemaining -= (mempool[txid]?.weight || 0);
|
||||
} else {
|
||||
logger.warn('projected transaction missing from mempool cache');
|
||||
}
|
||||
overflowWeightRemaining -= (mempool[txid]?.weight || 0);
|
||||
index--;
|
||||
}
|
||||
|
||||
const numCensored = Object.keys(isCensored).length;
|
||||
const numMatches = matches.length - 1; // adjust for coinbase tx
|
||||
const score = numMatches > 0 ? (numMatches / (numMatches + numCensored)) : 0;
|
||||
let score = 0;
|
||||
if (numMatches <= 0 && numCensored <= 0) {
|
||||
score = 1;
|
||||
} else if (numMatches > 0) {
|
||||
score = (numMatches / (numMatches + numCensored));
|
||||
}
|
||||
const similarity = projectedWeight ? matchedWeight / projectedWeight : 1;
|
||||
|
||||
return {
|
||||
censored: Object.keys(isCensored),
|
||||
added,
|
||||
fresh,
|
||||
score
|
||||
sigop: [],
|
||||
fullrbf: rbf,
|
||||
accelerated,
|
||||
score,
|
||||
similarity,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { SocksProxyAgent } from 'socks-proxy-agent';
|
||||
import { BisqBlocks, BisqBlock, BisqTransaction, BisqStats, BisqTrade } from './interfaces';
|
||||
import { Common } from '../common';
|
||||
import { BlockExtended } from '../../mempool.interfaces';
|
||||
import { StaticPool } from 'node-worker-threads-pool';
|
||||
import backendInfo from '../backend-info';
|
||||
import logger from '../../logger';
|
||||
|
||||
@@ -31,10 +30,6 @@ class Bisq {
|
||||
private priceUpdateCallbackFunction: ((price: number) => void) | undefined;
|
||||
private topDirectoryWatcher: fs.FSWatcher | undefined;
|
||||
private subdirectoryWatcher: fs.FSWatcher | undefined;
|
||||
private jsonParsePool = new StaticPool({
|
||||
size: 4,
|
||||
task: (blob: string) => JSON.parse(blob),
|
||||
});
|
||||
|
||||
constructor() {}
|
||||
|
||||
|
||||
@@ -646,7 +646,7 @@ class BisqMarketsApi {
|
||||
case 'year':
|
||||
return strtotime('midnight first day of january', ts);
|
||||
default:
|
||||
throw new Error('Unsupported interval: ' + interval);
|
||||
throw new Error('Unsupported interval');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { IBitcoinApi } from './bitcoin-api.interface';
|
||||
import { IEsploraApi } from './esplora-api.interface';
|
||||
|
||||
export interface AbstractBitcoinApi {
|
||||
$getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]>;
|
||||
$getRawTransaction(txId: string, skipConversion?: boolean, addPrevout?: boolean, lazyPrevouts?: boolean): Promise<IEsploraApi.Transaction>;
|
||||
$getRawTransactions(txids: string[]): Promise<IEsploraApi.Transaction[]>;
|
||||
$getMempoolTransactions(txids: string[]): Promise<IEsploraApi.Transaction[]>;
|
||||
$getAllMempoolTransactions(lastTxid?: string, max_txs?: number);
|
||||
$getTransactionHex(txId: string): Promise<string>;
|
||||
$getBlockHeightTip(): Promise<number>;
|
||||
$getBlockHashTip(): Promise<string>;
|
||||
$getTxIdsForBlock(hash: string): Promise<string[]>;
|
||||
$getTxsForBlock(hash: string): Promise<IEsploraApi.Transaction[]>;
|
||||
$getBlockHash(height: number): Promise<string>;
|
||||
$getBlockHeader(hash: string): Promise<string>;
|
||||
$getBlock(hash: string): Promise<IEsploraApi.Block>;
|
||||
@@ -14,10 +19,16 @@ export interface AbstractBitcoinApi {
|
||||
$getAddress(address: string): Promise<IEsploraApi.Address>;
|
||||
$getAddressTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]>;
|
||||
$getAddressPrefix(prefix: string): string[];
|
||||
$getScriptHash(scripthash: string): Promise<IEsploraApi.ScriptHash>;
|
||||
$getScriptHashTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]>;
|
||||
$sendRawTransaction(rawTransaction: string): Promise<string>;
|
||||
$getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend>;
|
||||
$getOutspends(txId: string): Promise<IEsploraApi.Outspend[]>;
|
||||
$getBatchedOutspends(txId: string[]): Promise<IEsploraApi.Outspend[][]>;
|
||||
$getBatchedOutspendsInternal(txId: string[]): Promise<IEsploraApi.Outspend[][]>;
|
||||
$getOutSpendsByOutpoint(outpoints: { txid: string, vout: number }[]): Promise<IEsploraApi.Outspend[]>;
|
||||
|
||||
startHealthChecks(): void;
|
||||
}
|
||||
export interface BitcoinRpcCredentials {
|
||||
host: string;
|
||||
@@ -25,4 +36,5 @@ export interface BitcoinRpcCredentials {
|
||||
user: string;
|
||||
pass: string;
|
||||
timeout: number;
|
||||
cookie?: string;
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ export namespace IBitcoinApi {
|
||||
address?: string; // (string) bitcoin address
|
||||
addresses?: string[]; // (string) bitcoin addresses
|
||||
pegout_chain?: string; // (string) Elements peg-out chain
|
||||
pegout_address?: string; // (string) Elements peg-out address
|
||||
pegout_addresses?: string[]; // (string) Elements peg-out addresses
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { IEsploraApi } from './esplora-api.interface';
|
||||
import blocks from '../blocks';
|
||||
import mempool from '../mempool';
|
||||
import { TransactionExtended } from '../../mempool.interfaces';
|
||||
import transactionUtils from '../transaction-utils';
|
||||
|
||||
class BitcoinApi implements AbstractBitcoinApi {
|
||||
private rawMempoolCache: IBitcoinApi.RawMempool | null = null;
|
||||
@@ -29,6 +30,7 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
weight: block.weight,
|
||||
previousblockhash: block.previousblockhash,
|
||||
mediantime: block.mediantime,
|
||||
stale: block.confirmations === -1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,23 +60,46 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
});
|
||||
}
|
||||
|
||||
$getTransactionHex(txId: string): Promise<string> {
|
||||
return this.$getRawTransaction(txId, true)
|
||||
.then((tx) => tx.hex || '');
|
||||
async $getRawTransactions(txids: string[]): Promise<IEsploraApi.Transaction[]> {
|
||||
const txs: IEsploraApi.Transaction[] = [];
|
||||
for (const txid of txids) {
|
||||
try {
|
||||
const tx = await this.$getRawTransaction(txid, false, true);
|
||||
txs.push(tx);
|
||||
} catch (err) {
|
||||
// skip failures
|
||||
}
|
||||
}
|
||||
return txs;
|
||||
}
|
||||
|
||||
$getMempoolTransactions(txids: string[]): Promise<IEsploraApi.Transaction[]> {
|
||||
throw new Error('Method getMempoolTransactions not supported by the Bitcoin RPC API.');
|
||||
}
|
||||
|
||||
$getAllMempoolTransactions(lastTxid?: string, max_txs?: number): Promise<IEsploraApi.Transaction[]> {
|
||||
throw new Error('Method getAllMempoolTransactions not supported by the Bitcoin RPC API.');
|
||||
|
||||
}
|
||||
|
||||
async $getTransactionHex(txId: string): Promise<string> {
|
||||
const txInMempool = mempool.getMempool()[txId];
|
||||
if (txInMempool && txInMempool.hex) {
|
||||
return txInMempool.hex;
|
||||
}
|
||||
|
||||
return this.bitcoindClient.getRawTransaction(txId, true)
|
||||
.then((transaction: IBitcoinApi.Transaction) => {
|
||||
return transaction.hex;
|
||||
});
|
||||
}
|
||||
|
||||
$getBlockHeightTip(): Promise<number> {
|
||||
return this.bitcoindClient.getChainTips()
|
||||
.then((result: IBitcoinApi.ChainTips[]) => {
|
||||
return result.find(tip => tip.status === 'active')!.height;
|
||||
});
|
||||
return this.bitcoindClient.getBlockCount();
|
||||
}
|
||||
|
||||
$getBlockHashTip(): Promise<string> {
|
||||
return this.bitcoindClient.getChainTips()
|
||||
.then((result: IBitcoinApi.ChainTips[]) => {
|
||||
return result.find(tip => tip.status === 'active')!.hash;
|
||||
});
|
||||
return this.bitcoindClient.getBestBlockHash();
|
||||
}
|
||||
|
||||
$getTxIdsForBlock(hash: string): Promise<string[]> {
|
||||
@@ -82,6 +107,10 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
.then((rpcBlock: IBitcoinApi.Block) => rpcBlock.tx);
|
||||
}
|
||||
|
||||
$getTxsForBlock(hash: string): Promise<IEsploraApi.Transaction[]> {
|
||||
throw new Error('Method getTxsForBlock not supported by the Bitcoin RPC API.');
|
||||
}
|
||||
|
||||
$getRawBlock(hash: string): Promise<Buffer> {
|
||||
return this.bitcoindClient.getBlock(hash, 0)
|
||||
.then((raw: string) => Buffer.from(raw, "hex"));
|
||||
@@ -113,6 +142,14 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
throw new Error('Method getAddressTransactions not supported by the Bitcoin RPC API.');
|
||||
}
|
||||
|
||||
$getScriptHash(scripthash: string): Promise<IEsploraApi.ScriptHash> {
|
||||
throw new Error('Method getScriptHash not supported by the Bitcoin RPC API.');
|
||||
}
|
||||
|
||||
$getScriptHashTransactions(scripthash: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]> {
|
||||
throw new Error('Method getScriptHashTransactions not supported by the Bitcoin RPC API.');
|
||||
}
|
||||
|
||||
$getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]> {
|
||||
return this.bitcoindClient.getRawMemPool();
|
||||
}
|
||||
@@ -174,6 +211,19 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
return outspends;
|
||||
}
|
||||
|
||||
async $getBatchedOutspendsInternal(txId: string[]): Promise<IEsploraApi.Outspend[][]> {
|
||||
return this.$getBatchedOutspends(txId);
|
||||
}
|
||||
|
||||
async $getOutSpendsByOutpoint(outpoints: { txid: string, vout: number }[]): Promise<IEsploraApi.Outspend[]> {
|
||||
const outspends: IEsploraApi.Outspend[] = [];
|
||||
for (const outpoint of outpoints) {
|
||||
const outspend = await this.$getOutspend(outpoint.txid, outpoint.vout);
|
||||
outspends.push(outspend);
|
||||
}
|
||||
return outspends;
|
||||
}
|
||||
|
||||
$getEstimatedHashrate(blockHeight: number): Promise<number> {
|
||||
// 120 is the default block span in Core
|
||||
return this.bitcoindClient.getNetworkHashPs(120, blockHeight);
|
||||
@@ -198,7 +248,7 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
scriptpubkey: vout.scriptPubKey.hex,
|
||||
scriptpubkey_address: vout.scriptPubKey && vout.scriptPubKey.address ? vout.scriptPubKey.address
|
||||
: vout.scriptPubKey.addresses ? vout.scriptPubKey.addresses[0] : '',
|
||||
scriptpubkey_asm: vout.scriptPubKey.asm ? this.convertScriptSigAsm(vout.scriptPubKey.hex) : '',
|
||||
scriptpubkey_asm: vout.scriptPubKey.asm ? transactionUtils.convertScriptSigAsm(vout.scriptPubKey.hex) : '',
|
||||
scriptpubkey_type: this.translateScriptPubKeyType(vout.scriptPubKey.type),
|
||||
};
|
||||
});
|
||||
@@ -208,7 +258,7 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
is_coinbase: !!vin.coinbase,
|
||||
prevout: null,
|
||||
scriptsig: vin.scriptSig && vin.scriptSig.hex || vin.coinbase || '',
|
||||
scriptsig_asm: vin.scriptSig && this.convertScriptSigAsm(vin.scriptSig.hex) || '',
|
||||
scriptsig_asm: vin.scriptSig && transactionUtils.convertScriptSigAsm(vin.scriptSig.hex) || '',
|
||||
sequence: vin.sequence,
|
||||
txid: vin.txid || '',
|
||||
vout: vin.vout || 0,
|
||||
@@ -280,7 +330,7 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
}
|
||||
const innerTx = await this.$getRawTransaction(vin.txid, false, false);
|
||||
vin.prevout = innerTx.vout[vin.vout];
|
||||
this.addInnerScriptsToVin(vin);
|
||||
transactionUtils.addInnerScriptsToVin(vin);
|
||||
}
|
||||
return transaction;
|
||||
}
|
||||
@@ -319,7 +369,7 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
}
|
||||
const innerTx = await this.$getRawTransaction(transaction.vin[i].txid, false, false);
|
||||
transaction.vin[i].prevout = innerTx.vout[transaction.vin[i].vout];
|
||||
this.addInnerScriptsToVin(transaction.vin[i]);
|
||||
transactionUtils.addInnerScriptsToVin(transaction.vin[i]);
|
||||
totalIn += innerTx.vout[transaction.vin[i].vout].value;
|
||||
}
|
||||
if (lazyPrevouts && transaction.vin.length > 12) {
|
||||
@@ -331,96 +381,7 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
return transaction;
|
||||
}
|
||||
|
||||
private convertScriptSigAsm(hex: string): string {
|
||||
const buf = Buffer.from(hex, 'hex');
|
||||
|
||||
const b: string[] = [];
|
||||
|
||||
let i = 0;
|
||||
while (i < buf.length) {
|
||||
const op = buf[i];
|
||||
if (op >= 0x01 && op <= 0x4e) {
|
||||
i++;
|
||||
let push: number;
|
||||
if (op === 0x4c) {
|
||||
push = buf.readUInt8(i);
|
||||
b.push('OP_PUSHDATA1');
|
||||
i += 1;
|
||||
} else if (op === 0x4d) {
|
||||
push = buf.readUInt16LE(i);
|
||||
b.push('OP_PUSHDATA2');
|
||||
i += 2;
|
||||
} else if (op === 0x4e) {
|
||||
push = buf.readUInt32LE(i);
|
||||
b.push('OP_PUSHDATA4');
|
||||
i += 4;
|
||||
} else {
|
||||
push = op;
|
||||
b.push('OP_PUSHBYTES_' + push);
|
||||
}
|
||||
|
||||
const data = buf.slice(i, i + push);
|
||||
if (data.length !== push) {
|
||||
break;
|
||||
}
|
||||
|
||||
b.push(data.toString('hex'));
|
||||
i += data.length;
|
||||
} else {
|
||||
if (op === 0x00) {
|
||||
b.push('OP_0');
|
||||
} else if (op === 0x4f) {
|
||||
b.push('OP_PUSHNUM_NEG1');
|
||||
} else if (op === 0xb1) {
|
||||
b.push('OP_CLTV');
|
||||
} else if (op === 0xb2) {
|
||||
b.push('OP_CSV');
|
||||
} else if (op === 0xba) {
|
||||
b.push('OP_CHECKSIGADD');
|
||||
} else {
|
||||
const opcode = bitcoinjs.script.toASM([ op ]);
|
||||
if (opcode && op < 0xfd) {
|
||||
if (/^OP_(\d+)$/.test(opcode)) {
|
||||
b.push(opcode.replace(/^OP_(\d+)$/, 'OP_PUSHNUM_$1'));
|
||||
} else {
|
||||
b.push(opcode);
|
||||
}
|
||||
} else {
|
||||
b.push('OP_RETURN_' + op);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return b.join(' ');
|
||||
}
|
||||
|
||||
private addInnerScriptsToVin(vin: IEsploraApi.Vin): void {
|
||||
if (!vin.prevout) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (vin.prevout.scriptpubkey_type === 'p2sh') {
|
||||
const redeemScript = vin.scriptsig_asm.split(' ').reverse()[0];
|
||||
vin.inner_redeemscript_asm = this.convertScriptSigAsm(redeemScript);
|
||||
if (vin.witness && vin.witness.length > 2) {
|
||||
const witnessScript = vin.witness[vin.witness.length - 1];
|
||||
vin.inner_witnessscript_asm = this.convertScriptSigAsm(witnessScript);
|
||||
}
|
||||
}
|
||||
|
||||
if (vin.prevout.scriptpubkey_type === 'v0_p2wsh' && vin.witness) {
|
||||
const witnessScript = vin.witness[vin.witness.length - 1];
|
||||
vin.inner_witnessscript_asm = this.convertScriptSigAsm(witnessScript);
|
||||
}
|
||||
|
||||
if (vin.prevout.scriptpubkey_type === 'v1_p2tr' && vin.witness && vin.witness.length > 1) {
|
||||
const witnessScript = vin.witness[vin.witness.length - 2];
|
||||
vin.inner_witnessscript_asm = this.convertScriptSigAsm(witnessScript);
|
||||
}
|
||||
}
|
||||
|
||||
public startHealthChecks(): void {};
|
||||
}
|
||||
|
||||
export default BitcoinApi;
|
||||
|
||||
@@ -7,7 +7,8 @@ const nodeRpcCredentials: BitcoinRpcCredentials = {
|
||||
port: config.CORE_RPC.PORT,
|
||||
user: config.CORE_RPC.USERNAME,
|
||||
pass: config.CORE_RPC.PASSWORD,
|
||||
timeout: 60000,
|
||||
timeout: config.CORE_RPC.TIMEOUT,
|
||||
cookie: config.CORE_RPC.COOKIE ? config.CORE_RPC.COOKIE_PATH : undefined,
|
||||
};
|
||||
|
||||
export default new bitcoin.Client(nodeRpcCredentials);
|
||||
|
||||
249
backend/src/api/bitcoin/bitcoin-core.routes.ts
Normal file
249
backend/src/api/bitcoin/bitcoin-core.routes.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { Application, NextFunction, Request, Response } from 'express';
|
||||
import logger from '../../logger';
|
||||
import bitcoinClient from './bitcoin-client';
|
||||
|
||||
/**
|
||||
* Define a set of routes used by the accelerator server
|
||||
* Those routes are not designed to be public
|
||||
*/
|
||||
class BitcoinBackendRoutes {
|
||||
private static tag = 'BitcoinBackendRoutes';
|
||||
|
||||
public initRoutes(app: Application) {
|
||||
app
|
||||
.get('/api/internal/bitcoin-core/' + 'get-mempool-entry', this.disableCache, this.$getMempoolEntry)
|
||||
.post('/api/internal/bitcoin-core/' + 'decode-raw-transaction', this.disableCache, this.$decodeRawTransaction)
|
||||
.get('/api/internal/bitcoin-core/' + 'get-raw-transaction', this.disableCache, this.$getRawTransaction)
|
||||
.post('/api/internal/bitcoin-core/' + 'send-raw-transaction', this.disableCache, this.$sendRawTransaction)
|
||||
.post('/api/internal/bitcoin-core/' + 'test-mempool-accept', this.disableCache, this.$testMempoolAccept)
|
||||
.get('/api/internal/bitcoin-core/' + 'get-mempool-ancestors', this.disableCache, this.$getMempoolAncestors)
|
||||
.get('/api/internal/bitcoin-core/' + 'get-block', this.disableCache, this.$getBlock)
|
||||
.get('/api/internal/bitcoin-core/' + 'get-block-hash', this.disableCache, this.$getBlockHash)
|
||||
.get('/api/internal/bitcoin-core/' + 'get-block-count', this.disableCache, this.$getBlockCount)
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable caching for bitcoin core routes
|
||||
*
|
||||
* @param req
|
||||
* @param res
|
||||
* @param next
|
||||
*/
|
||||
private disableCache(req: Request, res: Response, next: NextFunction): 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);
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Exeption handler to return proper details to the accelerator server
|
||||
*
|
||||
* @param e
|
||||
* @param fnName
|
||||
* @param res
|
||||
*/
|
||||
private static handleException(e: any, fnName: string, res: Response): void {
|
||||
if (typeof(e.code) === 'number') {
|
||||
res.status(400).send(JSON.stringify(e, ['code', 'message']));
|
||||
} else {
|
||||
const err = `exception in ${fnName}. ${e}. Details: ${JSON.stringify(e, ['code', 'message'])}`;
|
||||
logger.err(err, BitcoinBackendRoutes.tag);
|
||||
res.status(500).send(err);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getMempoolEntry(req: Request, res: Response): Promise<void> {
|
||||
const txid = req.query.txid;
|
||||
try {
|
||||
if (typeof(txid) !== 'string' || txid.length !== 64) {
|
||||
res.status(400).send(`invalid param txid ${txid}. must be a string of 64 char`);
|
||||
return;
|
||||
}
|
||||
const mempoolEntry = await bitcoinClient.getMempoolEntry(txid);
|
||||
if (!mempoolEntry) {
|
||||
res.status(404).send(`no mempool entry found for txid ${txid}`);
|
||||
return;
|
||||
}
|
||||
res.status(200).send(mempoolEntry);
|
||||
} catch (e: any) {
|
||||
BitcoinBackendRoutes.handleException(e, 'getMempoolEntry', res);
|
||||
}
|
||||
}
|
||||
|
||||
private async $decodeRawTransaction(req: Request, res: Response): Promise<void> {
|
||||
const rawTx = req.body.rawTx;
|
||||
try {
|
||||
if (typeof(rawTx) !== 'string') {
|
||||
res.status(400).send(`invalid param rawTx ${rawTx}. must be a string`);
|
||||
return;
|
||||
}
|
||||
const decodedTx = await bitcoinClient.decodeRawTransaction(rawTx);
|
||||
if (!decodedTx) {
|
||||
res.status(400).send(`unable to decode rawTx ${rawTx}`);
|
||||
return;
|
||||
}
|
||||
res.status(200).send(decodedTx);
|
||||
} catch (e: any) {
|
||||
BitcoinBackendRoutes.handleException(e, 'decodeRawTransaction', res);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getRawTransaction(req: Request, res: Response): Promise<void> {
|
||||
const txid = req.query.txid;
|
||||
const verbose = req.query.verbose;
|
||||
try {
|
||||
if (typeof(txid) !== 'string' || txid.length !== 64) {
|
||||
res.status(400).send(`invalid param txid ${txid}. must be a string of 64 char`);
|
||||
return;
|
||||
}
|
||||
if (typeof(verbose) !== 'string') {
|
||||
res.status(400).send(`invalid param verbose ${verbose}. must be a string representing an integer`);
|
||||
return;
|
||||
}
|
||||
const verboseNumber = parseInt(verbose, 10);
|
||||
if (typeof(verboseNumber) !== 'number') {
|
||||
res.status(400).send(`invalid param verbose ${verbose}. must be a valid integer`);
|
||||
return;
|
||||
}
|
||||
|
||||
const decodedTx = await bitcoinClient.getRawTransaction(txid, verboseNumber);
|
||||
if (!decodedTx) {
|
||||
res.status(400).send(`unable to get raw transaction for txid ${txid}`);
|
||||
return;
|
||||
}
|
||||
res.status(200).send(decodedTx);
|
||||
} catch (e: any) {
|
||||
BitcoinBackendRoutes.handleException(e, 'decodeRawTransaction', res);
|
||||
}
|
||||
}
|
||||
|
||||
private async $sendRawTransaction(req: Request, res: Response): Promise<void> {
|
||||
const rawTx = req.body.rawTx;
|
||||
try {
|
||||
if (typeof(rawTx) !== 'string') {
|
||||
res.status(400).send(`invalid param rawTx ${rawTx}. must be a string`);
|
||||
return;
|
||||
}
|
||||
const txHex = await bitcoinClient.sendRawTransaction(rawTx);
|
||||
if (!txHex) {
|
||||
res.status(400).send(`unable to send rawTx ${rawTx}`);
|
||||
return;
|
||||
}
|
||||
res.status(200).send(txHex);
|
||||
} catch (e: any) {
|
||||
BitcoinBackendRoutes.handleException(e, 'sendRawTransaction', res);
|
||||
}
|
||||
}
|
||||
|
||||
private async $testMempoolAccept(req: Request, res: Response): Promise<void> {
|
||||
const rawTxs = req.body.rawTxs;
|
||||
try {
|
||||
if (typeof(rawTxs) !== 'object') {
|
||||
res.status(400).send(`invalid param rawTxs ${JSON.stringify(rawTxs)}. must be an array of string`);
|
||||
return;
|
||||
}
|
||||
const txHex = await bitcoinClient.testMempoolAccept(rawTxs);
|
||||
if (typeof(txHex) !== 'object' || txHex.length === 0) {
|
||||
res.status(400).send(`testmempoolaccept failed for raw txs ${JSON.stringify(rawTxs)}, got an empty result`);
|
||||
return;
|
||||
}
|
||||
res.status(200).send(txHex);
|
||||
} catch (e: any) {
|
||||
BitcoinBackendRoutes.handleException(e, 'testMempoolAccept', res);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getMempoolAncestors(req: Request, res: Response): Promise<void> {
|
||||
const txid = req.query.txid;
|
||||
const verbose = req.query.verbose;
|
||||
try {
|
||||
if (typeof(txid) !== 'string' || txid.length !== 64) {
|
||||
res.status(400).send(`invalid param txid ${txid}. must be a string of 64 char`);
|
||||
return;
|
||||
}
|
||||
if (typeof(verbose) !== 'string' || (verbose !== 'true' && verbose !== 'false')) {
|
||||
res.status(400).send(`invalid param verbose ${verbose}. must be a string ('true' | 'false')`);
|
||||
return;
|
||||
}
|
||||
|
||||
const ancestors = await bitcoinClient.getMempoolAncestors(txid, verbose === 'true' ? true : false);
|
||||
if (!ancestors) {
|
||||
res.status(400).send(`unable to get mempool ancestors for txid ${txid}`);
|
||||
return;
|
||||
}
|
||||
res.status(200).send(ancestors);
|
||||
} catch (e: any) {
|
||||
BitcoinBackendRoutes.handleException(e, 'getMempoolAncestors', res);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getBlock(req: Request, res: Response): Promise<void> {
|
||||
const blockHash = req.query.hash;
|
||||
const verbosity = req.query.verbosity;
|
||||
try {
|
||||
if (typeof(blockHash) !== 'string' || blockHash.length !== 64) {
|
||||
res.status(400).send(`invalid param blockHash ${blockHash}. must be a string of 64 char`);
|
||||
return;
|
||||
}
|
||||
if (typeof(verbosity) !== 'string') {
|
||||
res.status(400).send(`invalid param verbosity ${verbosity}. must be a string representing an integer`);
|
||||
return;
|
||||
}
|
||||
const verbosityNumber = parseInt(verbosity, 10);
|
||||
if (typeof(verbosityNumber) !== 'number') {
|
||||
res.status(400).send(`invalid param verbosity ${verbosity}. must be a valid integer`);
|
||||
return;
|
||||
}
|
||||
|
||||
const block = await bitcoinClient.getBlock(blockHash, verbosityNumber);
|
||||
if (!block) {
|
||||
res.status(400).send(`unable to get block for block hash ${blockHash}`);
|
||||
return;
|
||||
}
|
||||
res.status(200).send(block);
|
||||
} catch (e: any) {
|
||||
BitcoinBackendRoutes.handleException(e, 'getBlock', res);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getBlockHash(req: Request, res: Response): Promise<void> {
|
||||
const blockHeight = req.query.height;
|
||||
try {
|
||||
if (typeof(blockHeight) !== 'string') {
|
||||
res.status(400).send(`invalid param blockHeight ${blockHeight}, must be a string representing an integer`);
|
||||
return;
|
||||
}
|
||||
const blockHeightNumber = parseInt(blockHeight, 10);
|
||||
if (typeof(blockHeightNumber) !== 'number') {
|
||||
res.status(400).send(`invalid param blockHeight ${blockHeight}. must be a valid integer`);
|
||||
return;
|
||||
}
|
||||
|
||||
const block = await bitcoinClient.getBlockHash(blockHeightNumber);
|
||||
if (!block) {
|
||||
res.status(400).send(`unable to get block hash for block height ${blockHeightNumber}`);
|
||||
return;
|
||||
}
|
||||
res.status(200).send(block);
|
||||
} catch (e: any) {
|
||||
BitcoinBackendRoutes.handleException(e, 'getBlockHash', res);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getBlockCount(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const count = await bitcoinClient.getBlockCount();
|
||||
if (!count) {
|
||||
res.status(400).send(`unable to get block count`);
|
||||
return;
|
||||
}
|
||||
res.status(200).send(`${count}`);
|
||||
} catch (e: any) {
|
||||
BitcoinBackendRoutes.handleException(e, 'getBlockCount', res);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new BitcoinBackendRoutes
|
||||
@@ -7,7 +7,8 @@ const nodeRpcCredentials: BitcoinRpcCredentials = {
|
||||
port: config.SECOND_CORE_RPC.PORT,
|
||||
user: config.SECOND_CORE_RPC.USERNAME,
|
||||
pass: config.SECOND_CORE_RPC.PASSWORD,
|
||||
timeout: 60000,
|
||||
timeout: config.SECOND_CORE_RPC.TIMEOUT,
|
||||
cookie: config.SECOND_CORE_RPC.COOKIE ? config.SECOND_CORE_RPC.COOKIE_PATH : undefined,
|
||||
};
|
||||
|
||||
export default new bitcoin.Client(nodeRpcCredentials);
|
||||
|
||||
@@ -6,7 +6,7 @@ import websocketHandler from '../websocket-handler';
|
||||
import mempool from '../mempool';
|
||||
import feeApi from '../fee-api';
|
||||
import mempoolBlocks from '../mempool-blocks';
|
||||
import bitcoinApi, { bitcoinCoreApi } from './bitcoin-api-factory';
|
||||
import bitcoinApi from './bitcoin-api-factory';
|
||||
import { Common } from '../common';
|
||||
import backendInfo from '../backend-info';
|
||||
import transactionUtils from '../transaction-utils';
|
||||
@@ -24,7 +24,6 @@ class BitcoinRoutes {
|
||||
public initRoutes(app: Application) {
|
||||
app
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'transaction-times', this.getTransactionTimes)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'outspends', this.$getBatchedOutspends)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'cpfp/:txId', this.$getCpfpInfo)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'difficulty-adjustment', this.getDifficultyChange)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'fees/recommended', this.getRecommendedFees)
|
||||
@@ -32,8 +31,10 @@ class BitcoinRoutes {
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'backend-info', this.getBackendInfo)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'init-data', this.getInitData)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'validate-address/:address', this.validateAddress)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/replaces', this.getRbfHistory)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/rbf', this.getRbfHistory)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/cached', this.getCachedTx)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'replacements', this.getRbfReplacements)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'fullrbf/replacements', this.getFullRbfReplacements)
|
||||
.post(config.MEMPOOL.API_URL_PREFIX + 'tx/push', this.$postTransactionForm)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'donations', async (req, res) => {
|
||||
try {
|
||||
@@ -94,6 +95,7 @@ class BitcoinRoutes {
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash', this.getBlock)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/summary', this.getStrippedBlockTransactions)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/audit-summary', this.getBlockAuditSummary)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks/tip/height', this.getBlockTipHeight)
|
||||
.post(config.MEMPOOL.API_URL_PREFIX + 'psbt/addparents', this.postPsbtCompletion)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from', this.getBlocksByBulk.bind(this))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks-bulk/:from/:to', this.getBlocksByBulk.bind(this))
|
||||
@@ -109,8 +111,8 @@ class BitcoinRoutes {
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/hex', this.getRawTransaction)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/status', this.getTransactionStatus)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/outspends', this.getTransactionOutspends)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'txs/outspends', this.$getBatchedOutspends)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/header', this.getBlockHeader)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks/tip/height', this.getBlockTipHeight)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks/tip/hash', this.getBlockTipHash)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/raw', this.getRawBlock)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/txids', this.getTxIdsForBlock)
|
||||
@@ -119,7 +121,8 @@ class BitcoinRoutes {
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'block-height/:height', this.getBlockHeight)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'address/:address', this.getAddress)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'address/:address/txs', this.getAddressTransactions)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'address/:address/txs/chain/:txId', this.getAddressTransactions)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'scripthash/:scripthash', this.getScriptHash)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'scripthash/:scripthash/txs', this.getScriptHashTransactions)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'address-prefix/:prefix', this.getAddressPrefix)
|
||||
;
|
||||
}
|
||||
@@ -128,8 +131,9 @@ class BitcoinRoutes {
|
||||
|
||||
private getInitData(req: Request, res: Response) {
|
||||
try {
|
||||
const result = websocketHandler.getInitData();
|
||||
res.json(result);
|
||||
const result = websocketHandler.getSerializedInitData();
|
||||
res.set('Content-Type', 'application/json');
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
@@ -170,24 +174,20 @@ class BitcoinRoutes {
|
||||
res.json(times);
|
||||
}
|
||||
|
||||
private async $getBatchedOutspends(req: Request, res: Response) {
|
||||
if (!Array.isArray(req.query.txId)) {
|
||||
res.status(500).send('Not an array');
|
||||
private async $getBatchedOutspends(req: Request, res: Response): Promise<IEsploraApi.Outspend[][] | void> {
|
||||
const txids_csv = req.query.txids;
|
||||
if (!txids_csv || typeof txids_csv !== 'string') {
|
||||
res.status(500).send('Invalid txids format');
|
||||
return;
|
||||
}
|
||||
if (req.query.txId.length > 50) {
|
||||
const txids = txids_csv.split(',');
|
||||
if (txids.length > 50) {
|
||||
res.status(400).send('Too many txids requested');
|
||||
return;
|
||||
}
|
||||
const txIds: string[] = [];
|
||||
for (const _txId in req.query.txId) {
|
||||
if (typeof req.query.txId[_txId] === 'string') {
|
||||
txIds.push(req.query.txId[_txId].toString());
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const batchedOutspends = await bitcoinApi.$getBatchedOutspends(txIds);
|
||||
const batchedOutspends = await bitcoinApi.$getBatchedOutspends(txids);
|
||||
res.json(batchedOutspends);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
@@ -208,6 +208,9 @@ class BitcoinRoutes {
|
||||
bestDescendant: tx.bestDescendant || null,
|
||||
descendants: tx.descendants || null,
|
||||
effectiveFeePerVsize: tx.effectiveFeePerVsize || null,
|
||||
sigops: tx.sigops,
|
||||
adjustedVsize: tx.adjustedVsize,
|
||||
acceleration: tx.acceleration
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -219,7 +222,12 @@ class BitcoinRoutes {
|
||||
} else {
|
||||
let cpfpInfo;
|
||||
if (config.DATABASE.ENABLED) {
|
||||
cpfpInfo = await transactionRepository.$getCpfpInfo(req.params.txId);
|
||||
try {
|
||||
cpfpInfo = await transactionRepository.$getCpfpInfo(req.params.txId);
|
||||
} catch (e) {
|
||||
res.status(500).send('failed to get CPFP info');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (cpfpInfo) {
|
||||
res.json(cpfpInfo);
|
||||
@@ -239,7 +247,7 @@ class BitcoinRoutes {
|
||||
|
||||
private async getTransaction(req: Request, res: Response) {
|
||||
try {
|
||||
const transaction = await transactionUtils.$getTransactionExtended(req.params.txId, true);
|
||||
const transaction = await transactionUtils.$getTransactionExtended(req.params.txId, true, false, false, true);
|
||||
res.json(transaction);
|
||||
} catch (e) {
|
||||
let statusCode = 500;
|
||||
@@ -389,9 +397,13 @@ class BitcoinRoutes {
|
||||
|
||||
private async getBlockAuditSummary(req: Request, res: Response) {
|
||||
try {
|
||||
const transactions = await blocks.$getBlockAuditSummary(req.params.hash);
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString());
|
||||
res.json(transactions);
|
||||
const auditSummary = await blocks.$getBlockAuditSummary(req.params.hash);
|
||||
if (auditSummary) {
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString());
|
||||
res.json(auditSummary);
|
||||
} else {
|
||||
return res.status(404).send(`audit not available`);
|
||||
}
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
@@ -462,13 +474,13 @@ class BitcoinRoutes {
|
||||
}
|
||||
|
||||
let nextHash = startFromHash;
|
||||
for (let i = 0; i < 10 && nextHash; i++) {
|
||||
for (let i = 0; i < 15 && nextHash; i++) {
|
||||
const localBlock = blocks.getBlocks().find((b) => b.id === nextHash);
|
||||
if (localBlock) {
|
||||
returnBlocks.push(localBlock);
|
||||
nextHash = localBlock.previousblockhash;
|
||||
} else {
|
||||
const block = await bitcoinCoreApi.$getBlock(nextHash);
|
||||
const block = await bitcoinApi.$getBlock(nextHash);
|
||||
returnBlocks.push(block);
|
||||
nextHash = block.previousblockhash;
|
||||
}
|
||||
@@ -532,15 +544,39 @@ class BitcoinRoutes {
|
||||
}
|
||||
}
|
||||
|
||||
private async getAddressTransactions(req: Request, res: Response) {
|
||||
private async getAddressTransactions(req: Request, res: Response): Promise<void> {
|
||||
if (config.MEMPOOL.BACKEND === 'none') {
|
||||
res.status(405).send('Address lookups cannot be used with bitcoind as backend.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const transactions = await bitcoinApi.$getAddressTransactions(req.params.address, req.params.txId);
|
||||
let lastTxId: string = '';
|
||||
if (req.query.after_txid && typeof req.query.after_txid === 'string') {
|
||||
lastTxId = req.query.after_txid;
|
||||
}
|
||||
const transactions = await bitcoinApi.$getAddressTransactions(req.params.address, lastTxId);
|
||||
res.json(transactions);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) {
|
||||
res.status(413).send(e instanceof Error ? e.message : e);
|
||||
return;
|
||||
}
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async getScriptHash(req: Request, res: Response) {
|
||||
if (config.MEMPOOL.BACKEND === 'none') {
|
||||
res.status(405).send('Address lookups cannot be used with bitcoind as backend.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// electrum expects scripthashes in little-endian
|
||||
const electrumScripthash = req.params.scripthash.match(/../g)?.reverse().join('') ?? '';
|
||||
const addressData = await bitcoinApi.$getScriptHash(electrumScripthash);
|
||||
res.json(addressData);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) {
|
||||
return res.status(413).send(e instanceof Error ? e.message : e);
|
||||
@@ -549,8 +585,28 @@ class BitcoinRoutes {
|
||||
}
|
||||
}
|
||||
|
||||
private async getAdressTxChain(req: Request, res: Response) {
|
||||
res.status(501).send('Not implemented');
|
||||
private async getScriptHashTransactions(req: Request, res: Response): Promise<void> {
|
||||
if (config.MEMPOOL.BACKEND === 'none') {
|
||||
res.status(405).send('Address lookups cannot be used with bitcoind as backend.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// electrum expects scripthashes in little-endian
|
||||
const electrumScripthash = req.params.scripthash.match(/../g)?.reverse().join('') ?? '';
|
||||
let lastTxId: string = '';
|
||||
if (req.query.after_txid && typeof req.query.after_txid === 'string') {
|
||||
lastTxId = req.query.after_txid;
|
||||
}
|
||||
const transactions = await bitcoinApi.$getScriptHashTransactions(electrumScripthash, lastTxId);
|
||||
res.json(transactions);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) {
|
||||
res.status(413).send(e instanceof Error ? e.message : e);
|
||||
return;
|
||||
}
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async getAddressPrefix(req: Request, res: Response) {
|
||||
@@ -589,10 +645,14 @@ class BitcoinRoutes {
|
||||
}
|
||||
}
|
||||
|
||||
private async getBlockTipHeight(req: Request, res: Response) {
|
||||
private getBlockTipHeight(req: Request, res: Response) {
|
||||
try {
|
||||
const result = await bitcoinApi.$getBlockHeightTip();
|
||||
res.json(result);
|
||||
const result = blocks.getCurrentBlockHeight();
|
||||
if (!result) {
|
||||
return res.status(503).send(`Service Temporarily Unavailable`);
|
||||
}
|
||||
res.setHeader('content-type', 'text/plain');
|
||||
res.send(result.toString());
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
@@ -638,8 +698,30 @@ class BitcoinRoutes {
|
||||
|
||||
private async getRbfHistory(req: Request, res: Response) {
|
||||
try {
|
||||
const result = rbfCache.getReplaces(req.params.txId);
|
||||
res.json(result || []);
|
||||
const replacements = rbfCache.getRbfTree(req.params.txId) || null;
|
||||
const replaces = rbfCache.getReplaces(req.params.txId) || null;
|
||||
res.json({
|
||||
replacements,
|
||||
replaces
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async getRbfReplacements(req: Request, res: Response) {
|
||||
try {
|
||||
const result = rbfCache.getRbfTrees(false);
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async getFullRbfReplacements(req: Request, res: Response) {
|
||||
try {
|
||||
const result = rbfCache.getRbfTrees(true);
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
@@ -683,12 +765,7 @@ class BitcoinRoutes {
|
||||
private async $postTransaction(req: Request, res: Response) {
|
||||
res.setHeader('content-type', 'text/plain');
|
||||
try {
|
||||
let rawTx;
|
||||
if (typeof req.body === 'object') {
|
||||
rawTx = Object.keys(req.body)[0];
|
||||
} else {
|
||||
rawTx = req.body;
|
||||
}
|
||||
const rawTx = Common.getTransactionFromRequest(req, false);
|
||||
const txIdResult = await bitcoinApi.$sendRawTransaction(rawTx);
|
||||
res.send(txIdResult);
|
||||
} catch (e: any) {
|
||||
@@ -699,12 +776,8 @@ class BitcoinRoutes {
|
||||
|
||||
private async $postTransactionForm(req: Request, res: Response) {
|
||||
res.setHeader('content-type', 'text/plain');
|
||||
const matches = /tx=([a-z0-9]+)/.exec(req.body);
|
||||
let txHex = '';
|
||||
if (matches && matches[1]) {
|
||||
txHex = matches[1];
|
||||
}
|
||||
try {
|
||||
const txHex = Common.getTransactionFromRequest(req, true);
|
||||
const txIdResult = await bitcoinClient.sendRawTransaction(txHex);
|
||||
res.send(txIdResult);
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -16,7 +16,7 @@ class BitcoindElectrsApi extends BitcoinApi implements AbstractBitcoinApi {
|
||||
super(bitcoinClient);
|
||||
|
||||
const electrumConfig = { client: 'mempool-v2', version: '1.4' };
|
||||
const electrumPersistencePolicy = { retryPeriod: 10000, maxRetry: 1000, callback: null };
|
||||
const electrumPersistencePolicy = { retryPeriod: 1000, maxRetry: Number.MAX_SAFE_INTEGER, callback: null };
|
||||
|
||||
const electrumCallbacks = {
|
||||
onConnect: (client, versionInfo) => { logger.info(`Connected to Electrum Server at ${config.ELECTRUM.HOST}:${config.ELECTRUM.PORT} (${JSON.stringify(versionInfo)})`); },
|
||||
@@ -126,6 +126,77 @@ class BitcoindElectrsApi extends BitcoinApi implements AbstractBitcoinApi {
|
||||
}
|
||||
}
|
||||
|
||||
async $getScriptHash(scripthash: string): Promise<IEsploraApi.ScriptHash> {
|
||||
try {
|
||||
const balance = await this.electrumClient.blockchainScripthash_getBalance(scripthash);
|
||||
let history = memoryCache.get<IElectrumApi.ScriptHashHistory[]>('Scripthash_getHistory', scripthash);
|
||||
if (!history) {
|
||||
history = await this.electrumClient.blockchainScripthash_getHistory(scripthash);
|
||||
memoryCache.set('Scripthash_getHistory', scripthash, history, 2);
|
||||
}
|
||||
|
||||
const unconfirmed = history ? history.filter((h) => h.fee).length : 0;
|
||||
|
||||
return {
|
||||
'scripthash': scripthash,
|
||||
'chain_stats': {
|
||||
'funded_txo_count': 0,
|
||||
'funded_txo_sum': balance.confirmed ? balance.confirmed : 0,
|
||||
'spent_txo_count': 0,
|
||||
'spent_txo_sum': balance.confirmed < 0 ? balance.confirmed : 0,
|
||||
'tx_count': (history?.length || 0) - unconfirmed,
|
||||
},
|
||||
'mempool_stats': {
|
||||
'funded_txo_count': 0,
|
||||
'funded_txo_sum': balance.unconfirmed > 0 ? balance.unconfirmed : 0,
|
||||
'spent_txo_count': 0,
|
||||
'spent_txo_sum': balance.unconfirmed < 0 ? -balance.unconfirmed : 0,
|
||||
'tx_count': unconfirmed,
|
||||
},
|
||||
'electrum': true,
|
||||
};
|
||||
} catch (e: any) {
|
||||
throw new Error(typeof e === 'string' ? e : e && e.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
async $getScriptHashTransactions(scripthash: string, lastSeenTxId?: string): Promise<IEsploraApi.Transaction[]> {
|
||||
try {
|
||||
loadingIndicators.setProgress('address-' + scripthash, 0);
|
||||
|
||||
const transactions: IEsploraApi.Transaction[] = [];
|
||||
let history = memoryCache.get<IElectrumApi.ScriptHashHistory[]>('Scripthash_getHistory', scripthash);
|
||||
if (!history) {
|
||||
history = await this.electrumClient.blockchainScripthash_getHistory(scripthash);
|
||||
memoryCache.set('Scripthash_getHistory', scripthash, history, 2);
|
||||
}
|
||||
if (!history) {
|
||||
throw new Error('failed to get scripthash history');
|
||||
}
|
||||
history.sort((a, b) => (b.height || 9999999) - (a.height || 9999999));
|
||||
|
||||
let startingIndex = 0;
|
||||
if (lastSeenTxId) {
|
||||
const pos = history.findIndex((historicalTx) => historicalTx.tx_hash === lastSeenTxId);
|
||||
if (pos) {
|
||||
startingIndex = pos + 1;
|
||||
}
|
||||
}
|
||||
const endIndex = Math.min(startingIndex + 10, history.length);
|
||||
|
||||
for (let i = startingIndex; i < endIndex; i++) {
|
||||
const tx = await this.$getRawTransaction(history[i].tx_hash, false, true);
|
||||
transactions.push(tx);
|
||||
loadingIndicators.setProgress('address-' + scripthash, (i + 1) / endIndex * 100);
|
||||
}
|
||||
|
||||
return transactions;
|
||||
} catch (e: any) {
|
||||
loadingIndicators.setProgress('address-' + scripthash, 100);
|
||||
throw new Error(typeof e === 'string' ? e : e && e.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
private $getScriptHashBalance(scriptHash: string): Promise<IElectrumApi.ScriptHashBalance> {
|
||||
return this.electrumClient.blockchainScripthash_getBalance(this.encodeScriptHash(scriptHash));
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export namespace IEsploraApi {
|
||||
size: number;
|
||||
weight: number;
|
||||
fee: number;
|
||||
sigops?: number;
|
||||
vin: Vin[];
|
||||
vout: Vout[];
|
||||
status: Status;
|
||||
@@ -89,6 +90,7 @@ export namespace IEsploraApi {
|
||||
weight: number;
|
||||
previousblockhash: string;
|
||||
mediantime: number;
|
||||
stale: boolean;
|
||||
}
|
||||
|
||||
export interface Address {
|
||||
@@ -98,6 +100,13 @@ export namespace IEsploraApi {
|
||||
electrum?: boolean;
|
||||
}
|
||||
|
||||
export interface ScriptHash {
|
||||
scripthash: string;
|
||||
chain_stats: ChainStats;
|
||||
mempool_stats: MempoolStats;
|
||||
electrum?: boolean;
|
||||
}
|
||||
|
||||
export interface ChainStats {
|
||||
funded_txo_count: number;
|
||||
funded_txo_sum: number;
|
||||
|
||||
@@ -1,67 +1,297 @@
|
||||
import config from '../../config';
|
||||
import axios, { AxiosRequestConfig } from 'axios';
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import http from 'http';
|
||||
import { AbstractBitcoinApi } from './bitcoin-api-abstract-factory';
|
||||
import { IEsploraApi } from './esplora-api.interface';
|
||||
import logger from '../../logger';
|
||||
import { Common } from '../common';
|
||||
|
||||
const axiosConnection = axios.create({
|
||||
httpAgent: new http.Agent({ keepAlive: true })
|
||||
});
|
||||
interface FailoverHost {
|
||||
host: string,
|
||||
rtts: number[],
|
||||
rtt: number,
|
||||
failures: number,
|
||||
latestHeight?: number,
|
||||
socket?: boolean,
|
||||
outOfSync?: boolean,
|
||||
unreachable?: boolean,
|
||||
preferred?: boolean,
|
||||
checked: boolean,
|
||||
}
|
||||
|
||||
class FailoverRouter {
|
||||
activeHost: FailoverHost;
|
||||
fallbackHost: FailoverHost;
|
||||
maxHeight: number = 0;
|
||||
hosts: FailoverHost[];
|
||||
multihost: boolean;
|
||||
pollInterval: number = 60000;
|
||||
pollTimer: NodeJS.Timeout | null = null;
|
||||
pollConnection = axios.create();
|
||||
requestConnection = axios.create({
|
||||
httpAgent: new http.Agent({ keepAlive: true })
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// setup list of hosts
|
||||
this.hosts = (config.ESPLORA.FALLBACK || []).map(domain => {
|
||||
return {
|
||||
host: domain,
|
||||
checked: false,
|
||||
rtts: [],
|
||||
rtt: Infinity,
|
||||
failures: 0,
|
||||
};
|
||||
});
|
||||
this.activeHost = {
|
||||
host: config.ESPLORA.UNIX_SOCKET_PATH || config.ESPLORA.REST_API_URL,
|
||||
rtts: [],
|
||||
rtt: 0,
|
||||
failures: 0,
|
||||
socket: !!config.ESPLORA.UNIX_SOCKET_PATH,
|
||||
preferred: true,
|
||||
checked: false,
|
||||
};
|
||||
this.fallbackHost = this.activeHost;
|
||||
this.hosts.unshift(this.activeHost);
|
||||
this.multihost = this.hosts.length > 1;
|
||||
}
|
||||
|
||||
public startHealthChecks(): void {
|
||||
// use axios interceptors to measure request rtt
|
||||
this.pollConnection.interceptors.request.use((config) => {
|
||||
config['meta'] = { startTime: Date.now() };
|
||||
return config;
|
||||
});
|
||||
this.pollConnection.interceptors.response.use((response) => {
|
||||
response.config['meta'].rtt = Date.now() - response.config['meta'].startTime;
|
||||
return response;
|
||||
});
|
||||
|
||||
if (this.multihost) {
|
||||
this.pollHosts();
|
||||
}
|
||||
}
|
||||
|
||||
// start polling hosts to measure availability & rtt
|
||||
private async pollHosts(): Promise<void> {
|
||||
if (this.pollTimer) {
|
||||
clearTimeout(this.pollTimer);
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
// update rtts & sync status
|
||||
for (const host of this.hosts) {
|
||||
try {
|
||||
const result = await (host.socket
|
||||
? this.pollConnection.get<number>('/blocks/tip/height', { socketPath: host.host, timeout: config.ESPLORA.FALLBACK_TIMEOUT })
|
||||
: this.pollConnection.get<number>(host.host + '/blocks/tip/height', { timeout: config.ESPLORA.FALLBACK_TIMEOUT })
|
||||
);
|
||||
if (result) {
|
||||
const height = result.data;
|
||||
this.maxHeight = Math.max(height, this.maxHeight);
|
||||
const rtt = result.config['meta'].rtt;
|
||||
host.rtts.unshift(rtt);
|
||||
host.rtts.slice(0, 5);
|
||||
host.rtt = host.rtts.reduce((acc, l) => acc + l, 0) / host.rtts.length;
|
||||
host.latestHeight = height;
|
||||
if (height == null || isNaN(height) || (this.maxHeight - height > 2)) {
|
||||
host.outOfSync = true;
|
||||
} else {
|
||||
host.outOfSync = false;
|
||||
}
|
||||
host.unreachable = false;
|
||||
} else {
|
||||
host.outOfSync = true;
|
||||
host.unreachable = true;
|
||||
host.rtts = [];
|
||||
host.rtt = Infinity;
|
||||
}
|
||||
} catch (e) {
|
||||
host.outOfSync = true;
|
||||
host.unreachable = true;
|
||||
host.rtts = [];
|
||||
host.rtt = Infinity;
|
||||
}
|
||||
host.checked = true;
|
||||
|
||||
|
||||
// switch if the current host is out of sync or significantly slower than the next best alternative
|
||||
const rankOrder = this.sortHosts();
|
||||
// switch if the current host is out of sync or significantly slower than the next best alternative
|
||||
if (this.activeHost.outOfSync || this.activeHost.unreachable || (this.activeHost !== rankOrder[0] && rankOrder[0].preferred) || (!this.activeHost.preferred && this.activeHost.rtt > (rankOrder[0].rtt * 2) + 50)) {
|
||||
if (this.activeHost.unreachable) {
|
||||
logger.warn(`🚨🚨🚨 Unable to reach ${this.activeHost.host}, failing over to next best alternative 🚨🚨🚨`);
|
||||
} else if (this.activeHost.outOfSync) {
|
||||
logger.warn(`🚨🚨🚨 ${this.activeHost.host} has fallen behind, failing over to next best alternative 🚨🚨🚨`);
|
||||
} else {
|
||||
logger.debug(`🛠️ ${this.activeHost.host} is no longer the best esplora host 🛠️`);
|
||||
}
|
||||
this.electHost();
|
||||
}
|
||||
await Common.sleep$(50);
|
||||
}
|
||||
|
||||
const rankOrder = this.updateFallback();
|
||||
logger.debug(`Tomahawk ranking:\n${rankOrder.map((host, index) => this.formatRanking(index, host, this.activeHost, this.maxHeight)).join('\n')}`);
|
||||
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
this.pollTimer = setTimeout(() => { this.pollHosts(); }, Math.max(1, this.pollInterval - elapsed));
|
||||
}
|
||||
|
||||
private formatRanking(index: number, host: FailoverHost, active: FailoverHost, maxHeight: number): string {
|
||||
const heightStatus = !host.checked ? '⏳' : (host.outOfSync ? '🚫' : (host.latestHeight && host.latestHeight < maxHeight ? '🟧' : '✅'));
|
||||
return `${host === active ? '⭐️' : ' '} ${host.rtt < Infinity ? Math.round(host.rtt).toString().padStart(5, ' ') + 'ms' : ' - '} ${!host.checked ? '⏳' : (host.unreachable ? '🔥' : '✅')} | block: ${host.latestHeight || '??????'} ${heightStatus} | ${host.host} ${host === active ? '⭐️' : ' '}`;
|
||||
}
|
||||
|
||||
private updateFallback(): FailoverHost[] {
|
||||
const rankOrder = this.sortHosts();
|
||||
if (rankOrder.length > 1 && rankOrder[0] === this.activeHost) {
|
||||
this.fallbackHost = rankOrder[1];
|
||||
} else {
|
||||
this.fallbackHost = rankOrder[0];
|
||||
}
|
||||
return rankOrder;
|
||||
}
|
||||
|
||||
// sort hosts by connection quality, and update default fallback
|
||||
private sortHosts(): FailoverHost[] {
|
||||
// sort by connection quality
|
||||
return this.hosts.slice().sort((a, b) => {
|
||||
if ((a.unreachable || a.outOfSync) === (b.unreachable || b.outOfSync)) {
|
||||
if (a.preferred === b.preferred) {
|
||||
// lower rtt is best
|
||||
return a.rtt - b.rtt;
|
||||
} else { // unless we have a preferred host
|
||||
return a.preferred ? -1 : 1;
|
||||
}
|
||||
} else { // or the host is out of sync
|
||||
return (a.unreachable || a.outOfSync) ? 1 : -1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// depose the active host and choose the next best replacement
|
||||
private electHost(): void {
|
||||
this.activeHost.outOfSync = true;
|
||||
this.activeHost.failures = 0;
|
||||
const rankOrder = this.sortHosts();
|
||||
this.activeHost = rankOrder[0];
|
||||
logger.warn(`Switching esplora host to ${this.activeHost.host}`);
|
||||
}
|
||||
|
||||
private addFailure(host: FailoverHost): FailoverHost {
|
||||
host.failures++;
|
||||
if (host.failures > 5 && this.multihost) {
|
||||
logger.warn(`🚨🚨🚨 Too many esplora failures on ${this.activeHost.host}, falling back to next best alternative 🚨🚨🚨`);
|
||||
this.electHost();
|
||||
return this.activeHost;
|
||||
} else {
|
||||
return this.fallbackHost;
|
||||
}
|
||||
}
|
||||
|
||||
private async $query<T>(method: 'get'| 'post', path, data: any, responseType = 'json', host = this.activeHost, retry: boolean = true): Promise<T> {
|
||||
let axiosConfig;
|
||||
let url;
|
||||
if (host.socket) {
|
||||
axiosConfig = { socketPath: host.host, timeout: config.ESPLORA.REQUEST_TIMEOUT, responseType };
|
||||
url = path;
|
||||
} else {
|
||||
axiosConfig = { timeout: config.ESPLORA.REQUEST_TIMEOUT, responseType };
|
||||
url = host.host + path;
|
||||
}
|
||||
if (data?.params) {
|
||||
axiosConfig.params = data.params;
|
||||
}
|
||||
return (method === 'post'
|
||||
? this.requestConnection.post<T>(url, data, axiosConfig)
|
||||
: this.requestConnection.get<T>(url, axiosConfig)
|
||||
).then((response) => { host.failures = Math.max(0, host.failures - 1); return response.data; })
|
||||
.catch((e) => {
|
||||
let fallbackHost = this.fallbackHost;
|
||||
if (e?.response?.status !== 404) {
|
||||
logger.warn(`esplora request failed ${e?.response?.status} ${host.host}${path}`);
|
||||
logger.warn(e instanceof Error ? e.message : e);
|
||||
fallbackHost = this.addFailure(host);
|
||||
}
|
||||
if (retry && e?.code === 'ECONNREFUSED' && this.multihost) {
|
||||
// Retry immediately
|
||||
return this.$query(method, path, data, responseType, fallbackHost, false);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async $get<T>(path, responseType = 'json', params: any = null): Promise<T> {
|
||||
return this.$query<T>('get', path, params ? { params } : null, responseType);
|
||||
}
|
||||
|
||||
public async $post<T>(path, data: any, responseType = 'json'): Promise<T> {
|
||||
return this.$query<T>('post', path, data, responseType);
|
||||
}
|
||||
}
|
||||
|
||||
class ElectrsApi implements AbstractBitcoinApi {
|
||||
axiosConfig: AxiosRequestConfig = {
|
||||
timeout: 10000,
|
||||
};
|
||||
|
||||
constructor() { }
|
||||
private failoverRouter = new FailoverRouter();
|
||||
|
||||
$getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]> {
|
||||
return axiosConnection.get<IEsploraApi.Transaction['txid'][]>(config.ESPLORA.REST_API_URL + '/mempool/txids', this.axiosConfig)
|
||||
.then((response) => response.data);
|
||||
return this.failoverRouter.$get<IEsploraApi.Transaction['txid'][]>('/mempool/txids');
|
||||
}
|
||||
|
||||
$getRawTransaction(txId: string): Promise<IEsploraApi.Transaction> {
|
||||
return axiosConnection.get<IEsploraApi.Transaction>(config.ESPLORA.REST_API_URL + '/tx/' + txId, this.axiosConfig)
|
||||
.then((response) => response.data);
|
||||
return this.failoverRouter.$get<IEsploraApi.Transaction>('/tx/' + txId);
|
||||
}
|
||||
|
||||
async $getRawTransactions(txids: string[]): Promise<IEsploraApi.Transaction[]> {
|
||||
return this.failoverRouter.$post<IEsploraApi.Transaction[]>('/internal/txs', txids, 'json');
|
||||
}
|
||||
|
||||
async $getMempoolTransactions(txids: string[]): Promise<IEsploraApi.Transaction[]> {
|
||||
return this.failoverRouter.$post<IEsploraApi.Transaction[]>('/internal/mempool/txs', txids, 'json');
|
||||
}
|
||||
|
||||
async $getAllMempoolTransactions(lastSeenTxid?: string, max_txs?: number): Promise<IEsploraApi.Transaction[]> {
|
||||
return this.failoverRouter.$get<IEsploraApi.Transaction[]>('/internal/mempool/txs' + (lastSeenTxid ? '/' + lastSeenTxid : ''), 'json', max_txs ? { max_txs } : null);
|
||||
}
|
||||
|
||||
$getTransactionHex(txId: string): Promise<string> {
|
||||
return axiosConnection.get<string>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/hex', this.axiosConfig)
|
||||
.then((response) => response.data);
|
||||
return this.failoverRouter.$get<string>('/tx/' + txId + '/hex');
|
||||
}
|
||||
|
||||
$getBlockHeightTip(): Promise<number> {
|
||||
return axiosConnection.get<number>(config.ESPLORA.REST_API_URL + '/blocks/tip/height', this.axiosConfig)
|
||||
.then((response) => response.data);
|
||||
return this.failoverRouter.$get<number>('/blocks/tip/height');
|
||||
}
|
||||
|
||||
$getBlockHashTip(): Promise<string> {
|
||||
return axiosConnection.get<string>(config.ESPLORA.REST_API_URL + '/blocks/tip/hash', this.axiosConfig)
|
||||
.then((response) => response.data);
|
||||
return this.failoverRouter.$get<string>('/blocks/tip/hash');
|
||||
}
|
||||
|
||||
$getTxIdsForBlock(hash: string): Promise<string[]> {
|
||||
return axiosConnection.get<string[]>(config.ESPLORA.REST_API_URL + '/block/' + hash + '/txids', this.axiosConfig)
|
||||
.then((response) => response.data);
|
||||
return this.failoverRouter.$get<string[]>('/block/' + hash + '/txids');
|
||||
}
|
||||
|
||||
$getTxsForBlock(hash: string): Promise<IEsploraApi.Transaction[]> {
|
||||
return this.failoverRouter.$get<IEsploraApi.Transaction[]>('/internal/block/' + hash + '/txs');
|
||||
}
|
||||
|
||||
$getBlockHash(height: number): Promise<string> {
|
||||
return axiosConnection.get<string>(config.ESPLORA.REST_API_URL + '/block-height/' + height, this.axiosConfig)
|
||||
.then((response) => response.data);
|
||||
return this.failoverRouter.$get<string>('/block-height/' + height);
|
||||
}
|
||||
|
||||
$getBlockHeader(hash: string): Promise<string> {
|
||||
return axiosConnection.get<string>(config.ESPLORA.REST_API_URL + '/block/' + hash + '/header', this.axiosConfig)
|
||||
.then((response) => response.data);
|
||||
return this.failoverRouter.$get<string>('/block/' + hash + '/header');
|
||||
}
|
||||
|
||||
$getBlock(hash: string): Promise<IEsploraApi.Block> {
|
||||
return axiosConnection.get<IEsploraApi.Block>(config.ESPLORA.REST_API_URL + '/block/' + hash, this.axiosConfig)
|
||||
.then((response) => response.data);
|
||||
return this.failoverRouter.$get<IEsploraApi.Block>('/block/' + hash);
|
||||
}
|
||||
|
||||
$getRawBlock(hash: string): Promise<Buffer> {
|
||||
return axiosConnection.get<string>(config.ESPLORA.REST_API_URL + '/block/' + hash + "/raw", { ...this.axiosConfig, responseType: 'arraybuffer' })
|
||||
return this.failoverRouter.$get<any>('/block/' + hash + '/raw', 'arraybuffer')
|
||||
.then((response) => { return Buffer.from(response.data); });
|
||||
}
|
||||
|
||||
@@ -73,6 +303,14 @@ class ElectrsApi implements AbstractBitcoinApi {
|
||||
throw new Error('Method getAddressTransactions not implemented.');
|
||||
}
|
||||
|
||||
$getScriptHash(scripthash: string): Promise<IEsploraApi.ScriptHash> {
|
||||
throw new Error('Method getScriptHash not implemented.');
|
||||
}
|
||||
|
||||
$getScriptHashTransactions(scripthash: string, txId?: string): Promise<IEsploraApi.Transaction[]> {
|
||||
throw new Error('Method getScriptHashTransactions not implemented.');
|
||||
}
|
||||
|
||||
$getAddressPrefix(prefix: string): string[] {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
@@ -82,22 +320,27 @@ class ElectrsApi implements AbstractBitcoinApi {
|
||||
}
|
||||
|
||||
$getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend> {
|
||||
return axiosConnection.get<IEsploraApi.Outspend>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspend/' + vout, this.axiosConfig)
|
||||
.then((response) => response.data);
|
||||
return this.failoverRouter.$get<IEsploraApi.Outspend>('/tx/' + txId + '/outspend/' + vout);
|
||||
}
|
||||
|
||||
$getOutspends(txId: string): Promise<IEsploraApi.Outspend[]> {
|
||||
return axiosConnection.get<IEsploraApi.Outspend[]>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspends', this.axiosConfig)
|
||||
.then((response) => response.data);
|
||||
return this.failoverRouter.$get<IEsploraApi.Outspend[]>('/tx/' + txId + '/outspends');
|
||||
}
|
||||
|
||||
async $getBatchedOutspends(txId: string[]): Promise<IEsploraApi.Outspend[][]> {
|
||||
const outspends: IEsploraApi.Outspend[][] = [];
|
||||
for (const tx of txId) {
|
||||
const outspend = await this.$getOutspends(tx);
|
||||
outspends.push(outspend);
|
||||
}
|
||||
return outspends;
|
||||
async $getBatchedOutspends(txids: string[]): Promise<IEsploraApi.Outspend[][]> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
async $getBatchedOutspendsInternal(txids: string[]): Promise<IEsploraApi.Outspend[][]> {
|
||||
return this.failoverRouter.$post<IEsploraApi.Outspend[][]>('/internal/txs/outspends/by-txid', txids, 'json');
|
||||
}
|
||||
|
||||
async $getOutSpendsByOutpoint(outpoints: { txid: string, vout: number }[]): Promise<IEsploraApi.Outspend[]> {
|
||||
return this.failoverRouter.$post<IEsploraApi.Outspend[]>('/internal/txs/outspends/by-outpoint', outpoints.map(out => `${out.txid}:${out.vout}`), 'json');
|
||||
}
|
||||
|
||||
public startHealthChecks(): void {
|
||||
this.failoverRouter.startHealthChecks();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,12 @@
|
||||
import { CpfpInfo, TransactionExtended, TransactionStripped } from '../mempool.interfaces';
|
||||
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 config from '../config';
|
||||
import { NodeSocket } from '../repositories/NodesSocketsRepository';
|
||||
import { isIP } from 'net';
|
||||
import transactionUtils from './transaction-utils';
|
||||
import { isPoint } from '../utils/secp256k1';
|
||||
import logger from '../logger';
|
||||
export class Common {
|
||||
static nativeAssetId = config.MEMPOOL.NETWORK === 'liquidtestnet' ?
|
||||
'144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49'
|
||||
@@ -57,35 +62,331 @@ export class Common {
|
||||
return arr;
|
||||
}
|
||||
|
||||
static findRbfTransactions(added: TransactionExtended[], deleted: TransactionExtended[]): { [txid: string]: TransactionExtended } {
|
||||
const matches: { [txid: string]: TransactionExtended } = {};
|
||||
deleted
|
||||
.forEach((deletedTx) => {
|
||||
const foundMatches = added.find((addedTx) => {
|
||||
static findRbfTransactions(added: MempoolTransactionExtended[], deleted: MempoolTransactionExtended[], forceScalable = false): { [txid: string]: MempoolTransactionExtended[] } {
|
||||
const matches: { [txid: string]: MempoolTransactionExtended[] } = {};
|
||||
|
||||
// For small N, a naive nested loop is extremely fast, but it doesn't scale
|
||||
if (added.length < 1000 && deleted.length < 50 && !forceScalable) {
|
||||
added.forEach((addedTx) => {
|
||||
const foundMatches = deleted.filter((deletedTx) => {
|
||||
// The new tx must, absolutely speaking, pay at least as much fee as the replaced tx.
|
||||
return addedTx.fee > deletedTx.fee
|
||||
// The new transaction must pay more fee per kB than the replaced tx.
|
||||
&& addedTx.feePerVsize > deletedTx.feePerVsize
|
||||
&& addedTx.adjustedFeePerVsize > deletedTx.adjustedFeePerVsize
|
||||
// Spends one or more of the same inputs
|
||||
&& deletedTx.vin.some((deletedVin) =>
|
||||
addedTx.vin.some((vin) => vin.txid === deletedVin.txid && vin.vout === deletedVin.vout));
|
||||
});
|
||||
if (foundMatches) {
|
||||
matches[deletedTx.txid] = foundMatches;
|
||||
if (foundMatches?.length) {
|
||||
matches[addedTx.txid] = [...new Set(foundMatches)];
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// for large N, build a lookup table of prevouts we can check in ~constant time
|
||||
const deletedSpendMap: { [txid: string]: { [vout: number]: MempoolTransactionExtended } } = {};
|
||||
for (const tx of deleted) {
|
||||
for (const vin of tx.vin) {
|
||||
if (!deletedSpendMap[vin.txid]) {
|
||||
deletedSpendMap[vin.txid] = {};
|
||||
}
|
||||
deletedSpendMap[vin.txid][vin.vout] = tx;
|
||||
}
|
||||
}
|
||||
|
||||
for (const addedTx of added) {
|
||||
const foundMatches = new Set<MempoolTransactionExtended>();
|
||||
for (const vin of addedTx.vin) {
|
||||
const deletedTx = deletedSpendMap[vin.txid]?.[vin.vout];
|
||||
if (deletedTx && deletedTx.txid !== addedTx.txid
|
||||
// The new tx must, absolutely speaking, pay at least as much fee as the replaced tx.
|
||||
&& addedTx.fee > deletedTx.fee
|
||||
// The new transaction must pay more fee per kB than the replaced tx.
|
||||
&& addedTx.adjustedFeePerVsize > deletedTx.adjustedFeePerVsize
|
||||
) {
|
||||
foundMatches.add(deletedTx);
|
||||
}
|
||||
if (foundMatches.size) {
|
||||
matches[addedTx.txid] = [...foundMatches];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
static findMinedRbfTransactions(minedTransactions: TransactionExtended[], spendMap: Map<string, MempoolTransactionExtended>): { [txid: string]: { replaced: MempoolTransactionExtended[], replacedBy: TransactionExtended }} {
|
||||
const matches: { [txid: string]: { replaced: MempoolTransactionExtended[], replacedBy: TransactionExtended }} = {};
|
||||
for (const tx of minedTransactions) {
|
||||
const replaced: Set<MempoolTransactionExtended> = new Set();
|
||||
for (let i = 0; i < tx.vin.length; i++) {
|
||||
const vin = tx.vin[i];
|
||||
const match = spendMap.get(`${vin.txid}:${vin.vout}`);
|
||||
if (match && match.txid !== tx.txid) {
|
||||
replaced.add(match);
|
||||
// remove this tx from the spendMap
|
||||
// prevents the same tx being replaced more than once
|
||||
for (const replacedVin of match.vin) {
|
||||
const key = `${replacedVin.txid}:${replacedVin.vout}`;
|
||||
spendMap.delete(key);
|
||||
}
|
||||
}
|
||||
const key = `${vin.txid}:${vin.vout}`;
|
||||
spendMap.delete(key);
|
||||
}
|
||||
if (replaced.size) {
|
||||
matches[tx.txid] = { replaced: Array.from(replaced), replacedBy: tx };
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
static setSchnorrSighashFlags(flags: bigint, witness: string[]): bigint {
|
||||
// no witness items
|
||||
if (!witness?.length) {
|
||||
return flags;
|
||||
}
|
||||
const hasAnnex = witness.length > 1 && witness[witness.length - 1].startsWith('50');
|
||||
if (witness?.length === (hasAnnex ? 2 : 1)) {
|
||||
// keypath spend, signature is the only witness item
|
||||
if (witness[0].length === 130) {
|
||||
flags |= this.setSighashFlags(flags, witness[0]);
|
||||
} else {
|
||||
flags |= TransactionFlags.sighash_default;
|
||||
}
|
||||
} else {
|
||||
// scriptpath spend, all items except for the script, control block and annex could be signatures
|
||||
for (let i = 0; i < witness.length - (hasAnnex ? 3 : 2); i++) {
|
||||
// handle probable signatures
|
||||
if (witness[i].length === 130) {
|
||||
flags |= this.setSighashFlags(flags, witness[i]);
|
||||
} else if (witness[i].length === 128) {
|
||||
flags |= TransactionFlags.sighash_default;
|
||||
}
|
||||
}
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
static isDERSig(w: string): boolean {
|
||||
// heuristic to detect probable DER signatures
|
||||
return (w.length >= 18
|
||||
&& w.startsWith('30') // minimum DER signature length is 8 bytes + sighash flag (see https://mempool.space/testnet/tx/c6c232a36395fa338da458b86ff1327395a9afc28c5d2daa4273e410089fd433)
|
||||
&& ['01', '02', '03', '81', '82', '83'].includes(w.slice(-2)) // signature must end with a valid sighash flag
|
||||
&& (w.length === (2 * parseInt(w.slice(2, 4), 16)) + 6) // second byte encodes the combined length of the R and S components
|
||||
);
|
||||
}
|
||||
|
||||
static setSegwitSighashFlags(flags: bigint, witness: string[]): bigint {
|
||||
for (const w of witness) {
|
||||
if (this.isDERSig(w)) {
|
||||
flags |= this.setSighashFlags(flags, w);
|
||||
}
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
static setLegacySighashFlags(flags: bigint, scriptsig_asm: string): bigint {
|
||||
for (const item of scriptsig_asm.split(' ')) {
|
||||
// skip op_codes
|
||||
if (item.startsWith('OP_')) {
|
||||
continue;
|
||||
}
|
||||
// check pushed data
|
||||
if (this.isDERSig(item)) {
|
||||
flags |= this.setSighashFlags(flags, item);
|
||||
}
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
static setSighashFlags(flags: bigint, signature: string): bigint {
|
||||
switch(signature.slice(-2)) {
|
||||
case '01': return flags | TransactionFlags.sighash_all;
|
||||
case '02': return flags | TransactionFlags.sighash_none;
|
||||
case '03': return flags | TransactionFlags.sighash_single;
|
||||
case '81': return flags | TransactionFlags.sighash_all | TransactionFlags.sighash_acp;
|
||||
case '82': return flags | TransactionFlags.sighash_none | TransactionFlags.sighash_acp;
|
||||
case '83': return flags | TransactionFlags.sighash_single | TransactionFlags.sighash_acp;
|
||||
default: return flags | TransactionFlags.sighash_default; // taproot only
|
||||
}
|
||||
}
|
||||
|
||||
static isBurnKey(pubkey: string): boolean {
|
||||
return [
|
||||
'022222222222222222222222222222222222222222222222222222222222222222',
|
||||
'033333333333333333333333333333333333333333333333333333333333333333',
|
||||
'020202020202020202020202020202020202020202020202020202020202020202',
|
||||
'030303030303030303030303030303030303030303030303030303030303030303',
|
||||
].includes(pubkey);
|
||||
}
|
||||
|
||||
static getTransactionFlags(tx: TransactionExtended): number {
|
||||
let flags = tx.flags ? BigInt(tx.flags) : 0n;
|
||||
|
||||
// Update variable flags (CPFP, RBF)
|
||||
if (tx.ancestors?.length) {
|
||||
flags |= TransactionFlags.cpfp_child;
|
||||
}
|
||||
if (tx.descendants?.length) {
|
||||
flags |= TransactionFlags.cpfp_parent;
|
||||
}
|
||||
if (tx.replacement) {
|
||||
flags |= TransactionFlags.replacement;
|
||||
}
|
||||
|
||||
// Already processed static flags, no need to do it again
|
||||
if (tx.flags) {
|
||||
return Number(flags);
|
||||
}
|
||||
|
||||
// Process static flags
|
||||
if (tx.version === 1) {
|
||||
flags |= TransactionFlags.v1;
|
||||
} else if (tx.version === 2) {
|
||||
flags |= TransactionFlags.v2;
|
||||
} else if (tx.version === 3) {
|
||||
flags |= TransactionFlags.v3;
|
||||
}
|
||||
const reusedInputAddresses: { [address: string ]: number } = {};
|
||||
const reusedOutputAddresses: { [address: string ]: number } = {};
|
||||
const inValues = {};
|
||||
const outValues = {};
|
||||
let rbf = false;
|
||||
for (const vin of tx.vin) {
|
||||
if (vin.sequence < 0xfffffffe) {
|
||||
rbf = true;
|
||||
}
|
||||
switch (vin.prevout?.scriptpubkey_type) {
|
||||
case 'p2pk': flags |= TransactionFlags.p2pk; break;
|
||||
case 'multisig': flags |= TransactionFlags.p2ms; break;
|
||||
case 'p2pkh': flags |= TransactionFlags.p2pkh; break;
|
||||
case 'p2sh': flags |= TransactionFlags.p2sh; break;
|
||||
case 'v0_p2wpkh': flags |= TransactionFlags.p2wpkh; break;
|
||||
case 'v0_p2wsh': flags |= TransactionFlags.p2wsh; break;
|
||||
case 'v1_p2tr': {
|
||||
if (!vin.witness?.length) {
|
||||
throw new Error('Taproot input missing witness data');
|
||||
}
|
||||
flags |= TransactionFlags.p2tr;
|
||||
// in taproot, if the last witness item begins with 0x50, it's an annex
|
||||
const hasAnnex = vin.witness?.[vin.witness.length - 1].startsWith('50');
|
||||
// script spends have more than one witness item, not counting the annex (if present)
|
||||
if (vin.witness.length > (hasAnnex ? 2 : 1)) {
|
||||
// the script itself is the second-to-last witness item, not counting the annex
|
||||
const asm = vin.inner_witnessscript_asm || transactionUtils.convertScriptSigAsm(vin.witness[vin.witness.length - (hasAnnex ? 3 : 2)]);
|
||||
// inscriptions smuggle data within an 'OP_0 OP_IF ... OP_ENDIF' envelope
|
||||
if (asm?.includes('OP_0 OP_IF')) {
|
||||
flags |= TransactionFlags.inscription;
|
||||
}
|
||||
}
|
||||
} break;
|
||||
}
|
||||
|
||||
// sighash flags
|
||||
if (vin.prevout?.scriptpubkey_type === 'v1_p2tr') {
|
||||
flags |= this.setSchnorrSighashFlags(flags, vin.witness);
|
||||
} else if (vin.witness) {
|
||||
flags |= this.setSegwitSighashFlags(flags, vin.witness);
|
||||
} else if (vin.scriptsig?.length) {
|
||||
flags |= this.setLegacySighashFlags(flags, vin.scriptsig_asm || transactionUtils.convertScriptSigAsm(vin.scriptsig));
|
||||
}
|
||||
|
||||
if (vin.prevout?.scriptpubkey_address) {
|
||||
reusedInputAddresses[vin.prevout?.scriptpubkey_address] = (reusedInputAddresses[vin.prevout?.scriptpubkey_address] || 0) + 1;
|
||||
}
|
||||
inValues[vin.prevout?.value || Math.random()] = (inValues[vin.prevout?.value || Math.random()] || 0) + 1;
|
||||
}
|
||||
if (rbf) {
|
||||
flags |= TransactionFlags.rbf;
|
||||
} else {
|
||||
flags |= TransactionFlags.no_rbf;
|
||||
}
|
||||
let hasFakePubkey = false;
|
||||
for (const vout of tx.vout) {
|
||||
switch (vout.scriptpubkey_type) {
|
||||
case 'p2pk': {
|
||||
flags |= TransactionFlags.p2pk;
|
||||
// detect fake pubkey (i.e. not a valid DER point on the secp256k1 curve)
|
||||
hasFakePubkey = hasFakePubkey || !isPoint(vout.scriptpubkey?.slice(2, -2));
|
||||
} break;
|
||||
case 'multisig': {
|
||||
flags |= TransactionFlags.p2ms;
|
||||
// detect fake pubkeys (i.e. not valid DER points on the secp256k1 curve)
|
||||
const asm = vout.scriptpubkey_asm || transactionUtils.convertScriptSigAsm(vout.scriptpubkey);
|
||||
for (const key of (asm?.split(' ') || [])) {
|
||||
if (!hasFakePubkey && !key.startsWith('OP_')) {
|
||||
hasFakePubkey = hasFakePubkey || this.isBurnKey(key) || !isPoint(key);
|
||||
}
|
||||
}
|
||||
} break;
|
||||
case 'p2pkh': flags |= TransactionFlags.p2pkh; break;
|
||||
case 'p2sh': flags |= TransactionFlags.p2sh; break;
|
||||
case 'v0_p2wpkh': flags |= TransactionFlags.p2wpkh; break;
|
||||
case 'v0_p2wsh': flags |= TransactionFlags.p2wsh; break;
|
||||
case 'v1_p2tr': flags |= TransactionFlags.p2tr; break;
|
||||
case 'op_return': flags |= TransactionFlags.op_return; break;
|
||||
}
|
||||
if (vout.scriptpubkey_address) {
|
||||
reusedOutputAddresses[vout.scriptpubkey_address] = (reusedOutputAddresses[vout.scriptpubkey_address] || 0) + 1;
|
||||
}
|
||||
outValues[vout.value || Math.random()] = (outValues[vout.value || Math.random()] || 0) + 1;
|
||||
}
|
||||
if (hasFakePubkey) {
|
||||
flags |= TransactionFlags.fake_pubkey;
|
||||
}
|
||||
|
||||
// fast but bad heuristic to detect possible coinjoins
|
||||
// (at least 5 inputs and 5 outputs, less than half of which are unique amounts, with no address reuse)
|
||||
const addressReuse = Object.keys(reusedOutputAddresses).reduce((acc, key) => Math.max(acc, (reusedInputAddresses[key] || 0) + (reusedOutputAddresses[key] || 0)), 0) > 1;
|
||||
if (!addressReuse && tx.vin.length >= 5 && tx.vout.length >= 5 && (Object.keys(inValues).length + Object.keys(outValues).length) <= (tx.vin.length + tx.vout.length) / 2 ) {
|
||||
flags |= TransactionFlags.coinjoin;
|
||||
}
|
||||
// more than 5:1 input:output ratio
|
||||
if (tx.vin.length / tx.vout.length >= 5) {
|
||||
flags |= TransactionFlags.consolidation;
|
||||
}
|
||||
// less than 1:5 input:output ratio
|
||||
if (tx.vin.length / tx.vout.length <= 0.2) {
|
||||
flags |= TransactionFlags.batch_payout;
|
||||
}
|
||||
|
||||
return Number(flags);
|
||||
}
|
||||
|
||||
static classifyTransaction(tx: TransactionExtended): TransactionClassified {
|
||||
let flags = 0;
|
||||
try {
|
||||
flags = Common.getTransactionFlags(tx);
|
||||
} catch (e) {
|
||||
logger.warn('Failed to add classification flags to transaction: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
tx.flags = flags;
|
||||
return {
|
||||
...Common.stripTransaction(tx),
|
||||
flags,
|
||||
};
|
||||
}
|
||||
|
||||
static classifyTransactions(txs: TransactionExtended[]): TransactionClassified[] {
|
||||
return txs.map(Common.classifyTransaction);
|
||||
}
|
||||
|
||||
static stripTransaction(tx: TransactionExtended): TransactionStripped {
|
||||
return {
|
||||
txid: tx.txid,
|
||||
fee: tx.fee,
|
||||
fee: tx.fee || 0,
|
||||
vsize: tx.weight / 4,
|
||||
value: tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0),
|
||||
acc: tx.acceleration || undefined,
|
||||
rate: tx.effectiveFeePerVsize,
|
||||
};
|
||||
}
|
||||
|
||||
static stripTransactions(txs: TransactionExtended[]): TransactionStripped[] {
|
||||
return txs.map(Common.stripTransaction);
|
||||
}
|
||||
|
||||
static sleep$(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
@@ -101,18 +402,18 @@ export class Common {
|
||||
}
|
||||
}
|
||||
|
||||
static setRelativesAndGetCpfpInfo(tx: TransactionExtended, memPool: { [txid: string]: TransactionExtended }): CpfpInfo {
|
||||
static setRelativesAndGetCpfpInfo(tx: MempoolTransactionExtended, memPool: { [txid: string]: MempoolTransactionExtended }): CpfpInfo {
|
||||
const parents = this.findAllParents(tx, memPool);
|
||||
const lowerFeeParents = parents.filter((parent) => parent.feePerVsize < tx.effectiveFeePerVsize);
|
||||
const lowerFeeParents = parents.filter((parent) => parent.adjustedFeePerVsize < tx.effectiveFeePerVsize);
|
||||
|
||||
let totalWeight = tx.weight + lowerFeeParents.reduce((prev, val) => prev + val.weight, 0);
|
||||
let totalWeight = (tx.adjustedVsize * 4) + lowerFeeParents.reduce((prev, val) => prev + (val.adjustedVsize * 4), 0);
|
||||
let totalFees = tx.fee + lowerFeeParents.reduce((prev, val) => prev + val.fee, 0);
|
||||
|
||||
tx.ancestors = parents
|
||||
.map((t) => {
|
||||
return {
|
||||
txid: t.txid,
|
||||
weight: t.weight,
|
||||
weight: (t.adjustedVsize * 4),
|
||||
fee: t.fee,
|
||||
};
|
||||
});
|
||||
@@ -133,8 +434,8 @@ export class Common {
|
||||
}
|
||||
|
||||
|
||||
private static findAllParents(tx: TransactionExtended, memPool: { [txid: string]: TransactionExtended }): TransactionExtended[] {
|
||||
let parents: TransactionExtended[] = [];
|
||||
private static findAllParents(tx: MempoolTransactionExtended, memPool: { [txid: string]: MempoolTransactionExtended }): MempoolTransactionExtended[] {
|
||||
let parents: MempoolTransactionExtended[] = [];
|
||||
tx.vin.forEach((parent) => {
|
||||
if (parents.find((p) => p.txid === parent.txid)) {
|
||||
return;
|
||||
@@ -142,17 +443,17 @@ export class Common {
|
||||
|
||||
const parentTx = memPool[parent.txid];
|
||||
if (parentTx) {
|
||||
if (tx.bestDescendant && tx.bestDescendant.fee / (tx.bestDescendant.weight / 4) > parentTx.feePerVsize) {
|
||||
if (tx.bestDescendant && tx.bestDescendant.fee / (tx.bestDescendant.weight / 4) > parentTx.adjustedFeePerVsize) {
|
||||
if (parentTx.bestDescendant && parentTx.bestDescendant.fee < tx.fee + tx.bestDescendant.fee) {
|
||||
parentTx.bestDescendant = {
|
||||
weight: tx.weight + tx.bestDescendant.weight,
|
||||
weight: (tx.adjustedVsize * 4) + tx.bestDescendant.weight,
|
||||
fee: tx.fee + tx.bestDescendant.fee,
|
||||
txid: tx.txid,
|
||||
};
|
||||
}
|
||||
} else if (tx.feePerVsize > parentTx.feePerVsize) {
|
||||
} else if (tx.adjustedFeePerVsize > parentTx.adjustedFeePerVsize) {
|
||||
parentTx.bestDescendant = {
|
||||
weight: tx.weight,
|
||||
weight: (tx.adjustedVsize * 4),
|
||||
fee: tx.fee,
|
||||
txid: tx.txid
|
||||
};
|
||||
@@ -164,6 +465,30 @@ export class Common {
|
||||
return parents;
|
||||
}
|
||||
|
||||
// calculates the ratio of matched transactions to projected transactions by weight
|
||||
static getSimilarity(projectedBlock: MempoolBlockWithTransactions, transactions: TransactionExtended[]): number {
|
||||
let matchedWeight = 0;
|
||||
let projectedWeight = 0;
|
||||
const inBlock = {};
|
||||
|
||||
for (const tx of transactions) {
|
||||
inBlock[tx.txid] = tx;
|
||||
}
|
||||
|
||||
// look for transactions that were expected in the template, but missing from the mined block
|
||||
for (const tx of projectedBlock.transactions) {
|
||||
if (inBlock[tx.txid]) {
|
||||
matchedWeight += tx.vsize * 4;
|
||||
}
|
||||
projectedWeight += tx.vsize * 4;
|
||||
}
|
||||
|
||||
projectedWeight += transactions[0].weight;
|
||||
matchedWeight += transactions[0].weight;
|
||||
|
||||
return projectedWeight ? matchedWeight / projectedWeight : 1;
|
||||
}
|
||||
|
||||
static getSqlInterval(interval: string | null): string | null {
|
||||
switch (interval) {
|
||||
case '24h': return '1 DAY';
|
||||
@@ -195,6 +520,13 @@ export class Common {
|
||||
);
|
||||
}
|
||||
|
||||
static gogglesIndexingEnabled(): boolean {
|
||||
return (
|
||||
Common.blocksSummariesIndexingEnabled() &&
|
||||
config.MEMPOOL.GOGGLES_INDEXING === true
|
||||
);
|
||||
}
|
||||
|
||||
static cpfpIndexingEnabled(): boolean {
|
||||
return (
|
||||
Common.indexingEnabled() &&
|
||||
@@ -321,4 +653,373 @@ 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);
|
||||
|
||||
let weightCount = 0;
|
||||
let medianFee = 0;
|
||||
let medianWeight = 0;
|
||||
|
||||
// calculate the "medianFee" as the average fee rate of the middle 10000 weight units of transactions
|
||||
const leftBound = 1995000;
|
||||
const rightBound = 2005000;
|
||||
for (let i = 0; i < sortedTxs.length && weightCount < rightBound; i++) {
|
||||
const left = weightCount;
|
||||
const right = weightCount + sortedTxs[i].weight;
|
||||
if (right > leftBound) {
|
||||
const weight = Math.min(right, rightBound) - Math.max(left, leftBound);
|
||||
medianFee += (sortedTxs[i].rate * (weight / 4) );
|
||||
medianWeight += weight;
|
||||
}
|
||||
weightCount += sortedTxs[i].weight;
|
||||
}
|
||||
const medianFeeRate = medianWeight ? (medianFee / (medianWeight / 4)) : 0;
|
||||
|
||||
// minimum effective fee heuristic:
|
||||
// lowest of
|
||||
// a) the 1st percentile of effective fee rates
|
||||
// b) the minimum effective fee rate in the last 2% of transactions (in block order)
|
||||
const minFee = Math.min(
|
||||
Common.getNthPercentile(1, sortedTxs).rate,
|
||||
transactions.slice(-transactions.length / 50).reduce((min, tx) => { return Math.min(min, tx.effectiveFeePerVsize || ((tx.fee || 0) / (tx.weight / 4))); }, Infinity)
|
||||
);
|
||||
|
||||
// maximum effective fee heuristic:
|
||||
// highest of
|
||||
// a) the 99th percentile of effective fee rates
|
||||
// b) the maximum effective fee rate in the first 2% of transactions (in block order)
|
||||
const maxFee = Math.max(
|
||||
Common.getNthPercentile(99, sortedTxs).rate,
|
||||
transactions.slice(0, transactions.length / 50).reduce((max, tx) => { return Math.max(max, tx.effectiveFeePerVsize || ((tx.fee || 0) / (tx.weight / 4))); }, 0)
|
||||
);
|
||||
|
||||
return {
|
||||
medianFee: medianFeeRate,
|
||||
feeRange: [
|
||||
minFee,
|
||||
[10,25,50,75,90].map(n => Common.getNthPercentile(n, sortedTxs).rate),
|
||||
maxFee,
|
||||
].flat(),
|
||||
};
|
||||
}
|
||||
|
||||
static getNthPercentile(n: number, sortedDistribution: any[]): any {
|
||||
return sortedDistribution[Math.floor((sortedDistribution.length - 1) * (n / 100))];
|
||||
}
|
||||
|
||||
static getTransactionFromRequest(req: Request, form: boolean): string {
|
||||
let rawTx: any = typeof req.body === 'object' && form
|
||||
? Object.values(req.body)[0] as any
|
||||
: req.body;
|
||||
if (typeof rawTx !== 'string') {
|
||||
throw Object.assign(new Error('Non-string request body'), { code: -1 });
|
||||
}
|
||||
|
||||
// Support both upper and lower case hex
|
||||
// Support both txHash= Form and direct API POST
|
||||
const reg = form ? /^txHash=((?:[a-fA-F0-9]{2})+)$/ : /^((?:[a-fA-F0-9]{2})+)$/;
|
||||
const matches = reg.exec(rawTx);
|
||||
if (!matches || !matches[1]) {
|
||||
throw Object.assign(new Error('Non-hex request body'), { code: -2 });
|
||||
}
|
||||
|
||||
// Guaranteed to be a hex string of multiple of 2
|
||||
// Guaranteed to be lower case
|
||||
// Guaranteed to pass validation (see function below)
|
||||
return this.validateTransactionHex(matches[1].toLowerCase());
|
||||
}
|
||||
|
||||
private static validateTransactionHex(txhex: string): string {
|
||||
// Do not mutate txhex
|
||||
|
||||
// We assume txhex to be valid hex (output of getTransactionFromRequest above)
|
||||
|
||||
// Check 1: Valid transaction parse
|
||||
let tx: bitcoinjs.Transaction;
|
||||
try {
|
||||
tx = bitcoinjs.Transaction.fromHex(txhex);
|
||||
} catch(e) {
|
||||
throw Object.assign(new Error('Invalid transaction (could not parse)'), { code: -4 });
|
||||
}
|
||||
|
||||
// Check 2: Simple size check
|
||||
if (tx.weight() > config.MEMPOOL.MAX_PUSH_TX_SIZE_WEIGHT) {
|
||||
throw Object.assign(new Error(`Transaction too large (max ${config.MEMPOOL.MAX_PUSH_TX_SIZE_WEIGHT} weight units)`), { code: -3 });
|
||||
}
|
||||
|
||||
// Check 3: Check unreachable script in taproot (if not allowed)
|
||||
if (!config.MEMPOOL.ALLOW_UNREACHABLE) {
|
||||
tx.ins.forEach(input => {
|
||||
const witness = input.witness;
|
||||
// See BIP 341: Script validation rules
|
||||
const hasAnnex = witness.length >= 2 &&
|
||||
witness[witness.length - 1][0] === 0x50;
|
||||
const scriptSpendMinLength = hasAnnex ? 3 : 2;
|
||||
const maybeScriptSpend = witness.length >= scriptSpendMinLength;
|
||||
|
||||
if (maybeScriptSpend) {
|
||||
const controlBlock = witness[witness.length - scriptSpendMinLength + 1];
|
||||
if (controlBlock.length === 0 || !this.isValidLeafVersion(controlBlock[0])) {
|
||||
// Skip this input, it's not taproot
|
||||
return;
|
||||
}
|
||||
// Definitely taproot. Get script
|
||||
const script = witness[witness.length - scriptSpendMinLength];
|
||||
const decompiled = bitcoinjs.script.decompile(script);
|
||||
if (!decompiled || decompiled.length < 2) {
|
||||
// Skip this input
|
||||
return;
|
||||
}
|
||||
// Iterate up to second last (will look ahead 1 item)
|
||||
for (let i = 0; i < decompiled.length - 1; i++) {
|
||||
const first = decompiled[i];
|
||||
const second = decompiled[i + 1];
|
||||
if (
|
||||
first === bitcoinjs.opcodes.OP_FALSE &&
|
||||
second === bitcoinjs.opcodes.OP_IF
|
||||
) {
|
||||
throw Object.assign(new Error('Unreachable taproot scripts not allowed'), { code: -5 });
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Pass through the input string untouched
|
||||
return txhex;
|
||||
}
|
||||
|
||||
private static isValidLeafVersion(leafVersion: number): boolean {
|
||||
// See Note 7 in BIP341
|
||||
// https://github.com/bitcoin/bips/blob/66a1a8151021913047934ebab3f8883f2f8ca75b/bip-0341.mediawiki#cite_note-7
|
||||
// "What constraints are there on the leaf version?"
|
||||
|
||||
// Must be an integer between 0 and 255
|
||||
// Since we're parsing a byte
|
||||
if (Math.floor(leafVersion) !== leafVersion || leafVersion < 0 || leafVersion > 255) {
|
||||
return false;
|
||||
}
|
||||
// "the leaf version cannot be odd"
|
||||
if ((leafVersion & 0x01) === 1) {
|
||||
return false;
|
||||
}
|
||||
// "The values that comply to this rule are
|
||||
// the 32 even values between 0xc0 and 0xfe
|
||||
if (leafVersion >= 0xc0 && leafVersion <= 0xfe) {
|
||||
return true;
|
||||
}
|
||||
// and also 0x66, 0x7e, 0x80, 0x84, 0x96, 0x98, 0xba, 0xbc, 0xbe."
|
||||
if ([0x66, 0x7e, 0x80, 0x84, 0x96, 0x98, 0xba, 0xbc, 0xbe].includes(leafVersion)) {
|
||||
return true;
|
||||
}
|
||||
// Otherwise, invalid
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Class to calculate average fee rates of a list of transactions
|
||||
* at certain weight percentiles, in a single pass
|
||||
*
|
||||
* init with:
|
||||
* maxWeight - the total weight to measure percentiles relative to (e.g. 4MW for a single block)
|
||||
* percentileBandWidth - how many weight units to average over for each percentile (as a % of maxWeight)
|
||||
* percentiles - an array of weight percentiles to compute, in %
|
||||
*
|
||||
* then call .processNext(tx) for each transaction, in descending order
|
||||
*
|
||||
* retrieve the final results with .getFeeStats()
|
||||
*/
|
||||
export class OnlineFeeStatsCalculator {
|
||||
private maxWeight: number;
|
||||
private percentiles = [10,25,50,75,90];
|
||||
|
||||
private bandWidthPercent = 2;
|
||||
private bandWidth: number = 0;
|
||||
private bandIndex = 0;
|
||||
private leftBound = 0;
|
||||
private rightBound = 0;
|
||||
private inBand = false;
|
||||
private totalBandFee = 0;
|
||||
private totalBandWeight = 0;
|
||||
private minBandRate = Infinity;
|
||||
private maxBandRate = 0;
|
||||
|
||||
private feeRange: { avg: number, min: number, max: number }[] = [];
|
||||
private totalWeight: number = 0;
|
||||
|
||||
constructor (maxWeight: number, percentileBandWidth?: number, percentiles?: number[]) {
|
||||
this.maxWeight = maxWeight;
|
||||
if (percentiles && percentiles.length) {
|
||||
this.percentiles = percentiles;
|
||||
}
|
||||
if (percentileBandWidth != null) {
|
||||
this.bandWidthPercent = percentileBandWidth;
|
||||
}
|
||||
this.bandWidth = this.maxWeight * (this.bandWidthPercent / 100);
|
||||
// add min/max percentiles aligned to the ends of the range
|
||||
this.percentiles.unshift(this.bandWidthPercent / 2);
|
||||
this.percentiles.push(100 - (this.bandWidthPercent / 2));
|
||||
this.setNextBounds();
|
||||
}
|
||||
|
||||
processNext(tx: { weight: number, fee: number, effectiveFeePerVsize?: number, feePerVsize?: number, rate?: number, txid: string }): void {
|
||||
let left = this.totalWeight;
|
||||
const right = this.totalWeight + tx.weight;
|
||||
if (!this.inBand && right <= this.leftBound) {
|
||||
this.totalWeight += tx.weight;
|
||||
return;
|
||||
}
|
||||
|
||||
while (left < right) {
|
||||
if (right > this.leftBound) {
|
||||
this.inBand = true;
|
||||
const txRate = (tx.rate || tx.effectiveFeePerVsize || tx.feePerVsize || 0);
|
||||
const weight = Math.min(right, this.rightBound) - Math.max(left, this.leftBound);
|
||||
this.totalBandFee += (txRate * weight);
|
||||
this.totalBandWeight += weight;
|
||||
this.maxBandRate = Math.max(this.maxBandRate, txRate);
|
||||
this.minBandRate = Math.min(this.minBandRate, txRate);
|
||||
}
|
||||
left = Math.min(right, this.rightBound);
|
||||
|
||||
if (left >= this.rightBound) {
|
||||
this.inBand = false;
|
||||
const avgBandFeeRate = this.totalBandWeight ? (this.totalBandFee / this.totalBandWeight) : 0;
|
||||
this.feeRange.unshift({ avg: avgBandFeeRate, min: this.minBandRate, max: this.maxBandRate });
|
||||
this.bandIndex++;
|
||||
this.setNextBounds();
|
||||
this.totalBandFee = 0;
|
||||
this.totalBandWeight = 0;
|
||||
this.minBandRate = Infinity;
|
||||
this.maxBandRate = 0;
|
||||
}
|
||||
}
|
||||
this.totalWeight += tx.weight;
|
||||
}
|
||||
|
||||
private setNextBounds(): void {
|
||||
const nextPercentile = this.percentiles[this.bandIndex];
|
||||
if (nextPercentile != null) {
|
||||
this.leftBound = ((nextPercentile / 100) * this.maxWeight) - (this.bandWidth / 2);
|
||||
this.rightBound = this.leftBound + this.bandWidth;
|
||||
} else {
|
||||
this.leftBound = Infinity;
|
||||
this.rightBound = Infinity;
|
||||
}
|
||||
}
|
||||
|
||||
getRawFeeStats(): WorkingEffectiveFeeStats {
|
||||
if (this.totalBandWeight > 0) {
|
||||
const avgBandFeeRate = this.totalBandWeight ? (this.totalBandFee / this.totalBandWeight) : 0;
|
||||
this.feeRange.unshift({ avg: avgBandFeeRate, min: this.minBandRate, max: this.maxBandRate });
|
||||
}
|
||||
while (this.feeRange.length < this.percentiles.length) {
|
||||
this.feeRange.unshift({ avg: 0, min: 0, max: 0 });
|
||||
}
|
||||
return {
|
||||
minFee: this.feeRange[0].min,
|
||||
medianFee: this.feeRange[Math.floor(this.feeRange.length / 2)].avg,
|
||||
maxFee: this.feeRange[this.feeRange.length - 1].max,
|
||||
feeRange: this.feeRange.map(f => f.avg),
|
||||
};
|
||||
}
|
||||
|
||||
getFeeStats(): EffectiveFeeStats {
|
||||
const stats = this.getRawFeeStats();
|
||||
stats.feeRange[0] = stats.minFee;
|
||||
stats.feeRange[stats.feeRange.length - 1] = stats.maxFee;
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import cpfpRepository from '../repositories/CpfpRepository';
|
||||
import { RowDataPacket } from 'mysql2';
|
||||
|
||||
class DatabaseMigration {
|
||||
private static currentVersion = 58;
|
||||
private static currentVersion = 68;
|
||||
private queryTimeout = 3600_000;
|
||||
private statisticsAddedIndexed = false;
|
||||
private uniqueLogs: string[] = [];
|
||||
@@ -497,6 +497,7 @@ class DatabaseMigration {
|
||||
this.uniqueLog(logger.notice, this.blocksTruncatedMessage);
|
||||
await this.$executeQuery('DELETE FROM `pools`');
|
||||
await this.$executeQuery('ALTER TABLE pools AUTO_INCREMENT = 1');
|
||||
await this.$executeQuery(`UPDATE state SET string = NULL WHERE name = 'pools_json_sha'`);
|
||||
this.uniqueLog(logger.notice, '`pools` table has been truncated`');
|
||||
await this.updateToSchemaVersion(56);
|
||||
}
|
||||
@@ -510,6 +511,75 @@ class DatabaseMigration {
|
||||
// We only run some migration queries for this version
|
||||
await this.updateToSchemaVersion(58);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 59 && (config.MEMPOOL.NETWORK === 'signet' || config.MEMPOOL.NETWORK === 'testnet')) {
|
||||
// https://github.com/mempool/mempool/issues/3360
|
||||
await this.$executeQuery(`TRUNCATE prices`);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 60 && isBitcoin === true) {
|
||||
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD sigop_txs JSON DEFAULT "[]"');
|
||||
await this.updateToSchemaVersion(60);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 61 && isBitcoin === true) {
|
||||
// Break block templates into their own table
|
||||
if (! await this.$checkIfTableExists('blocks_templates')) {
|
||||
await this.$executeQuery('CREATE TABLE blocks_templates AS SELECT id, template FROM blocks_summaries WHERE template != "[]"');
|
||||
}
|
||||
await this.$executeQuery('ALTER TABLE blocks_templates MODIFY template JSON DEFAULT "[]"');
|
||||
await this.$executeQuery('ALTER TABLE blocks_templates ADD PRIMARY KEY (id)');
|
||||
await this.$executeQuery('ALTER TABLE blocks_summaries DROP COLUMN template');
|
||||
await this.updateToSchemaVersion(61);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 62 && isBitcoin === true) {
|
||||
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD expected_fees BIGINT UNSIGNED DEFAULT NULL');
|
||||
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD expected_weight BIGINT UNSIGNED DEFAULT NULL');
|
||||
await this.updateToSchemaVersion(62);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 63 && isBitcoin === true) {
|
||||
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD fullrbf_txs JSON DEFAULT "[]"');
|
||||
await this.updateToSchemaVersion(63);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 64 && isBitcoin === true) {
|
||||
await this.$executeQuery('ALTER TABLE `nodes` ADD features text NULL');
|
||||
await this.updateToSchemaVersion(64);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 65 && isBitcoin === true) {
|
||||
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD accelerated_txs JSON DEFAULT "[]"');
|
||||
await this.updateToSchemaVersion(65);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 66) {
|
||||
await this.$executeQuery('ALTER TABLE `statistics` ADD min_fee FLOAT UNSIGNED DEFAULT NULL');
|
||||
await this.updateToSchemaVersion(66);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 67 && isBitcoin === true) {
|
||||
await this.$executeQuery('ALTER TABLE `blocks_summaries` ADD version INT NOT NULL DEFAULT 0');
|
||||
await this.$executeQuery('ALTER TABLE `blocks_summaries` ADD INDEX `version` (`version`)');
|
||||
await this.$executeQuery('ALTER TABLE `blocks_templates` ADD version INT NOT NULL DEFAULT 0');
|
||||
await this.$executeQuery('ALTER TABLE `blocks_templates` ADD INDEX `version` (`version`)');
|
||||
await this.updateToSchemaVersion(67);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 68 && config.MEMPOOL.NETWORK === "liquid") {
|
||||
await this.$executeQuery('TRUNCATE TABLE elements_pegs');
|
||||
await this.$executeQuery('ALTER TABLE elements_pegs ADD PRIMARY KEY (txid, txindex);');
|
||||
await this.$executeQuery(`UPDATE state SET number = 0 WHERE name = 'last_elements_block';`);
|
||||
// Create the federation_addresses table and add the two Liquid Federation change addresses in
|
||||
await this.$executeQuery(this.getCreateFederationAddressesTableQuery(), await this.$checkIfTableExists('federation_addresses'));
|
||||
await this.$executeQuery(`INSERT INTO federation_addresses (bitcoinaddress) VALUES ('bc1qxvay4an52gcghxq5lavact7r6qe9l4laedsazz8fj2ee2cy47tlqff4aj4')`); // Federation change address
|
||||
await this.$executeQuery(`INSERT INTO federation_addresses (bitcoinaddress) VALUES ('3EiAcrzq1cELXScc98KeCswGWZaPGceT1d')`); // Federation change address
|
||||
// Create the federation_txos table that uses the federation_addresses table as a foreign key
|
||||
await this.$executeQuery(this.getCreateFederationTxosTableQuery(), await this.$checkIfTableExists('federation_txos'));
|
||||
await this.$executeQuery(`INSERT INTO state VALUES('last_bitcoin_block_audit', 0, NULL);`);
|
||||
await this.updateToSchemaVersion(68);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -757,6 +827,32 @@ class DatabaseMigration {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||
}
|
||||
|
||||
private getCreateFederationAddressesTableQuery(): string {
|
||||
return `CREATE TABLE IF NOT EXISTS federation_addresses (
|
||||
bitcoinaddress varchar(100) NOT NULL,
|
||||
PRIMARY KEY (bitcoinaddress)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||
}
|
||||
|
||||
private getCreateFederationTxosTableQuery(): string {
|
||||
return `CREATE TABLE IF NOT EXISTS federation_txos (
|
||||
txid varchar(65) NOT NULL,
|
||||
txindex int(11) NOT NULL,
|
||||
bitcoinaddress varchar(100) NOT NULL,
|
||||
amount bigint(20) unsigned NOT NULL,
|
||||
blocknumber int(11) unsigned NOT NULL,
|
||||
blocktime int(11) unsigned NOT NULL,
|
||||
unspent tinyint(1) NOT NULL,
|
||||
lastblockupdate int(11) unsigned NOT NULL,
|
||||
lasttimeupdate int(11) unsigned NOT NULL,
|
||||
pegtxid varchar(65) NOT NULL,
|
||||
pegindex int(11) NOT NULL,
|
||||
pegblocktime int(11) unsigned NOT NULL,
|
||||
PRIMARY KEY (txid, txindex),
|
||||
FOREIGN KEY (bitcoinaddress) REFERENCES federation_addresses (bitcoinaddress)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||
}
|
||||
|
||||
private getCreatePoolsTableQuery(): string {
|
||||
return `CREATE TABLE IF NOT EXISTS pools (
|
||||
id int(11) NOT NULL AUTO_INCREMENT,
|
||||
@@ -1028,7 +1124,7 @@ class DatabaseMigration {
|
||||
}
|
||||
|
||||
public async $blocksReindexingTruncate(): Promise<void> {
|
||||
logger.warn(`Truncating pools, blocks and hashrates for re-indexing (using '--reindex-blocks'). You can cancel this command within 5 seconds`);
|
||||
logger.warn(`Truncating pools, blocks, hashrates and difficulty_adjustments tables for re-indexing (using '--reindex-blocks'). You can cancel this command within 5 seconds`);
|
||||
await Common.sleep$(5000);
|
||||
|
||||
await this.$executeQuery(`TRUNCATE blocks`);
|
||||
|
||||
@@ -12,43 +12,117 @@ export interface DifficultyAdjustment {
|
||||
previousTime: number; // Unix time in ms
|
||||
nextRetargetHeight: number; // Block Height
|
||||
timeAvg: number; // Duration of time in ms
|
||||
adjustedTimeAvg; // Expected block interval with hashrate implied over last 504 blocks
|
||||
timeOffset: number; // (Testnet) Time since last block (cap @ 20min) in ms
|
||||
expectedBlocks: number; // Block count
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the difficulty increase/decrease by using the `bits` integer contained in two
|
||||
* block headers.
|
||||
*
|
||||
* Warning: Only compare `bits` from blocks in two adjacent difficulty periods. This code
|
||||
* assumes the maximum difference is x4 or /4 (as per the protocol) and will throw an
|
||||
* error if an exponent difference of 2 or more is seen.
|
||||
*
|
||||
* @param {number} oldBits The 32 bit `bits` integer from a block header.
|
||||
* @param {number} newBits The 32 bit `bits` integer from a block header in the next difficulty period.
|
||||
* @returns {number} A floating point decimal of the difficulty change from old to new.
|
||||
* (ie. 21.3 means 21.3% increase in difficulty, -21.3 is a 21.3% decrease in difficulty)
|
||||
*/
|
||||
export function calcBitsDifference(oldBits: number, newBits: number): number {
|
||||
// Must be
|
||||
// - integer
|
||||
// - highest exponent is 0x20, so max value (as integer) is 0x207fffff
|
||||
// - min value is 1 (exponent = 0)
|
||||
// - highest bit of the number-part is +- sign, it must not be 1
|
||||
const verifyBits = (bits: number): void => {
|
||||
if (
|
||||
Math.floor(bits) !== bits ||
|
||||
bits > 0x207fffff ||
|
||||
bits < 1 ||
|
||||
(bits & 0x00800000) !== 0 ||
|
||||
(bits & 0x007fffff) === 0
|
||||
) {
|
||||
throw new Error('Invalid bits');
|
||||
}
|
||||
};
|
||||
verifyBits(oldBits);
|
||||
verifyBits(newBits);
|
||||
|
||||
// No need to mask exponents because we checked the bounds above
|
||||
const oldExp = oldBits >> 24;
|
||||
const newExp = newBits >> 24;
|
||||
const oldNum = oldBits & 0x007fffff;
|
||||
const newNum = newBits & 0x007fffff;
|
||||
// The diff can only possibly be 1, 0, -1
|
||||
// (because maximum difficulty change is x4 or /4 (2 bits up or down))
|
||||
let result: number;
|
||||
switch (newExp - oldExp) {
|
||||
// New less than old, target lowered, difficulty increased
|
||||
case -1:
|
||||
result = ((oldNum << 8) * 100) / newNum - 100;
|
||||
break;
|
||||
// Same exponent, compare numbers as is.
|
||||
case 0:
|
||||
result = (oldNum * 100) / newNum - 100;
|
||||
break;
|
||||
// Old less than new, target raised, difficulty decreased
|
||||
case 1:
|
||||
result = (oldNum * 100) / (newNum << 8) - 100;
|
||||
break;
|
||||
default:
|
||||
throw new Error('Impossible exponent difference');
|
||||
}
|
||||
|
||||
// Min/Max values
|
||||
return result > 300 ? 300 : result < -75 ? -75 : result;
|
||||
}
|
||||
|
||||
export function calcDifficultyAdjustment(
|
||||
DATime: number,
|
||||
quarterEpochTime: number | null,
|
||||
nowSeconds: number,
|
||||
blockHeight: number,
|
||||
previousRetarget: number,
|
||||
network: string,
|
||||
latestBlockTimestamp: number,
|
||||
): DifficultyAdjustment {
|
||||
const ESTIMATE_LAG_BLOCKS = 146; // For first 7.2% of epoch, don't estimate.
|
||||
const EPOCH_BLOCK_LENGTH = 2016; // Bitcoin mainnet
|
||||
const BLOCK_SECONDS_TARGET = 600; // Bitcoin mainnet
|
||||
const TESTNET_MAX_BLOCK_SECONDS = 1200; // Bitcoin testnet
|
||||
|
||||
const diffSeconds = nowSeconds - DATime;
|
||||
const diffSeconds = Math.max(0, nowSeconds - DATime);
|
||||
const blocksInEpoch = (blockHeight >= 0) ? blockHeight % EPOCH_BLOCK_LENGTH : 0;
|
||||
const progressPercent = (blockHeight >= 0) ? blocksInEpoch / EPOCH_BLOCK_LENGTH * 100 : 100;
|
||||
const remainingBlocks = EPOCH_BLOCK_LENGTH - blocksInEpoch;
|
||||
const nextRetargetHeight = (blockHeight >= 0) ? blockHeight + remainingBlocks : 0;
|
||||
const expectedBlocks = diffSeconds / BLOCK_SECONDS_TARGET;
|
||||
const actualTimespan = (blocksInEpoch === 2015 ? latestBlockTimestamp : nowSeconds) - DATime;
|
||||
|
||||
let difficultyChange = 0;
|
||||
let timeAvgSecs = diffSeconds / blocksInEpoch;
|
||||
// Only calculate the estimate once we have 7.2% of blocks in current epoch
|
||||
if (blocksInEpoch >= ESTIMATE_LAG_BLOCKS) {
|
||||
difficultyChange = (BLOCK_SECONDS_TARGET / timeAvgSecs - 1) * 100;
|
||||
// Max increase is x4 (+300%)
|
||||
if (difficultyChange > 300) {
|
||||
difficultyChange = 300;
|
||||
}
|
||||
// Max decrease is /4 (-75%)
|
||||
if (difficultyChange < -75) {
|
||||
difficultyChange = -75;
|
||||
}
|
||||
let timeAvgSecs = blocksInEpoch ? diffSeconds / blocksInEpoch : BLOCK_SECONDS_TARGET;
|
||||
let adjustedTimeAvgSecs = timeAvgSecs;
|
||||
|
||||
// for the first 504 blocks of the epoch, calculate the expected avg block interval
|
||||
// from a sliding window over the last 504 blocks
|
||||
if (quarterEpochTime && blocksInEpoch < 503) {
|
||||
const timeLastEpoch = DATime - quarterEpochTime;
|
||||
const adjustedTimeLastEpoch = timeLastEpoch * (1 + (previousRetarget / 100));
|
||||
const adjustedTimeSpan = diffSeconds + adjustedTimeLastEpoch;
|
||||
adjustedTimeAvgSecs = adjustedTimeSpan / 503;
|
||||
difficultyChange = (BLOCK_SECONDS_TARGET / (adjustedTimeSpan / 504) - 1) * 100;
|
||||
} else {
|
||||
difficultyChange = (BLOCK_SECONDS_TARGET / (actualTimespan / (blocksInEpoch + 1)) - 1) * 100;
|
||||
}
|
||||
|
||||
// Max increase is x4 (+300%)
|
||||
if (difficultyChange > 300) {
|
||||
difficultyChange = 300;
|
||||
}
|
||||
// Max decrease is /4 (-75%)
|
||||
if (difficultyChange < -75) {
|
||||
difficultyChange = -75;
|
||||
}
|
||||
|
||||
// Testnet difficulty is set to 1 after 20 minutes of no blocks,
|
||||
@@ -66,7 +140,8 @@ export function calcDifficultyAdjustment(
|
||||
}
|
||||
|
||||
const timeAvg = Math.floor(timeAvgSecs * 1000);
|
||||
const remainingTime = remainingBlocks * timeAvg;
|
||||
const adjustedTimeAvg = Math.floor(adjustedTimeAvgSecs * 1000);
|
||||
const remainingTime = remainingBlocks * adjustedTimeAvg;
|
||||
const estimatedRetargetDate = remainingTime + nowSeconds * 1000;
|
||||
|
||||
return {
|
||||
@@ -79,6 +154,7 @@ export function calcDifficultyAdjustment(
|
||||
previousTime: DATime,
|
||||
nextRetargetHeight,
|
||||
timeAvg,
|
||||
adjustedTimeAvg,
|
||||
timeOffset,
|
||||
expectedBlocks,
|
||||
};
|
||||
@@ -95,9 +171,10 @@ class DifficultyAdjustmentApi {
|
||||
return null;
|
||||
}
|
||||
const nowSeconds = Math.floor(new Date().getTime() / 1000);
|
||||
const quarterEpochBlockTime = blocks.getQuarterEpochBlockTime();
|
||||
|
||||
return calcDifficultyAdjustment(
|
||||
DATime, nowSeconds, blockHeight, previousRetarget,
|
||||
DATime, quarterEpochBlockTime, nowSeconds, blockHeight, previousRetarget,
|
||||
config.MEMPOOL.NETWORK, latestBlock.timestamp
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,33 +7,39 @@ import logger from '../logger';
|
||||
import config from '../config';
|
||||
import { TransactionExtended } from '../mempool.interfaces';
|
||||
import { Common } from './common';
|
||||
import rbfCache from './rbf-cache';
|
||||
|
||||
class DiskCache {
|
||||
private cacheSchemaVersion = 3;
|
||||
private rbfCacheSchemaVersion = 1;
|
||||
|
||||
private static TMP_FILE_NAME = config.MEMPOOL.CACHE_DIR + '/tmp-cache.json';
|
||||
private static TMP_FILE_NAMES = config.MEMPOOL.CACHE_DIR + '/tmp-cache{number}.json';
|
||||
private static FILE_NAME = config.MEMPOOL.CACHE_DIR + '/cache.json';
|
||||
private static FILE_NAMES = config.MEMPOOL.CACHE_DIR + '/cache{number}.json';
|
||||
private static TMP_RBF_FILE_NAME = config.MEMPOOL.CACHE_DIR + '/tmp-rbfcache.json';
|
||||
private static RBF_FILE_NAME = config.MEMPOOL.CACHE_DIR + '/rbfcache.json';
|
||||
private static CHUNK_FILES = 25;
|
||||
private isWritingCache = false;
|
||||
private ignoreBlocksCache = false;
|
||||
|
||||
private semaphore: { resume: (() => void)[], locks: number } = {
|
||||
resume: [],
|
||||
locks: 0,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
if (!cluster.isMaster) {
|
||||
if (!cluster.isPrimary || !config.MEMPOOL.CACHE_ENABLED) {
|
||||
return;
|
||||
}
|
||||
process.on('SIGINT', (e) => {
|
||||
this.saveCacheToDiskSync();
|
||||
process.exit(2);
|
||||
});
|
||||
process.on('SIGTERM', (e) => {
|
||||
this.saveCacheToDiskSync();
|
||||
process.exit(2);
|
||||
this.$saveCacheToDisk(true);
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
async $saveCacheToDisk(): Promise<void> {
|
||||
if (!cluster.isPrimary) {
|
||||
async $saveCacheToDisk(sync: boolean = false): Promise<void> {
|
||||
if (!cluster.isPrimary || !config.MEMPOOL.CACHE_ENABLED) {
|
||||
return;
|
||||
}
|
||||
if (this.isWritingCache) {
|
||||
@@ -41,87 +47,95 @@ class DiskCache {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
logger.debug('Writing mempool and blocks data to disk cache (async)...');
|
||||
logger.debug(`Writing mempool and blocks data to disk cache (${ sync ? 'sync' : 'async' })...`);
|
||||
this.isWritingCache = true;
|
||||
|
||||
const mempool = memPool.getMempool();
|
||||
const mempoolArray: TransactionExtended[] = [];
|
||||
for (const tx in mempool) {
|
||||
mempoolArray.push(mempool[tx]);
|
||||
if (mempool[tx]) {
|
||||
mempoolArray.push(mempool[tx]);
|
||||
}
|
||||
}
|
||||
|
||||
Common.shuffleArray(mempoolArray);
|
||||
|
||||
const chunkSize = Math.floor(mempoolArray.length / DiskCache.CHUNK_FILES);
|
||||
|
||||
await fsPromises.writeFile(DiskCache.FILE_NAME, JSON.stringify({
|
||||
network: config.MEMPOOL.NETWORK,
|
||||
cacheSchemaVersion: this.cacheSchemaVersion,
|
||||
blocks: blocks.getBlocks(),
|
||||
blockSummaries: blocks.getBlockSummaries(),
|
||||
mempool: {},
|
||||
mempoolArray: mempoolArray.splice(0, chunkSize),
|
||||
}), { flag: 'w' });
|
||||
for (let i = 1; i < DiskCache.CHUNK_FILES; i++) {
|
||||
await fsPromises.writeFile(DiskCache.FILE_NAMES.replace('{number}', i.toString()), JSON.stringify({
|
||||
if (sync) {
|
||||
fs.writeFileSync(DiskCache.TMP_FILE_NAME, JSON.stringify({
|
||||
network: config.MEMPOOL.NETWORK,
|
||||
cacheSchemaVersion: this.cacheSchemaVersion,
|
||||
blocks: blocks.getBlocks(),
|
||||
blockSummaries: blocks.getBlockSummaries(),
|
||||
mempool: {},
|
||||
mempoolArray: mempoolArray.splice(0, chunkSize),
|
||||
}), { flag: 'w' });
|
||||
for (let i = 1; i < DiskCache.CHUNK_FILES; i++) {
|
||||
fs.writeFileSync(DiskCache.TMP_FILE_NAMES.replace('{number}', i.toString()), JSON.stringify({
|
||||
mempool: {},
|
||||
mempoolArray: mempoolArray.splice(0, chunkSize),
|
||||
}), { flag: 'w' });
|
||||
}
|
||||
|
||||
fs.renameSync(DiskCache.TMP_FILE_NAME, DiskCache.FILE_NAME);
|
||||
for (let i = 1; i < DiskCache.CHUNK_FILES; i++) {
|
||||
fs.renameSync(DiskCache.TMP_FILE_NAMES.replace('{number}', i.toString()), DiskCache.FILE_NAMES.replace('{number}', i.toString()));
|
||||
}
|
||||
} else {
|
||||
await this.$yield();
|
||||
await fsPromises.writeFile(DiskCache.TMP_FILE_NAME, JSON.stringify({
|
||||
network: config.MEMPOOL.NETWORK,
|
||||
cacheSchemaVersion: this.cacheSchemaVersion,
|
||||
blocks: blocks.getBlocks(),
|
||||
blockSummaries: blocks.getBlockSummaries(),
|
||||
mempool: {},
|
||||
mempoolArray: mempoolArray.splice(0, chunkSize),
|
||||
}), { flag: 'w' });
|
||||
for (let i = 1; i < DiskCache.CHUNK_FILES; i++) {
|
||||
await this.$yield();
|
||||
await fsPromises.writeFile(DiskCache.TMP_FILE_NAMES.replace('{number}', i.toString()), JSON.stringify({
|
||||
mempool: {},
|
||||
mempoolArray: mempoolArray.splice(0, chunkSize),
|
||||
}), { flag: 'w' });
|
||||
}
|
||||
|
||||
await fsPromises.rename(DiskCache.TMP_FILE_NAME, DiskCache.FILE_NAME);
|
||||
for (let i = 1; i < DiskCache.CHUNK_FILES; i++) {
|
||||
await fsPromises.rename(DiskCache.TMP_FILE_NAMES.replace('{number}', i.toString()), DiskCache.FILE_NAMES.replace('{number}', i.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('Mempool and blocks data saved to disk cache');
|
||||
this.isWritingCache = false;
|
||||
} catch (e) {
|
||||
logger.warn('Error writing to cache file: ' + (e instanceof Error ? e.message : e));
|
||||
this.isWritingCache = false;
|
||||
}
|
||||
}
|
||||
|
||||
saveCacheToDiskSync(): void {
|
||||
if (!cluster.isPrimary) {
|
||||
return;
|
||||
}
|
||||
if (this.isWritingCache) {
|
||||
logger.debug('Saving cache already in progress. Skipping.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
logger.debug('Writing mempool and blocks data to disk cache (sync)...');
|
||||
logger.debug('Writing rbf data to disk cache (async)...');
|
||||
this.isWritingCache = true;
|
||||
|
||||
const mempool = memPool.getMempool();
|
||||
const mempoolArray: TransactionExtended[] = [];
|
||||
for (const tx in mempool) {
|
||||
mempoolArray.push(mempool[tx]);
|
||||
}
|
||||
|
||||
Common.shuffleArray(mempoolArray);
|
||||
|
||||
const chunkSize = Math.floor(mempoolArray.length / DiskCache.CHUNK_FILES);
|
||||
|
||||
fs.writeFileSync(DiskCache.TMP_FILE_NAME, JSON.stringify({
|
||||
network: config.MEMPOOL.NETWORK,
|
||||
cacheSchemaVersion: this.cacheSchemaVersion,
|
||||
blocks: blocks.getBlocks(),
|
||||
blockSummaries: blocks.getBlockSummaries(),
|
||||
mempool: {},
|
||||
mempoolArray: mempoolArray.splice(0, chunkSize),
|
||||
}), { flag: 'w' });
|
||||
for (let i = 1; i < DiskCache.CHUNK_FILES; i++) {
|
||||
fs.writeFileSync(DiskCache.TMP_FILE_NAMES.replace('{number}', i.toString()), JSON.stringify({
|
||||
mempool: {},
|
||||
mempoolArray: mempoolArray.splice(0, chunkSize),
|
||||
const rbfData = rbfCache.dump();
|
||||
if (sync) {
|
||||
fs.writeFileSync(DiskCache.TMP_RBF_FILE_NAME, JSON.stringify({
|
||||
network: config.MEMPOOL.NETWORK,
|
||||
rbfCacheSchemaVersion: this.rbfCacheSchemaVersion,
|
||||
rbf: rbfData,
|
||||
}), { flag: 'w' });
|
||||
fs.renameSync(DiskCache.TMP_RBF_FILE_NAME, DiskCache.RBF_FILE_NAME);
|
||||
} else {
|
||||
await fsPromises.writeFile(DiskCache.TMP_RBF_FILE_NAME, JSON.stringify({
|
||||
network: config.MEMPOOL.NETWORK,
|
||||
rbfCacheSchemaVersion: this.rbfCacheSchemaVersion,
|
||||
rbf: rbfData,
|
||||
}), { flag: 'w' });
|
||||
await fsPromises.rename(DiskCache.TMP_RBF_FILE_NAME, DiskCache.RBF_FILE_NAME);
|
||||
}
|
||||
|
||||
fs.renameSync(DiskCache.TMP_FILE_NAME, DiskCache.FILE_NAME);
|
||||
for (let i = 1; i < DiskCache.CHUNK_FILES; i++) {
|
||||
fs.renameSync(DiskCache.TMP_FILE_NAMES.replace('{number}', i.toString()), DiskCache.FILE_NAMES.replace('{number}', i.toString()));
|
||||
}
|
||||
|
||||
logger.debug('Mempool and blocks data saved to disk cache');
|
||||
logger.debug('Rbf data saved to disk cache');
|
||||
this.isWritingCache = false;
|
||||
} catch (e) {
|
||||
logger.warn('Error writing to cache file: ' + (e instanceof Error ? e.message : e));
|
||||
logger.warn('Error writing rbf data to cache file: ' + (e instanceof Error ? e.message : e));
|
||||
this.isWritingCache = false;
|
||||
}
|
||||
}
|
||||
@@ -148,11 +162,24 @@ class DiskCache {
|
||||
}
|
||||
}
|
||||
|
||||
loadMempoolCache(): void {
|
||||
if (!fs.existsSync(DiskCache.FILE_NAME)) {
|
||||
wipeRbfCache() {
|
||||
logger.notice(`Wipping nodejs backend cache/rbfcache.json file`);
|
||||
|
||||
try {
|
||||
fs.unlinkSync(DiskCache.RBF_FILE_NAME);
|
||||
} catch (e: any) {
|
||||
if (e?.code !== 'ENOENT') {
|
||||
logger.err(`Cannot wipe cache file ${DiskCache.RBF_FILE_NAME}. Exception ${JSON.stringify(e)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async $loadMempoolCache(): Promise<void> {
|
||||
if (!config.MEMPOOL.CACHE_ENABLED || !fs.existsSync(DiskCache.FILE_NAME)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const start = Date.now();
|
||||
let data: any = {};
|
||||
const cacheData = fs.readFileSync(DiskCache.FILE_NAME, 'utf8');
|
||||
if (cacheData) {
|
||||
@@ -169,6 +196,7 @@ class DiskCache {
|
||||
|
||||
if (data.mempoolArray) {
|
||||
for (const tx of data.mempoolArray) {
|
||||
delete tx.uid;
|
||||
data.mempool[tx.txid] = tx;
|
||||
}
|
||||
}
|
||||
@@ -181,6 +209,7 @@ class DiskCache {
|
||||
const cacheData2 = JSON.parse(fs.readFileSync(fileName, 'utf8'));
|
||||
if (cacheData2.mempoolArray) {
|
||||
for (const tx of cacheData2.mempoolArray) {
|
||||
delete tx.uid;
|
||||
data.mempool[tx.txid] = tx;
|
||||
}
|
||||
} else {
|
||||
@@ -188,16 +217,81 @@ class DiskCache {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.info('Error parsing ' + fileName + '. Skipping. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
logger.err('Error parsing ' + fileName + '. Skipping. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
memPool.setMempool(data.mempool);
|
||||
blocks.setBlocks(data.blocks);
|
||||
blocks.setBlockSummaries(data.blockSummaries || []);
|
||||
logger.info(`Loaded mempool from disk cache in ${Date.now() - start} ms`);
|
||||
|
||||
await memPool.$setMempool(data.mempool);
|
||||
if (!this.ignoreBlocksCache) {
|
||||
blocks.setBlocks(data.blocks);
|
||||
blocks.setBlockSummaries(data.blockSummaries || []);
|
||||
} else {
|
||||
logger.info('Re-saving cache with empty recent blocks data');
|
||||
await this.$saveCacheToDisk(true);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse mempoool and blocks cache. Skipping. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
|
||||
try {
|
||||
let rbfData: any = {};
|
||||
const rbfCacheData = fs.readFileSync(DiskCache.RBF_FILE_NAME, 'utf8');
|
||||
if (rbfCacheData) {
|
||||
logger.info('Restoring rbf data from disk cache');
|
||||
rbfData = JSON.parse(rbfCacheData);
|
||||
if (rbfData.rbfCacheSchemaVersion === undefined || rbfData.rbfCacheSchemaVersion !== this.rbfCacheSchemaVersion) {
|
||||
logger.notice('Rbf disk cache contains an outdated schema version. Clearing it and skipping the cache loading.');
|
||||
return this.wipeRbfCache();
|
||||
}
|
||||
if (rbfData.network && rbfData.network !== config.MEMPOOL.NETWORK) {
|
||||
logger.notice('Rbf disk cache contains data from a different network. Clearing it and skipping the cache loading.');
|
||||
return this.wipeRbfCache();
|
||||
}
|
||||
}
|
||||
|
||||
if (rbfData?.rbf) {
|
||||
rbfCache.load({
|
||||
txs: rbfData.rbf.txs.map(([txid, entry]) => ({ value: entry })),
|
||||
trees: rbfData.rbf.trees,
|
||||
expiring: rbfData.rbf.expiring.map(([txid, value]) => ({ key: txid, value })),
|
||||
mempool: memPool.getMempool(),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse rbf cache. Skipping. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
private $yield(): Promise<void> {
|
||||
if (this.semaphore.locks) {
|
||||
logger.debug('Pause writing mempool and blocks data to disk cache (async)');
|
||||
return new Promise((resolve) => {
|
||||
this.semaphore.resume.push(resolve);
|
||||
});
|
||||
} else {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
public lock(): void {
|
||||
this.semaphore.locks++;
|
||||
}
|
||||
|
||||
public unlock(): void {
|
||||
this.semaphore.locks = Math.max(0, this.semaphore.locks - 1);
|
||||
if (!this.semaphore.locks && this.semaphore.resume.length) {
|
||||
const nextResume = this.semaphore.resume.shift();
|
||||
if (nextResume) {
|
||||
logger.debug('Resume writing mempool and blocks data to disk cache (async)');
|
||||
nextResume();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public setIgnoreBlocksCache(): void {
|
||||
this.ignoreBlocksCache = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,13 @@ class ChannelsApi {
|
||||
|
||||
public async $searchChannelsById(search: string): Promise<any[]> {
|
||||
try {
|
||||
const searchStripped = search.replace('%', '') + '%';
|
||||
// restrict search to valid id/short_id prefix formats
|
||||
let searchStripped = search.match(/^[0-9]+[0-9x]*$/)?.[0] || '';
|
||||
if (!searchStripped.length) {
|
||||
return [];
|
||||
}
|
||||
// add wildcard to search by prefix
|
||||
searchStripped += '%';
|
||||
const query = `SELECT id, short_id, capacity, status FROM channels WHERE id LIKE ? OR short_id LIKE ? LIMIT 10`;
|
||||
const [rows]: any = await DB.query(query, [searchStripped, searchStripped]);
|
||||
return rows;
|
||||
@@ -117,6 +123,26 @@ class ChannelsApi {
|
||||
}
|
||||
}
|
||||
|
||||
public async $getPenaltyClosedChannels(): Promise<any[]> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT n1.alias AS alias_left,
|
||||
n2.alias AS alias_right,
|
||||
channels.*
|
||||
FROM channels
|
||||
LEFT JOIN nodes AS n1 ON n1.public_key = channels.node1_public_key
|
||||
LEFT JOIN nodes AS n2 ON n2.public_key = channels.node2_public_key
|
||||
WHERE channels.status = 2 AND channels.closing_reason = 3
|
||||
ORDER BY closing_date DESC
|
||||
`;
|
||||
const [rows]: any = await DB.query(query);
|
||||
return rows;
|
||||
} catch (e) {
|
||||
logger.err('$getPenaltyClosedChannels error: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getUnresolvedClosedChannels(): Promise<any[]> {
|
||||
try {
|
||||
const query = `SELECT * FROM channels WHERE status = 2 AND closing_reason = 2 AND closing_resolved = 0 AND closing_transaction_id != ''`;
|
||||
|
||||
@@ -11,6 +11,7 @@ class ChannelsRoutes {
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels/search/:search', this.$searchChannelsById)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels/:short_id', this.$getChannel)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels', this.$getChannelsForNode)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/penalties', this.$getPenaltyClosedChannels)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels-geo', this.$getAllChannelsGeo)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels-geo/:publicKey', this.$getAllChannelsGeo)
|
||||
;
|
||||
@@ -108,6 +109,18 @@ class ChannelsRoutes {
|
||||
}
|
||||
}
|
||||
|
||||
private async $getPenaltyClosedChannels(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const channels = await channelsApi.$getPenaltyClosedChannels();
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
res.json(channels);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getAllChannelsGeo(req: Request, res: Response) {
|
||||
try {
|
||||
const style: string = typeof req.query.style === 'string' ? req.query.style : '';
|
||||
|
||||
@@ -3,6 +3,7 @@ import DB from '../../database';
|
||||
import { ResultSetHeader } from 'mysql2';
|
||||
import { ILightningApi } from '../lightning/lightning-api.interface';
|
||||
import { ITopNodesPerCapacity, ITopNodesPerChannels } from '../../mempool.interfaces';
|
||||
import { bin2hex } from '../../utils/format';
|
||||
|
||||
class NodesApi {
|
||||
public async $getWorldNodes(): Promise<any> {
|
||||
@@ -56,7 +57,8 @@ class NodesApi {
|
||||
UNIX_TIMESTAMP(updated_at) AS updated_at, color, sockets as sockets,
|
||||
as_number, city_id, country_id, subdivision_id, longitude, latitude,
|
||||
geo_names_iso.names as iso_code, geo_names_as.names as as_organization, geo_names_city.names as city,
|
||||
geo_names_country.names as country, geo_names_subdivision.names as subdivision
|
||||
geo_names_country.names as country, geo_names_subdivision.names as subdivision,
|
||||
features
|
||||
FROM nodes
|
||||
LEFT JOIN geo_names geo_names_as on geo_names_as.id = as_number
|
||||
LEFT JOIN geo_names geo_names_city on geo_names_city.id = city_id
|
||||
@@ -76,6 +78,23 @@ class NodesApi {
|
||||
node.city = JSON.parse(node.city);
|
||||
node.country = JSON.parse(node.country);
|
||||
|
||||
// Features
|
||||
node.features = JSON.parse(node.features);
|
||||
node.featuresBits = null;
|
||||
if (node.features) {
|
||||
let maxBit = 0;
|
||||
for (const feature of node.features) {
|
||||
maxBit = Math.max(maxBit, feature.bit);
|
||||
}
|
||||
maxBit = Math.ceil(maxBit / 4) * 4 - 1;
|
||||
|
||||
node.featuresBits = new Array(maxBit + 1).fill(0);
|
||||
for (const feature of node.features) {
|
||||
node.featuresBits[feature.bit] = 1;
|
||||
}
|
||||
node.featuresBits = bin2hex(node.featuresBits.reverse().join(''));
|
||||
}
|
||||
|
||||
// Active channels and capacity
|
||||
const activeChannelsStats: any = await this.$getActiveChannelsStats(public_key);
|
||||
node.active_channel_count = activeChannelsStats.active_channel_count ?? 0;
|
||||
@@ -373,7 +392,7 @@ class NodesApi {
|
||||
|
||||
public async $searchNodeByPublicKeyOrAlias(search: string) {
|
||||
try {
|
||||
const publicKeySearch = search.replace('%', '') + '%';
|
||||
const publicKeySearch = search.replace(/[^a-zA-Z0-9]/g, '') + '%';
|
||||
const aliasSearch = search
|
||||
.replace(/[-_.]/g, ' ') // Replace all -_. characters with empty space. Eg: "ln.nicehash" becomes "ln nicehash".
|
||||
.replace(/[^a-zA-Z0-9 ]/g, '') // Remove all special characters and keep just A to Z, 0 to 9.
|
||||
@@ -656,10 +675,19 @@ class NodesApi {
|
||||
alias_search,
|
||||
color,
|
||||
sockets,
|
||||
status
|
||||
status,
|
||||
features
|
||||
)
|
||||
VALUES (?, NOW(), FROM_UNIXTIME(?), ?, ?, ?, ?, 1)
|
||||
ON DUPLICATE KEY UPDATE updated_at = FROM_UNIXTIME(?), alias = ?, alias_search = ?, color = ?, sockets = ?, status = 1`;
|
||||
VALUES (?, NOW(), FROM_UNIXTIME(?), ?, ?, ?, ?, 1, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
updated_at = FROM_UNIXTIME(?),
|
||||
alias = ?,
|
||||
alias_search = ?,
|
||||
color = ?,
|
||||
sockets = ?,
|
||||
status = 1,
|
||||
features = ?
|
||||
`;
|
||||
|
||||
await DB.query(query, [
|
||||
node.pub_key,
|
||||
@@ -668,11 +696,13 @@ class NodesApi {
|
||||
this.aliasToSearchText(node.alias),
|
||||
node.color,
|
||||
sockets,
|
||||
JSON.stringify(node.features),
|
||||
node.last_update,
|
||||
node.alias,
|
||||
this.aliasToSearchText(node.alias),
|
||||
node.color,
|
||||
sockets,
|
||||
JSON.stringify(node.features),
|
||||
]);
|
||||
} catch (e) {
|
||||
logger.err('$saveNode() error: ' + (e instanceof Error ? e.message : e));
|
||||
|
||||
@@ -42,6 +42,12 @@ class NodesRoutes {
|
||||
switch (config.MEMPOOL.NETWORK) {
|
||||
case 'testnet':
|
||||
nodesList = [
|
||||
'0259db43b4e4ac0ff12a805f2d81e521253ba2317f6739bc611d8e2fa156d64256',
|
||||
'0352b9944b9a52bd2116c91f1ba70c4ef851ac5ba27e1b20f1d92da3ade010dd10',
|
||||
'03424f5a7601eaa47482cb17100b31a84a04d14fb44b83a57eeceffd8e299878e3',
|
||||
'032850492ee61a5f7006a2fda6925e4b4ec3782f2b6de2ff0e439ef5a38c3b2470',
|
||||
'022c80bace98831c44c32fb69755f2b353434e0ee9e7fbda29507f7ef8abea1421',
|
||||
'02c3559c833e6f99f9ca05fe503e0b4e7524dea9121344edfd3e811101e0c28680',
|
||||
'032c7c7819276c4f706a04df1a0f1e10a5495994a7be4c1d3d28ca766e5a2b957b',
|
||||
'025a7e38c2834dd843591a4d23d5f09cdeb77ddca85f673c2d944a14220ff14cf7',
|
||||
'0395e2731a1673ef21d7a16a727c4fc4d4c35a861c428ce2c819c53d2b81c8bd55',
|
||||
@@ -64,6 +70,12 @@ class NodesRoutes {
|
||||
break;
|
||||
case 'signet':
|
||||
nodesList = [
|
||||
'029fe3621fc0c6e08056a14b868f8fb9acca1aa28a129512f6cea0f0d7654d9f92',
|
||||
'02f60cd7a3a4f1c953dd9554a6ebd51a34f8b10b8124b7fc43a0b381139b55c883',
|
||||
'03cbbf581774700865eebd1be42d022bc004ba30881274ab304e088a25d70e773d',
|
||||
'0243348cb3741cfe2d8485fa8375c29c7bc7cbb67577c363cb6987a5e5fd0052cc',
|
||||
'02cb73e631af44bee600d80f8488a9194c9dc5c7590e575c421a070d1be05bc8e9',
|
||||
'0306f55ee631aa1e2cd4d9b2bfcbc14404faec5c541cef8b2e6f779061029d09c4',
|
||||
'03ddab321b760433cbf561b615ef62ac7d318630c5f51d523aaf5395b90b751956',
|
||||
'033d92c7bfd213ef1b34c90e985fb5dc77f9ec2409d391492484e57a44c4aca1de',
|
||||
'02ad010dda54253c1eb9efe38b0760657a3b43ecad62198c359c051c9d99d45781',
|
||||
@@ -86,6 +98,12 @@ class NodesRoutes {
|
||||
break;
|
||||
default:
|
||||
nodesList = [
|
||||
'02b12b889fe3c943cb05645921040ef13d6d397a2e7a4ad000e28500c505ff26d6',
|
||||
'0302240ac9d71b39617cbde2764837ec3d6198bd6074b15b75d2ff33108e89d2e1',
|
||||
'03364a8ace313376e5e4b68c954e287c6388e16df9e9fdbaf0363ecac41105cbf6',
|
||||
'03229ab4b7f692753e094b93df90530150680f86b535b5183b0cffd75b3df583fc',
|
||||
'03a696eb7acde991c1be97a58a9daef416659539ae462b897f5e9ae361f990228e',
|
||||
'0248bf26cf3a63ab8870f34dc0ec9e6c8c6288cdba96ba3f026f34ec0f13ac4055',
|
||||
'03fbc17549ec667bccf397ababbcb4cdc0e3394345e4773079ab2774612ec9be61',
|
||||
'03da9a8623241ccf95f19cd645c6cecd4019ac91570e976eb0a128bebbc4d8a437',
|
||||
'03ca5340cf85cb2e7cf076e489f785410838de174e40be62723e8a60972ad75144',
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
import { MempoolBlock } from '../mempool.interfaces';
|
||||
import { Common } from './common';
|
||||
import config from '../config';
|
||||
import mempool from './mempool';
|
||||
import projectedBlocks from './mempool-blocks';
|
||||
|
||||
const isLiquid = config.MEMPOOL.NETWORK === 'liquid' || config.MEMPOOL.NETWORK === 'liquidtestnet';
|
||||
|
||||
interface RecommendedFees {
|
||||
fastestFee: number,
|
||||
halfHourFee: number,
|
||||
hourFee: number,
|
||||
economyFee: number,
|
||||
minimumFee: number,
|
||||
}
|
||||
|
||||
class FeeApi {
|
||||
constructor() { }
|
||||
|
||||
defaultFee = Common.isLiquid() ? 0.1 : 1;
|
||||
defaultFee = isLiquid ? 0.1 : 1;
|
||||
minimumIncrement = isLiquid ? 0.1 : 1;
|
||||
|
||||
public getRecommendedFee() {
|
||||
public getRecommendedFee(): RecommendedFees {
|
||||
const pBlocks = projectedBlocks.getMempoolBlocks();
|
||||
const mPool = mempool.getMempoolInfo();
|
||||
const minimumFee = Math.ceil(mPool.mempoolminfee * 100000);
|
||||
const minimumFee = this.roundUpToNearest(mPool.mempoolminfee * 100000, this.minimumIncrement);
|
||||
const defaultMinFee = Math.max(minimumFee, this.defaultFee);
|
||||
|
||||
if (!pBlocks.length) {
|
||||
return {
|
||||
'fastestFee': this.defaultFee,
|
||||
'halfHourFee': this.defaultFee,
|
||||
'hourFee': this.defaultFee,
|
||||
'fastestFee': defaultMinFee,
|
||||
'halfHourFee': defaultMinFee,
|
||||
'hourFee': defaultMinFee,
|
||||
'economyFee': minimumFee,
|
||||
'minimumFee': minimumFee,
|
||||
};
|
||||
@@ -27,11 +39,25 @@ class FeeApi {
|
||||
const secondMedianFee = pBlocks[1] ? this.optimizeMedianFee(pBlocks[1], pBlocks[2], firstMedianFee) : this.defaultFee;
|
||||
const thirdMedianFee = pBlocks[2] ? this.optimizeMedianFee(pBlocks[2], pBlocks[3], secondMedianFee) : this.defaultFee;
|
||||
|
||||
let fastestFee = Math.max(minimumFee, firstMedianFee);
|
||||
let halfHourFee = Math.max(minimumFee, secondMedianFee);
|
||||
let hourFee = Math.max(minimumFee, thirdMedianFee);
|
||||
const economyFee = Math.max(minimumFee, Math.min(2 * minimumFee, thirdMedianFee));
|
||||
|
||||
// ensure recommendations always increase w/ priority
|
||||
fastestFee = Math.max(fastestFee, halfHourFee, hourFee, economyFee);
|
||||
halfHourFee = Math.max(halfHourFee, hourFee, economyFee);
|
||||
hourFee = Math.max(hourFee, economyFee);
|
||||
|
||||
// explicitly enforce a minimum of ceil(mempoolminfee) on all recommendations.
|
||||
// simply rounding up recommended rates is insufficient, as the purging rate
|
||||
// can exceed the median rate of projected blocks in some extreme scenarios
|
||||
// (see https://bitcoin.stackexchange.com/a/120024)
|
||||
return {
|
||||
'fastestFee': firstMedianFee,
|
||||
'halfHourFee': secondMedianFee,
|
||||
'hourFee': thirdMedianFee,
|
||||
'economyFee': Math.min(2 * minimumFee, thirdMedianFee),
|
||||
'fastestFee': fastestFee,
|
||||
'halfHourFee': halfHourFee,
|
||||
'hourFee': hourFee,
|
||||
'economyFee': economyFee,
|
||||
'minimumFee': minimumFee,
|
||||
};
|
||||
}
|
||||
@@ -45,7 +71,11 @@ class FeeApi {
|
||||
const multiplier = (pBlock.blockVSize - 500000) / 500000;
|
||||
return Math.max(Math.round(useFee * multiplier), this.defaultFee);
|
||||
}
|
||||
return Math.ceil(useFee);
|
||||
return this.roundUpToNearest(useFee, this.minimumIncrement);
|
||||
}
|
||||
|
||||
private roundUpToNearest(value: number, nearest: number): number {
|
||||
return Math.ceil(value / nearest) * nearest;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,99 @@ import { ILightningApi } from '../lightning-api.interface';
|
||||
import FundingTxFetcher from '../../../tasks/lightning/sync-tasks/funding-tx-fetcher';
|
||||
import logger from '../../../logger';
|
||||
import { Common } from '../../common';
|
||||
import { hex2bin } from '../../../utils/format';
|
||||
import config from '../../../config';
|
||||
|
||||
// https://github.com/lightningnetwork/lnd/blob/master/lnwire/features.go
|
||||
export enum FeatureBits {
|
||||
DataLossProtectRequired = 0,
|
||||
DataLossProtectOptional = 1,
|
||||
InitialRoutingSync = 3,
|
||||
UpfrontShutdownScriptRequired = 4,
|
||||
UpfrontShutdownScriptOptional = 5,
|
||||
GossipQueriesRequired = 6,
|
||||
GossipQueriesOptional = 7,
|
||||
TLVOnionPayloadRequired = 8,
|
||||
TLVOnionPayloadOptional = 9,
|
||||
StaticRemoteKeyRequired = 12,
|
||||
StaticRemoteKeyOptional = 13,
|
||||
PaymentAddrRequired = 14,
|
||||
PaymentAddrOptional = 15,
|
||||
MPPRequired = 16,
|
||||
MPPOptional = 17,
|
||||
WumboChannelsRequired = 18,
|
||||
WumboChannelsOptional = 19,
|
||||
AnchorsRequired = 20,
|
||||
AnchorsOptional = 21,
|
||||
AnchorsZeroFeeHtlcTxRequired = 22,
|
||||
AnchorsZeroFeeHtlcTxOptional = 23,
|
||||
ShutdownAnySegwitRequired = 26,
|
||||
ShutdownAnySegwitOptional = 27,
|
||||
AMPRequired = 30,
|
||||
AMPOptional = 31,
|
||||
ExplicitChannelTypeRequired = 44,
|
||||
ExplicitChannelTypeOptional = 45,
|
||||
ScidAliasRequired = 46,
|
||||
ScidAliasOptional = 47,
|
||||
PaymentMetadataRequired = 48,
|
||||
PaymentMetadataOptional = 49,
|
||||
ZeroConfRequired = 50,
|
||||
ZeroConfOptional = 51,
|
||||
KeysendRequired = 54,
|
||||
KeysendOptional = 55,
|
||||
ScriptEnforcedLeaseRequired = 2022,
|
||||
ScriptEnforcedLeaseOptional = 2023,
|
||||
SimpleTaprootChannelsRequiredFinal = 80,
|
||||
SimpleTaprootChannelsOptionalFinal = 81,
|
||||
SimpleTaprootChannelsRequiredStaging = 180,
|
||||
SimpleTaprootChannelsOptionalStaging = 181,
|
||||
MaxBolt11Feature = 5114,
|
||||
};
|
||||
|
||||
export const FeaturesMap = new Map<FeatureBits, string>([
|
||||
[FeatureBits.DataLossProtectRequired, 'data-loss-protect'],
|
||||
[FeatureBits.DataLossProtectOptional, 'data-loss-protect'],
|
||||
[FeatureBits.InitialRoutingSync, 'initial-routing-sync'],
|
||||
[FeatureBits.UpfrontShutdownScriptRequired, 'upfront-shutdown-script'],
|
||||
[FeatureBits.UpfrontShutdownScriptOptional, 'upfront-shutdown-script'],
|
||||
[FeatureBits.GossipQueriesRequired, 'gossip-queries'],
|
||||
[FeatureBits.GossipQueriesOptional, 'gossip-queries'],
|
||||
[FeatureBits.TLVOnionPayloadRequired, 'tlv-onion'],
|
||||
[FeatureBits.TLVOnionPayloadOptional, 'tlv-onion'],
|
||||
[FeatureBits.StaticRemoteKeyOptional, 'static-remote-key'],
|
||||
[FeatureBits.StaticRemoteKeyRequired, 'static-remote-key'],
|
||||
[FeatureBits.PaymentAddrOptional, 'payment-addr'],
|
||||
[FeatureBits.PaymentAddrRequired, 'payment-addr'],
|
||||
[FeatureBits.MPPOptional, 'multi-path-payments'],
|
||||
[FeatureBits.MPPRequired, 'multi-path-payments'],
|
||||
[FeatureBits.AnchorsRequired, 'anchor-commitments'],
|
||||
[FeatureBits.AnchorsOptional, 'anchor-commitments'],
|
||||
[FeatureBits.AnchorsZeroFeeHtlcTxRequired, 'anchors-zero-fee-htlc-tx'],
|
||||
[FeatureBits.AnchorsZeroFeeHtlcTxOptional, 'anchors-zero-fee-htlc-tx'],
|
||||
[FeatureBits.WumboChannelsRequired, 'wumbo-channels'],
|
||||
[FeatureBits.WumboChannelsOptional, 'wumbo-channels'],
|
||||
[FeatureBits.AMPRequired, 'amp'],
|
||||
[FeatureBits.AMPOptional, 'amp'],
|
||||
[FeatureBits.PaymentMetadataOptional, 'payment-metadata'],
|
||||
[FeatureBits.PaymentMetadataRequired, 'payment-metadata'],
|
||||
[FeatureBits.ExplicitChannelTypeOptional, 'explicit-commitment-type'],
|
||||
[FeatureBits.ExplicitChannelTypeRequired, 'explicit-commitment-type'],
|
||||
[FeatureBits.KeysendOptional, 'keysend'],
|
||||
[FeatureBits.KeysendRequired, 'keysend'],
|
||||
[FeatureBits.ScriptEnforcedLeaseRequired, 'script-enforced-lease'],
|
||||
[FeatureBits.ScriptEnforcedLeaseOptional, 'script-enforced-lease'],
|
||||
[FeatureBits.ScidAliasRequired, 'scid-alias'],
|
||||
[FeatureBits.ScidAliasOptional, 'scid-alias'],
|
||||
[FeatureBits.ZeroConfRequired, 'zero-conf'],
|
||||
[FeatureBits.ZeroConfOptional, 'zero-conf'],
|
||||
[FeatureBits.ShutdownAnySegwitRequired, 'shutdown-any-segwit'],
|
||||
[FeatureBits.ShutdownAnySegwitOptional, 'shutdown-any-segwit'],
|
||||
[FeatureBits.SimpleTaprootChannelsRequiredFinal, 'taproot-channels'],
|
||||
[FeatureBits.SimpleTaprootChannelsOptionalFinal, 'taproot-channels'],
|
||||
[FeatureBits.SimpleTaprootChannelsRequiredStaging, 'taproot-channels-staging'],
|
||||
[FeatureBits.SimpleTaprootChannelsOptionalStaging, 'taproot-channels-staging'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Convert a clightning "listnode" entry to a lnd node entry
|
||||
*/
|
||||
@@ -17,10 +108,36 @@ export function convertNode(clNode: any): ILightningApi.Node {
|
||||
custom_records = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const nodeFeatures: ILightningApi.Feature[] = [];
|
||||
const nodeFeaturesBinary = hex2bin(clNode.features).split('').reverse().join('');
|
||||
|
||||
for (let i = 0; i < nodeFeaturesBinary.length; i++) {
|
||||
if (nodeFeaturesBinary[i] === '0') {
|
||||
continue;
|
||||
}
|
||||
const feature = FeaturesMap.get(i);
|
||||
if (!feature) {
|
||||
nodeFeatures.push({
|
||||
bit: i,
|
||||
name: 'unknown',
|
||||
is_required: i % 2 === 0,
|
||||
is_known: false
|
||||
});
|
||||
} else {
|
||||
nodeFeatures.push({
|
||||
bit: i,
|
||||
name: feature,
|
||||
is_required: i % 2 === 0,
|
||||
is_known: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
alias: clNode.alias ?? '',
|
||||
color: `#${clNode.color ?? ''}`,
|
||||
features: [], // TODO parse and return clNode.feature
|
||||
features: nodeFeatures,
|
||||
pub_key: clNode.nodeid,
|
||||
addresses: clNode.addresses?.map((addr) => {
|
||||
let address = addr.address;
|
||||
@@ -108,7 +225,7 @@ async function buildFullChannel(clChannelA: any, clChannelB: any): Promise<ILigh
|
||||
|
||||
return {
|
||||
channel_id: Common.channelShortIdToIntegerId(clChannelA.short_channel_id),
|
||||
capacity: clChannelA.satoshis,
|
||||
capacity: (clChannelA.amount_msat / 1000).toString(),
|
||||
last_update: lastUpdate,
|
||||
node1_policy: convertPolicy(clChannelA),
|
||||
node2_policy: convertPolicy(clChannelB),
|
||||
@@ -132,7 +249,7 @@ async function buildIncompleteChannel(clChannel: any): Promise<ILightningApi.Cha
|
||||
|
||||
return {
|
||||
channel_id: Common.channelShortIdToIntegerId(clChannel.short_channel_id),
|
||||
capacity: clChannel.satoshis,
|
||||
capacity: (clChannel.amount_msat / 1000).toString(),
|
||||
last_update: clChannel.last_update ?? 0,
|
||||
node1_policy: convertPolicy(clChannel),
|
||||
node2_policy: null,
|
||||
@@ -148,8 +265,8 @@ async function buildIncompleteChannel(clChannel: any): Promise<ILightningApi.Cha
|
||||
function convertPolicy(clChannel: any): ILightningApi.RoutingPolicy {
|
||||
return {
|
||||
time_lock_delta: clChannel.delay,
|
||||
min_htlc: clChannel.htlc_minimum_msat.slice(0, -4),
|
||||
max_htlc_msat: clChannel.htlc_maximum_msat.slice(0, -4),
|
||||
min_htlc: clChannel.htlc_minimum_msat.toString(),
|
||||
max_htlc_msat: clChannel.htlc_maximum_msat.toString(),
|
||||
fee_base_msat: clChannel.base_fee_millisatoshi,
|
||||
fee_rate_milli_msat: clChannel.fee_per_millionth,
|
||||
disabled: !clChannel.active,
|
||||
|
||||
@@ -79,6 +79,7 @@ export namespace ILightningApi {
|
||||
}
|
||||
|
||||
export interface Feature {
|
||||
bit: number;
|
||||
name: string;
|
||||
is_required: boolean;
|
||||
is_known: boolean;
|
||||
|
||||
@@ -4,21 +4,29 @@ import * as fs from 'fs';
|
||||
import { AbstractLightningApi } from '../lightning-api-abstract-factory';
|
||||
import { ILightningApi } from '../lightning-api.interface';
|
||||
import config from '../../../config';
|
||||
import logger from '../../../logger';
|
||||
|
||||
class LndApi implements AbstractLightningApi {
|
||||
axiosConfig: AxiosRequestConfig = {};
|
||||
|
||||
constructor() {
|
||||
if (config.LIGHTNING.ENABLED) {
|
||||
if (!config.LIGHTNING.ENABLED) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.axiosConfig = {
|
||||
headers: {
|
||||
'Grpc-Metadata-macaroon': fs.readFileSync(config.LND.MACAROON_PATH).toString('hex')
|
||||
'Grpc-Metadata-macaroon': fs.readFileSync(config.LND.MACAROON_PATH).toString('hex'),
|
||||
},
|
||||
httpsAgent: new Agent({
|
||||
ca: fs.readFileSync(config.LND.TLS_CERT_PATH)
|
||||
}),
|
||||
timeout: 10000
|
||||
timeout: config.LND.TIMEOUT
|
||||
};
|
||||
} catch (e) {
|
||||
config.LIGHTNING.ENABLED = false;
|
||||
logger.updateNetwork();
|
||||
logger.err(`Could not initialize LND Macaroon/TLS Cert. Disabling LIGHTNING. ` + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +41,23 @@ class LndApi implements AbstractLightningApi {
|
||||
}
|
||||
|
||||
async $getNetworkGraph(): Promise<ILightningApi.NetworkGraph> {
|
||||
return axios.get<ILightningApi.NetworkGraph>(config.LND.REST_API_URL + '/v1/graph', this.axiosConfig)
|
||||
const graph = await axios.get<ILightningApi.NetworkGraph>(config.LND.REST_API_URL + '/v1/graph', this.axiosConfig)
|
||||
.then((response) => response.data);
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
const nodeFeatures: ILightningApi.Feature[] = [];
|
||||
for (const bit in node.features) {
|
||||
nodeFeatures.push({
|
||||
bit: parseInt(bit, 10),
|
||||
name: node.features[bit].name,
|
||||
is_required: node.features[bit].is_required,
|
||||
is_known: node.features[bit].is_known,
|
||||
});
|
||||
}
|
||||
node.features = nodeFeatures;
|
||||
}
|
||||
|
||||
return graph;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,12 @@ import { Common } from '../common';
|
||||
import DB from '../../database';
|
||||
import logger from '../../logger';
|
||||
|
||||
const federationChangeAddresses = ['bc1qxvay4an52gcghxq5lavact7r6qe9l4laedsazz8fj2ee2cy47tlqff4aj4', '3EiAcrzq1cELXScc98KeCswGWZaPGceT1d'];
|
||||
const auditBlockOffsetWithTip = 1; // Wait for 1 block confirmation before processing the block in the audit process to reduce the risk of reorgs
|
||||
|
||||
class ElementsParser {
|
||||
private isRunning = false;
|
||||
private isUtxosUpdatingRunning = false;
|
||||
|
||||
constructor() { }
|
||||
|
||||
@@ -32,12 +36,6 @@ class ElementsParser {
|
||||
}
|
||||
}
|
||||
|
||||
public async $getPegDataByMonth(): Promise<any> {
|
||||
const query = `SELECT SUM(amount) AS amount, DATE_FORMAT(FROM_UNIXTIME(datetime), '%Y-%m-01') AS date FROM elements_pegs GROUP BY DATE_FORMAT(FROM_UNIXTIME(datetime), '%Y%m')`;
|
||||
const [rows] = await DB.query(query);
|
||||
return rows;
|
||||
}
|
||||
|
||||
protected async $parseBlock(block: IBitcoinApi.Block) {
|
||||
for (const tx of block.tx) {
|
||||
await this.$parseInputs(tx, block);
|
||||
@@ -55,29 +53,30 @@ class ElementsParser {
|
||||
|
||||
protected async $parsePegIn(input: IBitcoinApi.Vin, vindex: number, txid: string, block: IBitcoinApi.Block) {
|
||||
const bitcoinTx: IBitcoinApi.Transaction = await bitcoinSecondClient.getRawTransaction(input.txid, true);
|
||||
const bitcoinBlock: IBitcoinApi.Block = await bitcoinSecondClient.getBlock(bitcoinTx.blockhash);
|
||||
const prevout = bitcoinTx.vout[input.vout || 0];
|
||||
const outputAddress = prevout.scriptPubKey.address || (prevout.scriptPubKey.addresses && prevout.scriptPubKey.addresses[0]) || '';
|
||||
await this.$savePegToDatabase(block.height, block.time, prevout.value * 100000000, txid, vindex,
|
||||
outputAddress, bitcoinTx.txid, prevout.n, 1);
|
||||
outputAddress, bitcoinTx.txid, prevout.n, bitcoinBlock.height, bitcoinBlock.time, 1);
|
||||
}
|
||||
|
||||
protected async $parseOutputs(tx: IBitcoinApi.Transaction, block: IBitcoinApi.Block) {
|
||||
for (const output of tx.vout) {
|
||||
if (output.scriptPubKey.pegout_chain) {
|
||||
await this.$savePegToDatabase(block.height, block.time, 0 - output.value * 100000000, tx.txid, output.n,
|
||||
(output.scriptPubKey.pegout_addresses && output.scriptPubKey.pegout_addresses[0] || ''), '', 0, 0);
|
||||
(output.scriptPubKey.pegout_address || ''), '', 0, 0, 0, 0);
|
||||
}
|
||||
if (!output.scriptPubKey.pegout_chain && output.scriptPubKey.type === 'nulldata'
|
||||
&& output.value && output.value > 0 && output.asset && output.asset === Common.nativeAssetId) {
|
||||
await this.$savePegToDatabase(block.height, block.time, 0 - output.value * 100000000, tx.txid, output.n,
|
||||
(output.scriptPubKey.pegout_addresses && output.scriptPubKey.pegout_addresses[0] || ''), '', 0, 1);
|
||||
(output.scriptPubKey.pegout_address || ''), '', 0, 0, 0, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async $savePegToDatabase(height: number, blockTime: number, amount: number, txid: string,
|
||||
txindex: number, bitcoinaddress: string, bitcointxid: string, bitcoinindex: number, final_tx: number): Promise<void> {
|
||||
const query = `INSERT INTO elements_pegs(
|
||||
txindex: number, bitcoinaddress: string, bitcointxid: string, bitcoinindex: number, bitcoinblock: number, bitcoinBlockTime: number, final_tx: number): Promise<void> {
|
||||
const query = `INSERT IGNORE INTO elements_pegs(
|
||||
block, datetime, amount, txid, txindex, bitcoinaddress, bitcointxid, bitcoinindex, final_tx
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
||||
|
||||
@@ -85,7 +84,22 @@ class ElementsParser {
|
||||
height, blockTime, amount, txid, txindex, bitcoinaddress, bitcointxid, bitcoinindex, final_tx
|
||||
];
|
||||
await DB.query(query, params);
|
||||
logger.debug(`Saved L-BTC peg from block height #${height} with TXID ${txid}.`);
|
||||
logger.debug(`Saved L-BTC peg from Liquid block height #${height} with TXID ${txid}.`);
|
||||
|
||||
if (amount > 0) { // Peg-in
|
||||
|
||||
// Add the address to the federation addresses table
|
||||
await DB.query(`INSERT IGNORE INTO federation_addresses (bitcoinaddress) VALUES (?)`, [bitcoinaddress]);
|
||||
|
||||
// Add the UTXO to the federation txos table
|
||||
const query_utxos = `INSERT IGNORE INTO federation_txos (txid, txindex, bitcoinaddress, amount, blocknumber, blocktime, unspent, lastblockupdate, lasttimeupdate, pegtxid, pegindex, pegblocktime) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
||||
const params_utxos: (string | number)[] = [bitcointxid, bitcoinindex, bitcoinaddress, amount, bitcoinblock, bitcoinBlockTime, 1, bitcoinblock - 1, 0, txid, txindex, blockTime];
|
||||
await DB.query(query_utxos, params_utxos);
|
||||
const [minBlockUpdate] = await DB.query(`SELECT MIN(lastblockupdate) AS lastblockupdate FROM federation_txos WHERE unspent = 1`)
|
||||
await this.$saveLastBlockAuditToDatabase(minBlockUpdate[0]['lastblockupdate']);
|
||||
logger.debug(`Saved new Federation UTXO ${bitcointxid}:${bitcoinindex} belonging to ${bitcoinaddress} to federation txos`);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
protected async $getLatestBlockHeightFromDatabase(): Promise<number> {
|
||||
@@ -98,6 +112,328 @@ class ElementsParser {
|
||||
const query = `UPDATE state SET number = ? WHERE name = 'last_elements_block'`;
|
||||
await DB.query(query, [blockHeight]);
|
||||
}
|
||||
|
||||
///////////// FEDERATION AUDIT //////////////
|
||||
|
||||
public async $updateFederationUtxos() {
|
||||
if (this.isUtxosUpdatingRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isUtxosUpdatingRunning = true;
|
||||
|
||||
try {
|
||||
let auditProgress = await this.$getAuditProgress();
|
||||
// If no peg in transaction was found in the database, return
|
||||
if (!auditProgress.lastBlockAudit) {
|
||||
logger.debug(`No Federation UTXOs found in the database. Waiting for some to be confirmed before starting the Federation UTXOs audit`);
|
||||
this.isUtxosUpdatingRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const bitcoinBlocksToSync = await this.$getBitcoinBlockchainState();
|
||||
// If the bitcoin blockchain is not synced yet, return
|
||||
if (bitcoinBlocksToSync.bitcoinHeaders > bitcoinBlocksToSync.bitcoinBlocks + 1) {
|
||||
logger.debug(`Bitcoin client is not synced yet. ${bitcoinBlocksToSync.bitcoinHeaders - bitcoinBlocksToSync.bitcoinBlocks} blocks remaining to sync before the Federation audit process can start`);
|
||||
this.isUtxosUpdatingRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
auditProgress.lastBlockAudit++;
|
||||
|
||||
// Logging
|
||||
let indexedThisRun = 0;
|
||||
let timer = Date.now() / 1000;
|
||||
const startedAt = Date.now() / 1000;
|
||||
const indexingSpeeds: number[] = [];
|
||||
|
||||
while (auditProgress.lastBlockAudit <= auditProgress.confirmedTip) {
|
||||
|
||||
// First, get the current UTXOs that need to be scanned in the block
|
||||
const utxos = await this.$getFederationUtxosToScan(auditProgress.lastBlockAudit);
|
||||
|
||||
// Get the peg-out addresses that need to be scanned
|
||||
const redeemAddresses = await this.$getRedeemAddressesToScan();
|
||||
|
||||
// The fast way: check if these UTXOs are still unspent as of the current block with gettxout
|
||||
let spentAsTip: any[];
|
||||
let unspentAsTip: any[];
|
||||
if (auditProgress.confirmedTip - auditProgress.lastBlockAudit <= 150) { // If the audit status is not too far in the past, we can use gettxout (fast way)
|
||||
const utxosToParse = await this.$getFederationUtxosToParse(utxos);
|
||||
spentAsTip = utxosToParse.spentAsTip;
|
||||
unspentAsTip = utxosToParse.unspentAsTip;
|
||||
logger.debug(`Found ${utxos.length} Federation UTXOs and ${redeemAddresses.length} Peg-Out Addresses to scan in Bitcoin block height #${auditProgress.lastBlockAudit} / #${auditProgress.confirmedTip}`);
|
||||
logger.debug(`${unspentAsTip.length} / ${utxos.length} Federation UTXOs are unspent as of tip`);
|
||||
} else { // If the audit status is too far in the past, it is useless and wasteful to look for still unspent txos since they will all be spent as of the tip
|
||||
spentAsTip = utxos;
|
||||
unspentAsTip = [];
|
||||
|
||||
// Logging
|
||||
const elapsedSeconds = (Date.now() / 1000) - timer;
|
||||
if (elapsedSeconds > 5) {
|
||||
const runningFor = (Date.now() / 1000) - startedAt;
|
||||
const blockPerSeconds = indexedThisRun / elapsedSeconds;
|
||||
indexingSpeeds.push(blockPerSeconds);
|
||||
if (indexingSpeeds.length > 100) indexingSpeeds.shift(); // Keep the length of the up to 100 last indexing speeds
|
||||
const meanIndexingSpeed = indexingSpeeds.reduce((a, b) => a + b, 0) / indexingSpeeds.length;
|
||||
const eta = (auditProgress.confirmedTip - auditProgress.lastBlockAudit) / meanIndexingSpeed;
|
||||
logger.debug(`Scanning ${utxos.length} Federation UTXOs and ${redeemAddresses.length} Peg-Out Addresses at Bitcoin block height #${auditProgress.lastBlockAudit} / #${auditProgress.confirmedTip} | ~${meanIndexingSpeed.toFixed(2)} blocks/sec | elapsed: ${(runningFor / 60).toFixed(0)} minutes | ETA: ${(eta / 60).toFixed(0)} minutes`);
|
||||
timer = Date.now() / 1000;
|
||||
indexedThisRun = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// The slow way: parse the block to look for the spending tx
|
||||
const blockHash: IBitcoinApi.ChainTips = await bitcoinSecondClient.getBlockHash(auditProgress.lastBlockAudit);
|
||||
const block: IBitcoinApi.Block = await bitcoinSecondClient.getBlock(blockHash, 2);
|
||||
await this.$parseBitcoinBlock(block, spentAsTip, unspentAsTip, auditProgress.confirmedTip, redeemAddresses);
|
||||
|
||||
// Finally, update the lastblockupdate of the remaining UTXOs and save to the database
|
||||
const [minBlockUpdate] = await DB.query(`SELECT MIN(lastblockupdate) AS lastblockupdate FROM federation_txos WHERE unspent = 1`)
|
||||
await this.$saveLastBlockAuditToDatabase(minBlockUpdate[0]['lastblockupdate']);
|
||||
|
||||
auditProgress = await this.$getAuditProgress();
|
||||
auditProgress.lastBlockAudit++;
|
||||
indexedThisRun++;
|
||||
}
|
||||
|
||||
this.isUtxosUpdatingRunning = false;
|
||||
} catch (e) {
|
||||
this.isUtxosUpdatingRunning = false;
|
||||
throw new Error(e instanceof Error ? e.message : 'Error');
|
||||
}
|
||||
}
|
||||
|
||||
// Get the UTXOs that need to be scanned in block height (UTXOs that were last updated in the block height - 1)
|
||||
protected async $getFederationUtxosToScan(height: number) {
|
||||
const query = `SELECT txid, txindex, bitcoinaddress, amount FROM federation_txos WHERE lastblockupdate = ? AND unspent = 1`;
|
||||
const [rows] = await DB.query(query, [height - 1]);
|
||||
return rows as any[];
|
||||
}
|
||||
|
||||
// Returns the UTXOs that are spent as of tip and need to be scanned
|
||||
protected async $getFederationUtxosToParse(utxos: any[]): Promise<any> {
|
||||
const spentAsTip: any[] = [];
|
||||
const unspentAsTip: any[] = [];
|
||||
|
||||
for (const utxo of utxos) {
|
||||
const result = await bitcoinSecondClient.getTxOut(utxo.txid, utxo.txindex, false);
|
||||
result ? unspentAsTip.push(utxo) : spentAsTip.push(utxo);
|
||||
}
|
||||
|
||||
return {spentAsTip, unspentAsTip};
|
||||
}
|
||||
|
||||
protected async $parseBitcoinBlock(block: IBitcoinApi.Block, spentAsTip: any[], unspentAsTip: any[], confirmedTip: number, redeemAddressesData: any[] = []) {
|
||||
const redeemAddresses: string[] = redeemAddressesData.map(redeemAddress => redeemAddress.bitcoinaddress);
|
||||
for (const tx of block.tx) {
|
||||
let mightRedeemInThisTx = false; // If a Federation UTXO is spent in this block, we might find a peg-out address in the outputs...
|
||||
// Check if the Federation UTXOs that was spent as of tip are spent in this block
|
||||
for (const input of tx.vin) {
|
||||
const txo = spentAsTip.find(txo => txo.txid === input.txid && txo.txindex === input.vout);
|
||||
if (txo) {
|
||||
mightRedeemInThisTx = true;
|
||||
await DB.query(`UPDATE federation_txos SET unspent = 0, lastblockupdate = ?, lasttimeupdate = ? WHERE txid = ? AND txindex = ?`, [block.height, block.time, txo.txid, txo.txindex]);
|
||||
// Remove the TXO from the utxo array
|
||||
spentAsTip.splice(spentAsTip.indexOf(txo), 1);
|
||||
logger.debug(`Federation UTXO ${txo.txid}:${txo.txindex} (${txo.amount} sats) was spent in block ${block.height}`);
|
||||
}
|
||||
}
|
||||
// Check if an output is sent to a change address of the federation
|
||||
for (const output of tx.vout) {
|
||||
if (output.scriptPubKey.address && federationChangeAddresses.includes(output.scriptPubKey.address)) {
|
||||
// Check that the UTXO was not already added in the DB by previous scans
|
||||
const [rows_check] = await DB.query(`SELECT txid FROM federation_txos WHERE txid = ? AND txindex = ?`, [tx.txid, output.n]) as any[];
|
||||
if (rows_check.length === 0) {
|
||||
const query_utxos = `INSERT INTO federation_txos (txid, txindex, bitcoinaddress, amount, blocknumber, blocktime, unspent, lastblockupdate, lasttimeupdate, pegtxid, pegindex, pegblocktime) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
||||
const params_utxos: (string | number)[] = [tx.txid, output.n, output.scriptPubKey.address, output.value * 100000000, block.height, block.time, 1, block.height, 0, '', 0, 0];
|
||||
await DB.query(query_utxos, params_utxos);
|
||||
// Add the UTXO to the utxo array
|
||||
spentAsTip.push({
|
||||
txid: tx.txid,
|
||||
txindex: output.n,
|
||||
bitcoinaddress: output.scriptPubKey.address,
|
||||
amount: output.value * 100000000
|
||||
});
|
||||
logger.debug(`Added new Federation UTXO ${tx.txid}:${output.n} (${output.value * 100000000} sats), change address: ${output.scriptPubKey.address}`);
|
||||
}
|
||||
}
|
||||
if (mightRedeemInThisTx && output.scriptPubKey.address && redeemAddresses.includes(output.scriptPubKey.address)) {
|
||||
// Find the number of times output.scriptPubKey.address appears in redeemAddresses. There can be address reuse for peg-outs...
|
||||
const matchingAddress: any[] = redeemAddressesData.filter(redeemAddress => redeemAddress.bitcoinaddress === output.scriptPubKey.address && -redeemAddress.amount === Math.round(output.value * 100000000));
|
||||
if (matchingAddress.length > 0) {
|
||||
if (matchingAddress.length > 1) {
|
||||
// If there are more than one peg out address with the same amount, we can't know which one redeemed the UTXO: we take the oldest one
|
||||
matchingAddress.sort((a, b) => a.datetime - b.datetime);
|
||||
logger.debug(`Found redeem txid ${tx.txid}:${output.n} to peg-out address ${matchingAddress[0].bitcoinaddress}, amount ${matchingAddress[0].amount}, datetime ${matchingAddress[0].datetime}`);
|
||||
} else {
|
||||
logger.debug(`Found redeem txid ${tx.txid}:${output.n} to peg-out address ${matchingAddress[0].bitcoinaddress}, amount ${matchingAddress[0].amount}`);
|
||||
}
|
||||
const query_add_redeem = `UPDATE elements_pegs SET bitcointxid = ?, bitcoinindex = ? WHERE bitcoinaddress = ? AND amount = ? AND datetime = ?`;
|
||||
const params_add_redeem: (string | number)[] = [tx.txid, output.n, matchingAddress[0].bitcoinaddress, matchingAddress[0].amount, matchingAddress[0].datetime];
|
||||
await DB.query(query_add_redeem, params_add_redeem);
|
||||
const index = redeemAddressesData.indexOf(matchingAddress[0]);
|
||||
redeemAddressesData.splice(index, 1);
|
||||
redeemAddresses.splice(index, 1);
|
||||
} else { // The output amount does not match the peg-out amount... log it
|
||||
logger.debug(`Found redeem txid ${tx.txid}:${output.n} to peg-out address ${output.scriptPubKey.address} but output amount ${Math.round(output.value * 100000000)} does not match the peg-out amount!`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
for (const utxo of spentAsTip) {
|
||||
await DB.query(`UPDATE federation_txos SET lastblockupdate = ? WHERE txid = ? AND txindex = ?`, [block.height, utxo.txid, utxo.txindex]);
|
||||
}
|
||||
|
||||
for (const utxo of unspentAsTip) {
|
||||
await DB.query(`UPDATE federation_txos SET lastblockupdate = ? WHERE txid = ? AND txindex = ?`, [confirmedTip, utxo.txid, utxo.txindex]);
|
||||
}
|
||||
}
|
||||
|
||||
protected async $saveLastBlockAuditToDatabase(blockHeight: number) {
|
||||
const query = `UPDATE state SET number = ? WHERE name = 'last_bitcoin_block_audit'`;
|
||||
await DB.query(query, [blockHeight]);
|
||||
}
|
||||
|
||||
// Get the bitcoin block where the audit process was last updated
|
||||
protected async $getAuditProgress(): Promise<any> {
|
||||
const lastblockaudit = await this.$getLastBlockAudit();
|
||||
const bitcoinBlocksToSync = await this.$getBitcoinBlockchainState();
|
||||
return {
|
||||
lastBlockAudit: lastblockaudit,
|
||||
confirmedTip: bitcoinBlocksToSync.bitcoinBlocks - auditBlockOffsetWithTip,
|
||||
};
|
||||
}
|
||||
|
||||
// Get the bitcoin blocks remaining to be synced
|
||||
protected async $getBitcoinBlockchainState(): Promise<any> {
|
||||
const result = await bitcoinSecondClient.getBlockchainInfo();
|
||||
return {
|
||||
bitcoinBlocks: result.blocks,
|
||||
bitcoinHeaders: result.headers,
|
||||
}
|
||||
}
|
||||
|
||||
protected async $getLastBlockAudit(): Promise<number> {
|
||||
const query = `SELECT number FROM state WHERE name = 'last_bitcoin_block_audit'`;
|
||||
const [rows] = await DB.query(query);
|
||||
return rows[0]['number'];
|
||||
}
|
||||
|
||||
protected async $getRedeemAddressesToScan(): Promise<any[]> {
|
||||
const query = `SELECT datetime, amount, bitcoinaddress FROM elements_pegs where amount < 0 AND bitcoinaddress != '' AND bitcointxid = '';`;
|
||||
const [rows]: any[] = await DB.query(query);
|
||||
return rows;
|
||||
}
|
||||
|
||||
///////////// DATA QUERY //////////////
|
||||
|
||||
public async $getAuditStatus(): Promise<any> {
|
||||
const lastBlockAudit = await this.$getLastBlockAudit();
|
||||
const bitcoinBlocksToSync = await this.$getBitcoinBlockchainState();
|
||||
return {
|
||||
bitcoinBlocks: bitcoinBlocksToSync.bitcoinBlocks,
|
||||
bitcoinHeaders: bitcoinBlocksToSync.bitcoinHeaders,
|
||||
lastBlockAudit: lastBlockAudit,
|
||||
isAuditSynced: bitcoinBlocksToSync.bitcoinHeaders - bitcoinBlocksToSync.bitcoinBlocks <= 2 && bitcoinBlocksToSync.bitcoinBlocks - lastBlockAudit <= 3,
|
||||
};
|
||||
}
|
||||
|
||||
public async $getPegDataByMonth(): Promise<any> {
|
||||
const query = `SELECT SUM(amount) AS amount, DATE_FORMAT(FROM_UNIXTIME(datetime), '%Y-%m-01') AS date FROM elements_pegs GROUP BY DATE_FORMAT(FROM_UNIXTIME(datetime), '%Y%m')`;
|
||||
const [rows] = await DB.query(query);
|
||||
return rows;
|
||||
}
|
||||
|
||||
public async $getFederationReservesByMonth(): Promise<any> {
|
||||
const query = `
|
||||
SELECT SUM(amount) AS amount, DATE_FORMAT(FROM_UNIXTIME(blocktime), '%Y-%m-01') AS date FROM federation_txos
|
||||
WHERE
|
||||
(blocktime > UNIX_TIMESTAMP(LAST_DAY(FROM_UNIXTIME(blocktime) - INTERVAL 1 MONTH) + INTERVAL 1 DAY))
|
||||
AND
|
||||
((unspent = 1) OR (unspent = 0 AND lasttimeupdate > UNIX_TIMESTAMP(LAST_DAY(FROM_UNIXTIME(blocktime)) + INTERVAL 1 DAY)))
|
||||
GROUP BY
|
||||
date;`;
|
||||
const [rows] = await DB.query(query);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Get the current L-BTC pegs and the last Liquid block it was updated
|
||||
public async $getCurrentLbtcSupply(): Promise<any> {
|
||||
const [rows] = await DB.query(`SELECT SUM(amount) AS LBTC_supply FROM elements_pegs;`);
|
||||
const lastblockupdate = await this.$getLatestBlockHeightFromDatabase();
|
||||
const hash = await bitcoinClient.getBlockHash(lastblockupdate);
|
||||
return {
|
||||
amount: rows[0]['LBTC_supply'],
|
||||
lastBlockUpdate: lastblockupdate,
|
||||
hash: hash
|
||||
};
|
||||
}
|
||||
|
||||
// Get the current reserves of the federation and the last Bitcoin block it was updated
|
||||
public async $getCurrentFederationReserves(): Promise<any> {
|
||||
const [rows] = await DB.query(`SELECT SUM(amount) AS total_balance FROM federation_txos WHERE unspent = 1;`);
|
||||
const lastblockaudit = await this.$getLastBlockAudit();
|
||||
const hash = await bitcoinSecondClient.getBlockHash(lastblockaudit);
|
||||
return {
|
||||
amount: rows[0]['total_balance'],
|
||||
lastBlockUpdate: lastblockaudit,
|
||||
hash: hash
|
||||
};
|
||||
}
|
||||
|
||||
// Get all of the federation addresses, most balances first
|
||||
public async $getFederationAddresses(): Promise<any> {
|
||||
const query = `SELECT bitcoinaddress, SUM(amount) AS balance FROM federation_txos WHERE unspent = 1 GROUP BY bitcoinaddress ORDER BY balance DESC;`;
|
||||
const [rows] = await DB.query(query);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Get all of the UTXOs held by the federation, most recent first
|
||||
public async $getFederationUtxos(): Promise<any> {
|
||||
const query = `SELECT txid, txindex, bitcoinaddress, amount, blocknumber, blocktime, pegtxid, pegindex, pegblocktime FROM federation_txos WHERE unspent = 1 ORDER BY blocktime DESC;`;
|
||||
const [rows] = await DB.query(query);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Get the total number of federation addresses
|
||||
public async $getFederationAddressesNumber(): Promise<any> {
|
||||
const query = `SELECT COUNT(DISTINCT bitcoinaddress) AS address_count FROM federation_txos WHERE unspent = 1;`;
|
||||
const [rows] = await DB.query(query);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// Get the total number of federation utxos
|
||||
public async $getFederationUtxosNumber(): Promise<any> {
|
||||
const query = `SELECT COUNT(*) AS utxo_count FROM federation_txos WHERE unspent = 1;`;
|
||||
const [rows] = await DB.query(query);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// Get recent pegs in / out
|
||||
public async $getPegsList(count: number = 0): Promise<any> {
|
||||
const query = `SELECT txid, txindex, amount, bitcoinaddress, bitcointxid, bitcoinindex, datetime AS blocktime FROM elements_pegs ORDER BY block DESC LIMIT 15 OFFSET ?;`;
|
||||
const [rows] = await DB.query(query, [count]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Get all peg in / out from the last month
|
||||
public async $getPegsVolumeDaily(): Promise<any> {
|
||||
const pegInQuery = await DB.query(`SELECT SUM(amount) AS volume, COUNT(*) AS number FROM elements_pegs WHERE amount > 0 and datetime > UNIX_TIMESTAMP(TIMESTAMPADD(DAY, -1, CURRENT_TIMESTAMP()));`);
|
||||
const pegOutQuery = await DB.query(`SELECT SUM(amount) AS volume, COUNT(*) AS number FROM elements_pegs WHERE amount < 0 and datetime > UNIX_TIMESTAMP(TIMESTAMPADD(DAY, -1, CURRENT_TIMESTAMP()));`);
|
||||
return [
|
||||
pegInQuery[0][0],
|
||||
pegOutQuery[0][0]
|
||||
];
|
||||
}
|
||||
|
||||
// Get the total pegs number
|
||||
public async $getPegsCount(): Promise<any> {
|
||||
const [rows] = await DB.query(`SELECT COUNT(*) AS pegs_count FROM elements_pegs;`);
|
||||
return rows[0];
|
||||
}
|
||||
}
|
||||
|
||||
export default new ElementsParser();
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as fs from 'fs';
|
||||
import logger from '../../logger';
|
||||
|
||||
class Icons {
|
||||
private static FILE_NAME = './icons.json';
|
||||
private static FILE_NAME = '/elements/asset_registry_db/icons.json';
|
||||
private iconIds: string[] = [];
|
||||
private icons: { [assetId: string]: string; } = {};
|
||||
|
||||
|
||||
@@ -15,7 +15,18 @@ class LiquidRoutes {
|
||||
|
||||
if (config.DATABASE.ENABLED) {
|
||||
app
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/pegs', this.$getElementsPegs)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/pegs/month', this.$getElementsPegsByMonth)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/pegs/list/:count', this.$getPegsList)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/pegs/volume', this.$getPegsVolumeDaily)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/pegs/count', this.$getPegsCount)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves', this.$getFederationReserves)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/month', this.$getFederationReservesByMonth)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/addresses', this.$getFederationAddresses)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/addresses/total', this.$getFederationAddressesNumber)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/utxos', this.$getFederationUtxos)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/utxos/total', this.$getFederationUtxosNumber)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/status', this.$getFederationAuditStatus)
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -63,11 +74,147 @@ class LiquidRoutes {
|
||||
private async $getElementsPegsByMonth(req: Request, res: Response) {
|
||||
try {
|
||||
const pegs = await elementsParser.$getPegDataByMonth();
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60 * 60).toUTCString());
|
||||
res.json(pegs);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getFederationReservesByMonth(req: Request, res: Response) {
|
||||
try {
|
||||
const reserves = await elementsParser.$getFederationReservesByMonth();
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60 * 60).toUTCString());
|
||||
res.json(reserves);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getElementsPegs(req: Request, res: Response) {
|
||||
try {
|
||||
const currentSupply = await elementsParser.$getCurrentLbtcSupply();
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
|
||||
res.json(currentSupply);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getFederationReserves(req: Request, res: Response) {
|
||||
try {
|
||||
const currentReserves = await elementsParser.$getCurrentFederationReserves();
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
|
||||
res.json(currentReserves);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getFederationAuditStatus(req: Request, res: Response) {
|
||||
try {
|
||||
const auditStatus = await elementsParser.$getAuditStatus();
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
|
||||
res.json(auditStatus);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getFederationAddresses(req: Request, res: Response) {
|
||||
try {
|
||||
const federationAddresses = await elementsParser.$getFederationAddresses();
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
|
||||
res.json(federationAddresses);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getFederationAddressesNumber(req: Request, res: Response) {
|
||||
try {
|
||||
const federationAddresses = await elementsParser.$getFederationAddressesNumber();
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
|
||||
res.json(federationAddresses);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getFederationUtxos(req: Request, res: Response) {
|
||||
try {
|
||||
const federationUtxos = await elementsParser.$getFederationUtxos();
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
|
||||
res.json(federationUtxos);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getFederationUtxosNumber(req: Request, res: Response) {
|
||||
try {
|
||||
const federationUtxos = await elementsParser.$getFederationUtxosNumber();
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
|
||||
res.json(federationUtxos);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getPegsList(req: Request, res: Response) {
|
||||
try {
|
||||
const recentPegs = await elementsParser.$getPegsList(parseInt(req.params?.count));
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
|
||||
res.json(recentPegs);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getPegsVolumeDaily(req: Request, res: Response) {
|
||||
try {
|
||||
const pegsVolume = await elementsParser.$getPegsVolumeDaily();
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
|
||||
res.json(pegsVolume);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getPegsCount(req: Request, res: Response) {
|
||||
try {
|
||||
const pegsCount = await elementsParser.$getPegsCount();
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
|
||||
res.json(pegsCount);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default new LiquidRoutes();
|
||||
|
||||
@@ -31,7 +31,7 @@ class MemoryCache {
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
this.cache = this.cache.filter((cache) => cache.expires < (new Date()));
|
||||
this.cache = this.cache.filter((cache) => cache.expires > (new Date()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { GbtGenerator, GbtResult, ThreadTransaction as RustThreadTransaction, ThreadAcceleration as RustThreadAcceleration } from 'rust-gbt';
|
||||
import logger from '../logger';
|
||||
import { MempoolBlock, TransactionExtended, ThreadTransaction, TransactionStripped, MempoolBlockWithTransactions, MempoolBlockDelta, Ancestor } from '../mempool.interfaces';
|
||||
import { Common } from './common';
|
||||
import { MempoolBlock, MempoolTransactionExtended, MempoolBlockWithTransactions, MempoolBlockDelta, Ancestor, CompactThreadTransaction, EffectiveFeeStats, PoolTag, TransactionClassified, TransactionCompressed, MempoolDeltaChange } from '../mempool.interfaces';
|
||||
import { Common, OnlineFeeStatsCalculator } from './common';
|
||||
import config from '../config';
|
||||
import { Worker } from 'worker_threads';
|
||||
import path from 'path';
|
||||
import mempool from './mempool';
|
||||
|
||||
const MAX_UINT32 = Math.pow(2, 32) - 1;
|
||||
|
||||
class MempoolBlocks {
|
||||
private mempoolBlocks: MempoolBlockWithTransactions[] = [];
|
||||
private mempoolBlockDeltas: MempoolBlockDelta[] = [];
|
||||
private txSelectionWorker: Worker | null = null;
|
||||
private rustInitialized: boolean = false;
|
||||
private rustGbtGenerator: GbtGenerator = new GbtGenerator();
|
||||
|
||||
constructor() {}
|
||||
private nextUid: number = 1;
|
||||
private uidMap: Map<number, string> = new Map(); // map short numerical uids to full txids
|
||||
|
||||
public getMempoolBlocks(): MempoolBlock[] {
|
||||
return this.mempoolBlocks.map((block) => {
|
||||
@@ -33,13 +40,11 @@ class MempoolBlocks {
|
||||
return this.mempoolBlockDeltas;
|
||||
}
|
||||
|
||||
public updateMempoolBlocks(memPool: { [txid: string]: TransactionExtended }, saveResults: boolean = false): MempoolBlockWithTransactions[] {
|
||||
public updateMempoolBlocks(memPool: { [txid: string]: MempoolTransactionExtended }, saveResults: boolean = false): MempoolBlockWithTransactions[] {
|
||||
const latestMempool = memPool;
|
||||
const memPoolArray: TransactionExtended[] = [];
|
||||
const memPoolArray: MempoolTransactionExtended[] = [];
|
||||
for (const i in latestMempool) {
|
||||
if (latestMempool.hasOwnProperty(i)) {
|
||||
memPoolArray.push(latestMempool[i]);
|
||||
}
|
||||
memPoolArray.push(latestMempool[i]);
|
||||
}
|
||||
const start = new Date().getTime();
|
||||
|
||||
@@ -49,17 +54,24 @@ class MempoolBlocks {
|
||||
tx.ancestors = [];
|
||||
tx.cpfpChecked = false;
|
||||
if (!tx.effectiveFeePerVsize) {
|
||||
tx.effectiveFeePerVsize = tx.feePerVsize;
|
||||
tx.effectiveFeePerVsize = tx.adjustedFeePerVsize;
|
||||
}
|
||||
});
|
||||
|
||||
// First sort
|
||||
memPoolArray.sort((a, b) => b.feePerVsize - a.feePerVsize);
|
||||
memPoolArray.sort((a, b) => {
|
||||
if (a.adjustedFeePerVsize === b.adjustedFeePerVsize) {
|
||||
// tie-break by lexicographic txid order for stability
|
||||
return a.txid < b.txid ? -1 : 1;
|
||||
} else {
|
||||
return b.adjustedFeePerVsize - a.adjustedFeePerVsize;
|
||||
}
|
||||
});
|
||||
|
||||
// Loop through and traverse all ancestors and sum up all the sizes + fees
|
||||
// Pass down size + fee to all unconfirmed children
|
||||
let sizes = 0;
|
||||
memPoolArray.forEach((tx, i) => {
|
||||
memPoolArray.forEach((tx) => {
|
||||
sizes += tx.weight;
|
||||
if (sizes > 4000000 * 8) {
|
||||
return;
|
||||
@@ -68,13 +80,20 @@ class MempoolBlocks {
|
||||
});
|
||||
|
||||
// Final sort, by effective fee
|
||||
memPoolArray.sort((a, b) => b.effectiveFeePerVsize - a.effectiveFeePerVsize);
|
||||
memPoolArray.sort((a, b) => {
|
||||
if (a.effectiveFeePerVsize === b.effectiveFeePerVsize) {
|
||||
// tie-break by lexicographic txid order for stability
|
||||
return a.txid < b.txid ? -1 : 1;
|
||||
} else {
|
||||
return b.effectiveFeePerVsize - a.effectiveFeePerVsize;
|
||||
}
|
||||
});
|
||||
|
||||
const end = new Date().getTime();
|
||||
const time = end - start;
|
||||
logger.debug('Mempool blocks calculated in ' + time / 1000 + ' seconds');
|
||||
|
||||
const blocks = this.calculateMempoolBlocks(memPoolArray, this.mempoolBlocks);
|
||||
const blocks = this.calculateMempoolBlocks(memPoolArray);
|
||||
|
||||
if (saveResults) {
|
||||
const deltas = this.calculateMempoolDeltas(this.mempoolBlocks, blocks);
|
||||
@@ -85,26 +104,63 @@ class MempoolBlocks {
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private calculateMempoolBlocks(transactionsSorted: TransactionExtended[], prevBlocks: MempoolBlockWithTransactions[]): MempoolBlockWithTransactions[] {
|
||||
private calculateMempoolBlocks(transactionsSorted: MempoolTransactionExtended[]): MempoolBlockWithTransactions[] {
|
||||
const mempoolBlocks: MempoolBlockWithTransactions[] = [];
|
||||
let blockWeight = 0;
|
||||
let feeStatsCalculator: OnlineFeeStatsCalculator = new OnlineFeeStatsCalculator(config.MEMPOOL.BLOCK_WEIGHT_UNITS);
|
||||
let onlineStats = false;
|
||||
let blockSize = 0;
|
||||
let transactions: TransactionExtended[] = [];
|
||||
transactionsSorted.forEach((tx) => {
|
||||
let blockWeight = 0;
|
||||
let blockVsize = 0;
|
||||
let blockFees = 0;
|
||||
const sizeLimit = (config.MEMPOOL.BLOCK_WEIGHT_UNITS / 4) * 1.2;
|
||||
let transactionIds: string[] = [];
|
||||
let transactions: MempoolTransactionExtended[] = [];
|
||||
transactionsSorted.forEach((tx, index) => {
|
||||
if (blockWeight + tx.weight <= config.MEMPOOL.BLOCK_WEIGHT_UNITS
|
||||
|| mempoolBlocks.length === config.MEMPOOL.MEMPOOL_BLOCKS_AMOUNT - 1) {
|
||||
tx.position = {
|
||||
block: mempoolBlocks.length,
|
||||
vsize: blockVsize + (tx.vsize / 2),
|
||||
};
|
||||
blockWeight += tx.weight;
|
||||
blockVsize += tx.vsize;
|
||||
blockSize += tx.size;
|
||||
transactions.push(tx);
|
||||
blockFees += tx.fee;
|
||||
if (blockVsize <= sizeLimit) {
|
||||
transactions.push(tx);
|
||||
}
|
||||
transactionIds.push(tx.txid);
|
||||
if (onlineStats) {
|
||||
feeStatsCalculator.processNext(tx);
|
||||
}
|
||||
} else {
|
||||
mempoolBlocks.push(this.dataToMempoolBlocks(transactions, mempoolBlocks.length));
|
||||
mempoolBlocks.push(this.dataToMempoolBlocks(transactionIds, transactions, blockSize, blockWeight, blockFees));
|
||||
blockVsize = 0;
|
||||
tx.position = {
|
||||
block: mempoolBlocks.length,
|
||||
vsize: blockVsize + (tx.vsize / 2),
|
||||
};
|
||||
|
||||
if (mempoolBlocks.length === config.MEMPOOL.MEMPOOL_BLOCKS_AMOUNT - 1) {
|
||||
const stackWeight = transactionsSorted.slice(index).reduce((total, tx) => total + (tx.weight || 0), 0);
|
||||
if (stackWeight > config.MEMPOOL.BLOCK_WEIGHT_UNITS) {
|
||||
onlineStats = true;
|
||||
feeStatsCalculator = new OnlineFeeStatsCalculator(stackWeight, 0.5, [10, 20, 30, 40, 50, 60, 70, 80, 90]);
|
||||
feeStatsCalculator.processNext(tx);
|
||||
}
|
||||
}
|
||||
|
||||
blockVsize += tx.vsize;
|
||||
blockWeight = tx.weight;
|
||||
blockSize = tx.size;
|
||||
blockFees = tx.fee;
|
||||
transactionIds = [tx.txid];
|
||||
transactions = [tx];
|
||||
}
|
||||
});
|
||||
if (transactions.length) {
|
||||
mempoolBlocks.push(this.dataToMempoolBlocks(transactions, mempoolBlocks.length));
|
||||
const feeStats = onlineStats ? feeStatsCalculator.getRawFeeStats() : undefined;
|
||||
mempoolBlocks.push(this.dataToMempoolBlocks(transactionIds, transactions, blockSize, blockWeight, blockFees, feeStats));
|
||||
}
|
||||
|
||||
return mempoolBlocks;
|
||||
@@ -113,8 +169,9 @@ class MempoolBlocks {
|
||||
private calculateMempoolDeltas(prevBlocks: MempoolBlockWithTransactions[], mempoolBlocks: MempoolBlockWithTransactions[]): MempoolBlockDelta[] {
|
||||
const mempoolBlockDeltas: MempoolBlockDelta[] = [];
|
||||
for (let i = 0; i < Math.max(mempoolBlocks.length, prevBlocks.length); i++) {
|
||||
let added: TransactionStripped[] = [];
|
||||
let added: TransactionClassified[] = [];
|
||||
let removed: string[] = [];
|
||||
const changed: TransactionClassified[] = [];
|
||||
if (mempoolBlocks[i] && !prevBlocks[i]) {
|
||||
added = mempoolBlocks[i].transactions;
|
||||
} else if (!mempoolBlocks[i] && prevBlocks[i]) {
|
||||
@@ -123,7 +180,7 @@ class MempoolBlocks {
|
||||
const prevIds = {};
|
||||
const newIds = {};
|
||||
prevBlocks[i].transactions.forEach(tx => {
|
||||
prevIds[tx.txid] = true;
|
||||
prevIds[tx.txid] = tx;
|
||||
});
|
||||
mempoolBlocks[i].transactions.forEach(tx => {
|
||||
newIds[tx.txid] = true;
|
||||
@@ -136,30 +193,50 @@ class MempoolBlocks {
|
||||
mempoolBlocks[i].transactions.forEach(tx => {
|
||||
if (!prevIds[tx.txid]) {
|
||||
added.push(tx);
|
||||
} else if (tx.rate !== prevIds[tx.txid].rate || tx.acc !== prevIds[tx.txid].acc) {
|
||||
changed.push(tx);
|
||||
}
|
||||
});
|
||||
}
|
||||
mempoolBlockDeltas.push({
|
||||
added,
|
||||
removed
|
||||
added: added.map(this.compressTx),
|
||||
removed,
|
||||
changed: changed.map(this.compressDeltaChange),
|
||||
});
|
||||
}
|
||||
return mempoolBlockDeltas;
|
||||
}
|
||||
|
||||
public async makeBlockTemplates(newMempool: { [txid: string]: TransactionExtended }, saveResults: boolean = false): Promise<MempoolBlockWithTransactions[]> {
|
||||
public async $makeBlockTemplates(newMempool: { [txid: string]: MempoolTransactionExtended }, saveResults: boolean = false, useAccelerations: boolean = false, accelerationPool?: number): Promise<MempoolBlockWithTransactions[]> {
|
||||
const start = Date.now();
|
||||
|
||||
// reset mempool short ids
|
||||
if (saveResults) {
|
||||
this.resetUids();
|
||||
}
|
||||
// set missing short ids
|
||||
for (const tx of Object.values(newMempool)) {
|
||||
this.setUid(tx, !saveResults);
|
||||
}
|
||||
|
||||
const accelerations = useAccelerations ? mempool.getAccelerations() : {};
|
||||
|
||||
// prepare a stripped down version of the mempool with only the minimum necessary data
|
||||
// to reduce the overhead of passing this data to the worker thread
|
||||
const strippedMempool: { [txid: string]: ThreadTransaction } = {};
|
||||
const strippedMempool: Map<number, CompactThreadTransaction> = new Map();
|
||||
Object.values(newMempool).forEach(entry => {
|
||||
strippedMempool[entry.txid] = {
|
||||
txid: entry.txid,
|
||||
fee: entry.fee,
|
||||
weight: entry.weight,
|
||||
feePerVsize: entry.fee / (entry.weight / 4),
|
||||
effectiveFeePerVsize: entry.fee / (entry.weight / 4),
|
||||
vin: entry.vin.map(v => v.txid),
|
||||
};
|
||||
if (entry.uid !== null && entry.uid !== undefined) {
|
||||
const stripped = {
|
||||
uid: entry.uid,
|
||||
fee: entry.fee + (useAccelerations && (!accelerationPool || accelerations[entry.txid]?.pools?.includes(accelerationPool)) ? (accelerations[entry.txid]?.feeDelta || 0) : 0),
|
||||
weight: (entry.adjustedVsize * 4),
|
||||
sigops: entry.sigops,
|
||||
feePerVsize: entry.adjustedFeePerVsize || entry.feePerVsize,
|
||||
effectiveFeePerVsize: entry.effectiveFeePerVsize || entry.adjustedFeePerVsize || entry.feePerVsize,
|
||||
inputs: entry.vin.map(v => this.getUid(newMempool[v.txid])).filter(uid => (uid !== null && uid !== undefined)) as number[],
|
||||
};
|
||||
strippedMempool.set(entry.uid, stripped);
|
||||
}
|
||||
});
|
||||
|
||||
// (re)initialize tx selection worker thread
|
||||
@@ -178,7 +255,7 @@ class MempoolBlocks {
|
||||
// run the block construction algorithm in a separate thread, and wait for a result
|
||||
let threadErrorListener;
|
||||
try {
|
||||
const workerResultPromise = new Promise<{ blocks: ThreadTransaction[][], clusters: { [root: string]: string[] } }>((resolve, reject) => {
|
||||
const workerResultPromise = new Promise<{ blocks: number[][], rates: Map<number, number>, clusters: Map<number, number[]> }>((resolve, reject) => {
|
||||
threadErrorListener = reject;
|
||||
this.txSelectionWorker?.once('message', (result): void => {
|
||||
resolve(result);
|
||||
@@ -186,144 +263,466 @@ class MempoolBlocks {
|
||||
this.txSelectionWorker?.once('error', reject);
|
||||
});
|
||||
this.txSelectionWorker.postMessage({ type: 'set', mempool: strippedMempool });
|
||||
const { blocks, clusters } = await workerResultPromise;
|
||||
const { blocks, rates, clusters } = this.convertResultTxids(await workerResultPromise);
|
||||
|
||||
// clean up thread error listener
|
||||
this.txSelectionWorker?.removeListener('error', threadErrorListener);
|
||||
|
||||
return this.processBlockTemplates(newMempool, blocks, clusters, saveResults);
|
||||
const processed = this.processBlockTemplates(newMempool, blocks, null, Object.entries(rates), Object.values(clusters), accelerations, accelerationPool, saveResults);
|
||||
|
||||
logger.debug(`makeBlockTemplates completed in ${(Date.now() - start)/1000} seconds`);
|
||||
|
||||
return processed;
|
||||
} catch (e) {
|
||||
logger.err('makeBlockTemplates failed. ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
return this.mempoolBlocks;
|
||||
}
|
||||
|
||||
public async updateBlockTemplates(newMempool: { [txid: string]: TransactionExtended }, added: TransactionExtended[], removed: string[], saveResults: boolean = false): Promise<void> {
|
||||
public async $updateBlockTemplates(newMempool: { [txid: string]: MempoolTransactionExtended }, added: MempoolTransactionExtended[], removed: MempoolTransactionExtended[], accelerationDelta: string[] = [], saveResults: boolean = false, useAccelerations: boolean = false): Promise<void> {
|
||||
if (!this.txSelectionWorker) {
|
||||
// need to reset the worker
|
||||
this.makeBlockTemplates(newMempool, saveResults);
|
||||
await this.$makeBlockTemplates(newMempool, saveResults, useAccelerations);
|
||||
return;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
const accelerations = useAccelerations ? mempool.getAccelerations() : {};
|
||||
const addedAndChanged: MempoolTransactionExtended[] = useAccelerations ? accelerationDelta.map(txid => newMempool[txid]).filter(tx => tx != null).concat(added) : added;
|
||||
|
||||
for (const tx of addedAndChanged) {
|
||||
this.setUid(tx, true);
|
||||
}
|
||||
const removedUids = removed.map(tx => this.getUid(tx)).filter(uid => uid != null) as number[];
|
||||
|
||||
// prepare a stripped down version of the mempool with only the minimum necessary data
|
||||
// to reduce the overhead of passing this data to the worker thread
|
||||
const addedStripped: ThreadTransaction[] = added.map(entry => {
|
||||
const addedStripped: CompactThreadTransaction[] = addedAndChanged.filter(entry => entry.uid != null).map(entry => {
|
||||
return {
|
||||
txid: entry.txid,
|
||||
fee: entry.fee,
|
||||
weight: entry.weight,
|
||||
feePerVsize: entry.fee / (entry.weight / 4),
|
||||
effectiveFeePerVsize: entry.fee / (entry.weight / 4),
|
||||
vin: entry.vin.map(v => v.txid),
|
||||
uid: entry.uid || 0,
|
||||
fee: entry.fee + (useAccelerations ? (accelerations[entry.txid]?.feeDelta || 0) : 0),
|
||||
weight: (entry.adjustedVsize * 4),
|
||||
sigops: entry.sigops,
|
||||
feePerVsize: entry.adjustedFeePerVsize || entry.feePerVsize,
|
||||
effectiveFeePerVsize: entry.effectiveFeePerVsize || entry.adjustedFeePerVsize || entry.feePerVsize,
|
||||
inputs: entry.vin.map(v => this.getUid(newMempool[v.txid])).filter(uid => (uid !== null && uid !== undefined)) as number[],
|
||||
};
|
||||
});
|
||||
|
||||
// run the block construction algorithm in a separate thread, and wait for a result
|
||||
let threadErrorListener;
|
||||
try {
|
||||
const workerResultPromise = new Promise<{ blocks: ThreadTransaction[][], clusters: { [root: string]: string[] } }>((resolve, reject) => {
|
||||
const workerResultPromise = new Promise<{ blocks: number[][], rates: Map<number, number>, clusters: Map<number, number[]> }>((resolve, reject) => {
|
||||
threadErrorListener = reject;
|
||||
this.txSelectionWorker?.once('message', (result): void => {
|
||||
resolve(result);
|
||||
});
|
||||
this.txSelectionWorker?.once('error', reject);
|
||||
});
|
||||
this.txSelectionWorker.postMessage({ type: 'update', added: addedStripped, removed });
|
||||
const { blocks, clusters } = await workerResultPromise;
|
||||
this.txSelectionWorker.postMessage({ type: 'update', added: addedStripped, removed: removedUids });
|
||||
const { blocks, rates, clusters } = this.convertResultTxids(await workerResultPromise);
|
||||
|
||||
this.removeUids(removedUids);
|
||||
|
||||
// clean up thread error listener
|
||||
this.txSelectionWorker?.removeListener('error', threadErrorListener);
|
||||
|
||||
this.processBlockTemplates(newMempool, blocks, clusters, saveResults);
|
||||
this.processBlockTemplates(newMempool, blocks, null, Object.entries(rates), Object.values(clusters), accelerations, null, saveResults);
|
||||
logger.debug(`updateBlockTemplates completed in ${(Date.now() - start) / 1000} seconds`);
|
||||
} catch (e) {
|
||||
logger.err('updateBlockTemplates failed. ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
private processBlockTemplates(mempool, blocks, clusters, saveResults): MempoolBlockWithTransactions[] {
|
||||
// update this thread's mempool with the results
|
||||
blocks.forEach(block => {
|
||||
block.forEach(tx => {
|
||||
if (tx.txid in mempool) {
|
||||
if (tx.effectiveFeePerVsize != null) {
|
||||
mempool[tx.txid].effectiveFeePerVsize = tx.effectiveFeePerVsize;
|
||||
}
|
||||
if (tx.cpfpRoot && tx.cpfpRoot in clusters) {
|
||||
const ancestors: Ancestor[] = [];
|
||||
const descendants: Ancestor[] = [];
|
||||
const cluster = clusters[tx.cpfpRoot];
|
||||
let matched = false;
|
||||
cluster.forEach(txid => {
|
||||
if (txid === tx.txid) {
|
||||
matched = true;
|
||||
} else {
|
||||
const relative = {
|
||||
txid: txid,
|
||||
fee: mempool[txid].fee,
|
||||
weight: mempool[txid].weight,
|
||||
};
|
||||
if (matched) {
|
||||
descendants.push(relative);
|
||||
} else {
|
||||
ancestors.push(relative);
|
||||
}
|
||||
}
|
||||
});
|
||||
mempool[tx.txid].ancestors = ancestors;
|
||||
mempool[tx.txid].descendants = descendants;
|
||||
mempool[tx.txid].bestDescendant = null;
|
||||
}
|
||||
mempool[tx.txid].cpfpChecked = tx.cpfpChecked;
|
||||
}
|
||||
});
|
||||
private resetRustGbt(): void {
|
||||
this.rustInitialized = false;
|
||||
this.rustGbtGenerator = new GbtGenerator();
|
||||
}
|
||||
|
||||
public async $rustMakeBlockTemplates(newMempool: { [txid: string]: MempoolTransactionExtended }, saveResults: boolean = false, useAccelerations: boolean = false, accelerationPool?: number): Promise<MempoolBlockWithTransactions[]> {
|
||||
const start = Date.now();
|
||||
|
||||
// reset mempool short ids
|
||||
if (saveResults) {
|
||||
this.resetUids();
|
||||
}
|
||||
// set missing short ids
|
||||
for (const tx of Object.values(newMempool)) {
|
||||
this.setUid(tx, !saveResults);
|
||||
}
|
||||
// set short ids for transaction inputs
|
||||
for (const tx of Object.values(newMempool)) {
|
||||
tx.inputs = tx.vin.map(v => this.getUid(newMempool[v.txid])).filter(uid => (uid !== null && uid !== undefined)) as number[];
|
||||
}
|
||||
|
||||
const accelerations = useAccelerations ? mempool.getAccelerations() : {};
|
||||
const acceleratedList = accelerationPool ? Object.values(accelerations).filter(acc => newMempool[acc.txid] && acc.pools.includes(accelerationPool)) : Object.values(accelerations).filter(acc => newMempool[acc.txid]);
|
||||
const convertedAccelerations = acceleratedList.map(acc => {
|
||||
return {
|
||||
uid: this.getUid(newMempool[acc.txid]),
|
||||
delta: acc.feeDelta,
|
||||
};
|
||||
});
|
||||
|
||||
// unpack the condensed blocks into proper mempool blocks
|
||||
const mempoolBlocks = blocks.map((transactions, blockIndex) => {
|
||||
return this.dataToMempoolBlocks(transactions.map(tx => {
|
||||
return mempool[tx.txid] || null;
|
||||
}).filter(tx => !!tx), blockIndex);
|
||||
// run the block construction algorithm in a separate thread, and wait for a result
|
||||
const rustGbt = saveResults ? this.rustGbtGenerator : new GbtGenerator();
|
||||
try {
|
||||
const { blocks, blockWeights, rates, clusters, overflow } = this.convertNapiResultTxids(
|
||||
await rustGbt.make(Object.values(newMempool) as RustThreadTransaction[], convertedAccelerations as RustThreadAcceleration[], this.nextUid),
|
||||
);
|
||||
if (saveResults) {
|
||||
this.rustInitialized = true;
|
||||
}
|
||||
const mempoolSize = Object.keys(newMempool).length;
|
||||
const resultMempoolSize = blocks.reduce((total, block) => total + block.length, 0) + overflow.length;
|
||||
logger.debug(`RUST updateBlockTemplates returned ${resultMempoolSize} txs out of ${mempoolSize} in the mempool, ${overflow.length} were unmineable`);
|
||||
const processed = this.processBlockTemplates(newMempool, blocks, blockWeights, rates, clusters, accelerations, accelerationPool, saveResults);
|
||||
logger.debug(`RUST makeBlockTemplates completed in ${(Date.now() - start)/1000} seconds`);
|
||||
return processed;
|
||||
} catch (e) {
|
||||
logger.err('RUST makeBlockTemplates failed. ' + (e instanceof Error ? e.message : e));
|
||||
if (saveResults) {
|
||||
this.resetRustGbt();
|
||||
}
|
||||
}
|
||||
return this.mempoolBlocks;
|
||||
}
|
||||
|
||||
public async $oneOffRustBlockTemplates(newMempool: { [txid: string]: MempoolTransactionExtended }, useAccelerations: boolean, accelerationPool?: number): Promise<MempoolBlockWithTransactions[]> {
|
||||
return this.$rustMakeBlockTemplates(newMempool, false, useAccelerations, accelerationPool);
|
||||
}
|
||||
|
||||
public async $rustUpdateBlockTemplates(newMempool: { [txid: string]: MempoolTransactionExtended }, mempoolSize: number, added: MempoolTransactionExtended[], removed: MempoolTransactionExtended[], useAccelerations: boolean, accelerationPool?: number): Promise<MempoolBlockWithTransactions[]> {
|
||||
// GBT optimization requires that uids never get too sparse
|
||||
// as a sanity check, we should also explicitly prevent uint32 uid overflow
|
||||
if (this.nextUid + added.length >= Math.min(Math.max(262144, 2 * mempoolSize), MAX_UINT32)) {
|
||||
this.resetRustGbt();
|
||||
}
|
||||
|
||||
if (!this.rustInitialized) {
|
||||
// need to reset the worker
|
||||
return this.$rustMakeBlockTemplates(newMempool, true, useAccelerations, accelerationPool);
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
// set missing short ids
|
||||
for (const tx of added) {
|
||||
this.setUid(tx, true);
|
||||
}
|
||||
// set short ids for transaction inputs
|
||||
for (const tx of added) {
|
||||
tx.inputs = tx.vin.map(v => this.getUid(newMempool[v.txid])).filter(uid => (uid !== null && uid !== undefined)) as number[];
|
||||
}
|
||||
const removedUids = removed.map(tx => this.getUid(tx)).filter(uid => (uid !== null && uid !== undefined)) as number[];
|
||||
|
||||
const accelerations = useAccelerations ? mempool.getAccelerations() : {};
|
||||
const acceleratedList = accelerationPool ? Object.values(accelerations).filter(acc => newMempool[acc.txid] && acc.pools.includes(accelerationPool)) : Object.values(accelerations).filter(acc => newMempool[acc.txid]);
|
||||
const convertedAccelerations = acceleratedList.map(acc => {
|
||||
return {
|
||||
uid: this.getUid(newMempool[acc.txid]),
|
||||
delta: acc.feeDelta,
|
||||
};
|
||||
});
|
||||
|
||||
// run the block construction algorithm in a separate thread, and wait for a result
|
||||
try {
|
||||
const { blocks, blockWeights, rates, clusters, overflow } = this.convertNapiResultTxids(
|
||||
await this.rustGbtGenerator.update(
|
||||
added as RustThreadTransaction[],
|
||||
removedUids,
|
||||
convertedAccelerations as RustThreadAcceleration[],
|
||||
this.nextUid,
|
||||
),
|
||||
);
|
||||
const resultMempoolSize = blocks.reduce((total, block) => total + block.length, 0) + overflow.length;
|
||||
logger.debug(`RUST updateBlockTemplates returned ${resultMempoolSize} txs out of ${mempoolSize} in the mempool, ${overflow.length} were unmineable`);
|
||||
if (mempoolSize !== resultMempoolSize) {
|
||||
throw new Error('GBT returned wrong number of transactions , cache is probably out of sync');
|
||||
} else {
|
||||
const processed = this.processBlockTemplates(newMempool, blocks, blockWeights, rates, clusters, accelerations, accelerationPool, true);
|
||||
this.removeUids(removedUids);
|
||||
logger.debug(`RUST updateBlockTemplates completed in ${(Date.now() - start)/1000} seconds`);
|
||||
return processed;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err('RUST updateBlockTemplates failed. ' + (e instanceof Error ? e.message : e));
|
||||
this.resetRustGbt();
|
||||
return this.mempoolBlocks;
|
||||
}
|
||||
}
|
||||
|
||||
private processBlockTemplates(mempool: { [txid: string]: MempoolTransactionExtended }, blocks: string[][], blockWeights: number[] | null, rates: [string, number][], clusters: string[][], accelerations, accelerationPool, saveResults): MempoolBlockWithTransactions[] {
|
||||
for (const [txid, rate] of rates) {
|
||||
if (txid in mempool) {
|
||||
mempool[txid].cpfpDirty = (rate !== mempool[txid].effectiveFeePerVsize);
|
||||
mempool[txid].effectiveFeePerVsize = rate;
|
||||
mempool[txid].cpfpChecked = false;
|
||||
}
|
||||
}
|
||||
|
||||
const lastBlockIndex = blocks.length - 1;
|
||||
let hasBlockStack = blocks.length >= 8;
|
||||
let stackWeight;
|
||||
let feeStatsCalculator: OnlineFeeStatsCalculator | void;
|
||||
if (hasBlockStack) {
|
||||
if (blockWeights && blockWeights[7] !== null) {
|
||||
stackWeight = blockWeights[7];
|
||||
} else {
|
||||
stackWeight = blocks[lastBlockIndex].reduce((total, tx) => total + (mempool[tx]?.weight || 0), 0);
|
||||
}
|
||||
hasBlockStack = stackWeight > config.MEMPOOL.BLOCK_WEIGHT_UNITS;
|
||||
feeStatsCalculator = new OnlineFeeStatsCalculator(stackWeight, 0.5, [10, 20, 30, 40, 50, 60, 70, 80, 90]);
|
||||
}
|
||||
|
||||
for (const cluster of clusters) {
|
||||
for (const memberTxid of cluster) {
|
||||
const mempoolTx = mempool[memberTxid];
|
||||
if (mempoolTx) {
|
||||
const ancestors: Ancestor[] = [];
|
||||
const descendants: Ancestor[] = [];
|
||||
let matched = false;
|
||||
cluster.forEach(txid => {
|
||||
if (txid === memberTxid) {
|
||||
matched = true;
|
||||
} else {
|
||||
const relative = {
|
||||
txid: txid,
|
||||
fee: mempool[txid].fee,
|
||||
weight: (mempool[txid].adjustedVsize * 4),
|
||||
};
|
||||
if (matched) {
|
||||
descendants.push(relative);
|
||||
mempoolTx.lastBoosted = Math.max(mempoolTx.lastBoosted || 0, mempool[txid].firstSeen || 0);
|
||||
} 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 isAccelerated : { [txid: string]: boolean } = {};
|
||||
|
||||
const sizeLimit = (config.MEMPOOL.BLOCK_WEIGHT_UNITS / 4) * 1.2;
|
||||
// update this thread's mempool with the results
|
||||
let mempoolTx: MempoolTransactionExtended;
|
||||
const mempoolBlocks: MempoolBlockWithTransactions[] = blocks.map((block, blockIndex) => {
|
||||
let totalSize = 0;
|
||||
let totalVsize = 0;
|
||||
let totalWeight = 0;
|
||||
let totalFees = 0;
|
||||
const transactions: MempoolTransactionExtended[] = [];
|
||||
for (const txid of block) {
|
||||
if (txid) {
|
||||
mempoolTx = mempool[txid];
|
||||
// save position in projected blocks
|
||||
mempoolTx.position = {
|
||||
block: blockIndex,
|
||||
vsize: totalVsize + (mempoolTx.vsize / 2),
|
||||
};
|
||||
if (!mempoolTx.cpfpChecked) {
|
||||
if (mempoolTx.ancestors?.length) {
|
||||
mempoolTx.ancestors = [];
|
||||
}
|
||||
if (mempoolTx.descendants?.length) {
|
||||
mempoolTx.descendants = [];
|
||||
}
|
||||
mempoolTx.bestDescendant = null;
|
||||
mempoolTx.cpfpChecked = true;
|
||||
}
|
||||
|
||||
const acceleration = accelerations[txid];
|
||||
if (isAccelerated[txid] || (acceleration && (!accelerationPool || acceleration.pools.includes(accelerationPool)))) {
|
||||
if (!mempoolTx.acceleration) {
|
||||
mempoolTx.cpfpDirty = true;
|
||||
}
|
||||
mempoolTx.acceleration = true;
|
||||
for (const ancestor of mempoolTx.ancestors || []) {
|
||||
if (!mempool[ancestor.txid].acceleration) {
|
||||
mempool[ancestor.txid].cpfpDirty = true;
|
||||
}
|
||||
mempool[ancestor.txid].acceleration = true;
|
||||
isAccelerated[ancestor.txid] = true;
|
||||
}
|
||||
} else {
|
||||
if (mempoolTx.acceleration) {
|
||||
mempoolTx.cpfpDirty = true;
|
||||
}
|
||||
delete mempoolTx.acceleration;
|
||||
}
|
||||
|
||||
// online calculation of stack-of-blocks fee stats
|
||||
if (hasBlockStack && blockIndex === lastBlockIndex && feeStatsCalculator) {
|
||||
feeStatsCalculator.processNext(mempoolTx);
|
||||
}
|
||||
|
||||
totalSize += mempoolTx.size;
|
||||
totalVsize += mempoolTx.vsize;
|
||||
totalWeight += mempoolTx.weight;
|
||||
totalFees += mempoolTx.fee;
|
||||
|
||||
if (totalVsize <= sizeLimit) {
|
||||
transactions.push(mempoolTx);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.dataToMempoolBlocks(
|
||||
block,
|
||||
transactions,
|
||||
totalSize,
|
||||
totalWeight,
|
||||
totalFees,
|
||||
(hasBlockStack && blockIndex === lastBlockIndex && feeStatsCalculator) ? feeStatsCalculator.getRawFeeStats() : undefined,
|
||||
);
|
||||
});
|
||||
|
||||
if (saveResults) {
|
||||
const deltas = this.calculateMempoolDeltas(this.mempoolBlocks, mempoolBlocks);
|
||||
this.mempoolBlocks = mempoolBlocks;
|
||||
this.mempoolBlockDeltas = deltas;
|
||||
|
||||
}
|
||||
|
||||
return mempoolBlocks;
|
||||
}
|
||||
|
||||
private dataToMempoolBlocks(transactions: TransactionExtended[], blocksIndex: number): MempoolBlockWithTransactions {
|
||||
let totalSize = 0;
|
||||
let totalWeight = 0;
|
||||
const fitTransactions: TransactionExtended[] = [];
|
||||
transactions.forEach(tx => {
|
||||
totalSize += tx.size;
|
||||
totalWeight += tx.weight;
|
||||
if ((totalWeight + tx.weight) <= config.MEMPOOL.BLOCK_WEIGHT_UNITS * 1.2) {
|
||||
fitTransactions.push(tx);
|
||||
}
|
||||
});
|
||||
let rangeLength = 4;
|
||||
if (blocksIndex === 0) {
|
||||
rangeLength = 8;
|
||||
}
|
||||
if (transactions.length > 4000) {
|
||||
rangeLength = 6;
|
||||
} else if (transactions.length > 10000) {
|
||||
rangeLength = 8;
|
||||
private dataToMempoolBlocks(transactionIds: string[], transactions: MempoolTransactionExtended[], totalSize: number, totalWeight: number, totalFees: number, feeStats?: EffectiveFeeStats ): MempoolBlockWithTransactions {
|
||||
if (!feeStats) {
|
||||
feeStats = Common.calcEffectiveFeeStatistics(transactions.filter(tx => !tx.acceleration));
|
||||
}
|
||||
return {
|
||||
blockSize: totalSize,
|
||||
blockVSize: totalWeight / 4,
|
||||
nTx: transactions.length,
|
||||
totalFees: transactions.reduce((acc, cur) => acc + cur.fee, 0),
|
||||
medianFee: Common.percentile(transactions.map((tx) => tx.effectiveFeePerVsize), config.MEMPOOL.RECOMMENDED_FEE_PERCENTILE),
|
||||
feeRange: Common.getFeesInRange(transactions, rangeLength),
|
||||
transactionIds: transactions.map((tx) => tx.txid),
|
||||
transactions: fitTransactions.map((tx) => Common.stripTransaction(tx)),
|
||||
blockVSize: (totalWeight / 4), // fractional vsize to avoid rounding errors
|
||||
nTx: transactionIds.length,
|
||||
totalFees: totalFees,
|
||||
medianFee: feeStats.medianFee, // Common.percentile(transactions.map((tx) => tx.effectiveFeePerVsize), config.MEMPOOL.RECOMMENDED_FEE_PERCENTILE),
|
||||
feeRange: feeStats.feeRange, //Common.getFeesInRange(transactions, rangeLength),
|
||||
transactionIds: transactionIds,
|
||||
transactions: transactions.map((tx) => Common.classifyTransaction(tx)),
|
||||
};
|
||||
}
|
||||
|
||||
private resetUids(): void {
|
||||
this.uidMap.clear();
|
||||
this.nextUid = 1;
|
||||
}
|
||||
|
||||
private setUid(tx: MempoolTransactionExtended, skipSet = false): number {
|
||||
if (tx.uid === null || tx.uid === undefined || !skipSet) {
|
||||
const uid = this.nextUid;
|
||||
this.nextUid++;
|
||||
this.uidMap.set(uid, tx.txid);
|
||||
tx.uid = uid;
|
||||
return uid;
|
||||
} else {
|
||||
return tx.uid;
|
||||
}
|
||||
}
|
||||
|
||||
private getUid(tx: MempoolTransactionExtended): number | void {
|
||||
if (tx?.uid !== null && tx?.uid !== undefined && this.uidMap.has(tx.uid)) {
|
||||
return tx.uid;
|
||||
}
|
||||
}
|
||||
|
||||
private removeUids(uids: number[]): void {
|
||||
for (const uid of uids) {
|
||||
this.uidMap.delete(uid);
|
||||
}
|
||||
}
|
||||
|
||||
private convertResultTxids({ blocks, rates, clusters }: { blocks: number[][], rates: Map<number, number>, clusters: Map<number, number[]>})
|
||||
: { blocks: string[][], rates: { [root: string]: number }, clusters: { [root: string]: string[] }} {
|
||||
const convertedBlocks: string[][] = blocks.map(block => block.map(uid => {
|
||||
return this.uidMap.get(uid) || '';
|
||||
}));
|
||||
const convertedRates = {};
|
||||
for (const rateUid of rates.keys()) {
|
||||
const rateTxid = this.uidMap.get(rateUid);
|
||||
if (rateTxid) {
|
||||
convertedRates[rateTxid] = rates.get(rateUid);
|
||||
}
|
||||
}
|
||||
const convertedClusters = {};
|
||||
for (const rootUid of clusters.keys()) {
|
||||
const rootTxid = this.uidMap.get(rootUid);
|
||||
if (rootTxid) {
|
||||
const members = clusters.get(rootUid)?.map(uid => {
|
||||
return this.uidMap.get(uid);
|
||||
});
|
||||
convertedClusters[rootTxid] = members;
|
||||
}
|
||||
}
|
||||
return { blocks: convertedBlocks, rates: convertedRates, clusters: convertedClusters } as { blocks: string[][], rates: { [root: string]: number }, clusters: { [root: string]: string[] }};
|
||||
}
|
||||
|
||||
private convertNapiResultTxids({ blocks, blockWeights, rates, clusters, overflow }: GbtResult)
|
||||
: { blocks: string[][], blockWeights: number[], rates: [string, number][], clusters: string[][], overflow: string[] } {
|
||||
const convertedBlocks: string[][] = blocks.map(block => block.map(uid => {
|
||||
const txid = this.uidMap.get(uid);
|
||||
if (txid !== undefined) {
|
||||
return txid;
|
||||
} else {
|
||||
throw new Error('GBT returned a block containing a transaction with unknown uid');
|
||||
}
|
||||
}));
|
||||
const convertedRates: [string, number][] = [];
|
||||
for (const [rateUid, rate] of rates) {
|
||||
const rateTxid = this.uidMap.get(rateUid) as string;
|
||||
convertedRates.push([rateTxid, rate]);
|
||||
}
|
||||
const convertedClusters: string[][] = [];
|
||||
for (const cluster of clusters) {
|
||||
convertedClusters.push(cluster.map(uid => this.uidMap.get(uid)) as string[]);
|
||||
}
|
||||
const convertedOverflow: string[] = overflow.map(uid => {
|
||||
const txid = this.uidMap.get(uid);
|
||||
if (txid !== undefined) {
|
||||
return txid;
|
||||
} else {
|
||||
throw new Error('GBT returned an unmineable transaction with unknown uid');
|
||||
}
|
||||
});
|
||||
return { blocks: convertedBlocks, blockWeights, rates: convertedRates, clusters: convertedClusters, overflow: convertedOverflow };
|
||||
}
|
||||
|
||||
public compressTx(tx: TransactionClassified): TransactionCompressed {
|
||||
if (tx.acc) {
|
||||
return [
|
||||
tx.txid,
|
||||
tx.fee,
|
||||
tx.vsize,
|
||||
tx.value,
|
||||
Math.round((tx.rate || (tx.fee / tx.vsize)) * 100) / 100,
|
||||
tx.flags,
|
||||
1
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
tx.txid,
|
||||
tx.fee,
|
||||
tx.vsize,
|
||||
tx.value,
|
||||
Math.round((tx.rate || (tx.fee / tx.vsize)) * 100) / 100,
|
||||
tx.flags,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
public compressDeltaChange(tx: TransactionClassified): MempoolDeltaChange {
|
||||
return [
|
||||
tx.txid,
|
||||
Math.round((tx.rate || (tx.fee / tx.vsize)) * 100) / 100,
|
||||
tx.flags,
|
||||
tx.acc ? 1 : 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export default new MempoolBlocks();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import config from '../config';
|
||||
import bitcoinApi from './bitcoin/bitcoin-api-factory';
|
||||
import { TransactionExtended, VbytesPerSecond } from '../mempool.interfaces';
|
||||
import { MempoolTransactionExtended, TransactionExtended, VbytesPerSecond } from '../mempool.interfaces';
|
||||
import logger from '../logger';
|
||||
import { Common } from './common';
|
||||
import transactionUtils from './transaction-utils';
|
||||
@@ -9,19 +9,22 @@ import loadingIndicators from './loading-indicators';
|
||||
import bitcoinClient from './bitcoin/bitcoin-client';
|
||||
import bitcoinSecondClient from './bitcoin/bitcoin-second-client';
|
||||
import rbfCache from './rbf-cache';
|
||||
import { Acceleration } from './services/acceleration';
|
||||
import redisCache from './redis-cache';
|
||||
|
||||
class Mempool {
|
||||
private static WEBSOCKET_REFRESH_RATE_MS = 10000;
|
||||
private static LAZY_DELETE_AFTER_SECONDS = 30;
|
||||
private inSync: boolean = false;
|
||||
private mempoolCacheDelta: number = -1;
|
||||
private mempoolCache: { [txId: string]: TransactionExtended } = {};
|
||||
private mempoolCache: { [txId: string]: MempoolTransactionExtended } = {};
|
||||
private spendMap = new Map<string, MempoolTransactionExtended>();
|
||||
private mempoolInfo: IBitcoinApi.MempoolInfo = { loaded: false, size: 0, bytes: 0, usage: 0, total_fee: 0,
|
||||
maxmempool: 300000000, mempoolminfee: 0.00001000, minrelaytxfee: 0.00001000 };
|
||||
private mempoolChangedCallback: ((newMempool: {[txId: string]: TransactionExtended; }, newTransactions: TransactionExtended[],
|
||||
deletedTransactions: TransactionExtended[]) => void) | undefined;
|
||||
private asyncMempoolChangedCallback: ((newMempool: {[txId: string]: TransactionExtended; }, newTransactions: TransactionExtended[],
|
||||
deletedTransactions: TransactionExtended[]) => Promise<void>) | undefined;
|
||||
maxmempool: 300000000, mempoolminfee: Common.isLiquid() ? 0.00000100 : 0.00001000, minrelaytxfee: Common.isLiquid() ? 0.00000100 : 0.00001000 };
|
||||
private mempoolChangedCallback: ((newMempool: {[txId: string]: MempoolTransactionExtended; }, newTransactions: MempoolTransactionExtended[],
|
||||
deletedTransactions: MempoolTransactionExtended[], accelerationDelta: string[]) => void) | undefined;
|
||||
private $asyncMempoolChangedCallback: ((newMempool: {[txId: string]: MempoolTransactionExtended; }, mempoolSize: number, newTransactions: MempoolTransactionExtended[],
|
||||
deletedTransactions: MempoolTransactionExtended[], accelerationDelta: string[]) => Promise<void>) | undefined;
|
||||
|
||||
private accelerations: { [txId: string]: Acceleration } = {};
|
||||
|
||||
private txPerSecondArray: number[] = [];
|
||||
private txPerSecond: number = 0;
|
||||
@@ -35,10 +38,10 @@ class Mempool {
|
||||
private SAMPLE_TIME = 10000; // In ms
|
||||
private timer = new Date().getTime();
|
||||
private missingTxCount = 0;
|
||||
private mainLoopTimeout: number = 120000;
|
||||
|
||||
constructor() {
|
||||
setInterval(this.updateTxPerSecond.bind(this), 1000);
|
||||
setInterval(this.deleteExpiredTransactions.bind(this), 20000);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,28 +68,95 @@ class Mempool {
|
||||
return this.latestTransactions;
|
||||
}
|
||||
|
||||
public setMempoolChangedCallback(fn: (newMempool: { [txId: string]: TransactionExtended; },
|
||||
newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]) => void) {
|
||||
public setMempoolChangedCallback(fn: (newMempool: { [txId: string]: MempoolTransactionExtended; },
|
||||
newTransactions: MempoolTransactionExtended[], deletedTransactions: MempoolTransactionExtended[], accelerationDelta: string[]) => void): void {
|
||||
this.mempoolChangedCallback = fn;
|
||||
}
|
||||
|
||||
public setAsyncMempoolChangedCallback(fn: (newMempool: { [txId: string]: TransactionExtended; },
|
||||
newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]) => Promise<void>) {
|
||||
this.asyncMempoolChangedCallback = fn;
|
||||
public setAsyncMempoolChangedCallback(fn: (newMempool: { [txId: string]: MempoolTransactionExtended; }, mempoolSize: number,
|
||||
newTransactions: MempoolTransactionExtended[], deletedTransactions: MempoolTransactionExtended[], accelerationDelta: string[]) => Promise<void>): void {
|
||||
this.$asyncMempoolChangedCallback = fn;
|
||||
}
|
||||
|
||||
public getMempool(): { [txid: string]: TransactionExtended } {
|
||||
public getMempool(): { [txid: string]: MempoolTransactionExtended } {
|
||||
return this.mempoolCache;
|
||||
}
|
||||
|
||||
public setMempool(mempoolData: { [txId: string]: TransactionExtended }) {
|
||||
public getSpendMap(): Map<string, MempoolTransactionExtended> {
|
||||
return this.spendMap;
|
||||
}
|
||||
|
||||
public async $setMempool(mempoolData: { [txId: string]: MempoolTransactionExtended }) {
|
||||
this.mempoolCache = mempoolData;
|
||||
let count = 0;
|
||||
const redisTimer = Date.now();
|
||||
if (config.MEMPOOL.CACHE_ENABLED && config.REDIS.ENABLED) {
|
||||
logger.debug(`Migrating ${Object.keys(this.mempoolCache).length} transactions from disk cache to Redis cache`);
|
||||
}
|
||||
for (const txid of Object.keys(this.mempoolCache)) {
|
||||
if (!this.mempoolCache[txid].adjustedVsize || this.mempoolCache[txid].sigops == null || this.mempoolCache[txid].effectiveFeePerVsize == null) {
|
||||
this.mempoolCache[txid] = transactionUtils.extendMempoolTransaction(this.mempoolCache[txid]);
|
||||
}
|
||||
if (this.mempoolCache[txid].order == null) {
|
||||
this.mempoolCache[txid].order = transactionUtils.txidToOrdering(txid);
|
||||
}
|
||||
for (const vin of this.mempoolCache[txid].vin) {
|
||||
transactionUtils.addInnerScriptsToVin(vin);
|
||||
}
|
||||
count++;
|
||||
if (config.MEMPOOL.CACHE_ENABLED && config.REDIS.ENABLED) {
|
||||
await redisCache.$addTransaction(this.mempoolCache[txid]);
|
||||
}
|
||||
}
|
||||
if (config.MEMPOOL.CACHE_ENABLED && config.REDIS.ENABLED) {
|
||||
await redisCache.$flushTransactions();
|
||||
logger.debug(`Finished migrating cache transactions in ${((Date.now() - redisTimer) / 1000).toFixed(2)} seconds`);
|
||||
}
|
||||
if (this.mempoolChangedCallback) {
|
||||
this.mempoolChangedCallback(this.mempoolCache, [], []);
|
||||
this.mempoolChangedCallback(this.mempoolCache, [], [], []);
|
||||
}
|
||||
if (this.asyncMempoolChangedCallback) {
|
||||
this.asyncMempoolChangedCallback(this.mempoolCache, [], []);
|
||||
if (this.$asyncMempoolChangedCallback) {
|
||||
await this.$asyncMempoolChangedCallback(this.mempoolCache, count, [], [], []);
|
||||
}
|
||||
this.addToSpendMap(Object.values(this.mempoolCache));
|
||||
}
|
||||
|
||||
public async $reloadMempool(expectedCount: number): Promise<MempoolTransactionExtended[]> {
|
||||
let count = 0;
|
||||
let done = false;
|
||||
let last_txid;
|
||||
const newTransactions: MempoolTransactionExtended[] = [];
|
||||
loadingIndicators.setProgress('mempool', count / expectedCount * 100);
|
||||
while (!done) {
|
||||
try {
|
||||
const result = await bitcoinApi.$getAllMempoolTransactions(last_txid, config.ESPLORA.BATCH_QUERY_BASE_SIZE);
|
||||
if (result) {
|
||||
for (const tx of result) {
|
||||
const extendedTransaction = transactionUtils.extendMempoolTransaction(tx);
|
||||
if (!this.mempoolCache[extendedTransaction.txid]) {
|
||||
newTransactions.push(extendedTransaction);
|
||||
this.mempoolCache[extendedTransaction.txid] = extendedTransaction;
|
||||
}
|
||||
count++;
|
||||
}
|
||||
logger.info(`Fetched ${count} of ${expectedCount} mempool transactions from esplora`);
|
||||
if (result.length > 0) {
|
||||
last_txid = result[result.length - 1].txid;
|
||||
} else {
|
||||
done = true;
|
||||
}
|
||||
if (Math.floor((count / expectedCount) * 100) < 100) {
|
||||
loadingIndicators.setProgress('mempool', count / expectedCount * 100);
|
||||
}
|
||||
} else {
|
||||
done = true;
|
||||
}
|
||||
} catch(err) {
|
||||
logger.err('failed to fetch bulk mempool transactions from esplora');
|
||||
}
|
||||
}
|
||||
logger.info(`Done inserting loaded mempool transactions into local cache`);
|
||||
return newTransactions;
|
||||
}
|
||||
|
||||
public async $updateMemPoolInfo() {
|
||||
@@ -118,19 +188,23 @@ class Mempool {
|
||||
return txTimes;
|
||||
}
|
||||
|
||||
public async $updateMempool(): Promise<void> {
|
||||
public async $updateMempool(transactions: string[], accelerations: Acceleration[] | null, pollRate: number): Promise<void> {
|
||||
logger.debug(`Updating mempool...`);
|
||||
|
||||
// warn if this run stalls the main loop for more than 2 minutes
|
||||
const timer = this.startTimer();
|
||||
|
||||
const start = new Date().getTime();
|
||||
let hasChange: boolean = false;
|
||||
const currentMempoolSize = Object.keys(this.mempoolCache).length;
|
||||
const transactions = await bitcoinApi.$getRawMempool();
|
||||
this.updateTimerProgress(timer, 'got raw mempool');
|
||||
const diff = transactions.length - currentMempoolSize;
|
||||
const newTransactions: TransactionExtended[] = [];
|
||||
let newTransactions: MempoolTransactionExtended[] = [];
|
||||
|
||||
this.mempoolCacheDelta = Math.abs(diff);
|
||||
|
||||
if (!this.inSync) {
|
||||
loadingIndicators.setProgress('mempool', Object.keys(this.mempoolCache).length / transactions.length * 100);
|
||||
loadingIndicators.setProgress('mempool', currentMempoolSize / transactions.length * 100);
|
||||
}
|
||||
|
||||
// https://github.com/mempool/mempool/issues/3283
|
||||
@@ -143,11 +217,36 @@ class Mempool {
|
||||
}
|
||||
};
|
||||
|
||||
for (const txid of transactions) {
|
||||
if (!this.mempoolCache[txid]) {
|
||||
try {
|
||||
const transaction = await transactionUtils.$getTransactionExtended(txid);
|
||||
this.mempoolCache[txid] = transaction;
|
||||
let intervalTimer = Date.now();
|
||||
|
||||
let loaded = false;
|
||||
if (config.MEMPOOL.BACKEND === 'esplora' && currentMempoolSize < transactions.length * 0.5 && transactions.length > 20_000) {
|
||||
this.inSync = false;
|
||||
logger.info(`Missing ${transactions.length - currentMempoolSize} mempool transactions, attempting to reload in bulk from esplora`);
|
||||
try {
|
||||
newTransactions = await this.$reloadMempool(transactions.length);
|
||||
if (config.REDIS.ENABLED) {
|
||||
for (const tx of newTransactions) {
|
||||
await redisCache.$addTransaction(tx);
|
||||
}
|
||||
}
|
||||
loaded = true;
|
||||
} catch (e) {
|
||||
logger.err('failed to load mempool in bulk from esplora, falling back to fetching individual transactions');
|
||||
}
|
||||
}
|
||||
|
||||
if (!loaded) {
|
||||
const remainingTxids = transactions.filter(txid => !this.mempoolCache[txid]);
|
||||
const sliceLength = config.ESPLORA.BATCH_QUERY_BASE_SIZE;
|
||||
for (let i = 0; i < Math.ceil(remainingTxids.length / sliceLength); i++) {
|
||||
const slice = remainingTxids.slice(i * sliceLength, (i + 1) * sliceLength);
|
||||
const txs = await transactionUtils.$getMempoolTransactionsExtended(slice, false, false, false);
|
||||
logger.debug(`fetched ${txs.length} transactions`);
|
||||
this.updateTimerProgress(timer, 'fetched new transactions');
|
||||
|
||||
for (const transaction of txs) {
|
||||
this.mempoolCache[transaction.txid] = transaction;
|
||||
if (this.inSync) {
|
||||
this.txPerSecondArray.push(new Date().getTime());
|
||||
this.vBytesPerSecondArray.push({
|
||||
@@ -157,16 +256,35 @@ class Mempool {
|
||||
}
|
||||
hasChange = true;
|
||||
newTransactions.push(transaction);
|
||||
} catch (e: any) {
|
||||
if (config.MEMPOOL.BACKEND === 'esplora' && e.response?.status === 404) {
|
||||
this.missingTxCount++;
|
||||
}
|
||||
logger.debug(`Error finding transaction '${txid}' in the mempool: ` + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
if ((new Date().getTime()) - start > Mempool.WEBSOCKET_REFRESH_RATE_MS) {
|
||||
break;
|
||||
if (config.REDIS.ENABLED) {
|
||||
await redisCache.$addTransaction(transaction);
|
||||
}
|
||||
}
|
||||
|
||||
if (txs.length < slice.length) {
|
||||
const missing = slice.length - txs.length;
|
||||
if (config.MEMPOOL.BACKEND === 'esplora') {
|
||||
this.missingTxCount += missing;
|
||||
}
|
||||
logger.debug(`Error finding ${missing} transactions in the mempool: `);
|
||||
}
|
||||
|
||||
if (Date.now() - intervalTimer > Math.max(pollRate * 2, 5_000)) {
|
||||
if (this.inSync) {
|
||||
// Break and restart mempool loop if we spend too much time processing
|
||||
// new transactions that may lead to falling behind on block height
|
||||
logger.debug('Breaking mempool loop because the 5s time limit exceeded.');
|
||||
break;
|
||||
} else {
|
||||
const progress = (currentMempoolSize + newTransactions.length) / transactions.length * 100;
|
||||
logger.debug(`Mempool is synchronizing. Processed ${newTransactions.length}/${diff} txs (${Math.round(progress)}%)`);
|
||||
if (Math.floor(progress) < 100) {
|
||||
loadingIndicators.setProgress('mempool', progress);
|
||||
}
|
||||
intervalTimer = Date.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,11 +306,11 @@ class Mempool {
|
||||
logger.warn(`Mempool clear protection triggered because transactions.length: ${transactions.length} and currentMempoolSize: ${currentMempoolSize}.`);
|
||||
setTimeout(() => {
|
||||
this.mempoolProtection = 2;
|
||||
logger.warn('Mempool clear protection resumed.');
|
||||
logger.warn('Mempool clear protection ended, normal operation resumed.');
|
||||
}, 1000 * 60 * config.MEMPOOL.CLEAR_PROTECTION_MINUTES);
|
||||
}
|
||||
|
||||
const deletedTransactions: TransactionExtended[] = [];
|
||||
const deletedTransactions: MempoolTransactionExtended[] = [];
|
||||
|
||||
if (this.mempoolProtection !== 1) {
|
||||
this.mempoolProtection = 0;
|
||||
@@ -200,45 +318,174 @@ class Mempool {
|
||||
const transactionsObject = {};
|
||||
transactions.forEach((txId) => transactionsObject[txId] = true);
|
||||
|
||||
// Flag transactions for lazy deletion
|
||||
// Delete evicted transactions from mempool
|
||||
for (const tx in this.mempoolCache) {
|
||||
if (!transactionsObject[tx] && !this.mempoolCache[tx].deleteAfter) {
|
||||
if (!transactionsObject[tx]) {
|
||||
deletedTransactions.push(this.mempoolCache[tx]);
|
||||
this.mempoolCache[tx].deleteAfter = new Date().getTime() + Mempool.LAZY_DELETE_AFTER_SECONDS * 1000;
|
||||
}
|
||||
}
|
||||
for (const tx of deletedTransactions) {
|
||||
delete this.mempoolCache[tx.txid];
|
||||
}
|
||||
}
|
||||
|
||||
const newMempoolSize = currentMempoolSize + newTransactions.length - deletedTransactions.length;
|
||||
const newTransactionsStripped = newTransactions.map((tx) => Common.stripTransaction(tx));
|
||||
this.latestTransactions = newTransactionsStripped.concat(this.latestTransactions).slice(0, 6);
|
||||
|
||||
if (!this.inSync && transactions.length === Object.keys(this.mempoolCache).length) {
|
||||
const accelerationDelta = accelerations != null ? await this.$updateAccelerations(accelerations) : [];
|
||||
if (accelerationDelta.length) {
|
||||
hasChange = true;
|
||||
}
|
||||
|
||||
this.mempoolCacheDelta = Math.abs(transactions.length - newMempoolSize);
|
||||
|
||||
if (this.mempoolChangedCallback && (hasChange || deletedTransactions.length)) {
|
||||
this.mempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions, accelerationDelta);
|
||||
}
|
||||
if (this.$asyncMempoolChangedCallback && (hasChange || deletedTransactions.length)) {
|
||||
this.updateTimerProgress(timer, 'running async mempool callback');
|
||||
await this.$asyncMempoolChangedCallback(this.mempoolCache, newMempoolSize, newTransactions, deletedTransactions, accelerationDelta);
|
||||
this.updateTimerProgress(timer, 'completed async mempool callback');
|
||||
}
|
||||
|
||||
if (!this.inSync && transactions.length === newMempoolSize) {
|
||||
this.inSync = true;
|
||||
logger.notice('The mempool is now in sync!');
|
||||
loadingIndicators.setProgress('mempool', 100);
|
||||
}
|
||||
|
||||
this.mempoolCacheDelta = Math.abs(transactions.length - Object.keys(this.mempoolCache).length);
|
||||
|
||||
if (this.mempoolChangedCallback && (hasChange || deletedTransactions.length)) {
|
||||
this.mempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions);
|
||||
}
|
||||
if (this.asyncMempoolChangedCallback && (hasChange || deletedTransactions.length)) {
|
||||
await this.asyncMempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions);
|
||||
// Update Redis cache
|
||||
if (config.REDIS.ENABLED) {
|
||||
await redisCache.$flushTransactions();
|
||||
await redisCache.$removeTransactions(deletedTransactions.map(tx => tx.txid));
|
||||
await rbfCache.updateCache();
|
||||
}
|
||||
|
||||
const end = new Date().getTime();
|
||||
const time = end - start;
|
||||
logger.debug(`Mempool updated in ${time / 1000} seconds. New size: ${Object.keys(this.mempoolCache).length} (${diff > 0 ? '+' + diff : diff})`);
|
||||
|
||||
this.clearTimer(timer);
|
||||
}
|
||||
|
||||
public handleRbfTransactions(rbfTransactions: { [txid: string]: TransactionExtended; }) {
|
||||
public getAccelerations(): { [txid: string]: Acceleration } {
|
||||
return this.accelerations;
|
||||
}
|
||||
|
||||
public $updateAccelerations(newAccelerations: Acceleration[]): string[] {
|
||||
if (!config.MEMPOOL_SERVICES.ACCELERATIONS) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const changed: string[] = [];
|
||||
|
||||
const newAccelerationMap: { [txid: string]: Acceleration } = {};
|
||||
for (const acceleration of newAccelerations) {
|
||||
newAccelerationMap[acceleration.txid] = acceleration;
|
||||
if (this.accelerations[acceleration.txid] == null) {
|
||||
// new acceleration
|
||||
changed.push(acceleration.txid);
|
||||
} else {
|
||||
if (this.accelerations[acceleration.txid].feeDelta !== acceleration.feeDelta) {
|
||||
// feeDelta changed
|
||||
changed.push(acceleration.txid);
|
||||
} else if (this.accelerations[acceleration.txid].pools?.length) {
|
||||
let poolsChanged = false;
|
||||
const pools = new Set();
|
||||
this.accelerations[acceleration.txid].pools.forEach(pool => {
|
||||
pools.add(pool);
|
||||
});
|
||||
acceleration.pools.forEach(pool => {
|
||||
if (!pools.has(pool)) {
|
||||
poolsChanged = true;
|
||||
} else {
|
||||
pools.delete(pool);
|
||||
}
|
||||
});
|
||||
if (pools.size > 0) {
|
||||
poolsChanged = true;
|
||||
}
|
||||
if (poolsChanged) {
|
||||
// pools changed
|
||||
changed.push(acceleration.txid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const oldTxid of Object.keys(this.accelerations)) {
|
||||
if (!newAccelerationMap[oldTxid]) {
|
||||
// removed
|
||||
changed.push(oldTxid);
|
||||
}
|
||||
}
|
||||
|
||||
this.accelerations = newAccelerationMap;
|
||||
|
||||
return changed;
|
||||
} catch (e: any) {
|
||||
logger.debug(`Failed to update accelerations: ` + (e instanceof Error ? e.message : e));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private startTimer() {
|
||||
const state: any = {
|
||||
start: Date.now(),
|
||||
progress: 'begin $updateMempool',
|
||||
timer: null,
|
||||
};
|
||||
state.timer = setTimeout(() => {
|
||||
logger.err(`$updateMempool stalled at "${state.progress}"`);
|
||||
}, this.mainLoopTimeout);
|
||||
return state;
|
||||
}
|
||||
|
||||
private updateTimerProgress(state, msg) {
|
||||
state.progress = msg;
|
||||
}
|
||||
|
||||
private clearTimer(state) {
|
||||
if (state.timer) {
|
||||
clearTimeout(state.timer);
|
||||
}
|
||||
}
|
||||
|
||||
public handleRbfTransactions(rbfTransactions: { [txid: string]: MempoolTransactionExtended[]; }): void {
|
||||
for (const rbfTransaction in rbfTransactions) {
|
||||
if (this.mempoolCache[rbfTransaction]) {
|
||||
if (this.mempoolCache[rbfTransaction] && rbfTransactions[rbfTransaction]?.length) {
|
||||
// Store replaced transactions
|
||||
rbfCache.add(this.mempoolCache[rbfTransaction], rbfTransactions[rbfTransaction].txid);
|
||||
// Erase the replaced transactions from the local mempool
|
||||
delete this.mempoolCache[rbfTransaction];
|
||||
rbfCache.add(rbfTransactions[rbfTransaction], this.mempoolCache[rbfTransaction]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public handleMinedRbfTransactions(rbfTransactions: { [txid: string]: { replaced: MempoolTransactionExtended[], replacedBy: TransactionExtended }}): void {
|
||||
for (const rbfTransaction in rbfTransactions) {
|
||||
if (rbfTransactions[rbfTransaction].replacedBy && rbfTransactions[rbfTransaction]?.replaced?.length) {
|
||||
// Store replaced transactions
|
||||
rbfCache.add(rbfTransactions[rbfTransaction].replaced, transactionUtils.extendMempoolTransaction(rbfTransactions[rbfTransaction].replacedBy));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public addToSpendMap(transactions: MempoolTransactionExtended[]): void {
|
||||
for (const tx of transactions) {
|
||||
for (const vin of tx.vin) {
|
||||
this.spendMap.set(`${vin.txid}:${vin.vout}`, tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public removeFromSpendMap(transactions: TransactionExtended[]): void {
|
||||
for (const tx of transactions) {
|
||||
for (const vin of tx.vin) {
|
||||
const key = `${vin.txid}:${vin.vout}`;
|
||||
if (this.spendMap.get(key)?.txid === tx.txid) {
|
||||
this.spendMap.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,17 +503,6 @@ class Mempool {
|
||||
}
|
||||
}
|
||||
|
||||
private deleteExpiredTransactions() {
|
||||
const now = new Date().getTime();
|
||||
for (const tx in this.mempoolCache) {
|
||||
const lazyDeleteAt = this.mempoolCache[tx].deleteAfter;
|
||||
if (lazyDeleteAt && lazyDeleteAt < now) {
|
||||
delete this.mempoolCache[tx];
|
||||
rbfCache.evict(tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private $getMempoolInfo() {
|
||||
if (config.MEMPOOL.USE_SECOND_NODE_FOR_MINFEE) {
|
||||
return Promise.all([
|
||||
|
||||
@@ -12,6 +12,7 @@ import PricesRepository from '../../repositories/PricesRepository';
|
||||
class MiningRoutes {
|
||||
public initRoutes(app: Application) {
|
||||
app
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools', this.$listPools)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/:interval', this.$getPools)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:slug/hashrate', this.$getPoolHistoricalHashrate)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:slug/blocks', this.$getPoolBlocks)
|
||||
@@ -26,7 +27,7 @@ class MiningRoutes {
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/fee-rates/:interval', this.$getHistoricalBlockFeeRates)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/sizes-weights/:interval', this.$getHistoricalBlockSizeAndWeight)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/difficulty-adjustments/:interval', this.$getDifficultyAdjustments)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/predictions/:interval', this.$getHistoricalBlockPrediction)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/predictions/:interval', this.$getHistoricalBlocksHealth)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/audit/scores', this.$getBlockAuditScores)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/audit/scores/:height', this.$getBlockAuditScores)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/audit/score/:hash', this.$getBlockAuditScore)
|
||||
@@ -41,6 +42,10 @@ class MiningRoutes {
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString());
|
||||
if (['testnet', 'signet', 'liquidtestnet'].includes(config.MEMPOOL.NETWORK)) {
|
||||
res.status(400).send('Prices are not available on testnets.');
|
||||
return;
|
||||
}
|
||||
if (req.query.timestamp) {
|
||||
res.status(200).send(await PricesRepository.$getNearestHistoricalPrice(
|
||||
parseInt(<string>req.query.timestamp ?? 0, 10)
|
||||
@@ -88,6 +93,29 @@ class MiningRoutes {
|
||||
}
|
||||
}
|
||||
|
||||
private async $listPools(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());
|
||||
|
||||
const pools = await mining.$listPools();
|
||||
if (!pools) {
|
||||
res.status(500).end();
|
||||
return;
|
||||
}
|
||||
|
||||
res.header('X-total-count', pools.length.toString());
|
||||
if (pools.length === 0) {
|
||||
res.status(204).send();
|
||||
} else {
|
||||
res.json(pools);
|
||||
}
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getPools(req: Request, res: Response) {
|
||||
try {
|
||||
const stats = await mining.$getPoolsStats(req.params.interval);
|
||||
@@ -244,15 +272,15 @@ class MiningRoutes {
|
||||
}
|
||||
}
|
||||
|
||||
private async $getHistoricalBlockPrediction(req: Request, res: Response) {
|
||||
private async $getHistoricalBlocksHealth(req: Request, res: Response) {
|
||||
try {
|
||||
const blockPredictions = await mining.$getBlockPredictionsHistory(req.params.interval);
|
||||
const blockCount = await BlocksAuditsRepository.$getPredictionsCount();
|
||||
const blocksHealth = await mining.$getBlocksHealthHistory(req.params.interval);
|
||||
const blockCount = await BlocksAuditsRepository.$getBlocksHealthCount();
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.header('X-total-count', blockCount.toString());
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
res.json(blockPredictions.map(prediction => [prediction.time, prediction.height, prediction.match_rate]));
|
||||
res.json(blocksHealth.map(health => [health.time, health.height, health.match_rate]));
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
|
||||
@@ -11,19 +11,30 @@ import DifficultyAdjustmentsRepository from '../../repositories/DifficultyAdjust
|
||||
import config from '../../config';
|
||||
import BlocksAuditsRepository from '../../repositories/BlocksAuditsRepository';
|
||||
import PricesRepository from '../../repositories/PricesRepository';
|
||||
import { bitcoinCoreApi } from '../bitcoin/bitcoin-api-factory';
|
||||
import bitcoinApi from '../bitcoin/bitcoin-api-factory';
|
||||
import { IEsploraApi } from '../bitcoin/esplora-api.interface';
|
||||
import database from '../../database';
|
||||
|
||||
interface DifficultyBlock {
|
||||
timestamp: number,
|
||||
height: number,
|
||||
bits: number,
|
||||
difficulty: number,
|
||||
}
|
||||
|
||||
class Mining {
|
||||
private blocksPriceIndexingRunning = false;
|
||||
public lastHashrateIndexingDate: number | null = null;
|
||||
public lastWeeklyHashrateIndexingDate: number | null = null;
|
||||
|
||||
public reindexHashrateRequested = false;
|
||||
public reindexDifficultyAdjustmentRequested = false;
|
||||
|
||||
/**
|
||||
* Get historical block predictions match rate
|
||||
* Get historical blocks health
|
||||
*/
|
||||
public async $getBlockPredictionsHistory(interval: string | null = null): Promise<any> {
|
||||
return await BlocksAuditsRepository.$getBlockPredictionsHistory(
|
||||
public async $getBlocksHealthHistory(interval: string | null = null): Promise<any> {
|
||||
return await BlocksAuditsRepository.$getBlocksHealthHistory(
|
||||
this.getTimeRange(interval),
|
||||
Common.getSqlInterval(interval)
|
||||
);
|
||||
@@ -52,7 +63,7 @@ class Mining {
|
||||
/**
|
||||
* Get historical block fee rates percentiles
|
||||
*/
|
||||
public async $getHistoricalBlockFeeRates(interval: string | null = null): Promise<any> {
|
||||
public async $getHistoricalBlockFeeRates(interval: string | null = null): Promise<any> {
|
||||
return await BlocksRepository.$getHistoricalBlockFeeRates(
|
||||
this.getTimeRange(interval),
|
||||
Common.getSqlInterval(interval)
|
||||
@@ -62,7 +73,7 @@ class Mining {
|
||||
/**
|
||||
* Get historical block sizes
|
||||
*/
|
||||
public async $getHistoricalBlockSizes(interval: string | null = null): Promise<any> {
|
||||
public async $getHistoricalBlockSizes(interval: string | null = null): Promise<any> {
|
||||
return await BlocksRepository.$getHistoricalBlockSizes(
|
||||
this.getTimeRange(interval),
|
||||
Common.getSqlInterval(interval)
|
||||
@@ -72,7 +83,7 @@ class Mining {
|
||||
/**
|
||||
* Get historical block weights
|
||||
*/
|
||||
public async $getHistoricalBlockWeights(interval: string | null = null): Promise<any> {
|
||||
public async $getHistoricalBlockWeights(interval: string | null = null): Promise<any> {
|
||||
return await BlocksRepository.$getHistoricalBlockWeights(
|
||||
this.getTimeRange(interval),
|
||||
Common.getSqlInterval(interval)
|
||||
@@ -102,6 +113,8 @@ class Mining {
|
||||
emptyBlocks: emptyBlocksCount.length > 0 ? emptyBlocksCount[0]['count'] : 0,
|
||||
slug: poolInfo.slug,
|
||||
avgMatchRate: poolInfo.avgMatchRate !== null ? Math.round(100 * poolInfo.avgMatchRate) / 100 : null,
|
||||
avgFeeDelta: poolInfo.avgFeeDelta,
|
||||
poolUniqueId: poolInfo.poolUniqueId
|
||||
};
|
||||
poolsStats.push(poolStat);
|
||||
});
|
||||
@@ -129,7 +142,7 @@ class Mining {
|
||||
public async $getPoolStat(slug: string): Promise<object> {
|
||||
const pool = await PoolsRepository.$getPool(slug);
|
||||
if (!pool) {
|
||||
throw new Error('This mining pool does not exist ' + escape(slug));
|
||||
throw new Error('This mining pool does not exist');
|
||||
}
|
||||
|
||||
const blockCount: number = await BlocksRepository.$blockCount(pool.id);
|
||||
@@ -141,6 +154,9 @@ class Mining {
|
||||
const blockCount1w: number = await BlocksRepository.$blockCount(pool.id, '1w');
|
||||
const totalBlock1w: number = await BlocksRepository.$blockCount(null, '1w');
|
||||
|
||||
const avgHealth = await BlocksRepository.$getAvgBlockHealthPerPoolId(pool.id);
|
||||
const totalReward = await BlocksRepository.$getTotalRewardForPoolId(pool.id);
|
||||
|
||||
let currentEstimatedHashrate = 0;
|
||||
try {
|
||||
currentEstimatedHashrate = await bitcoinClient.getNetworkHashPs(totalBlock24h);
|
||||
@@ -162,6 +178,8 @@ class Mining {
|
||||
},
|
||||
estimatedHashrate: currentEstimatedHashrate * (blockCount24h / totalBlock24h),
|
||||
reportedHashrate: null,
|
||||
avgBlockHealth: avgHealth,
|
||||
totalReward: totalReward,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -191,7 +209,7 @@ class Mining {
|
||||
try {
|
||||
const oldestConsecutiveBlockTimestamp = 1000 * (await BlocksRepository.$getOldestConsecutiveBlock()).timestamp;
|
||||
|
||||
const genesisBlock: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(await bitcoinClient.getBlockHash(0));
|
||||
const genesisBlock: IEsploraApi.Block = await bitcoinApi.$getBlock(await bitcoinApi.$getBlockHash(0));
|
||||
const genesisTimestamp = genesisBlock.timestamp * 1000;
|
||||
|
||||
const indexedTimestamp = await HashratesRepository.$getWeeklyHashrateTimestamps();
|
||||
@@ -284,6 +302,14 @@ class Mining {
|
||||
* Generate daily hashrate data
|
||||
*/
|
||||
public async $generateNetworkHashrateHistory(): Promise<void> {
|
||||
// If a re-index was requested, truncate first
|
||||
if (this.reindexHashrateRequested === true) {
|
||||
logger.notice(`hashrates will now be re-indexed`);
|
||||
await database.query(`TRUNCATE hashrates`);
|
||||
this.lastHashrateIndexingDate = 0;
|
||||
this.reindexHashrateRequested = false;
|
||||
}
|
||||
|
||||
// We only run this once a day around midnight
|
||||
const today = new Date().getUTCDate();
|
||||
if (today === this.lastHashrateIndexingDate) {
|
||||
@@ -294,7 +320,7 @@ class Mining {
|
||||
const oldestConsecutiveBlockTimestamp = 1000 * (await BlocksRepository.$getOldestConsecutiveBlock()).timestamp;
|
||||
|
||||
try {
|
||||
const genesisBlock: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(await bitcoinClient.getBlockHash(0));
|
||||
const genesisBlock: IEsploraApi.Block = await bitcoinApi.$getBlock(await bitcoinApi.$getBlockHash(0));
|
||||
const genesisTimestamp = genesisBlock.timestamp * 1000;
|
||||
const indexedTimestamp = (await HashratesRepository.$getRawNetworkDailyHashrate(null)).map(hashrate => hashrate.timestamp);
|
||||
const lastMidnight = this.getDateMidnight(new Date());
|
||||
@@ -389,15 +415,24 @@ class Mining {
|
||||
* Index difficulty adjustments
|
||||
*/
|
||||
public async $indexDifficultyAdjustments(): Promise<void> {
|
||||
// If a re-index was requested, truncate first
|
||||
if (this.reindexDifficultyAdjustmentRequested === true) {
|
||||
logger.notice(`difficulty_adjustments will now be re-indexed`);
|
||||
await database.query(`TRUNCATE difficulty_adjustments`);
|
||||
this.reindexDifficultyAdjustmentRequested = false;
|
||||
}
|
||||
|
||||
const indexedHeightsArray = await DifficultyAdjustmentsRepository.$getAdjustmentsHeights();
|
||||
const indexedHeights = {};
|
||||
for (const height of indexedHeightsArray) {
|
||||
indexedHeights[height] = true;
|
||||
}
|
||||
|
||||
// gets {time, height, difficulty, bits} of blocks in ascending order of height
|
||||
const blocks: any = await BlocksRepository.$getBlocksDifficulty();
|
||||
const genesisBlock: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(await bitcoinClient.getBlockHash(0));
|
||||
const genesisBlock: IEsploraApi.Block = await bitcoinApi.$getBlock(await bitcoinApi.$getBlockHash(0));
|
||||
let currentDifficulty = genesisBlock.difficulty;
|
||||
let currentBits = genesisBlock.bits;
|
||||
let totalIndexed = 0;
|
||||
|
||||
if (config.MEMPOOL.INDEXING_BLOCKS_AMOUNT === -1 && indexedHeights[0] !== true) {
|
||||
@@ -409,44 +444,51 @@ class Mining {
|
||||
});
|
||||
}
|
||||
|
||||
const oldestConsecutiveBlock = await BlocksRepository.$getOldestConsecutiveBlock();
|
||||
if (config.MEMPOOL.INDEXING_BLOCKS_AMOUNT !== -1) {
|
||||
currentDifficulty = oldestConsecutiveBlock.difficulty;
|
||||
if (!blocks?.length) {
|
||||
// no blocks in database yet
|
||||
return;
|
||||
}
|
||||
|
||||
const oldestConsecutiveBlock = this.getOldestConsecutiveBlock(blocks);
|
||||
|
||||
currentBits = oldestConsecutiveBlock.bits;
|
||||
currentDifficulty = oldestConsecutiveBlock.difficulty;
|
||||
|
||||
let totalBlockChecked = 0;
|
||||
let timer = new Date().getTime() / 1000;
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.difficulty !== currentDifficulty) {
|
||||
if (indexedHeights[block.height] === true) { // Already indexed
|
||||
if (block.height >= oldestConsecutiveBlock.height) {
|
||||
currentDifficulty = block.difficulty;
|
||||
}
|
||||
continue;
|
||||
// skip until the first block after the oldest consecutive block
|
||||
if (block.height <= oldestConsecutiveBlock.height) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// difficulty has changed between two consecutive blocks!
|
||||
if (block.bits !== currentBits) {
|
||||
// skip if already indexed
|
||||
if (indexedHeights[block.height] !== true) {
|
||||
let adjustment = block.difficulty / currentDifficulty;
|
||||
adjustment = Math.round(adjustment * 1000000) / 1000000; // Remove float point noise
|
||||
|
||||
await DifficultyAdjustmentsRepository.$saveAdjustments({
|
||||
time: block.time,
|
||||
height: block.height,
|
||||
difficulty: block.difficulty,
|
||||
adjustment: adjustment,
|
||||
});
|
||||
|
||||
totalIndexed++;
|
||||
}
|
||||
|
||||
let adjustment = block.difficulty / currentDifficulty;
|
||||
adjustment = Math.round(adjustment * 1000000) / 1000000; // Remove float point noise
|
||||
|
||||
await DifficultyAdjustmentsRepository.$saveAdjustments({
|
||||
time: block.time,
|
||||
height: block.height,
|
||||
difficulty: block.difficulty,
|
||||
adjustment: adjustment,
|
||||
});
|
||||
|
||||
totalIndexed++;
|
||||
if (block.height >= oldestConsecutiveBlock.height) {
|
||||
currentDifficulty = block.difficulty;
|
||||
}
|
||||
}
|
||||
// update the current difficulty
|
||||
currentDifficulty = block.difficulty;
|
||||
currentBits = block.bits;
|
||||
}
|
||||
|
||||
totalBlockChecked++;
|
||||
const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - timer));
|
||||
if (elapsedSeconds > 5) {
|
||||
const progress = Math.round(totalBlockChecked / blocks.length * 100);
|
||||
logger.info(`Indexing difficulty adjustment at block #${block.height} | Progress: ${progress}%`, logger.tags.mining);
|
||||
logger.debug(`Indexing difficulty adjustment at block #${block.height} | Progress: ${progress}%`, logger.tags.mining);
|
||||
timer = new Date().getTime() / 1000;
|
||||
}
|
||||
}
|
||||
@@ -467,11 +509,11 @@ class Mining {
|
||||
}
|
||||
this.blocksPriceIndexingRunning = true;
|
||||
|
||||
let totalInserted = 0;
|
||||
try {
|
||||
const prices: any[] = await PricesRepository.$getPricesTimesAndId();
|
||||
const blocksWithoutPrices: any[] = await BlocksRepository.$getBlocksWithoutPrice();
|
||||
|
||||
let totalInserted = 0;
|
||||
const blocksPrices: BlockPrice[] = [];
|
||||
|
||||
for (const block of blocksWithoutPrices) {
|
||||
@@ -516,7 +558,13 @@ class Mining {
|
||||
}
|
||||
} catch (e) {
|
||||
this.blocksPriceIndexingRunning = false;
|
||||
throw e;
|
||||
logger.err(`Cannot index block prices. ${e}`);
|
||||
}
|
||||
|
||||
if (totalInserted > 0) {
|
||||
logger.info(`Indexing blocks prices completed. Indexed ${totalInserted}`, logger.tags.mining);
|
||||
} else {
|
||||
logger.debug(`Indexing blocks prices completed. Indexed 0.`, logger.tags.mining);
|
||||
}
|
||||
|
||||
this.blocksPriceIndexingRunning = false;
|
||||
@@ -552,11 +600,27 @@ class Mining {
|
||||
currentBlockHeight -= 10000;
|
||||
}
|
||||
|
||||
if (totalIndexed) {
|
||||
logger.info(`Indexing missing coinstatsindex data completed`, logger.tags.mining);
|
||||
if (totalIndexed > 0) {
|
||||
logger.info(`Indexing missing coinstatsindex data completed. Indexed ${totalIndexed}`, logger.tags.mining);
|
||||
} else {
|
||||
logger.debug(`Indexing missing coinstatsindex data completed. Indexed 0.`, logger.tags.mining);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List existing mining pools
|
||||
*/
|
||||
public async $listPools(): Promise<{name: string, slug: string, unique_id: number}[] | null> {
|
||||
const [rows] = await database.query(`
|
||||
SELECT
|
||||
name,
|
||||
slug,
|
||||
unique_id
|
||||
FROM pools`
|
||||
);
|
||||
return rows as {name: string, slug: string, unique_id: number}[];
|
||||
}
|
||||
|
||||
private getDateMidnight(date: Date): Date {
|
||||
date.setUTCHours(0);
|
||||
date.setUTCMinutes(0);
|
||||
@@ -581,6 +645,17 @@ class Mining {
|
||||
default: return 86400 * scale;
|
||||
}
|
||||
}
|
||||
|
||||
// Finds the oldest block in a consecutive chain back from the tip
|
||||
// assumes `blocks` is sorted in ascending height order
|
||||
private getOldestConsecutiveBlock(blocks: DifficultyBlock[]): DifficultyBlock {
|
||||
for (let i = blocks.length - 1; i > 0; i--) {
|
||||
if ((blocks[i].height - blocks[i - 1].height) > 1) {
|
||||
return blocks[i];
|
||||
}
|
||||
}
|
||||
return blocks[0];
|
||||
}
|
||||
}
|
||||
|
||||
export default new Mining();
|
||||
|
||||
@@ -4,6 +4,7 @@ import config from '../config';
|
||||
import PoolsRepository from '../repositories/PoolsRepository';
|
||||
import { PoolTag } from '../mempool.interfaces';
|
||||
import diskCache from './disk-cache';
|
||||
import mining from './mining/mining';
|
||||
|
||||
class PoolsParser {
|
||||
miningPools: any[] = [];
|
||||
@@ -41,7 +42,7 @@ class PoolsParser {
|
||||
public async migratePoolsJson(): Promise<void> {
|
||||
// We also need to wipe the backend cache to make sure we don't serve blocks with
|
||||
// the wrong mining pool (usually happen with unknown blocks)
|
||||
diskCache.wipeCache();
|
||||
diskCache.setIgnoreBlocksCache();
|
||||
|
||||
await this.$insertUnknownPool();
|
||||
|
||||
@@ -73,14 +74,12 @@ class PoolsParser {
|
||||
if (JSON.stringify(pool.addresses) !== poolDB.addresses ||
|
||||
JSON.stringify(pool.regexes) !== poolDB.regexes) {
|
||||
// Pool addresses changed or coinbase tags changed
|
||||
logger.notice(`Updating addresses and/or coinbase tags for ${pool.name} mining pool. If 'AUTOMATIC_BLOCK_REINDEXING' is enabled, we will re-index its blocks and 'unknown' blocks`);
|
||||
logger.notice(`Updating addresses and/or coinbase tags for ${pool.name} mining pool.`);
|
||||
await PoolsRepository.$updateMiningPoolTags(poolDB.id, pool.addresses, pool.regexes);
|
||||
await this.$deleteBlocksForPool(poolDB);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Mining pools-v2.json import completed');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,10 +117,6 @@ class PoolsParser {
|
||||
* @param pool
|
||||
*/
|
||||
private async $deleteBlocksForPool(pool: PoolTag): Promise<void> {
|
||||
if (config.MEMPOOL.AUTOMATIC_BLOCK_REINDEXING === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get oldest blocks mined by the pool and assume pools-v2.json updates only concern most recent years
|
||||
// Ignore early days of Bitcoin as there were no mining pool yet
|
||||
const [oldestPoolBlock]: any[] = await DB.query(`
|
||||
@@ -132,7 +127,15 @@ class PoolsParser {
|
||||
LIMIT 1`,
|
||||
[pool.id]
|
||||
);
|
||||
const oldestBlockHeight = oldestPoolBlock.length ?? 0 > 0 ? oldestPoolBlock[0].height : 130635;
|
||||
|
||||
let firstKnownBlockPool = 130635; // https://mempool.space/block/0000000000000a067d94ff753eec72830f1205ad3a4c216a08a80c832e551a52
|
||||
if (config.MEMPOOL.NETWORK === 'testnet') {
|
||||
firstKnownBlockPool = 21106; // https://mempool.space/testnet/block/0000000070b701a5b6a1b965f6a38e0472e70b2bb31b973e4638dec400877581
|
||||
} else if (config.MEMPOOL.NETWORK === 'signet') {
|
||||
firstKnownBlockPool = 0;
|
||||
}
|
||||
|
||||
const oldestBlockHeight = oldestPoolBlock.length ?? 0 > 0 ? oldestPoolBlock[0].height : firstKnownBlockPool;
|
||||
const [unknownPool] = await DB.query(`SELECT id from pools where slug = "unknown"`);
|
||||
this.uniqueLog(logger.notice, `Deleting blocks with unknown mining pool from height ${oldestBlockHeight} for re-indexing`);
|
||||
await DB.query(`
|
||||
@@ -146,16 +149,31 @@ class PoolsParser {
|
||||
WHERE pool_id = ?`,
|
||||
[pool.id]
|
||||
);
|
||||
|
||||
// Re-index hashrates and difficulty adjustments later
|
||||
mining.reindexHashrateRequested = true;
|
||||
mining.reindexDifficultyAdjustmentRequested = true;
|
||||
}
|
||||
|
||||
private async $deleteUnknownBlocks(): Promise<void> {
|
||||
let firstKnownBlockPool = 130635; // https://mempool.space/block/0000000000000a067d94ff753eec72830f1205ad3a4c216a08a80c832e551a52
|
||||
if (config.MEMPOOL.NETWORK === 'testnet') {
|
||||
firstKnownBlockPool = 21106; // https://mempool.space/testnet/block/0000000070b701a5b6a1b965f6a38e0472e70b2bb31b973e4638dec400877581
|
||||
} else if (config.MEMPOOL.NETWORK === 'signet') {
|
||||
firstKnownBlockPool = 0;
|
||||
}
|
||||
|
||||
const [unknownPool] = await DB.query(`SELECT id from pools where slug = "unknown"`);
|
||||
this.uniqueLog(logger.notice, `Deleting blocks with unknown mining pool from height 130635 for re-indexing`);
|
||||
this.uniqueLog(logger.notice, `Deleting blocks with unknown mining pool from height ${firstKnownBlockPool} for re-indexing`);
|
||||
await DB.query(`
|
||||
DELETE FROM blocks
|
||||
WHERE pool_id = ? AND height >= 130635`,
|
||||
WHERE pool_id = ? AND height >= ${firstKnownBlockPool}`,
|
||||
[unknownPool[0].id]
|
||||
);
|
||||
|
||||
// Re-index hashrates and difficulty adjustments later
|
||||
mining.reindexHashrateRequested = true;
|
||||
mining.reindexDifficultyAdjustmentRequested = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
19
backend/src/api/prices/prices.routes.ts
Normal file
19
backend/src/api/prices/prices.routes.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Application, Request, Response } from 'express';
|
||||
import config from '../../config';
|
||||
import pricesUpdater from '../../tasks/price-updater';
|
||||
|
||||
class PricesRoutes {
|
||||
public initRoutes(app: Application): void {
|
||||
app.get(config.MEMPOOL.API_URL_PREFIX + 'prices', this.$getCurrentPrices.bind(this));
|
||||
}
|
||||
|
||||
private $getCurrentPrices(req: Request, res: Response): void {
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 360_0000 / config.MEMPOOL.PRICE_UPDATES_PER_HOUR).toUTCString());
|
||||
|
||||
res.json(pricesUpdater.getLatestPrices());
|
||||
}
|
||||
}
|
||||
|
||||
export default new PricesRoutes();
|
||||
@@ -1,65 +1,543 @@
|
||||
import { TransactionExtended } from "../mempool.interfaces";
|
||||
import config from "../config";
|
||||
import logger from "../logger";
|
||||
import { MempoolTransactionExtended, TransactionStripped } from "../mempool.interfaces";
|
||||
import bitcoinApi from './bitcoin/bitcoin-api-factory';
|
||||
import { IEsploraApi } from "./bitcoin/esplora-api.interface";
|
||||
import { Common } from "./common";
|
||||
import redisCache from "./redis-cache";
|
||||
|
||||
export interface RbfTransaction extends TransactionStripped {
|
||||
rbf?: boolean;
|
||||
mined?: boolean;
|
||||
fullRbf?: boolean;
|
||||
}
|
||||
|
||||
export interface RbfTree {
|
||||
tx: RbfTransaction;
|
||||
time: number;
|
||||
interval?: number;
|
||||
mined?: boolean;
|
||||
fullRbf: boolean;
|
||||
replaces: RbfTree[];
|
||||
}
|
||||
|
||||
export interface ReplacementInfo {
|
||||
mined: boolean;
|
||||
fullRbf: boolean;
|
||||
txid: string;
|
||||
oldFee: number;
|
||||
oldVsize: number;
|
||||
newFee: number;
|
||||
newVsize: number;
|
||||
}
|
||||
|
||||
enum CacheOp {
|
||||
Remove = 0,
|
||||
Add = 1,
|
||||
Change = 2,
|
||||
}
|
||||
|
||||
interface CacheEvent {
|
||||
op: CacheOp;
|
||||
type: 'tx' | 'tree' | 'exp';
|
||||
txid: string,
|
||||
value?: any,
|
||||
}
|
||||
|
||||
class RbfCache {
|
||||
private replacedBy: { [txid: string]: string; } = {};
|
||||
private replaces: { [txid: string]: string[] } = {};
|
||||
private txs: { [txid: string]: TransactionExtended } = {};
|
||||
private expiring: { [txid: string]: Date } = {};
|
||||
private replacedBy: Map<string, string> = new Map();
|
||||
private replaces: Map<string, string[]> = new Map();
|
||||
private rbfTrees: Map<string, RbfTree> = new Map(); // sequences of consecutive replacements
|
||||
private dirtyTrees: Set<string> = new Set();
|
||||
private treeMap: Map<string, string> = new Map(); // map of txids to sequence ids
|
||||
private txs: Map<string, MempoolTransactionExtended> = new Map();
|
||||
private expiring: Map<string, number> = new Map();
|
||||
private cacheQueue: CacheEvent[] = [];
|
||||
|
||||
private evictionCount = 0;
|
||||
private staleCount = 0;
|
||||
|
||||
constructor() {
|
||||
setInterval(this.cleanup.bind(this), 1000 * 60 * 60);
|
||||
setInterval(this.cleanup.bind(this), 1000 * 60 * 10);
|
||||
}
|
||||
|
||||
public add(replacedTx: TransactionExtended, newTxId: string): void {
|
||||
this.replacedBy[replacedTx.txid] = newTxId;
|
||||
this.txs[replacedTx.txid] = replacedTx;
|
||||
if (!this.replaces[newTxId]) {
|
||||
this.replaces[newTxId] = [];
|
||||
private addTx(txid: string, tx: MempoolTransactionExtended): void {
|
||||
this.txs.set(txid, tx);
|
||||
this.cacheQueue.push({ op: CacheOp.Add, type: 'tx', txid });
|
||||
}
|
||||
|
||||
private addTree(txid: string, tree: RbfTree): void {
|
||||
this.rbfTrees.set(txid, tree);
|
||||
this.dirtyTrees.add(txid);
|
||||
this.cacheQueue.push({ op: CacheOp.Add, type: 'tree', txid });
|
||||
}
|
||||
|
||||
private addExpiration(txid: string, expiry: number): void {
|
||||
this.expiring.set(txid, expiry);
|
||||
this.cacheQueue.push({ op: CacheOp.Add, type: 'exp', txid, value: expiry });
|
||||
}
|
||||
|
||||
private removeTx(txid: string): void {
|
||||
this.txs.delete(txid);
|
||||
this.cacheQueue.push({ op: CacheOp.Remove, type: 'tx', txid });
|
||||
}
|
||||
|
||||
private removeTree(txid: string): void {
|
||||
this.rbfTrees.delete(txid);
|
||||
this.cacheQueue.push({ op: CacheOp.Remove, type: 'tree', txid });
|
||||
}
|
||||
|
||||
private removeExpiration(txid: string): void {
|
||||
this.expiring.delete(txid);
|
||||
this.cacheQueue.push({ op: CacheOp.Remove, type: 'exp', txid });
|
||||
}
|
||||
|
||||
public add(replaced: MempoolTransactionExtended[], newTxExtended: MempoolTransactionExtended): void {
|
||||
if (!newTxExtended || !replaced?.length || this.txs.has(newTxExtended.txid)) {
|
||||
return;
|
||||
}
|
||||
this.replaces[newTxId].push(replacedTx.txid);
|
||||
|
||||
newTxExtended.replacement = true;
|
||||
|
||||
const newTx = Common.stripTransaction(newTxExtended) as RbfTransaction;
|
||||
const newTime = newTxExtended.firstSeen || (Date.now() / 1000);
|
||||
newTx.rbf = newTxExtended.vin.some((v) => v.sequence < 0xfffffffe);
|
||||
this.addTx(newTx.txid, newTxExtended);
|
||||
|
||||
// maintain rbf trees
|
||||
let txFullRbf = false;
|
||||
let treeFullRbf = false;
|
||||
const replacedTrees: RbfTree[] = [];
|
||||
for (const replacedTxExtended of replaced) {
|
||||
const replacedTx = Common.stripTransaction(replacedTxExtended) as RbfTransaction;
|
||||
replacedTx.rbf = replacedTxExtended.vin.some((v) => v.sequence < 0xfffffffe);
|
||||
if (!replacedTx.rbf) {
|
||||
txFullRbf = true;
|
||||
}
|
||||
this.replacedBy.set(replacedTx.txid, newTx.txid);
|
||||
if (this.treeMap.has(replacedTx.txid)) {
|
||||
const treeId = this.treeMap.get(replacedTx.txid);
|
||||
if (treeId) {
|
||||
const tree = this.rbfTrees.get(treeId);
|
||||
this.removeTree(treeId);
|
||||
if (tree) {
|
||||
tree.interval = newTime - tree?.time;
|
||||
replacedTrees.push(tree);
|
||||
treeFullRbf = treeFullRbf || tree.fullRbf || !tree.tx.rbf;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const replacedTime = replacedTxExtended.firstSeen || (Date.now() / 1000);
|
||||
replacedTrees.push({
|
||||
tx: replacedTx,
|
||||
time: replacedTime,
|
||||
interval: newTime - replacedTime,
|
||||
fullRbf: !replacedTx.rbf,
|
||||
replaces: [],
|
||||
});
|
||||
treeFullRbf = treeFullRbf || !replacedTx.rbf;
|
||||
this.addTx(replacedTx.txid, replacedTxExtended);
|
||||
}
|
||||
}
|
||||
newTx.fullRbf = txFullRbf;
|
||||
const treeId = replacedTrees[0].tx.txid;
|
||||
const newTree = {
|
||||
tx: newTx,
|
||||
time: newTime,
|
||||
fullRbf: treeFullRbf,
|
||||
replaces: replacedTrees
|
||||
};
|
||||
this.addTree(treeId, newTree);
|
||||
this.updateTreeMap(treeId, newTree);
|
||||
this.replaces.set(newTx.txid, replacedTrees.map(tree => tree.tx.txid));
|
||||
}
|
||||
|
||||
public has(txId: string): boolean {
|
||||
return this.txs.has(txId);
|
||||
}
|
||||
|
||||
public anyInSameTree(txId: string, predicate: (tx: RbfTransaction) => boolean): boolean {
|
||||
const tree = this.getRbfTree(txId);
|
||||
if (!tree) {
|
||||
return false;
|
||||
}
|
||||
const txs = this.getTransactionsInTree(tree);
|
||||
for (const tx of txs) {
|
||||
if (predicate(tx)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public getReplacedBy(txId: string): string | undefined {
|
||||
return this.replacedBy[txId];
|
||||
return this.replacedBy.get(txId);
|
||||
}
|
||||
|
||||
public getReplaces(txId: string): string[] | undefined {
|
||||
return this.replaces[txId];
|
||||
return this.replaces.get(txId);
|
||||
}
|
||||
|
||||
public getTx(txId: string): TransactionExtended | undefined {
|
||||
return this.txs[txId];
|
||||
public getTx(txId: string): MempoolTransactionExtended | undefined {
|
||||
return this.txs.get(txId);
|
||||
}
|
||||
|
||||
public getRbfTree(txId: string): RbfTree | void {
|
||||
return this.rbfTrees.get(this.treeMap.get(txId) || '');
|
||||
}
|
||||
|
||||
// get a paginated list of RbfTrees
|
||||
// ordered by most recent replacement time
|
||||
public getRbfTrees(onlyFullRbf: boolean, after?: string): RbfTree[] {
|
||||
const limit = 25;
|
||||
const trees: RbfTree[] = [];
|
||||
const used = new Set<string>();
|
||||
const replacements: string[][] = Array.from(this.replacedBy).reverse();
|
||||
const afterTree = after ? this.treeMap.get(after) : null;
|
||||
let ready = !afterTree;
|
||||
for (let i = 0; i < replacements.length && trees.length <= limit - 1; i++) {
|
||||
const txid = replacements[i][1];
|
||||
const treeId = this.treeMap.get(txid) || '';
|
||||
if (treeId === afterTree) {
|
||||
ready = true;
|
||||
} else if (ready) {
|
||||
if (!used.has(treeId)) {
|
||||
const tree = this.rbfTrees.get(treeId);
|
||||
used.add(treeId);
|
||||
if (tree && (!onlyFullRbf || tree.fullRbf)) {
|
||||
trees.push(tree);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return trees;
|
||||
}
|
||||
|
||||
// get map of rbf trees that have been updated since the last call
|
||||
public getRbfChanges(): { trees: {[id: string]: RbfTree }, map: { [txid: string]: string }} {
|
||||
const changes: { trees: {[id: string]: RbfTree }, map: { [txid: string]: string }} = {
|
||||
trees: {},
|
||||
map: {},
|
||||
};
|
||||
this.dirtyTrees.forEach(id => {
|
||||
const tree = this.rbfTrees.get(id);
|
||||
if (tree) {
|
||||
changes.trees[id] = tree;
|
||||
this.getTransactionsInTree(tree).forEach(tx => {
|
||||
changes.map[tx.txid] = id;
|
||||
});
|
||||
}
|
||||
});
|
||||
this.dirtyTrees = new Set();
|
||||
return changes;
|
||||
}
|
||||
|
||||
public mined(txid): void {
|
||||
if (!this.txs.has(txid)) {
|
||||
return;
|
||||
}
|
||||
const treeId = this.treeMap.get(txid);
|
||||
if (treeId && this.rbfTrees.has(treeId)) {
|
||||
const tree = this.rbfTrees.get(treeId);
|
||||
if (tree) {
|
||||
this.setTreeMined(tree, txid);
|
||||
tree.mined = true;
|
||||
this.dirtyTrees.add(treeId);
|
||||
this.cacheQueue.push({ op: CacheOp.Change, type: 'tree', txid: treeId });
|
||||
}
|
||||
}
|
||||
this.evict(txid);
|
||||
}
|
||||
|
||||
// flag a transaction as removed from the mempool
|
||||
public evict(txid): void {
|
||||
this.expiring[txid] = new Date(Date.now() + 1000 * 86400); // 24 hours
|
||||
public evict(txid: string, fast: boolean = false): void {
|
||||
this.evictionCount++;
|
||||
if (this.txs.has(txid) && (fast || !this.expiring.has(txid))) {
|
||||
const expiryTime = fast ? Date.now() + (1000 * 60 * 10) : Date.now() + (1000 * 86400); // 24 hours
|
||||
this.addExpiration(txid, expiryTime);
|
||||
}
|
||||
}
|
||||
|
||||
// is the transaction involved in a full rbf replacement?
|
||||
public isFullRbf(txid: string): boolean {
|
||||
const treeId = this.treeMap.get(txid);
|
||||
if (!treeId) {
|
||||
return false;
|
||||
}
|
||||
const tree = this.rbfTrees.get(treeId);
|
||||
if (!tree) {
|
||||
return false;
|
||||
}
|
||||
return tree?.fullRbf;
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
const currentDate = new Date();
|
||||
for (const txid in this.expiring) {
|
||||
if (this.expiring[txid] < currentDate) {
|
||||
delete this.expiring[txid];
|
||||
const now = Date.now();
|
||||
for (const txid of this.expiring.keys()) {
|
||||
if ((this.expiring.get(txid) || 0) < now) {
|
||||
this.removeExpiration(txid);
|
||||
this.remove(txid);
|
||||
}
|
||||
}
|
||||
logger.debug(`rbf cache contains ${this.txs.size} txs, ${this.rbfTrees.size} trees, ${this.expiring.size} due to expire (${this.evictionCount} newly expired)`);
|
||||
this.evictionCount = 0;
|
||||
}
|
||||
|
||||
// remove a transaction & all previous versions from the cache
|
||||
private remove(txid): void {
|
||||
// don't remove a transaction while a newer version remains in the mempool
|
||||
if (this.replaces[txid] && !this.replacedBy[txid]) {
|
||||
const replaces = this.replaces[txid];
|
||||
delete this.replaces[txid];
|
||||
for (const tx of replaces) {
|
||||
// don't remove a transaction if a newer version remains in the mempool
|
||||
if (!this.replacedBy.has(txid)) {
|
||||
const root = this.treeMap.get(txid);
|
||||
const replaces = this.replaces.get(txid);
|
||||
this.replaces.delete(txid);
|
||||
this.treeMap.delete(txid);
|
||||
this.removeTx(txid);
|
||||
this.removeExpiration(txid);
|
||||
if (root === txid) {
|
||||
this.removeTree(txid);
|
||||
}
|
||||
for (const tx of (replaces || [])) {
|
||||
// recursively remove prior versions from the cache
|
||||
delete this.replacedBy[tx];
|
||||
delete this.txs[tx];
|
||||
this.replacedBy.delete(tx);
|
||||
// if this is the id of a tree, remove that too
|
||||
if (this.treeMap.get(tx) === tx) {
|
||||
this.removeTree(tx);
|
||||
}
|
||||
this.remove(tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateTreeMap(newId: string, tree: RbfTree): void {
|
||||
this.treeMap.set(tree.tx.txid, newId);
|
||||
tree.replaces.forEach(subtree => {
|
||||
this.updateTreeMap(newId, subtree);
|
||||
});
|
||||
}
|
||||
|
||||
private getTransactionsInTree(tree: RbfTree, txs: RbfTransaction[] = []): RbfTransaction[] {
|
||||
txs.push(tree.tx);
|
||||
tree.replaces.forEach(subtree => {
|
||||
this.getTransactionsInTree(subtree, txs);
|
||||
});
|
||||
return txs;
|
||||
}
|
||||
|
||||
private setTreeMined(tree: RbfTree, txid: string): void {
|
||||
if (tree.tx.txid === txid) {
|
||||
tree.tx.mined = true;
|
||||
} else {
|
||||
tree.replaces.forEach(subtree => {
|
||||
this.setTreeMined(subtree, txid);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async updateCache(): Promise<void> {
|
||||
if (!config.REDIS.ENABLED) {
|
||||
return;
|
||||
}
|
||||
// Update the Redis cache by replaying queued events
|
||||
for (const e of this.cacheQueue) {
|
||||
if (e.op === CacheOp.Add || e.op === CacheOp.Change) {
|
||||
let value = e.value;
|
||||
switch(e.type) {
|
||||
case 'tx': {
|
||||
value = this.txs.get(e.txid);
|
||||
} break;
|
||||
case 'tree': {
|
||||
const tree = this.rbfTrees.get(e.txid);
|
||||
value = tree ? this.exportTree(tree) : null;
|
||||
} break;
|
||||
}
|
||||
if (value != null) {
|
||||
await redisCache.$setRbfEntry(e.type, e.txid, value);
|
||||
}
|
||||
} else if (e.op === CacheOp.Remove) {
|
||||
await redisCache.$removeRbfEntry(e.type, e.txid);
|
||||
}
|
||||
}
|
||||
this.cacheQueue = [];
|
||||
}
|
||||
|
||||
public dump(): any {
|
||||
const trees = Array.from(this.rbfTrees.values()).map((tree: RbfTree) => { return this.exportTree(tree); });
|
||||
|
||||
return {
|
||||
txs: Array.from(this.txs.entries()),
|
||||
trees,
|
||||
expiring: Array.from(this.expiring.entries()),
|
||||
};
|
||||
}
|
||||
|
||||
public async load({ txs, trees, expiring, mempool }): Promise<void> {
|
||||
try {
|
||||
txs.forEach(txEntry => {
|
||||
this.txs.set(txEntry.value.txid, txEntry.value);
|
||||
});
|
||||
this.staleCount = 0;
|
||||
for (const deflatedTree of trees) {
|
||||
await this.importTree(mempool, deflatedTree.root, deflatedTree.root, deflatedTree, this.txs);
|
||||
}
|
||||
expiring.forEach(expiringEntry => {
|
||||
if (this.txs.has(expiringEntry.key)) {
|
||||
this.expiring.set(expiringEntry.key, new Date(expiringEntry.value).getTime());
|
||||
}
|
||||
});
|
||||
this.staleCount = 0;
|
||||
await this.checkTrees();
|
||||
logger.debug(`loaded ${txs.length} txs, ${trees.length} trees into rbf cache, ${expiring.length} due to expire, ${this.staleCount} were stale`);
|
||||
this.cleanup();
|
||||
|
||||
} catch (e) {
|
||||
logger.err('failed to restore RBF cache: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
exportTree(tree: RbfTree, deflated: any = null) {
|
||||
if (!deflated) {
|
||||
deflated = {
|
||||
root: tree.tx.txid,
|
||||
};
|
||||
}
|
||||
deflated[tree.tx.txid] = {
|
||||
tx: tree.tx.txid,
|
||||
txMined: tree.tx.mined,
|
||||
time: tree.time,
|
||||
interval: tree.interval,
|
||||
mined: tree.mined,
|
||||
fullRbf: tree.fullRbf,
|
||||
replaces: tree.replaces.map(child => child.tx.txid),
|
||||
};
|
||||
tree.replaces.forEach(child => {
|
||||
this.exportTree(child, deflated);
|
||||
});
|
||||
return deflated;
|
||||
}
|
||||
|
||||
async importTree(mempool, root, txid, deflated, txs: Map<string, MempoolTransactionExtended>, mined: boolean = false): Promise<RbfTree | void> {
|
||||
const treeInfo = deflated[txid];
|
||||
const replaces: RbfTree[] = [];
|
||||
|
||||
// if the root tx is unknown, remove this tree and return early
|
||||
if (root === txid && !txs.has(txid)) {
|
||||
this.staleCount++;
|
||||
this.removeTree(deflated.key);
|
||||
return;
|
||||
}
|
||||
|
||||
// recursively reconstruct child trees
|
||||
for (const childId of treeInfo.replaces) {
|
||||
const replaced = await this.importTree(mempool, root, childId, deflated, txs, mined);
|
||||
if (replaced) {
|
||||
this.replacedBy.set(replaced.tx.txid, txid);
|
||||
if (mempool[replaced.tx.txid]) {
|
||||
mempool[replaced.tx.txid].replacement = true;
|
||||
}
|
||||
replaces.push(replaced);
|
||||
if (replaced.mined) {
|
||||
mined = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.replaces.set(txid, replaces.map(t => t.tx.txid));
|
||||
|
||||
const tx = txs.get(txid);
|
||||
if (!tx) {
|
||||
return;
|
||||
}
|
||||
const strippedTx = Common.stripTransaction(tx) as RbfTransaction;
|
||||
strippedTx.rbf = tx.vin.some((v) => v.sequence < 0xfffffffe);
|
||||
strippedTx.mined = treeInfo.txMined;
|
||||
const tree = {
|
||||
tx: strippedTx,
|
||||
time: treeInfo.time,
|
||||
interval: treeInfo.interval,
|
||||
mined: mined,
|
||||
fullRbf: treeInfo.fullRbf,
|
||||
replaces,
|
||||
};
|
||||
this.treeMap.set(txid, root);
|
||||
if (root === txid) {
|
||||
this.addTree(root, tree);
|
||||
}
|
||||
return tree;
|
||||
}
|
||||
|
||||
private async checkTrees(): Promise<void> {
|
||||
const found: { [txid: string]: boolean } = {};
|
||||
const txids = Array.from(this.txs.values()).map(tx => tx.txid).filter(txid => {
|
||||
return !this.expiring.has(txid) && !this.getRbfTree(txid)?.mined;
|
||||
});
|
||||
|
||||
const processTxs = (txs: IEsploraApi.Transaction[]): void => {
|
||||
for (const tx of txs) {
|
||||
found[tx.txid] = true;
|
||||
if (tx.status?.confirmed) {
|
||||
const tree = this.getRbfTree(tx.txid);
|
||||
if (tree) {
|
||||
this.setTreeMined(tree, tx.txid);
|
||||
tree.mined = true;
|
||||
this.evict(tx.txid, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (config.MEMPOOL.BACKEND === 'esplora') {
|
||||
let processedCount = 0;
|
||||
const sliceLength = Math.ceil(config.ESPLORA.BATCH_QUERY_BASE_SIZE / 40);
|
||||
for (let i = 0; i < Math.ceil(txids.length / sliceLength); i++) {
|
||||
const slice = txids.slice(i * sliceLength, (i + 1) * sliceLength);
|
||||
processedCount += slice.length;
|
||||
try {
|
||||
const txs = await bitcoinApi.$getRawTransactions(slice);
|
||||
processTxs(txs);
|
||||
logger.debug(`fetched and processed ${processedCount} of ${txids.length} cached rbf transactions (${(processedCount / txids.length * 100).toFixed(2)}%)`);
|
||||
} catch (err) {
|
||||
logger.err(`failed to fetch or process ${slice.length} cached rbf transactions`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const txs: IEsploraApi.Transaction[] = [];
|
||||
for (const txid of txids) {
|
||||
try {
|
||||
const tx = await bitcoinApi.$getRawTransaction(txid, true, false);
|
||||
txs.push(tx);
|
||||
} catch (err) {
|
||||
// some 404s are expected, so continue quietly
|
||||
}
|
||||
}
|
||||
processTxs(txs);
|
||||
}
|
||||
|
||||
for (const txid of txids) {
|
||||
if (!found[txid]) {
|
||||
this.evict(txid, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public getLatestRbfSummary(): ReplacementInfo[] {
|
||||
const rbfList = this.getRbfTrees(false);
|
||||
return rbfList.slice(0, 6).map(rbfTree => {
|
||||
let oldFee = 0;
|
||||
let oldVsize = 0;
|
||||
for (const replaced of rbfTree.replaces) {
|
||||
oldFee += replaced.tx.fee;
|
||||
oldVsize += replaced.tx.vsize;
|
||||
}
|
||||
return {
|
||||
txid: rbfTree.tx.txid,
|
||||
mined: !!rbfTree.tx.mined,
|
||||
fullRbf: !!rbfTree.tx.fullRbf,
|
||||
oldFee,
|
||||
oldVsize,
|
||||
newFee: rbfTree.tx.fee,
|
||||
newVsize: rbfTree.tx.vsize,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new RbfCache();
|
||||
|
||||
416
backend/src/api/redis-cache.ts
Normal file
416
backend/src/api/redis-cache.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { createClient } from 'redis';
|
||||
import memPool from './mempool';
|
||||
import blocks from './blocks';
|
||||
import logger from '../logger';
|
||||
import config from '../config';
|
||||
import { BlockExtended, BlockSummary, MempoolTransactionExtended } from '../mempool.interfaces';
|
||||
import rbfCache from './rbf-cache';
|
||||
import transactionUtils from './transaction-utils';
|
||||
|
||||
enum NetworkDB {
|
||||
mainnet = 0,
|
||||
testnet,
|
||||
signet,
|
||||
liquid,
|
||||
liquidtestnet,
|
||||
}
|
||||
|
||||
class RedisCache {
|
||||
private client;
|
||||
private connected = false;
|
||||
private schemaVersion = 1;
|
||||
private redisConfig: any;
|
||||
|
||||
private pauseFlush: boolean = false;
|
||||
private cacheQueue: MempoolTransactionExtended[] = [];
|
||||
private removeQueue: string[] = [];
|
||||
private rbfCacheQueue: { type: string, txid: string, value: any }[] = [];
|
||||
private rbfRemoveQueue: { type: string, txid: string }[] = [];
|
||||
private txFlushLimit: number = 10000;
|
||||
|
||||
constructor() {
|
||||
if (config.REDIS.ENABLED) {
|
||||
this.redisConfig = {
|
||||
socket: {
|
||||
path: config.REDIS.UNIX_SOCKET_PATH
|
||||
},
|
||||
database: NetworkDB[config.MEMPOOL.NETWORK],
|
||||
};
|
||||
this.$ensureConnected();
|
||||
setInterval(() => { this.$ensureConnected(); }, 10000);
|
||||
}
|
||||
}
|
||||
|
||||
private async $ensureConnected(): Promise<boolean> {
|
||||
if (!this.connected && config.REDIS.ENABLED) {
|
||||
try {
|
||||
this.client = createClient(this.redisConfig);
|
||||
this.client.on('error', async (e) => {
|
||||
logger.err(`Error in Redis client: ${e instanceof Error ? e.message : e}`);
|
||||
this.connected = false;
|
||||
await this.client.disconnect();
|
||||
});
|
||||
await this.client.connect().then(async () => {
|
||||
try {
|
||||
const version = await this.client.get('schema_version');
|
||||
this.connected = true;
|
||||
if (version !== this.schemaVersion) {
|
||||
// schema changed
|
||||
// perform migrations or flush DB if necessary
|
||||
logger.info(`Redis schema version changed from ${version} to ${this.schemaVersion}`);
|
||||
await this.client.set('schema_version', this.schemaVersion);
|
||||
}
|
||||
logger.info(`Redis client connected`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.connected = false;
|
||||
logger.warn('Failed to connect to Redis');
|
||||
return false;
|
||||
}
|
||||
});
|
||||
await this.$onConnected();
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.warn('Error connecting to Redis: ' + (e instanceof Error ? e.message : e));
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
// test connection
|
||||
await this.client.get('schema_version');
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.warn('Lost connection to Redis: ' + (e instanceof Error ? e.message : e));
|
||||
logger.warn('Attempting to reconnect in 10 seconds');
|
||||
this.connected = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async $onConnected(): Promise<void> {
|
||||
await this.$flushTransactions();
|
||||
await this.$removeTransactions([]);
|
||||
await this.$flushRbfQueues();
|
||||
}
|
||||
|
||||
async $updateBlocks(blocks: BlockExtended[]): Promise<void> {
|
||||
if (!config.REDIS.ENABLED) {
|
||||
return;
|
||||
}
|
||||
if (!this.connected) {
|
||||
logger.warn(`Failed to update blocks in Redis cache: Redis is not connected`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.client.set('blocks', JSON.stringify(blocks));
|
||||
logger.debug(`Saved latest blocks to Redis cache`);
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to update blocks in Redis cache: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async $updateBlockSummaries(summaries: BlockSummary[]): Promise<void> {
|
||||
if (!config.REDIS.ENABLED) {
|
||||
return;
|
||||
}
|
||||
if (!this.connected) {
|
||||
logger.warn(`Failed to update block summaries in Redis cache: Redis is not connected`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.client.set('block-summaries', JSON.stringify(summaries));
|
||||
logger.debug(`Saved latest block summaries to Redis cache`);
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to update block summaries in Redis cache: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async $addTransaction(tx: MempoolTransactionExtended): Promise<void> {
|
||||
if (!config.REDIS.ENABLED) {
|
||||
return;
|
||||
}
|
||||
this.cacheQueue.push(tx);
|
||||
if (this.cacheQueue.length >= this.txFlushLimit) {
|
||||
if (!this.pauseFlush) {
|
||||
await this.$flushTransactions();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async $flushTransactions(): Promise<void> {
|
||||
if (!config.REDIS.ENABLED) {
|
||||
return;
|
||||
}
|
||||
if (!this.cacheQueue.length) {
|
||||
return;
|
||||
}
|
||||
if (!this.connected) {
|
||||
logger.warn(`Failed to add ${this.cacheQueue.length} transactions to Redis cache: Redis not connected`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.pauseFlush = false;
|
||||
|
||||
const toAdd = this.cacheQueue.slice(0, this.txFlushLimit);
|
||||
try {
|
||||
const msetData = toAdd.map(tx => {
|
||||
const minified: any = { ...tx };
|
||||
delete minified.hex;
|
||||
for (const vin of minified.vin) {
|
||||
delete vin.inner_redeemscript_asm;
|
||||
delete vin.inner_witnessscript_asm;
|
||||
delete vin.scriptsig_asm;
|
||||
}
|
||||
for (const vout of minified.vout) {
|
||||
delete vout.scriptpubkey_asm;
|
||||
}
|
||||
return [`mempool:tx:${tx.txid}`, JSON.stringify(minified)];
|
||||
});
|
||||
await this.client.MSET(msetData);
|
||||
// successful, remove transactions from cache queue
|
||||
this.cacheQueue = this.cacheQueue.slice(toAdd.length);
|
||||
logger.debug(`Saved ${toAdd.length} transactions to Redis cache, ${this.cacheQueue.length} left in queue`);
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to add ${toAdd.length} transactions to Redis cache: ${e instanceof Error ? e.message : e}`);
|
||||
this.pauseFlush = true;
|
||||
}
|
||||
}
|
||||
|
||||
async $removeTransactions(transactions: string[]): Promise<void> {
|
||||
if (!config.REDIS.ENABLED) {
|
||||
return;
|
||||
}
|
||||
const toRemove = this.removeQueue.concat(transactions);
|
||||
this.removeQueue = [];
|
||||
let failed: string[] = [];
|
||||
let numRemoved = 0;
|
||||
if (this.connected) {
|
||||
const sliceLength = config.REDIS.BATCH_QUERY_BASE_SIZE;
|
||||
for (let i = 0; i < Math.ceil(toRemove.length / sliceLength); i++) {
|
||||
const slice = toRemove.slice(i * sliceLength, (i + 1) * sliceLength);
|
||||
try {
|
||||
await this.client.unlink(slice.map(txid => `mempool:tx:${txid}`));
|
||||
numRemoved+= sliceLength;
|
||||
logger.debug(`Deleted ${slice.length} transactions from the Redis cache`);
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to remove ${slice.length} transactions from Redis cache: ${e instanceof Error ? e.message : e}`);
|
||||
failed = failed.concat(slice);
|
||||
}
|
||||
}
|
||||
// concat instead of replace, in case more txs have been added in the meantime
|
||||
this.removeQueue = this.removeQueue.concat(failed);
|
||||
} else {
|
||||
this.removeQueue = this.removeQueue.concat(toRemove);
|
||||
}
|
||||
}
|
||||
|
||||
async $setRbfEntry(type: string, txid: string, value: any): Promise<void> {
|
||||
if (!config.REDIS.ENABLED) {
|
||||
return;
|
||||
}
|
||||
if (!this.connected) {
|
||||
this.rbfCacheQueue.push({ type, txid, value });
|
||||
logger.warn(`Failed to set RBF ${type} in Redis cache: Redis is not connected`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.client.set(`rbf:${type}:${txid}`, JSON.stringify(value));
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to set RBF ${type} in Redis cache: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async $removeRbfEntry(type: string, txid: string): Promise<void> {
|
||||
if (!config.REDIS.ENABLED) {
|
||||
return;
|
||||
}
|
||||
if (!this.connected) {
|
||||
this.rbfRemoveQueue.push({ type, txid });
|
||||
logger.warn(`Failed to remove RBF ${type} from Redis cache: Redis is not connected`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.client.unlink(`rbf:${type}:${txid}`);
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to remove RBF ${type} from Redis cache: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async $flushRbfQueues(): Promise<void> {
|
||||
if (!config.REDIS.ENABLED) {
|
||||
return;
|
||||
}
|
||||
if (!this.connected) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const toAdd = this.rbfCacheQueue;
|
||||
this.rbfCacheQueue = [];
|
||||
for (const { type, txid, value } of toAdd) {
|
||||
await this.$setRbfEntry(type, txid, value);
|
||||
}
|
||||
logger.debug(`Saved ${toAdd.length} queued RBF entries to the Redis cache`);
|
||||
const toRemove = this.rbfRemoveQueue;
|
||||
this.rbfRemoveQueue = [];
|
||||
for (const { type, txid } of toRemove) {
|
||||
await this.$removeRbfEntry(type, txid);
|
||||
}
|
||||
logger.debug(`Removed ${toRemove.length} queued RBF entries from the Redis cache`);
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to flush RBF cache event queues after reconnecting to Redis: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async $getBlocks(): Promise<BlockExtended[]> {
|
||||
if (!config.REDIS.ENABLED) {
|
||||
return [];
|
||||
}
|
||||
if (!this.connected) {
|
||||
logger.warn(`Failed to retrieve blocks from Redis cache: Redis is not connected`);
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const json = await this.client.get('blocks');
|
||||
return JSON.parse(json);
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to retrieve blocks from Redis cache: ${e instanceof Error ? e.message : e}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async $getBlockSummaries(): Promise<BlockSummary[]> {
|
||||
if (!config.REDIS.ENABLED) {
|
||||
return [];
|
||||
}
|
||||
if (!this.connected) {
|
||||
logger.warn(`Failed to retrieve blocks from Redis cache: Redis is not connected`);
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const json = await this.client.get('block-summaries');
|
||||
return JSON.parse(json);
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to retrieve blocks from Redis cache: ${e instanceof Error ? e.message : e}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async $getMempool(): Promise<{ [txid: string]: MempoolTransactionExtended }> {
|
||||
if (!config.REDIS.ENABLED) {
|
||||
return {};
|
||||
}
|
||||
if (!this.connected) {
|
||||
logger.warn(`Failed to retrieve mempool from Redis cache: Redis is not connected`);
|
||||
return {};
|
||||
}
|
||||
const start = Date.now();
|
||||
const mempool = {};
|
||||
try {
|
||||
const mempoolList = await this.scanKeys<MempoolTransactionExtended>('mempool:tx:*');
|
||||
for (const tx of mempoolList) {
|
||||
mempool[tx.key] = tx.value;
|
||||
}
|
||||
logger.info(`Loaded mempool from Redis cache in ${Date.now() - start} ms`);
|
||||
return mempool || {};
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to retrieve mempool from Redis cache: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async $getRbfEntries(type: string): Promise<any[]> {
|
||||
if (!config.REDIS.ENABLED) {
|
||||
return [];
|
||||
}
|
||||
if (!this.connected) {
|
||||
logger.warn(`Failed to retrieve Rbf ${type}s from Redis cache: Redis is not connected`);
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const rbfEntries = await this.scanKeys<MempoolTransactionExtended[]>(`rbf:${type}:*`);
|
||||
return rbfEntries;
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to retrieve Rbf ${type}s from Redis cache: ${e instanceof Error ? e.message : e}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async $loadCache(): Promise<void> {
|
||||
if (!config.REDIS.ENABLED) {
|
||||
return;
|
||||
}
|
||||
logger.info('Restoring mempool and blocks data from Redis cache');
|
||||
// Load block data
|
||||
const loadedBlocks = await this.$getBlocks();
|
||||
const loadedBlockSummaries = await this.$getBlockSummaries();
|
||||
// Load mempool
|
||||
const loadedMempool = await this.$getMempool();
|
||||
this.inflateLoadedTxs(loadedMempool);
|
||||
// Load rbf data
|
||||
const rbfTxs = await this.$getRbfEntries('tx');
|
||||
const rbfTrees = await this.$getRbfEntries('tree');
|
||||
const rbfExpirations = await this.$getRbfEntries('exp');
|
||||
|
||||
// Set loaded data
|
||||
blocks.setBlocks(loadedBlocks || []);
|
||||
blocks.setBlockSummaries(loadedBlockSummaries || []);
|
||||
await memPool.$setMempool(loadedMempool);
|
||||
await rbfCache.load({
|
||||
txs: rbfTxs,
|
||||
trees: rbfTrees.map(loadedTree => { loadedTree.value.key = loadedTree.key; return loadedTree.value; }),
|
||||
expiring: rbfExpirations,
|
||||
mempool: memPool.getMempool(),
|
||||
});
|
||||
}
|
||||
|
||||
private inflateLoadedTxs(mempool: { [txid: string]: MempoolTransactionExtended }): void {
|
||||
for (const tx of Object.values(mempool)) {
|
||||
for (const vin of tx.vin) {
|
||||
if (vin.scriptsig) {
|
||||
vin.scriptsig_asm = transactionUtils.convertScriptSigAsm(vin.scriptsig);
|
||||
transactionUtils.addInnerScriptsToVin(vin);
|
||||
}
|
||||
}
|
||||
for (const vout of tx.vout) {
|
||||
if (vout.scriptpubkey) {
|
||||
vout.scriptpubkey_asm = transactionUtils.convertScriptSigAsm(vout.scriptpubkey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async scanKeys<T>(pattern): Promise<{ key: string, value: T }[]> {
|
||||
logger.info(`loading Redis entries for ${pattern}`);
|
||||
let keys: string[] = [];
|
||||
const result: { key: string, value: T }[] = [];
|
||||
const patternLength = pattern.length - 1;
|
||||
let count = 0;
|
||||
const processValues = async (keys): Promise<void> => {
|
||||
const values = await this.client.MGET(keys);
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
if (values[i]) {
|
||||
result.push({ key: keys[i].slice(patternLength), value: JSON.parse(values[i]) });
|
||||
count++;
|
||||
}
|
||||
}
|
||||
logger.info(`loaded ${count} entries from Redis cache`);
|
||||
};
|
||||
for await (const key of this.client.scanIterator({
|
||||
MATCH: pattern,
|
||||
COUNT: 100
|
||||
})) {
|
||||
keys.push(key);
|
||||
if (keys.length >= 10000) {
|
||||
await processValues(keys);
|
||||
keys = [];
|
||||
}
|
||||
}
|
||||
if (keys.length) {
|
||||
await processValues(keys);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export default new RedisCache();
|
||||
36
backend/src/api/services/acceleration.ts
Normal file
36
backend/src/api/services/acceleration.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import config from '../../config';
|
||||
import logger from '../../logger';
|
||||
import { BlockExtended, PoolTag } from '../../mempool.interfaces';
|
||||
import axios from 'axios';
|
||||
|
||||
export interface Acceleration {
|
||||
txid: string,
|
||||
feeDelta: number,
|
||||
pools: number[],
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public isAcceleratedBlock(block: BlockExtended, accelerations: Acceleration[]): boolean {
|
||||
let anyAccelerated = false;
|
||||
for (let i = 0; i < accelerations.length && !anyAccelerated; i++) {
|
||||
anyAccelerated = anyAccelerated || accelerations[i].pools?.includes(block.extras.pool.id);
|
||||
}
|
||||
return anyAccelerated;
|
||||
}
|
||||
}
|
||||
|
||||
export default new AccelerationApi();
|
||||
@@ -15,6 +15,7 @@ class StatisticsApi {
|
||||
mempool_byte_weight,
|
||||
fee_data,
|
||||
total_fee,
|
||||
min_fee,
|
||||
vsize_1,
|
||||
vsize_2,
|
||||
vsize_3,
|
||||
@@ -54,7 +55,7 @@ class StatisticsApi {
|
||||
vsize_1800,
|
||||
vsize_2000
|
||||
)
|
||||
VALUES (NOW(), 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
VALUES (NOW(), 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)`;
|
||||
const [result]: any = await DB.query(query);
|
||||
return result.insertId;
|
||||
@@ -73,6 +74,7 @@ class StatisticsApi {
|
||||
mempool_byte_weight,
|
||||
fee_data,
|
||||
total_fee,
|
||||
min_fee,
|
||||
vsize_1,
|
||||
vsize_2,
|
||||
vsize_3,
|
||||
@@ -112,7 +114,7 @@ class StatisticsApi {
|
||||
vsize_1800,
|
||||
vsize_2000
|
||||
)
|
||||
VALUES (${statistics.added}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
VALUES (${statistics.added}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
||||
|
||||
const params: (string | number)[] = [
|
||||
@@ -122,6 +124,7 @@ class StatisticsApi {
|
||||
statistics.mempool_byte_weight,
|
||||
statistics.fee_data,
|
||||
statistics.total_fee,
|
||||
statistics.min_fee,
|
||||
statistics.vsize_1,
|
||||
statistics.vsize_2,
|
||||
statistics.vsize_3,
|
||||
@@ -171,7 +174,9 @@ class StatisticsApi {
|
||||
private getQueryForDaysAvg(div: number, interval: string) {
|
||||
return `SELECT
|
||||
UNIX_TIMESTAMP(added) as added,
|
||||
CAST(avg(unconfirmed_transactions) as DOUBLE) as unconfirmed_transactions,
|
||||
CAST(avg(vbytes_per_second) as DOUBLE) as vbytes_per_second,
|
||||
CAST(avg(min_fee) as DOUBLE) as min_fee,
|
||||
CAST(avg(vsize_1) as DOUBLE) as vsize_1,
|
||||
CAST(avg(vsize_2) as DOUBLE) as vsize_2,
|
||||
CAST(avg(vsize_3) as DOUBLE) as vsize_3,
|
||||
@@ -211,7 +216,7 @@ class StatisticsApi {
|
||||
CAST(avg(vsize_1800) as DOUBLE) as vsize_1800,
|
||||
CAST(avg(vsize_2000) as DOUBLE) as vsize_2000 \
|
||||
FROM statistics \
|
||||
WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW() \
|
||||
${interval === 'all' ? '' : `WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`} \
|
||||
GROUP BY UNIX_TIMESTAMP(added) DIV ${div} \
|
||||
ORDER BY statistics.added DESC;`;
|
||||
}
|
||||
@@ -219,7 +224,9 @@ class StatisticsApi {
|
||||
private getQueryForDays(div: number, interval: string) {
|
||||
return `SELECT
|
||||
UNIX_TIMESTAMP(added) as added,
|
||||
CAST(avg(unconfirmed_transactions) as DOUBLE) as unconfirmed_transactions,
|
||||
CAST(avg(vbytes_per_second) as DOUBLE) as vbytes_per_second,
|
||||
CAST(avg(min_fee) as DOUBLE) as min_fee,
|
||||
vsize_1,
|
||||
vsize_2,
|
||||
vsize_3,
|
||||
@@ -259,7 +266,7 @@ class StatisticsApi {
|
||||
vsize_1800,
|
||||
vsize_2000 \
|
||||
FROM statistics \
|
||||
WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW() \
|
||||
${interval === 'all' ? '' : `WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`} \
|
||||
GROUP BY UNIX_TIMESTAMP(added) DIV ${div} \
|
||||
ORDER BY statistics.added DESC;`;
|
||||
}
|
||||
@@ -278,7 +285,7 @@ class StatisticsApi {
|
||||
|
||||
public async $list2H(): Promise<OptimizedStatistic[]> {
|
||||
try {
|
||||
const query = `SELECT *, UNIX_TIMESTAMP(added) as added FROM statistics ORDER BY statistics.added DESC LIMIT 120`;
|
||||
const query = `SELECT *, UNIX_TIMESTAMP(added) as added FROM statistics WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL 2 HOUR) AND NOW() ORDER BY statistics.added DESC`;
|
||||
const [rows] = await DB.query({ sql: query, timeout: this.queryTimeout });
|
||||
return this.mapStatisticToOptimizedStatistic(rows as Statistic[]);
|
||||
} catch (e) {
|
||||
@@ -289,7 +296,7 @@ class StatisticsApi {
|
||||
|
||||
public async $list24H(): Promise<OptimizedStatistic[]> {
|
||||
try {
|
||||
const query = `SELECT *, UNIX_TIMESTAMP(added) as added FROM statistics ORDER BY statistics.added DESC LIMIT 1440`;
|
||||
const query = `SELECT *, UNIX_TIMESTAMP(added) as added FROM statistics WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL 24 HOUR) AND NOW() ORDER BY statistics.added DESC`;
|
||||
const [rows] = await DB.query({ sql: query, timeout: this.queryTimeout });
|
||||
return this.mapStatisticToOptimizedStatistic(rows as Statistic[]);
|
||||
} catch (e) {
|
||||
@@ -386,13 +393,26 @@ class StatisticsApi {
|
||||
}
|
||||
}
|
||||
|
||||
public async $listAll(): Promise<OptimizedStatistic[]> {
|
||||
try {
|
||||
const query = this.getQueryForDays(43200, 'all'); // 12h interval
|
||||
const [rows] = await DB.query({ sql: query, timeout: this.queryTimeout });
|
||||
return this.mapStatisticToOptimizedStatistic(rows as Statistic[]);
|
||||
} catch (e) {
|
||||
logger.err('$listAll() error' + (e instanceof Error ? e.message : e));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private mapStatisticToOptimizedStatistic(statistic: Statistic[]): OptimizedStatistic[] {
|
||||
return statistic.map((s) => {
|
||||
return {
|
||||
added: s.added,
|
||||
count: s.unconfirmed_transactions,
|
||||
vbytes_per_second: s.vbytes_per_second,
|
||||
mempool_byte_weight: s.mempool_byte_weight,
|
||||
total_fee: s.total_fee,
|
||||
min_fee: s.min_fee,
|
||||
vsizes: [
|
||||
s.vsize_1,
|
||||
s.vsize_2,
|
||||
|
||||
@@ -15,10 +15,11 @@ class StatisticsRoutes {
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/2y', this.$getStatisticsByTime.bind(this, '2y'))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/3y', this.$getStatisticsByTime.bind(this, '3y'))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/4y', this.$getStatisticsByTime.bind(this, '4y'))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/all', this.$getStatisticsByTime.bind(this, 'all'))
|
||||
;
|
||||
}
|
||||
|
||||
private async $getStatisticsByTime(time: '2h' | '24h' | '1w' | '1m' | '3m' | '6m' | '1y' | '2y' | '3y' | '4y', req: Request, res: Response) {
|
||||
private async $getStatisticsByTime(time: '2h' | '24h' | '1w' | '1m' | '3m' | '6m' | '1y' | '2y' | '3y' | '4y' | 'all', req: Request, res: Response) {
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString());
|
||||
@@ -26,10 +27,6 @@ class StatisticsRoutes {
|
||||
try {
|
||||
let result;
|
||||
switch (time as string) {
|
||||
case '2h':
|
||||
result = await statisticsApi.$list2H();
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
|
||||
break;
|
||||
case '24h':
|
||||
result = await statisticsApi.$list24H();
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
@@ -58,8 +55,13 @@ class StatisticsRoutes {
|
||||
case '4y':
|
||||
result = await statisticsApi.$list4Y();
|
||||
break;
|
||||
case 'all':
|
||||
result = await statisticsApi.$listAll();
|
||||
break;
|
||||
default:
|
||||
result = await statisticsApi.$list2H();
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
|
||||
break;
|
||||
}
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import statisticsApi from './statistics-api';
|
||||
|
||||
class Statistics {
|
||||
protected intervalTimer: NodeJS.Timer | undefined;
|
||||
protected lastRun: number = 0;
|
||||
protected newStatisticsEntryCallback: ((stats: OptimizedStatistic) => void) | undefined;
|
||||
|
||||
public setNewStatisticsEntryCallback(fn: (stats: OptimizedStatistic) => void) {
|
||||
@@ -23,15 +24,21 @@ class Statistics {
|
||||
setTimeout(() => {
|
||||
this.runStatistics();
|
||||
this.intervalTimer = setInterval(() => {
|
||||
this.runStatistics();
|
||||
this.runStatistics(true);
|
||||
}, 1 * 60 * 1000);
|
||||
}, difference);
|
||||
}
|
||||
|
||||
private async runStatistics(): Promise<void> {
|
||||
public async runStatistics(skipIfRecent = false): Promise<void> {
|
||||
if (!memPool.isInSync()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (skipIfRecent && new Date().getTime() / 1000 - this.lastRun < 30) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastRun = new Date().getTime() / 1000;
|
||||
const currentMempool = memPool.getMempool();
|
||||
const txPerSecond = memPool.getTxPerSecond();
|
||||
const vBytesPerSecond = memPool.getVBytesPerSecond();
|
||||
@@ -89,6 +96,9 @@ class Statistics {
|
||||
}
|
||||
});
|
||||
|
||||
// get minFee and convert to sats/vb
|
||||
const minFee = memPool.getMempoolInfo().mempoolminfee * 100000;
|
||||
|
||||
try {
|
||||
const insertId = await statisticsApi.$create({
|
||||
added: 'NOW()',
|
||||
@@ -98,6 +108,7 @@ class Statistics {
|
||||
mempool_byte_weight: totalWeight,
|
||||
total_fee: totalFee,
|
||||
fee_data: '',
|
||||
min_fee: minFee,
|
||||
vsize_1: weightVsizeFees['1'] || 0,
|
||||
vsize_2: weightVsizeFees['2'] || 0,
|
||||
vsize_3: weightVsizeFees['3'] || 0,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { TransactionExtended, TransactionMinerInfo } from '../mempool.interfaces';
|
||||
import { TransactionExtended, MempoolTransactionExtended, TransactionMinerInfo } from '../mempool.interfaces';
|
||||
import { IEsploraApi } from './bitcoin/esplora-api.interface';
|
||||
import { Common } from './common';
|
||||
import bitcoinApi, { bitcoinCoreApi } from './bitcoin/bitcoin-api-factory';
|
||||
import * as bitcoinjs from 'bitcoinjs-lib';
|
||||
import logger from '../logger';
|
||||
import config from '../config';
|
||||
import pLimit from '../utils/p-limit';
|
||||
|
||||
class TransactionUtils {
|
||||
constructor() { }
|
||||
@@ -21,37 +25,113 @@ class TransactionUtils {
|
||||
};
|
||||
}
|
||||
|
||||
// Wrapper for $getTransactionExtended with an automatic retry direct to Core if the first API request fails.
|
||||
// Propagates any error from the retry request.
|
||||
public async $getTransactionExtendedRetry(txid: string, addPrevouts = false, lazyPrevouts = false, forceCore = false, addMempoolData = false): Promise<TransactionExtended> {
|
||||
try {
|
||||
const result = await this.$getTransactionExtended(txid, addPrevouts, lazyPrevouts, forceCore, addMempoolData);
|
||||
if (result) {
|
||||
return result;
|
||||
} else {
|
||||
logger.err(`Cannot fetch tx ${txid}. Reason: backend returned null data`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err(`Cannot fetch tx ${txid}. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
// retry direct from Core if first request failed
|
||||
return this.$getTransactionExtended(txid, addPrevouts, lazyPrevouts, true, addMempoolData);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param txId
|
||||
* @param addPrevouts
|
||||
* @param lazyPrevouts
|
||||
* @param txId
|
||||
* @param addPrevouts
|
||||
* @param lazyPrevouts
|
||||
* @param forceCore - See https://github.com/mempool/mempool/issues/2904
|
||||
*/
|
||||
public async $getTransactionExtended(txId: string, addPrevouts = false, lazyPrevouts = false, forceCore = false): Promise<TransactionExtended> {
|
||||
public async $getTransactionExtended(txId: string, addPrevouts = false, lazyPrevouts = false, forceCore = false, addMempoolData = false): Promise<TransactionExtended> {
|
||||
let transaction: IEsploraApi.Transaction;
|
||||
if (forceCore === true) {
|
||||
transaction = await bitcoinCoreApi.$getRawTransaction(txId, true);
|
||||
transaction = await bitcoinCoreApi.$getRawTransaction(txId, false, addPrevouts, lazyPrevouts);
|
||||
} else {
|
||||
transaction = await bitcoinApi.$getRawTransaction(txId, false, addPrevouts, lazyPrevouts);
|
||||
}
|
||||
return this.extendTransaction(transaction);
|
||||
|
||||
if (Common.isLiquid()) {
|
||||
if (!isFinite(Number(transaction.fee))) {
|
||||
transaction.fee = Object.values(transaction.fee || {}).reduce((total, output) => total + output, 0);
|
||||
}
|
||||
}
|
||||
|
||||
if (addMempoolData || !transaction?.status?.confirmed) {
|
||||
return this.extendMempoolTransaction(transaction);
|
||||
} else {
|
||||
return this.extendTransaction(transaction);
|
||||
}
|
||||
}
|
||||
|
||||
private extendTransaction(transaction: IEsploraApi.Transaction): TransactionExtended {
|
||||
public async $getMempoolTransactionExtended(txId: string, addPrevouts = false, lazyPrevouts = false, forceCore = false): Promise<MempoolTransactionExtended> {
|
||||
return (await this.$getTransactionExtended(txId, addPrevouts, lazyPrevouts, forceCore, true)) as MempoolTransactionExtended;
|
||||
}
|
||||
|
||||
public async $getMempoolTransactionsExtended(txids: string[], addPrevouts = false, lazyPrevouts = false, forceCore = false): Promise<MempoolTransactionExtended[]> {
|
||||
if (forceCore || config.MEMPOOL.BACKEND !== 'esplora') {
|
||||
const limiter = pLimit(8); // Run 8 requests at a time
|
||||
const results = await Promise.allSettled(txids.map(
|
||||
txid => limiter(() => this.$getMempoolTransactionExtended(txid, addPrevouts, lazyPrevouts, forceCore))
|
||||
));
|
||||
return results.filter(reply => reply.status === 'fulfilled')
|
||||
.map(r => (r as PromiseFulfilledResult<MempoolTransactionExtended>).value);
|
||||
} else {
|
||||
const transactions = await bitcoinApi.$getMempoolTransactions(txids);
|
||||
return transactions.map(transaction => {
|
||||
if (Common.isLiquid()) {
|
||||
if (!isFinite(Number(transaction.fee))) {
|
||||
transaction.fee = Object.values(transaction.fee || {}).reduce((total, output) => total + output, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return this.extendMempoolTransaction(transaction);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public extendTransaction(transaction: IEsploraApi.Transaction): TransactionExtended {
|
||||
// @ts-ignore
|
||||
if (transaction.vsize) {
|
||||
// @ts-ignore
|
||||
return transaction;
|
||||
}
|
||||
const feePerVbytes = Math.max(Common.isLiquid() ? 0.1 : 1,
|
||||
(transaction.fee || 0) / (transaction.weight / 4));
|
||||
const feePerVbytes = (transaction.fee || 0) / (transaction.weight / 4);
|
||||
const transactionExtended: TransactionExtended = Object.assign({
|
||||
vsize: Math.round(transaction.weight / 4),
|
||||
feePerVsize: feePerVbytes,
|
||||
effectiveFeePerVsize: feePerVbytes,
|
||||
}, transaction);
|
||||
if (!transaction.status.confirmed) {
|
||||
transactionExtended.firstSeen = Math.round((new Date().getTime() / 1000));
|
||||
if (!transaction?.status?.confirmed && !transactionExtended.firstSeen) {
|
||||
transactionExtended.firstSeen = Math.round((Date.now() / 1000));
|
||||
}
|
||||
return transactionExtended;
|
||||
}
|
||||
|
||||
public extendMempoolTransaction(transaction: IEsploraApi.Transaction): MempoolTransactionExtended {
|
||||
const vsize = Math.ceil(transaction.weight / 4);
|
||||
const fractionalVsize = (transaction.weight / 4);
|
||||
let sigops = Common.isLiquid() ? 0 : (transaction.sigops != null ? transaction.sigops : this.countSigops(transaction));
|
||||
// https://github.com/bitcoin/bitcoin/blob/e9262ea32a6e1d364fb7974844fadc36f931f8c6/src/policy/policy.cpp#L295-L298
|
||||
const adjustedVsize = Math.max(fractionalVsize, sigops * 5); // adjusted vsize = Max(weight, sigops * bytes_per_sigop) / witness_scale_factor
|
||||
const feePerVbytes = (transaction.fee || 0) / fractionalVsize;
|
||||
const adjustedFeePerVsize = (transaction.fee || 0) / adjustedVsize;
|
||||
const transactionExtended: MempoolTransactionExtended = Object.assign(transaction, {
|
||||
order: this.txidToOrdering(transaction.txid),
|
||||
vsize: Math.round(transaction.weight / 4),
|
||||
adjustedVsize,
|
||||
sigops,
|
||||
feePerVsize: feePerVbytes,
|
||||
adjustedFeePerVsize: adjustedFeePerVsize,
|
||||
effectiveFeePerVsize: adjustedFeePerVsize,
|
||||
});
|
||||
if (!transactionExtended?.status?.confirmed && !transactionExtended.firstSeen) {
|
||||
transactionExtended.firstSeen = Math.round((Date.now() / 1000));
|
||||
}
|
||||
return transactionExtended;
|
||||
}
|
||||
@@ -63,6 +143,197 @@ class TransactionUtils {
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
public countScriptSigops(script: string, isRawScript: boolean = false, witness: boolean = false): number {
|
||||
let sigops = 0;
|
||||
// count OP_CHECKSIG and OP_CHECKSIGVERIFY
|
||||
sigops += (script.match(/OP_CHECKSIG/g)?.length || 0);
|
||||
|
||||
// count OP_CHECKMULTISIG and OP_CHECKMULTISIGVERIFY
|
||||
if (isRawScript) {
|
||||
// in scriptPubKey or scriptSig, always worth 20
|
||||
sigops += 20 * (script.match(/OP_CHECKMULTISIG/g)?.length || 0);
|
||||
} else {
|
||||
// in redeem scripts and witnesses, worth N if preceded by OP_N, 20 otherwise
|
||||
const matches = script.matchAll(/(?:OP_(?:PUSHNUM_)?(\d+))? OP_CHECKMULTISIG/g);
|
||||
for (const match of matches) {
|
||||
const n = parseInt(match[1]);
|
||||
if (Number.isInteger(n)) {
|
||||
sigops += n;
|
||||
} else {
|
||||
sigops += 20;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return witness ? sigops : (sigops * 4);
|
||||
}
|
||||
|
||||
public countSigops(transaction: IEsploraApi.Transaction): number {
|
||||
let sigops = 0;
|
||||
|
||||
for (const input of transaction.vin) {
|
||||
if (input.scriptsig_asm) {
|
||||
sigops += this.countScriptSigops(input.scriptsig_asm, true);
|
||||
}
|
||||
if (input.prevout) {
|
||||
switch (true) {
|
||||
case input.prevout.scriptpubkey_type === 'p2sh' && input.witness?.length === 2 && input.scriptsig && input.scriptsig.startsWith('160014'):
|
||||
case input.prevout.scriptpubkey_type === 'v0_p2wpkh':
|
||||
sigops += 1;
|
||||
break;
|
||||
|
||||
case input.prevout?.scriptpubkey_type === 'p2sh' && input.witness?.length && input.scriptsig && input.scriptsig.startsWith('220020'):
|
||||
case input.prevout.scriptpubkey_type === 'v0_p2wsh':
|
||||
if (input.witness?.length) {
|
||||
sigops += this.countScriptSigops(bitcoinjs.script.toASM(Buffer.from(input.witness[input.witness.length - 1], 'hex')), false, true);
|
||||
}
|
||||
break;
|
||||
|
||||
case input.prevout.scriptpubkey_type === 'p2sh':
|
||||
if (input.inner_redeemscript_asm) {
|
||||
sigops += this.countScriptSigops(input.inner_redeemscript_asm);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const output of transaction.vout) {
|
||||
if (output.scriptpubkey_asm) {
|
||||
sigops += this.countScriptSigops(output.scriptpubkey_asm, true);
|
||||
}
|
||||
}
|
||||
|
||||
return sigops;
|
||||
}
|
||||
|
||||
// returns the most significant 4 bytes of the txid as an integer
|
||||
public txidToOrdering(txid: string): number {
|
||||
return parseInt(
|
||||
txid.substr(62, 2) +
|
||||
txid.substr(60, 2) +
|
||||
txid.substr(58, 2) +
|
||||
txid.substr(56, 2),
|
||||
16
|
||||
);
|
||||
}
|
||||
|
||||
public addInnerScriptsToVin(vin: IEsploraApi.Vin): void {
|
||||
if (!vin.prevout) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (vin.prevout.scriptpubkey_type === 'p2sh') {
|
||||
const redeemScript = vin.scriptsig_asm.split(' ').reverse()[0];
|
||||
vin.inner_redeemscript_asm = this.convertScriptSigAsm(redeemScript);
|
||||
if (vin.witness && vin.witness.length > 2) {
|
||||
const witnessScript = vin.witness[vin.witness.length - 1];
|
||||
vin.inner_witnessscript_asm = this.convertScriptSigAsm(witnessScript);
|
||||
}
|
||||
}
|
||||
|
||||
if (vin.prevout.scriptpubkey_type === 'v0_p2wsh' && vin.witness) {
|
||||
const witnessScript = vin.witness[vin.witness.length - 1];
|
||||
vin.inner_witnessscript_asm = this.convertScriptSigAsm(witnessScript);
|
||||
}
|
||||
|
||||
if (vin.prevout.scriptpubkey_type === 'v1_p2tr' && vin.witness) {
|
||||
const witnessScript = this.witnessToP2TRScript(vin.witness);
|
||||
if (witnessScript !== null) {
|
||||
vin.inner_witnessscript_asm = this.convertScriptSigAsm(witnessScript);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public convertScriptSigAsm(hex: string): string {
|
||||
const buf = Buffer.from(hex, 'hex');
|
||||
|
||||
const b: string[] = [];
|
||||
|
||||
let i = 0;
|
||||
while (i < buf.length) {
|
||||
const op = buf[i];
|
||||
if (op >= 0x01 && op <= 0x4e) {
|
||||
i++;
|
||||
let push: number;
|
||||
if (op === 0x4c) {
|
||||
push = buf.readUInt8(i);
|
||||
b.push('OP_PUSHDATA1');
|
||||
i += 1;
|
||||
} else if (op === 0x4d) {
|
||||
push = buf.readUInt16LE(i);
|
||||
b.push('OP_PUSHDATA2');
|
||||
i += 2;
|
||||
} else if (op === 0x4e) {
|
||||
push = buf.readUInt32LE(i);
|
||||
b.push('OP_PUSHDATA4');
|
||||
i += 4;
|
||||
} else {
|
||||
push = op;
|
||||
b.push('OP_PUSHBYTES_' + push);
|
||||
}
|
||||
|
||||
const data = buf.slice(i, i + push);
|
||||
if (data.length !== push) {
|
||||
break;
|
||||
}
|
||||
|
||||
b.push(data.toString('hex'));
|
||||
i += data.length;
|
||||
} else {
|
||||
if (op === 0x00) {
|
||||
b.push('OP_0');
|
||||
} else if (op === 0x4f) {
|
||||
b.push('OP_PUSHNUM_NEG1');
|
||||
} else if (op === 0xb1) {
|
||||
b.push('OP_CLTV');
|
||||
} else if (op === 0xb2) {
|
||||
b.push('OP_CSV');
|
||||
} else if (op === 0xba) {
|
||||
b.push('OP_CHECKSIGADD');
|
||||
} else {
|
||||
const opcode = bitcoinjs.script.toASM([ op ]);
|
||||
if (opcode && op < 0xfd) {
|
||||
if (/^OP_(\d+)$/.test(opcode)) {
|
||||
b.push(opcode.replace(/^OP_(\d+)$/, 'OP_PUSHNUM_$1'));
|
||||
} else {
|
||||
b.push(opcode);
|
||||
}
|
||||
} else {
|
||||
b.push('OP_RETURN_' + op);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return b.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* This function must only be called when we know the witness we are parsing
|
||||
* is a taproot witness.
|
||||
* @param witness An array of hex strings that represents the witness stack of
|
||||
* the input.
|
||||
* @returns null if the witness is not a script spend, and the hex string of
|
||||
* the script item if it is a script spend.
|
||||
*/
|
||||
public witnessToP2TRScript(witness: string[]): string | null {
|
||||
if (witness.length < 2) return null;
|
||||
// Note: see BIP341 for parsing details of witness stack
|
||||
|
||||
// If there are at least two witness elements, and the first byte of the
|
||||
// last element is 0x50, this last element is called annex a and
|
||||
// is removed from the witness stack.
|
||||
const hasAnnex = witness[witness.length - 1].substring(0, 2) === '50';
|
||||
// If there are at least two witness elements left, script path spending is used.
|
||||
// Call the second-to-last stack element s, the script.
|
||||
// (Note: this phrasing from BIP341 assumes we've *removed* the annex from the stack)
|
||||
if (hasAnnex && witness.length < 3) return null;
|
||||
const positionOfScript = hasAnnex ? witness.length - 3 : witness.length - 2;
|
||||
return witness[positionOfScript];
|
||||
}
|
||||
}
|
||||
|
||||
export default new TransactionUtils();
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import config from '../config';
|
||||
import logger from '../logger';
|
||||
import { ThreadTransaction, MempoolBlockWithTransactions, AuditTransaction } from '../mempool.interfaces';
|
||||
import { CompactThreadTransaction, AuditTransaction } from '../mempool.interfaces';
|
||||
import { PairingHeap } from '../utils/pairing-heap';
|
||||
import { Common } from './common';
|
||||
import { parentPort } from 'worker_threads';
|
||||
|
||||
let mempool: { [txid: string]: ThreadTransaction } = {};
|
||||
let mempool: Map<number, CompactThreadTransaction> = new Map();
|
||||
|
||||
if (parentPort) {
|
||||
parentPort.on('message', (params) => {
|
||||
@@ -13,18 +12,18 @@ if (parentPort) {
|
||||
mempool = params.mempool;
|
||||
} else if (params.type === 'update') {
|
||||
params.added.forEach(tx => {
|
||||
mempool[tx.txid] = tx;
|
||||
mempool.set(tx.uid, tx);
|
||||
});
|
||||
params.removed.forEach(txid => {
|
||||
delete mempool[txid];
|
||||
params.removed.forEach(uid => {
|
||||
mempool.delete(uid);
|
||||
});
|
||||
}
|
||||
|
||||
const { blocks, clusters } = makeBlockTemplates(mempool);
|
||||
const { blocks, rates, clusters } = makeBlockTemplates(mempool);
|
||||
|
||||
// return the result to main thread.
|
||||
if (parentPort) {
|
||||
parentPort.postMessage({ blocks, clusters });
|
||||
parentPort.postMessage({ blocks, rates, clusters });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -33,35 +32,36 @@ if (parentPort) {
|
||||
* Build projected mempool blocks 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)
|
||||
*/
|
||||
function makeBlockTemplates(mempool: { [txid: string]: ThreadTransaction })
|
||||
: { blocks: ThreadTransaction[][], clusters: { [root: string]: string[] } } {
|
||||
function makeBlockTemplates(mempool: Map<number, CompactThreadTransaction>)
|
||||
: { blocks: number[][], rates: Map<number, number>, clusters: Map<number, number[]> } {
|
||||
const start = Date.now();
|
||||
const auditPool: { [txid: string]: AuditTransaction } = {};
|
||||
const auditPool: Map<number, AuditTransaction> = new Map();
|
||||
const mempoolArray: AuditTransaction[] = [];
|
||||
const restOfArray: ThreadTransaction[] = [];
|
||||
const cpfpClusters: { [root: string]: string[] } = {};
|
||||
const cpfpClusters: Map<number, number[]> = new Map();
|
||||
|
||||
// grab the top feerate txs up to maxWeight
|
||||
Object.values(mempool).sort((a, b) => b.feePerVsize - a.feePerVsize).forEach(tx => {
|
||||
mempool.forEach(tx => {
|
||||
tx.dirty = false;
|
||||
// initializing everything up front helps V8 optimize property access later
|
||||
auditPool[tx.txid] = {
|
||||
txid: tx.txid,
|
||||
auditPool.set(tx.uid, {
|
||||
uid: tx.uid,
|
||||
fee: tx.fee,
|
||||
weight: tx.weight,
|
||||
feePerVsize: tx.feePerVsize,
|
||||
effectiveFeePerVsize: tx.feePerVsize,
|
||||
vin: tx.vin,
|
||||
sigops: tx.sigops,
|
||||
inputs: tx.inputs || [],
|
||||
relativesSet: false,
|
||||
ancestorMap: new Map<string, AuditTransaction>(),
|
||||
ancestorMap: new Map<number, AuditTransaction>(),
|
||||
children: new Set<AuditTransaction>(),
|
||||
ancestorFee: 0,
|
||||
ancestorWeight: 0,
|
||||
ancestorSigops: 0,
|
||||
score: 0,
|
||||
used: false,
|
||||
modified: false,
|
||||
modifiedNode: null,
|
||||
};
|
||||
mempoolArray.push(auditPool[tx.txid]);
|
||||
});
|
||||
mempoolArray.push(auditPool.get(tx.uid) as AuditTransaction);
|
||||
});
|
||||
|
||||
// Build relatives graph & calculate ancestor scores
|
||||
@@ -72,15 +72,29 @@ function makeBlockTemplates(mempool: { [txid: string]: ThreadTransaction })
|
||||
}
|
||||
|
||||
// Sort by descending ancestor score
|
||||
mempoolArray.sort((a, b) => (b.score || 0) - (a.score || 0));
|
||||
mempoolArray.sort((a, b) => {
|
||||
if (b.score === a.score) {
|
||||
// tie-break by uid for stability
|
||||
return a.uid < b.uid ? -1 : 1;
|
||||
} else {
|
||||
return (b.score || 0) - (a.score || 0);
|
||||
}
|
||||
});
|
||||
|
||||
// Build blocks by greedily choosing the highest feerate package
|
||||
// (i.e. the package rooted in the transaction with the best ancestor score)
|
||||
const blocks: ThreadTransaction[][] = [];
|
||||
const blocks: number[][] = [];
|
||||
let blockWeight = 4000;
|
||||
let blockSize = 0;
|
||||
let blockSigops = 0;
|
||||
let transactions: AuditTransaction[] = [];
|
||||
const modified: PairingHeap<AuditTransaction> = new PairingHeap((a, b): boolean => (a.score || 0) > (b.score || 0));
|
||||
const modified: PairingHeap<AuditTransaction> = new PairingHeap((a, b): boolean => {
|
||||
if (a.score === b.score) {
|
||||
// tie-break by uid for stability
|
||||
return a.uid > b.uid;
|
||||
} else {
|
||||
return (a.score || 0) > (b.score || 0);
|
||||
}
|
||||
});
|
||||
let overflow: AuditTransaction[] = [];
|
||||
let failures = 0;
|
||||
let top = 0;
|
||||
@@ -107,30 +121,36 @@ function makeBlockTemplates(mempool: { [txid: string]: ThreadTransaction })
|
||||
|
||||
if (nextTx && !nextTx?.used) {
|
||||
// Check if the package fits into this block
|
||||
if (blockWeight + nextTx.ancestorWeight < config.MEMPOOL.BLOCK_WEIGHT_UNITS) {
|
||||
if (blocks.length >= 7 || ((blockWeight + nextTx.ancestorWeight < config.MEMPOOL.BLOCK_WEIGHT_UNITS) && (blockSigops + nextTx.ancestorSigops <= 80000))) {
|
||||
const ancestors: AuditTransaction[] = 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];
|
||||
let isCluster = false;
|
||||
if (sortedTxSet.length > 1) {
|
||||
cpfpClusters[nextTx.txid] = sortedTxSet.map(tx => tx.txid);
|
||||
cpfpClusters.set(nextTx.uid, sortedTxSet.map(tx => tx.uid));
|
||||
isCluster = true;
|
||||
}
|
||||
const effectiveFeeRate = nextTx.ancestorFee / (nextTx.ancestorWeight / 4);
|
||||
const effectiveFeeRate = Math.min(nextTx.dependencyRate || Infinity, nextTx.ancestorFee / (nextTx.ancestorWeight / 4));
|
||||
const used: AuditTransaction[] = [];
|
||||
while (sortedTxSet.length) {
|
||||
const ancestor = sortedTxSet.pop();
|
||||
const mempoolTx = mempool[ancestor.txid];
|
||||
const mempoolTx = mempool.get(ancestor.uid);
|
||||
if (!mempoolTx) {
|
||||
continue;
|
||||
}
|
||||
ancestor.used = true;
|
||||
ancestor.usedBy = nextTx.txid;
|
||||
ancestor.usedBy = nextTx.uid;
|
||||
// update original copy of this tx with effective fee rate & relatives data
|
||||
mempoolTx.effectiveFeePerVsize = effectiveFeeRate;
|
||||
if (isCluster) {
|
||||
mempoolTx.cpfpRoot = nextTx.txid;
|
||||
if (mempoolTx.effectiveFeePerVsize !== effectiveFeeRate) {
|
||||
mempoolTx.effectiveFeePerVsize = effectiveFeeRate;
|
||||
mempoolTx.dirty = true;
|
||||
}
|
||||
if (mempoolTx.cpfpRoot !== nextTx.uid) {
|
||||
mempoolTx.cpfpRoot = isCluster ? nextTx.uid : null;
|
||||
mempoolTx.dirty;
|
||||
}
|
||||
mempoolTx.cpfpChecked = true;
|
||||
transactions.push(ancestor);
|
||||
blockSize += ancestor.size;
|
||||
blockWeight += ancestor.weight;
|
||||
used.push(ancestor);
|
||||
}
|
||||
@@ -138,7 +158,7 @@ function makeBlockTemplates(mempool: { [txid: string]: ThreadTransaction })
|
||||
// remove these as valid package ancestors for any descendants remaining in the mempool
|
||||
if (used.length) {
|
||||
used.forEach(tx => {
|
||||
updateDescendants(tx, auditPool, modified);
|
||||
updateDescendants(tx, auditPool, modified, effectiveFeeRate);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -153,14 +173,16 @@ function makeBlockTemplates(mempool: { [txid: string]: ThreadTransaction })
|
||||
// this block is full
|
||||
const exceededPackageTries = failures > 1000 && blockWeight > (config.MEMPOOL.BLOCK_WEIGHT_UNITS - 4000);
|
||||
const queueEmpty = top >= mempoolArray.length && modified.isEmpty();
|
||||
|
||||
if ((exceededPackageTries || queueEmpty) && blocks.length < 7) {
|
||||
// construct this block
|
||||
if (transactions.length) {
|
||||
blocks.push(transactions.map(t => mempool[t.txid]));
|
||||
blocks.push(transactions.map(t => t.uid));
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
// reset for the next block
|
||||
transactions = [];
|
||||
blockSize = 0;
|
||||
blockWeight = 4000;
|
||||
|
||||
// 'overflow' packages didn't fit in this block, but are valid candidates for the next
|
||||
@@ -175,50 +197,38 @@ function makeBlockTemplates(mempool: { [txid: string]: ThreadTransaction })
|
||||
overflow = [];
|
||||
}
|
||||
}
|
||||
// pack any leftover transactions into the last block
|
||||
for (const tx of overflow) {
|
||||
if (!tx || tx?.used) {
|
||||
continue;
|
||||
}
|
||||
blockWeight += tx.weight;
|
||||
const mempoolTx = mempool[tx.txid];
|
||||
// update original copy of this tx with effective fee rate & relatives data
|
||||
mempoolTx.effectiveFeePerVsize = tx.score;
|
||||
if (tx.ancestorMap.size > 0) {
|
||||
cpfpClusters[tx.txid] = Array.from(tx.ancestorMap?.values()).map(a => a.txid);
|
||||
mempoolTx.cpfpRoot = tx.txid;
|
||||
}
|
||||
mempoolTx.cpfpChecked = true;
|
||||
transactions.push(tx);
|
||||
tx.used = true;
|
||||
|
||||
if (overflow.length > 0) {
|
||||
logger.warn('GBT overflow list unexpectedly non-empty after final block constructed');
|
||||
}
|
||||
const blockTransactions = transactions.map(t => mempool[t.txid]);
|
||||
restOfArray.forEach(tx => {
|
||||
blockWeight += tx.weight;
|
||||
tx.effectiveFeePerVsize = tx.feePerVsize;
|
||||
tx.cpfpChecked = false;
|
||||
blockTransactions.push(tx);
|
||||
});
|
||||
if (blockTransactions.length) {
|
||||
blocks.push(blockTransactions);
|
||||
// add the final unbounded block if it contains any transactions
|
||||
if (transactions.length > 0) {
|
||||
blocks.push(transactions.map(t => t.uid));
|
||||
}
|
||||
|
||||
// get map of dirty transactions
|
||||
const rates = new Map<number, number>();
|
||||
for (const tx of mempool.values()) {
|
||||
if (tx?.dirty) {
|
||||
rates.set(tx.uid, tx.effectiveFeePerVsize || tx.feePerVsize);
|
||||
}
|
||||
}
|
||||
transactions = [];
|
||||
|
||||
const end = Date.now();
|
||||
const time = end - start;
|
||||
logger.debug('Mempool templates calculated in ' + time / 1000 + ' seconds');
|
||||
|
||||
return { blocks, clusters: cpfpClusters };
|
||||
return { blocks, rates, clusters: cpfpClusters };
|
||||
}
|
||||
|
||||
// traverse in-mempool ancestors
|
||||
// recursion unavoidable, but should be limited to depth < 25 by mempool policy
|
||||
function setRelatives(
|
||||
tx: AuditTransaction,
|
||||
mempool: { [txid: string]: AuditTransaction },
|
||||
mempool: Map<number, AuditTransaction>,
|
||||
): void {
|
||||
for (const parent of tx.vin) {
|
||||
const parentTx = mempool[parent];
|
||||
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);
|
||||
@@ -227,15 +237,17 @@ function setRelatives(
|
||||
setRelatives(parentTx, mempool);
|
||||
}
|
||||
parentTx.ancestorMap.forEach((ancestor) => {
|
||||
tx.ancestorMap.set(ancestor.txid, ancestor);
|
||||
tx.ancestorMap.set(ancestor.uid, ancestor);
|
||||
});
|
||||
}
|
||||
};
|
||||
tx.ancestorFee = tx.fee || 0;
|
||||
tx.ancestorWeight = tx.weight || 0;
|
||||
tx.ancestorSigops = tx.sigops || 0;
|
||||
tx.ancestorMap.forEach((ancestor) => {
|
||||
tx.ancestorFee += ancestor.fee;
|
||||
tx.ancestorWeight += ancestor.weight;
|
||||
tx.ancestorSigops += ancestor.sigops;
|
||||
});
|
||||
tx.score = tx.ancestorFee / ((tx.ancestorWeight / 4) || 1);
|
||||
tx.relativesSet = true;
|
||||
@@ -245,8 +257,9 @@ function setRelatives(
|
||||
// avoids recursion to limit call stack depth
|
||||
function updateDescendants(
|
||||
rootTx: AuditTransaction,
|
||||
mempool: { [txid: string]: AuditTransaction },
|
||||
mempool: Map<number, AuditTransaction>,
|
||||
modified: PairingHeap<AuditTransaction>,
|
||||
clusterRate: number,
|
||||
): void {
|
||||
const descendantSet: Set<AuditTransaction> = new Set();
|
||||
// stack of nodes left to visit
|
||||
@@ -261,13 +274,15 @@ function updateDescendants(
|
||||
});
|
||||
while (descendants.length) {
|
||||
descendantTx = descendants.pop();
|
||||
if (descendantTx && descendantTx.ancestorMap && descendantTx.ancestorMap.has(rootTx.txid)) {
|
||||
if (descendantTx && descendantTx.ancestorMap && descendantTx.ancestorMap.has(rootTx.uid)) {
|
||||
// remove tx as ancestor
|
||||
descendantTx.ancestorMap.delete(rootTx.txid);
|
||||
descendantTx.ancestorMap.delete(rootTx.uid);
|
||||
descendantTx.ancestorFee -= rootTx.fee;
|
||||
descendantTx.ancestorWeight -= rootTx.weight;
|
||||
descendantTx.ancestorSigops -= rootTx.sigops;
|
||||
tmpScore = descendantTx.score;
|
||||
descendantTx.score = descendantTx.ancestorFee / (descendantTx.ancestorWeight / 4);
|
||||
descendantTx.dependencyRate = descendantTx.dependencyRate ? Math.min(descendantTx.dependencyRate, clusterRate) : clusterRate;
|
||||
|
||||
if (!descendantTx.modifiedNode) {
|
||||
descendantTx.modified = true;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ interface IConfig {
|
||||
API_URL_PREFIX: string;
|
||||
POLL_RATE_MS: number;
|
||||
CACHE_DIR: string;
|
||||
CACHE_ENABLED: boolean;
|
||||
CLEAR_PROTECTION_MINUTES: number;
|
||||
RECOMMENDED_FEE_PERCENTILE: number;
|
||||
BLOCK_WEIGHT_UNITS: number;
|
||||
@@ -19,6 +20,7 @@ interface IConfig {
|
||||
MEMPOOL_BLOCKS_AMOUNT: number;
|
||||
INDEXING_BLOCKS_AMOUNT: number;
|
||||
BLOCKS_SUMMARIES_INDEXING: boolean;
|
||||
GOGGLES_INDEXING: boolean;
|
||||
USE_SECOND_NODE_FOR_MINFEE: boolean;
|
||||
EXTERNAL_ASSETS: string[];
|
||||
EXTERNAL_MAX_RETRY: number;
|
||||
@@ -31,11 +33,23 @@ interface IConfig {
|
||||
AUDIT: boolean;
|
||||
ADVANCED_GBT_AUDIT: boolean;
|
||||
ADVANCED_GBT_MEMPOOL: boolean;
|
||||
RUST_GBT: boolean;
|
||||
CPFP_INDEXING: boolean;
|
||||
MAX_BLOCKS_BULK_QUERY: number;
|
||||
DISK_CACHE_BLOCK_INTERVAL: number;
|
||||
MAX_PUSH_TX_SIZE_WEIGHT: number;
|
||||
ALLOW_UNREACHABLE: boolean;
|
||||
PRICE_UPDATES_PER_HOUR: number;
|
||||
MAX_TRACKED_ADDRESSES: number;
|
||||
};
|
||||
ESPLORA: {
|
||||
REST_API_URL: string;
|
||||
UNIX_SOCKET_PATH: string | void | null;
|
||||
BATCH_QUERY_BASE_SIZE: number;
|
||||
RETRY_UNIX_SOCKET_AFTER: number;
|
||||
REQUEST_TIMEOUT: number;
|
||||
FALLBACK_TIMEOUT: number;
|
||||
FALLBACK: string[];
|
||||
};
|
||||
LIGHTNING: {
|
||||
ENABLED: boolean;
|
||||
@@ -51,6 +65,7 @@ interface IConfig {
|
||||
TLS_CERT_PATH: string;
|
||||
MACAROON_PATH: string;
|
||||
REST_API_URL: string;
|
||||
TIMEOUT: number;
|
||||
};
|
||||
CLIGHTNING: {
|
||||
SOCKET: string;
|
||||
@@ -65,12 +80,18 @@ interface IConfig {
|
||||
PORT: number;
|
||||
USERNAME: string;
|
||||
PASSWORD: string;
|
||||
TIMEOUT: number;
|
||||
COOKIE: boolean;
|
||||
COOKIE_PATH: string;
|
||||
};
|
||||
SECOND_CORE_RPC: {
|
||||
HOST: string;
|
||||
PORT: number;
|
||||
USERNAME: string;
|
||||
PASSWORD: string;
|
||||
TIMEOUT: number;
|
||||
COOKIE: boolean;
|
||||
COOKIE_PATH: string;
|
||||
};
|
||||
DATABASE: {
|
||||
ENABLED: boolean;
|
||||
@@ -80,6 +101,8 @@ interface IConfig {
|
||||
DATABASE: string;
|
||||
USERNAME: string;
|
||||
PASSWORD: string;
|
||||
TIMEOUT: number;
|
||||
PID_DIR: string;
|
||||
};
|
||||
SYSLOG: {
|
||||
ENABLED: boolean;
|
||||
@@ -104,10 +127,6 @@ interface IConfig {
|
||||
USERNAME: string;
|
||||
PASSWORD: string;
|
||||
};
|
||||
PRICE_DATA_SERVER: {
|
||||
TOR_URL: string;
|
||||
CLEARNET_URL: string;
|
||||
};
|
||||
EXTERNAL_DATA_SERVER: {
|
||||
MEMPOOL_API: string;
|
||||
MEMPOOL_ONION: string;
|
||||
@@ -122,6 +141,21 @@ interface IConfig {
|
||||
GEOLITE2_ASN: string;
|
||||
GEOIP2_ISP: string;
|
||||
},
|
||||
REPLICATION: {
|
||||
ENABLED: boolean;
|
||||
AUDIT: boolean;
|
||||
AUDIT_START_HEIGHT: number;
|
||||
SERVERS: string[];
|
||||
},
|
||||
MEMPOOL_SERVICES: {
|
||||
API: string;
|
||||
ACCELERATIONS: boolean;
|
||||
},
|
||||
REDIS: {
|
||||
ENABLED: boolean;
|
||||
UNIX_SOCKET_PATH: string;
|
||||
BATCH_QUERY_BASE_SIZE: number;
|
||||
},
|
||||
}
|
||||
|
||||
const defaults: IConfig = {
|
||||
@@ -134,6 +168,7 @@ const defaults: IConfig = {
|
||||
'API_URL_PREFIX': '/api/v1/',
|
||||
'POLL_RATE_MS': 2000,
|
||||
'CACHE_DIR': './cache',
|
||||
'CACHE_ENABLED': true,
|
||||
'CLEAR_PROTECTION_MINUTES': 20,
|
||||
'RECOMMENDED_FEE_PERCENTILE': 50,
|
||||
'BLOCK_WEIGHT_UNITS': 4000000,
|
||||
@@ -141,6 +176,7 @@ const defaults: IConfig = {
|
||||
'MEMPOOL_BLOCKS_AMOUNT': 8,
|
||||
'INDEXING_BLOCKS_AMOUNT': 11000, // 0 = disable indexing, -1 = index all blocks
|
||||
'BLOCKS_SUMMARIES_INDEXING': false,
|
||||
'GOGGLES_INDEXING': false,
|
||||
'USE_SECOND_NODE_FOR_MINFEE': false,
|
||||
'EXTERNAL_ASSETS': [],
|
||||
'EXTERNAL_MAX_RETRY': 1,
|
||||
@@ -153,11 +189,23 @@ const defaults: IConfig = {
|
||||
'AUDIT': false,
|
||||
'ADVANCED_GBT_AUDIT': false,
|
||||
'ADVANCED_GBT_MEMPOOL': false,
|
||||
'RUST_GBT': false,
|
||||
'CPFP_INDEXING': false,
|
||||
'MAX_BLOCKS_BULK_QUERY': 0,
|
||||
'DISK_CACHE_BLOCK_INTERVAL': 6,
|
||||
'MAX_PUSH_TX_SIZE_WEIGHT': 400000,
|
||||
'ALLOW_UNREACHABLE': true,
|
||||
'PRICE_UPDATES_PER_HOUR': 1,
|
||||
'MAX_TRACKED_ADDRESSES': 1,
|
||||
},
|
||||
'ESPLORA': {
|
||||
'REST_API_URL': 'http://127.0.0.1:3000',
|
||||
'UNIX_SOCKET_PATH': null,
|
||||
'BATCH_QUERY_BASE_SIZE': 1000,
|
||||
'RETRY_UNIX_SOCKET_AFTER': 30000,
|
||||
'REQUEST_TIMEOUT': 10000,
|
||||
'FALLBACK_TIMEOUT': 5000,
|
||||
'FALLBACK': [],
|
||||
},
|
||||
'ELECTRUM': {
|
||||
'HOST': '127.0.0.1',
|
||||
@@ -168,13 +216,19 @@ const defaults: IConfig = {
|
||||
'HOST': '127.0.0.1',
|
||||
'PORT': 8332,
|
||||
'USERNAME': 'mempool',
|
||||
'PASSWORD': 'mempool'
|
||||
'PASSWORD': 'mempool',
|
||||
'TIMEOUT': 60000,
|
||||
'COOKIE': false,
|
||||
'COOKIE_PATH': '/bitcoin/.cookie'
|
||||
},
|
||||
'SECOND_CORE_RPC': {
|
||||
'HOST': '127.0.0.1',
|
||||
'PORT': 8332,
|
||||
'USERNAME': 'mempool',
|
||||
'PASSWORD': 'mempool'
|
||||
'PASSWORD': 'mempool',
|
||||
'TIMEOUT': 60000,
|
||||
'COOKIE': false,
|
||||
'COOKIE_PATH': '/bitcoin/.cookie'
|
||||
},
|
||||
'DATABASE': {
|
||||
'ENABLED': true,
|
||||
@@ -183,7 +237,9 @@ const defaults: IConfig = {
|
||||
'PORT': 3306,
|
||||
'DATABASE': 'mempool',
|
||||
'USERNAME': 'mempool',
|
||||
'PASSWORD': 'mempool'
|
||||
'PASSWORD': 'mempool',
|
||||
'TIMEOUT': 180000,
|
||||
'PID_DIR': '',
|
||||
},
|
||||
'SYSLOG': {
|
||||
'ENABLED': true,
|
||||
@@ -214,6 +270,7 @@ const defaults: IConfig = {
|
||||
'TLS_CERT_PATH': '',
|
||||
'MACAROON_PATH': '',
|
||||
'REST_API_URL': 'https://localhost:8080',
|
||||
'TIMEOUT': 10000,
|
||||
},
|
||||
'CLIGHTNING': {
|
||||
'SOCKET': '',
|
||||
@@ -226,10 +283,6 @@ const defaults: IConfig = {
|
||||
'USERNAME': '',
|
||||
'PASSWORD': ''
|
||||
},
|
||||
'PRICE_DATA_SERVER': {
|
||||
'TOR_URL': 'http://wizpriceje6q5tdrxkyiazsgu7irquiqjy2dptezqhrtu7l2qelqktid.onion/getAllMarketPrices',
|
||||
'CLEARNET_URL': 'https://price.bisq.wiz.biz/getAllMarketPrices'
|
||||
},
|
||||
'EXTERNAL_DATA_SERVER': {
|
||||
'MEMPOOL_API': 'https://mempool.space/api/v1',
|
||||
'MEMPOOL_ONION': 'http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api/v1',
|
||||
@@ -244,6 +297,21 @@ const defaults: IConfig = {
|
||||
'GEOLITE2_ASN': '/usr/local/share/GeoIP/GeoLite2-ASN.mmdb',
|
||||
'GEOIP2_ISP': '/usr/local/share/GeoIP/GeoIP2-ISP.mmdb'
|
||||
},
|
||||
'REPLICATION': {
|
||||
'ENABLED': false,
|
||||
'AUDIT': false,
|
||||
'AUDIT_START_HEIGHT': 774000,
|
||||
'SERVERS': [],
|
||||
},
|
||||
'MEMPOOL_SERVICES': {
|
||||
'API': '',
|
||||
'ACCELERATIONS': false,
|
||||
},
|
||||
'REDIS': {
|
||||
'ENABLED': false,
|
||||
'UNIX_SOCKET_PATH': '',
|
||||
'BATCH_QUERY_BASE_SIZE': 5000,
|
||||
},
|
||||
};
|
||||
|
||||
class Config implements IConfig {
|
||||
@@ -260,9 +328,11 @@ class Config implements IConfig {
|
||||
LND: IConfig['LND'];
|
||||
CLIGHTNING: IConfig['CLIGHTNING'];
|
||||
SOCKS5PROXY: IConfig['SOCKS5PROXY'];
|
||||
PRICE_DATA_SERVER: IConfig['PRICE_DATA_SERVER'];
|
||||
EXTERNAL_DATA_SERVER: IConfig['EXTERNAL_DATA_SERVER'];
|
||||
MAXMIND: IConfig['MAXMIND'];
|
||||
REPLICATION: IConfig['REPLICATION'];
|
||||
MEMPOOL_SERVICES: IConfig['MEMPOOL_SERVICES'];
|
||||
REDIS: IConfig['REDIS'];
|
||||
|
||||
constructor() {
|
||||
const configs = this.merge(configFromFile, defaults);
|
||||
@@ -279,9 +349,11 @@ class Config implements IConfig {
|
||||
this.LND = configs.LND;
|
||||
this.CLIGHTNING = configs.CLIGHTNING;
|
||||
this.SOCKS5PROXY = configs.SOCKS5PROXY;
|
||||
this.PRICE_DATA_SERVER = configs.PRICE_DATA_SERVER;
|
||||
this.EXTERNAL_DATA_SERVER = configs.EXTERNAL_DATA_SERVER;
|
||||
this.MAXMIND = configs.MAXMIND;
|
||||
this.REPLICATION = configs.REPLICATION;
|
||||
this.MEMPOOL_SERVICES = configs.MEMPOOL_SERVICES;
|
||||
this.REDIS = configs.REDIS;
|
||||
}
|
||||
|
||||
merge = (...objects: object[]): IConfig => {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import * as fs from 'fs';
|
||||
import path from 'path';
|
||||
import config from './config';
|
||||
import { createPool, Pool, PoolConnection } from 'mysql2/promise';
|
||||
import { LogLevel } from './logger';
|
||||
import logger from './logger';
|
||||
import { FieldPacket, OkPacket, PoolOptions, ResultSetHeader, RowDataPacket } from 'mysql2/typings/mysql';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
class DB {
|
||||
constructor() {
|
||||
@@ -30,11 +34,82 @@ import { FieldPacket, OkPacket, PoolOptions, ResultSetHeader, RowDataPacket } fr
|
||||
}
|
||||
|
||||
public async query<T extends RowDataPacket[][] | RowDataPacket[] | OkPacket |
|
||||
OkPacket[] | ResultSetHeader>(query, params?): Promise<[T, FieldPacket[]]>
|
||||
OkPacket[] | ResultSetHeader>(query, params?, errorLogLevel: LogLevel | 'silent' = 'debug', connection?: PoolConnection): Promise<[T, FieldPacket[]]>
|
||||
{
|
||||
this.checkDBFlag();
|
||||
let hardTimeout;
|
||||
if (query?.timeout != null) {
|
||||
hardTimeout = Math.floor(query.timeout * 1.1);
|
||||
} else {
|
||||
hardTimeout = config.DATABASE.TIMEOUT;
|
||||
}
|
||||
if (hardTimeout > 0) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error(`DB query failed to return, reject or time out within ${hardTimeout / 1000}s - ${query?.sql?.slice(0, 160) || (typeof(query) === 'string' || query instanceof String ? query?.slice(0, 160) : 'unknown query')}`));
|
||||
}, hardTimeout);
|
||||
|
||||
// Use a specific connection if provided, otherwise delegate to the pool
|
||||
const connectionPromise = connection ? Promise.resolve(connection) : this.getPool();
|
||||
connectionPromise.then((pool: PoolConnection | Pool) => {
|
||||
return pool.query(query, params) as Promise<[T, FieldPacket[]]>;
|
||||
}).then(result => {
|
||||
resolve(result);
|
||||
}).catch(error => {
|
||||
if (errorLogLevel !== 'silent') {
|
||||
logger[errorLogLevel](`database query "${query?.sql?.slice(0, 160) || (typeof(query) === 'string' || query instanceof String ? query?.slice(0, 160) : 'unknown query')}" failed!`);
|
||||
}
|
||||
reject(error);
|
||||
}).finally(() => {
|
||||
clearTimeout(timer);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
const pool = await this.getPool();
|
||||
return pool.query(query, params);
|
||||
} catch (e) {
|
||||
if (errorLogLevel !== 'silent') {
|
||||
logger[errorLogLevel](`database query "${query?.sql?.slice(0, 160) || (typeof(query) === 'string' || query instanceof String ? query?.slice(0, 160) : 'unknown query')}" failed!`);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async $rollbackAtomic(connection: PoolConnection): Promise<void> {
|
||||
try {
|
||||
await connection.rollback();
|
||||
await connection.release();
|
||||
} catch (e) {
|
||||
logger.warn('Failed to rollback incomplete db transaction: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
public async $atomicQuery<T extends RowDataPacket[][] | RowDataPacket[] | OkPacket |
|
||||
OkPacket[] | ResultSetHeader>(queries: { query, params }[], errorLogLevel: LogLevel | 'silent' = 'debug'): Promise<[T, FieldPacket[]][]>
|
||||
{
|
||||
const pool = await this.getPool();
|
||||
return pool.query(query, params);
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
|
||||
const results: [T, FieldPacket[]][] = [];
|
||||
for (const query of queries) {
|
||||
const result = await this.query(query.query, query.params, errorLogLevel, connection) as [T, FieldPacket[]];
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
await connection.commit();
|
||||
|
||||
return results;
|
||||
} catch (e) {
|
||||
logger.warn('Could not complete db transaction, rolling back: ' + (e instanceof Error ? e.message : e));
|
||||
this.$rollbackAtomic(connection);
|
||||
throw e;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
public async checkDbConnection() {
|
||||
@@ -48,6 +123,50 @@ import { FieldPacket, OkPacket, PoolOptions, ResultSetHeader, RowDataPacket } fr
|
||||
}
|
||||
}
|
||||
|
||||
public getPidLock(): boolean {
|
||||
const filePath = path.join(config.DATABASE.PID_DIR || __dirname, `/mempool-${config.DATABASE.DATABASE}.pid`);
|
||||
this.enforcePidLock(filePath);
|
||||
fs.writeFileSync(filePath, `${process.pid}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
private enforcePidLock(filePath: string): void {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const pid = parseInt(fs.readFileSync(filePath, 'utf-8'));
|
||||
if (pid === process.pid) {
|
||||
logger.warn('PID file already exists for this process');
|
||||
return;
|
||||
}
|
||||
|
||||
let cmd;
|
||||
try {
|
||||
cmd = execSync(`ps -p ${pid} -o args=`);
|
||||
} catch (e) {
|
||||
logger.warn(`Stale PID file at ${filePath}, but no process running on that PID ${pid}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd && cmd.toString()?.includes('node')) {
|
||||
const msg = `Another mempool nodejs process is already running on PID ${pid}`;
|
||||
logger.err(msg);
|
||||
throw new Error(msg);
|
||||
} else {
|
||||
logger.warn(`Stale PID file at ${filePath}, but the PID ${pid} does not belong to a running mempool instance`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public releasePidLock(): void {
|
||||
const filePath = path.join(config.DATABASE.PID_DIR || __dirname, `/mempool-${config.DATABASE.DATABASE}.pid`);
|
||||
if (fs.existsSync(filePath)) {
|
||||
const pid = parseInt(fs.readFileSync(filePath, 'utf-8'));
|
||||
// only release our own pid file
|
||||
if (pid === process.pid) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getPool(): Promise<Pool> {
|
||||
if (this.pool === null) {
|
||||
this.pool = createPool(this.poolConfig);
|
||||
|
||||
@@ -2,6 +2,7 @@ import express from 'express';
|
||||
import { Application, Request, Response, NextFunction } from 'express';
|
||||
import * as http from 'http';
|
||||
import * as WebSocket from 'ws';
|
||||
import bitcoinApi from './api/bitcoin/bitcoin-api-factory';
|
||||
import cluster from 'cluster';
|
||||
import DB from './database';
|
||||
import config from './config';
|
||||
@@ -29,6 +30,7 @@ import generalLightningRoutes from './api/explorer/general.routes';
|
||||
import lightningStatsUpdater from './tasks/lightning/stats-updater.service';
|
||||
import networkSyncService from './tasks/lightning/network-sync.service';
|
||||
import statisticsRoutes from './api/statistics/statistics.routes';
|
||||
import pricesRoutes from './api/prices/prices.routes';
|
||||
import miningRoutes from './api/mining/mining-routes';
|
||||
import bisqRoutes from './api/bisq/bisq.routes';
|
||||
import liquidRoutes from './api/liquid/liquid.routes';
|
||||
@@ -40,12 +42,16 @@ import chainTips from './api/chain-tips';
|
||||
import { AxiosError } from 'axios';
|
||||
import v8 from 'v8';
|
||||
import { formatBytes, getBytesUnit } from './utils/format';
|
||||
import redisCache from './api/redis-cache';
|
||||
import accelerationApi from './api/services/acceleration';
|
||||
import bitcoinCoreRoutes from './api/bitcoin/bitcoin-core.routes';
|
||||
|
||||
class Server {
|
||||
private wss: WebSocket.Server | undefined;
|
||||
private server: http.Server | undefined;
|
||||
private app: Application;
|
||||
private currentBackendRetryInterval = 5;
|
||||
private currentBackendRetryInterval = 1;
|
||||
private backendRetryCount = 0;
|
||||
|
||||
private maxHeapSize: number = 0;
|
||||
private heapLogInterval: number = 60;
|
||||
@@ -87,7 +93,24 @@ class Server {
|
||||
async startServer(worker = false): Promise<void> {
|
||||
logger.notice(`Starting Mempool Server${worker ? ' (worker)' : ''}... (${backendInfo.getShortCommitHash()})`);
|
||||
|
||||
// Register cleanup listeners for exit events
|
||||
['exit', 'SIGHUP', 'SIGINT', 'SIGTERM', 'SIGUSR1', 'SIGUSR2'].forEach(event => {
|
||||
process.on(event, () => { this.onExit(event); });
|
||||
});
|
||||
process.on('uncaughtException', (error) => {
|
||||
this.onUnhandledException('uncaughtException', error);
|
||||
});
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
this.onUnhandledException('unhandledRejection', reason);
|
||||
});
|
||||
|
||||
if (config.MEMPOOL.BACKEND === 'esplora') {
|
||||
bitcoinApi.startHealthChecks();
|
||||
}
|
||||
|
||||
if (config.DATABASE.ENABLED) {
|
||||
DB.getPidLock();
|
||||
|
||||
await DB.checkDbConnection();
|
||||
try {
|
||||
if (process.env.npm_config_reindex_blocks === 'true') { // Re-index requests
|
||||
@@ -120,7 +143,11 @@ class Server {
|
||||
await poolsUpdater.updatePoolsJson(); // Needs to be done before loading the disk cache because we sometimes wipe it
|
||||
await syncAssets.syncAssets$();
|
||||
if (config.MEMPOOL.ENABLED) {
|
||||
diskCache.loadMempoolCache();
|
||||
if (config.MEMPOOL.CACHE_ENABLED) {
|
||||
await diskCache.$loadMempoolCache();
|
||||
} else if (config.REDIS.ENABLED) {
|
||||
await redisCache.$loadCache();
|
||||
}
|
||||
}
|
||||
|
||||
if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED && cluster.isPrimary) {
|
||||
@@ -148,7 +175,7 @@ class Server {
|
||||
|
||||
if (config.BISQ.ENABLED) {
|
||||
bisq.startBisqService();
|
||||
bisq.setPriceCallbackFunction((price) => websocketHandler.setExtraInitProperties('bsq-price', price));
|
||||
bisq.setPriceCallbackFunction((price) => websocketHandler.setExtraInitData('bsq-price', price));
|
||||
blocks.setNewBlockCallback(bisq.handleNewBitcoinBlock.bind(bisq));
|
||||
bisqMarkets.startBisqService();
|
||||
}
|
||||
@@ -167,6 +194,7 @@ class Server {
|
||||
}
|
||||
|
||||
async runMainUpdateLoop(): Promise<void> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
try {
|
||||
await memPool.$updateMemPoolInfo();
|
||||
@@ -178,22 +206,31 @@ class Server {
|
||||
logger.debug(msg);
|
||||
}
|
||||
}
|
||||
await blocks.$updateBlocks();
|
||||
await memPool.$updateMempool();
|
||||
const newMempool = await bitcoinApi.$getRawMempool();
|
||||
const newAccelerations = await accelerationApi.$fetchAccelerations();
|
||||
const numHandledBlocks = await blocks.$updateBlocks();
|
||||
const pollRate = config.MEMPOOL.POLL_RATE_MS * (indexer.indexerIsRunning() ? 10 : 1);
|
||||
if (numHandledBlocks === 0) {
|
||||
await memPool.$updateMempool(newMempool, newAccelerations, pollRate);
|
||||
}
|
||||
indexer.$run();
|
||||
priceUpdater.$run();
|
||||
|
||||
setTimeout(this.runMainUpdateLoop.bind(this), config.MEMPOOL.POLL_RATE_MS);
|
||||
this.currentBackendRetryInterval = 5;
|
||||
// rerun immediately if we skipped the mempool update, otherwise wait POLL_RATE_MS
|
||||
const elapsed = Date.now() - start;
|
||||
const remainingTime = Math.max(0, pollRate - elapsed);
|
||||
setTimeout(this.runMainUpdateLoop.bind(this), numHandledBlocks > 0 ? 0 : remainingTime);
|
||||
this.backendRetryCount = 0;
|
||||
} catch (e: any) {
|
||||
let loggerMsg = `Exception in runMainUpdateLoop(). Retrying in ${this.currentBackendRetryInterval} sec.`;
|
||||
this.backendRetryCount++;
|
||||
let loggerMsg = `Exception in runMainUpdateLoop() (count: ${this.backendRetryCount}). Retrying in ${this.currentBackendRetryInterval} sec.`;
|
||||
loggerMsg += ` Reason: ${(e instanceof Error ? e.message : e)}.`;
|
||||
if (e?.stack) {
|
||||
loggerMsg += ` Stack trace: ${e.stack}`;
|
||||
}
|
||||
// When we get a first Exception, only `logger.debug` it and retry after 5 seconds
|
||||
// From the second Exception, `logger.warn` the Exception and increase the retry delay
|
||||
// Maximum retry delay is 60 seconds
|
||||
if (this.currentBackendRetryInterval > 5) {
|
||||
if (this.backendRetryCount >= 5) {
|
||||
logger.warn(loggerMsg);
|
||||
mempool.setOutOfSync();
|
||||
} else {
|
||||
@@ -203,8 +240,8 @@ class Server {
|
||||
logger.debug(`AxiosError: ${e?.message}`);
|
||||
}
|
||||
setTimeout(this.runMainUpdateLoop.bind(this), 1000 * this.currentBackendRetryInterval);
|
||||
this.currentBackendRetryInterval *= 2;
|
||||
this.currentBackendRetryInterval = Math.min(this.currentBackendRetryInterval, 60);
|
||||
} finally {
|
||||
diskCache.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,11 +252,11 @@ class Server {
|
||||
await lightningStatsUpdater.$startService();
|
||||
await forensicsService.$startService();
|
||||
} catch(e) {
|
||||
logger.err(`Nodejs lightning backend crashed. Restarting in 1 minute. Reason: ${(e instanceof Error ? e.message : e)}`);
|
||||
logger.err(`Exception in $runLightningBackend. Restarting in 1 minute. Reason: ${(e instanceof Error ? e.message : e)}`);
|
||||
await Common.sleep$(1000 * 60);
|
||||
this.$runLightningBackend();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
setUpWebsocketHandling(): void {
|
||||
if (this.wss) {
|
||||
@@ -229,6 +266,7 @@ class Server {
|
||||
blocks.setNewBlockCallback(async () => {
|
||||
try {
|
||||
await elementsParser.$parse();
|
||||
await elementsParser.$updateFederationUtxos();
|
||||
} catch (e) {
|
||||
logger.warn('Elements parsing error: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
@@ -237,7 +275,7 @@ class Server {
|
||||
websocketHandler.setupConnectionHandling();
|
||||
if (config.MEMPOOL.ENABLED) {
|
||||
statistics.setNewStatisticsEntryCallback(websocketHandler.handleNewStatistic.bind(websocketHandler));
|
||||
memPool.setAsyncMempoolChangedCallback(websocketHandler.handleMempoolChange.bind(websocketHandler));
|
||||
memPool.setAsyncMempoolChangedCallback(websocketHandler.$handleMempoolChange.bind(websocketHandler));
|
||||
blocks.setNewAsyncBlockCallback(websocketHandler.handleNewBlock.bind(websocketHandler));
|
||||
}
|
||||
priceUpdater.setRatesChangedCallback(websocketHandler.handleNewConversionRates.bind(websocketHandler));
|
||||
@@ -246,6 +284,8 @@ class Server {
|
||||
|
||||
setUpHttpApiRoutes(): void {
|
||||
bitcoinRoutes.initRoutes(this.app);
|
||||
bitcoinCoreRoutes.initRoutes(this.app);
|
||||
pricesRoutes.initRoutes(this.app);
|
||||
if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED && config.MEMPOOL.ENABLED) {
|
||||
statisticsRoutes.initRoutes(this.app);
|
||||
}
|
||||
@@ -275,7 +315,7 @@ class Server {
|
||||
|
||||
if (!this.warnedHeapCritical && this.maxHeapSize > warnThreshold) {
|
||||
this.warnedHeapCritical = true;
|
||||
logger.warn(`Used ${(this.maxHeapSize / stats.heap_size_limit).toFixed(2)}% of heap limit (${formatBytes(this.maxHeapSize, byteUnits, true)} / ${formatBytes(stats.heap_size_limit, byteUnits)})!`);
|
||||
logger.warn(`Used ${(this.maxHeapSize / stats.heap_size_limit * 100).toFixed(2)}% of heap limit (${formatBytes(this.maxHeapSize, byteUnits, true)} / ${formatBytes(stats.heap_size_limit, byteUnits)})!`);
|
||||
}
|
||||
if (this.lastHeapLogTime === null || (now - this.lastHeapLogTime) > (this.heapLogInterval * 1000)) {
|
||||
logger.debug(`Memory usage: ${formatBytes(this.maxHeapSize, byteUnits)} / ${formatBytes(stats.heap_size_limit, byteUnits)}`);
|
||||
@@ -284,6 +324,19 @@ class Server {
|
||||
this.lastHeapLogTime = now;
|
||||
}
|
||||
}
|
||||
|
||||
onExit(exitEvent, code = 0): void {
|
||||
logger.debug(`onExit for signal: ${exitEvent}`);
|
||||
if (config.DATABASE.ENABLED) {
|
||||
DB.releasePidLock();
|
||||
}
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
onUnhandledException(type, error): void {
|
||||
console.error(`${type}:`, error);
|
||||
this.onExit(type, 1);
|
||||
}
|
||||
}
|
||||
|
||||
((): Server => new Server())();
|
||||
|
||||
@@ -6,6 +6,8 @@ import logger from './logger';
|
||||
import bitcoinClient from './api/bitcoin/bitcoin-client';
|
||||
import priceUpdater from './tasks/price-updater';
|
||||
import PricesRepository from './repositories/PricesRepository';
|
||||
import config from './config';
|
||||
import auditReplicator from './replication/AuditReplication';
|
||||
|
||||
export interface CoreIndex {
|
||||
name: string;
|
||||
@@ -13,11 +15,18 @@ export interface CoreIndex {
|
||||
best_block_height: number;
|
||||
}
|
||||
|
||||
type TaskName = 'blocksPrices' | 'coinStatsIndex';
|
||||
|
||||
class Indexer {
|
||||
runIndexer = true;
|
||||
indexerRunning = false;
|
||||
tasksRunning: string[] = [];
|
||||
coreIndexes: CoreIndex[] = [];
|
||||
private runIndexer = true;
|
||||
private indexerRunning = false;
|
||||
private tasksRunning: { [key in TaskName]?: boolean; } = {};
|
||||
private tasksScheduled: { [key in TaskName]?: NodeJS.Timeout; } = {};
|
||||
private coreIndexes: CoreIndex[] = [];
|
||||
|
||||
public indexerIsRunning(): boolean {
|
||||
return this.indexerRunning;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check which core index is available for indexing
|
||||
@@ -67,33 +76,69 @@ class Indexer {
|
||||
}
|
||||
}
|
||||
|
||||
public async runSingleTask(task: 'blocksPrices' | 'coinStatsIndex'): Promise<void> {
|
||||
if (!Common.indexingEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (task === 'blocksPrices' && !this.tasksRunning.includes(task)) {
|
||||
this.tasksRunning.push(task);
|
||||
const lastestPriceId = await PricesRepository.$getLatestPriceId();
|
||||
if (priceUpdater.historyInserted === false || lastestPriceId === null) {
|
||||
logger.debug(`Blocks prices indexer is waiting for the price updater to complete`, logger.tags.mining);
|
||||
setTimeout(() => {
|
||||
this.tasksRunning = this.tasksRunning.filter(runningTask => runningTask !== task);
|
||||
this.runSingleTask('blocksPrices');
|
||||
}, 10000);
|
||||
} else {
|
||||
logger.debug(`Blocks prices indexer will run now`, logger.tags.mining);
|
||||
await mining.$indexBlockPrices();
|
||||
this.tasksRunning = this.tasksRunning.filter(runningTask => runningTask !== task);
|
||||
/**
|
||||
* schedules a single task to run in `timeout` ms
|
||||
* only one task of each type may be scheduled
|
||||
*
|
||||
* @param {TaskName} task - the type of task
|
||||
* @param {number} timeout - delay in ms
|
||||
* @param {boolean} replace - `true` replaces any already scheduled task (works like a debounce), `false` ignores subsequent requests (works like a throttle)
|
||||
*/
|
||||
public scheduleSingleTask(task: TaskName, timeout: number = 10000, replace = false): void {
|
||||
if (this.tasksScheduled[task]) {
|
||||
if (!replace) { //throttle
|
||||
return;
|
||||
} else { // debounce
|
||||
clearTimeout(this.tasksScheduled[task]);
|
||||
}
|
||||
}
|
||||
this.tasksScheduled[task] = setTimeout(async () => {
|
||||
try {
|
||||
await this.runSingleTask(task);
|
||||
} catch (e) {
|
||||
logger.err(`Unexpected error in scheduled task ${task}: ` + (e instanceof Error ? e.message : e));
|
||||
} finally {
|
||||
clearTimeout(this.tasksScheduled[task]);
|
||||
}
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
if (task === 'coinStatsIndex' && !this.tasksRunning.includes(task)) {
|
||||
this.tasksRunning.push(task);
|
||||
logger.debug(`Indexing coinStatsIndex now`);
|
||||
await mining.$indexCoinStatsIndex();
|
||||
this.tasksRunning = this.tasksRunning.filter(runningTask => runningTask !== task);
|
||||
/**
|
||||
* Runs a single task immediately
|
||||
*
|
||||
* (use `scheduleSingleTask` instead to queue a task to run after some timeout)
|
||||
*/
|
||||
public async runSingleTask(task: TaskName): Promise<void> {
|
||||
if (!Common.indexingEnabled() || this.tasksRunning[task]) {
|
||||
return;
|
||||
}
|
||||
this.tasksRunning[task] = true;
|
||||
|
||||
switch (task) {
|
||||
case 'blocksPrices': {
|
||||
if (!['testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) {
|
||||
let lastestPriceId;
|
||||
try {
|
||||
lastestPriceId = await PricesRepository.$getLatestPriceId();
|
||||
} catch (e) {
|
||||
logger.debug('failed to fetch latest price id from db: ' + (e instanceof Error ? e.message : e));
|
||||
} if (priceUpdater.historyInserted === false || lastestPriceId === null) {
|
||||
logger.debug(`Blocks prices indexer is waiting for the price updater to complete`, logger.tags.mining);
|
||||
this.scheduleSingleTask(task, 10000);
|
||||
} else {
|
||||
logger.debug(`Blocks prices indexer will run now`, logger.tags.mining);
|
||||
await mining.$indexBlockPrices();
|
||||
}
|
||||
}
|
||||
} break;
|
||||
|
||||
case 'coinStatsIndex': {
|
||||
logger.debug(`Indexing coinStatsIndex now`);
|
||||
await mining.$indexCoinStatsIndex();
|
||||
} break;
|
||||
}
|
||||
|
||||
this.tasksRunning[task] = false;
|
||||
}
|
||||
|
||||
public async $run(): Promise<void> {
|
||||
@@ -103,6 +148,12 @@ class Indexer {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await priceUpdater.$run();
|
||||
} catch (e) {
|
||||
logger.err(`Running priceUpdater failed. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
|
||||
// Do not attempt to index anything unless Bitcoin Core is fully synced
|
||||
const blockchainInfo = await bitcoinClient.getBlockchainInfo();
|
||||
if (blockchainInfo.blocks !== blockchainInfo.headers) {
|
||||
@@ -117,8 +168,6 @@ class Indexer {
|
||||
await this.checkAvailableCoreIndexes();
|
||||
|
||||
try {
|
||||
await priceUpdater.$run();
|
||||
|
||||
const chainValid = await blocks.$generateBlockDatabase();
|
||||
if (chainValid === false) {
|
||||
// Chain of block hash was invalid, so we need to reindex. Stop here and continue at the next iteration
|
||||
@@ -134,6 +183,10 @@ class Indexer {
|
||||
await mining.$generatePoolHashrateHistory();
|
||||
await blocks.$generateBlocksSummariesDatabase();
|
||||
await blocks.$generateCPFPDatabase();
|
||||
await blocks.$generateAuditStats();
|
||||
await auditReplicator.$sync();
|
||||
// do not wait for classify blocks to finish
|
||||
blocks.$classifyBlocks();
|
||||
} catch (e) {
|
||||
this.indexerRunning = false;
|
||||
logger.err(`Indexer failed, trying again in 10 seconds. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
|
||||
@@ -35,6 +35,7 @@ class Logger {
|
||||
public tags = {
|
||||
mining: 'Mining',
|
||||
ln: 'Lightning',
|
||||
goggles: 'Goggles',
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
@@ -69,6 +70,10 @@ class Logger {
|
||||
this.network = this.getNetwork();
|
||||
}
|
||||
|
||||
public updateNetwork(): void {
|
||||
this.network = this.getNetwork();
|
||||
}
|
||||
|
||||
private addprio(prio): void {
|
||||
this[prio] = (function(_this) {
|
||||
return function(msg, tag?: string) {
|
||||
@@ -153,4 +158,6 @@ class Logger {
|
||||
}
|
||||
}
|
||||
|
||||
export type LogLevel = 'emerg' | 'alert' | 'crit' | 'err' | 'warn' | 'notice' | 'info' | 'debug';
|
||||
|
||||
export default new Logger();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user