Compare commits
2721 Commits
v2.4.0
...
v2.5.0-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80a7b6d8d5 | ||
|
|
f72e17c12e | ||
|
|
f570b2762f | ||
|
|
e2fda99578 | ||
|
|
d7d45146c8 | ||
|
|
45dbc6c6f6 | ||
|
|
d76e3a5939 | ||
|
|
cb8fdb5e8d | ||
|
|
d337bf3ee2 | ||
|
|
758e4d4f4c | ||
|
|
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 | ||
|
|
ef54385068 | ||
|
|
b8f77c4be4 | ||
|
|
b5c2073414 | ||
|
|
25aacb5046 | ||
|
|
c24724dcdf | ||
|
|
64ab14f995 | ||
|
|
4a64c0dfa5 | ||
|
|
0ebe0a5dc9 | ||
|
|
c683a52a01 | ||
|
|
5fbdd0bb2a | ||
|
|
599881366b | ||
|
|
4c294b010d | ||
|
|
418c32e334 | ||
|
|
12b605e5cc | ||
|
|
870a7e51b1 | ||
|
|
cdfde05452 | ||
|
|
ce24b8bb0a | ||
|
|
1b2810ec0e | ||
|
|
c8e84ec056 | ||
|
|
7bf8fea9f2 | ||
|
|
3458c5af71 | ||
|
|
3e46dabf7b | ||
|
|
a5dd141934 | ||
|
|
881af309ab | ||
|
|
374ff50a62 | ||
|
|
2b9e63dbc5 | ||
|
|
9f453deceb | ||
|
|
0b88d94573 | ||
|
|
536114853c | ||
|
|
4d281277d6 | ||
|
|
c97a722a3b | ||
|
|
adacb42d1a | ||
|
|
b6427d6f67 | ||
|
|
433acb7b1d | ||
|
|
d015ee7824 | ||
|
|
ecfb980e75 | ||
|
|
cfdbd30956 | ||
|
|
7acfec2406 | ||
|
|
070ee10fb0 | ||
|
|
446b0de8f3 | ||
|
|
99b7fc8814 | ||
|
|
defb88a474 | ||
|
|
7392535182 | ||
|
|
130ae8c3a5 | ||
|
|
f4f8b2b271 | ||
|
|
477d09412b | ||
|
|
87bc7917d8 | ||
|
|
9030d95207 | ||
|
|
5e4131b474 | ||
|
|
3bf96dafde | ||
|
|
2f4dba895c | ||
|
|
a5e281706f | ||
|
|
eba0e1c25a | ||
|
|
97d82042c0 | ||
|
|
8bd05987e5 | ||
|
|
7e676dbaf0 | ||
|
|
760f3193d9 | ||
|
|
72663b30df | ||
|
|
8995283a58 | ||
|
|
a8ac6aedf7 | ||
|
|
9613247283 | ||
|
|
ab96a17e80 | ||
|
|
3b080ee5fb | ||
|
|
24a8cca758 | ||
|
|
dbad3af8ba | ||
|
|
cdddf3a8b2 | ||
|
|
b675bd8d55 | ||
|
|
cb04d67d07 | ||
|
|
b1c6c5cbb6 | ||
|
|
7f8f72e9d6 | ||
|
|
a1ea77ee50 | ||
|
|
19e2d687d0 | ||
|
|
4be8016eb1 | ||
|
|
627c913d5e | ||
|
|
3c5165603b | ||
|
|
513277cef7 | ||
|
|
72a9b71901 | ||
|
|
60bef0eeb6 | ||
|
|
eeb97bdb81 | ||
|
|
8407c07b88 | ||
|
|
951cf2daf9 | ||
|
|
31cfbf6625 | ||
|
|
5dc52250e6 | ||
|
|
6b544ac179 | ||
|
|
2ad9bf57f7 | ||
|
|
e0e97f0d5e | ||
|
|
c72024b4e3 | ||
|
|
093469c164 | ||
|
|
24d9977919 | ||
|
|
7b2ea9c4c8 | ||
|
|
6b4650f3cd | ||
|
|
e971846b7e | ||
|
|
23ea5d582b | ||
|
|
6196860387 | ||
|
|
368f858ff2 | ||
|
|
ef0cc9d2db | ||
|
|
39051e94e3 | ||
|
|
e1f0bb9901 | ||
|
|
097eed0baa | ||
|
|
25c7c84705 | ||
|
|
7e873e6637 | ||
|
|
36e1777b96 | ||
|
|
35a05a420d | ||
|
|
1be5c6ec53 | ||
|
|
e4aa3c2091 | ||
|
|
4263977d99 | ||
|
|
5bd9be7ab1 | ||
|
|
74c95bfdf5 | ||
|
|
a1c0a58b9d | ||
|
|
d3d67627f3 | ||
|
|
4a0d9cb66f | ||
|
|
a2d673f9ed | ||
|
|
8dccaee0b0 | ||
|
|
ccc413a800 | ||
|
|
4d7e23064c | ||
|
|
a22a62836e | ||
|
|
dd01371b61 | ||
|
|
46d89ac837 | ||
|
|
796566e7ae | ||
|
|
3f234431fb | ||
|
|
778e2f9b64 | ||
|
|
6327ce7c89 | ||
|
|
cec8445223 | ||
|
|
548a6ea664 | ||
|
|
9cef9b67c1 | ||
|
|
42228dc70f | ||
|
|
63dd9fd09e | ||
|
|
cd3c1ed82e | ||
|
|
304089b3d0 | ||
|
|
c60903565e | ||
|
|
37e94249df | ||
|
|
d2337ae4e8 | ||
|
|
7557c47502 | ||
|
|
a8214bcbbd | ||
|
|
fcf51e2af8 | ||
|
|
c657e622eb | ||
|
|
526e46b8e4 | ||
|
|
ead7a13ff0 | ||
|
|
c3c0696844 | ||
|
|
2907054a01 | ||
|
|
64408bfd16 | ||
|
|
ca690bf123 | ||
|
|
0243428fe9 | ||
|
|
901d32d8f7 | ||
|
|
8adacd4a0e | ||
|
|
b8a3c15ed2 | ||
|
|
3e31e68a19 | ||
|
|
626b395ab7 | ||
|
|
5eae84bb75 | ||
|
|
c5a01135b3 | ||
|
|
1a4f3b105e | ||
|
|
ae1e2dcb50 | ||
|
|
da25577ea7 | ||
|
|
5937e959c3 | ||
|
|
6360913e84 | ||
|
|
fb71136dae | ||
|
|
3739e5be0d | ||
|
|
ebfd0b9ddd | ||
|
|
355acfd338 | ||
|
|
5a5ebe8435 | ||
|
|
43b2fe2f9a | ||
|
|
182cb16695 | ||
|
|
5a09e3099c | ||
|
|
7f78fefb21 | ||
|
|
ac932c641c | ||
|
|
4f297f0a7a | ||
|
|
c28d1c4610 | ||
|
|
1ad4ff0683 | ||
|
|
c50f5d45e1 | ||
|
|
226a4e9bde | ||
|
|
154e65d470 | ||
|
|
a895d21179 | ||
|
|
d5ea2aec25 | ||
|
|
07271f56d7 | ||
|
|
f37946118c | ||
|
|
fdbcef29e5 | ||
|
|
9eeaf76369 | ||
|
|
a9a2ff0347 | ||
|
|
aae61bcb45 | ||
|
|
43bed7cf56 | ||
|
|
fca813147d | ||
|
|
62ef1d4439 | ||
|
|
ff7c85180d | ||
|
|
001be82f5a | ||
|
|
32a260473a | ||
|
|
2e74d7fa4a | ||
|
|
4e39c27c75 | ||
|
|
aef26097ec | ||
|
|
7b24b124c2 | ||
|
|
82b0844928 | ||
|
|
2ed4e5bb6e | ||
|
|
fdcf4ce501 | ||
|
|
0223846f91 | ||
|
|
1192d4fbd4 | ||
|
|
027603acf7 | ||
|
|
152b8e8a7d | ||
|
|
375219242c | ||
|
|
5fb4eac4b7 | ||
|
|
f8624020e8 | ||
|
|
269dcb2b16 | ||
|
|
ec2e7a46eb | ||
|
|
a37bcfec65 | ||
|
|
0c8636c803 | ||
|
|
059139689d | ||
|
|
4e68498979 | ||
|
|
9024e21868 | ||
|
|
e0c3c732d1 | ||
|
|
622929831e | ||
|
|
d483362a9b | ||
|
|
32466f4d46 | ||
|
|
b21fd0d37d | ||
|
|
a2688f1ca5 | ||
|
|
51d50292e6 | ||
|
|
f90a23bde6 | ||
|
|
6a66d06208 | ||
|
|
27cf23f9b0 | ||
|
|
d16bda0630 | ||
|
|
fa77161379 | ||
|
|
d76d14253a | ||
|
|
ad7d3d97de | ||
|
|
3e27f684d1 | ||
|
|
2cadb75163 | ||
|
|
0e338a2c64 | ||
|
|
ab88a51e01 | ||
|
|
cffb41b54d | ||
|
|
fdd66a685a | ||
|
|
98e9a91058 | ||
|
|
5a8270a12d | ||
|
|
5d976eff75 | ||
|
|
d87fd95523 | ||
|
|
545b5be7d4 | ||
|
|
1ac8e5004f | ||
|
|
dd7c56eb53 | ||
|
|
c18dc44f4d | ||
|
|
0f077afef2 | ||
|
|
7ea2d3b808 | ||
|
|
0b77000aab | ||
|
|
bc94560636 | ||
|
|
c404895b1b | ||
|
|
0846f346ee | ||
|
|
0382f11052 | ||
|
|
89d9c1d78d | ||
|
|
decc96d293 | ||
|
|
4f689885e6 | ||
|
|
210843916e | ||
|
|
bc2d8dd7c3 | ||
|
|
9b871dbfcc | ||
|
|
b1e7bbcc3e | ||
|
|
488693804c | ||
|
|
a54684ad74 | ||
|
|
7b01286ed2 | ||
|
|
b4b08a8531 | ||
|
|
96ddf98159 | ||
|
|
ec0d8b7c48 | ||
|
|
dfcbaabeda | ||
|
|
29498e136b | ||
|
|
13db2626b0 | ||
|
|
3d01ecd1a6 | ||
|
|
b14d6bc850 | ||
|
|
743f15e08e | ||
|
|
5129116fe4 | ||
|
|
746e1bb3da | ||
|
|
be4bd691ee | ||
|
|
2309a769cd | ||
|
|
4efabe18b1 | ||
|
|
5e484d3eca | ||
|
|
9d290562cd | ||
|
|
5fe8f42489 | ||
|
|
3265b32a56 | ||
|
|
2c55182cf0 | ||
|
|
9043d23a03 | ||
|
|
e2ef8721d7 | ||
|
|
c27e165434 | ||
|
|
88913b1a89 | ||
|
|
7acfa6e13f | ||
|
|
9c5a9f2eba | ||
|
|
d5342a4e9a | ||
|
|
6c271ab6ee | ||
|
|
87d678e268 | ||
|
|
8aebcf3e57 | ||
|
|
40c250502e | ||
|
|
d673365a0e | ||
|
|
43e0c7e0d8 | ||
|
|
7875829370 | ||
|
|
a67656389e | ||
|
|
1d5e4aa410 | ||
|
|
af2e3cb42a | ||
|
|
f09a2aab24 | ||
|
|
0929d53c56 | ||
|
|
ec7f0d1143 | ||
|
|
a5acb81def | ||
|
|
792cb12755 | ||
|
|
8630ae0682 | ||
|
|
7316d7d7e8 | ||
|
|
7e093d912b | ||
|
|
54f7e59978 | ||
|
|
852513500a | ||
|
|
f057b07021 | ||
|
|
73b90fcd25 | ||
|
|
4bdad54bb2 | ||
|
|
174758bdd9 | ||
|
|
b585a90abd | ||
|
|
7d8875eb73 | ||
|
|
143344cb7e | ||
|
|
f471a85d1d | ||
|
|
76ae9d4ccb | ||
|
|
01d699e454 | ||
|
|
0aff276a5c | ||
|
|
5792dee553 | ||
|
|
416ba77394 | ||
|
|
4cc0f9b5c7 | ||
|
|
0ce4c5fa4d | ||
|
|
321499da88 | ||
|
|
5fba448dca | ||
|
|
32a0f4f6e0 | ||
|
|
86a0488bba | ||
|
|
75faa2b45a | ||
|
|
91dfd06015 | ||
|
|
0b92b692c4 | ||
|
|
f1863596d8 | ||
|
|
a7f17f4299 | ||
|
|
955e216037 | ||
|
|
d938448fe9 | ||
|
|
bc3400ce75 | ||
|
|
c731eeba87 | ||
|
|
51bc749415 | ||
|
|
200da478d2 | ||
|
|
d0d2303513 | ||
|
|
25186ae6fa | ||
|
|
7519eaf5d8 | ||
|
|
7ad207766b | ||
|
|
57fb305452 | ||
|
|
5981e52534 | ||
|
|
9a4a5ad94e | ||
|
|
32733a3023 | ||
|
|
b6c7c02a2d | ||
|
|
f2e7dd51af | ||
|
|
a721761d49 | ||
|
|
6b49096fa9 | ||
|
|
303f123c17 | ||
|
|
0b9c64483c | ||
|
|
a4b9397286 | ||
|
|
c1faefe74e | ||
|
|
ba1cc05979 | ||
|
|
d3b681dca2 | ||
|
|
b7425dc339 | ||
|
|
619c4fdfb1 | ||
|
|
333aef5e94 | ||
|
|
6d1e6a92ad | ||
|
|
b7e6b6da13 | ||
|
|
9395a5031e | ||
|
|
2363a397f1 | ||
|
|
3d38064dbb | ||
|
|
ad9e42db26 | ||
|
|
c2f5cb9529 | ||
|
|
6cd42cfc73 | ||
|
|
117aa1375d | ||
|
|
d87fb04a92 | ||
|
|
d3fdef256c | ||
|
|
e8ffd4335f | ||
|
|
210f939e65 | ||
|
|
8d9568016e | ||
|
|
5d7c9f9315 | ||
|
|
ad4cbd60d5 | ||
|
|
822362c105 | ||
|
|
6c3a273e75 | ||
|
|
ed8cf89fee | ||
|
|
e19db4ae35 | ||
|
|
aa1114926c | ||
|
|
0bf4d52183 | ||
|
|
a0488dba76 | ||
|
|
086ee68b52 | ||
|
|
75a99568bf | ||
|
|
b2eaa7efb1 | ||
|
|
eceedf0bdf | ||
|
|
6965c8f41b | ||
|
|
e2fe39f241 | ||
|
|
281899f551 | ||
|
|
458f24c9f2 | ||
|
|
8f716a1d8c | ||
|
|
8612dd2d73 | ||
|
|
73f76474dd | ||
|
|
c44896f53e | ||
|
|
9a246c68de | ||
|
|
8df2476266 | ||
|
|
80e0ef8970 | ||
|
|
58eb6ccc8e | ||
|
|
b50e973573 | ||
|
|
56789532ed | ||
|
|
92862939da | ||
|
|
98e709b739 | ||
|
|
7cee6df369 | ||
|
|
ab6219a828 | ||
|
|
7d3c7c3f45 | ||
|
|
20404cf6a0 | ||
|
|
ddd8109187 | ||
|
|
a26dc977ba | ||
|
|
4921ea1a2b | ||
|
|
b4d0e20d75 | ||
|
|
4d7c69dd73 | ||
|
|
6f68c1666f | ||
|
|
c65674479a | ||
|
|
ee265be55e | ||
|
|
8eca1e5f7e | ||
|
|
da3446f522 | ||
|
|
676f6ff2ef | ||
|
|
de93a0c53e | ||
|
|
d74429e359 | ||
|
|
bb5fd4b1b1 | ||
|
|
d2ea9215f0 | ||
|
|
7cc0622702 | ||
|
|
b147898823 | ||
|
|
a7fb62ce4a | ||
|
|
0147af1325 | ||
|
|
aa9ebc277d | ||
|
|
4e55f5323e | ||
|
|
6cd946c8bb | ||
|
|
7e913e4d34 | ||
|
|
f9fe096669 | ||
|
|
58f886b337 | ||
|
|
50c3f83484 | ||
|
|
51ac04f207 | ||
|
|
aa561773ee | ||
|
|
ffe02c2509 | ||
|
|
7929500dd2 | ||
|
|
1b92099004 | ||
|
|
ee54e782f8 | ||
|
|
2d03332333 | ||
|
|
3f95d094a3 | ||
|
|
5cfd715d4a | ||
|
|
d487c78ae1 | ||
|
|
10bfb51215 | ||
|
|
5b5de95828 | ||
|
|
03be3ab561 | ||
|
|
0851dad0b2 | ||
|
|
5749820999 | ||
|
|
ba2aa0456c | ||
|
|
0ef259d408 | ||
|
|
22ad06a94c | ||
|
|
ea74a737ff | ||
|
|
f6c7839524 | ||
|
|
62e1fa03c1 | ||
|
|
f44eacd5d5 | ||
|
|
6e7ed29caa | ||
|
|
2246a6f3ce | ||
|
|
4b2083e756 | ||
|
|
4203972a49 | ||
|
|
b6792784e8 | ||
|
|
437350aaff | ||
|
|
32b38e6cd1 | ||
|
|
2ff930ef3e | ||
|
|
e8d54f254b | ||
|
|
00cbb5d0d8 | ||
|
|
ef8a1c7393 | ||
|
|
c1a3b5f045 | ||
|
|
e3603d9ce9 | ||
|
|
680f7d39cf | ||
|
|
70113d9c91 | ||
|
|
70d96583fb | ||
|
|
6beab1f8c1 | ||
|
|
2bfe192324 | ||
|
|
8f51e20b2e | ||
|
|
05594675c0 | ||
|
|
cf1bf9f0c5 | ||
|
|
5bcd54b808 | ||
|
|
952e540d65 | ||
|
|
0e5a4df978 | ||
|
|
b2162130d9 | ||
|
|
de117e30f2 | ||
|
|
c949fee49f | ||
|
|
3c94755a69 | ||
|
|
994b31527b | ||
|
|
14be0fc547 | ||
|
|
0dc2a598c3 | ||
|
|
e3e7271c9d | ||
|
|
e2585da7aa | ||
|
|
df578f64ee | ||
|
|
ac22a10e06 | ||
|
|
85251fcd5c | ||
|
|
f3ef1e7a53 | ||
|
|
ca15887e4b | ||
|
|
0e0acff00f | ||
|
|
89319d9117 | ||
|
|
c260b4f0f2 | ||
|
|
6a285064af | ||
|
|
250f07732f | ||
|
|
e0f149550a | ||
|
|
ee6bdeec66 | ||
|
|
209d5a57c7 | ||
|
|
66d52c9773 | ||
|
|
c1fc60e61e | ||
|
|
44865d18ae | ||
|
|
761edbce9a | ||
|
|
f6800b848a | ||
|
|
5a5e81c731 | ||
|
|
9c02d6a276 | ||
|
|
6229708dfd | ||
|
|
e2eccf6e85 | ||
|
|
b2621e7c27 | ||
|
|
3d1d29bd3b | ||
|
|
bc4d21577d | ||
|
|
95b029ee7b | ||
|
|
330aba0985 | ||
|
|
83c248e4b6 | ||
|
|
7f54e30a26 | ||
|
|
65875c3ced | ||
|
|
83e80b90d7 | ||
|
|
9b6537eb4e | ||
|
|
224613e5ee | ||
|
|
177fba2178 | ||
|
|
83fc60d6ee | ||
|
|
71b373463b | ||
|
|
3f0bcbe64c | ||
|
|
17c16ba4d5 | ||
|
|
e7a19dfe2f | ||
|
|
508f8119d3 | ||
|
|
87cda325a5 | ||
|
|
8e2ff1242c | ||
|
|
2993a286b8 | ||
|
|
59f08247ef | ||
|
|
77686821ba | ||
|
|
04d8926ef5 | ||
|
|
565e572b88 | ||
|
|
a897aebbc1 | ||
|
|
ce012b7d55 | ||
|
|
f040c85fe9 | ||
|
|
ae9f98f26c | ||
|
|
40decd1556 | ||
|
|
cc5873f995 | ||
|
|
2845a226f2 | ||
|
|
1eb68962d8 | ||
|
|
408b0a23ce | ||
|
|
9734052477 | ||
|
|
5e10b75b87 | ||
|
|
3beb85dd4a | ||
|
|
774f7630ce | ||
|
|
215e92d33e | ||
|
|
c923a4bc22 | ||
|
|
2363631326 | ||
|
|
28bd813fb8 | ||
|
|
6c0dc34dd6 | ||
|
|
32aa7aaff1 | ||
|
|
6f650e936d | ||
|
|
b3a5d1a8fc | ||
|
|
1b51193cdf | ||
|
|
27a9622875 | ||
|
|
fddbf51084 | ||
|
|
9f1a6ad31d | ||
|
|
fc216819d1 | ||
|
|
e009c78c3e | ||
|
|
b4c30cad5c | ||
|
|
8f2255a7a2 | ||
|
|
56dad33fce | ||
|
|
14a794d9cc | ||
|
|
014113bb33 | ||
|
|
bba9cb4a58 | ||
|
|
763b59ce9e | ||
|
|
49754745d8 | ||
|
|
746b44d973 | ||
|
|
b0b7546332 | ||
|
|
bc22098679 | ||
|
|
2b60ba0f38 | ||
|
|
b2329df81a | ||
|
|
491f2c6280 | ||
|
|
cbbb36b030 | ||
|
|
52824abf00 | ||
|
|
7aca045080 | ||
|
|
33d7a0af60 | ||
|
|
0e4bebb870 | ||
|
|
b817ee9e5c | ||
|
|
d8ebc5a92c | ||
|
|
635fadd13f | ||
|
|
771825f224 | ||
|
|
38ce8b8dc1 | ||
|
|
1c7698eb36 | ||
|
|
610597b687 | ||
|
|
198f85af2d | ||
|
|
2b5442783e | ||
|
|
0148a5f489 | ||
|
|
1fc6e13bf8 | ||
|
|
cf720c4bef | ||
|
|
0176afa394 | ||
|
|
c85d8cd29d | ||
|
|
cf0897e27c | ||
|
|
fd16f5c4c3 | ||
|
|
8f3b8276c5 | ||
|
|
133044ab8e | ||
|
|
d66921938c | ||
|
|
a67a074eaf | ||
|
|
241850beca | ||
|
|
8d2856dc84 | ||
|
|
74c5e4af90 | ||
|
|
900e66aef7 | ||
|
|
b269e99cfb | ||
|
|
9f15e9a1d7 | ||
|
|
368041e7d4 | ||
|
|
d06dcdccb4 | ||
|
|
c2ff6a996a | ||
|
|
02655d757e | ||
|
|
aa8a3e60c2 | ||
|
|
69f5d483ee | ||
|
|
c45bc0a9eb | ||
|
|
3f0cbcab63 | ||
|
|
e3c1343a24 | ||
|
|
7ce67f7ca5 | ||
|
|
b2251d99c9 | ||
|
|
e1c0f0a529 | ||
|
|
b1addfb45a | ||
|
|
b867e9f00f | ||
|
|
ff3af3a159 | ||
|
|
573fe3515a | ||
|
|
e8c3273541 | ||
|
|
0293ed2b41 | ||
|
|
e858408c4c | ||
|
|
bc1735a5d0 | ||
|
|
f9026b8f35 | ||
|
|
35d873e7a6 | ||
|
|
150f16ea92 | ||
|
|
922dc5bdbf | ||
|
|
74eb5ea11b | ||
|
|
05e23b058c | ||
|
|
92352e1453 | ||
|
|
22cd20bef2 | ||
|
|
bb7b0d4595 | ||
|
|
b82f76f9e2 | ||
|
|
93bbf61180 | ||
|
|
2c2003af5a | ||
|
|
dd3d047aa8 | ||
|
|
d8737ef6e1 | ||
|
|
3c3814910a | ||
|
|
687451e5ee | ||
|
|
1cf3e1814b | ||
|
|
f6be04dafd | ||
|
|
2e7a701ca7 | ||
|
|
da51557960 | ||
|
|
714700d8f1 | ||
|
|
d3b59bc459 | ||
|
|
2ed49cf944 | ||
|
|
94add379d0 | ||
|
|
931abb7d59 | ||
|
|
f1188efb40 | ||
|
|
5e0ad1da5c | ||
|
|
5dc2d0ba98 | ||
|
|
4407b42aab | ||
|
|
34b90e77c2 | ||
|
|
c191bbe5be | ||
|
|
6ed4e24f86 | ||
|
|
ee0ec8421f | ||
|
|
6f3303315b | ||
|
|
59eb271782 | ||
|
|
e4fcac93f2 | ||
|
|
42b3144a22 | ||
|
|
15c590a63f | ||
|
|
d8e4d7054b | ||
|
|
78e4788ab3 | ||
|
|
a8b162387b | ||
|
|
b401284eb3 | ||
|
|
fc0af50ab5 | ||
|
|
ec6c96c997 | ||
|
|
402d9496c3 | ||
|
|
c3ae46795b | ||
|
|
d89b313db8 | ||
|
|
e842aa814e | ||
|
|
c5f9682b5b | ||
|
|
7da308c1e1 | ||
|
|
d778530620 | ||
|
|
0481f57304 | ||
|
|
47d2a6d5c7 | ||
|
|
8e954d59db | ||
|
|
92b0b23765 | ||
|
|
73f2d54a26 | ||
|
|
dfd1de67b2 | ||
|
|
c46464b57b | ||
|
|
ce7e2e8801 | ||
|
|
f81e11e313 | ||
|
|
05d7ca2cd3 | ||
|
|
9b61e2bcb0 | ||
|
|
0202fb68a0 | ||
|
|
b870e7fcaa | ||
|
|
649dba940d | ||
|
|
73dd34af6f | ||
|
|
2284772f6a | ||
|
|
c69f2f2bc2 | ||
|
|
eead4d0af8 | ||
|
|
fbd8e0588c | ||
|
|
43e7328f6f | ||
|
|
9fa19e7b26 | ||
|
|
f685a23ead | ||
|
|
a2d241c687 | ||
|
|
5bf00b565e | ||
|
|
8c58fbfacc | ||
|
|
fd912c5284 | ||
|
|
fd53d7ec30 | ||
|
|
04f00ca521 | ||
|
|
2b9975ded9 | ||
|
|
a61106377c | ||
|
|
15ea4d3288 | ||
|
|
2187d96c25 | ||
|
|
d7767a053a | ||
|
|
646999eb56 | ||
|
|
fd105c9c99 | ||
|
|
ab3186a99d | ||
|
|
c15d5920d8 | ||
|
|
500f94227a | ||
|
|
b2b8911030 | ||
|
|
fc56c371ec | ||
|
|
3b8802d39e | ||
|
|
01a46344b9 | ||
|
|
7f6d6b86a4 | ||
|
|
2548d2a5e9 | ||
|
|
bd30f2eb12 | ||
|
|
f0d3bb87c6 | ||
|
|
8de3fd0988 | ||
|
|
fcd047f302 | ||
|
|
01c96f80f9 | ||
|
|
b50936f001 | ||
|
|
7793eaecbc | ||
|
|
ee95d033ac | ||
|
|
0d921cf7a6 | ||
|
|
b2dbb09ddb | ||
|
|
2e5d4a6df9 | ||
|
|
b4bac7ea09 | ||
|
|
5379ec0f30 | ||
|
|
5f87d6c4f1 | ||
|
|
87fd6dc256 | ||
|
|
8ebe04baa7 | ||
|
|
549d61b41e | ||
|
|
fcd34eb876 | ||
|
|
649d14011e | ||
|
|
f5922b7b71 | ||
|
|
2e45dab4b7 | ||
|
|
7f903b0331 | ||
|
|
0d7c52817e | ||
|
|
73c55c450c | ||
|
|
71d1c3de04 | ||
|
|
e3a82dae83 | ||
|
|
333593c166 | ||
|
|
5a8d1795a6 | ||
|
|
0fdb5099e1 | ||
|
|
31d732172c | ||
|
|
33d6892aa4 | ||
|
|
472d00a067 | ||
|
|
98fbd524fc | ||
|
|
d987669b1e | ||
|
|
8a3bcd3b28 | ||
|
|
9f4107319f | ||
|
|
6d95cfadac | ||
|
|
bf941b0227 | ||
|
|
32bf30872d | ||
|
|
7be3ed416e | ||
|
|
befafaa60c | ||
|
|
5905eebaa6 | ||
|
|
f99aa8f1f0 | ||
|
|
3e99605870 | ||
|
|
05c4440680 | ||
|
|
238a2e75b1 | ||
|
|
6448ad0ac7 | ||
|
|
42a39c1f7c | ||
|
|
44147f5976 | ||
|
|
667fc4ea18 | ||
|
|
05a8154db0 | ||
|
|
68642aeb5f | ||
|
|
dcc8b81ca6 | ||
|
|
7e42d2d792 | ||
|
|
2ca12a72f8 | ||
|
|
26a92cda45 | ||
|
|
2f7e74a2a6 | ||
|
|
a51b4e88d8 | ||
|
|
a975936d3c | ||
|
|
fbbd86d8e0 | ||
|
|
8ccfa5b038 | ||
|
|
04006b8c98 | ||
|
|
3317f5e6db | ||
|
|
79e1beb2ca | ||
|
|
811d0c824f | ||
|
|
f3adab7d26 | ||
|
|
d730366ea7 | ||
|
|
7e60526887 | ||
|
|
ab69c03d8d | ||
|
|
efa352821a | ||
|
|
1dcf6ab599 | ||
|
|
869d7df844 | ||
|
|
597536efaf | ||
|
|
1f5754943a | ||
|
|
e98d03431c | ||
|
|
39d92f57fa | ||
|
|
313df79e33 | ||
|
|
13ceb368e2 | ||
|
|
4bcb4e37e8 | ||
|
|
72e6e36cbb | ||
|
|
729f2aff3e | ||
|
|
1348e2318d | ||
|
|
731443f670 | ||
|
|
fc2edbf1d1 | ||
|
|
8ac514733a | ||
|
|
e986aaf1d9 | ||
|
|
606e6df834 | ||
|
|
743f2a1cd4 | ||
|
|
5e633344c5 | ||
|
|
c4d5ea971e | ||
|
|
04fec6c894 | ||
|
|
f4b70fa886 | ||
|
|
1b9056298a | ||
|
|
343a48818b | ||
|
|
028a26f574 | ||
|
|
dcd0a53fba | ||
|
|
ea5ec7bc32 | ||
|
|
908b92af71 | ||
|
|
db4bf52596 | ||
|
|
1513c61cd5 | ||
|
|
713ac64636 | ||
|
|
f18ea6a7a3 | ||
|
|
35ee58befb | ||
|
|
79f79b0e3b | ||
|
|
250df0d56c | ||
|
|
a210a3faf2 | ||
|
|
b8edcbadf4 | ||
|
|
fb137e6247 | ||
|
|
1a4f699c95 | ||
|
|
56b6f79f97 | ||
|
|
4d0637768d | ||
|
|
07987ff4b6 | ||
|
|
e7e0a64ca2 | ||
|
|
484c503f6d | ||
|
|
c59ab2a129 | ||
|
|
8e668be703 | ||
|
|
c62059c0f4 | ||
|
|
28c21b3770 | ||
|
|
3a7dffbe09 | ||
|
|
7a7172bb64 | ||
|
|
5658e053d0 | ||
|
|
d17ccbc5ae | ||
|
|
685433fe4c | ||
|
|
79f6ae3b6f | ||
|
|
e54e896e56 | ||
|
|
3126a559a0 | ||
|
|
132e848fdc | ||
|
|
ade3c09b2a | ||
|
|
0d92779971 | ||
|
|
efb48271f9 | ||
|
|
3572ba837d | ||
|
|
5ff5275b36 | ||
|
|
534f2e2781 | ||
|
|
a671bfc226 | ||
|
|
2cd98c7c04 | ||
|
|
75459729ad | ||
|
|
2b411aad0a | ||
|
|
229dd7718a | ||
|
|
13b52c427c | ||
|
|
fc778e1e25 | ||
|
|
f6813f1d1c | ||
|
|
1db11d1d67 | ||
|
|
12b130cfdc | ||
|
|
175bcf7467 | ||
|
|
0b54035e80 | ||
|
|
92807dbdde | ||
|
|
059d5a94a9 | ||
|
|
501ca1832b | ||
|
|
ddc7de0d4a | ||
|
|
59f1b031c8 | ||
|
|
3d45054e38 | ||
|
|
38c890626b | ||
|
|
c7d61a3be4 | ||
|
|
0b37a02435 | ||
|
|
03a3320e45 | ||
|
|
6d075842f4 | ||
|
|
ead60aaa21 | ||
|
|
0fd672a741 | ||
|
|
6741a2b226 | ||
|
|
100c1b292a | ||
|
|
7e5c0a4c46 | ||
|
|
8117b9799c | ||
|
|
afc5c6786b | ||
|
|
c7cca500fa | ||
|
|
5f1a71cc9b | ||
|
|
734c953714 | ||
|
|
ba10df69b7 | ||
|
|
ded11892f5 | ||
|
|
609f68eb24 | ||
|
|
5e1f54e862 | ||
|
|
dc7d5bc94d | ||
|
|
35ae672177 | ||
|
|
8f0830f6d1 | ||
|
|
0c96a11150 | ||
|
|
cf89ded14d | ||
|
|
a9e766046f | ||
|
|
030889250f | ||
|
|
50993d3b95 | ||
|
|
33775f32e2 | ||
|
|
95e8789ba9 | ||
|
|
194e4b4c80 | ||
|
|
272b6d2437 | ||
|
|
89293b4358 | ||
|
|
c682a8e3ff | ||
|
|
cc30da0b4d | ||
|
|
6d6dd09d11 | ||
|
|
f2ad184d1f | ||
|
|
ab5308e1c8 | ||
|
|
205d832d31 | ||
|
|
3e7270d1c5 | ||
|
|
fa515402bf | ||
|
|
9b6a012476 | ||
|
|
3406758fd2 | ||
|
|
cc93674591 | ||
|
|
c9fc77490f | ||
|
|
ddb4fbac5c | ||
|
|
3eb4ea9048 | ||
|
|
6d99d0a9ce | ||
|
|
d43a9cc5ea | ||
|
|
a3a2adac02 | ||
|
|
c8aea18c5e | ||
|
|
c2f45f9bc1 | ||
|
|
208946a8bf | ||
|
|
0e8e5dc3a9 | ||
|
|
f1122384dd | ||
|
|
2290f98011 | ||
|
|
b0e3022ddb | ||
|
|
acd633530f | ||
|
|
f73dc59f49 | ||
|
|
e627122239 | ||
|
|
201b32bdcd | ||
|
|
6ec9c2f816 | ||
|
|
de04914851 | ||
|
|
5fc3b8b70c | ||
|
|
276470474d | ||
|
|
1461cb1b17 | ||
|
|
c43e4bb71b | ||
|
|
92538b1a48 | ||
|
|
fa519a0d8f | ||
|
|
da10b36524 | ||
|
|
c2b6316c8b | ||
|
|
6ada839282 | ||
|
|
7de068368c | ||
|
|
0d797ff7fd | ||
|
|
fe8cdb5867 | ||
|
|
74dbd6cee1 | ||
|
|
0b7182715f | ||
|
|
e08902b85b | ||
|
|
7d3ec63335 | ||
|
|
584f443f56 | ||
|
|
4f3296566a | ||
|
|
1309a63430 | ||
|
|
ca33a629cf | ||
|
|
311774103e | ||
|
|
e72cdb42e8 | ||
|
|
6f807b7a2c | ||
|
|
7f83b4be28 | ||
|
|
802d38c363 | ||
|
|
38311e191b | ||
|
|
a1c5769d0d | ||
|
|
01a727a344 | ||
|
|
6cd1f9e870 | ||
|
|
d107286344 | ||
|
|
330ab9682b | ||
|
|
2b94849881 | ||
|
|
37bf67aa38 | ||
|
|
28d5ec34b3 | ||
|
|
eeea6cd9c8 | ||
|
|
7bafeefa95 | ||
|
|
dc86f41e03 | ||
|
|
2f7aacaf3b | ||
|
|
446d76980a | ||
|
|
92dbba64e6 | ||
|
|
43bb3aa50b | ||
|
|
5198cc51dc | ||
|
|
56e00d7ea9 | ||
|
|
5e72ecfdc9 | ||
|
|
6c1457e257 | ||
|
|
7e01a22265 | ||
|
|
cb7e25d646 | ||
|
|
2653e7bf39 | ||
|
|
d8d8a52445 | ||
|
|
3e50941351 | ||
|
|
b9a761fb88 | ||
|
|
b1d490972b | ||
|
|
786d73625a | ||
|
|
08ad6a0da3 | ||
|
|
38cb45e026 | ||
|
|
24dba5a2ef | ||
|
|
a32f960c4a | ||
|
|
9345b1609f | ||
|
|
4abd77fe31 | ||
|
|
a9760326f2 | ||
|
|
ed184824d4 | ||
|
|
9d5717f30d | ||
|
|
547b60fce7 | ||
|
|
b7bf2ec666 | ||
|
|
9b5d8fdad6 | ||
|
|
782d4b391b | ||
|
|
19e778c4b5 | ||
|
|
4bc5de306a | ||
|
|
47c61842f5 | ||
|
|
672001af72 | ||
|
|
5da8f2b6dc | ||
|
|
9df0e602d3 | ||
|
|
8a367fc6fd | ||
|
|
a33562a47a | ||
|
|
fc7024351e | ||
|
|
d3d4f93f85 | ||
|
|
14ec427f5e | ||
|
|
2c1f38aa9d | ||
|
|
eb2abefabc | ||
|
|
90912af62d | ||
|
|
adcc1ba4f0 | ||
|
|
a0b6719105 | ||
|
|
c2ab0bc715 | ||
|
|
010e9f2bb1 | ||
|
|
373e02a5b0 | ||
|
|
d36b239dbe | ||
|
|
eb03fc18ad | ||
|
|
a7c511fc1c | ||
|
|
5b6f713ef3 | ||
|
|
1b3bc0ef4e | ||
|
|
2022d3f6d5 | ||
|
|
695d81a3f6 | ||
|
|
29f7c89c53 | ||
|
|
7232c4755d | ||
|
|
88fa6bffb5 | ||
|
|
235ac204b4 | ||
|
|
e051758ca7 | ||
|
|
be3acf8694 | ||
|
|
2020cd74e9 | ||
|
|
67cbbda04b | ||
|
|
5957b71774 | ||
|
|
b0198de7e8 | ||
|
|
8cc252642b | ||
|
|
5e5daca600 | ||
|
|
cfb4fdb7a4 | ||
|
|
5d95eb475e | ||
|
|
c57542c8ae | ||
|
|
dbc2d752bc | ||
|
|
7c7273b696 | ||
|
|
34500f7d47 | ||
|
|
f18226bd01 | ||
|
|
c1e741a025 | ||
|
|
2a6ac4a5da | ||
|
|
34d5a2f9c0 | ||
|
|
3654178c83 | ||
|
|
5df54b6b3e | ||
|
|
8bd3e14652 | ||
|
|
ddcd387848 | ||
|
|
ef27aca6e4 | ||
|
|
997e8a4624 | ||
|
|
d65f267122 | ||
|
|
d32d97fbaf | ||
|
|
65bfe8163c | ||
|
|
b069196c27 | ||
|
|
38255a5452 | ||
|
|
48e2df3f7a | ||
|
|
4fc355a05d | ||
|
|
7c6349f2ba | ||
|
|
899d6558ec | ||
|
|
dd5a1847d0 | ||
|
|
02820b0e68 | ||
|
|
4bb6a3800c | ||
|
|
b6d4e6b993 | ||
|
|
de46f7c10e | ||
|
|
69a36e17a8 | ||
|
|
06eeaf68e8 | ||
|
|
f789334d47 | ||
|
|
387a51d87e | ||
|
|
64426fa9c9 | ||
|
|
9c6799e193 | ||
|
|
8d6a0f867b | ||
|
|
057456504c | ||
|
|
45273f9309 | ||
|
|
2cbb7231a7 | ||
|
|
bee573fdb8 | ||
|
|
12bd89dade | ||
|
|
e24fd8e275 | ||
|
|
8c4a8f3a71 | ||
|
|
38ec5ef957 | ||
|
|
dbb6f267f4 | ||
|
|
23a4ab461e | ||
|
|
b657eb4e7d | ||
|
|
f3eb403c17 | ||
|
|
b6343ddc2d | ||
|
|
d86f045150 | ||
|
|
e2e50ac6bf | ||
|
|
6d28259515 | ||
|
|
968d7b827b | ||
|
|
832ccdac46 | ||
|
|
39afa4cda1 | ||
|
|
702ff2796a | ||
|
|
cb576ce601 | ||
|
|
e14fff45d6 | ||
|
|
a28544d046 | ||
|
|
847aa1ba13 | ||
|
|
58371bbd7d | ||
|
|
f3faf99c15 | ||
|
|
a5c4f8e2f3 | ||
|
|
27c39ef557 | ||
|
|
9e0a91efd2 | ||
|
|
601a559784 | ||
|
|
0e0ac363cf | ||
|
|
b31642e554 | ||
|
|
5f87cc6d37 | ||
|
|
b89d526379 | ||
|
|
67429d83b5 | ||
|
|
5c6060780b | ||
|
|
06a89bc1a7 | ||
|
|
022785a555 | ||
|
|
69baf97445 | ||
|
|
04fa08085d | ||
|
|
9bb897307f | ||
|
|
f3c947685a | ||
|
|
dffe9fa4e6 | ||
|
|
20bef70390 | ||
|
|
ae9439a991 | ||
|
|
9964f1ab14 | ||
|
|
f27abb1421 | ||
|
|
ee6766e34c | ||
|
|
76764936f9 | ||
|
|
596c7afecb | ||
|
|
ffad5e2a30 | ||
|
|
8da476c48c | ||
|
|
5bfc8a9d58 | ||
|
|
670f85b1f5 | ||
|
|
82a4212b72 | ||
|
|
cfa8a9a7d6 | ||
|
|
b77fe0dca2 | ||
|
|
81d35d9401 | ||
|
|
2742acf6ee | ||
|
|
8a2b144e29 | ||
|
|
3e66e4d6db | ||
|
|
61e8892204 | ||
|
|
543c4feaf9 | ||
|
|
992ea6da3c | ||
|
|
f3cfc7f80b | ||
|
|
4c170b08f4 | ||
|
|
d3b3c7df21 | ||
|
|
893aa03622 | ||
|
|
f4df51dd21 | ||
|
|
3e41e512ad | ||
|
|
7bdde13b40 | ||
|
|
7ec0e3ac86 | ||
|
|
02340d57dd | ||
|
|
1e6ea0b5f5 | ||
|
|
5d9bcce5cd | ||
|
|
39dd8ebe07 | ||
|
|
e5ec152002 | ||
|
|
e77f48abd4 | ||
|
|
3b692d05bc | ||
|
|
6895eb0b05 | ||
|
|
61333b2286 | ||
|
|
1240a3f115 | ||
|
|
f70ff9b402 | ||
|
|
5cdb0c5ce9 | ||
|
|
3971814710 | ||
|
|
c5d4e86e0e | ||
|
|
ad7e7795f9 | ||
|
|
71e00f66c9 | ||
|
|
5d21a61840 | ||
|
|
8ef88e9f39 | ||
|
|
ddb1e97ce0 | ||
|
|
b638719e72 | ||
|
|
4fee471992 | ||
|
|
5365f61121 | ||
|
|
4924c521a4 | ||
|
|
43d56a2121 | ||
|
|
bede502f2d | ||
|
|
6005bbea49 | ||
|
|
3653e75810 | ||
|
|
66c99e2f3b | ||
|
|
9876805bc3 | ||
|
|
d08e5e293c | ||
|
|
6635238934 | ||
|
|
001f7a4fd7 | ||
|
|
3ba4fd454e | ||
|
|
52b2ee4f35 | ||
|
|
bfac856eb2 | ||
|
|
42dec95738 | ||
|
|
6eacbf80d8 | ||
|
|
be7e2c2c80 | ||
|
|
e428565d50 | ||
|
|
50cc424679 | ||
|
|
d288da1e18 | ||
|
|
0c1993e264 | ||
|
|
75fd036ec2 | ||
|
|
626a1a2977 | ||
|
|
d1cedbb981 | ||
|
|
0df796f873 | ||
|
|
c10ace8fb5 | ||
|
|
5d3ee50bca | ||
|
|
be2b72eea7 | ||
|
|
1af38456f3 | ||
|
|
0a4c1c24af | ||
|
|
54c44565fb | ||
|
|
b86d8bd836 | ||
|
|
5610afde36 | ||
|
|
d88e12fc6e | ||
|
|
95156eebd1 | ||
|
|
8f0a9a9dd2 | ||
|
|
42189ec1a5 | ||
|
|
f2889fc05c | ||
|
|
6e235924d8 | ||
|
|
15caef10d6 | ||
|
|
21db64b2a5 | ||
|
|
d07bf30737 | ||
|
|
135fbfc4f3 | ||
|
|
03c6a7c54f | ||
|
|
9d3d3ed5f8 | ||
|
|
619a6bd34d | ||
|
|
d9c967b529 | ||
|
|
0e716165e5 | ||
|
|
4154d3081d | ||
|
|
678977a2a0 | ||
|
|
f7548a6154 | ||
|
|
b1cb3f3798 | ||
|
|
611d86f3f7 | ||
|
|
e402be1dd2 | ||
|
|
cc79b2b2a2 | ||
|
|
34d5f97c79 | ||
|
|
16cb3de211 | ||
|
|
788377d174 | ||
|
|
666a03baf9 | ||
|
|
6235dc97a3 | ||
|
|
fd8d61e742 | ||
|
|
cce82c12f0 | ||
|
|
b3a5a52432 | ||
|
|
bf08498b72 | ||
|
|
ea461ad592 | ||
|
|
96870bf934 | ||
|
|
ea52e4df35 | ||
|
|
c4760578a4 | ||
|
|
b5027cd646 | ||
|
|
c79c1d9958 | ||
|
|
b496f075a8 | ||
|
|
eb28fd90e5 | ||
|
|
409e5a335f | ||
|
|
7c13f5d8de | ||
|
|
29118dc0e8 | ||
|
|
0dab6e4ab1 | ||
|
|
6df731af58 | ||
|
|
63417c9179 | ||
|
|
ab2adc48a3 | ||
|
|
2c370ffccd | ||
|
|
575a79145e | ||
|
|
b7b1dfdeb5 | ||
|
|
0f218ced47 | ||
|
|
1ead34d42d | ||
|
|
387cebeb50 | ||
|
|
2e0afefe63 | ||
|
|
65c3a40039 | ||
|
|
c34cb939b7 | ||
|
|
e3abd3d5ef | ||
|
|
fa11cb0619 | ||
|
|
7cbc87d3df | ||
|
|
bc0af68d97 | ||
|
|
5d1c5b51dd | ||
|
|
72bed3b062 | ||
|
|
59931afd62 | ||
|
|
ad30ba9602 | ||
|
|
1b2e7090c3 | ||
|
|
05e8811fe9 | ||
|
|
04ed24feae | ||
|
|
5c8d28bf1d | ||
|
|
78cc33ab01 | ||
|
|
da2260a62e | ||
|
|
287756ea19 | ||
|
|
691f9aade1 | ||
|
|
b255f68a83 | ||
|
|
19467de809 | ||
|
|
f7cd401e7a | ||
|
|
0ca33f7b5b | ||
|
|
a43f0454f9 | ||
|
|
64f3a597a2 | ||
|
|
1e5cef4a62 | ||
|
|
5e1ca44a7f | ||
|
|
0694e71b14 | ||
|
|
b1aa7965d7 | ||
|
|
25cc038dd3 | ||
|
|
65b677238c | ||
|
|
7014ac2335 | ||
|
|
19a86cbd59 | ||
|
|
fc57effd5c | ||
|
|
b6296fcbeb | ||
|
|
0a645431ae | ||
|
|
2d0b4f868e | ||
|
|
23efacad70 | ||
|
|
deae7b28e6 | ||
|
|
dd5d85cc7a | ||
|
|
da8044c073 | ||
|
|
33334fd94c | ||
|
|
047843b19a | ||
|
|
a74811cb7e | ||
|
|
ce530e24e2 | ||
|
|
0ac3352835 | ||
|
|
7d367572dc | ||
|
|
bd4cf980bd | ||
|
|
9b1fc1e000 | ||
|
|
e63096239e | ||
|
|
b53bd5149e | ||
|
|
90db8c15f2 | ||
|
|
1b08f94497 | ||
|
|
9a5844bbdc | ||
|
|
9a87b357fc | ||
|
|
67675e1f79 | ||
|
|
1eda695630 | ||
|
|
9591be6401 | ||
|
|
4089e4e8d1 | ||
|
|
cbb8997d5c | ||
|
|
f8fbef78bf | ||
|
|
4fb77a9a45 | ||
|
|
1a8102f91c | ||
|
|
a8188a3536 | ||
|
|
0e090f940a | ||
|
|
dd9ba701ad | ||
|
|
dbfb886475 | ||
|
|
1a2e336c18 | ||
|
|
e6bc15a9e1 | ||
|
|
6d75a2284e | ||
|
|
3a63375499 | ||
|
|
d6d0c42691 | ||
|
|
b30483572d | ||
|
|
c92fcd20f7 | ||
|
|
ffbb4e0b9e | ||
|
|
c98e95751f | ||
|
|
5e1f891f02 | ||
|
|
2aeccd72e9 | ||
|
|
478d8ce70d | ||
|
|
7087f7a78c | ||
|
|
91c607b0e8 | ||
|
|
9c025e79ca | ||
|
|
faec398cf0 | ||
|
|
dcfcac2cc6 | ||
|
|
769ca5794a | ||
|
|
004768132b | ||
|
|
12c188266a | ||
|
|
22def9b01c | ||
|
|
2d2b7d3a9f | ||
|
|
aa51484b0b | ||
|
|
aa1519c18e | ||
|
|
249a65bb57 | ||
|
|
b5f6fdecbf | ||
|
|
f015b165ee | ||
|
|
4cb6418f83 | ||
|
|
5a0ffee58b | ||
|
|
22091e05ac | ||
|
|
3a1da0eb4a | ||
|
|
1a2c0b7843 | ||
|
|
24c8ae2002 | ||
|
|
51bf4f769f | ||
|
|
eaa5c0fb33 | ||
|
|
d0b4d1da4a | ||
|
|
07978bc3d4 | ||
|
|
11d6b372ba | ||
|
|
04b4c61f83 | ||
|
|
d9483dbd7a | ||
|
|
01588305fc | ||
|
|
4fe3c308fe | ||
|
|
bdf60b2b68 | ||
|
|
c57e9706cd | ||
|
|
367c06dca6 | ||
|
|
e3f6767259 | ||
|
|
b608b66823 | ||
|
|
a1895a66b3 | ||
|
|
2d52dcd867 | ||
|
|
5d986e86de | ||
|
|
25ee1acde9 | ||
|
|
50b9644bd0 | ||
|
|
474b94f9af | ||
|
|
eb18625802 | ||
|
|
d536d63d69 | ||
|
|
efb18c7548 | ||
|
|
5eab47674c | ||
|
|
50ae075b1f | ||
|
|
f062132636 | ||
|
|
5389928c49 | ||
|
|
5086f132f8 | ||
|
|
206edb7613 | ||
|
|
a75262d79e | ||
|
|
dac3a43c1b | ||
|
|
a2dd0baaf6 | ||
|
|
3801f988ba | ||
|
|
34c8ad614a | ||
|
|
19bb8988f8 | ||
|
|
be72c5109a | ||
|
|
d591f7c456 | ||
|
|
5683f639ed | ||
|
|
8f0fc3af57 | ||
|
|
dcd55d9757 | ||
|
|
83df23a902 | ||
|
|
ee23d1695d | ||
|
|
88a36f4378 | ||
|
|
1aad4f2926 | ||
|
|
2a28ccc758 | ||
|
|
4ee5ef336c | ||
|
|
3da76892d5 | ||
|
|
9047cb5998 | ||
|
|
869cff89c6 | ||
|
|
95cd01d1fa | ||
|
|
ad753b9d16 | ||
|
|
c155598c08 | ||
|
|
80dfa0e937 | ||
|
|
5922ff0f40 | ||
|
|
10bca8f665 | ||
|
|
0bc310243f | ||
|
|
8a2925ab0c | ||
|
|
3f750fb8d4 | ||
|
|
38324575e8 | ||
|
|
1a10acf8ce | ||
|
|
799e8d9e23 | ||
|
|
454166dbca | ||
|
|
c9343f56d6 | ||
|
|
b389457092 | ||
|
|
d5f8ce00b7 | ||
|
|
78298d16d7 | ||
|
|
4f8c36df35 | ||
|
|
e65d2a522f | ||
|
|
e357a75b70 | ||
|
|
ff1aae853e | ||
|
|
08833b08a0 | ||
|
|
e8151e8393 | ||
|
|
434963e8a0 | ||
|
|
48a7f8a3ee | ||
|
|
9131521e7d | ||
|
|
c593ded864 | ||
|
|
9dc45d9db3 | ||
|
|
8f060d3d65 | ||
|
|
4d7ae95d4f | ||
|
|
a2e6b265d3 | ||
|
|
ffc9081e1a | ||
|
|
a0c54531c0 | ||
|
|
0dfda66578 | ||
|
|
70eb0abb7e | ||
|
|
91355c0936 | ||
|
|
b690dcaabc | ||
|
|
a5e532d485 | ||
|
|
c150129d74 | ||
|
|
313d8d6a53 | ||
|
|
5a339c382f | ||
|
|
ef16f3bd68 | ||
|
|
c289f821e4 | ||
|
|
51d35ec7d2 | ||
|
|
b36a7a2bcf | ||
|
|
d9320574d8 | ||
|
|
abb2ce5146 | ||
|
|
64a1ba3ac3 | ||
|
|
b6bebad14d | ||
|
|
b30c6a6147 | ||
|
|
a62dfe55cf | ||
|
|
cd2ef20d5c | ||
|
|
7f2e68dae4 | ||
|
|
dc8256489b | ||
|
|
4a2c35c81b | ||
|
|
8094965a2c | ||
|
|
dfb35315e0 | ||
|
|
0aa5dff450 | ||
|
|
d1d4d0e5c4 | ||
|
|
868136bb38 | ||
|
|
9bc1393981 | ||
|
|
af805f15c7 | ||
|
|
f489ec6cee | ||
|
|
56ec8b900c | ||
|
|
2f42dc9898 | ||
|
|
b9d56e8882 | ||
|
|
b0492f52a4 | ||
|
|
a98c7a4b32 | ||
|
|
daeac2f894 | ||
|
|
0f5e4d3a15 | ||
|
|
25c5ca731d | ||
|
|
8435c775ec | ||
|
|
89f93d23b6 | ||
|
|
f97d3f57af | ||
|
|
8e236e6594 | ||
|
|
e1c98ceaa2 | ||
|
|
10e49fd77f | ||
|
|
e27b97f0f4 | ||
|
|
32ee0ae908 | ||
|
|
3573912d8b | ||
|
|
2b333d513c | ||
|
|
bd690951e7 | ||
|
|
034b7fb516 | ||
|
|
b0fb93f7be | ||
|
|
0b4f17c129 | ||
|
|
916042faab | ||
|
|
dd9ff41fde | ||
|
|
0c71d505f2 | ||
|
|
948375f0e9 | ||
|
|
b8f73b9495 | ||
|
|
2debea28c8 | ||
|
|
f4f60a6b2e | ||
|
|
c93334a849 | ||
|
|
31c21137b0 | ||
|
|
aec6d57fc3 | ||
|
|
36663f0aa6 | ||
|
|
6b248fb46d | ||
|
|
c4e656e275 | ||
|
|
1d571c284c | ||
|
|
1a5ee32565 | ||
|
|
feff3c52ef | ||
|
|
90c0ece93f | ||
|
|
45f2b016e1 | ||
|
|
f2377a5f92 | ||
|
|
d1c7105a84 | ||
|
|
d36ae1476b | ||
|
|
19d78ca519 | ||
|
|
676e83a872 | ||
|
|
70fd94dc1f | ||
|
|
70d8a664e5 | ||
|
|
dcfaa9b474 | ||
|
|
9c606a6240 | ||
|
|
586473a4e8 | ||
|
|
0ad8dc8529 | ||
|
|
680c85eee7 | ||
|
|
c3604b5495 | ||
|
|
73bf52dc58 | ||
|
|
c8cde11ff1 | ||
|
|
e89d466827 | ||
|
|
71a6b85b04 | ||
|
|
80d6ec580d | ||
|
|
0847e15d07 | ||
|
|
f25ec08f5e | ||
|
|
a5db46240e | ||
|
|
b7662347c9 | ||
|
|
448d073bf2 | ||
|
|
59c11379b2 | ||
|
|
2979f286cf | ||
|
|
1ca1d0b109 | ||
|
|
7e1ab55c01 | ||
|
|
5878def72c | ||
|
|
0992258222 | ||
|
|
6d027dd7ce | ||
|
|
622636e35f | ||
|
|
3b8dc89b53 | ||
|
|
5896ba9cf5 | ||
|
|
01e745e389 | ||
|
|
9377df9646 | ||
|
|
530cae3cdb | ||
|
|
22f6bf60c0 | ||
|
|
49813c9629 | ||
|
|
add01f547b | ||
|
|
f50bba1d39 | ||
|
|
8d3a1c2daa | ||
|
|
28b9205e95 | ||
|
|
38fa8f58b7 | ||
|
|
0018c865bd | ||
|
|
bf0244f9dc | ||
|
|
9c09c00fab | ||
|
|
6ae05842d9 | ||
|
|
9cd61944d9 | ||
|
|
e7aa862e43 | ||
|
|
5d0547af48 | ||
|
|
a83b2ddeea | ||
|
|
1cd0796428 | ||
|
|
a2e4da840e | ||
|
|
b7bf4ba010 | ||
|
|
8ec61dd603 | ||
|
|
3f16b53159 | ||
|
|
1eef5d40a5 | ||
|
|
0b7aa8a83c | ||
|
|
d8e87bccab | ||
|
|
930f1e4f09 | ||
|
|
a65d54c549 | ||
|
|
35de1d4d9a | ||
|
|
6b049f2c33 | ||
|
|
7ba8a3da84 | ||
|
|
626b4e61cd | ||
|
|
141789b034 | ||
|
|
f19978345e | ||
|
|
be10cc65f4 | ||
|
|
4ca87e730c | ||
|
|
d931ddc731 | ||
|
|
54c3b2ba4a | ||
|
|
6f87fd9c89 | ||
|
|
88e6afadb2 | ||
|
|
40dc476460 | ||
|
|
c2c7448c45 | ||
|
|
3b2061bb5c | ||
|
|
1a756c5fa9 | ||
|
|
004dcebc19 | ||
|
|
6d535441c0 | ||
|
|
119de111e4 | ||
|
|
bba9f2608a | ||
|
|
936921781e | ||
|
|
d051538c6a | ||
|
|
69d4ba18d5 | ||
|
|
738d1f8007 | ||
|
|
6092a7d9ed | ||
|
|
ba7b65a978 | ||
|
|
85c428be25 | ||
|
|
beac979c32 | ||
|
|
62be755fc4 | ||
|
|
cfb5f2f3b5 | ||
|
|
a48f116bcd | ||
|
|
d045f2750b | ||
|
|
686cfb6d2f | ||
|
|
dda8c7fb14 | ||
|
|
5eac69b6df | ||
|
|
144eb558c4 | ||
|
|
a9e1ce42df | ||
|
|
5d3ad78611 | ||
|
|
0add42c53b | ||
|
|
865b25d8df | ||
|
|
a5410178c8 | ||
|
|
b6bea12bca | ||
|
|
fafe40cef0 | ||
|
|
1971d5d6b6 | ||
|
|
dc4cd96fc0 | ||
|
|
cd57cfc861 | ||
|
|
ca6edb4bc4 | ||
|
|
f51a4b4416 | ||
|
|
f888011191 | ||
|
|
d575f554ad | ||
|
|
b1b84c1e50 | ||
|
|
fd8ed4bbdf | ||
|
|
c23aa67b0c | ||
|
|
52ba1b7910 | ||
|
|
0afdaf116e | ||
|
|
8dd8c01aab | ||
|
|
71002ee119 | ||
|
|
5ad9572166 | ||
|
|
9fc753630a | ||
|
|
00bd61e1d3 | ||
|
|
a151a90d2f | ||
|
|
4e9bc955e6 | ||
|
|
b121e46bcc | ||
|
|
43cc9499b1 | ||
|
|
35512bef8d | ||
|
|
755ac276f7 | ||
|
|
1fa882d59e | ||
|
|
23fab65216 | ||
|
|
4cceb008ea | ||
|
|
7995058d86 | ||
|
|
e0c097e0dd | ||
|
|
9d9ead60a6 | ||
|
|
0a8b8cc75a | ||
|
|
c4914c2ced | ||
|
|
a30bb4e6c0 | ||
|
|
7babc82f5d | ||
|
|
7d5ed66db1 | ||
|
|
8c885e87d4 | ||
|
|
4f009e0320 | ||
|
|
13c5e05044 | ||
|
|
2104570889 | ||
|
|
f24d4124fb | ||
|
|
fc5525ec4a | ||
|
|
08b04c3264 | ||
|
|
b3735328b7 | ||
|
|
73d2930230 | ||
|
|
fd46ea82bf | ||
|
|
bd1d9573d6 | ||
|
|
7fe9029a4e | ||
|
|
0b3e8c3fef | ||
|
|
7ad1c15245 | ||
|
|
29dd842404 | ||
|
|
6c4ad94b59 | ||
|
|
8fa65f170d | ||
|
|
9692ae5cdd | ||
|
|
c780962892 | ||
|
|
f0283a3e17 | ||
|
|
0f6aec31fd | ||
|
|
6110d89bb4 | ||
|
|
713c0ef216 | ||
|
|
63404edeec | ||
|
|
9af2d478c8 | ||
|
|
0ea9b47604 | ||
|
|
2a26d532df | ||
|
|
f7d475aa75 | ||
|
|
766dcddd36 | ||
|
|
4727b4bade | ||
|
|
f48460687b | ||
|
|
14bf256ab8 | ||
|
|
3201caf54e | ||
|
|
7c25fd61da | ||
|
|
330ba7cb71 | ||
|
|
3a02df28af | ||
|
|
b66cfa23a8 | ||
|
|
9fd4adecb3 | ||
|
|
7c1e35ae3b | ||
|
|
8c9ac01123 | ||
|
|
c75ea29d26 | ||
|
|
998dcc6f89 | ||
|
|
daa5ebbcff | ||
|
|
454414179b | ||
|
|
ad33890dea | ||
|
|
fe139651f5 | ||
|
|
de3e89ac33 | ||
|
|
13bac763a1 | ||
|
|
ffd8a527f2 | ||
|
|
6b0d89e920 | ||
|
|
13dca97505 | ||
|
|
fa83c2a26d | ||
|
|
ea931da38b | ||
|
|
406a65cfb6 | ||
|
|
dd2c226354 | ||
|
|
3a4982c5e6 | ||
|
|
87c9f881c0 | ||
|
|
d700b5f145 | ||
|
|
1bc2c18167 | ||
|
|
a00eb2736b | ||
|
|
bb1adf41e7 | ||
|
|
772765959b | ||
|
|
2435d12181 | ||
|
|
5b9b717a93 | ||
|
|
64c5f1ce02 | ||
|
|
b2d07d2d44 | ||
|
|
e46636f573 | ||
|
|
4cf4efd3f2 | ||
|
|
48dcf01199 | ||
|
|
64c07cf2d2 | ||
|
|
4e7b0b8650 | ||
|
|
59f0e2d345 | ||
|
|
e7d99e9653 | ||
|
|
1ef4485a26 | ||
|
|
c9a8b91c0b | ||
|
|
0dc950ab95 | ||
|
|
298edb6430 | ||
|
|
558ddec0a1 | ||
|
|
9fca8de52f | ||
|
|
f4ee983807 | ||
|
|
78f28c4bc3 | ||
|
|
cbd8936872 | ||
|
|
60fb61f70a | ||
|
|
c767f39619 | ||
|
|
daf3e269f4 | ||
|
|
d2240532c1 | ||
|
|
e2f60a6761 | ||
|
|
eb1d6a4a78 | ||
|
|
5ab05e4e12 | ||
|
|
5d22023d19 | ||
|
|
0e3e62fee8 | ||
|
|
e437f2125d | ||
|
|
64443d4b1b | ||
|
|
42604cc6be | ||
|
|
9f60d787fe | ||
|
|
0243769a02 | ||
|
|
57e0980134 | ||
|
|
c37b4cadb1 | ||
|
|
350aedd934 | ||
|
|
9c8fd6431e | ||
|
|
50d99634f7 | ||
|
|
b1e8d0aab6 | ||
|
|
86e5048566 | ||
|
|
6421bc82f2 | ||
|
|
2359e44b16 | ||
|
|
7520e3beba | ||
|
|
44cf47b86d | ||
|
|
8dc41257ce | ||
|
|
a71262f538 | ||
|
|
264ce1222a | ||
|
|
82f8bf6bb4 | ||
|
|
54451c9a8c | ||
|
|
7f48416dc3 | ||
|
|
e0ea47b8ee | ||
|
|
a6b1d4059f | ||
|
|
238008010d | ||
|
|
c895bc2681 | ||
|
|
3684ee1e6c | ||
|
|
097a763e6e | ||
|
|
50d39295a5 | ||
|
|
d667b8d455 | ||
|
|
9216936a71 | ||
|
|
18d18fa234 | ||
|
|
67ce4a956f | ||
|
|
ca51d15f86 | ||
|
|
d0dd78c47c | ||
|
|
c0773afb68 | ||
|
|
8200d54c20 | ||
|
|
53bc616b1b | ||
|
|
b9b1265b78 | ||
|
|
927a54e4d9 | ||
|
|
f855173b9b | ||
|
|
f618f12515 | ||
|
|
d902e2b838 | ||
|
|
80f4a98ea7 | ||
|
|
b870cd69de | ||
|
|
0f4b11e455 | ||
|
|
ebb119aa90 | ||
|
|
3e9543f0b6 | ||
|
|
f55cbe11af | ||
|
|
bd6831fd48 | ||
|
|
e60bd52e8b | ||
|
|
ce1a8053c8 | ||
|
|
fad66c7266 | ||
|
|
240394817f | ||
|
|
d037ae2faf | ||
|
|
53d1497ce8 | ||
|
|
b7a8ac8343 | ||
|
|
9a3929554b | ||
|
|
b082d81924 | ||
|
|
927141fb13 | ||
|
|
1a903d3efb | ||
|
|
0631f357b6 | ||
|
|
04242b686e | ||
|
|
b0015dfe5c | ||
|
|
2fc15f3be8 | ||
|
|
8edbd60bbf | ||
|
|
4249270e30 | ||
|
|
ffe8e52095 | ||
|
|
0c13d9f585 | ||
|
|
39549c8ca9 | ||
|
|
54355b61d6 | ||
|
|
ed2f1b4603 | ||
|
|
576400414f | ||
|
|
06453edc7c | ||
|
|
329e9aa034 | ||
|
|
6cba4a8492 | ||
|
|
4f1ebc2545 | ||
|
|
1008ab6222 | ||
|
|
bbf04648f9 | ||
|
|
31ced9e23c | ||
|
|
94d1aeb287 | ||
|
|
06f232fdd8 | ||
|
|
e4342113fa | ||
|
|
578a1b6d19 | ||
|
|
251de2be11 | ||
|
|
786cd85c74 | ||
|
|
7e356ef0a0 | ||
|
|
e48eab403c | ||
|
|
e0c61f7299 | ||
|
|
b24256a83e | ||
|
|
631de8d85f | ||
|
|
c1ebbc556b | ||
|
|
73285ae776 | ||
|
|
2d692c3f20 | ||
|
|
0f4b127275 | ||
|
|
c85b6d53c5 | ||
|
|
8ed1644081 | ||
|
|
1d71e26a12 | ||
|
|
bb2e4a4fb3 | ||
|
|
5b4d394039 | ||
|
|
2aaa392bf5 | ||
|
|
8919cbcdc1 | ||
|
|
dc7231537f | ||
|
|
b31a82d27e | ||
|
|
7fecea9cca | ||
|
|
b0d4c9eac8 | ||
|
|
db41aed44b | ||
|
|
ff370684f1 | ||
|
|
8c21cc56d4 | ||
|
|
49d8b3bacd | ||
|
|
e0d677b01c | ||
|
|
2b2c40f65a | ||
|
|
4e61f1ff36 | ||
|
|
33cf69872b | ||
|
|
48a0a6c7e3 | ||
|
|
7012a480e8 | ||
|
|
88e6305b9a | ||
|
|
b479136688 | ||
|
|
d6a42cdf6b | ||
|
|
aed37afb3e | ||
|
|
a64cb4bbad | ||
|
|
9b974dfbd9 | ||
|
|
61e512b8f7 | ||
|
|
2a6f48d8c8 | ||
|
|
6a52725b63 | ||
|
|
abb078f7ee | ||
|
|
47363b477e | ||
|
|
c0e6b7af58 | ||
|
|
bd822998a5 | ||
|
|
771d21e410 | ||
|
|
3b1d4ffe43 | ||
|
|
78d1ef9b1c | ||
|
|
632da54146 | ||
|
|
b725fc8d26 | ||
|
|
03c6c0567a | ||
|
|
1d106a9851 | ||
|
|
5612a033d5 | ||
|
|
cacd4abd9d | ||
|
|
c01c610bb3 | ||
|
|
82bcac7c74 | ||
|
|
5a50a0d973 | ||
|
|
5d81a13a80 | ||
|
|
0f9941f0d1 | ||
|
|
f7cbe30a16 | ||
|
|
76f261eb38 | ||
|
|
fad73cf3f5 | ||
|
|
6796bb94cc | ||
|
|
54669281de | ||
|
|
d647edcae3 | ||
|
|
ab985afe01 | ||
|
|
f60ef05223 | ||
|
|
f6d6ea5d31 | ||
|
|
3bf7bf5563 | ||
|
|
3c2e27f778 | ||
|
|
99379d53bf | ||
|
|
6be2985b40 | ||
|
|
faa59f59bd | ||
|
|
5147b0dbc4 | ||
|
|
db779578d2 | ||
|
|
76600af698 | ||
|
|
a43a65df2c | ||
|
|
215df5efed | ||
|
|
feda827860 | ||
|
|
33f3b0006b | ||
|
|
a25af16f7c | ||
|
|
00cd3ee9bf | ||
|
|
80f1ee45b5 | ||
|
|
eb90434c28 | ||
|
|
a94403b3a1 | ||
|
|
3f83e517f0 | ||
|
|
82cef095fc | ||
|
|
b6ba3c5781 | ||
|
|
5b521cfc7c | ||
|
|
d7f2f4136c | ||
|
|
5d7e42195f | ||
|
|
7fdf95ad34 | ||
|
|
5287490894 | ||
|
|
b246c6f4c3 | ||
|
|
2daf94f65a | ||
|
|
91ada9ce75 | ||
|
|
4ea1e98547 | ||
|
|
82f9814438 | ||
|
|
b8c82c8f2c | ||
|
|
55966e601a | ||
|
|
886498fe01 | ||
|
|
54eb11a2a7 | ||
|
|
73c9ec20e8 | ||
|
|
c4f125b2d8 | ||
|
|
03dfca3bee | ||
|
|
04d7265a86 | ||
|
|
4335ee8157 | ||
|
|
6ba8b0ec58 | ||
|
|
fc463a9561 | ||
|
|
a80c79bd70 | ||
|
|
3fd8af0c78 | ||
|
|
474f2d2e7d | ||
|
|
b61ef6814b | ||
|
|
f4670156a5 | ||
|
|
2a3111af6d | ||
|
|
573168f647 | ||
|
|
7570603b37 | ||
|
|
887fb13f34 | ||
|
|
1a761e79ad | ||
|
|
8d1624476f | ||
|
|
8f183945c0 | ||
|
|
5d704b0e43 | ||
|
|
0c6ceaefa2 | ||
|
|
a413c6ebb8 | ||
|
|
2e891eb926 | ||
|
|
21b6c6158a | ||
|
|
eaf7da9acb | ||
|
|
63a22082bc | ||
|
|
6e38caee63 | ||
|
|
cfa2690549 | ||
|
|
c097db2c3c | ||
|
|
2a96d3d213 | ||
|
|
01a5748c02 | ||
|
|
61e8e2fb2a | ||
|
|
d7fe92765c | ||
|
|
bc0c3a1eed | ||
|
|
98e5f78d5f | ||
|
|
6714533ed4 | ||
|
|
94a536af28 | ||
|
|
c3780adab2 | ||
|
|
bdb76b3d4b | ||
|
|
80ee890a9f | ||
|
|
b0a232806b | ||
|
|
9c44bf171c | ||
|
|
5d88dfd00b | ||
|
|
fc14dc95df | ||
|
|
51bdbc5d4a | ||
|
|
b2e6573743 | ||
|
|
d66cc8a213 | ||
|
|
a6663d7869 | ||
|
|
b0fe879503 | ||
|
|
f610699ef4 | ||
|
|
d2ece2993e | ||
|
|
a9248a5f13 | ||
|
|
3e2cf5c058 | ||
|
|
48496abbf4 | ||
|
|
88648da890 | ||
|
|
929491ce3d | ||
|
|
512739ae90 | ||
|
|
69930ef698 | ||
|
|
5854931430 | ||
|
|
f57fa1286c | ||
|
|
edfa0d6074 | ||
|
|
d6e9500bee | ||
|
|
f30883a018 | ||
|
|
12eea0e4cc | ||
|
|
16db740986 | ||
|
|
d1ad9efe64 | ||
|
|
1b3faa1203 | ||
|
|
ce9a4024b3 | ||
|
|
16e79a3662 | ||
|
|
a67c0b166c | ||
|
|
fbf15f05ed | ||
|
|
d1e2ead13e | ||
|
|
9bf04b0af4 | ||
|
|
d6ff8753e2 | ||
|
|
3207e2a285 | ||
|
|
a30808a972 | ||
|
|
a49cb2d611 | ||
|
|
7933a51994 | ||
|
|
fbef2157ec | ||
|
|
b2321495d8 | ||
|
|
7e504e783f | ||
|
|
a14565e288 | ||
|
|
9e6dd65e57 | ||
|
|
898fef19cc | ||
|
|
a9469f7e2b | ||
|
|
412a0ee577 | ||
|
|
d8b3c21a6c | ||
|
|
68f288f69c | ||
|
|
3ba37aaa5a | ||
|
|
b69a7a5031 | ||
|
|
2acaa45e0a | ||
|
|
6341839de4 | ||
|
|
bc132e4337 | ||
|
|
46e63ca6cf | ||
|
|
6ae05c2023 | ||
|
|
65cd708295 | ||
|
|
9656ee92b7 | ||
|
|
9ff8487feb | ||
|
|
fbdf6da314 | ||
|
|
46bce30a64 | ||
|
|
b875bc2552 | ||
|
|
37fd1fb76d | ||
|
|
777a1bb4c1 | ||
|
|
b484852a62 | ||
|
|
12e516c366 | ||
|
|
441a5fa2b4 | ||
|
|
a5b502db4a | ||
|
|
0b2b8fc56c | ||
|
|
d3e24914cd | ||
|
|
1438391515 | ||
|
|
8316c37a0e | ||
|
|
44725d9b29 | ||
|
|
d9e85fdcb6 | ||
|
|
886e7e6638 | ||
|
|
479f635754 | ||
|
|
75cd5a15b7 | ||
|
|
0f91778970 | ||
|
|
eb9c6f2231 | ||
|
|
ad9e989598 | ||
|
|
ffe22399d5 | ||
|
|
300b9e4e05 | ||
|
|
89f7f99720 | ||
|
|
b139423eb9 | ||
|
|
40f2b97075 | ||
|
|
8de89a9f26 | ||
|
|
33776b2b09 | ||
|
|
e0d189b70a | ||
|
|
2e210e7aa4 | ||
|
|
f96b7e7004 | ||
|
|
0508ac1a5d | ||
|
|
ae34225f66 | ||
|
|
c509a69f1d | ||
|
|
6635f2ce8f | ||
|
|
37c28940bf | ||
|
|
51b832335b | ||
|
|
0aec1a5d68 | ||
|
|
1244eac03c | ||
|
|
ce940d7c50 | ||
|
|
d0ff377086 | ||
|
|
773e43e0cf | ||
|
|
68f63683f1 | ||
|
|
b5072f823c | ||
|
|
3d75022a6c | ||
|
|
a030fbedb4 | ||
|
|
310bb3cf24 | ||
|
|
fa18796109 | ||
|
|
41bd89521d | ||
|
|
29f36d7f9e | ||
|
|
2dc875bb33 | ||
|
|
e7e907d535 | ||
|
|
a705e44c09 | ||
|
|
72ec4c0d7b | ||
|
|
7f405ffcdd | ||
|
|
dfe50d4355 | ||
|
|
181c13025d | ||
|
|
219694db8b | ||
|
|
7b3f5c378d | ||
|
|
621789b20c | ||
|
|
7bbfc7872b | ||
|
|
9698339488 | ||
|
|
8503fd2fc0 | ||
|
|
c839abb479 | ||
|
|
fd70a51489 | ||
|
|
b1b4bdf575 | ||
|
|
b52a8c58ab | ||
|
|
58b60c1f68 | ||
|
|
f4bb927dbd | ||
|
|
76b6d2a21b | ||
|
|
93aef078cf | ||
|
|
a5f1ba92e4 | ||
|
|
3e1e47c49a | ||
|
|
c7909a1ca8 | ||
|
|
e0cc58bc6e | ||
|
|
ddb0272d60 | ||
|
|
59f84e82b4 | ||
|
|
3dc37dc34d | ||
|
|
88febf6262 | ||
|
|
29b8660602 | ||
|
|
92da3988da | ||
|
|
8b1fa82c0c | ||
|
|
d2f13eced1 | ||
|
|
d373fd1424 | ||
|
|
d5f5fffd7d | ||
|
|
cc8c52e848 | ||
|
|
c41bfe755e | ||
|
|
8c667a76a7 | ||
|
|
57e033a32c | ||
|
|
b776b935a0 | ||
|
|
3633d36b28 | ||
|
|
b4c6c9c86c | ||
|
|
681d9db900 | ||
|
|
0137d29cd2 | ||
|
|
36412bedd1 | ||
|
|
ccdeb108ee | ||
|
|
f16076b401 | ||
|
|
ac611a4518 | ||
|
|
420ff16c2b | ||
|
|
b4bcd84a53 | ||
|
|
63ebace378 | ||
|
|
75f1b52a2a | ||
|
|
d7f0dc4c05 | ||
|
|
09171c749a | ||
|
|
971f402ced | ||
|
|
3768c28b01 | ||
|
|
dbf60dd4d9 | ||
|
|
683190eaa3 | ||
|
|
93e93d44f4 | ||
|
|
0902015264 | ||
|
|
b11fb44461 | ||
|
|
376484a937 | ||
|
|
fc5fd244d0 | ||
|
|
561d75c694 | ||
|
|
82c0987e7b | ||
|
|
c75138ee50 | ||
|
|
d40545bd52 | ||
|
|
4093cc0cbf | ||
|
|
0c71e11cda | ||
|
|
3edd6f23a5 | ||
|
|
28cf0f71eb | ||
|
|
2fd34cbd91 | ||
|
|
d6158060e7 | ||
|
|
e0952a4c1d | ||
|
|
2872d2e299 | ||
|
|
d8a1c0ac1b | ||
|
|
058d15e67f | ||
|
|
16c6f030db | ||
|
|
1be7c953ea | ||
|
|
c6f33310e5 | ||
|
|
68205ddb9d | ||
|
|
dd683094da | ||
|
|
681708ffa0 | ||
|
|
9a29b4adf3 | ||
|
|
ee058deb74 | ||
|
|
2f0c8d94b0 | ||
|
|
6e09a1c96b | ||
|
|
f2983e28a3 | ||
|
|
c6fa8c6172 | ||
|
|
1988971290 | ||
|
|
6ef200ae7f | ||
|
|
908635b3dd | ||
|
|
a4946de028 | ||
|
|
54931cb23e | ||
|
|
3d2ff7ef62 | ||
|
|
89b2e11083 | ||
|
|
5ac9b5674e | ||
|
|
a97675c538 | ||
|
|
40634a0eb8 | ||
|
|
80b3b91a82 | ||
|
|
47ad5fffc8 | ||
|
|
75bb586f38 | ||
|
|
c743381d33 | ||
|
|
2253dd570d | ||
|
|
97046a7dc4 | ||
|
|
475bb11991 | ||
|
|
fd35c8f4ad | ||
|
|
929a4b955c | ||
|
|
ca86364c35 | ||
|
|
c888d59368 | ||
|
|
495cd26219 | ||
|
|
519494668b | ||
|
|
73b2be0a97 | ||
|
|
9fce787105 | ||
|
|
8c5460c319 | ||
|
|
bd796ae8cc | ||
|
|
97c05facdb | ||
|
|
46bed0be29 | ||
|
|
81bc449043 | ||
|
|
eec82e1bf9 | ||
|
|
1c86273059 | ||
|
|
7320fadec9 | ||
|
|
355e89ce55 | ||
|
|
90b9c5fe8a | ||
|
|
a458cf8ee3 | ||
|
|
4b3cc7396c | ||
|
|
e86c5987e3 | ||
|
|
65ce49817c | ||
|
|
38ac38849e | ||
|
|
960c31a3c7 | ||
|
|
38fa8de01f | ||
|
|
a4641b8480 | ||
|
|
f2e703e928 | ||
|
|
0093eab269 | ||
|
|
b8b50b552e | ||
|
|
665d85204b | ||
|
|
291277f299 | ||
|
|
8c94ef4a03 | ||
|
|
ed3aa7f516 | ||
|
|
37f731d21c | ||
|
|
4ccaafcd63 | ||
|
|
c8e090149a | ||
|
|
9c6a28d9b0 | ||
|
|
9000b6b18e | ||
|
|
4009a066e0 | ||
|
|
d2135a374a | ||
|
|
e8a3b104f8 | ||
|
|
5437beedef | ||
|
|
b7709ac3d0 | ||
|
|
a638369a57 | ||
|
|
22bd8c4bf8 | ||
|
|
eb71276948 | ||
|
|
18030ba33e | ||
|
|
2129146838 | ||
|
|
b6a113f05c | ||
|
|
bd89bf885d | ||
|
|
7d3c105b29 | ||
|
|
a99b52a735 | ||
|
|
e58b71fd4f | ||
|
|
59d10fd3c6 | ||
|
|
30e8b134bc | ||
|
|
cdf0fe0335 | ||
|
|
f4667c0892 | ||
|
|
ac257b4165 | ||
|
|
75ab2bc920 | ||
|
|
307c30e33b | ||
|
|
c7835b1326 | ||
|
|
7e389c8863 | ||
|
|
d697c0c45e | ||
|
|
4fa4088694 | ||
|
|
07cb4a49bc | ||
|
|
067ee168dd | ||
|
|
d8a90cce47 | ||
|
|
e303a4c374 | ||
|
|
c75f485e54 | ||
|
|
f3f0c688d8 | ||
|
|
030020ea9e | ||
|
|
654e7589a6 | ||
|
|
de8d3d7e3e | ||
|
|
d2bae2fa8b | ||
|
|
057b1c7569 | ||
|
|
682682c74a | ||
|
|
b5daf205a0 | ||
|
|
1037fbe52b | ||
|
|
051d151fb7 | ||
|
|
9b6bbaf51c | ||
|
|
735a069b6d | ||
|
|
7ba7440bb6 | ||
|
|
dafbd5cc43 | ||
|
|
81f20e53ea | ||
|
|
05a2c05a9e | ||
|
|
c6d56f06b2 | ||
|
|
73c4a934ce | ||
|
|
1f2254681a | ||
|
|
850060cc07 | ||
|
|
89c4023ddf | ||
|
|
17a6b7fefd | ||
|
|
71b304cafd | ||
|
|
a238420d7f | ||
|
|
faafa6db3b | ||
|
|
1f6008f269 | ||
|
|
d32579dfb5 | ||
|
|
cea7ce140f | ||
|
|
3ecce35b11 | ||
|
|
a8fd04e2f0 | ||
|
|
f2e42b17a7 | ||
|
|
f46543b264 | ||
|
|
d8a39f2e49 | ||
|
|
24631116c4 | ||
|
|
3152effba5 | ||
|
|
4d83478e7d | ||
|
|
da9834d272 | ||
|
|
4bb23cf0c8 | ||
|
|
f0ad38dec6 | ||
|
|
b87308e14f | ||
|
|
473cb55dc4 | ||
|
|
11a7babbc4 | ||
|
|
9ebc8813e3 | ||
|
|
ac10aafc07 | ||
|
|
8604869e5e | ||
|
|
1ed4c93b94 | ||
|
|
caadae3f98 | ||
|
|
67eab93129 | ||
|
|
31d280f729 | ||
|
|
d23e5d0e87 | ||
|
|
774215a073 | ||
|
|
07821769cd | ||
|
|
b0b73e6c70 | ||
|
|
7e1c2f4f40 | ||
|
|
65c731e1ad | ||
|
|
795bb6a7a6 | ||
|
|
f5325b3a6d | ||
|
|
8d622e3606 | ||
|
|
3e6af8e87b | ||
|
|
948f905a66 | ||
|
|
93b398a54f | ||
|
|
44b1daeed2 | ||
|
|
bed266abac | ||
|
|
8487548271 | ||
|
|
c5e8a83ebb | ||
|
|
b6f81bc83a | ||
|
|
294c278c42 | ||
|
|
ff88a65936 | ||
|
|
76c7508224 | ||
|
|
49723c4d1b | ||
|
|
3c02131133 | ||
|
|
c89fd8c39f | ||
|
|
69623d71b2 | ||
|
|
2d6f4d3bdb | ||
|
|
97ff1e37aa | ||
|
|
d0381e7850 | ||
|
|
c4638f2ac5 | ||
|
|
83c383b1ec | ||
|
|
4f22864080 | ||
|
|
92780daa78 | ||
|
|
a5e4b09e64 | ||
|
|
1501dd23ab | ||
|
|
15ab134fa4 | ||
|
|
05f0ba72e2 | ||
|
|
15fcbf18bf | ||
|
|
c9c5e8008c | ||
|
|
bdd3af6b6a | ||
|
|
6582c8b36f | ||
|
|
be838ec313 | ||
|
|
92eef3a6c1 | ||
|
|
63939981c1 | ||
|
|
fc629d7109 | ||
|
|
c9f788e3a4 | ||
|
|
0a866b468a | ||
|
|
8f0f755014 | ||
|
|
6a802d7e80 | ||
|
|
5943b88ffe | ||
|
|
7bc3ece5b5 | ||
|
|
c5dfe92e60 | ||
|
|
753cf3cbac | ||
|
|
1b9100a7f7 | ||
|
|
af27d68add | ||
|
|
07610c7ed0 | ||
|
|
6bb1b8a64c | ||
|
|
90dd78c2ec | ||
|
|
dc258da6c0 | ||
|
|
2c222c36d2 | ||
|
|
3c92919359 | ||
|
|
f36fa62569 | ||
|
|
451e36e288 | ||
|
|
352f0817d9 | ||
|
|
4a64984d7f | ||
|
|
eeb84e5d42 | ||
|
|
50cd8c80d8 | ||
|
|
567d4aebbc | ||
|
|
b53cc4c37c | ||
|
|
d46e1abd07 | ||
|
|
0b0c0b458f | ||
|
|
997b5a1c9d | ||
|
|
4345661a0b | ||
|
|
5867c79a1f | ||
|
|
41f0619572 | ||
|
|
96b4ea6b50 | ||
|
|
8dda51a92a | ||
|
|
b4bb54212c | ||
|
|
d57193c269 | ||
|
|
4bc03c2d60 | ||
|
|
bf969ec8f7 | ||
|
|
6ead907e08 | ||
|
|
243168a450 | ||
|
|
06d2cf1b88 | ||
|
|
d622162f33 | ||
|
|
12807583c2 | ||
|
|
47f3d539c3 | ||
|
|
993cd64126 | ||
|
|
a0e32ab0bd | ||
|
|
409763b885 | ||
|
|
e06819fc6f | ||
|
|
812783f2cd | ||
|
|
849373a6d3 | ||
|
|
0417d3b70d | ||
|
|
a55eb653f9 | ||
|
|
6b05ed764e | ||
|
|
766eeb2a6f | ||
|
|
753e6bd956 | ||
|
|
5e6295d79a | ||
|
|
3b611275d2 | ||
|
|
cb4dac3506 | ||
|
|
9a7dc3fa49 | ||
|
|
e1c833872e | ||
|
|
5de559f5ad | ||
|
|
bc068a0d9a | ||
|
|
13ccc96e03 | ||
|
|
bc56878039 | ||
|
|
8cb2149fbd | ||
|
|
7d7c331238 | ||
|
|
a8d58d14ff | ||
|
|
4723a9d41b | ||
|
|
4ee9a42f3f | ||
|
|
aae2dec16d | ||
|
|
43e0fe655e | ||
|
|
958d77ed6c | ||
|
|
ad32ba8a98 | ||
|
|
d7847c7630 | ||
|
|
284e6e5720 | ||
|
|
4223bb2047 | ||
|
|
0fa18be43e | ||
|
|
43f2faa077 | ||
|
|
f9dfbf94ef | ||
|
|
3424bb9d6a | ||
|
|
6288bcde51 | ||
|
|
80476a2b61 | ||
|
|
78ee671051 | ||
|
|
386a2de117 | ||
|
|
70b2731b82 | ||
|
|
ae3f8b8bd5 | ||
|
|
2d888d7c13 | ||
|
|
98db8b1b25 | ||
|
|
f710ffb7d0 | ||
|
|
b702782c27 | ||
|
|
5dc7fe6a72 | ||
|
|
39e8f75e07 | ||
|
|
f4e0b1125c | ||
|
|
663bd118a5 | ||
|
|
2a8e2d2d25 | ||
|
|
83a08b0f74 | ||
|
|
0887428066 | ||
|
|
1c018d18bd | ||
|
|
8040abaec4 | ||
|
|
a2e2b36a76 | ||
|
|
f17998cfce | ||
|
|
421375ba62 | ||
|
|
db1289f985 | ||
|
|
f5271bc7b4 | ||
|
|
1117324a7b | ||
|
|
57276b7abd | ||
|
|
b0c334fbe3 | ||
|
|
7a8fa6e056 | ||
|
|
72c4ea0065 | ||
|
|
1805b74edf | ||
|
|
327b2aa070 | ||
|
|
fdc3e7a95f | ||
|
|
9ed7b2aad3 | ||
|
|
42188dcef5 | ||
|
|
2b2f4f05b6 | ||
|
|
f5dab6f215 | ||
|
|
2911fbe5e4 | ||
|
|
92d7519d8d | ||
|
|
e94938d5dd | ||
|
|
950d874b9b | ||
|
|
81c68620a1 | ||
|
|
9d832f9bfc | ||
|
|
460ff68a52 | ||
|
|
96007509b5 | ||
|
|
bb74a25adc | ||
|
|
c36cad4619 | ||
|
|
989c74699f | ||
|
|
fa92ba4478 | ||
|
|
e8829e21e7 | ||
|
|
d61e599de0 | ||
|
|
4a6f3e189d | ||
|
|
7154d755c1 | ||
|
|
307ee50798 | ||
|
|
e8175a90f4 | ||
|
|
bbc9df486e | ||
|
|
c7014fc6c8 | ||
|
|
c22aee5e60 | ||
|
|
feeb93b298 | ||
|
|
a74dace594 | ||
|
|
e0e2a2a626 | ||
|
|
7424c65430 | ||
|
|
c17cf308d4 | ||
|
|
bb3f7fe61f | ||
|
|
f7fcc82933 | ||
|
|
351d9864fe | ||
|
|
8148e9a36d | ||
|
|
34195f0e45 | ||
|
|
9b529d075a | ||
|
|
38a98f70d9 | ||
|
|
544ab890b0 | ||
|
|
d0ad4742c1 | ||
|
|
ee5f7600dc | ||
|
|
5b400daf3b | ||
|
|
58882136a0 | ||
|
|
1ebf089d37 | ||
|
|
1499eb3ba8 | ||
|
|
67adf4c310 | ||
|
|
cd4ced8d6d | ||
|
|
acfdc8163b | ||
|
|
df73548f7e | ||
|
|
f4389e11ba | ||
|
|
f8c6a7c77b | ||
|
|
9c65ff3e12 | ||
|
|
79a90aeec2 | ||
|
|
8e8609371f | ||
|
|
174976ce82 | ||
|
|
57adce693a | ||
|
|
d9576bb2e4 | ||
|
|
97686e1c87 | ||
|
|
35db3ffbf0 | ||
|
|
0a747b5609 | ||
|
|
e947f3259e | ||
|
|
a16eb6e804 | ||
|
|
54334a1854 | ||
|
|
a34eb9ba88 | ||
|
|
f218efbeb2 | ||
|
|
570d8cfc74 | ||
|
|
d964ccca12 | ||
|
|
746e205d74 | ||
|
|
ad29462a6d | ||
|
|
d6cd17e4c8 | ||
|
|
1383c20703 | ||
|
|
5fc91fe466 | ||
|
|
77d9cba468 | ||
|
|
32cd93b689 | ||
|
|
083634826e | ||
|
|
29557ddd86 | ||
|
|
f80b97af53 | ||
|
|
543c1cee62 | ||
|
|
3c2171efb3 | ||
|
|
a7b28ca8e8 | ||
|
|
383e3e55a5 | ||
|
|
8660dc3eba | ||
|
|
00bb09faaa | ||
|
|
f13c8b36cd | ||
|
|
e4ac09ea57 | ||
|
|
72492c9b39 | ||
|
|
39b74a42e5 | ||
|
|
cdd2d9089b | ||
|
|
e086daeecb | ||
|
|
7f5ddaf930 | ||
|
|
0f39b3b7d0 | ||
|
|
bae43249b2 | ||
|
|
7f01bda06d | ||
|
|
2c73153db0 | ||
|
|
7e22fe1617 | ||
|
|
415ec685e6 | ||
|
|
35f8e06ec4 | ||
|
|
431c8c35b9 | ||
|
|
57c30da40f | ||
|
|
9ae2cb79c6 | ||
|
|
1b2fbfd506 | ||
|
|
db73b0f671 | ||
|
|
044e786379 | ||
|
|
7262f61ca0 | ||
|
|
19ae01defb | ||
|
|
1c40a22416 | ||
|
|
ceb0050ea9 | ||
|
|
b97ea010cb | ||
|
|
aad94a1af3 | ||
|
|
c738816cb6 | ||
|
|
f5f53c93f7 | ||
|
|
07415d3871 | ||
|
|
1d2841b2a6 | ||
|
|
4ea2a8244a | ||
|
|
ab0c55b0fa | ||
|
|
d6f594b95a | ||
|
|
a8de738e9b | ||
|
|
61c309cd1d | ||
|
|
5b9d6a31e5 | ||
|
|
8fa0539b5a | ||
|
|
7262485f3b | ||
|
|
24300eeac5 | ||
|
|
2be6c19ba2 | ||
|
|
001bd1d442 | ||
|
|
f30d26b83c | ||
|
|
198c52fd5f | ||
|
|
c5e0b0fc74 | ||
|
|
ecefddf2c3 | ||
|
|
4c8eaac144 | ||
|
|
9991d43b3b | ||
|
|
98b9f007c6 | ||
|
|
53812c3751 | ||
|
|
3e01207026 | ||
|
|
68f72e3074 | ||
|
|
03ade97c0e | ||
|
|
411e9c2e89 | ||
|
|
93dab57959 | ||
|
|
625dba943b | ||
|
|
b272d1e27e | ||
|
|
960513c370 | ||
|
|
61afa92d05 | ||
|
|
fa0373c181 | ||
|
|
5373078a30 | ||
|
|
17f0222e47 | ||
|
|
1479039fb5 | ||
|
|
da28e7b80e | ||
|
|
f2780e65cd | ||
|
|
f9a1f10b99 | ||
|
|
816263bd54 | ||
|
|
eb169cf58b | ||
|
|
2d6fcd6d67 | ||
|
|
9d1883f925 | ||
|
|
85e544dc8e | ||
|
|
aa86885e6b | ||
|
|
72a603ac37 | ||
|
|
a5d9d5e575 | ||
|
|
95d645255d | ||
|
|
6c0fe3d7a1 | ||
|
|
071d3e65a3 | ||
|
|
95323ac4cb | ||
|
|
f7b60f3da7 | ||
|
|
806a30c3d8 | ||
|
|
74570676b5 | ||
|
|
903471ee43 | ||
|
|
9a54a94dca | ||
|
|
532b7a430c | ||
|
|
7e08058d0a | ||
|
|
47d84d4ab6 | ||
|
|
288bddcaf2 | ||
|
|
2d529bd581 | ||
|
|
300f5375c8 | ||
|
|
7f4c6352ba | ||
|
|
225decd286 | ||
|
|
539d41f19e | ||
|
|
ca92834493 | ||
|
|
d28fe93360 | ||
|
|
6ff69c0fa8 | ||
|
|
0409c9a9c0 | ||
|
|
c5bcf76353 | ||
|
|
20a4b9fb5a | ||
|
|
9d20637dcb | ||
|
|
da4efdb2d0 | ||
|
|
9fe4cc2d2b | ||
|
|
b82abc2827 | ||
|
|
8de1fb5289 | ||
|
|
0031fbf886 | ||
|
|
e24efe7528 | ||
|
|
109de73691 | ||
|
|
dc475462d0 | ||
|
|
19883c03ad | ||
|
|
46ae76081d | ||
|
|
63a931a10a | ||
|
|
a15da76566 | ||
|
|
465053f3ff | ||
|
|
bf99407816 | ||
|
|
0493d57b2e | ||
|
|
947864cff8 | ||
|
|
1074d23a90 | ||
|
|
24ffc97317 | ||
|
|
1f6b59f2f5 | ||
|
|
5314eb2d45 | ||
|
|
035b29e70b | ||
|
|
68ec7bce12 | ||
|
|
02b34c9811 | ||
|
|
99fcca3cb7 | ||
|
|
fc2ff27928 | ||
|
|
abaaef2285 | ||
|
|
8154a4dd77 | ||
|
|
6bbea198e5 | ||
|
|
2d569b8bcf | ||
|
|
da3272df76 | ||
|
|
3243b1a3cb | ||
|
|
2492bc69ff | ||
|
|
feb1c051e1 | ||
|
|
6fb57cb1a9 | ||
|
|
b9f0e63341 | ||
|
|
ca41edea22 | ||
|
|
e1611d1e18 | ||
|
|
752156281f | ||
|
|
0073322758 | ||
|
|
ca3ca4557e | ||
|
|
056a9980d6 |
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
||||
backend/src/api/database-migration.ts @wiz @softsimon
|
||||
12
.github/FUNDING.yml
vendored
12
.github/FUNDING.yml
vendored
@@ -1,12 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: ['mempool'] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
otechie: # Replace with a single Otechie username
|
||||
custom: ['https://mempool.space/sponsor'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
63
.github/dependabot.yml
vendored
63
.github/dependabot.yml
vendored
@@ -1,20 +1,47 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: "/backend"
|
||||
schedule:
|
||||
interval: daily
|
||||
open-pull-requests-limit: 10
|
||||
- package-ecosystem: npm
|
||||
directory: "/frontend"
|
||||
schedule:
|
||||
interval: daily
|
||||
open-pull-requests-limit: 10
|
||||
- package-ecosystem: docker
|
||||
directory: "/docker/backend"
|
||||
schedule:
|
||||
interval: weekly
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: npm
|
||||
directory: "/backend"
|
||||
schedule:
|
||||
interval: daily
|
||||
open-pull-requests-limit: 10
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
allow:
|
||||
- dependency-type: "production"
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: "/frontend"
|
||||
schedule:
|
||||
interval: daily
|
||||
open-pull-requests-limit: 10
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
allow:
|
||||
- dependency-type: "production"
|
||||
|
||||
- package-ecosystem: docker
|
||||
directory: "/docker/backend"
|
||||
schedule:
|
||||
interval: weekly
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
|
||||
- package-ecosystem: docker
|
||||
directory: "/docker/frontend"
|
||||
schedule:
|
||||
interval: weekly
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
|
||||
96
.github/workflows/ci.yml
vendored
Normal file
96
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,96 @@
|
||||
name: CI Pipeline for the Backend and Frontend
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, review_requested, synchronize]
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')"
|
||||
strategy:
|
||||
matrix:
|
||||
node: ["16.16.0", "18.14.1"]
|
||||
flavor: ["dev", "prod"]
|
||||
fail-fast: false
|
||||
runs-on: "ubuntu-latest"
|
||||
|
||||
name: Backend (${{ matrix.flavor }}) - node ${{ matrix.node }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
path: ${{ matrix.node }}/${{ matrix.flavor }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Install
|
||||
if: ${{ matrix.flavor == 'dev'}}
|
||||
run: npm ci
|
||||
working-directory: ${{ matrix.node }}/${{ matrix.flavor }}/backend
|
||||
|
||||
- name: Install (Prod dependencies only)
|
||||
if: ${{ matrix.flavor == 'prod'}}
|
||||
run: npm ci --omit=dev --omit=optional
|
||||
working-directory: ${{ matrix.node }}/${{ matrix.flavor }}/backend
|
||||
|
||||
- name: Lint
|
||||
if: ${{ matrix.flavor == 'dev'}}
|
||||
run: npm run lint
|
||||
working-directory: ${{ matrix.node }}/${{ matrix.flavor }}/backend
|
||||
|
||||
- name: Unit Tests
|
||||
if: ${{ matrix.flavor == 'dev'}}
|
||||
run: npm run test
|
||||
working-directory: ${{ matrix.node }}/${{ matrix.flavor }}/backend
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
working-directory: ${{ matrix.node }}/${{ matrix.flavor }}/backend
|
||||
|
||||
frontend:
|
||||
if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')"
|
||||
strategy:
|
||||
matrix:
|
||||
node: ["16.16.0", "18.14.1"]
|
||||
flavor: ["dev", "prod"]
|
||||
fail-fast: false
|
||||
runs-on: "ubuntu-latest"
|
||||
|
||||
name: Frontend (${{ matrix.flavor }}) - node ${{ matrix.node }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
path: ${{ matrix.node }}/${{ matrix.flavor }}
|
||||
|
||||
- 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
|
||||
if: ${{ matrix.flavor == 'prod'}}
|
||||
working-directory: ${{ matrix.node }}/${{ matrix.flavor }}/frontend
|
||||
|
||||
- name: Install
|
||||
if: ${{ matrix.flavor == 'dev'}}
|
||||
run: npm ci
|
||||
working-directory: ${{ matrix.node }}/${{ matrix.flavor }}/frontend
|
||||
|
||||
- name: Lint
|
||||
if: ${{ matrix.flavor == 'dev'}}
|
||||
run: npm run lint
|
||||
working-directory: ${{ matrix.node }}/${{ matrix.flavor }}/frontend
|
||||
|
||||
# - name: Test
|
||||
# run: npm run test
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
working-directory: ${{ matrix.node }}/${{ matrix.flavor }}/frontend
|
||||
107
.github/workflows/cypress.yml
vendored
107
.github/workflows/cypress.yml
vendored
@@ -1,89 +1,62 @@
|
||||
name: Cypress Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
|
||||
jobs:
|
||||
cypress:
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: "!contains(github.event.pull_request.labels.*.name, 'ops') && !contains(github.head_ref, 'ops/')"
|
||||
runs-on: "ubuntu-latest"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
containers: [1, 2, 3, 4, 5]
|
||||
os: ["ubuntu-latest"]
|
||||
browser: [chrome]
|
||||
name: E2E tests on ${{ matrix.browser }} - ${{ matrix.os }}
|
||||
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@v2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
path: ${{ matrix.module }}
|
||||
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16.15.0
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: ${{ matrix.browser }} browser tests (Mempool)
|
||||
uses: cypress-io/github-action@v4
|
||||
with:
|
||||
tag: ${{ github.event_name }}
|
||||
working-directory: frontend
|
||||
build: npm run config:defaults:mempool
|
||||
start: npm run start:local-staging
|
||||
wait-on: 'http://localhost:4200'
|
||||
wait-on-timeout: 120
|
||||
record: true
|
||||
parallel: true
|
||||
spec: |
|
||||
cypress/e2e/mainnet/*.spec.ts
|
||||
cypress/e2e/signet/*.spec.ts
|
||||
cypress/e2e/testnet/*.spec.ts
|
||||
group: Tests on ${{ matrix.browser }} (Mempool)
|
||||
browser: ${{ matrix.browser }}
|
||||
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 }}
|
||||
cache: "npm"
|
||||
cache-dependency-path: ${{ matrix.module }}/frontend/package-lock.json
|
||||
|
||||
- name: ${{ matrix.browser }} browser tests (Liquid)
|
||||
uses: cypress-io/github-action@v4
|
||||
if: always()
|
||||
- name: Chrome browser tests (${{ matrix.module }})
|
||||
uses: cypress-io/github-action@v5
|
||||
with:
|
||||
tag: ${{ github.event_name }}
|
||||
working-directory: frontend
|
||||
build: npm run config:defaults:liquid
|
||||
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: "http://localhost:4200"
|
||||
wait-on-timeout: 120
|
||||
record: true
|
||||
parallel: true
|
||||
spec: |
|
||||
cypress/e2e/liquid/liquid.spec.ts
|
||||
cypress/e2e/liquidtestnet/liquidtestnet.spec.ts
|
||||
group: Tests on ${{ matrix.browser }} (Liquid)
|
||||
browser: ${{ matrix.browser }}
|
||||
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 }}
|
||||
|
||||
- name: ${{ matrix.browser }} browser tests (Bisq)
|
||||
uses: cypress-io/github-action@v4
|
||||
if: always()
|
||||
with:
|
||||
tag: ${{ github.event_name }}
|
||||
working-directory: frontend
|
||||
build: npm run config:defaults:bisq
|
||||
start: npm run start:local-staging
|
||||
wait-on: 'http://localhost:4200'
|
||||
wait-on-timeout: 120
|
||||
record: true
|
||||
parallel: true
|
||||
spec: cypress/e2e/bisq/bisq.spec.ts
|
||||
group: Tests on ${{ matrix.browser }} (Bisq)
|
||||
browser: ${{ matrix.browser }}
|
||||
ci-build-id: '${{ github.sha }}-${{ github.workflow }}-${{ github.event_name }}'
|
||||
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 }}
|
||||
|
||||
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 }}
|
||||
44
.github/workflows/on-tag.yml
vendored
44
.github/workflows/on-tag.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Docker build on tag
|
||||
env:
|
||||
DOCKER_CLI_EXPERIMENTAL: enabled
|
||||
TAG_FMT: '^refs/tags/(((.?[0-9]+){3,4}))$'
|
||||
TAG_FMT: "^refs/tags/(((.?[0-9]+){3,4}))$"
|
||||
DOCKER_BUILDKIT: 0
|
||||
COMPOSE_DOCKER_CLI_BUILD: 0
|
||||
|
||||
@@ -21,16 +21,46 @@ jobs:
|
||||
service:
|
||||
- frontend
|
||||
- backend
|
||||
runs-on: ubuntu-18.04
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
name: Build and push to DockerHub
|
||||
steps:
|
||||
# Workaround based on JonasAlfredsson/docker-on-tmpfs@v1.0.1
|
||||
- name: Replace the current swap file
|
||||
shell: bash
|
||||
run: |
|
||||
sudo swapoff /mnt/swapfile
|
||||
sudo rm -v /mnt/swapfile
|
||||
sudo fallocate -l 13G /mnt/swapfile
|
||||
sudo chmod 600 /mnt/swapfile
|
||||
sudo mkswap /mnt/swapfile
|
||||
sudo swapon /mnt/swapfile
|
||||
|
||||
- name: Show current memory and swap status
|
||||
shell: bash
|
||||
run: |
|
||||
sudo free -h
|
||||
echo
|
||||
sudo swapon --show
|
||||
|
||||
- name: Mount a tmpfs over /var/lib/docker
|
||||
shell: bash
|
||||
run: |
|
||||
if [ ! -d "/var/lib/docker" ]; then
|
||||
echo "Directory '/var/lib/docker' not found"
|
||||
exit 1
|
||||
fi
|
||||
sudo mount -t tmpfs -o size=10G tmpfs /var/lib/docker
|
||||
sudo systemctl restart docker
|
||||
sudo df -h | grep docker
|
||||
|
||||
- name: Set env variables
|
||||
run: echo "TAG=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV
|
||||
|
||||
- name: Show set environment variables
|
||||
run: |
|
||||
printf " TAG: %s\n" "$TAG"
|
||||
|
||||
|
||||
- name: Add SHORT_SHA env property with commit short sha
|
||||
run: echo "SHORT_SHA=`echo ${GITHUB_SHA} | cut -c1-8`" >> $GITHUB_ENV
|
||||
|
||||
@@ -38,24 +68,24 @@ jobs:
|
||||
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Checkout project
|
||||
uses: actions/checkout@629c2de402a417ea7690ca6ce3f33229e27606a5 # v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Init repo for Dockerization
|
||||
run: docker/init.sh "$TAG"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@27d0a4f181a40b142cce983c5393082c365d1480 # v1
|
||||
uses: docker/setup-qemu-action@v2
|
||||
id: qemu
|
||||
|
||||
- name: Setup Docker buildx action
|
||||
uses: docker/setup-buildx-action@94ab11c41e45d028884a99163086648e898eed25 # v1
|
||||
uses: docker/setup-buildx-action@v2
|
||||
id: buildx
|
||||
|
||||
- name: Available platforms
|
||||
run: echo ${{ steps.buildx.outputs.platforms }}
|
||||
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@661fd3eb7f2f20d8c7c84bc2b0509efd7a826628 # v2
|
||||
uses: actions/cache@v3
|
||||
id: cache
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,3 +2,6 @@ sitemap
|
||||
data
|
||||
docker-compose.yml
|
||||
backend/mempool-config.json
|
||||
*.swp
|
||||
frontend/src/resources/config.template.js
|
||||
frontend/src/resources/config.js
|
||||
|
||||
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"editor.tabSize": 2,
|
||||
"typescript.preferences.importModuleSpecifier": "relative",
|
||||
"typescript.tsdk": "./backend/node_modules/typescript/lib"
|
||||
}
|
||||
@@ -6,6 +6,8 @@ In order to clarify the intellectual property license granted with Contributions
|
||||
|
||||
When submitting a pull request for the first time, please create a file with a name like `/contributors/{github_username}.txt`, and in the content of that file indicate your agreement to the Contributor License Agreement terms below. An example of what that file should contain can be seen in wiz's agreement file. (This method of CLA "signing" is borrowed from Medium's open source project.)
|
||||
|
||||
Also, please GPG-sign all your commits (`git config commit.gpgsign true`).
|
||||
|
||||
# Contributor License Agreement
|
||||
|
||||
Last Updated: January 25, 2022
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -1,5 +1,5 @@
|
||||
The Mempool Open Source Project
|
||||
Copyright (c) 2019-2022 The Mempool Open Source Project Developers
|
||||
Copyright (c) 2019-2023 The Mempool Open Source Project Developers
|
||||
|
||||
This program is free software; you can redistribute it and/or modify it under
|
||||
the terms of (at your option) either:
|
||||
|
||||
10
README.md
10
README.md
@@ -1,11 +1,13 @@
|
||||
# The Mempool Open Source Project™ [](https://dashboard.cypress.io/projects/ry4br7/runs)
|
||||
|
||||
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.
|
||||
@@ -29,5 +31,5 @@ Mempool can be conveniently installed on the following full-node distros:
|
||||
Mempool can be installed in other ways too, but we only recommend doing so if you're a developer, have experience managing servers, or otherwise know what you're doing.
|
||||
|
||||
- See the [`docker/`](./docker/) directory for instructions on deploying Mempool with Docker.
|
||||
- See the [`backend/`](./backend/) and [`frontend/`](./frontend/) directories for manual install instructions oriented for developers and small-scale deployments.
|
||||
- See the [`production/`](./production/) directory for guidance on setting up a more serious Mempool instance designed for high performance at scale.
|
||||
- See the [`backend/`](./backend/) and [`frontend/`](./frontend/) directories for manual install instructions oriented for developers.
|
||||
- See the [`production/`](./production/) directory for guidance on setting up a more serious Mempool instance designed for high performance at scale.
|
||||
|
||||
17
backend/.editorconfig
Normal file
17
backend/.editorconfig
Normal file
@@ -0,0 +1,17 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
2
backend/.eslintignore
Normal file
2
backend/.eslintignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
37
backend/.eslintrc
Normal file
37
backend/.eslintrc
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/ban-ts-comment": 1,
|
||||
"@typescript-eslint/ban-types": 1,
|
||||
"@typescript-eslint/no-empty-function": 1,
|
||||
"@typescript-eslint/no-explicit-any": 1,
|
||||
"@typescript-eslint/no-inferrable-types": 0,
|
||||
"@typescript-eslint/no-namespace": 1,
|
||||
"@typescript-eslint/no-this-alias": 1,
|
||||
"@typescript-eslint/no-var-requires": 1,
|
||||
"@typescript-eslint/explicit-function-return-type": 1,
|
||||
"no-console": 1,
|
||||
"no-constant-condition": 1,
|
||||
"no-dupe-else-if": 1,
|
||||
"no-empty": 1,
|
||||
"no-prototype-builtins": 1,
|
||||
"no-self-assign": 1,
|
||||
"no-useless-catch": 1,
|
||||
"no-var": 1,
|
||||
"prefer-const": 1,
|
||||
"prefer-rest-params": 1,
|
||||
"quotes": [1, "single", { "allowTemplateLiterals": true }],
|
||||
"semi": 1,
|
||||
"eqeqeq": 1
|
||||
}
|
||||
}
|
||||
5
backend/.gitignore
vendored
5
backend/.gitignore
vendored
@@ -1,9 +1,10 @@
|
||||
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
||||
|
||||
# production config and external assets
|
||||
*.json
|
||||
!mempool-config.template.json
|
||||
!mempool-config.sample.json
|
||||
|
||||
mempool-config.json
|
||||
pools.json
|
||||
icons.json
|
||||
|
||||
# compiled output
|
||||
|
||||
2
backend/.prettierignore
Normal file
2
backend/.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
package-lock.json
|
||||
6
backend/.prettierrc
Normal file
6
backend/.prettierrc
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"endOfLine": "lf",
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# Mempool Backend
|
||||
|
||||
These instructions are mostly intended for developers, but can be used as a basis for personal or small-scale production setups.
|
||||
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.
|
||||
|
||||
@@ -77,13 +77,13 @@ Query OK, 0 rows affected (0.00 sec)
|
||||
|
||||
#### Build
|
||||
|
||||
_Make sure to use Node.js 16.15 and npm 7._
|
||||
_Make sure to use Node.js 16.10 and npm 7._
|
||||
|
||||
Install dependencies with `npm` and build the backend:
|
||||
|
||||
```
|
||||
cd backend
|
||||
npm install # add --prod for production
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
@@ -110,6 +110,11 @@ Run the Mempool backend:
|
||||
|
||||
```
|
||||
npm run start
|
||||
|
||||
```
|
||||
You can also set env var `MEMPOOL_CONFIG_FILE` to specify a custom config file location:
|
||||
```
|
||||
MEMPOOL_CONFIG_FILE=/path/to/mempool-config.json npm run start
|
||||
```
|
||||
|
||||
When it's running, you should see output like this:
|
||||
@@ -155,7 +160,95 @@ npm install -g ts-node nodemon
|
||||
Then, run the watcher:
|
||||
|
||||
```
|
||||
nodemon src/index.ts --ignore cache/ --ignore pools.json
|
||||
nodemon src/index.ts --ignore cache/
|
||||
```
|
||||
|
||||
`nodemon` should be in npm's global binary folder. If needed, you can determine where that is with `npm -g bin`.
|
||||
|
||||
### Useful Regtest Commands
|
||||
|
||||
Helpful link: https://gist.github.com/System-Glitch/cb4e87bf1ae3fec9925725bb3ebe223a
|
||||
|
||||
Run bitcoind on regtest:
|
||||
```
|
||||
bitcoind -regtest
|
||||
```
|
||||
|
||||
Create a new wallet, if needed:
|
||||
```
|
||||
bitcoin-cli -regtest createwallet test
|
||||
```
|
||||
|
||||
Load wallet (this command may take a while if you have lot of UTXOs):
|
||||
```
|
||||
bitcoin-cli -regtest loadwallet test
|
||||
```
|
||||
|
||||
Get a new address:
|
||||
```
|
||||
address=$(bitcoin-cli -regtest getnewaddress)
|
||||
```
|
||||
|
||||
Mine blocks to the previously generated address. You need at least 101 blocks before you can spend. This will take some time to execute (~1 min):
|
||||
```
|
||||
bitcoin-cli -regtest generatetoaddress 101 $address
|
||||
```
|
||||
|
||||
Send 0.1 BTC at 5 sat/vB to another address:
|
||||
```
|
||||
bitcoin-cli -named -regtest sendtoaddress address=$(bitcoin-cli -regtest getnewaddress) amount=0.1 fee_rate=5
|
||||
```
|
||||
|
||||
See more example of `sendtoaddress`:
|
||||
```
|
||||
bitcoin-cli sendtoaddress # will print the help
|
||||
```
|
||||
|
||||
Mini script to generate random network activity (random TX count with random tx fee-rate). It's slow so don't expect to use this to test mempool spam, except if you let it run for a long time, or maybe with multiple regtest nodes connected to each other.
|
||||
```
|
||||
#!/bin/bash
|
||||
address=$(bitcoin-cli -regtest getnewaddress)
|
||||
bitcoin-cli -regtest generatetoaddress 101 $address
|
||||
for i in {1..1000000}
|
||||
do
|
||||
for y in $(seq 1 "$(jot -r 1 1 1000)")
|
||||
do
|
||||
bitcoin-cli -regtest -named sendtoaddress address=$address amount=0.01 fee_rate=$(jot -r 1 1 100)
|
||||
done
|
||||
bitcoin-cli -regtest generatetoaddress 1 $address
|
||||
sleep 5
|
||||
done
|
||||
```
|
||||
|
||||
Generate block at regular interval (every 10 seconds in this example):
|
||||
```
|
||||
watch -n 10 "bitcoin-cli -regtest generatetoaddress 1 $address"
|
||||
```
|
||||
|
||||
### Mining pools update
|
||||
|
||||
By default, mining pools will be not automatically updated regularly (`config.MEMPOOL.AUTOMATIC_BLOCK_REINDEXING` is set to `false`).
|
||||
|
||||
To manually update your mining pools, you can use the `--update-pools` command line flag when you run the nodejs backend. For example `npm run start --update-pools`. This will trigger the mining pools update and automatically re-index appropriate blocks.
|
||||
|
||||
You can enabled the automatic mining pools update by settings `config.MEMPOOL.AUTOMATIC_BLOCK_REINDEXING` to `true` in your `mempool-config.json`.
|
||||
|
||||
When a `coinbase tag` or `coinbase address` change is detected, all blocks tagged to the `unknown` mining pools (starting from height 130635) will be deleted from the `blocks` table. Additionaly, all blocks which were tagged to the pool which has been updated will also be deleted from the `blocks` table. Of course, those blocks will be automatically reindexed.
|
||||
|
||||
### Re-index tables
|
||||
|
||||
You can manually force the nodejs backend to drop all data from a specified set of tables for future re-index. This is mostly useful for the mining dashboard and the lightning explorer.
|
||||
|
||||
Use the `--reindex` command to specify a list of comma separated table which will be truncated at start. Note that a 5 seconds delay will be observed before truncating tables in order to give you a chance to cancel (CTRL+C) in case of misuse of the command.
|
||||
|
||||
Usage:
|
||||
```
|
||||
npm run start --reindex=blocks,hashrates
|
||||
```
|
||||
Example output:
|
||||
```
|
||||
Feb 13 14:55:27 [63246] WARN: <lightning> Indexed data for "hashrates" tables will be erased in 5 seconds (using '--reindex')
|
||||
Feb 13 14:55:32 [63246] NOTICE: <lightning> Table hashrates has been truncated
|
||||
```
|
||||
|
||||
Reference: https://github.com/mempool/mempool/pull/1269
|
||||
|
||||
20
backend/jest.config.ts
Normal file
20
backend/jest.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Config } from "@jest/types"
|
||||
|
||||
const config: Config.InitialOptions = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node",
|
||||
verbose: true,
|
||||
automock: false,
|
||||
collectCoverage: true,
|
||||
collectCoverageFrom: ["./src/**/**.ts"],
|
||||
coverageProvider: "babel",
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
lines: 1
|
||||
}
|
||||
},
|
||||
setupFiles: [
|
||||
"./testSetup.ts",
|
||||
],
|
||||
}
|
||||
export default config;
|
||||
@@ -2,6 +2,7 @@
|
||||
"MEMPOOL": {
|
||||
"NETWORK": "mainnet",
|
||||
"BACKEND": "electrum",
|
||||
"ENABLED": true,
|
||||
"HTTP_PORT": 8999,
|
||||
"SPAWN_CLUSTER_PROCS": 0,
|
||||
"API_URL_PREFIX": "/api/v1/",
|
||||
@@ -13,19 +14,28 @@
|
||||
"INITIAL_BLOCKS_AMOUNT": 8,
|
||||
"MEMPOOL_BLOCKS_AMOUNT": 8,
|
||||
"INDEXING_BLOCKS_AMOUNT": 11000,
|
||||
"PRICE_FEED_UPDATE_INTERVAL": 600,
|
||||
"BLOCKS_SUMMARIES_INDEXING": false,
|
||||
"USE_SECOND_NODE_FOR_MINFEE": false,
|
||||
"EXTERNAL_ASSETS": [],
|
||||
"EXTERNAL_MAX_RETRY": 1,
|
||||
"EXTERNAL_RETRY_INTERVAL": 0,
|
||||
"USER_AGENT": "mempool",
|
||||
"STDOUT_LOG_MIN_PRIORITY": "debug"
|
||||
"STDOUT_LOG_MIN_PRIORITY": "debug",
|
||||
"AUTOMATIC_BLOCK_REINDEXING": false,
|
||||
"POOLS_JSON_URL": "https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json",
|
||||
"POOLS_JSON_TREE_URL": "https://api.github.com/repos/mempool/mining-pools/git/trees/master",
|
||||
"AUDIT": false,
|
||||
"ADVANCED_GBT_AUDIT": false,
|
||||
"ADVANCED_GBT_MEMPOOL": false,
|
||||
"CPFP_INDEXING": false,
|
||||
"DISK_CACHE_BLOCK_INTERVAL": 6
|
||||
},
|
||||
"CORE_RPC": {
|
||||
"HOST": "127.0.0.1",
|
||||
"PORT": 8332,
|
||||
"USERNAME": "mempool",
|
||||
"PASSWORD": "mempool"
|
||||
"PASSWORD": "mempool",
|
||||
"TIMEOUT": 60000
|
||||
},
|
||||
"ELECTRUM": {
|
||||
"HOST": "127.0.0.1",
|
||||
@@ -39,7 +49,8 @@
|
||||
"HOST": "127.0.0.1",
|
||||
"PORT": 8332,
|
||||
"USERNAME": "mempool",
|
||||
"PASSWORD": "mempool"
|
||||
"PASSWORD": "mempool",
|
||||
"TIMEOUT": 60000
|
||||
},
|
||||
"DATABASE": {
|
||||
"ENABLED": true,
|
||||
@@ -61,10 +72,34 @@
|
||||
"ENABLED": true,
|
||||
"TX_PER_SECOND_SAMPLE_PERIOD": 150
|
||||
},
|
||||
"MAXMIND": {
|
||||
"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"
|
||||
},
|
||||
"BISQ": {
|
||||
"ENABLED": false,
|
||||
"DATA_PATH": "/bisq/statsnode-data/btc_mainnet/db"
|
||||
},
|
||||
"LIGHTNING": {
|
||||
"ENABLED": false,
|
||||
"BACKEND": "lnd",
|
||||
"STATS_REFRESH_INTERVAL": 600,
|
||||
"GRAPH_REFRESH_INTERVAL": 600,
|
||||
"LOGGER_UPDATE_INTERVAL": 30,
|
||||
"FORENSICS_INTERVAL": 43200,
|
||||
"FORENSICS_RATE_LIMIT": 20
|
||||
},
|
||||
"LND": {
|
||||
"TLS_CERT_PATH": "tls.cert",
|
||||
"MACAROON_PATH": "readonly.macaroon",
|
||||
"REST_API_URL": "https://localhost:8080",
|
||||
"TIMEOUT": 10000
|
||||
},
|
||||
"CLIGHTNING": {
|
||||
"SOCKET": "lightning-rpc"
|
||||
},
|
||||
"SOCKS5PROXY": {
|
||||
"ENABLED": false,
|
||||
"USE_ONION": true,
|
||||
|
||||
8941
backend/package-lock.json
generated
8941
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mempool-backend",
|
||||
"version": "2.4.0",
|
||||
"version": "2.5.0-dev",
|
||||
"description": "Bitcoin mempool visualizer and blockchain explorer backend",
|
||||
"license": "GNU Affero General Public License v3.0",
|
||||
"homepage": "https://mempool.space",
|
||||
@@ -16,33 +16,53 @@
|
||||
"mempool",
|
||||
"blockchain",
|
||||
"explorer",
|
||||
"liquid"
|
||||
"liquid",
|
||||
"lightning"
|
||||
],
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"ng": "./node_modules/@angular/cli/bin/ng",
|
||||
"tsc": "./node_modules/typescript/bin/tsc",
|
||||
"build": "npm run tsc",
|
||||
"tsc": "./node_modules/typescript/bin/tsc -p tsconfig.build.json",
|
||||
"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)",
|
||||
"start": "node --max-old-space-size=2048 dist/index.js",
|
||||
"start-production": "node --max-old-space-size=4096 dist/index.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"start-production": "node --max-old-space-size=16384 dist/index.js",
|
||||
"test": "./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}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.20.12",
|
||||
"@mempool/electrum-client": "^1.1.7",
|
||||
"@types/node": "^16.18.11",
|
||||
"axios": "~0.27.2",
|
||||
"bitcoinjs-lib": "6.0.1",
|
||||
"crypto-js": "^4.0.0",
|
||||
"express": "^4.18.0",
|
||||
"mysql2": "2.3.3",
|
||||
"node-worker-threads-pool": "^1.5.1",
|
||||
"socks-proxy-agent": "^6.2.0",
|
||||
"typescript": "~4.7.2",
|
||||
"ws": "~8.7.0"
|
||||
"bitcoinjs-lib": "~6.1.0",
|
||||
"crypto-js": "~4.1.1",
|
||||
"express": "~4.18.2",
|
||||
"maxmind": "~4.3.8",
|
||||
"mysql2": "~2.3.3",
|
||||
"node-worker-threads-pool": "~1.5.1",
|
||||
"socks-proxy-agent": "~7.0.0",
|
||||
"typescript": "~4.7.4",
|
||||
"ws": "~8.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.7",
|
||||
"@babel/code-frame": "^7.18.6",
|
||||
"@types/compression": "^1.7.2",
|
||||
"@types/ws": "~8.5.3",
|
||||
"@types/express": "^4.17.13",
|
||||
"tslint": "^6.1.0"
|
||||
"@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",
|
||||
"ts-node": "^10.9.1"
|
||||
}
|
||||
}
|
||||
|
||||
119
backend/src/__fixtures__/mempool-config.template.json
Normal file
119
backend/src/__fixtures__/mempool-config.template.json
Normal file
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"MEMPOOL": {
|
||||
"ENABLED": true,
|
||||
"NETWORK": "__MEMPOOL_NETWORK__",
|
||||
"BACKEND": "__MEMPOOL_BACKEND__",
|
||||
"BLOCKS_SUMMARIES_INDEXING": true,
|
||||
"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__",
|
||||
"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_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__",
|
||||
"DISK_CACHE_BLOCK_INTERVAL": "__MEMPOOL_DISK_CACHE_BLOCK_INTERVAL__"
|
||||
},
|
||||
"CORE_RPC": {
|
||||
"HOST": "__CORE_RPC_HOST__",
|
||||
"PORT": 15,
|
||||
"USERNAME": "__CORE_RPC_USERNAME__",
|
||||
"PASSWORD": "__CORE_RPC_PASSWORD__",
|
||||
"TIMEOUT": "__CORE_RPC_TIMEOUT__"
|
||||
},
|
||||
"ELECTRUM": {
|
||||
"HOST": "__ELECTRUM_HOST__",
|
||||
"PORT": 16,
|
||||
"TLS_ENABLED": true
|
||||
},
|
||||
"ESPLORA": {
|
||||
"REST_API_URL": "__ESPLORA_REST_API_URL__"
|
||||
},
|
||||
"SECOND_CORE_RPC": {
|
||||
"HOST": "__SECOND_CORE_RPC_HOST__",
|
||||
"PORT": 17,
|
||||
"USERNAME": "__SECOND_CORE_RPC_USERNAME__",
|
||||
"PASSWORD": "__SECOND_CORE_RPC_PASSWORD__",
|
||||
"TIMEOUT": "__SECOND_CORE_RPC_TIMEOUT__"
|
||||
},
|
||||
"DATABASE": {
|
||||
"ENABLED": false,
|
||||
"HOST": "__DATABASE_HOST__",
|
||||
"SOCKET": "__DATABASE_SOCKET__",
|
||||
"PORT": 18,
|
||||
"DATABASE": "__DATABASE_DATABASE__",
|
||||
"USERNAME": "__DATABASE_USERNAME__",
|
||||
"PASSWORD": "__DATABASE_PASSWORD__"
|
||||
},
|
||||
"SYSLOG": {
|
||||
"ENABLED": false,
|
||||
"HOST": "__SYSLOG_HOST__",
|
||||
"PORT": 19,
|
||||
"MIN_PRIORITY": "__SYSLOG_MIN_PRIORITY__",
|
||||
"FACILITY": "__SYSLOG_FACILITY__"
|
||||
},
|
||||
"STATISTICS": {
|
||||
"ENABLED": false,
|
||||
"TX_PER_SECOND_SAMPLE_PERIOD": 20
|
||||
},
|
||||
"BISQ": {
|
||||
"ENABLED": true,
|
||||
"DATA_PATH": "__BISQ_DATA_PATH__"
|
||||
},
|
||||
"SOCKS5PROXY": {
|
||||
"ENABLED": true,
|
||||
"USE_ONION": true,
|
||||
"HOST": "__SOCKS5PROXY_HOST__",
|
||||
"PORT": "__SOCKS5PROXY_PORT__",
|
||||
"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__",
|
||||
"LIQUID_API": "__EXTERNAL_DATA_SERVER_LIQUID_API__",
|
||||
"LIQUID_ONION": "__EXTERNAL_DATA_SERVER_LIQUID_ONION__",
|
||||
"BISQ_URL": "__EXTERNAL_DATA_SERVER_BISQ_URL__",
|
||||
"BISQ_ONION": "__EXTERNAL_DATA_SERVER_BISQ_ONION__"
|
||||
},
|
||||
"LIGHTNING": {
|
||||
"ENABLED": "__LIGHTNING_ENABLED__",
|
||||
"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__"
|
||||
},
|
||||
"LND": {
|
||||
"TLS_CERT_PATH": "",
|
||||
"MACAROON_PATH": "",
|
||||
"REST_API_URL": "https://localhost:8080",
|
||||
"TIMEOUT": 10000
|
||||
},
|
||||
"CLIGHTNING": {
|
||||
"SOCKET": "__CLIGHTNING_SOCKET__"
|
||||
}
|
||||
}
|
||||
66
backend/src/__tests__/api/difficulty-adjustment.test.ts
Normal file
66
backend/src/__tests__/api/difficulty-adjustment.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { calcDifficultyAdjustment, DifficultyAdjustment } from '../../api/difficulty-adjustment';
|
||||
|
||||
describe('Mempool Difficulty Adjustment', () => {
|
||||
test('should calculate Difficulty Adjustments properly', () => {
|
||||
const dt = (dtString) => {
|
||||
return Math.floor(new Date(dtString).getTime() / 1000);
|
||||
};
|
||||
|
||||
const vectors = [
|
||||
[ // Vector 1
|
||||
[ // Inputs
|
||||
dt('2022-08-18T11:07:00.000Z'), // Last DA time (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
|
||||
],
|
||||
{ // Expected Result
|
||||
progressPercent: 9.027777777777777,
|
||||
difficultyChange: 12.562233927411782,
|
||||
estimatedRetargetDate: 1661895424692,
|
||||
remainingBlocks: 1834,
|
||||
remainingTime: 977591692,
|
||||
previousRetarget: 0.6280047707459726,
|
||||
previousTime: 1660820820,
|
||||
nextRetargetHeight: 751968,
|
||||
timeAvg: 533038,
|
||||
timeOffset: 0,
|
||||
expectedBlocks: 161.68833333333333,
|
||||
},
|
||||
],
|
||||
[ // Vector 2 (testnet)
|
||||
[ // Inputs
|
||||
dt('2022-08-18T11:07:00.000Z'), // Last DA time (in seconds)
|
||||
dt('2022-08-19T14:03:53.000Z'), // Current time (now) (in seconds)
|
||||
750134, // Current block height
|
||||
0.6280047707459726, // Previous retarget % (Passed through)
|
||||
'testnet', // Network
|
||||
dt('2022-08-19T13:52:46.000Z'), // Latest block timestamp in seconds
|
||||
],
|
||||
{ // Expected Result is same other than timeOffset
|
||||
progressPercent: 9.027777777777777,
|
||||
difficultyChange: 12.562233927411782,
|
||||
estimatedRetargetDate: 1661895424692,
|
||||
remainingBlocks: 1834,
|
||||
remainingTime: 977591692,
|
||||
previousTime: 1660820820,
|
||||
previousRetarget: 0.6280047707459726,
|
||||
nextRetargetHeight: 751968,
|
||||
timeAvg: 533038,
|
||||
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][];
|
||||
|
||||
for (const vector of vectors) {
|
||||
const result = calcDifficultyAdjustment(...vector[0]);
|
||||
// previousRetarget is passed through untouched
|
||||
expect(result.previousRetarget).toStrictEqual(vector[0][3]);
|
||||
expect(result).toStrictEqual(vector[1]);
|
||||
}
|
||||
});
|
||||
});
|
||||
154
backend/src/__tests__/config.test.ts
Normal file
154
backend/src/__tests__/config.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import * as fs from 'fs';
|
||||
|
||||
describe('Mempool Backend Config', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
test('should return defaults when no file is present', () => {
|
||||
jest.isolateModules(() => {
|
||||
jest.mock('../../mempool-config.json', () => ({}), { virtual: true });
|
||||
|
||||
const config = jest.requireActual('../config').default;
|
||||
|
||||
expect(config.MEMPOOL).toStrictEqual({
|
||||
ENABLED: true,
|
||||
NETWORK: 'mainnet',
|
||||
BACKEND: 'none',
|
||||
BLOCKS_SUMMARIES_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',
|
||||
CLEAR_PROTECTION_MINUTES: 20,
|
||||
RECOMMENDED_FEE_PERCENTILE: 50,
|
||||
BLOCK_WEIGHT_UNITS: 4000000,
|
||||
INITIAL_BLOCKS_AMOUNT: 8,
|
||||
MEMPOOL_BLOCKS_AMOUNT: 8,
|
||||
INDEXING_BLOCKS_AMOUNT: 11000,
|
||||
USE_SECOND_NODE_FOR_MINFEE: false,
|
||||
EXTERNAL_ASSETS: [],
|
||||
EXTERNAL_MAX_RETRY: 1,
|
||||
EXTERNAL_RETRY_INTERVAL: 0,
|
||||
USER_AGENT: 'mempool',
|
||||
STDOUT_LOG_MIN_PRIORITY: 'debug',
|
||||
POOLS_JSON_TREE_URL: 'https://api.github.com/repos/mempool/mining-pools/git/trees/master',
|
||||
POOLS_JSON_URL: 'https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json',
|
||||
AUDIT: false,
|
||||
ADVANCED_GBT_AUDIT: false,
|
||||
ADVANCED_GBT_MEMPOOL: false,
|
||||
CPFP_INDEXING: false,
|
||||
MAX_BLOCKS_BULK_QUERY: 0,
|
||||
DISK_CACHE_BLOCK_INTERVAL: 6,
|
||||
});
|
||||
|
||||
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.CORE_RPC).toStrictEqual({
|
||||
HOST: '127.0.0.1',
|
||||
PORT: 8332,
|
||||
USERNAME: 'mempool',
|
||||
PASSWORD: 'mempool',
|
||||
TIMEOUT: 60000
|
||||
});
|
||||
|
||||
expect(config.SECOND_CORE_RPC).toStrictEqual({
|
||||
HOST: '127.0.0.1',
|
||||
PORT: 8332,
|
||||
USERNAME: 'mempool',
|
||||
PASSWORD: 'mempool',
|
||||
TIMEOUT: 60000
|
||||
});
|
||||
|
||||
expect(config.DATABASE).toStrictEqual({
|
||||
ENABLED: true,
|
||||
HOST: '127.0.0.1',
|
||||
SOCKET: '',
|
||||
PORT: 3306,
|
||||
DATABASE: 'mempool',
|
||||
USERNAME: 'mempool',
|
||||
PASSWORD: 'mempool'
|
||||
});
|
||||
|
||||
expect(config.SYSLOG).toStrictEqual({
|
||||
ENABLED: true,
|
||||
HOST: '127.0.0.1',
|
||||
PORT: 514,
|
||||
MIN_PRIORITY: 'info',
|
||||
FACILITY: 'local7'
|
||||
});
|
||||
|
||||
expect(config.STATISTICS).toStrictEqual({ ENABLED: true, TX_PER_SECOND_SAMPLE_PERIOD: 150 });
|
||||
|
||||
expect(config.BISQ).toStrictEqual({ ENABLED: false, DATA_PATH: '/bisq/statsnode-data/btc_mainnet/db' });
|
||||
|
||||
expect(config.SOCKS5PROXY).toStrictEqual({
|
||||
ENABLED: false,
|
||||
USE_ONION: true,
|
||||
HOST: '127.0.0.1',
|
||||
PORT: 9050,
|
||||
USERNAME: '',
|
||||
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',
|
||||
LIQUID_API: 'https://liquid.network/api/v1',
|
||||
LIQUID_ONION: 'http://liquidmom47f6s3m53ebfxn47p76a6tlnxib3wp6deux7wuzotdr6cyd.onion/api/v1',
|
||||
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'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should override the default values with the passed values', () => {
|
||||
jest.isolateModules(() => {
|
||||
const fixture = JSON.parse(fs.readFileSync(`${__dirname}/../__fixtures__/mempool-config.template.json`, 'utf8'));
|
||||
jest.mock('../../mempool-config.json', () => (fixture), { virtual: true });
|
||||
|
||||
const config = jest.requireActual('../config').default;
|
||||
|
||||
expect(config.MEMPOOL).toStrictEqual(fixture.MEMPOOL);
|
||||
|
||||
expect(config.ELECTRUM).toStrictEqual(fixture.ELECTRUM);
|
||||
|
||||
expect(config.ESPLORA).toStrictEqual(fixture.ESPLORA);
|
||||
|
||||
expect(config.CORE_RPC).toStrictEqual(fixture.CORE_RPC);
|
||||
|
||||
expect(config.SECOND_CORE_RPC).toStrictEqual(fixture.SECOND_CORE_RPC);
|
||||
|
||||
expect(config.DATABASE).toStrictEqual(fixture.DATABASE);
|
||||
|
||||
expect(config.SYSLOG).toStrictEqual(fixture.SYSLOG);
|
||||
|
||||
expect(config.STATISTICS).toStrictEqual(fixture.STATISTICS);
|
||||
|
||||
expect(config.BISQ).toStrictEqual(fixture.BISQ);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
143
backend/src/api/audit.ts
Normal file
143
backend/src/api/audit.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import config from '../config';
|
||||
import { TransactionExtended, MempoolBlockWithTransactions } from '../mempool.interfaces';
|
||||
|
||||
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, similarity: number } {
|
||||
if (!projectedBlocks?.[0]?.transactionIds || !mempool) {
|
||||
return { censored: [], added: [], fresh: [], score: 0, 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 isCensored = {}; // missing, without excuse
|
||||
const isDisplaced = {};
|
||||
let displacedWeight = 0;
|
||||
let matchedWeight = 0;
|
||||
let projectedWeight = 0;
|
||||
|
||||
const inBlock = {};
|
||||
const inTemplate = {};
|
||||
|
||||
const now = Math.round((Date.now() / 1000));
|
||||
for (const tx of transactions) {
|
||||
inBlock[tx.txid] = tx;
|
||||
}
|
||||
// coinbase is always expected
|
||||
if (transactions[0]) {
|
||||
inTemplate[transactions[0].txid] = true;
|
||||
}
|
||||
// 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) {
|
||||
fresh.push(txid);
|
||||
} else {
|
||||
isCensored[txid] = true;
|
||||
}
|
||||
displacedWeight += mempool[txid].weight;
|
||||
} else {
|
||||
matchedWeight += mempool[txid].weight;
|
||||
}
|
||||
projectedWeight += mempool[txid].weight;
|
||||
inTemplate[txid] = true;
|
||||
}
|
||||
|
||||
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
|
||||
let displacedWeightRemaining = displacedWeight;
|
||||
let index = 0;
|
||||
let lastFeeRate = Infinity;
|
||||
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);
|
||||
}
|
||||
if (mempool[txid].firstSeen == null || (now - (mempool[txid]?.firstSeen || 0)) > PROPAGATION_MARGIN) {
|
||||
displacedWeightRemaining -= mempool[txid].weight;
|
||||
}
|
||||
failures = 0;
|
||||
} else {
|
||||
failures++;
|
||||
}
|
||||
index++;
|
||||
}
|
||||
|
||||
// mark unexpected transactions in the mined block as 'added'
|
||||
let overflowWeight = 0;
|
||||
let totalWeight = 0;
|
||||
for (const tx of transactions) {
|
||||
if (inTemplate[tx.txid]) {
|
||||
matches.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;
|
||||
}
|
||||
|
||||
// transactions missing from near the end of our template are probably not being censored
|
||||
let overflowWeightRemaining = overflowWeight - (config.MEMPOOL.BLOCK_WEIGHT_UNITS - totalWeight);
|
||||
let maxOverflowRate = 0;
|
||||
let rateThreshold = 0;
|
||||
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];
|
||||
}
|
||||
}
|
||||
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;
|
||||
const similarity = projectedWeight ? matchedWeight / projectedWeight : 1;
|
||||
|
||||
return {
|
||||
censored: Object.keys(isCensored),
|
||||
added,
|
||||
fresh,
|
||||
score,
|
||||
similarity,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default new Audit();
|
||||
@@ -1,60 +1,39 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import logger from '../logger';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { IBackendInfo } from '../mempool.interfaces';
|
||||
const { spawnSync } = require('child_process');
|
||||
import config from '../config';
|
||||
|
||||
class BackendInfo {
|
||||
private gitCommitHash = '';
|
||||
private hostname = '';
|
||||
private version = '';
|
||||
private backendInfo: IBackendInfo;
|
||||
|
||||
constructor() {
|
||||
this.setLatestCommitHash();
|
||||
this.setVersion();
|
||||
this.hostname = os.hostname();
|
||||
}
|
||||
|
||||
public getBackendInfo(): IBackendInfo {
|
||||
return {
|
||||
hostname: this.hostname,
|
||||
gitCommit: this.gitCommitHash,
|
||||
version: this.version,
|
||||
// This file is created by ./fetch-version.ts during building
|
||||
const versionFile = path.join(__dirname, 'version.json')
|
||||
var versionInfo;
|
||||
if (fs.existsSync(versionFile)) {
|
||||
versionInfo = JSON.parse(fs.readFileSync(versionFile).toString());
|
||||
} else {
|
||||
// Use dummy values if `versionFile` doesn't exist (e.g., during testing)
|
||||
versionInfo = {
|
||||
version: '?',
|
||||
gitCommit: '?'
|
||||
};
|
||||
}
|
||||
this.backendInfo = {
|
||||
hostname: os.hostname(),
|
||||
version: versionInfo.version,
|
||||
gitCommit: versionInfo.gitCommit,
|
||||
lightning: config.LIGHTNING.ENABLED
|
||||
};
|
||||
}
|
||||
|
||||
public getBackendInfo(): IBackendInfo {
|
||||
return this.backendInfo;
|
||||
}
|
||||
|
||||
public getShortCommitHash() {
|
||||
return this.gitCommitHash.slice(0, 7);
|
||||
}
|
||||
|
||||
private setLatestCommitHash(): void {
|
||||
//TODO: share this logic with `generate-config.js`
|
||||
if (process.env.DOCKER_COMMIT_HASH) {
|
||||
this.gitCommitHash = process.env.DOCKER_COMMIT_HASH;
|
||||
} else {
|
||||
try {
|
||||
const gitRevParse = spawnSync('git', ['rev-parse', '--short', 'HEAD']);
|
||||
if (!gitRevParse.error) {
|
||||
const output = gitRevParse.stdout.toString('utf-8').replace(/[\n\r\s]+$/, '');
|
||||
this.gitCommitHash = output ? output : '?';
|
||||
} else if (gitRevParse.error.code === 'ENOENT') {
|
||||
console.log('git not found, cannot parse git hash');
|
||||
this.gitCommitHash = '?';
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.log('Could not load git commit info: ' + e.message);
|
||||
this.gitCommitHash = '?';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setVersion(): void {
|
||||
try {
|
||||
const packageJson = fs.readFileSync('package.json').toString();
|
||||
this.version = JSON.parse(packageJson).version;
|
||||
} catch (e) {
|
||||
throw new Error(e instanceof Error ? e.message : 'Error');
|
||||
}
|
||||
return this.backendInfo.gitCommit.slice(0, 7);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
381
backend/src/api/bisq/bisq.routes.ts
Normal file
381
backend/src/api/bisq/bisq.routes.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import { Application, Request, Response } from 'express';
|
||||
import config from '../../config';
|
||||
import { RequiredSpec } from '../../mempool.interfaces';
|
||||
import bisq from './bisq';
|
||||
import { MarketsApiError } from './interfaces';
|
||||
import marketsApi from './markets-api';
|
||||
|
||||
class BisqRoutes {
|
||||
public initRoutes(app: Application) {
|
||||
app
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/stats', this.getBisqStats)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/tx/:txId', this.getBisqTransaction)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/block/:hash', this.getBisqBlock)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/blocks/tip/height', this.getBisqTip)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/blocks/:index/:length', this.getBisqBlocks)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/address/:address', this.getBisqAddress)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/txs/:index/:length', this.getBisqTransactions)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/currencies', this.getBisqMarketCurrencies.bind(this))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/depth', this.getBisqMarketDepth.bind(this))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/hloc', this.getBisqMarketHloc.bind(this))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/markets', this.getBisqMarketMarkets.bind(this))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/offers', this.getBisqMarketOffers.bind(this))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/ticker', this.getBisqMarketTicker.bind(this))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/trades', this.getBisqMarketTrades.bind(this))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/volumes', this.getBisqMarketVolumes.bind(this))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/volumes/7d', this.getBisqMarketVolumes7d.bind(this))
|
||||
;
|
||||
}
|
||||
|
||||
|
||||
private getBisqStats(req: Request, res: Response) {
|
||||
const result = bisq.getStats();
|
||||
res.json(result);
|
||||
}
|
||||
|
||||
private getBisqTip(req: Request, res: Response) {
|
||||
const result = bisq.getLatestBlockHeight();
|
||||
res.type('text/plain');
|
||||
res.send(result.toString());
|
||||
}
|
||||
|
||||
private getBisqTransaction(req: Request, res: Response) {
|
||||
const result = bisq.getTransaction(req.params.txId);
|
||||
if (result) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(404).send('Bisq transaction not found');
|
||||
}
|
||||
}
|
||||
|
||||
private getBisqTransactions(req: Request, res: Response) {
|
||||
const types: string[] = [];
|
||||
req.query.types = req.query.types || [];
|
||||
if (!Array.isArray(req.query.types)) {
|
||||
res.status(500).send('Types is not an array');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const _type in req.query.types) {
|
||||
if (typeof req.query.types[_type] === 'string') {
|
||||
types.push(req.query.types[_type].toString());
|
||||
}
|
||||
}
|
||||
|
||||
const index = parseInt(req.params.index, 10) || 0;
|
||||
const length = parseInt(req.params.length, 10) > 100 ? 100 : parseInt(req.params.length, 10) || 25;
|
||||
const [transactions, count] = bisq.getTransactions(index, length, types);
|
||||
res.header('X-Total-Count', count.toString());
|
||||
res.json(transactions);
|
||||
}
|
||||
|
||||
private getBisqBlock(req: Request, res: Response) {
|
||||
const result = bisq.getBlock(req.params.hash);
|
||||
if (result) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(404).send('Bisq block not found');
|
||||
}
|
||||
}
|
||||
|
||||
private getBisqBlocks(req: Request, res: Response) {
|
||||
const index = parseInt(req.params.index, 10) || 0;
|
||||
const length = parseInt(req.params.length, 10) > 100 ? 100 : parseInt(req.params.length, 10) || 25;
|
||||
const [transactions, count] = bisq.getBlocks(index, length);
|
||||
res.header('X-Total-Count', count.toString());
|
||||
res.json(transactions);
|
||||
}
|
||||
|
||||
private getBisqAddress(req: Request, res: Response) {
|
||||
const result = bisq.getAddress(req.params.address.substr(1));
|
||||
if (result) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(404).send('Bisq address not found');
|
||||
}
|
||||
}
|
||||
|
||||
private getBisqMarketCurrencies(req: Request, res: Response) {
|
||||
const constraints: RequiredSpec = {
|
||||
'type': {
|
||||
required: false,
|
||||
types: ['crypto', 'fiat', 'all']
|
||||
},
|
||||
};
|
||||
|
||||
const p = this.parseRequestParameters(req.query, constraints);
|
||||
if (p.error) {
|
||||
res.status(400).json(this.getBisqMarketErrorResponse(p.error));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = marketsApi.getCurrencies(p.type);
|
||||
if (result) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(500).json(this.getBisqMarketErrorResponse('getBisqMarketCurrencies error'));
|
||||
}
|
||||
}
|
||||
|
||||
private getBisqMarketDepth(req: Request, res: Response) {
|
||||
const constraints: RequiredSpec = {
|
||||
'market': {
|
||||
required: true,
|
||||
types: ['@string']
|
||||
},
|
||||
};
|
||||
|
||||
const p = this.parseRequestParameters(req.query, constraints);
|
||||
if (p.error) {
|
||||
res.status(400).json(this.getBisqMarketErrorResponse(p.error));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = marketsApi.getDepth(p.market);
|
||||
if (result) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(500).json(this.getBisqMarketErrorResponse('getBisqMarketDepth error'));
|
||||
}
|
||||
}
|
||||
|
||||
private getBisqMarketMarkets(req: Request, res: Response) {
|
||||
const result = marketsApi.getMarkets();
|
||||
if (result) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(500).json(this.getBisqMarketErrorResponse('getBisqMarketMarkets error'));
|
||||
}
|
||||
}
|
||||
|
||||
private getBisqMarketTrades(req: Request, res: Response) {
|
||||
const constraints: RequiredSpec = {
|
||||
'market': {
|
||||
required: true,
|
||||
types: ['@string']
|
||||
},
|
||||
'timestamp_from': {
|
||||
required: false,
|
||||
types: ['@number']
|
||||
},
|
||||
'timestamp_to': {
|
||||
required: false,
|
||||
types: ['@number']
|
||||
},
|
||||
'trade_id_to': {
|
||||
required: false,
|
||||
types: ['@string']
|
||||
},
|
||||
'trade_id_from': {
|
||||
required: false,
|
||||
types: ['@string']
|
||||
},
|
||||
'direction': {
|
||||
required: false,
|
||||
types: ['buy', 'sell']
|
||||
},
|
||||
'limit': {
|
||||
required: false,
|
||||
types: ['@number']
|
||||
},
|
||||
'sort': {
|
||||
required: false,
|
||||
types: ['asc', 'desc']
|
||||
}
|
||||
};
|
||||
|
||||
const p = this.parseRequestParameters(req.query, constraints);
|
||||
if (p.error) {
|
||||
res.status(400).json(this.getBisqMarketErrorResponse(p.error));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = marketsApi.getTrades(p.market, p.timestamp_from,
|
||||
p.timestamp_to, p.trade_id_from, p.trade_id_to, p.direction, p.limit, p.sort);
|
||||
if (result) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(500).json(this.getBisqMarketErrorResponse('getBisqMarketTrades error'));
|
||||
}
|
||||
}
|
||||
|
||||
private getBisqMarketOffers(req: Request, res: Response) {
|
||||
const constraints: RequiredSpec = {
|
||||
'market': {
|
||||
required: true,
|
||||
types: ['@string']
|
||||
},
|
||||
'direction': {
|
||||
required: false,
|
||||
types: ['buy', 'sell']
|
||||
},
|
||||
};
|
||||
|
||||
const p = this.parseRequestParameters(req.query, constraints);
|
||||
if (p.error) {
|
||||
res.status(400).json(this.getBisqMarketErrorResponse(p.error));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = marketsApi.getOffers(p.market, p.direction);
|
||||
if (result) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(500).json(this.getBisqMarketErrorResponse('getBisqMarketOffers error'));
|
||||
}
|
||||
}
|
||||
|
||||
private getBisqMarketVolumes(req: Request, res: Response) {
|
||||
const constraints: RequiredSpec = {
|
||||
'market': {
|
||||
required: false,
|
||||
types: ['@string']
|
||||
},
|
||||
'interval': {
|
||||
required: false,
|
||||
types: ['minute', 'half_hour', 'hour', 'half_day', 'day', 'week', 'month', 'year', 'auto']
|
||||
},
|
||||
'timestamp_from': {
|
||||
required: false,
|
||||
types: ['@number']
|
||||
},
|
||||
'timestamp_to': {
|
||||
required: false,
|
||||
types: ['@number']
|
||||
},
|
||||
'milliseconds': {
|
||||
required: false,
|
||||
types: ['@boolean']
|
||||
},
|
||||
'timestamp': {
|
||||
required: false,
|
||||
types: ['no', 'yes']
|
||||
},
|
||||
};
|
||||
|
||||
const p = this.parseRequestParameters(req.query, constraints);
|
||||
if (p.error) {
|
||||
res.status(400).json(this.getBisqMarketErrorResponse(p.error));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = marketsApi.getVolumes(p.market, p.timestamp_from, p.timestamp_to, p.interval, p.milliseconds, p.timestamp);
|
||||
if (result) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(500).json(this.getBisqMarketErrorResponse('getBisqMarketVolumes error'));
|
||||
}
|
||||
}
|
||||
|
||||
private getBisqMarketHloc(req: Request, res: Response) {
|
||||
const constraints: RequiredSpec = {
|
||||
'market': {
|
||||
required: true,
|
||||
types: ['@string']
|
||||
},
|
||||
'interval': {
|
||||
required: false,
|
||||
types: ['minute', 'half_hour', 'hour', 'half_day', 'day', 'week', 'month', 'year', 'auto']
|
||||
},
|
||||
'timestamp_from': {
|
||||
required: false,
|
||||
types: ['@number']
|
||||
},
|
||||
'timestamp_to': {
|
||||
required: false,
|
||||
types: ['@number']
|
||||
},
|
||||
'milliseconds': {
|
||||
required: false,
|
||||
types: ['@boolean']
|
||||
},
|
||||
'timestamp': {
|
||||
required: false,
|
||||
types: ['no', 'yes']
|
||||
},
|
||||
};
|
||||
|
||||
const p = this.parseRequestParameters(req.query, constraints);
|
||||
if (p.error) {
|
||||
res.status(400).json(this.getBisqMarketErrorResponse(p.error));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = marketsApi.getHloc(p.market, p.interval, p.timestamp_from, p.timestamp_to, p.milliseconds, p.timestamp);
|
||||
if (result) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(500).json(this.getBisqMarketErrorResponse('getBisqMarketHloc error'));
|
||||
}
|
||||
}
|
||||
|
||||
private getBisqMarketTicker(req: Request, res: Response) {
|
||||
const constraints: RequiredSpec = {
|
||||
'market': {
|
||||
required: false,
|
||||
types: ['@string']
|
||||
},
|
||||
};
|
||||
|
||||
const p = this.parseRequestParameters(req.query, constraints);
|
||||
if (p.error) {
|
||||
res.status(400).json(this.getBisqMarketErrorResponse(p.error));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = marketsApi.getTicker(p.market);
|
||||
if (result) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(500).json(this.getBisqMarketErrorResponse('getBisqMarketTicker error'));
|
||||
}
|
||||
}
|
||||
|
||||
private getBisqMarketVolumes7d(req: Request, res: Response) {
|
||||
const result = marketsApi.getVolumesByTime(604800);
|
||||
if (result) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(500).json(this.getBisqMarketErrorResponse('getBisqMarketVolumes7d error'));
|
||||
}
|
||||
}
|
||||
|
||||
private parseRequestParameters(requestParams: object, params: RequiredSpec): { [name: string]: any; } {
|
||||
const final = {};
|
||||
for (const i in params) {
|
||||
if (params.hasOwnProperty(i)) {
|
||||
if (params[i].required && requestParams[i] === undefined) {
|
||||
return { error: i + ' parameter missing'};
|
||||
}
|
||||
if (typeof requestParams[i] === 'string') {
|
||||
const str = (requestParams[i] || '').toString().toLowerCase();
|
||||
if (params[i].types.indexOf('@number') > -1) {
|
||||
const number = parseInt((str).toString(), 10);
|
||||
final[i] = number;
|
||||
} else if (params[i].types.indexOf('@string') > -1) {
|
||||
final[i] = str;
|
||||
} else if (params[i].types.indexOf('@boolean') > -1) {
|
||||
final[i] = str === 'true' || str === 'yes';
|
||||
} else if (params[i].types.indexOf(str) > -1) {
|
||||
final[i] = str;
|
||||
} else {
|
||||
return { error: i + ' parameter invalid'};
|
||||
}
|
||||
} else if (typeof requestParams[i] === 'number') {
|
||||
final[i] = requestParams[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return final;
|
||||
}
|
||||
|
||||
private getBisqMarketErrorResponse(message: string): MarketsApiError {
|
||||
return {
|
||||
'success': 0,
|
||||
'error': message
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default new BisqRoutes;
|
||||
@@ -3,16 +3,21 @@ 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>;
|
||||
$getTransactionHex(txId: string): Promise<string>;
|
||||
$getBlockHeightTip(): Promise<number>;
|
||||
$getBlockHashTip(): Promise<string>;
|
||||
$getTxIdsForBlock(hash: string): Promise<string[]>;
|
||||
$getBlockHash(height: number): Promise<string>;
|
||||
$getBlockHeader(hash: string): Promise<string>;
|
||||
$getBlock(hash: string): Promise<IEsploraApi.Block>;
|
||||
$getRawBlock(hash: string): Promise<Buffer>;
|
||||
$getAddress(address: string): Promise<IEsploraApi.Address>;
|
||||
$getAddressTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]>;
|
||||
$getAddressPrefix(prefix: string): string[];
|
||||
$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[][]>;
|
||||
}
|
||||
export interface BitcoinRpcCredentials {
|
||||
host: string;
|
||||
|
||||
@@ -17,4 +17,6 @@ function bitcoinApiFactory(): AbstractBitcoinApi {
|
||||
}
|
||||
}
|
||||
|
||||
export const bitcoinCoreApi = new BitcoinApi(bitcoinClient);
|
||||
|
||||
export default bitcoinApiFactory();
|
||||
|
||||
@@ -73,6 +73,14 @@ export namespace IBitcoinApi {
|
||||
time: number; // (numeric) Same as blocktime
|
||||
}
|
||||
|
||||
export interface VerboseBlock extends Block {
|
||||
tx: VerboseTransaction[]; // The transactions in the format of the getrawtransaction RPC. Different from verbosity = 1 "tx" result
|
||||
}
|
||||
|
||||
export interface VerboseTransaction extends Transaction {
|
||||
fee?: number; // (numeric) The transaction fee in BTC, omitted if block undo data is not available
|
||||
}
|
||||
|
||||
export interface Vin {
|
||||
txid?: string; // (string) The transaction id
|
||||
vout?: number; // (string)
|
||||
@@ -164,4 +172,35 @@ export namespace IBitcoinApi {
|
||||
}
|
||||
}
|
||||
|
||||
export interface BlockStats {
|
||||
"avgfee": number;
|
||||
"avgfeerate": number;
|
||||
"avgtxsize": number;
|
||||
"blockhash": string;
|
||||
"feerate_percentiles": [number, number, number, number, number];
|
||||
"height": number;
|
||||
"ins": number;
|
||||
"maxfee": number;
|
||||
"maxfeerate": number;
|
||||
"maxtxsize": number;
|
||||
"medianfee": number;
|
||||
"mediantime": number;
|
||||
"mediantxsize": number;
|
||||
"minfee": number;
|
||||
"minfeerate": number;
|
||||
"mintxsize": number;
|
||||
"outs": number;
|
||||
"subsidy": number;
|
||||
"swtotal_size": number;
|
||||
"swtotal_weight": number;
|
||||
"swtxs": number;
|
||||
"time": number;
|
||||
"total_out": number;
|
||||
"total_size": number;
|
||||
"total_weight": number;
|
||||
"totalfee": number;
|
||||
"txs": number;
|
||||
"utxo_increase": number;
|
||||
"utxo_size_inc": number;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
size: block.size,
|
||||
weight: block.weight,
|
||||
previousblockhash: block.previousblockhash,
|
||||
mediantime: block.mediantime,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -57,6 +58,11 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
});
|
||||
}
|
||||
|
||||
$getTransactionHex(txId: string): Promise<string> {
|
||||
return this.$getRawTransaction(txId, true)
|
||||
.then((tx) => tx.hex || '');
|
||||
}
|
||||
|
||||
$getBlockHeightTip(): Promise<number> {
|
||||
return this.bitcoindClient.getChainTips()
|
||||
.then((result: IBitcoinApi.ChainTips[]) => {
|
||||
@@ -64,13 +70,21 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
});
|
||||
}
|
||||
|
||||
$getBlockHashTip(): Promise<string> {
|
||||
return this.bitcoindClient.getChainTips()
|
||||
.then((result: IBitcoinApi.ChainTips[]) => {
|
||||
return result.find(tip => tip.status === 'active')!.hash;
|
||||
});
|
||||
}
|
||||
|
||||
$getTxIdsForBlock(hash: string): Promise<string[]> {
|
||||
return this.bitcoindClient.getBlock(hash, 1)
|
||||
.then((rpcBlock: IBitcoinApi.Block) => rpcBlock.tx);
|
||||
}
|
||||
|
||||
$getRawBlock(hash: string): Promise<string> {
|
||||
return this.bitcoindClient.getBlock(hash, 0);
|
||||
$getRawBlock(hash: string): Promise<Buffer> {
|
||||
return this.bitcoindClient.getBlock(hash, 0)
|
||||
.then((raw: string) => Buffer.from(raw, "hex"));
|
||||
}
|
||||
|
||||
$getBlockHash(height: number): Promise<string> {
|
||||
@@ -123,6 +137,16 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
return this.bitcoindClient.sendRawTransaction(rawTransaction);
|
||||
}
|
||||
|
||||
async $getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend> {
|
||||
const txOut = await this.bitcoindClient.getTxOut(txId, vout, false);
|
||||
return {
|
||||
spent: txOut === null,
|
||||
status: {
|
||||
confirmed: true,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async $getOutspends(txId: string): Promise<IEsploraApi.Outspend[]> {
|
||||
const outSpends: IEsploraApi.Outspend[] = [];
|
||||
const tx = await this.$getRawTransaction(txId, true, false);
|
||||
@@ -141,6 +165,15 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
return 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;
|
||||
}
|
||||
|
||||
$getEstimatedHashrate(blockHeight: number): Promise<number> {
|
||||
// 120 is the default block span in Core
|
||||
return this.bitcoindClient.getNetworkHashPs(120, blockHeight);
|
||||
@@ -179,7 +212,9 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
sequence: vin.sequence,
|
||||
txid: vin.txid || '',
|
||||
vout: vin.vout || 0,
|
||||
witness: vin.txinwitness,
|
||||
witness: vin.txinwitness || [],
|
||||
inner_redeemscript_asm: '',
|
||||
inner_witnessscript_asm: '',
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ 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,
|
||||
};
|
||||
|
||||
export default new bitcoin.Client(nodeRpcCredentials);
|
||||
|
||||
@@ -7,7 +7,7 @@ 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,
|
||||
};
|
||||
|
||||
export default new bitcoin.Client(nodeRpcCredentials);
|
||||
|
||||
718
backend/src/api/bitcoin/bitcoin.routes.ts
Normal file
718
backend/src/api/bitcoin/bitcoin.routes.ts
Normal file
@@ -0,0 +1,718 @@
|
||||
import { Application, Request, Response } from 'express';
|
||||
import axios from 'axios';
|
||||
import * as bitcoinjs from 'bitcoinjs-lib';
|
||||
import config from '../../config';
|
||||
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 { Common } from '../common';
|
||||
import backendInfo from '../backend-info';
|
||||
import transactionUtils from '../transaction-utils';
|
||||
import { IEsploraApi } from './esplora-api.interface';
|
||||
import loadingIndicators from '../loading-indicators';
|
||||
import { TransactionExtended } from '../../mempool.interfaces';
|
||||
import logger from '../../logger';
|
||||
import blocks from '../blocks';
|
||||
import bitcoinClient from './bitcoin-client';
|
||||
import difficultyAdjustment from '../difficulty-adjustment';
|
||||
import transactionRepository from '../../repositories/TransactionRepository';
|
||||
import rbfCache from '../rbf-cache';
|
||||
|
||||
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)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'fees/mempool-blocks', this.getMempoolBlocks)
|
||||
.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/cached', this.getCachedTx)
|
||||
.post(config.MEMPOOL.API_URL_PREFIX + 'tx/push', this.$postTransactionForm)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'donations', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${config.EXTERNAL_DATA_SERVER.MEMPOOL_API}/donations`, { responseType: 'stream', timeout: 10000 });
|
||||
response.data.pipe(res);
|
||||
} catch (e) {
|
||||
res.status(500).end();
|
||||
}
|
||||
})
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'donations/images/:id', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${config.EXTERNAL_DATA_SERVER.MEMPOOL_API}/donations/images/${req.params.id}`, {
|
||||
responseType: 'stream', timeout: 10000
|
||||
});
|
||||
response.data.pipe(res);
|
||||
} catch (e) {
|
||||
res.status(500).end();
|
||||
}
|
||||
})
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'contributors', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${config.EXTERNAL_DATA_SERVER.MEMPOOL_API}/contributors`, { responseType: 'stream', timeout: 10000 });
|
||||
response.data.pipe(res);
|
||||
} catch (e) {
|
||||
res.status(500).end();
|
||||
}
|
||||
})
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'contributors/images/:id', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${config.EXTERNAL_DATA_SERVER.MEMPOOL_API}/contributors/images/${req.params.id}`, {
|
||||
responseType: 'stream', timeout: 10000
|
||||
});
|
||||
response.data.pipe(res);
|
||||
} catch (e) {
|
||||
res.status(500).end();
|
||||
}
|
||||
})
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'translators', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${config.EXTERNAL_DATA_SERVER.MEMPOOL_API}/translators`, { responseType: 'stream', timeout: 10000 });
|
||||
response.data.pipe(res);
|
||||
} catch (e) {
|
||||
res.status(500).end();
|
||||
}
|
||||
})
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'translators/images/:id', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${config.EXTERNAL_DATA_SERVER.MEMPOOL_API}/translators/images/${req.params.id}`, {
|
||||
responseType: 'stream', timeout: 10000
|
||||
});
|
||||
response.data.pipe(res);
|
||||
} catch (e) {
|
||||
res.status(500).end();
|
||||
}
|
||||
})
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks', this.getBlocks.bind(this))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks/:height', this.getBlocks.bind(this))
|
||||
.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)
|
||||
.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))
|
||||
;
|
||||
|
||||
if (config.MEMPOOL.BACKEND !== 'esplora') {
|
||||
app
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mempool', this.getMempool)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mempool/txids', this.getMempoolTxIds)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mempool/recent', this.getRecentMempoolTransactions)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId', this.getTransaction)
|
||||
.post(config.MEMPOOL.API_URL_PREFIX + 'tx', this.$postTransaction)
|
||||
.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 + '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)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/txs', this.getBlockTransactions)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/txs/:index', this.getBlockTransactions)
|
||||
.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 + 'address-prefix/:prefix', this.getAddressPrefix)
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private getInitData(req: Request, res: Response) {
|
||||
try {
|
||||
const result = websocketHandler.getInitData();
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private getRecommendedFees(req: Request, res: Response) {
|
||||
if (!mempool.isInSync()) {
|
||||
res.statusCode = 503;
|
||||
res.send('Service Unavailable');
|
||||
return;
|
||||
}
|
||||
const result = feeApi.getRecommendedFee();
|
||||
res.json(result);
|
||||
}
|
||||
|
||||
private getMempoolBlocks(req: Request, res: Response) {
|
||||
try {
|
||||
const result = mempoolBlocks.getMempoolBlocks();
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private getTransactionTimes(req: Request, res: Response) {
|
||||
if (!Array.isArray(req.query.txId)) {
|
||||
res.status(500).send('Not an array');
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
const times = mempool.getFirstSeenForTransactions(txIds);
|
||||
res.json(times);
|
||||
}
|
||||
|
||||
private async $getBatchedOutspends(req: Request, res: Response) {
|
||||
if (!Array.isArray(req.query.txId)) {
|
||||
res.status(500).send('Not an array');
|
||||
return;
|
||||
}
|
||||
if (req.query.txId.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);
|
||||
res.json(batchedOutspends);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getCpfpInfo(req: Request, res: Response) {
|
||||
if (!/^[a-fA-F0-9]{64}$/.test(req.params.txId)) {
|
||||
res.status(501).send(`Invalid transaction ID.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tx = mempool.getMempool()[req.params.txId];
|
||||
if (tx) {
|
||||
if (tx?.cpfpChecked) {
|
||||
res.json({
|
||||
ancestors: tx.ancestors,
|
||||
bestDescendant: tx.bestDescendant || null,
|
||||
descendants: tx.descendants || null,
|
||||
effectiveFeePerVsize: tx.effectiveFeePerVsize || null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const cpfpInfo = Common.setRelativesAndGetCpfpInfo(tx, mempool.getMempool());
|
||||
|
||||
res.json(cpfpInfo);
|
||||
return;
|
||||
} else {
|
||||
let cpfpInfo;
|
||||
if (config.DATABASE.ENABLED) {
|
||||
cpfpInfo = await transactionRepository.$getCpfpInfo(req.params.txId);
|
||||
}
|
||||
if (cpfpInfo) {
|
||||
res.json(cpfpInfo);
|
||||
return;
|
||||
} else {
|
||||
res.json({
|
||||
ancestors: []
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getBackendInfo(req: Request, res: Response) {
|
||||
res.json(backendInfo.getBackendInfo());
|
||||
}
|
||||
|
||||
private async getTransaction(req: Request, res: Response) {
|
||||
try {
|
||||
const transaction = await transactionUtils.$getTransactionExtended(req.params.txId, true);
|
||||
res.json(transaction);
|
||||
} catch (e) {
|
||||
let statusCode = 500;
|
||||
if (e instanceof Error && e instanceof Error && e.message && e.message.indexOf('No such mempool or blockchain transaction') > -1) {
|
||||
statusCode = 404;
|
||||
}
|
||||
res.status(statusCode).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async getRawTransaction(req: Request, res: Response) {
|
||||
try {
|
||||
const transaction: IEsploraApi.Transaction = await bitcoinApi.$getRawTransaction(req.params.txId, true);
|
||||
res.setHeader('content-type', 'text/plain');
|
||||
res.send(transaction.hex);
|
||||
} catch (e) {
|
||||
let statusCode = 500;
|
||||
if (e instanceof Error && e.message && e.message.indexOf('No such mempool or blockchain transaction') > -1) {
|
||||
statusCode = 404;
|
||||
}
|
||||
res.status(statusCode).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the PSBT as text/plain body, parses it, and adds the full
|
||||
* parent transaction to each input that doesn't already have it.
|
||||
* This is used for BTCPayServer / Trezor users which need access to
|
||||
* the full parent transaction even with segwit inputs.
|
||||
* It will respond with a text/plain PSBT in the same format (hex|base64).
|
||||
*/
|
||||
private async postPsbtCompletion(req: Request, res: Response): Promise<void> {
|
||||
res.setHeader('content-type', 'text/plain');
|
||||
const notFoundError = `Couldn't get transaction hex for parent of input`;
|
||||
try {
|
||||
let psbt: bitcoinjs.Psbt;
|
||||
let format: 'hex' | 'base64';
|
||||
let isModified = false;
|
||||
try {
|
||||
psbt = bitcoinjs.Psbt.fromBase64(req.body);
|
||||
format = 'base64';
|
||||
} catch (e1) {
|
||||
try {
|
||||
psbt = bitcoinjs.Psbt.fromHex(req.body);
|
||||
format = 'hex';
|
||||
} catch (e2) {
|
||||
throw new Error(`Unable to parse PSBT`);
|
||||
}
|
||||
}
|
||||
for (const [index, input] of psbt.data.inputs.entries()) {
|
||||
if (!input.nonWitnessUtxo) {
|
||||
// Buffer.from ensures it won't be modified in place by reverse()
|
||||
const txid = Buffer.from(psbt.txInputs[index].hash)
|
||||
.reverse()
|
||||
.toString('hex');
|
||||
|
||||
let transactionHex: string;
|
||||
// If missing transaction, return 404 status error
|
||||
try {
|
||||
transactionHex = await bitcoinApi.$getTransactionHex(txid);
|
||||
if (!transactionHex) {
|
||||
throw new Error('');
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error(`${notFoundError} #${index} @ ${txid}`);
|
||||
}
|
||||
|
||||
psbt.updateInput(index, {
|
||||
nonWitnessUtxo: Buffer.from(transactionHex, 'hex'),
|
||||
});
|
||||
if (!isModified) {
|
||||
isModified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isModified) {
|
||||
res.send(format === 'hex' ? psbt.toHex() : psbt.toBase64());
|
||||
} else {
|
||||
// Not modified
|
||||
// 422 Unprocessable Entity
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/422
|
||||
res.status(422).send(`Psbt had no missing nonWitnessUtxos.`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e instanceof Error && new RegExp(notFoundError).test(e.message)) {
|
||||
res.status(404).send(e.message);
|
||||
} else {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getTransactionStatus(req: Request, res: Response) {
|
||||
try {
|
||||
const transaction = await transactionUtils.$getTransactionExtended(req.params.txId, true);
|
||||
res.json(transaction.status);
|
||||
} catch (e) {
|
||||
let statusCode = 500;
|
||||
if (e instanceof Error && e.message && e.message.indexOf('No such mempool or blockchain transaction') > -1) {
|
||||
statusCode = 404;
|
||||
}
|
||||
res.status(statusCode).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async getStrippedBlockTransactions(req: Request, res: Response) {
|
||||
try {
|
||||
const transactions = await blocks.$getStrippedBlockTransactions(req.params.hash);
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString());
|
||||
res.json(transactions);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async getBlock(req: Request, res: Response) {
|
||||
try {
|
||||
const block = await blocks.$getBlock(req.params.hash);
|
||||
|
||||
const blockAge = new Date().getTime() / 1000 - block.timestamp;
|
||||
const day = 24 * 3600;
|
||||
let cacheDuration;
|
||||
if (blockAge > 365 * day) {
|
||||
cacheDuration = 30 * day;
|
||||
} else if (blockAge > 30 * day) {
|
||||
cacheDuration = 10 * day;
|
||||
} else {
|
||||
cacheDuration = 600
|
||||
}
|
||||
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * cacheDuration).toUTCString());
|
||||
res.json(block);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async getBlockHeader(req: Request, res: Response) {
|
||||
try {
|
||||
const blockHeader = await bitcoinApi.$getBlockHeader(req.params.hash);
|
||||
res.setHeader('content-type', 'text/plain');
|
||||
res.send(blockHeader);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async getBlocks(req: Request, res: Response) {
|
||||
try {
|
||||
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) { // Bitcoin
|
||||
const height = req.params.height === undefined ? undefined : parseInt(req.params.height, 10);
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
res.json(await blocks.$getBlocks(height, 15));
|
||||
} else { // Liquid, Bisq
|
||||
return await this.getLegacyBlocks(req, res);
|
||||
}
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async getBlocksByBulk(req: Request, res: Response) {
|
||||
try {
|
||||
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === false) { // Liquid, Bisq - Not implemented
|
||||
return res.status(404).send(`This API is only available for Bitcoin networks`);
|
||||
}
|
||||
if (config.MEMPOOL.MAX_BLOCKS_BULK_QUERY <= 0) {
|
||||
return res.status(404).send(`This API is disabled. Set config.MEMPOOL.MAX_BLOCKS_BULK_QUERY to a positive number to enable it.`);
|
||||
}
|
||||
if (!Common.indexingEnabled()) {
|
||||
return res.status(404).send(`Indexing is required for this API`);
|
||||
}
|
||||
|
||||
const from = parseInt(req.params.from, 10);
|
||||
if (!req.params.from || from < 0) {
|
||||
return res.status(400).send(`Parameter 'from' must be a block height (integer)`);
|
||||
}
|
||||
const to = req.params.to === undefined ? await bitcoinApi.$getBlockHeightTip() : parseInt(req.params.to, 10);
|
||||
if (to < 0) {
|
||||
return res.status(400).send(`Parameter 'to' must be a block height (integer)`);
|
||||
}
|
||||
if (from > to) {
|
||||
return res.status(400).send(`Parameter 'to' must be a higher block height than 'from'`);
|
||||
}
|
||||
if ((to - from + 1) > config.MEMPOOL.MAX_BLOCKS_BULK_QUERY) {
|
||||
return res.status(400).send(`You can only query ${config.MEMPOOL.MAX_BLOCKS_BULK_QUERY} blocks at once.`);
|
||||
}
|
||||
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
res.json(await blocks.$getBlocksBetweenHeight(from, to));
|
||||
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async getLegacyBlocks(req: Request, res: Response) {
|
||||
try {
|
||||
const returnBlocks: IEsploraApi.Block[] = [];
|
||||
const tip = blocks.getCurrentBlockHeight();
|
||||
const fromHeight = Math.min(parseInt(req.params.height, 10) || tip, tip);
|
||||
|
||||
// Check if block height exist in local cache to skip the hash lookup
|
||||
const blockByHeight = blocks.getBlocks().find((b) => b.height === fromHeight);
|
||||
let startFromHash: string | null = null;
|
||||
if (blockByHeight) {
|
||||
startFromHash = blockByHeight.id;
|
||||
} else {
|
||||
startFromHash = await bitcoinApi.$getBlockHash(fromHeight);
|
||||
}
|
||||
|
||||
let nextHash = startFromHash;
|
||||
for (let i = 0; i < 10 && 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);
|
||||
returnBlocks.push(block);
|
||||
nextHash = block.previousblockhash;
|
||||
}
|
||||
}
|
||||
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
res.json(returnBlocks);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async getBlockTransactions(req: Request, res: Response) {
|
||||
try {
|
||||
loadingIndicators.setProgress('blocktxs-' + req.params.hash, 0);
|
||||
|
||||
const txIds = await bitcoinApi.$getTxIdsForBlock(req.params.hash);
|
||||
const transactions: TransactionExtended[] = [];
|
||||
const startingIndex = Math.max(0, parseInt(req.params.index || '0', 10));
|
||||
|
||||
const endIndex = Math.min(startingIndex + 10, txIds.length);
|
||||
for (let i = startingIndex; i < endIndex; i++) {
|
||||
try {
|
||||
const transaction = await transactionUtils.$getTransactionExtended(txIds[i], true, true);
|
||||
transactions.push(transaction);
|
||||
loadingIndicators.setProgress('blocktxs-' + req.params.hash, (i - startingIndex + 1) / (endIndex - startingIndex) * 100);
|
||||
} catch (e) {
|
||||
logger.debug('getBlockTransactions error: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
res.json(transactions);
|
||||
} catch (e) {
|
||||
loadingIndicators.setProgress('blocktxs-' + req.params.hash, 100);
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async getBlockHeight(req: Request, res: Response) {
|
||||
try {
|
||||
const blockHash = await bitcoinApi.$getBlockHash(parseInt(req.params.height, 10));
|
||||
res.send(blockHash);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async getAddress(req: Request, res: Response) {
|
||||
if (config.MEMPOOL.BACKEND === 'none') {
|
||||
res.status(405).send('Address lookups cannot be used with bitcoind as backend.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const addressData = await bitcoinApi.$getAddress(req.params.address);
|
||||
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);
|
||||
}
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async getAddressTransactions(req: Request, res: Response) {
|
||||
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);
|
||||
res.json(transactions);
|
||||
} 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);
|
||||
}
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async getAdressTxChain(req: Request, res: Response) {
|
||||
res.status(501).send('Not implemented');
|
||||
}
|
||||
|
||||
private async getAddressPrefix(req: Request, res: Response) {
|
||||
try {
|
||||
const blockHash = await bitcoinApi.$getAddressPrefix(req.params.prefix);
|
||||
res.send(blockHash);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async getRecentMempoolTransactions(req: Request, res: Response) {
|
||||
const latestTransactions = Object.entries(mempool.getMempool())
|
||||
.sort((a, b) => (b[1].firstSeen || 0) - (a[1].firstSeen || 0))
|
||||
.slice(0, 10).map((tx) => Common.stripTransaction(tx[1]));
|
||||
|
||||
res.json(latestTransactions);
|
||||
}
|
||||
|
||||
private async getMempool(req: Request, res: Response) {
|
||||
const info = mempool.getMempoolInfo();
|
||||
res.json({
|
||||
count: info.size,
|
||||
vsize: info.bytes,
|
||||
total_fee: info.total_fee * 1e8,
|
||||
fee_histogram: []
|
||||
});
|
||||
}
|
||||
|
||||
private async getMempoolTxIds(req: Request, res: Response) {
|
||||
try {
|
||||
const rawMempool = await bitcoinApi.$getRawMempool();
|
||||
res.send(rawMempool);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async getBlockTipHeight(req: Request, res: Response) {
|
||||
try {
|
||||
const result = await bitcoinApi.$getBlockHeightTip();
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async getBlockTipHash(req: Request, res: Response) {
|
||||
try {
|
||||
const result = await bitcoinApi.$getBlockHashTip();
|
||||
res.setHeader('content-type', 'text/plain');
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async getRawBlock(req: Request, res: Response) {
|
||||
try {
|
||||
const result = await bitcoinApi.$getRawBlock(req.params.hash);
|
||||
res.setHeader('content-type', 'application/octet-stream');
|
||||
res.send(result);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async getTxIdsForBlock(req: Request, res: Response) {
|
||||
try {
|
||||
const result = await bitcoinApi.$getTxIdsForBlock(req.params.hash);
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async validateAddress(req: Request, res: Response) {
|
||||
try {
|
||||
const result = await bitcoinClient.validateAddress(req.params.address);
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async getRbfHistory(req: Request, res: Response) {
|
||||
try {
|
||||
const result = rbfCache.getReplaces(req.params.txId);
|
||||
res.json(result || []);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async getCachedTx(req: Request, res: Response) {
|
||||
try {
|
||||
const result = rbfCache.getTx(req.params.txId);
|
||||
if (result) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(204).send();
|
||||
}
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async getTransactionOutspends(req: Request, res: Response) {
|
||||
try {
|
||||
const result = await bitcoinApi.$getOutspends(req.params.txId);
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private getDifficultyChange(req: Request, res: Response) {
|
||||
try {
|
||||
const da = difficultyAdjustment.getDifficultyAdjustment();
|
||||
if (da) {
|
||||
res.json(da);
|
||||
} else {
|
||||
res.status(503).send(`Service Temporarily Unavailable`);
|
||||
}
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
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 txIdResult = await bitcoinApi.$sendRawTransaction(rawTx);
|
||||
res.send(txIdResult);
|
||||
} catch (e: any) {
|
||||
res.status(400).send(e.message && e.code ? 'sendrawtransaction RPC error: ' + JSON.stringify({ code: e.code, message: e.message })
|
||||
: (e.message || 'Error'));
|
||||
}
|
||||
}
|
||||
|
||||
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 txIdResult = await bitcoinClient.sendRawTransaction(txHex);
|
||||
res.send(txIdResult);
|
||||
} catch (e: any) {
|
||||
res.status(400).send(e.message && e.code ? 'sendrawtransaction RPC error: ' + JSON.stringify({ code: e.code, message: e.message })
|
||||
: (e.message || 'Error'));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default new BitcoinRoutes();
|
||||
@@ -1,12 +1,11 @@
|
||||
import config from '../../config';
|
||||
import Client from '@mempool/electrum-client';
|
||||
import { AbstractBitcoinApi } from './bitcoin-api-abstract-factory';
|
||||
import { IEsploraApi } from './esplora-api.interface';
|
||||
import { IElectrumApi } from './electrum-api.interface';
|
||||
import BitcoinApi from './bitcoin-api';
|
||||
import logger from '../../logger';
|
||||
import * as ElectrumClient from '@mempool/electrum-client';
|
||||
import * as sha256 from 'crypto-js/sha256';
|
||||
import * as hexEnc from 'crypto-js/enc-hex';
|
||||
import crypto from "crypto-js";
|
||||
import loadingIndicators from '../loading-indicators';
|
||||
import memoryCache from '../memory-cache';
|
||||
|
||||
@@ -26,7 +25,7 @@ class BitcoindElectrsApi extends BitcoinApi implements AbstractBitcoinApi {
|
||||
onLog: (str) => { logger.debug(str); },
|
||||
};
|
||||
|
||||
this.electrumClient = new ElectrumClient(
|
||||
this.electrumClient = new Client(
|
||||
config.ELECTRUM.PORT,
|
||||
config.ELECTRUM.HOST,
|
||||
config.ELECTRUM.TLS_ENABLED ? 'tls' : 'tcp',
|
||||
@@ -35,7 +34,7 @@ class BitcoindElectrsApi extends BitcoinApi implements AbstractBitcoinApi {
|
||||
);
|
||||
|
||||
this.electrumClient.initElectrum(electrumConfig, electrumPersistencePolicy)
|
||||
.then(() => {})
|
||||
.then(() => { })
|
||||
.catch((err) => {
|
||||
logger.err(`Error connecting to Electrum Server at ${config.ELECTRUM.HOST}:${config.ELECTRUM.PORT}`);
|
||||
});
|
||||
@@ -95,7 +94,7 @@ class BitcoindElectrsApi extends BitcoinApi implements AbstractBitcoinApi {
|
||||
async $getAddressTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]> {
|
||||
const addressInfo = await this.bitcoindClient.validateAddress(address);
|
||||
if (!addressInfo || !addressInfo.isvalid) {
|
||||
return [];
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -144,8 +143,8 @@ class BitcoindElectrsApi extends BitcoinApi implements AbstractBitcoinApi {
|
||||
}
|
||||
|
||||
private encodeScriptHash(scriptPubKey: string): string {
|
||||
const addrScripthash = hexEnc.stringify(sha256(hexEnc.parse(scriptPubKey)));
|
||||
return addrScripthash.match(/.{2}/g).reverse().join('');
|
||||
const addrScripthash = crypto.enc.Hex.stringify(crypto.SHA256(crypto.enc.Hex.parse(scriptPubKey)));
|
||||
return addrScripthash!.match(/.{2}/g)!.reverse().join('');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -25,10 +25,10 @@ export namespace IEsploraApi {
|
||||
is_coinbase: boolean;
|
||||
scriptsig: string;
|
||||
scriptsig_asm: string;
|
||||
inner_redeemscript_asm?: string;
|
||||
inner_witnessscript_asm?: string;
|
||||
inner_redeemscript_asm: string;
|
||||
inner_witnessscript_asm: string;
|
||||
sequence: any;
|
||||
witness?: string[];
|
||||
witness: string[];
|
||||
prevout: Vout | null;
|
||||
// Elements
|
||||
is_pegin?: boolean;
|
||||
@@ -88,6 +88,7 @@ export namespace IEsploraApi {
|
||||
size: number;
|
||||
weight: number;
|
||||
previousblockhash: string;
|
||||
mediantime: number;
|
||||
}
|
||||
|
||||
export interface Address {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import config from '../../config';
|
||||
import axios, { AxiosRequestConfig } from 'axios';
|
||||
import http from 'http';
|
||||
import { AbstractBitcoinApi } from './bitcoin-api-abstract-factory';
|
||||
import { IEsploraApi } from './esplora-api.interface';
|
||||
|
||||
const axiosConnection = axios.create({
|
||||
httpAgent: new http.Agent({ keepAlive: true })
|
||||
});
|
||||
|
||||
class ElectrsApi implements AbstractBitcoinApi {
|
||||
axiosConfig: AxiosRequestConfig = {
|
||||
timeout: 10000,
|
||||
@@ -11,40 +16,55 @@ class ElectrsApi implements AbstractBitcoinApi {
|
||||
constructor() { }
|
||||
|
||||
$getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]> {
|
||||
return axios.get<IEsploraApi.Transaction['txid'][]>(config.ESPLORA.REST_API_URL + '/mempool/txids', this.axiosConfig)
|
||||
return axiosConnection.get<IEsploraApi.Transaction['txid'][]>(config.ESPLORA.REST_API_URL + '/mempool/txids', this.axiosConfig)
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
$getRawTransaction(txId: string): Promise<IEsploraApi.Transaction> {
|
||||
return axios.get<IEsploraApi.Transaction>(config.ESPLORA.REST_API_URL + '/tx/' + txId, this.axiosConfig)
|
||||
return axiosConnection.get<IEsploraApi.Transaction>(config.ESPLORA.REST_API_URL + '/tx/' + txId, this.axiosConfig)
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
$getTransactionHex(txId: string): Promise<string> {
|
||||
return axiosConnection.get<string>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/hex', this.axiosConfig)
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
$getBlockHeightTip(): Promise<number> {
|
||||
return axios.get<number>(config.ESPLORA.REST_API_URL + '/blocks/tip/height', this.axiosConfig)
|
||||
return axiosConnection.get<number>(config.ESPLORA.REST_API_URL + '/blocks/tip/height', this.axiosConfig)
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
$getBlockHashTip(): Promise<string> {
|
||||
return axiosConnection.get<string>(config.ESPLORA.REST_API_URL + '/blocks/tip/hash', this.axiosConfig)
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
$getTxIdsForBlock(hash: string): Promise<string[]> {
|
||||
return axios.get<string[]>(config.ESPLORA.REST_API_URL + '/block/' + hash + '/txids', this.axiosConfig)
|
||||
return axiosConnection.get<string[]>(config.ESPLORA.REST_API_URL + '/block/' + hash + '/txids', this.axiosConfig)
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
$getBlockHash(height: number): Promise<string> {
|
||||
return axios.get<string>(config.ESPLORA.REST_API_URL + '/block-height/' + height, this.axiosConfig)
|
||||
return axiosConnection.get<string>(config.ESPLORA.REST_API_URL + '/block-height/' + height, this.axiosConfig)
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
$getBlockHeader(hash: string): Promise<string> {
|
||||
return axios.get<string>(config.ESPLORA.REST_API_URL + '/block/' + hash + '/header', this.axiosConfig)
|
||||
return axiosConnection.get<string>(config.ESPLORA.REST_API_URL + '/block/' + hash + '/header', this.axiosConfig)
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
$getBlock(hash: string): Promise<IEsploraApi.Block> {
|
||||
return axios.get<IEsploraApi.Block>(config.ESPLORA.REST_API_URL + '/block/' + hash, this.axiosConfig)
|
||||
return axiosConnection.get<IEsploraApi.Block>(config.ESPLORA.REST_API_URL + '/block/' + hash, this.axiosConfig)
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
$getRawBlock(hash: string): Promise<Buffer> {
|
||||
return axiosConnection.get<string>(config.ESPLORA.REST_API_URL + '/block/' + hash + "/raw", { ...this.axiosConfig, responseType: 'arraybuffer' })
|
||||
.then((response) => { return Buffer.from(response.data); });
|
||||
}
|
||||
|
||||
$getAddress(address: string): Promise<IEsploraApi.Address> {
|
||||
throw new Error('Method getAddress not implemented.');
|
||||
}
|
||||
@@ -61,8 +81,23 @@ class ElectrsApi implements AbstractBitcoinApi {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
$getOutspends(): Promise<IEsploraApi.Outspend[]> {
|
||||
throw new Error('Method not implemented.');
|
||||
$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);
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +1,40 @@
|
||||
import config from '../config';
|
||||
import bitcoinApi from './bitcoin/bitcoin-api-factory';
|
||||
import bitcoinApi, { bitcoinCoreApi } from './bitcoin/bitcoin-api-factory';
|
||||
import logger from '../logger';
|
||||
import memPool from './mempool';
|
||||
import { BlockExtended, PoolTag, TransactionExtended, TransactionMinerInfo } from '../mempool.interfaces';
|
||||
import { BlockExtended, BlockExtension, BlockSummary, PoolTag, TransactionExtended, TransactionStripped, TransactionMinerInfo } from '../mempool.interfaces';
|
||||
import { Common } from './common';
|
||||
import diskCache from './disk-cache';
|
||||
import transactionUtils from './transaction-utils';
|
||||
import bitcoinClient from './bitcoin/bitcoin-client';
|
||||
import { IBitcoinApi } from './bitcoin/bitcoin-api.interface';
|
||||
import { IEsploraApi } from './bitcoin/esplora-api.interface';
|
||||
import poolsRepository from '../repositories/PoolsRepository';
|
||||
import blocksRepository from '../repositories/BlocksRepository';
|
||||
import loadingIndicators from './loading-indicators';
|
||||
import BitcoinApi from './bitcoin/bitcoin-api';
|
||||
import { prepareBlock } from '../utils/blocks-utils';
|
||||
import BlocksRepository from '../repositories/BlocksRepository';
|
||||
import HashratesRepository from '../repositories/HashratesRepository';
|
||||
import indexer from '../indexer';
|
||||
import fiatConversion from './fiat-conversion';
|
||||
import RatesRepository from '../repositories/RatesRepository';
|
||||
import poolsParser from './pools-parser';
|
||||
import BlocksSummariesRepository from '../repositories/BlocksSummariesRepository';
|
||||
import BlocksAuditsRepository from '../repositories/BlocksAuditsRepository';
|
||||
import cpfpRepository from '../repositories/CpfpRepository';
|
||||
import mining from './mining/mining';
|
||||
import DifficultyAdjustmentsRepository from '../repositories/DifficultyAdjustmentsRepository';
|
||||
import PricesRepository from '../repositories/PricesRepository';
|
||||
import priceUpdater from '../tasks/price-updater';
|
||||
import chainTips from './chain-tips';
|
||||
|
||||
class Blocks {
|
||||
private blocks: BlockExtended[] = [];
|
||||
private blockSummaries: BlockSummary[] = [];
|
||||
private currentBlockHeight = 0;
|
||||
private currentDifficulty = 0;
|
||||
private lastDifficultyAdjustmentTime = 0;
|
||||
private previousDifficultyRetarget = 0;
|
||||
private newBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void)[] = [];
|
||||
private newAsyncBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => Promise<void>)[] = [];
|
||||
|
||||
constructor() { }
|
||||
|
||||
@@ -38,10 +46,22 @@ class Blocks {
|
||||
this.blocks = blocks;
|
||||
}
|
||||
|
||||
public getBlockSummaries(): BlockSummary[] {
|
||||
return this.blockSummaries;
|
||||
}
|
||||
|
||||
public setBlockSummaries(blockSummaries: BlockSummary[]) {
|
||||
this.blockSummaries = blockSummaries;
|
||||
}
|
||||
|
||||
public setNewBlockCallback(fn: (block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void) {
|
||||
this.newBlockCallbacks.push(fn);
|
||||
}
|
||||
|
||||
public setNewAsyncBlockCallback(fn: (block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => Promise<void>) {
|
||||
this.newAsyncBlockCallbacks.push(fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the list of transaction for a block
|
||||
* @param blockHash
|
||||
@@ -78,12 +98,23 @@ class Blocks {
|
||||
transactions.push(tx);
|
||||
transactionsFetched++;
|
||||
} catch (e) {
|
||||
if (i === 0) {
|
||||
const msg = `Cannot fetch coinbase tx ${txIds[i]}. Reason: ` + (e instanceof Error ? e.message : e);
|
||||
logger.err(msg);
|
||||
throw new Error(msg);
|
||||
} else {
|
||||
logger.err(`Cannot fetch tx ${txIds[i]}. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
try {
|
||||
if (config.MEMPOOL.BACKEND === 'esplora') {
|
||||
// Try again with core
|
||||
const tx = await transactionUtils.$getTransactionExtended(txIds[i], false, false, true);
|
||||
transactions.push(tx);
|
||||
transactionsFetched++;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
} catch (e) {
|
||||
if (i === 0) {
|
||||
const msg = `Cannot fetch coinbase tx ${txIds[i]}. Reason: ` + (e instanceof Error ? e.message : e);
|
||||
logger.err(msg);
|
||||
throw new Error(msg);
|
||||
} else {
|
||||
logger.err(`Cannot fetch tx ${txIds[i]}. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,6 +137,37 @@ class Blocks {
|
||||
return transactions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a block summary (list of stripped transactions)
|
||||
* @param block
|
||||
* @returns BlockSummary
|
||||
*/
|
||||
public summarizeBlock(block: IBitcoinApi.VerboseBlock): BlockSummary {
|
||||
if (Common.isLiquid()) {
|
||||
block = this.convertLiquidFees(block);
|
||||
}
|
||||
const stripped = block.tx.map((tx: IBitcoinApi.VerboseTransaction) => {
|
||||
return {
|
||||
txid: tx.txid,
|
||||
vsize: tx.weight / 4,
|
||||
fee: tx.fee ? Math.round(tx.fee * 100000000) : 0,
|
||||
value: Math.round(tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0) * 100000000)
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: block.hash,
|
||||
transactions: stripped
|
||||
};
|
||||
}
|
||||
|
||||
private convertLiquidFees(block: IBitcoinApi.VerboseBlock): IBitcoinApi.VerboseBlock {
|
||||
block.tx.forEach(tx => {
|
||||
tx.fee = Object.values(tx.fee || {}).reduce((total, output) => total + output, 0);
|
||||
});
|
||||
return block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a block with additional data (reward, coinbase, fees...)
|
||||
* @param block
|
||||
@@ -113,32 +175,81 @@ class Blocks {
|
||||
* @returns BlockExtended
|
||||
*/
|
||||
private async $getBlockExtended(block: IEsploraApi.Block, transactions: TransactionExtended[]): Promise<BlockExtended> {
|
||||
const blockExtended: BlockExtended = Object.assign({ extras: {} }, block);
|
||||
blockExtended.extras.reward = transactions[0].vout.reduce((acc, curr) => acc + curr.value, 0);
|
||||
blockExtended.extras.coinbaseTx = transactionUtils.stripCoinbaseTransaction(transactions[0]);
|
||||
blockExtended.extras.coinbaseRaw = blockExtended.extras.coinbaseTx.vin[0].scriptsig;
|
||||
const coinbaseTx = transactionUtils.stripCoinbaseTransaction(transactions[0]);
|
||||
|
||||
const blk: Partial<BlockExtended> = Object.assign({}, block);
|
||||
const extras: Partial<BlockExtension> = {};
|
||||
|
||||
extras.reward = transactions[0].vout.reduce((acc, curr) => acc + curr.value, 0);
|
||||
extras.coinbaseRaw = coinbaseTx.vin[0].scriptsig;
|
||||
extras.orphans = chainTips.getOrphanedBlocksAtHeight(blk.height);
|
||||
|
||||
if (block.height === 0) {
|
||||
blockExtended.extras.medianFee = 0; // 50th percentiles
|
||||
blockExtended.extras.feeRange = [0, 0, 0, 0, 0, 0, 0];
|
||||
blockExtended.extras.totalFees = 0;
|
||||
blockExtended.extras.avgFee = 0;
|
||||
blockExtended.extras.avgFeeRate = 0;
|
||||
extras.medianFee = 0; // 50th percentiles
|
||||
extras.feeRange = [0, 0, 0, 0, 0, 0, 0];
|
||||
extras.totalFees = 0;
|
||||
extras.avgFee = 0;
|
||||
extras.avgFeeRate = 0;
|
||||
extras.utxoSetChange = 0;
|
||||
extras.avgTxSize = 0;
|
||||
extras.totalInputs = 0;
|
||||
extras.totalOutputs = 1;
|
||||
extras.totalOutputAmt = 0;
|
||||
extras.segwitTotalTxs = 0;
|
||||
extras.segwitTotalSize = 0;
|
||||
extras.segwitTotalWeight = 0;
|
||||
} else {
|
||||
const stats = await bitcoinClient.getBlockStats(block.id, [
|
||||
'feerate_percentiles', 'minfeerate', 'maxfeerate', 'totalfee', 'avgfee', 'avgfeerate'
|
||||
]);
|
||||
blockExtended.extras.medianFee = stats.feerate_percentiles[2]; // 50th percentiles
|
||||
blockExtended.extras.feeRange = [stats.minfeerate, stats.feerate_percentiles, stats.maxfeerate].flat();
|
||||
blockExtended.extras.totalFees = stats.totalfee;
|
||||
blockExtended.extras.avgFee = stats.avgfee;
|
||||
blockExtended.extras.avgFeeRate = stats.avgfeerate;
|
||||
const stats: IBitcoinApi.BlockStats = await bitcoinClient.getBlockStats(block.id);
|
||||
extras.medianFee = stats.feerate_percentiles[2]; // 50th percentiles
|
||||
extras.feeRange = [stats.minfeerate, stats.feerate_percentiles, stats.maxfeerate].flat();
|
||||
extras.totalFees = stats.totalfee;
|
||||
extras.avgFee = stats.avgfee;
|
||||
extras.avgFeeRate = stats.avgfeerate;
|
||||
extras.utxoSetChange = stats.utxo_increase;
|
||||
extras.avgTxSize = Math.round(stats.total_size / stats.txs * 100) * 0.01;
|
||||
extras.totalInputs = stats.ins;
|
||||
extras.totalOutputs = stats.outs;
|
||||
extras.totalOutputAmt = stats.total_out;
|
||||
extras.segwitTotalTxs = stats.swtxs;
|
||||
extras.segwitTotalSize = stats.swtotal_size;
|
||||
extras.segwitTotalWeight = stats.swtotal_weight;
|
||||
}
|
||||
|
||||
if (['mainnet', 'testnet', 'signet', 'regtest'].includes(config.MEMPOOL.NETWORK)) {
|
||||
if (Common.blocksSummariesIndexingEnabled()) {
|
||||
extras.feePercentiles = await BlocksSummariesRepository.$getFeePercentilesByBlockId(block.id);
|
||||
if (extras.feePercentiles !== null) {
|
||||
extras.medianFeeAmt = extras.feePercentiles[3];
|
||||
}
|
||||
}
|
||||
|
||||
extras.virtualSize = block.weight / 4.0;
|
||||
if (coinbaseTx?.vout.length > 0) {
|
||||
extras.coinbaseAddress = coinbaseTx.vout[0].scriptpubkey_address ?? null;
|
||||
extras.coinbaseSignature = coinbaseTx.vout[0].scriptpubkey_asm ?? null;
|
||||
extras.coinbaseSignatureAscii = transactionUtils.hex2ascii(coinbaseTx.vin[0].scriptsig) ?? null;
|
||||
} else {
|
||||
extras.coinbaseAddress = null;
|
||||
extras.coinbaseSignature = null;
|
||||
extras.coinbaseSignatureAscii = null;
|
||||
}
|
||||
|
||||
const header = await bitcoinClient.getBlockHeader(block.id, false);
|
||||
extras.header = header;
|
||||
|
||||
const coinStatsIndex = indexer.isCoreIndexReady('coinstatsindex');
|
||||
if (coinStatsIndex !== null && coinStatsIndex.best_block_height >= block.height) {
|
||||
const txoutset = await bitcoinClient.getTxoutSetinfo('none', block.height);
|
||||
extras.utxoSetSize = txoutset.txouts,
|
||||
extras.totalInputAmt = Math.round(txoutset.block_info.prevout_spent * 100000000);
|
||||
} else {
|
||||
extras.utxoSetSize = null;
|
||||
extras.totalInputAmt = null;
|
||||
}
|
||||
|
||||
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) {
|
||||
let pool: PoolTag;
|
||||
if (blockExtended.extras?.coinbaseTx !== undefined) {
|
||||
pool = await this.$findBlockMiner(blockExtended.extras?.coinbaseTx);
|
||||
if (coinbaseTx !== undefined) {
|
||||
pool = await this.$findBlockMiner(coinbaseTx);
|
||||
} else {
|
||||
if (config.DATABASE.ENABLED === true) {
|
||||
pool = await poolsRepository.$getUnknownPool();
|
||||
@@ -148,19 +259,27 @@ class Blocks {
|
||||
}
|
||||
|
||||
if (!pool) { // We should never have this situation in practise
|
||||
logger.warn(`Cannot assign pool to block ${blockExtended.height} and 'unknown' pool does not exist. ` +
|
||||
logger.warn(`Cannot assign pool to block ${blk.height} and 'unknown' pool does not exist. ` +
|
||||
`Check your "pools" table entries`);
|
||||
return blockExtended;
|
||||
} else {
|
||||
extras.pool = {
|
||||
id: pool.uniqueId,
|
||||
name: pool.name,
|
||||
slug: pool.slug,
|
||||
};
|
||||
}
|
||||
|
||||
blockExtended.extras.pool = {
|
||||
id: pool.id,
|
||||
name: pool.name,
|
||||
slug: pool.slug,
|
||||
};
|
||||
extras.matchRate = null;
|
||||
if (config.MEMPOOL.AUDIT) {
|
||||
const auditScore = await BlocksAuditsRepository.$getBlockAuditScore(block.id);
|
||||
if (auditScore != null) {
|
||||
extras.matchRate = auditScore.matchRate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return blockExtended;
|
||||
blk.extras = <BlockExtension>extras;
|
||||
return <BlockExtended>blk;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,15 +305,18 @@ class Blocks {
|
||||
} else {
|
||||
pools = poolsParser.miningPools;
|
||||
}
|
||||
|
||||
for (let i = 0; i < pools.length; ++i) {
|
||||
if (address !== undefined) {
|
||||
const addresses: string[] = JSON.parse(pools[i].addresses);
|
||||
const addresses: string[] = typeof pools[i].addresses === 'string' ?
|
||||
JSON.parse(pools[i].addresses) : pools[i].addresses;
|
||||
if (addresses.indexOf(address) !== -1) {
|
||||
return pools[i];
|
||||
}
|
||||
}
|
||||
|
||||
const regexes: string[] = JSON.parse(pools[i].regexes);
|
||||
const regexes: string[] = typeof pools[i].regexes === 'string' ?
|
||||
JSON.parse(pools[i].regexes) : pools[i].regexes;
|
||||
for (let y = 0; y < regexes.length; ++y) {
|
||||
const regex = new RegExp(regexes[y], 'i');
|
||||
const match = asciiScriptSig.match(regex);
|
||||
@@ -212,15 +334,118 @@ class Blocks {
|
||||
}
|
||||
|
||||
/**
|
||||
* [INDEXING] Index all blocks metadata for the mining dashboard
|
||||
* [INDEXING] Index all blocks summaries for the block txs visualization
|
||||
*/
|
||||
public async $generateBlockDatabase() {
|
||||
const blockchainInfo = await bitcoinClient.getBlockchainInfo();
|
||||
if (blockchainInfo.blocks !== blockchainInfo.headers) { // Wait for node to sync
|
||||
public async $generateBlocksSummariesDatabase(): Promise<void> {
|
||||
if (Common.blocksSummariesIndexingEnabled() === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all indexed block hash
|
||||
const indexedBlocks = await blocksRepository.$getIndexedBlocks();
|
||||
const indexedBlockSummariesHashesArray = await BlocksSummariesRepository.$getIndexedSummariesId();
|
||||
|
||||
const indexedBlockSummariesHashes = {}; // Use a map for faster seek during the indexing loop
|
||||
for (const hash of indexedBlockSummariesHashesArray) {
|
||||
indexedBlockSummariesHashes[hash] = true;
|
||||
}
|
||||
|
||||
// Logging
|
||||
let newlyIndexed = 0;
|
||||
let totalIndexed = indexedBlockSummariesHashesArray.length;
|
||||
let indexedThisRun = 0;
|
||||
let timer = new Date().getTime() / 1000;
|
||||
const startedAt = new Date().getTime() / 1000;
|
||||
|
||||
for (const block of indexedBlocks) {
|
||||
if (indexedBlockSummariesHashes[block.hash] === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Logging
|
||||
const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - timer));
|
||||
if (elapsedSeconds > 5) {
|
||||
const runningFor = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt));
|
||||
const blockPerSeconds = Math.max(1, indexedThisRun / elapsedSeconds);
|
||||
const progress = Math.round(totalIndexed / indexedBlocks.length * 10000) / 100;
|
||||
logger.debug(`Indexing block summary for #${block.height} | ~${blockPerSeconds.toFixed(2)} blocks/sec | total: ${totalIndexed}/${indexedBlocks.length} (${progress}%) | elapsed: ${runningFor} seconds`, logger.tags.mining);
|
||||
timer = new Date().getTime() / 1000;
|
||||
indexedThisRun = 0;
|
||||
}
|
||||
|
||||
await this.$getStrippedBlockTransactions(block.hash, true, true); // This will index the block summary
|
||||
|
||||
// Logging
|
||||
indexedThisRun++;
|
||||
totalIndexed++;
|
||||
newlyIndexed++;
|
||||
}
|
||||
if (newlyIndexed > 0) {
|
||||
logger.notice(`Blocks summaries indexing completed: indexed ${newlyIndexed} blocks`, logger.tags.mining);
|
||||
} else {
|
||||
logger.debug(`Blocks summaries indexing completed: indexed ${newlyIndexed} blocks`, logger.tags.mining);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err(`Blocks summaries indexing failed. Trying again in 10 seconds. Reason: ${(e instanceof Error ? e.message : e)}`, logger.tags.mining);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [INDEXING] Index transaction CPFP data for all blocks
|
||||
*/
|
||||
public async $generateCPFPDatabase(): Promise<void> {
|
||||
if (Common.cpfpIndexingEnabled() === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all indexed block hash
|
||||
const unindexedBlockHeights = await blocksRepository.$getCPFPUnindexedBlocks();
|
||||
logger.info(`Indexing cpfp data for ${unindexedBlockHeights.length} blocks`);
|
||||
|
||||
if (!unindexedBlockHeights?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Logging
|
||||
let count = 0;
|
||||
let countThisRun = 0;
|
||||
let timer = new Date().getTime() / 1000;
|
||||
const startedAt = new Date().getTime() / 1000;
|
||||
for (const height of unindexedBlockHeights) {
|
||||
// Logging
|
||||
const hash = await bitcoinApi.$getBlockHash(height);
|
||||
const elapsedSeconds = Math.max(1, new Date().getTime() / 1000 - timer);
|
||||
if (elapsedSeconds > 5) {
|
||||
const runningFor = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt));
|
||||
const blockPerSeconds = (countThisRun / elapsedSeconds);
|
||||
const progress = Math.round(count / unindexedBlockHeights.length * 10000) / 100;
|
||||
logger.debug(`Indexing cpfp clusters for #${height} | ~${blockPerSeconds.toFixed(2)} blocks/sec | total: ${count}/${unindexedBlockHeights.length} (${progress}%) | elapsed: ${runningFor} seconds`);
|
||||
timer = new Date().getTime() / 1000;
|
||||
countThisRun = 0;
|
||||
}
|
||||
|
||||
await this.$indexCPFP(hash, height); // Calculate and save CPFP data for transactions in this block
|
||||
|
||||
// Logging
|
||||
count++;
|
||||
countThisRun++;
|
||||
}
|
||||
logger.notice(`CPFP indexing completed: indexed ${count} blocks`);
|
||||
} catch (e) {
|
||||
logger.err(`CPFP indexing failed. Trying again in 10 seconds. Reason: ${(e instanceof Error ? e.message : e)}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [INDEXING] Index all blocks metadata for the mining dashboard
|
||||
*/
|
||||
public async $generateBlockDatabase(): Promise<boolean> {
|
||||
try {
|
||||
const blockchainInfo = await bitcoinClient.getBlockchainInfo();
|
||||
let currentBlockHeight = blockchainInfo.blocks;
|
||||
|
||||
let indexingBlockAmount = Math.min(config.MEMPOOL.INDEXING_BLOCKS_AMOUNT, blockchainInfo.blocks);
|
||||
@@ -230,7 +455,7 @@ class Blocks {
|
||||
|
||||
const lastBlockToIndex = Math.max(0, currentBlockHeight - indexingBlockAmount + 1);
|
||||
|
||||
logger.debug(`Indexing blocks from #${currentBlockHeight} to #${lastBlockToIndex}`);
|
||||
logger.debug(`Indexing blocks from #${currentBlockHeight} to #${lastBlockToIndex}`, logger.tags.mining);
|
||||
loadingIndicators.setProgress('block-indexing', 0);
|
||||
|
||||
const chunkSize = 10000;
|
||||
@@ -250,7 +475,7 @@ class Blocks {
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(`Indexing ${missingBlockHeights.length} blocks from #${currentBlockHeight} to #${endBlock}`);
|
||||
logger.info(`Indexing ${missingBlockHeights.length} blocks from #${currentBlockHeight} to #${endBlock}`, logger.tags.mining);
|
||||
|
||||
for (const blockHeight of missingBlockHeights) {
|
||||
if (blockHeight < lastBlockToIndex) {
|
||||
@@ -258,19 +483,18 @@ class Blocks {
|
||||
}
|
||||
++indexedThisRun;
|
||||
++totalIndexed;
|
||||
const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - timer));
|
||||
const elapsedSeconds = Math.max(1, new Date().getTime() / 1000 - timer);
|
||||
if (elapsedSeconds > 5 || blockHeight === lastBlockToIndex) {
|
||||
const runningFor = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt));
|
||||
const blockPerSeconds = Math.max(1, Math.round(indexedThisRun / elapsedSeconds));
|
||||
const blockPerSeconds = Math.max(1, indexedThisRun / elapsedSeconds);
|
||||
const progress = Math.round(totalIndexed / indexingBlockAmount * 10000) / 100;
|
||||
const timeLeft = Math.round((indexingBlockAmount - totalIndexed) / blockPerSeconds);
|
||||
logger.debug(`Indexing block #${blockHeight} | ~${blockPerSeconds.toFixed(2)} blocks/sec | total: ${totalIndexed}/${indexingBlockAmount} (${progress}%) | elapsed: ${runningFor} seconds | left: ~${timeLeft} seconds`);
|
||||
logger.debug(`Indexing block #${blockHeight} | ~${blockPerSeconds.toFixed(2)} blocks/sec | total: ${totalIndexed}/${indexingBlockAmount} (${progress}%) | elapsed: ${runningFor} seconds`, logger.tags.mining);
|
||||
timer = new Date().getTime() / 1000;
|
||||
indexedThisRun = 0;
|
||||
loadingIndicators.setProgress('block-indexing', progress, false);
|
||||
}
|
||||
const blockHash = await bitcoinApi.$getBlockHash(blockHeight);
|
||||
const block = BitcoinApi.convertBlock(await bitcoinClient.getBlock(blockHash));
|
||||
const block: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(blockHash);
|
||||
const transactions = await this.$getTransactionsExtended(blockHash, block.height, true, true);
|
||||
const blockExtended = await this.$getBlockExtended(block, transactions);
|
||||
|
||||
@@ -280,18 +504,19 @@ class Blocks {
|
||||
|
||||
currentBlockHeight -= chunkSize;
|
||||
}
|
||||
logger.info(`Indexed ${newlyIndexed} blocks`);
|
||||
if (newlyIndexed > 0) {
|
||||
logger.notice(`Block indexing completed: indexed ${newlyIndexed} blocks`, logger.tags.mining);
|
||||
} else {
|
||||
logger.debug(`Block indexing completed: indexed ${newlyIndexed} blocks`, logger.tags.mining);
|
||||
}
|
||||
loadingIndicators.setProgress('block-indexing', 100);
|
||||
} catch (e) {
|
||||
logger.err('Block indexing failed. Trying again later. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
logger.err('Block indexing failed. Trying again in 10 seconds. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining);
|
||||
loadingIndicators.setProgress('block-indexing', 100);
|
||||
return;
|
||||
throw e;
|
||||
}
|
||||
|
||||
const chainValid = await BlocksRepository.$validateChain();
|
||||
if (!chainValid) {
|
||||
indexer.reindex();
|
||||
}
|
||||
return await BlocksRepository.$validateChain();
|
||||
}
|
||||
|
||||
public async $updateBlocks() {
|
||||
@@ -317,13 +542,13 @@ class Blocks {
|
||||
if (blockchainInfo.blocks === blockchainInfo.headers) {
|
||||
const heightDiff = blockHeightTip % 2016;
|
||||
const blockHash = await bitcoinApi.$getBlockHash(blockHeightTip - heightDiff);
|
||||
const block = BitcoinApi.convertBlock(await bitcoinClient.getBlock(blockHash));
|
||||
const block: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(blockHash);
|
||||
this.lastDifficultyAdjustmentTime = block.timestamp;
|
||||
this.currentDifficulty = block.difficulty;
|
||||
|
||||
if (blockHeightTip >= 2016) {
|
||||
const previousPeriodBlockHash = await bitcoinApi.$getBlockHash(blockHeightTip - heightDiff - 2016);
|
||||
const previousPeriodBlock = await bitcoinApi.$getBlock(previousPeriodBlockHash);
|
||||
const previousPeriodBlock: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(previousPeriodBlockHash);
|
||||
this.previousDifficultyRetarget = (block.difficulty - previousPeriodBlock.difficulty) / previousPeriodBlock.difficulty * 100;
|
||||
logger.debug(`Initial difficulty adjustment data set.`);
|
||||
}
|
||||
@@ -338,34 +563,77 @@ class Blocks {
|
||||
} else {
|
||||
this.currentBlockHeight++;
|
||||
logger.debug(`New block found (#${this.currentBlockHeight})!`);
|
||||
await chainTips.updateOrphanedBlocks();
|
||||
}
|
||||
|
||||
const blockHash = await bitcoinApi.$getBlockHash(this.currentBlockHeight);
|
||||
const block = BitcoinApi.convertBlock(await bitcoinClient.getBlock(blockHash));
|
||||
const verboseBlock = await bitcoinClient.getBlock(blockHash, 2);
|
||||
const block = BitcoinApi.convertBlock(verboseBlock);
|
||||
const txIds: string[] = await bitcoinApi.$getTxIdsForBlock(blockHash);
|
||||
const transactions = await this.$getTransactionsExtended(blockHash, block.height, false);
|
||||
const blockExtended: BlockExtended = await this.$getBlockExtended(block, transactions);
|
||||
const blockSummary: BlockSummary = this.summarizeBlock(verboseBlock);
|
||||
|
||||
// start async callbacks
|
||||
const callbackPromises = this.newAsyncBlockCallbacks.map((cb) => cb(blockExtended, txIds, transactions));
|
||||
|
||||
if (Common.indexingEnabled()) {
|
||||
if (!fastForwarded) {
|
||||
const lastBlock = await blocksRepository.$getBlockByHeight(blockExtended.height - 1);
|
||||
if (lastBlock !== null && blockExtended.previousblockhash !== lastBlock['hash']) {
|
||||
logger.warn(`Chain divergence detected at block ${lastBlock['height']}, re-indexing most recent data`);
|
||||
if (lastBlock !== null && blockExtended.previousblockhash !== lastBlock.id) {
|
||||
logger.warn(`Chain divergence detected at block ${lastBlock.height}, re-indexing most recent data`);
|
||||
// We assume there won't be a reorg with more than 10 block depth
|
||||
await BlocksRepository.$deleteBlocksFrom(lastBlock['height'] - 10);
|
||||
await BlocksRepository.$deleteBlocksFrom(lastBlock.height - 10);
|
||||
await HashratesRepository.$deleteLastEntries();
|
||||
await BlocksSummariesRepository.$deleteBlocksFrom(lastBlock.height - 10);
|
||||
await cpfpRepository.$deleteClustersFrom(lastBlock.height - 10);
|
||||
for (let i = 10; i >= 0; --i) {
|
||||
await this.$indexBlock(lastBlock['height'] - i);
|
||||
const newBlock = await this.$indexBlock(lastBlock.height - i);
|
||||
await this.$getStrippedBlockTransactions(newBlock.id, true, true);
|
||||
if (config.MEMPOOL.CPFP_INDEXING) {
|
||||
await this.$indexCPFP(newBlock.id, lastBlock.height - i);
|
||||
}
|
||||
}
|
||||
await mining.$indexDifficultyAdjustments();
|
||||
await DifficultyAdjustmentsRepository.$deleteLastAdjustment();
|
||||
logger.info(`Re-indexed 10 blocks and summaries. Also re-indexed the last difficulty adjustments. Will re-index latest hashrates in a few seconds.`);
|
||||
indexer.reindex();
|
||||
}
|
||||
await blocksRepository.$saveBlockInDatabase(blockExtended);
|
||||
|
||||
const lastestPriceId = await PricesRepository.$getLatestPriceId();
|
||||
if (priceUpdater.historyInserted === true && lastestPriceId !== null) {
|
||||
await blocksRepository.$saveBlockPrices([{
|
||||
height: blockExtended.height,
|
||||
priceId: lastestPriceId,
|
||||
}]);
|
||||
} else {
|
||||
logger.info(`Cannot save block price for ${blockExtended.height} because the price updater hasnt completed yet. Trying again in 10 seconds.`, logger.tags.mining);
|
||||
setTimeout(() => {
|
||||
indexer.runSingleTask('blocksPrices');
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
// Save blocks summary for visualization if it's enabled
|
||||
if (Common.blocksSummariesIndexingEnabled() === true) {
|
||||
await this.$getStrippedBlockTransactions(blockExtended.id, true);
|
||||
}
|
||||
if (config.MEMPOOL.CPFP_INDEXING) {
|
||||
this.$indexCPFP(blockExtended.id, this.currentBlockHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fiatConversion.ratesInitialized === true && config.DATABASE.ENABLED === true) {
|
||||
await RatesRepository.$saveRate(blockExtended.height, fiatConversion.getConversionRates());
|
||||
}
|
||||
|
||||
if (block.height % 2016 === 0) {
|
||||
if (Common.indexingEnabled()) {
|
||||
await DifficultyAdjustmentsRepository.$saveAdjustments({
|
||||
time: block.timestamp,
|
||||
height: block.height,
|
||||
difficulty: block.difficulty,
|
||||
adjustment: Math.round((block.difficulty / this.currentDifficulty) * 1000000) / 1000000, // Remove float point noise
|
||||
});
|
||||
}
|
||||
|
||||
this.previousDifficultyRetarget = (block.difficulty - this.currentDifficulty) / this.currentDifficulty * 100;
|
||||
this.lastDifficultyAdjustmentTime = block.timestamp;
|
||||
this.currentDifficulty = block.difficulty;
|
||||
@@ -375,13 +643,20 @@ class Blocks {
|
||||
if (this.blocks.length > config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4) {
|
||||
this.blocks = this.blocks.slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4);
|
||||
}
|
||||
this.blockSummaries.push(blockSummary);
|
||||
if (this.blockSummaries.length > config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4) {
|
||||
this.blockSummaries = this.blockSummaries.slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4);
|
||||
}
|
||||
|
||||
if (this.newBlockCallbacks.length) {
|
||||
this.newBlockCallbacks.forEach((cb) => cb(blockExtended, txIds, transactions));
|
||||
}
|
||||
if (!memPool.hasPriority()) {
|
||||
if (!memPool.hasPriority() && (block.height % config.MEMPOOL.DISK_CACHE_BLOCK_INTERVAL === 0)) {
|
||||
diskCache.$saveCacheToDisk();
|
||||
}
|
||||
|
||||
// wait for pending async callbacks to finish
|
||||
await Promise.all(callbackPromises);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,23 +664,27 @@ class Blocks {
|
||||
* Index a block if it's missing from the database. Returns the block after indexing
|
||||
*/
|
||||
public async $indexBlock(height: number): Promise<BlockExtended> {
|
||||
const dbBlock = await blocksRepository.$getBlockByHeight(height);
|
||||
if (dbBlock != null) {
|
||||
return prepareBlock(dbBlock);
|
||||
if (Common.indexingEnabled()) {
|
||||
const dbBlock = await blocksRepository.$getBlockByHeight(height);
|
||||
if (dbBlock !== null) {
|
||||
return dbBlock;
|
||||
}
|
||||
}
|
||||
|
||||
const blockHash = await bitcoinApi.$getBlockHash(height);
|
||||
const block = BitcoinApi.convertBlock(await bitcoinClient.getBlock(blockHash));
|
||||
const block: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(blockHash);
|
||||
const transactions = await this.$getTransactionsExtended(blockHash, block.height, true);
|
||||
const blockExtended = await this.$getBlockExtended(block, transactions);
|
||||
|
||||
await blocksRepository.$saveBlockInDatabase(blockExtended);
|
||||
if (Common.indexingEnabled()) {
|
||||
await blocksRepository.$saveBlockInDatabase(blockExtended);
|
||||
}
|
||||
|
||||
return prepareBlock(blockExtended);
|
||||
return blockExtended;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index a block by hash if it's missing from the database. Returns the block after indexing
|
||||
* Get one block by its hash
|
||||
*/
|
||||
public async $getBlock(hash: string): Promise<BlockExtended | IEsploraApi.Block> {
|
||||
// Check the memory cache
|
||||
@@ -414,74 +693,209 @@ class Blocks {
|
||||
return blockByHash;
|
||||
}
|
||||
|
||||
// Block has already been indexed
|
||||
if (Common.indexingEnabled()) {
|
||||
const dbBlock = await blocksRepository.$getBlockByHash(hash);
|
||||
if (dbBlock != null) {
|
||||
return prepareBlock(dbBlock);
|
||||
}
|
||||
}
|
||||
|
||||
const block = await bitcoinApi.$getBlock(hash);
|
||||
|
||||
// Not Bitcoin network, return the block as it
|
||||
// Not Bitcoin network, return the block as it from the bitcoin backend
|
||||
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === false) {
|
||||
return block;
|
||||
return await bitcoinCoreApi.$getBlock(hash);
|
||||
}
|
||||
|
||||
// Bitcoin network, add our custom data on top
|
||||
const transactions = await this.$getTransactionsExtended(hash, block.height, true);
|
||||
const blockExtended = await this.$getBlockExtended(block, transactions);
|
||||
if (Common.indexingEnabled()) {
|
||||
delete(blockExtended['coinbaseTx']);
|
||||
await blocksRepository.$saveBlockInDatabase(blockExtended);
|
||||
}
|
||||
|
||||
return blockExtended;
|
||||
const block: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(hash);
|
||||
return await this.$indexBlock(block.height);
|
||||
}
|
||||
|
||||
public async $getBlocks(fromHeight?: number, limit: number = 15): Promise<BlockExtended[]> {
|
||||
try {
|
||||
let currentHeight = fromHeight !== undefined ? fromHeight : this.getCurrentBlockHeight();
|
||||
const returnBlocks: BlockExtended[] = [];
|
||||
|
||||
if (currentHeight < 0) {
|
||||
return returnBlocks;
|
||||
public async $getStrippedBlockTransactions(hash: string, skipMemoryCache = false,
|
||||
skipDBLookup = false): Promise<TransactionStripped[]>
|
||||
{
|
||||
if (skipMemoryCache === false) {
|
||||
// Check the memory cache
|
||||
const cachedSummary = this.getBlockSummaries().find((b) => b.id === hash);
|
||||
if (cachedSummary?.transactions?.length) {
|
||||
return cachedSummary.transactions;
|
||||
}
|
||||
|
||||
if (currentHeight === 0 && Common.indexingEnabled()) {
|
||||
currentHeight = await blocksRepository.$mostRecentBlockHeight();
|
||||
}
|
||||
|
||||
// Check if block height exist in local cache to skip the hash lookup
|
||||
const blockByHeight = this.getBlocks().find((b) => b.height === currentHeight);
|
||||
let startFromHash: string | null = null;
|
||||
if (blockByHeight) {
|
||||
startFromHash = blockByHeight.id;
|
||||
} else if (!Common.indexingEnabled()) {
|
||||
startFromHash = await bitcoinApi.$getBlockHash(currentHeight);
|
||||
}
|
||||
|
||||
let nextHash = startFromHash;
|
||||
for (let i = 0; i < limit && currentHeight >= 0; i++) {
|
||||
let block = this.getBlocks().find((b) => b.height === currentHeight);
|
||||
if (block) {
|
||||
returnBlocks.push(block);
|
||||
} else if (Common.indexingEnabled()) {
|
||||
block = await this.$indexBlock(currentHeight);
|
||||
returnBlocks.push(block);
|
||||
} else if (nextHash != null) {
|
||||
block = prepareBlock(await bitcoinApi.$getBlock(nextHash));
|
||||
nextHash = block.previousblockhash;
|
||||
returnBlocks.push(block);
|
||||
}
|
||||
currentHeight--;
|
||||
}
|
||||
|
||||
return returnBlocks;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Check if it's indexed in db
|
||||
if (skipDBLookup === false && Common.blocksSummariesIndexingEnabled() === true) {
|
||||
const indexedSummary = await BlocksSummariesRepository.$getByBlockId(hash);
|
||||
if (indexedSummary !== undefined && indexedSummary?.transactions?.length) {
|
||||
return indexedSummary.transactions;
|
||||
}
|
||||
}
|
||||
|
||||
// Call Core RPC
|
||||
const block = await bitcoinClient.getBlock(hash, 2);
|
||||
const summary = this.summarizeBlock(block);
|
||||
|
||||
// Index the response if needed
|
||||
if (Common.blocksSummariesIndexingEnabled() === true) {
|
||||
await BlocksSummariesRepository.$saveSummary({height: block.height, mined: summary});
|
||||
}
|
||||
|
||||
return summary.transactions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get 15 blocks
|
||||
*
|
||||
* Internally this function uses two methods to get the blocks, and
|
||||
* the method is automatically selected:
|
||||
* - Using previous block hash links
|
||||
* - Using block height
|
||||
*
|
||||
* @param fromHeight
|
||||
* @param limit
|
||||
* @returns
|
||||
*/
|
||||
public async $getBlocks(fromHeight?: number, limit: number = 15): Promise<BlockExtended[]> {
|
||||
let currentHeight = fromHeight !== undefined ? fromHeight : this.currentBlockHeight;
|
||||
if (currentHeight > this.currentBlockHeight) {
|
||||
limit -= currentHeight - this.currentBlockHeight;
|
||||
currentHeight = this.currentBlockHeight;
|
||||
}
|
||||
const returnBlocks: BlockExtended[] = [];
|
||||
|
||||
if (currentHeight < 0) {
|
||||
return returnBlocks;
|
||||
}
|
||||
|
||||
for (let i = 0; i < limit && currentHeight >= 0; i++) {
|
||||
let block = this.getBlocks().find((b) => b.height === currentHeight);
|
||||
if (block) {
|
||||
// Using the memory cache (find by height)
|
||||
returnBlocks.push(block);
|
||||
} else {
|
||||
// Using indexing (find by height, index on the fly, save in database)
|
||||
block = await this.$indexBlock(currentHeight);
|
||||
returnBlocks.push(block);
|
||||
}
|
||||
currentHeight--;
|
||||
}
|
||||
|
||||
return returnBlocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for bulk block data query
|
||||
*
|
||||
* @param fromHeight
|
||||
* @param toHeight
|
||||
*/
|
||||
public async $getBlocksBetweenHeight(fromHeight: number, toHeight: number): Promise<any> {
|
||||
if (!Common.indexingEnabled()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const blocks: any[] = [];
|
||||
|
||||
while (fromHeight <= toHeight) {
|
||||
let block: BlockExtended | null = await blocksRepository.$getBlockByHeight(fromHeight);
|
||||
if (!block) {
|
||||
await this.$indexBlock(fromHeight);
|
||||
block = await blocksRepository.$getBlockByHeight(fromHeight);
|
||||
if (!block) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup fields before sending the response
|
||||
const cleanBlock: any = {
|
||||
height: block.height ?? null,
|
||||
hash: block.id ?? null,
|
||||
timestamp: block.timestamp ?? null,
|
||||
median_timestamp: block.mediantime ?? null,
|
||||
previous_block_hash: block.previousblockhash ?? null,
|
||||
difficulty: block.difficulty ?? null,
|
||||
header: block.extras.header ?? null,
|
||||
version: block.version ?? null,
|
||||
bits: block.bits ?? null,
|
||||
nonce: block.nonce ?? null,
|
||||
size: block.size ?? null,
|
||||
weight: block.weight ?? null,
|
||||
tx_count: block.tx_count ?? null,
|
||||
merkle_root: block.merkle_root ?? null,
|
||||
reward: block.extras.reward ?? null,
|
||||
total_fee_amt: block.extras.totalFees ?? null,
|
||||
avg_fee_amt: block.extras.avgFee ?? null,
|
||||
median_fee_amt: block.extras.medianFeeAmt ?? null,
|
||||
fee_amt_percentiles: block.extras.feePercentiles ?? null,
|
||||
avg_fee_rate: block.extras.avgFeeRate ?? null,
|
||||
median_fee_rate: block.extras.medianFee ?? null,
|
||||
fee_rate_percentiles: block.extras.feeRange ?? null,
|
||||
total_inputs: block.extras.totalInputs ?? null,
|
||||
total_input_amt: block.extras.totalInputAmt ?? null,
|
||||
total_outputs: block.extras.totalOutputs ?? null,
|
||||
total_output_amt: block.extras.totalOutputAmt ?? null,
|
||||
segwit_total_txs: block.extras.segwitTotalTxs ?? null,
|
||||
segwit_total_size: block.extras.segwitTotalSize ?? null,
|
||||
segwit_total_weight: block.extras.segwitTotalWeight ?? null,
|
||||
avg_tx_size: block.extras.avgTxSize ?? null,
|
||||
utxoset_change: block.extras.utxoSetChange ?? null,
|
||||
utxoset_size: block.extras.utxoSetSize ?? null,
|
||||
coinbase_raw: block.extras.coinbaseRaw ?? null,
|
||||
coinbase_address: block.extras.coinbaseAddress ?? null,
|
||||
coinbase_signature: block.extras.coinbaseSignature ?? null,
|
||||
coinbase_signature_ascii: block.extras.coinbaseSignatureAscii ?? null,
|
||||
pool_slug: block.extras.pool.slug ?? null,
|
||||
pool_id: block.extras.pool.id ?? null,
|
||||
};
|
||||
|
||||
if (Common.blocksSummariesIndexingEnabled() && cleanBlock.fee_amt_percentiles === null) {
|
||||
cleanBlock.fee_amt_percentiles = await BlocksSummariesRepository.$getFeePercentilesByBlockId(cleanBlock.hash);
|
||||
if (cleanBlock.fee_amt_percentiles === null) {
|
||||
const block = await bitcoinClient.getBlock(cleanBlock.hash, 2);
|
||||
const summary = this.summarizeBlock(block);
|
||||
await BlocksSummariesRepository.$saveSummary({ height: block.height, mined: summary });
|
||||
cleanBlock.fee_amt_percentiles = await BlocksSummariesRepository.$getFeePercentilesByBlockId(cleanBlock.hash);
|
||||
}
|
||||
if (cleanBlock.fee_amt_percentiles !== null) {
|
||||
cleanBlock.median_fee_amt = cleanBlock.fee_amt_percentiles[3];
|
||||
}
|
||||
}
|
||||
|
||||
cleanBlock.fee_amt_percentiles = {
|
||||
'min': cleanBlock.fee_amt_percentiles[0],
|
||||
'perc_10': cleanBlock.fee_amt_percentiles[1],
|
||||
'perc_25': cleanBlock.fee_amt_percentiles[2],
|
||||
'perc_50': cleanBlock.fee_amt_percentiles[3],
|
||||
'perc_75': cleanBlock.fee_amt_percentiles[4],
|
||||
'perc_90': cleanBlock.fee_amt_percentiles[5],
|
||||
'max': cleanBlock.fee_amt_percentiles[6],
|
||||
};
|
||||
cleanBlock.fee_rate_percentiles = {
|
||||
'min': cleanBlock.fee_rate_percentiles[0],
|
||||
'perc_10': cleanBlock.fee_rate_percentiles[1],
|
||||
'perc_25': cleanBlock.fee_rate_percentiles[2],
|
||||
'perc_50': cleanBlock.fee_rate_percentiles[3],
|
||||
'perc_75': cleanBlock.fee_rate_percentiles[4],
|
||||
'perc_90': cleanBlock.fee_rate_percentiles[5],
|
||||
'max': cleanBlock.fee_rate_percentiles[6],
|
||||
};
|
||||
|
||||
// Re-org can happen after indexing so we need to always get the
|
||||
// latest state from core
|
||||
cleanBlock.orphans = chainTips.getOrphanedBlocksAtHeight(cleanBlock.height);
|
||||
|
||||
blocks.push(cleanBlock);
|
||||
fromHeight++;
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
public async $getBlockAuditSummary(hash: string): Promise<any> {
|
||||
let summary;
|
||||
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) {
|
||||
summary = await BlocksAuditsRepository.$getBlockAudit(hash);
|
||||
}
|
||||
|
||||
// fallback to non-audited transaction summary
|
||||
if (!summary?.transactions?.length) {
|
||||
const strippedTransactions = await this.$getStrippedBlockTransactions(hash);
|
||||
summary = {
|
||||
transactions: strippedTransactions
|
||||
};
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
public getLastDifficultyAdjustmentTime(): number {
|
||||
@@ -495,6 +909,50 @@ class Blocks {
|
||||
public getCurrentBlockHeight(): number {
|
||||
return this.currentBlockHeight;
|
||||
}
|
||||
|
||||
public async $indexCPFP(hash: string, height: number): Promise<void> {
|
||||
const block = await bitcoinClient.getBlock(hash, 2);
|
||||
const transactions = block.tx.map(tx => {
|
||||
tx.vsize = tx.weight / 4;
|
||||
tx.fee *= 100_000_000;
|
||||
return tx;
|
||||
});
|
||||
|
||||
const clusters: any[] = [];
|
||||
|
||||
let cluster: TransactionStripped[] = [];
|
||||
let ancestors: { [txid: string]: boolean } = {};
|
||||
for (let i = transactions.length - 1; i >= 0; i--) {
|
||||
const tx = transactions[i];
|
||||
if (!ancestors[tx.txid]) {
|
||||
let totalFee = 0;
|
||||
let totalVSize = 0;
|
||||
cluster.forEach(tx => {
|
||||
totalFee += tx?.fee || 0;
|
||||
totalVSize += tx.vsize;
|
||||
});
|
||||
const effectiveFeePerVsize = totalFee / totalVSize;
|
||||
if (cluster.length > 1) {
|
||||
clusters.push({
|
||||
root: cluster[0].txid,
|
||||
height,
|
||||
txs: cluster.map(tx => { return { txid: tx.txid, weight: tx.vsize * 4, fee: tx.fee || 0 }; }),
|
||||
effectiveFeePerVsize,
|
||||
});
|
||||
}
|
||||
cluster = [];
|
||||
ancestors = {};
|
||||
}
|
||||
cluster.push(tx);
|
||||
tx.vin.forEach(vin => {
|
||||
ancestors[vin.txid] = true;
|
||||
});
|
||||
}
|
||||
const result = await cpfpRepository.$batchSaveClusters(clusters);
|
||||
if (!result) {
|
||||
await cpfpRepository.$insertProgressMarker(height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new Blocks();
|
||||
|
||||
61
backend/src/api/chain-tips.ts
Normal file
61
backend/src/api/chain-tips.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import logger from '../logger';
|
||||
import bitcoinClient from './bitcoin/bitcoin-client';
|
||||
|
||||
export interface ChainTip {
|
||||
height: number;
|
||||
hash: string;
|
||||
branchlen: number;
|
||||
status: 'invalid' | 'active' | 'valid-fork' | 'valid-headers' | 'headers-only';
|
||||
};
|
||||
|
||||
export interface OrphanedBlock {
|
||||
height: number;
|
||||
hash: string;
|
||||
status: 'valid-fork' | 'valid-headers' | 'headers-only';
|
||||
}
|
||||
|
||||
class ChainTips {
|
||||
private chainTips: ChainTip[] = [];
|
||||
private orphanedBlocks: OrphanedBlock[] = [];
|
||||
|
||||
public async updateOrphanedBlocks(): Promise<void> {
|
||||
try {
|
||||
this.chainTips = await bitcoinClient.getChainTips();
|
||||
this.orphanedBlocks = [];
|
||||
|
||||
for (const chain of this.chainTips) {
|
||||
if (chain.status === 'valid-fork' || chain.status === 'valid-headers') {
|
||||
let block = await bitcoinClient.getBlock(chain.hash);
|
||||
while (block && block.confirmations === -1) {
|
||||
this.orphanedBlocks.push({
|
||||
height: block.height,
|
||||
hash: block.hash,
|
||||
status: chain.status
|
||||
});
|
||||
block = await bitcoinClient.getBlock(block.previousblockhash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`Updated orphaned blocks cache. Found ${this.orphanedBlocks.length} orphaned blocks`);
|
||||
} catch (e) {
|
||||
logger.err(`Cannot get fetch orphaned blocks. Reason: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}
|
||||
|
||||
public getOrphanedBlocksAtHeight(height: number | undefined): OrphanedBlock[] {
|
||||
if (height === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const orphans: OrphanedBlock[] = [];
|
||||
for (const block of this.orphanedBlocks) {
|
||||
if (block.height === height) {
|
||||
orphans.push(block);
|
||||
}
|
||||
}
|
||||
return orphans;
|
||||
}
|
||||
}
|
||||
|
||||
export default new ChainTips();
|
||||
@@ -1,5 +1,7 @@
|
||||
import { CpfpInfo, TransactionExtended, TransactionStripped } from '../mempool.interfaces';
|
||||
import { CpfpInfo, MempoolBlockWithTransactions, TransactionExtended, TransactionStripped } from '../mempool.interfaces';
|
||||
import config from '../config';
|
||||
import { NodeSocket } from '../repositories/NodesSocketsRepository';
|
||||
import { isIP } from 'net';
|
||||
export class Common {
|
||||
static nativeAssetId = config.MEMPOOL.NETWORK === 'liquidtestnet' ?
|
||||
'144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49'
|
||||
@@ -33,24 +35,31 @@ export class Common {
|
||||
}
|
||||
|
||||
static getFeesInRange(transactions: TransactionExtended[], rangeLength: number) {
|
||||
const arr = [transactions[transactions.length - 1].effectiveFeePerVsize];
|
||||
const filtered: TransactionExtended[] = [];
|
||||
let lastValidRate = Infinity;
|
||||
// filter out anomalous fee rates to ensure monotonic range
|
||||
for (const tx of transactions) {
|
||||
if (tx.effectiveFeePerVsize <= lastValidRate) {
|
||||
filtered.push(tx);
|
||||
lastValidRate = tx.effectiveFeePerVsize;
|
||||
}
|
||||
}
|
||||
const arr = [filtered[filtered.length - 1].effectiveFeePerVsize];
|
||||
const chunk = 1 / (rangeLength - 1);
|
||||
let itemsToAdd = rangeLength - 2;
|
||||
|
||||
while (itemsToAdd > 0) {
|
||||
arr.push(transactions[Math.floor(transactions.length * chunk * itemsToAdd)].effectiveFeePerVsize);
|
||||
arr.push(filtered[Math.floor(filtered.length * chunk * itemsToAdd)].effectiveFeePerVsize);
|
||||
itemsToAdd--;
|
||||
}
|
||||
|
||||
arr.push(transactions[0].effectiveFeePerVsize);
|
||||
arr.push(filtered[0].effectiveFeePerVsize);
|
||||
return arr;
|
||||
}
|
||||
|
||||
static findRbfTransactions(added: TransactionExtended[], deleted: TransactionExtended[]): { [txid: string]: TransactionExtended } {
|
||||
const matches: { [txid: string]: TransactionExtended } = {};
|
||||
deleted
|
||||
// The replaced tx must have at least one input with nSequence < maxint-1 (That’s the opt-in)
|
||||
.filter((tx) => tx.vin.some((vin) => vin.sequence < 0xfffffffe))
|
||||
.forEach((deletedTx) => {
|
||||
const foundMatches = added.find((addedTx) => {
|
||||
// The new tx must, absolutely speaking, pay at least as much fee as the replaced tx.
|
||||
@@ -59,7 +68,7 @@ export class Common {
|
||||
&& addedTx.feePerVsize > deletedTx.feePerVsize
|
||||
// Spends one or more of the same inputs
|
||||
&& deletedTx.vin.some((deletedVin) =>
|
||||
addedTx.vin.some((vin) => vin.txid === deletedVin.txid));
|
||||
addedTx.vin.some((vin) => vin.txid === deletedVin.txid && vin.vout === deletedVin.vout));
|
||||
});
|
||||
if (foundMatches) {
|
||||
matches[deletedTx.txid] = foundMatches;
|
||||
@@ -114,7 +123,7 @@ export class Common {
|
||||
totalFees += tx.bestDescendant.fee;
|
||||
}
|
||||
|
||||
tx.effectiveFeePerVsize = Math.max(Common.isLiquid() ? 0.1 : 1, totalFees / (totalWeight / 4));
|
||||
tx.effectiveFeePerVsize = Math.max(0, totalFees / (totalWeight / 4));
|
||||
tx.cpfpChecked = true;
|
||||
|
||||
return {
|
||||
@@ -155,6 +164,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';
|
||||
@@ -166,15 +199,150 @@ export class Common {
|
||||
case '1y': return '1 YEAR';
|
||||
case '2y': return '2 YEAR';
|
||||
case '3y': return '3 YEAR';
|
||||
case '4y': return '4 YEAR';
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
static indexingEnabled(): boolean {
|
||||
return (
|
||||
['mainnet', 'testnet', 'signet', 'regtest'].includes(config.MEMPOOL.NETWORK) &&
|
||||
['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) &&
|
||||
config.DATABASE.ENABLED === true &&
|
||||
config.MEMPOOL.INDEXING_BLOCKS_AMOUNT !== 0
|
||||
);
|
||||
}
|
||||
|
||||
static blocksSummariesIndexingEnabled(): boolean {
|
||||
return (
|
||||
Common.indexingEnabled() &&
|
||||
config.MEMPOOL.BLOCKS_SUMMARIES_INDEXING === true
|
||||
);
|
||||
}
|
||||
|
||||
static cpfpIndexingEnabled(): boolean {
|
||||
return (
|
||||
Common.indexingEnabled() &&
|
||||
config.MEMPOOL.CPFP_INDEXING === true
|
||||
);
|
||||
}
|
||||
|
||||
static setDateMidnight(date: Date): void {
|
||||
date.setUTCHours(0);
|
||||
date.setUTCMinutes(0);
|
||||
date.setUTCSeconds(0);
|
||||
date.setUTCMilliseconds(0);
|
||||
}
|
||||
|
||||
static channelShortIdToIntegerId(channelId: string): string {
|
||||
if (channelId.indexOf('x') === -1) { // Already an integer id
|
||||
return channelId;
|
||||
}
|
||||
if (channelId.indexOf('/') !== -1) { // Topology import
|
||||
channelId = channelId.slice(0, -2);
|
||||
}
|
||||
const s = channelId.split('x').map(part => BigInt(part));
|
||||
return ((s[0] << 40n) | (s[1] << 16n) | s[2]).toString();
|
||||
}
|
||||
|
||||
/** Decodes a channel id returned by lnd as uint64 to a short channel id */
|
||||
static channelIntegerIdToShortId(id: string): string {
|
||||
if (id.indexOf('/') !== -1) {
|
||||
id = id.slice(0, -2);
|
||||
}
|
||||
|
||||
if (id.indexOf('x') !== -1) { // Already a short id
|
||||
return id;
|
||||
}
|
||||
|
||||
const n = BigInt(id);
|
||||
return [
|
||||
n >> 40n, // nth block
|
||||
(n >> 16n) & 0xffffffn, // nth tx of the block
|
||||
n & 0xffffn // nth output of the tx
|
||||
].join('x');
|
||||
}
|
||||
|
||||
static utcDateToMysql(date?: number | null): string | null {
|
||||
if (date === null) {
|
||||
return null;
|
||||
}
|
||||
const d = new Date((date || 0) * 1000);
|
||||
return d.toISOString().split('T')[0] + ' ' + d.toTimeString().split(' ')[0];
|
||||
}
|
||||
|
||||
static findSocketNetwork(addr: string): {network: string | null, url: string} {
|
||||
let network: string | null = null;
|
||||
let url: string = addr;
|
||||
|
||||
if (config.LIGHTNING.BACKEND === 'cln') {
|
||||
url = addr.split('://')[1];
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
return {
|
||||
network: null,
|
||||
url: addr,
|
||||
};
|
||||
}
|
||||
|
||||
if (addr.indexOf('onion') !== -1) {
|
||||
if (url.split('.')[0].length >= 56) {
|
||||
network = 'torv3';
|
||||
} else {
|
||||
network = 'torv2';
|
||||
}
|
||||
} else if (addr.indexOf('i2p') !== -1) {
|
||||
network = 'i2p';
|
||||
} else if (addr.indexOf('ipv4') !== -1 || (config.LIGHTNING.BACKEND === 'lnd' && isIP(url.split(':')[0]) === 4)) {
|
||||
const ipv = isIP(url.split(':')[0]);
|
||||
if (ipv === 4) {
|
||||
network = 'ipv4';
|
||||
} else {
|
||||
return {
|
||||
network: null,
|
||||
url: addr,
|
||||
};
|
||||
}
|
||||
} else if (addr.indexOf('ipv6') !== -1 || (config.LIGHTNING.BACKEND === 'lnd' && url.indexOf(']:'))) {
|
||||
url = url.split('[')[1].split(']')[0];
|
||||
const ipv = isIP(url);
|
||||
if (ipv === 6) {
|
||||
const parts = addr.split(':');
|
||||
network = 'ipv6';
|
||||
url = `[${url}]:${parts[parts.length - 1]}`;
|
||||
} else {
|
||||
return {
|
||||
network: null,
|
||||
url: addr,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
network: null,
|
||||
url: addr,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
network: network,
|
||||
url: url,
|
||||
};
|
||||
}
|
||||
|
||||
static formatSocket(publicKey: string, socket: {network: string, addr: string}): NodeSocket {
|
||||
if (config.LIGHTNING.BACKEND === 'cln') {
|
||||
return {
|
||||
publicKey: publicKey,
|
||||
network: socket.network,
|
||||
addr: socket.addr,
|
||||
};
|
||||
} else /* if (config.LIGHTNING.BACKEND === 'lnd') */ {
|
||||
const formatted = this.findSocketNetwork(socket.addr);
|
||||
return {
|
||||
publicKey: publicKey,
|
||||
network: formatted.network,
|
||||
addr: formatted.url,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,30 @@ import config from '../config';
|
||||
import DB from '../database';
|
||||
import logger from '../logger';
|
||||
import { Common } from './common';
|
||||
import blocksRepository from '../repositories/BlocksRepository';
|
||||
import cpfpRepository from '../repositories/CpfpRepository';
|
||||
import { RowDataPacket } from 'mysql2';
|
||||
|
||||
class DatabaseMigration {
|
||||
private static currentVersion = 19;
|
||||
private queryTimeout = 120000;
|
||||
private static currentVersion = 59;
|
||||
private queryTimeout = 3600_000;
|
||||
private statisticsAddedIndexed = false;
|
||||
private uniqueLogs: string[] = [];
|
||||
|
||||
private blocksTruncatedMessage = `'blocks' table has been truncated.`;
|
||||
private hashratesTruncatedMessage = `'hashrates' table has been truncated.`;
|
||||
|
||||
/**
|
||||
* Avoid printing multiple time the same message
|
||||
*/
|
||||
private uniqueLog(loggerFunction: any, msg: string) {
|
||||
if (this.uniqueLogs.includes(msg)) {
|
||||
return;
|
||||
}
|
||||
this.uniqueLogs.push(msg);
|
||||
loggerFunction(msg);
|
||||
}
|
||||
|
||||
constructor() { }
|
||||
/**
|
||||
* Entry point
|
||||
*/
|
||||
@@ -39,6 +56,16 @@ class DatabaseMigration {
|
||||
process.exit(-1);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion === 0) {
|
||||
logger.info('Initializing database (first run, clean install)');
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion <= 2) {
|
||||
// Disable some spam logs when they're not relevant
|
||||
this.uniqueLog(logger.notice, this.blocksTruncatedMessage);
|
||||
this.uniqueLog(logger.notice, this.hashratesTruncatedMessage);
|
||||
}
|
||||
|
||||
logger.debug('MIGRATIONS: Current state.schema_version ' + databaseSchemaVersion);
|
||||
logger.debug('MIGRATIONS: Latest DatabaseMigration.version is ' + DatabaseMigration.currentVersion);
|
||||
if (databaseSchemaVersion >= DatabaseMigration.currentVersion) {
|
||||
@@ -56,10 +83,13 @@ class DatabaseMigration {
|
||||
}
|
||||
|
||||
if (DatabaseMigration.currentVersion > databaseSchemaVersion) {
|
||||
logger.notice('MIGRATIONS: Upgrading database schema');
|
||||
try {
|
||||
await this.$migrateTableSchemaFromVersion(databaseSchemaVersion);
|
||||
logger.notice(`MIGRATIONS: OK. Database schema have been migrated from version ${databaseSchemaVersion} to ${DatabaseMigration.currentVersion} (latest version)`);
|
||||
if (databaseSchemaVersion === 0) {
|
||||
logger.notice(`MIGRATIONS: OK. Database schema has been properly initialized to version ${DatabaseMigration.currentVersion} (latest version)`);
|
||||
} else {
|
||||
logger.notice(`MIGRATIONS: OK. Database schema have been migrated from version ${databaseSchemaVersion} to ${DatabaseMigration.currentVersion} (latest version)`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err('MIGRATIONS: Unable to migrate database, aborting. ' + e);
|
||||
}
|
||||
@@ -75,121 +105,415 @@ class DatabaseMigration {
|
||||
await this.$setStatisticsAddedIndexedFlag(databaseSchemaVersion);
|
||||
|
||||
const isBitcoin = ['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK);
|
||||
try {
|
||||
await this.$executeQuery(this.getCreateElementsTableQuery(), await this.$checkIfTableExists('elements_pegs'));
|
||||
await this.$executeQuery(this.getCreateStatisticsQuery(), await this.$checkIfTableExists('statistics'));
|
||||
if (databaseSchemaVersion < 2 && this.statisticsAddedIndexed === false) {
|
||||
await this.$executeQuery(`CREATE INDEX added ON statistics (added);`);
|
||||
}
|
||||
if (databaseSchemaVersion < 3) {
|
||||
await this.$executeQuery(this.getCreatePoolsTableQuery(), await this.$checkIfTableExists('pools'));
|
||||
}
|
||||
if (databaseSchemaVersion < 4) {
|
||||
await this.$executeQuery('DROP table IF EXISTS blocks;');
|
||||
await this.$executeQuery(this.getCreateBlocksTableQuery(), await this.$checkIfTableExists('blocks'));
|
||||
}
|
||||
if (databaseSchemaVersion < 5 && isBitcoin === true) {
|
||||
logger.warn(`'blocks' table has been truncated. Re-indexing from scratch.`);
|
||||
await this.$executeQuery('TRUNCATE blocks;'); // Need to re-index
|
||||
await this.$executeQuery('ALTER TABLE blocks ADD `reward` double unsigned NOT NULL DEFAULT "0"');
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 6 && isBitcoin === true) {
|
||||
logger.warn(`'blocks' table has been truncated. Re-indexing from scratch.`);
|
||||
await this.$executeQuery('TRUNCATE blocks;'); // Need to re-index
|
||||
// Cleanup original blocks fields type
|
||||
await this.$executeQuery('ALTER TABLE blocks MODIFY `height` integer unsigned NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE blocks MODIFY `tx_count` smallint unsigned NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE blocks MODIFY `size` integer unsigned NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE blocks MODIFY `weight` integer unsigned NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE blocks MODIFY `difficulty` double NOT NULL DEFAULT "0"');
|
||||
// We also fix the pools.id type so we need to drop/re-create the foreign key
|
||||
await this.$executeQuery('ALTER TABLE blocks DROP FOREIGN KEY IF EXISTS `blocks_ibfk_1`');
|
||||
await this.$executeQuery('ALTER TABLE pools MODIFY `id` smallint unsigned AUTO_INCREMENT');
|
||||
await this.$executeQuery('ALTER TABLE blocks MODIFY `pool_id` smallint unsigned NULL');
|
||||
await this.$executeQuery('ALTER TABLE blocks ADD FOREIGN KEY (`pool_id`) REFERENCES `pools` (`id`)');
|
||||
// Add new block indexing fields
|
||||
await this.$executeQuery('ALTER TABLE blocks ADD `version` integer unsigned NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE blocks ADD `bits` integer unsigned NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE blocks ADD `nonce` bigint unsigned NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE blocks ADD `merkle_root` varchar(65) NOT NULL DEFAULT ""');
|
||||
await this.$executeQuery('ALTER TABLE blocks ADD `previous_block_hash` varchar(65) NULL');
|
||||
}
|
||||
await this.$executeQuery(this.getCreateElementsTableQuery(), await this.$checkIfTableExists('elements_pegs'));
|
||||
await this.$executeQuery(this.getCreateStatisticsQuery(), await this.$checkIfTableExists('statistics'));
|
||||
if (databaseSchemaVersion < 2 && this.statisticsAddedIndexed === false) {
|
||||
await this.$executeQuery(`CREATE INDEX added ON statistics (added);`);
|
||||
await this.updateToSchemaVersion(2);
|
||||
}
|
||||
if (databaseSchemaVersion < 3) {
|
||||
await this.$executeQuery(this.getCreatePoolsTableQuery(), await this.$checkIfTableExists('pools'));
|
||||
await this.updateToSchemaVersion(3);
|
||||
}
|
||||
if (databaseSchemaVersion < 4) {
|
||||
await this.$executeQuery('DROP table IF EXISTS blocks;');
|
||||
await this.$executeQuery(this.getCreateBlocksTableQuery(), await this.$checkIfTableExists('blocks'));
|
||||
await this.updateToSchemaVersion(4);
|
||||
}
|
||||
if (databaseSchemaVersion < 5 && isBitcoin === true) {
|
||||
this.uniqueLog(logger.notice, this.blocksTruncatedMessage);
|
||||
await this.$executeQuery('TRUNCATE blocks;'); // Need to re-index
|
||||
await this.$executeQuery('ALTER TABLE blocks ADD `reward` double unsigned NOT NULL DEFAULT "0"');
|
||||
await this.updateToSchemaVersion(5);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 7 && isBitcoin === true) {
|
||||
await this.$executeQuery('DROP table IF EXISTS hashrates;');
|
||||
await this.$executeQuery(this.getCreateDailyStatsTableQuery(), await this.$checkIfTableExists('hashrates'));
|
||||
}
|
||||
if (databaseSchemaVersion < 6 && isBitcoin === true) {
|
||||
this.uniqueLog(logger.notice, this.blocksTruncatedMessage);
|
||||
await this.$executeQuery('TRUNCATE blocks;'); // Need to re-index
|
||||
// Cleanup original blocks fields type
|
||||
await this.$executeQuery('ALTER TABLE blocks MODIFY `height` integer unsigned NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE blocks MODIFY `tx_count` smallint unsigned NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE blocks MODIFY `size` integer unsigned NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE blocks MODIFY `weight` integer unsigned NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE blocks MODIFY `difficulty` double NOT NULL DEFAULT "0"');
|
||||
// We also fix the pools.id type so we need to drop/re-create the foreign key
|
||||
await this.$executeQuery('ALTER TABLE blocks DROP FOREIGN KEY IF EXISTS `blocks_ibfk_1`');
|
||||
await this.$executeQuery('ALTER TABLE pools MODIFY `id` smallint unsigned AUTO_INCREMENT');
|
||||
await this.$executeQuery('ALTER TABLE blocks MODIFY `pool_id` smallint unsigned NULL');
|
||||
await this.$executeQuery('ALTER TABLE blocks ADD FOREIGN KEY (`pool_id`) REFERENCES `pools` (`id`)');
|
||||
// Add new block indexing fields
|
||||
await this.$executeQuery('ALTER TABLE blocks ADD `version` integer unsigned NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE blocks ADD `bits` integer unsigned NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE blocks ADD `nonce` bigint unsigned NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE blocks ADD `merkle_root` varchar(65) NOT NULL DEFAULT ""');
|
||||
await this.$executeQuery('ALTER TABLE blocks ADD `previous_block_hash` varchar(65) NULL');
|
||||
await this.updateToSchemaVersion(6);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 8 && isBitcoin === true) {
|
||||
logger.warn(`'hashrates' table has been truncated. Re-indexing from scratch.`);
|
||||
await this.$executeQuery('TRUNCATE hashrates;'); // Need to re-index
|
||||
await this.$executeQuery('ALTER TABLE `hashrates` DROP INDEX `PRIMARY`');
|
||||
await this.$executeQuery('ALTER TABLE `hashrates` ADD `id` int NOT NULL AUTO_INCREMENT PRIMARY KEY FIRST');
|
||||
await this.$executeQuery('ALTER TABLE `hashrates` ADD `share` float NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE `hashrates` ADD `type` enum("daily", "weekly") DEFAULT "daily"');
|
||||
}
|
||||
if (databaseSchemaVersion < 7 && isBitcoin === true) {
|
||||
await this.$executeQuery('DROP table IF EXISTS hashrates;');
|
||||
await this.$executeQuery(this.getCreateDailyStatsTableQuery(), await this.$checkIfTableExists('hashrates'));
|
||||
await this.updateToSchemaVersion(7);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 9 && isBitcoin === true) {
|
||||
logger.warn(`'hashrates' table has been truncated. Re-indexing from scratch.`);
|
||||
await this.$executeQuery('TRUNCATE hashrates;'); // Need to re-index
|
||||
await this.$executeQuery('ALTER TABLE `state` CHANGE `name` `name` varchar(100)');
|
||||
await this.$executeQuery('ALTER TABLE `hashrates` ADD UNIQUE `hashrate_timestamp_pool_id` (`hashrate_timestamp`, `pool_id`)');
|
||||
}
|
||||
if (databaseSchemaVersion < 8 && isBitcoin === true) {
|
||||
this.uniqueLog(logger.notice, this.blocksTruncatedMessage);
|
||||
await this.$executeQuery('TRUNCATE hashrates;'); // Need to re-index
|
||||
await this.$executeQuery('ALTER TABLE `hashrates` DROP INDEX `PRIMARY`');
|
||||
await this.$executeQuery('ALTER TABLE `hashrates` ADD `id` int NOT NULL AUTO_INCREMENT PRIMARY KEY FIRST');
|
||||
await this.$executeQuery('ALTER TABLE `hashrates` ADD `share` float NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE `hashrates` ADD `type` enum("daily", "weekly") DEFAULT "daily"');
|
||||
await this.updateToSchemaVersion(8);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 10 && isBitcoin === true) {
|
||||
await this.$executeQuery('ALTER TABLE `blocks` ADD INDEX `blockTimestamp` (`blockTimestamp`)');
|
||||
}
|
||||
if (databaseSchemaVersion < 9 && isBitcoin === true) {
|
||||
this.uniqueLog(logger.notice, this.hashratesTruncatedMessage);
|
||||
await this.$executeQuery('TRUNCATE hashrates;'); // Need to re-index
|
||||
await this.$executeQuery('ALTER TABLE `state` CHANGE `name` `name` varchar(100)');
|
||||
await this.$executeQuery('ALTER TABLE `hashrates` ADD UNIQUE `hashrate_timestamp_pool_id` (`hashrate_timestamp`, `pool_id`)');
|
||||
await this.updateToSchemaVersion(9);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 11 && isBitcoin === true) {
|
||||
logger.warn(`'blocks' table has been truncated. Re-indexing from scratch.`);
|
||||
await this.$executeQuery('TRUNCATE blocks;'); // Need to re-index
|
||||
await this.$executeQuery(`ALTER TABLE blocks
|
||||
ADD avg_fee INT UNSIGNED NULL,
|
||||
ADD avg_fee_rate INT UNSIGNED NULL
|
||||
`);
|
||||
await this.$executeQuery('ALTER TABLE blocks MODIFY `reward` BIGINT UNSIGNED NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE blocks MODIFY `median_fee` INT UNSIGNED NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE blocks MODIFY `fees` INT UNSIGNED NOT NULL DEFAULT "0"');
|
||||
}
|
||||
if (databaseSchemaVersion < 10 && isBitcoin === true) {
|
||||
await this.$executeQuery('ALTER TABLE `blocks` ADD INDEX `blockTimestamp` (`blockTimestamp`)');
|
||||
await this.updateToSchemaVersion(10);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 12 && isBitcoin === true) {
|
||||
// No need to re-index because the new data type can contain larger values
|
||||
await this.$executeQuery('ALTER TABLE blocks MODIFY `fees` BIGINT UNSIGNED NOT NULL DEFAULT "0"');
|
||||
}
|
||||
if (databaseSchemaVersion < 11 && isBitcoin === true) {
|
||||
this.uniqueLog(logger.notice, this.blocksTruncatedMessage);
|
||||
await this.$executeQuery('TRUNCATE blocks;'); // Need to re-index
|
||||
await this.$executeQuery(`ALTER TABLE blocks
|
||||
ADD avg_fee INT UNSIGNED NULL,
|
||||
ADD avg_fee_rate INT UNSIGNED NULL
|
||||
`);
|
||||
await this.$executeQuery('ALTER TABLE blocks MODIFY `reward` BIGINT UNSIGNED NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE blocks MODIFY `median_fee` INT UNSIGNED NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE blocks MODIFY `fees` INT UNSIGNED NOT NULL DEFAULT "0"');
|
||||
await this.updateToSchemaVersion(11);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 13 && isBitcoin === true) {
|
||||
await this.$executeQuery('ALTER TABLE blocks MODIFY `difficulty` DOUBLE UNSIGNED NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE blocks MODIFY `median_fee` BIGINT UNSIGNED NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE blocks MODIFY `avg_fee` BIGINT UNSIGNED NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE blocks MODIFY `avg_fee_rate` BIGINT UNSIGNED NOT NULL DEFAULT "0"');
|
||||
}
|
||||
if (databaseSchemaVersion < 12 && isBitcoin === true) {
|
||||
// No need to re-index because the new data type can contain larger values
|
||||
await this.$executeQuery('ALTER TABLE blocks MODIFY `fees` BIGINT UNSIGNED NOT NULL DEFAULT "0"');
|
||||
await this.updateToSchemaVersion(12);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 14 && isBitcoin === true) {
|
||||
logger.warn(`'hashrates' table has been truncated. Re-indexing from scratch.`);
|
||||
await this.$executeQuery('TRUNCATE hashrates;'); // Need to re-index
|
||||
await this.$executeQuery('ALTER TABLE `hashrates` DROP FOREIGN KEY `hashrates_ibfk_1`');
|
||||
await this.$executeQuery('ALTER TABLE `hashrates` MODIFY `pool_id` SMALLINT UNSIGNED NOT NULL DEFAULT "0"');
|
||||
}
|
||||
if (databaseSchemaVersion < 13 && isBitcoin === true) {
|
||||
await this.$executeQuery('ALTER TABLE blocks MODIFY `difficulty` DOUBLE UNSIGNED NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE blocks MODIFY `median_fee` BIGINT UNSIGNED NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE blocks MODIFY `avg_fee` BIGINT UNSIGNED NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE blocks MODIFY `avg_fee_rate` BIGINT UNSIGNED NOT NULL DEFAULT "0"');
|
||||
await this.updateToSchemaVersion(13);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 16 && isBitcoin === true) {
|
||||
logger.warn(`'hashrates' table has been truncated. Re-indexing from scratch.`);
|
||||
await this.$executeQuery('TRUNCATE hashrates;'); // Need to re-index because we changed timestamps
|
||||
}
|
||||
if (databaseSchemaVersion < 14 && isBitcoin === true) {
|
||||
this.uniqueLog(logger.notice, this.hashratesTruncatedMessage);
|
||||
await this.$executeQuery('TRUNCATE hashrates;'); // Need to re-index
|
||||
await this.$executeQuery('ALTER TABLE `hashrates` DROP FOREIGN KEY `hashrates_ibfk_1`');
|
||||
await this.$executeQuery('ALTER TABLE `hashrates` MODIFY `pool_id` SMALLINT UNSIGNED NOT NULL DEFAULT "0"');
|
||||
await this.updateToSchemaVersion(14);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 17 && isBitcoin === true) {
|
||||
await this.$executeQuery('ALTER TABLE `pools` ADD `slug` CHAR(50) NULL');
|
||||
}
|
||||
if (databaseSchemaVersion < 16 && isBitcoin === true) {
|
||||
this.uniqueLog(logger.notice, this.hashratesTruncatedMessage);
|
||||
await this.$executeQuery('TRUNCATE hashrates;'); // Need to re-index because we changed timestamps
|
||||
await this.updateToSchemaVersion(16);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 18 && isBitcoin === true) {
|
||||
await this.$executeQuery('ALTER TABLE `blocks` ADD INDEX `hash` (`hash`);');
|
||||
}
|
||||
if (databaseSchemaVersion < 17 && isBitcoin === true) {
|
||||
await this.$executeQuery('ALTER TABLE `pools` ADD `slug` CHAR(50) NULL');
|
||||
await this.updateToSchemaVersion(17);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 19) {
|
||||
await this.$executeQuery(this.getCreateRatesTableQuery(), await this.$checkIfTableExists('rates'));
|
||||
if (databaseSchemaVersion < 18 && isBitcoin === true) {
|
||||
await this.$executeQuery('ALTER TABLE `blocks` ADD INDEX `hash` (`hash`);');
|
||||
await this.updateToSchemaVersion(18);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 19) {
|
||||
await this.$executeQuery(this.getCreateRatesTableQuery(), await this.$checkIfTableExists('rates'));
|
||||
await this.updateToSchemaVersion(19);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 20 && isBitcoin === true) {
|
||||
await this.$executeQuery(this.getCreateBlocksSummariesTableQuery(), await this.$checkIfTableExists('blocks_summaries'));
|
||||
await this.updateToSchemaVersion(20);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 21) {
|
||||
await this.$executeQuery('DROP TABLE IF EXISTS `rates`');
|
||||
await this.$executeQuery(this.getCreatePricesTableQuery(), await this.$checkIfTableExists('prices'));
|
||||
await this.updateToSchemaVersion(21);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 22 && isBitcoin === true) {
|
||||
await this.$executeQuery('DROP TABLE IF EXISTS `difficulty_adjustments`');
|
||||
await this.$executeQuery(this.getCreateDifficultyAdjustmentsTableQuery(), await this.$checkIfTableExists('difficulty_adjustments'));
|
||||
await this.updateToSchemaVersion(22);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 23) {
|
||||
await this.$executeQuery('TRUNCATE `prices`');
|
||||
await this.$executeQuery('ALTER TABLE `prices` DROP `avg_prices`');
|
||||
await this.$executeQuery('ALTER TABLE `prices` ADD `USD` float DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE `prices` ADD `EUR` float DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE `prices` ADD `GBP` float DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE `prices` ADD `CAD` float DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE `prices` ADD `CHF` float DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE `prices` ADD `AUD` float DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE `prices` ADD `JPY` float DEFAULT "0"');
|
||||
await this.updateToSchemaVersion(23);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 24 && isBitcoin == true) {
|
||||
await this.$executeQuery('DROP TABLE IF EXISTS `blocks_audits`');
|
||||
await this.$executeQuery(this.getCreateBlocksAuditsTableQuery(), await this.$checkIfTableExists('blocks_audits'));
|
||||
await this.updateToSchemaVersion(24);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 25 && isBitcoin === true) {
|
||||
await this.$executeQuery(this.getCreateLightningStatisticsQuery(), await this.$checkIfTableExists('lightning_stats'));
|
||||
await this.$executeQuery(this.getCreateNodesQuery(), await this.$checkIfTableExists('nodes'));
|
||||
await this.$executeQuery(this.getCreateChannelsQuery(), await this.$checkIfTableExists('channels'));
|
||||
await this.$executeQuery(this.getCreateNodesStatsQuery(), await this.$checkIfTableExists('node_stats'));
|
||||
await this.updateToSchemaVersion(25);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 26 && isBitcoin === true) {
|
||||
if (config.LIGHTNING.ENABLED) {
|
||||
this.uniqueLog(logger.notice, `'lightning_stats' table has been truncated.`);
|
||||
}
|
||||
} catch (e) {
|
||||
throw e;
|
||||
await this.$executeQuery(`TRUNCATE lightning_stats`);
|
||||
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD tor_nodes int(11) NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD clearnet_nodes int(11) NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD unannounced_nodes int(11) NOT NULL DEFAULT "0"');
|
||||
await this.updateToSchemaVersion(26);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 27 && isBitcoin === true) {
|
||||
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD avg_capacity bigint(20) unsigned NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD avg_fee_rate int(11) unsigned NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD avg_base_fee_mtokens bigint(20) unsigned NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD med_capacity bigint(20) unsigned NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD med_fee_rate int(11) unsigned NOT NULL DEFAULT "0"');
|
||||
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD med_base_fee_mtokens bigint(20) unsigned NOT NULL DEFAULT "0"');
|
||||
await this.updateToSchemaVersion(27);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 28 && isBitcoin === true) {
|
||||
if (config.LIGHTNING.ENABLED) {
|
||||
this.uniqueLog(logger.notice, `'lightning_stats' and 'node_stats' tables have been truncated.`);
|
||||
}
|
||||
await this.$executeQuery(`TRUNCATE lightning_stats`);
|
||||
await this.$executeQuery(`TRUNCATE node_stats`);
|
||||
await this.$executeQuery(`ALTER TABLE lightning_stats MODIFY added DATE`);
|
||||
await this.updateToSchemaVersion(28);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 29 && isBitcoin === true) {
|
||||
await this.$executeQuery(this.getCreateGeoNamesTableQuery(), await this.$checkIfTableExists('geo_names'));
|
||||
await this.$executeQuery('ALTER TABLE `nodes` ADD as_number int(11) unsigned NULL DEFAULT NULL');
|
||||
await this.$executeQuery('ALTER TABLE `nodes` ADD city_id int(11) unsigned NULL DEFAULT NULL');
|
||||
await this.$executeQuery('ALTER TABLE `nodes` ADD country_id int(11) unsigned NULL DEFAULT NULL');
|
||||
await this.$executeQuery('ALTER TABLE `nodes` ADD accuracy_radius int(11) unsigned NULL DEFAULT NULL');
|
||||
await this.$executeQuery('ALTER TABLE `nodes` ADD subdivision_id int(11) unsigned NULL DEFAULT NULL');
|
||||
await this.$executeQuery('ALTER TABLE `nodes` ADD longitude double NULL DEFAULT NULL');
|
||||
await this.$executeQuery('ALTER TABLE `nodes` ADD latitude double NULL DEFAULT NULL');
|
||||
await this.updateToSchemaVersion(29);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 30 && isBitcoin === true) {
|
||||
await this.$executeQuery('ALTER TABLE `geo_names` CHANGE `type` `type` enum("city","country","division","continent","as_organization") NOT NULL');
|
||||
await this.updateToSchemaVersion(30);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 31 && isBitcoin == true) { // Link blocks to prices
|
||||
await this.$executeQuery('ALTER TABLE `prices` ADD `id` int NULL AUTO_INCREMENT UNIQUE');
|
||||
await this.$executeQuery('DROP TABLE IF EXISTS `blocks_prices`');
|
||||
await this.$executeQuery(this.getCreateBlocksPricesTableQuery(), await this.$checkIfTableExists('blocks_prices'));
|
||||
await this.updateToSchemaVersion(31);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 32 && isBitcoin == true) {
|
||||
await this.$executeQuery('ALTER TABLE `blocks_summaries` ADD `template` JSON DEFAULT "[]"');
|
||||
await this.updateToSchemaVersion(32);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 33 && isBitcoin == true) {
|
||||
await this.$executeQuery('ALTER TABLE `geo_names` CHANGE `type` `type` enum("city","country","division","continent","as_organization", "country_iso_code") NOT NULL');
|
||||
await this.updateToSchemaVersion(33);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 34 && isBitcoin == true) {
|
||||
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD clearnet_tor_nodes int(11) NOT NULL DEFAULT "0"');
|
||||
await this.updateToSchemaVersion(34);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 35 && isBitcoin == true) {
|
||||
await this.$executeQuery('DELETE from `lightning_stats` WHERE added > "2021-09-19"');
|
||||
await this.$executeQuery('ALTER TABLE `lightning_stats` ADD CONSTRAINT added_unique UNIQUE (added);');
|
||||
await this.updateToSchemaVersion(35);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 36 && isBitcoin == true) {
|
||||
await this.$executeQuery('ALTER TABLE `nodes` ADD status TINYINT NOT NULL DEFAULT "1"');
|
||||
await this.updateToSchemaVersion(36);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 37 && isBitcoin == true) {
|
||||
await this.$executeQuery(this.getCreateLNNodesSocketsTableQuery(), await this.$checkIfTableExists('nodes_sockets'));
|
||||
await this.updateToSchemaVersion(37);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 38 && isBitcoin == true) {
|
||||
if (config.LIGHTNING.ENABLED) {
|
||||
this.uniqueLog(logger.notice, `'lightning_stats' and 'node_stats' tables have been truncated.`);
|
||||
}
|
||||
await this.$executeQuery(`TRUNCATE lightning_stats`);
|
||||
await this.$executeQuery(`TRUNCATE node_stats`);
|
||||
await this.$executeQuery('ALTER TABLE `lightning_stats` CHANGE `added` `added` timestamp NULL');
|
||||
await this.$executeQuery('ALTER TABLE `node_stats` CHANGE `added` `added` timestamp NULL');
|
||||
await this.updateToSchemaVersion(38);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 39 && isBitcoin === true) {
|
||||
await this.$executeQuery('ALTER TABLE `nodes` ADD alias_search TEXT NULL DEFAULT NULL AFTER `alias`');
|
||||
await this.$executeQuery('ALTER TABLE nodes ADD FULLTEXT(alias_search)');
|
||||
await this.updateToSchemaVersion(39);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 40 && isBitcoin === true) {
|
||||
await this.$executeQuery('ALTER TABLE `nodes` ADD capacity bigint(20) unsigned DEFAULT NULL');
|
||||
await this.$executeQuery('ALTER TABLE `nodes` ADD channels int(11) unsigned DEFAULT NULL');
|
||||
await this.$executeQuery('ALTER TABLE `nodes` ADD INDEX `capacity` (`capacity`);');
|
||||
await this.updateToSchemaVersion(40);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 41 && isBitcoin === true) {
|
||||
await this.$executeQuery('UPDATE channels SET closing_reason = NULL WHERE closing_reason = 1');
|
||||
await this.updateToSchemaVersion(41);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 42 && isBitcoin === true) {
|
||||
await this.$executeQuery('ALTER TABLE `channels` ADD closing_resolved tinyint(1) DEFAULT 0');
|
||||
await this.updateToSchemaVersion(42);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 43 && isBitcoin === true) {
|
||||
await this.$executeQuery(this.getCreateLNNodeRecordsTableQuery(), await this.$checkIfTableExists('nodes_records'));
|
||||
await this.updateToSchemaVersion(43);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 44 && isBitcoin === true) {
|
||||
await this.$executeQuery('UPDATE blocks_summaries SET template = NULL');
|
||||
await this.updateToSchemaVersion(44);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 45 && isBitcoin === true) {
|
||||
await this.$executeQuery('ALTER TABLE `blocks_audits` ADD fresh_txs JSON DEFAULT "[]"');
|
||||
await this.updateToSchemaVersion(45);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 46) {
|
||||
await this.$executeQuery(`ALTER TABLE blocks MODIFY blockTimestamp timestamp NOT NULL DEFAULT 0`);
|
||||
await this.updateToSchemaVersion(46);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 47) {
|
||||
await this.$executeQuery('ALTER TABLE `blocks` ADD cpfp_indexed tinyint(1) DEFAULT 0');
|
||||
await this.$executeQuery(this.getCreateCPFPTableQuery(), await this.$checkIfTableExists('cpfp_clusters'));
|
||||
await this.$executeQuery(this.getCreateTransactionsTableQuery(), await this.$checkIfTableExists('transactions'));
|
||||
await this.updateToSchemaVersion(47);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 48 && isBitcoin === true) {
|
||||
await this.$executeQuery('ALTER TABLE `channels` ADD source_checked tinyint(1) DEFAULT 0');
|
||||
await this.$executeQuery('ALTER TABLE `channels` ADD closing_fee bigint(20) unsigned DEFAULT 0');
|
||||
await this.$executeQuery('ALTER TABLE `channels` ADD node1_funding_balance bigint(20) unsigned DEFAULT 0');
|
||||
await this.$executeQuery('ALTER TABLE `channels` ADD node2_funding_balance bigint(20) unsigned DEFAULT 0');
|
||||
await this.$executeQuery('ALTER TABLE `channels` ADD node1_closing_balance bigint(20) unsigned DEFAULT 0');
|
||||
await this.$executeQuery('ALTER TABLE `channels` ADD node2_closing_balance bigint(20) unsigned DEFAULT 0');
|
||||
await this.$executeQuery('ALTER TABLE `channels` ADD funding_ratio float unsigned DEFAULT NULL');
|
||||
await this.$executeQuery('ALTER TABLE `channels` ADD closed_by varchar(66) DEFAULT NULL');
|
||||
await this.$executeQuery('ALTER TABLE `channels` ADD single_funded tinyint(1) DEFAULT 0');
|
||||
await this.$executeQuery('ALTER TABLE `channels` ADD outputs JSON DEFAULT "[]"');
|
||||
await this.updateToSchemaVersion(48);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 49 && isBitcoin === true) {
|
||||
await this.$executeQuery('TRUNCATE TABLE `blocks_audits`');
|
||||
await this.updateToSchemaVersion(49);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 50) {
|
||||
await this.$executeQuery('ALTER TABLE `blocks` DROP COLUMN `cpfp_indexed`');
|
||||
await this.updateToSchemaVersion(50);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 51) {
|
||||
await this.$executeQuery('ALTER TABLE `cpfp_clusters` ADD INDEX `height` (`height`)');
|
||||
await this.updateToSchemaVersion(51);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 52) {
|
||||
await this.$executeQuery(this.getCreateCompactCPFPTableQuery(), await this.$checkIfTableExists('compact_cpfp_clusters'));
|
||||
await this.$executeQuery(this.getCreateCompactTransactionsTableQuery(), await this.$checkIfTableExists('compact_transactions'));
|
||||
try {
|
||||
await this.$convertCompactCpfpTables();
|
||||
await this.$executeQuery('DROP TABLE IF EXISTS `transactions`');
|
||||
await this.$executeQuery('DROP TABLE IF EXISTS `cpfp_clusters`');
|
||||
await this.updateToSchemaVersion(52);
|
||||
} catch (e) {
|
||||
logger.warn('' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 53) {
|
||||
await this.$executeQuery('ALTER TABLE statistics MODIFY mempool_byte_weight bigint(20) UNSIGNED NOT NULL');
|
||||
await this.updateToSchemaVersion(53);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 54) {
|
||||
this.uniqueLog(logger.notice, `'prices' table has been truncated`);
|
||||
await this.$executeQuery(`TRUNCATE prices`);
|
||||
if (isBitcoin === true) {
|
||||
this.uniqueLog(logger.notice, `'blocks_prices' table has been truncated`);
|
||||
await this.$executeQuery(`TRUNCATE blocks_prices`);
|
||||
}
|
||||
await this.updateToSchemaVersion(54);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 55) {
|
||||
await this.$executeQuery(this.getAdditionalBlocksDataQuery());
|
||||
this.uniqueLog(logger.notice, this.blocksTruncatedMessage);
|
||||
await this.$executeQuery('TRUNCATE blocks;'); // Need to re-index
|
||||
await this.updateToSchemaVersion(55);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 56) {
|
||||
await this.$executeQuery('ALTER TABLE pools ADD unique_id int NOT NULL DEFAULT -1');
|
||||
await this.$executeQuery('TRUNCATE TABLE `blocks`');
|
||||
this.uniqueLog(logger.notice, this.blocksTruncatedMessage);
|
||||
await this.$executeQuery('DELETE FROM `pools`');
|
||||
await this.$executeQuery('ALTER TABLE pools AUTO_INCREMENT = 1');
|
||||
this.uniqueLog(logger.notice, '`pools` table has been truncated`');
|
||||
await this.updateToSchemaVersion(56);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 57 && isBitcoin === true) {
|
||||
await this.$executeQuery(`ALTER TABLE nodes MODIFY updated_at datetime NULL`);
|
||||
await this.updateToSchemaVersion(57);
|
||||
}
|
||||
|
||||
if (databaseSchemaVersion < 58) {
|
||||
// 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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +552,7 @@ class DatabaseMigration {
|
||||
/**
|
||||
* Small query execution wrapper to log all executed queries
|
||||
*/
|
||||
private async $executeQuery(query: string, silent: boolean = false): Promise<any> {
|
||||
private async $executeQuery(query: string, silent = false): Promise<any> {
|
||||
if (!silent) {
|
||||
logger.debug('MIGRATIONS: Execute query:\n' + query);
|
||||
}
|
||||
@@ -257,21 +581,17 @@ class DatabaseMigration {
|
||||
* Create the `state` table
|
||||
*/
|
||||
private async $createMigrationStateTable(): Promise<void> {
|
||||
try {
|
||||
const query = `CREATE TABLE IF NOT EXISTS state (
|
||||
name varchar(25) NOT NULL,
|
||||
number int(11) NULL,
|
||||
string varchar(100) NULL,
|
||||
CONSTRAINT name_unique UNIQUE (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||
await this.$executeQuery(query);
|
||||
const query = `CREATE TABLE IF NOT EXISTS state (
|
||||
name varchar(25) NOT NULL,
|
||||
number int(11) NULL,
|
||||
string varchar(100) NULL,
|
||||
CONSTRAINT name_unique UNIQUE (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||
await this.$executeQuery(query);
|
||||
|
||||
// Set initial values
|
||||
await this.$executeQuery(`INSERT INTO state VALUES('schema_version', 0, NULL);`);
|
||||
await this.$executeQuery(`INSERT INTO state VALUES('last_elements_block', 0, NULL);`);
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
// Set initial values
|
||||
await this.$executeQuery(`INSERT INTO state VALUES('schema_version', 0, NULL);`);
|
||||
await this.$executeQuery(`INSERT INTO state VALUES('last_elements_block', 0, NULL);`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -282,6 +602,8 @@ class DatabaseMigration {
|
||||
for (const query of this.getMigrationQueriesFromVersion(version)) {
|
||||
transactionQueries.push(query);
|
||||
}
|
||||
|
||||
logger.notice(`MIGRATIONS: ${version > 0 ? 'Upgrading' : 'Initializing'} database schema version number to ${DatabaseMigration.currentVersion}`);
|
||||
transactionQueries.push(this.getUpdateToLatestSchemaVersionQuery());
|
||||
|
||||
try {
|
||||
@@ -305,6 +627,9 @@ class DatabaseMigration {
|
||||
|
||||
if (version < 1) {
|
||||
if (config.MEMPOOL.NETWORK !== 'liquid' && config.MEMPOOL.NETWORK !== 'liquidtestnet') {
|
||||
if (version > 0) {
|
||||
logger.notice(`MIGRATIONS: Migrating (shifting) statistics table data`);
|
||||
}
|
||||
queries.push(this.getShiftStatisticsQuery());
|
||||
}
|
||||
}
|
||||
@@ -313,10 +638,15 @@ class DatabaseMigration {
|
||||
queries.push(`INSERT INTO state(name, number, string) VALUES ('last_hashrates_indexing', 0, NULL)`);
|
||||
}
|
||||
|
||||
if (version < 9 && isBitcoin === true) {
|
||||
if (version < 9 && isBitcoin === true) {
|
||||
queries.push(`INSERT INTO state(name, number, string) VALUES ('last_weekly_hashrates_indexing', 0, NULL)`);
|
||||
}
|
||||
|
||||
if (version < 58) {
|
||||
queries.push(`DELETE FROM state WHERE name = 'last_hashrates_indexing'`);
|
||||
queries.push(`DELETE FROM state WHERE name = 'last_weekly_hashrates_indexing'`);
|
||||
}
|
||||
|
||||
return queries;
|
||||
}
|
||||
|
||||
@@ -327,6 +657,10 @@ class DatabaseMigration {
|
||||
return `UPDATE state SET number = ${DatabaseMigration.currentVersion} WHERE name = 'schema_version';`;
|
||||
}
|
||||
|
||||
private async updateToSchemaVersion(version): Promise<void> {
|
||||
await this.$executeQuery(`UPDATE state SET number = ${version} WHERE name = 'schema_version';`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Print current database version
|
||||
*/
|
||||
@@ -459,6 +793,28 @@ class DatabaseMigration {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||
}
|
||||
|
||||
private getAdditionalBlocksDataQuery(): string {
|
||||
return `ALTER TABLE blocks
|
||||
ADD median_timestamp timestamp NOT NULL,
|
||||
ADD coinbase_address varchar(100) NULL,
|
||||
ADD coinbase_signature varchar(500) NULL,
|
||||
ADD coinbase_signature_ascii varchar(500) NULL,
|
||||
ADD avg_tx_size double unsigned NOT NULL,
|
||||
ADD total_inputs int unsigned NOT NULL,
|
||||
ADD total_outputs int unsigned NOT NULL,
|
||||
ADD total_output_amt bigint unsigned NOT NULL,
|
||||
ADD fee_percentiles longtext NULL,
|
||||
ADD median_fee_amt int unsigned NULL,
|
||||
ADD segwit_total_txs int unsigned NOT NULL,
|
||||
ADD segwit_total_size int unsigned NOT NULL,
|
||||
ADD segwit_total_weight int unsigned NOT NULL,
|
||||
ADD header varchar(160) NOT NULL,
|
||||
ADD utxoset_change int NOT NULL,
|
||||
ADD utxoset_size int unsigned NULL,
|
||||
ADD total_input_amt bigint unsigned NULL
|
||||
`;
|
||||
}
|
||||
|
||||
private getCreateDailyStatsTableQuery(): string {
|
||||
return `CREATE TABLE IF NOT EXISTS hashrates (
|
||||
hashrate_timestamp timestamp NOT NULL,
|
||||
@@ -470,7 +826,7 @@ class DatabaseMigration {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||
}
|
||||
|
||||
private getCreateRatesTableQuery(): string {
|
||||
private getCreateRatesTableQuery(): string { // This table has been replaced by the prices table
|
||||
return `CREATE TABLE IF NOT EXISTS rates (
|
||||
height int(10) unsigned NOT NULL,
|
||||
bisq_rates JSON NOT NULL,
|
||||
@@ -478,24 +834,256 @@ class DatabaseMigration {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||
}
|
||||
|
||||
public async $truncateIndexedData(tables: string[]) {
|
||||
const allowedTables = ['blocks', 'hashrates'];
|
||||
private getCreateBlocksSummariesTableQuery(): string {
|
||||
return `CREATE TABLE IF NOT EXISTS blocks_summaries (
|
||||
height int(10) unsigned NOT NULL,
|
||||
id varchar(65) NOT NULL,
|
||||
transactions JSON NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
INDEX (height)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||
}
|
||||
|
||||
private getCreatePricesTableQuery(): string {
|
||||
return `CREATE TABLE IF NOT EXISTS prices (
|
||||
time timestamp NOT NULL,
|
||||
avg_prices JSON NOT NULL,
|
||||
PRIMARY KEY (time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||
}
|
||||
|
||||
private getCreateDifficultyAdjustmentsTableQuery(): string {
|
||||
return `CREATE TABLE IF NOT EXISTS difficulty_adjustments (
|
||||
time timestamp NOT NULL,
|
||||
height int(10) unsigned NOT NULL,
|
||||
difficulty double unsigned NOT NULL,
|
||||
adjustment float NOT NULL,
|
||||
PRIMARY KEY (height),
|
||||
INDEX (time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||
}
|
||||
|
||||
private getCreateLightningStatisticsQuery(): string {
|
||||
return `CREATE TABLE IF NOT EXISTS lightning_stats (
|
||||
id int(11) NOT NULL AUTO_INCREMENT,
|
||||
added datetime NOT NULL,
|
||||
channel_count int(11) NOT NULL,
|
||||
node_count int(11) NOT NULL,
|
||||
total_capacity double unsigned NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||
}
|
||||
|
||||
private getCreateNodesQuery(): string {
|
||||
return `CREATE TABLE IF NOT EXISTS nodes (
|
||||
public_key varchar(66) NOT NULL,
|
||||
first_seen datetime NOT NULL,
|
||||
updated_at datetime NOT NULL,
|
||||
alias varchar(200) CHARACTER SET utf8mb4 NOT NULL,
|
||||
color varchar(200) NOT NULL,
|
||||
sockets text DEFAULT NULL,
|
||||
PRIMARY KEY (public_key),
|
||||
KEY alias (alias(10))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||
}
|
||||
|
||||
private getCreateChannelsQuery(): string {
|
||||
return `CREATE TABLE IF NOT EXISTS channels (
|
||||
id bigint(11) unsigned NOT NULL,
|
||||
short_id varchar(15) NOT NULL DEFAULT '',
|
||||
capacity bigint(20) unsigned NOT NULL,
|
||||
transaction_id varchar(64) NOT NULL,
|
||||
transaction_vout int(11) NOT NULL,
|
||||
updated_at datetime DEFAULT NULL,
|
||||
created datetime DEFAULT NULL,
|
||||
status int(11) NOT NULL DEFAULT 0,
|
||||
closing_transaction_id varchar(64) DEFAULT NULL,
|
||||
closing_date datetime DEFAULT NULL,
|
||||
closing_reason int(11) DEFAULT NULL,
|
||||
node1_public_key varchar(66) NOT NULL,
|
||||
node1_base_fee_mtokens bigint(20) unsigned DEFAULT NULL,
|
||||
node1_cltv_delta int(11) DEFAULT NULL,
|
||||
node1_fee_rate bigint(11) DEFAULT NULL,
|
||||
node1_is_disabled tinyint(1) DEFAULT NULL,
|
||||
node1_max_htlc_mtokens bigint(20) unsigned DEFAULT NULL,
|
||||
node1_min_htlc_mtokens bigint(20) DEFAULT NULL,
|
||||
node1_updated_at datetime DEFAULT NULL,
|
||||
node2_public_key varchar(66) NOT NULL,
|
||||
node2_base_fee_mtokens bigint(20) unsigned DEFAULT NULL,
|
||||
node2_cltv_delta int(11) DEFAULT NULL,
|
||||
node2_fee_rate bigint(11) DEFAULT NULL,
|
||||
node2_is_disabled tinyint(1) DEFAULT NULL,
|
||||
node2_max_htlc_mtokens bigint(20) unsigned DEFAULT NULL,
|
||||
node2_min_htlc_mtokens bigint(20) unsigned DEFAULT NULL,
|
||||
node2_updated_at datetime DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY node1_public_key (node1_public_key),
|
||||
KEY node2_public_key (node2_public_key),
|
||||
KEY status (status),
|
||||
KEY short_id (short_id),
|
||||
KEY transaction_id (transaction_id),
|
||||
KEY closing_transaction_id (closing_transaction_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||
}
|
||||
|
||||
private getCreateNodesStatsQuery(): string {
|
||||
return `CREATE TABLE IF NOT EXISTS node_stats (
|
||||
id int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||||
public_key varchar(66) NOT NULL DEFAULT '',
|
||||
added date NOT NULL,
|
||||
capacity bigint(20) unsigned NOT NULL DEFAULT 0,
|
||||
channels int(11) unsigned NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY added (added,public_key),
|
||||
KEY public_key (public_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||
}
|
||||
|
||||
private getCreateBlocksAuditsTableQuery(): string {
|
||||
return `CREATE TABLE IF NOT EXISTS blocks_audits (
|
||||
time timestamp NOT NULL,
|
||||
hash varchar(65) NOT NULL,
|
||||
height int(10) unsigned NOT NULL,
|
||||
missing_txs JSON NOT NULL,
|
||||
added_txs JSON NOT NULL,
|
||||
match_rate float unsigned NOT NULL,
|
||||
PRIMARY KEY (hash),
|
||||
INDEX (height)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||
}
|
||||
|
||||
private getCreateGeoNamesTableQuery(): string {
|
||||
return `CREATE TABLE geo_names (
|
||||
id int(11) unsigned NOT NULL,
|
||||
type enum('city','country','division','continent') NOT NULL,
|
||||
names text DEFAULT NULL,
|
||||
UNIQUE KEY id (id,type),
|
||||
KEY id_2 (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||
}
|
||||
|
||||
private getCreateBlocksPricesTableQuery(): string {
|
||||
return `CREATE TABLE IF NOT EXISTS blocks_prices (
|
||||
height int(10) unsigned NOT NULL,
|
||||
price_id int(10) unsigned NOT NULL,
|
||||
PRIMARY KEY (height),
|
||||
INDEX (price_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||
}
|
||||
|
||||
private getCreateLNNodesSocketsTableQuery(): string {
|
||||
return `CREATE TABLE IF NOT EXISTS nodes_sockets (
|
||||
public_key varchar(66) NOT NULL,
|
||||
socket varchar(100) NOT NULL,
|
||||
type enum('ipv4', 'ipv6', 'torv2', 'torv3', 'i2p', 'dns', 'websocket') NULL,
|
||||
UNIQUE KEY public_key_socket (public_key, socket),
|
||||
INDEX (public_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||
}
|
||||
|
||||
private getCreateLNNodeRecordsTableQuery(): string {
|
||||
return `CREATE TABLE IF NOT EXISTS nodes_records (
|
||||
public_key varchar(66) NOT NULL,
|
||||
type int(10) unsigned NOT NULL,
|
||||
payload blob NOT NULL,
|
||||
UNIQUE KEY public_key_type (public_key, type),
|
||||
INDEX (public_key),
|
||||
FOREIGN KEY (public_key)
|
||||
REFERENCES nodes (public_key)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||
}
|
||||
|
||||
private getCreateCPFPTableQuery(): string {
|
||||
return `CREATE TABLE IF NOT EXISTS cpfp_clusters (
|
||||
root varchar(65) NOT NULL,
|
||||
height int(10) NOT NULL,
|
||||
txs JSON DEFAULT NULL,
|
||||
fee_rate double unsigned NOT NULL,
|
||||
PRIMARY KEY (root)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||
}
|
||||
|
||||
private getCreateTransactionsTableQuery(): string {
|
||||
return `CREATE TABLE IF NOT EXISTS transactions (
|
||||
txid varchar(65) NOT NULL,
|
||||
cluster varchar(65) DEFAULT NULL,
|
||||
PRIMARY KEY (txid),
|
||||
FOREIGN KEY (cluster) REFERENCES cpfp_clusters (root) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||
}
|
||||
|
||||
private getCreateCompactCPFPTableQuery(): string {
|
||||
return `CREATE TABLE IF NOT EXISTS compact_cpfp_clusters (
|
||||
root binary(32) NOT NULL,
|
||||
height int(10) NOT NULL,
|
||||
txs BLOB DEFAULT NULL,
|
||||
fee_rate float unsigned,
|
||||
PRIMARY KEY (root),
|
||||
INDEX (height)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||
}
|
||||
|
||||
private getCreateCompactTransactionsTableQuery(): string {
|
||||
return `CREATE TABLE IF NOT EXISTS compact_transactions (
|
||||
txid binary(32) NOT NULL,
|
||||
cluster binary(32) DEFAULT NULL,
|
||||
PRIMARY KEY (txid)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||
}
|
||||
|
||||
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`);
|
||||
await Common.sleep$(5000);
|
||||
|
||||
await this.$executeQuery(`TRUNCATE blocks`);
|
||||
await this.$executeQuery(`TRUNCATE hashrates`);
|
||||
await this.$executeQuery(`TRUNCATE difficulty_adjustments`);
|
||||
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'`);
|
||||
}
|
||||
|
||||
private async $convertCompactCpfpTables(): Promise<void> {
|
||||
try {
|
||||
for (const table of tables) {
|
||||
if (!allowedTables.includes(table)) {
|
||||
logger.debug(`Table ${table} cannot to be re-indexed (not allowed)`);
|
||||
continue;
|
||||
const batchSize = 250;
|
||||
const maxHeight = await blocksRepository.$mostRecentBlockHeight() || 0;
|
||||
const [minHeightRows]: any = await DB.query(`SELECT MIN(height) AS minHeight from cpfp_clusters`);
|
||||
const minHeight = (minHeightRows.length && minHeightRows[0].minHeight != null) ? minHeightRows[0].minHeight : maxHeight;
|
||||
let height = maxHeight;
|
||||
|
||||
// Logging
|
||||
let timer = new Date().getTime() / 1000;
|
||||
const startedAt = new Date().getTime() / 1000;
|
||||
|
||||
while (height > minHeight) {
|
||||
const [rows] = await DB.query(
|
||||
`
|
||||
SELECT * from cpfp_clusters
|
||||
WHERE height <= ? AND height > ?
|
||||
ORDER BY height
|
||||
`,
|
||||
[height, height - batchSize]
|
||||
) as RowDataPacket[][];
|
||||
if (rows?.length) {
|
||||
await cpfpRepository.$batchSaveClusters(rows.map(row => {
|
||||
return {
|
||||
root: row.root,
|
||||
height: row.height,
|
||||
txs: JSON.parse(row.txs),
|
||||
effectiveFeePerVsize: row.fee_rate,
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
await this.$executeQuery(`TRUNCATE ${table}`, true);
|
||||
if (table === 'hashrates') {
|
||||
await this.$executeQuery('UPDATE state set number = 0 where name = "last_hashrates_indexing"', true);
|
||||
}
|
||||
logger.notice(`Table ${table} has been truncated`);
|
||||
const elapsed = new Date().getTime() / 1000 - timer;
|
||||
const runningFor = new Date().getTime() / 1000 - startedAt;
|
||||
logger.debug(`Migrated cpfp data from block ${height} to ${height - batchSize} in ${elapsed.toFixed(2)} seconds | total elapsed: ${runningFor.toFixed(2)} seconds`);
|
||||
timer = new Date().getTime() / 1000;
|
||||
height -= batchSize;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn(`Unable to erase indexed data`);
|
||||
logger.warn(`Failed to migrate cpfp transaction data`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,65 +2,104 @@ import config from '../config';
|
||||
import { IDifficultyAdjustment } from '../mempool.interfaces';
|
||||
import blocks from './blocks';
|
||||
|
||||
class DifficultyAdjustmentApi {
|
||||
constructor() { }
|
||||
export interface DifficultyAdjustment {
|
||||
progressPercent: number; // Percent: 0 to 100
|
||||
difficultyChange: number; // Percent: -75 to 300
|
||||
estimatedRetargetDate: number; // Unix time in ms
|
||||
remainingBlocks: number; // Block count
|
||||
remainingTime: number; // Duration of time in ms
|
||||
previousRetarget: number; // Percent: -75 to 300
|
||||
previousTime: number; // Unix time in ms
|
||||
nextRetargetHeight: number; // Block Height
|
||||
timeAvg: number; // Duration of time in ms
|
||||
timeOffset: number; // (Testnet) Time since last block (cap @ 20min) in ms
|
||||
expectedBlocks: number; // Block count
|
||||
}
|
||||
|
||||
public getDifficultyAdjustment(): IDifficultyAdjustment {
|
||||
export function calcDifficultyAdjustment(
|
||||
DATime: number,
|
||||
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 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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Testnet difficulty is set to 1 after 20 minutes of no blocks,
|
||||
// therefore the time between blocks will always be below 20 minutes (1200s).
|
||||
let timeOffset = 0;
|
||||
if (network === 'testnet') {
|
||||
if (timeAvgSecs > TESTNET_MAX_BLOCK_SECONDS) {
|
||||
timeAvgSecs = TESTNET_MAX_BLOCK_SECONDS;
|
||||
}
|
||||
|
||||
const secondsSinceLastBlock = nowSeconds - latestBlockTimestamp;
|
||||
if (secondsSinceLastBlock + timeAvgSecs > TESTNET_MAX_BLOCK_SECONDS) {
|
||||
timeOffset = -Math.min(secondsSinceLastBlock, TESTNET_MAX_BLOCK_SECONDS) * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
const timeAvg = Math.floor(timeAvgSecs * 1000);
|
||||
const remainingTime = remainingBlocks * timeAvg;
|
||||
const estimatedRetargetDate = remainingTime + nowSeconds * 1000;
|
||||
|
||||
return {
|
||||
progressPercent,
|
||||
difficultyChange,
|
||||
estimatedRetargetDate,
|
||||
remainingBlocks,
|
||||
remainingTime,
|
||||
previousRetarget,
|
||||
previousTime: DATime,
|
||||
nextRetargetHeight,
|
||||
timeAvg,
|
||||
timeOffset,
|
||||
expectedBlocks,
|
||||
};
|
||||
}
|
||||
|
||||
class DifficultyAdjustmentApi {
|
||||
public getDifficultyAdjustment(): IDifficultyAdjustment | null {
|
||||
const DATime = blocks.getLastDifficultyAdjustmentTime();
|
||||
const previousRetarget = blocks.getPreviousDifficultyRetarget();
|
||||
const blockHeight = blocks.getCurrentBlockHeight();
|
||||
const blocksCache = blocks.getBlocks();
|
||||
const latestBlock = blocksCache[blocksCache.length - 1];
|
||||
|
||||
const now = new Date().getTime() / 1000;
|
||||
const diff = now - DATime;
|
||||
const blocksInEpoch = blockHeight % 2016;
|
||||
const progressPercent = (blocksInEpoch >= 0) ? blocksInEpoch / 2016 * 100 : 100;
|
||||
const remainingBlocks = 2016 - blocksInEpoch;
|
||||
const nextRetargetHeight = blockHeight + remainingBlocks;
|
||||
|
||||
let difficultyChange = 0;
|
||||
if (remainingBlocks < 1870) {
|
||||
if (blocksInEpoch > 0) {
|
||||
difficultyChange = (600 / (diff / blocksInEpoch) - 1) * 100;
|
||||
}
|
||||
if (difficultyChange > 300) {
|
||||
difficultyChange = 300;
|
||||
}
|
||||
if (difficultyChange < -75) {
|
||||
difficultyChange = -75;
|
||||
}
|
||||
if (!latestBlock) {
|
||||
return null;
|
||||
}
|
||||
const nowSeconds = Math.floor(new Date().getTime() / 1000);
|
||||
|
||||
let timeAvgMins = blocksInEpoch && blocksInEpoch > 146 ? diff / blocksInEpoch / 60 : 10;
|
||||
|
||||
// Testnet difficulty is set to 1 after 20 minutes of no blocks,
|
||||
// therefore the time between blocks will always be below 20 minutes (1200s).
|
||||
let timeOffset = 0;
|
||||
if (config.MEMPOOL.NETWORK === 'testnet') {
|
||||
if (timeAvgMins > 20) {
|
||||
timeAvgMins = 20;
|
||||
}
|
||||
if (now - latestBlock.timestamp + timeAvgMins * 60 > 1200) {
|
||||
timeOffset = -Math.min(now - latestBlock.timestamp, 1200) * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
const timeAvg = timeAvgMins * 60 * 1000 ;
|
||||
const remainingTime = (remainingBlocks * timeAvg) + (now * 1000);
|
||||
const estimatedRetargetDate = remainingTime + now;
|
||||
|
||||
return {
|
||||
progressPercent,
|
||||
difficultyChange,
|
||||
estimatedRetargetDate,
|
||||
remainingBlocks,
|
||||
remainingTime,
|
||||
previousRetarget,
|
||||
nextRetargetHeight,
|
||||
timeAvg,
|
||||
timeOffset,
|
||||
};
|
||||
return calcDifficultyAdjustment(
|
||||
DATime, nowSeconds, blockHeight, previousRetarget,
|
||||
config.MEMPOOL.NETWORK, latestBlock.timestamp
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as fs from 'fs';
|
||||
const fsPromises = fs.promises;
|
||||
import * as cluster from 'cluster';
|
||||
import cluster from 'cluster';
|
||||
import memPool from './mempool';
|
||||
import blocks from './blocks';
|
||||
import logger from '../logger';
|
||||
@@ -9,25 +9,35 @@ import { TransactionExtended } from '../mempool.interfaces';
|
||||
import { Common } from './common';
|
||||
|
||||
class DiskCache {
|
||||
private cacheSchemaVersion = 1;
|
||||
private cacheSchemaVersion = 3;
|
||||
|
||||
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 CHUNK_FILES = 25;
|
||||
private isWritingCache = false;
|
||||
|
||||
constructor() { }
|
||||
constructor() {
|
||||
if (!cluster.isPrimary) {
|
||||
return;
|
||||
}
|
||||
process.on('SIGINT', (e) => {
|
||||
this.$saveCacheToDisk(true);
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
async $saveCacheToDisk(): Promise<void> {
|
||||
if (!cluster.isMaster) {
|
||||
async $saveCacheToDisk(sync: boolean = false): Promise<void> {
|
||||
if (!cluster.isPrimary) {
|
||||
return;
|
||||
}
|
||||
if (this.isWritingCache) {
|
||||
logger.debug('Saving cache already in progress. Skipping.')
|
||||
logger.debug('Saving cache already in progress. Skipping.');
|
||||
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();
|
||||
@@ -40,18 +50,48 @@ class DiskCache {
|
||||
|
||||
const chunkSize = Math.floor(mempoolArray.length / DiskCache.CHUNK_FILES);
|
||||
|
||||
await fsPromises.writeFile(DiskCache.FILE_NAME, JSON.stringify({
|
||||
cacheSchemaVersion: this.cacheSchemaVersion,
|
||||
blocks: blocks.getBlocks(),
|
||||
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'});
|
||||
}), { 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 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 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) {
|
||||
@@ -60,14 +100,29 @@ class DiskCache {
|
||||
}
|
||||
}
|
||||
|
||||
wipeCache() {
|
||||
fs.unlinkSync(DiskCache.FILE_NAME);
|
||||
wipeCache(): void {
|
||||
logger.notice(`Wiping nodejs backend cache/cache*.json files`);
|
||||
try {
|
||||
fs.unlinkSync(DiskCache.FILE_NAME);
|
||||
} catch (e: any) {
|
||||
if (e?.code !== 'ENOENT') {
|
||||
logger.err(`Cannot wipe cache file ${DiskCache.FILE_NAME}. Exception ${JSON.stringify(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 1; i < DiskCache.CHUNK_FILES; i++) {
|
||||
fs.unlinkSync(DiskCache.FILE_NAMES.replace('{number}', i.toString()));
|
||||
const filename = DiskCache.FILE_NAMES.replace('{number}', i.toString());
|
||||
try {
|
||||
fs.unlinkSync(filename);
|
||||
} catch (e: any) {
|
||||
if (e?.code !== 'ENOENT') {
|
||||
logger.err(`Cannot wipe cache file ${filename}. Exception ${JSON.stringify(e)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadMempoolCache() {
|
||||
|
||||
loadMempoolCache(): void {
|
||||
if (!fs.existsSync(DiskCache.FILE_NAME)) {
|
||||
return;
|
||||
}
|
||||
@@ -81,6 +136,10 @@ class DiskCache {
|
||||
logger.notice('Disk cache contains an outdated schema version. Clearing it and skipping the cache loading.');
|
||||
return this.wipeCache();
|
||||
}
|
||||
if (data.network && data.network !== config.MEMPOOL.NETWORK) {
|
||||
logger.notice('Disk cache contains data from a different network. Clearing it and skipping the cache loading.');
|
||||
return this.wipeCache();
|
||||
}
|
||||
|
||||
if (data.mempoolArray) {
|
||||
for (const tx of data.mempoolArray) {
|
||||
@@ -109,6 +168,7 @@ class DiskCache {
|
||||
|
||||
memPool.setMempool(data.mempool);
|
||||
blocks.setBlocks(data.blocks);
|
||||
blocks.setBlockSummaries(data.blockSummaries || []);
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse mempoool and blocks cache. Skipping. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
|
||||
709
backend/src/api/explorer/channels.api.ts
Normal file
709
backend/src/api/explorer/channels.api.ts
Normal file
@@ -0,0 +1,709 @@
|
||||
import logger from '../../logger';
|
||||
import DB from '../../database';
|
||||
import nodesApi from './nodes.api';
|
||||
import { ResultSetHeader } from 'mysql2';
|
||||
import { ILightningApi } from '../lightning/lightning-api.interface';
|
||||
import { Common } from '../common';
|
||||
|
||||
class ChannelsApi {
|
||||
public async $getAllChannels(): Promise<any[]> {
|
||||
try {
|
||||
const query = `SELECT * FROM channels`;
|
||||
const [rows]: any = await DB.query(query);
|
||||
return rows;
|
||||
} catch (e) {
|
||||
logger.err('$getAllChannels error: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getAllChannelsGeo(publicKey?: string, style?: string): Promise<any[]> {
|
||||
try {
|
||||
let select: string;
|
||||
if (style === 'widget') {
|
||||
select = `
|
||||
nodes_1.latitude AS node1_latitude, nodes_1.longitude AS node1_longitude,
|
||||
nodes_2.latitude AS node2_latitude, nodes_2.longitude AS node2_longitude
|
||||
`;
|
||||
} else {
|
||||
select = `
|
||||
nodes_1.public_key as node1_public_key, nodes_1.alias AS node1_alias,
|
||||
nodes_1.latitude AS node1_latitude, nodes_1.longitude AS node1_longitude,
|
||||
nodes_2.public_key as node2_public_key, nodes_2.alias AS node2_alias,
|
||||
nodes_2.latitude AS node2_latitude, nodes_2.longitude AS node2_longitude
|
||||
`;
|
||||
}
|
||||
|
||||
const params: string[] = [];
|
||||
let query = `SELECT ${select}
|
||||
FROM channels
|
||||
JOIN nodes AS nodes_1 on nodes_1.public_key = channels.node1_public_key
|
||||
JOIN nodes AS nodes_2 on nodes_2.public_key = channels.node2_public_key
|
||||
WHERE channels.status = 1
|
||||
AND nodes_1.latitude IS NOT NULL AND nodes_1.longitude IS NOT NULL
|
||||
AND nodes_2.latitude IS NOT NULL AND nodes_2.longitude IS NOT NULL
|
||||
`;
|
||||
|
||||
if (publicKey !== undefined) {
|
||||
query += ' AND (nodes_1.public_key = ? OR nodes_2.public_key = ?)';
|
||||
params.push(publicKey);
|
||||
params.push(publicKey);
|
||||
} else {
|
||||
query += ` AND channels.capacity > 1000000
|
||||
GROUP BY nodes_1.public_key, nodes_2.public_key
|
||||
ORDER BY channels.capacity DESC
|
||||
LIMIT 10000
|
||||
`;
|
||||
}
|
||||
|
||||
const [rows]: any = await DB.query(query, params);
|
||||
return rows.map((row) => {
|
||||
if (style === 'widget') {
|
||||
return [
|
||||
row.node1_longitude, row.node1_latitude,
|
||||
row.node2_longitude, row.node2_latitude,
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
row.node1_public_key, row.node1_alias,
|
||||
row.node1_longitude, row.node1_latitude,
|
||||
row.node2_public_key, row.node2_alias,
|
||||
row.node2_longitude, row.node2_latitude,
|
||||
];
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
logger.err('$getAllChannelsGeo error: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $searchChannelsById(search: string): Promise<any[]> {
|
||||
try {
|
||||
const searchStripped = search.replace('%', '') + '%';
|
||||
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;
|
||||
} catch (e) {
|
||||
logger.err('$searchChannelsById error: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getChannelsByStatus(status: number | number[]): Promise<any[]> {
|
||||
try {
|
||||
let query: string;
|
||||
if (Array.isArray(status)) {
|
||||
query = `SELECT * FROM channels WHERE status IN (${status.join(',')})`;
|
||||
} else {
|
||||
query = `SELECT * FROM channels WHERE status = ?`;
|
||||
}
|
||||
const [rows]: any = await DB.query(query, [status]);
|
||||
return rows;
|
||||
} catch (e) {
|
||||
logger.err('$getChannelsByStatus error: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getClosedChannelsWithoutReason(): Promise<any[]> {
|
||||
try {
|
||||
const query = `SELECT * FROM channels WHERE status = 2 AND closing_reason IS NULL AND closing_transaction_id != ''`;
|
||||
const [rows]: any = await DB.query(query);
|
||||
return rows;
|
||||
} catch (e) {
|
||||
logger.err('$getClosedChannelsWithoutReason 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 != ''`;
|
||||
const [rows]: any = await DB.query(query);
|
||||
return rows;
|
||||
} catch (e) {
|
||||
logger.err('$getUnresolvedClosedChannels error: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getChannelsWithoutSourceChecked(): Promise<any[]> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT channels.*
|
||||
FROM channels
|
||||
WHERE channels.source_checked != 1
|
||||
`;
|
||||
const [rows]: any = await DB.query(query);
|
||||
return rows;
|
||||
} catch (e) {
|
||||
logger.err('$getUnresolvedClosedChannels error: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getChannelsWithoutCreatedDate(): Promise<any[]> {
|
||||
try {
|
||||
const query = `SELECT * FROM channels WHERE created IS NULL`;
|
||||
const [rows]: any = await DB.query(query);
|
||||
return rows;
|
||||
} catch (e) {
|
||||
logger.err('$getChannelsWithoutCreatedDate error: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getChannel(id: string): Promise<any> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT n1.alias AS alias_left, n1.longitude as node1_longitude, n1.latitude as node1_latitude,
|
||||
n2.alias AS alias_right, n2.longitude as node2_longitude, n2.latitude as node2_latitude,
|
||||
channels.*,
|
||||
ns1.channels AS channels_left, ns1.capacity AS capacity_left, ns2.channels AS channels_right, ns2.capacity AS capacity_right
|
||||
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
|
||||
LEFT JOIN node_stats AS ns1 ON ns1.public_key = channels.node1_public_key
|
||||
LEFT JOIN node_stats AS ns2 ON ns2.public_key = channels.node2_public_key
|
||||
WHERE (
|
||||
ns1.id = (
|
||||
SELECT MAX(id)
|
||||
FROM node_stats
|
||||
WHERE public_key = channels.node1_public_key
|
||||
)
|
||||
AND ns2.id = (
|
||||
SELECT MAX(id)
|
||||
FROM node_stats
|
||||
WHERE public_key = channels.node2_public_key
|
||||
)
|
||||
)
|
||||
AND channels.id = ?
|
||||
`;
|
||||
|
||||
const [rows]: any = await DB.query(query, [id]);
|
||||
if (rows[0]) {
|
||||
return this.convertChannel(rows[0]);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err('$getChannel error: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getChannelsStats(): Promise<any> {
|
||||
try {
|
||||
// Feedback from zerofeerouting:
|
||||
// "I would argue > 5000ppm can be ignored. Channels charging more than .5% fee are ignored by CLN for example."
|
||||
const ignoredFeeRateThreshold = 5000;
|
||||
const ignoredBaseFeeThreshold = 5000;
|
||||
|
||||
// Capacity
|
||||
let query = `SELECT AVG(capacity) AS avgCapacity FROM channels WHERE status = 1 ORDER BY capacity`;
|
||||
const [avgCapacity]: any = await DB.query(query);
|
||||
|
||||
query = `SELECT capacity FROM channels WHERE status = 1 ORDER BY capacity`;
|
||||
let [capacity]: any = await DB.query(query);
|
||||
capacity = capacity.map(capacity => capacity.capacity);
|
||||
const medianCapacity = capacity[Math.floor(capacity.length / 2)];
|
||||
|
||||
// Fee rates
|
||||
query = `SELECT node1_fee_rate FROM channels WHERE node1_fee_rate < ${ignoredFeeRateThreshold} AND status = 1`;
|
||||
let [feeRates1]: any = await DB.query(query);
|
||||
feeRates1 = feeRates1.map(rate => rate.node1_fee_rate);
|
||||
query = `SELECT node2_fee_rate FROM channels WHERE node2_fee_rate < ${ignoredFeeRateThreshold} AND status = 1`;
|
||||
let [feeRates2]: any = await DB.query(query);
|
||||
feeRates2 = feeRates2.map(rate => rate.node2_fee_rate);
|
||||
|
||||
let feeRates = (feeRates1.concat(feeRates2)).sort((a, b) => a - b);
|
||||
let avgFeeRate = 0;
|
||||
for (const rate of feeRates) {
|
||||
avgFeeRate += rate;
|
||||
}
|
||||
avgFeeRate /= feeRates.length;
|
||||
const medianFeeRate = feeRates[Math.floor(feeRates.length / 2)];
|
||||
|
||||
// Base fees
|
||||
query = `SELECT node1_base_fee_mtokens FROM channels WHERE node1_base_fee_mtokens < ${ignoredBaseFeeThreshold} AND status = 1`;
|
||||
let [baseFees1]: any = await DB.query(query);
|
||||
baseFees1 = baseFees1.map(rate => rate.node1_base_fee_mtokens);
|
||||
query = `SELECT node2_base_fee_mtokens FROM channels WHERE node2_base_fee_mtokens < ${ignoredBaseFeeThreshold} AND status = 1`;
|
||||
let [baseFees2]: any = await DB.query(query);
|
||||
baseFees2 = baseFees2.map(rate => rate.node2_base_fee_mtokens);
|
||||
|
||||
let baseFees = (baseFees1.concat(baseFees2)).sort((a, b) => a - b);
|
||||
let avgBaseFee = 0;
|
||||
for (const fee of baseFees) {
|
||||
avgBaseFee += fee;
|
||||
}
|
||||
avgBaseFee /= baseFees.length;
|
||||
const medianBaseFee = feeRates[Math.floor(baseFees.length / 2)];
|
||||
|
||||
return {
|
||||
avgCapacity: parseInt(avgCapacity[0].avgCapacity, 10),
|
||||
avgFeeRate: avgFeeRate,
|
||||
avgBaseFee: avgBaseFee,
|
||||
medianCapacity: medianCapacity,
|
||||
medianFeeRate: medianFeeRate,
|
||||
medianBaseFee: medianBaseFee,
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
logger.err(`Cannot calculate channels statistics. Reason: ${e instanceof Error ? e.message : e}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getChannelsByTransactionId(transactionIds: string[]): 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.transaction_id IN ? OR channels.closing_transaction_id IN ?
|
||||
`;
|
||||
const [rows]: any = await DB.query(query, [[transactionIds], [transactionIds]]);
|
||||
const channels = rows.map((row) => this.convertChannel(row));
|
||||
return channels;
|
||||
} catch (e) {
|
||||
logger.err('$getChannelByTransactionId error: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getChannelByClosingId(transactionId: string): Promise<any> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
channels.*
|
||||
FROM channels
|
||||
WHERE channels.closing_transaction_id = ?
|
||||
`;
|
||||
const [rows]: any = await DB.query(query, [transactionId]);
|
||||
if (rows.length > 0) {
|
||||
rows[0].outputs = JSON.parse(rows[0].outputs);
|
||||
return rows[0];
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err('$getChannelByClosingId error: ' + (e instanceof Error ? e.message : e));
|
||||
// don't throw - this data isn't essential
|
||||
}
|
||||
}
|
||||
|
||||
public async $getChannelsByOpeningId(transactionId: string): Promise<any> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
channels.*
|
||||
FROM channels
|
||||
WHERE channels.transaction_id = ?
|
||||
`;
|
||||
const [rows]: any = await DB.query(query, [transactionId]);
|
||||
if (rows.length > 0) {
|
||||
return rows.map(row => {
|
||||
row.outputs = JSON.parse(row.outputs);
|
||||
return row;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err('$getChannelsByOpeningId error: ' + (e instanceof Error ? e.message : e));
|
||||
// don't throw - this data isn't essential
|
||||
}
|
||||
}
|
||||
|
||||
public async $updateClosingInfo(channelInfo: { id: string, node1_closing_balance: number, node2_closing_balance: number, closed_by: string | null, closing_fee: number, outputs: ILightningApi.ForensicOutput[]}): Promise<void> {
|
||||
try {
|
||||
const query = `
|
||||
UPDATE channels SET
|
||||
node1_closing_balance = ?,
|
||||
node2_closing_balance = ?,
|
||||
closed_by = ?,
|
||||
closing_fee = ?,
|
||||
outputs = ?
|
||||
WHERE channels.id = ?
|
||||
`;
|
||||
await DB.query<ResultSetHeader>(query, [
|
||||
channelInfo.node1_closing_balance || 0,
|
||||
channelInfo.node2_closing_balance || 0,
|
||||
channelInfo.closed_by,
|
||||
channelInfo.closing_fee || 0,
|
||||
JSON.stringify(channelInfo.outputs),
|
||||
channelInfo.id,
|
||||
]);
|
||||
} catch (e) {
|
||||
logger.err('$updateClosingInfo error: ' + (e instanceof Error ? e.message : e));
|
||||
// don't throw - this data isn't essential
|
||||
}
|
||||
}
|
||||
|
||||
public async $updateOpeningInfo(channelInfo: { id: string, node1_funding_balance: number, node2_funding_balance: number, funding_ratio: number, single_funded: boolean | void }): Promise<void> {
|
||||
try {
|
||||
const query = `
|
||||
UPDATE channels SET
|
||||
node1_funding_balance = ?,
|
||||
node2_funding_balance = ?,
|
||||
funding_ratio = ?,
|
||||
single_funded = ?
|
||||
WHERE channels.id = ?
|
||||
`;
|
||||
await DB.query<ResultSetHeader>(query, [
|
||||
channelInfo.node1_funding_balance || 0,
|
||||
channelInfo.node2_funding_balance || 0,
|
||||
channelInfo.funding_ratio,
|
||||
channelInfo.single_funded ? 1 : 0,
|
||||
channelInfo.id,
|
||||
]);
|
||||
} catch (e) {
|
||||
logger.err('$updateOpeningInfo error: ' + (e instanceof Error ? e.message : e));
|
||||
// don't throw - this data isn't essential
|
||||
}
|
||||
}
|
||||
|
||||
public async $markChannelSourceChecked(id: string): Promise<void> {
|
||||
try {
|
||||
const query = `
|
||||
UPDATE channels
|
||||
SET source_checked = 1
|
||||
WHERE id = ?
|
||||
`;
|
||||
await DB.query<ResultSetHeader>(query, [id]);
|
||||
} catch (e) {
|
||||
logger.err('$markChannelSourceChecked error: ' + (e instanceof Error ? e.message : e));
|
||||
// don't throw - this data isn't essential
|
||||
}
|
||||
}
|
||||
|
||||
public async $getChannelsForNode(public_key: string, index: number, length: number, status: string): Promise<any[]> {
|
||||
try {
|
||||
let channelStatusFilter;
|
||||
if (status === 'open') {
|
||||
channelStatusFilter = '< 2';
|
||||
} else if (status === 'active') {
|
||||
channelStatusFilter = '= 1';
|
||||
} else if (status === 'closed') {
|
||||
channelStatusFilter = '= 2';
|
||||
} else {
|
||||
throw new Error('getChannelsForNode: Invalid status requested');
|
||||
}
|
||||
|
||||
// Channels originating from node
|
||||
let query = `
|
||||
SELECT COALESCE(node2.alias, SUBSTRING(node2_public_key, 0, 20)) AS alias, COALESCE(node2.public_key, node2_public_key) AS public_key,
|
||||
channels.status, channels.node1_fee_rate,
|
||||
channels.capacity, channels.short_id, channels.id, channels.closing_reason,
|
||||
UNIX_TIMESTAMP(closing_date) as closing_date, UNIX_TIMESTAMP(channels.updated_at) as updated_at
|
||||
FROM channels
|
||||
LEFT JOIN nodes AS node2 ON node2.public_key = channels.node2_public_key
|
||||
WHERE node1_public_key = ? AND channels.status ${channelStatusFilter}
|
||||
`;
|
||||
const [channelsFromNode]: any = await DB.query(query, [public_key]);
|
||||
|
||||
// Channels incoming to node
|
||||
query = `
|
||||
SELECT COALESCE(node1.alias, SUBSTRING(node1_public_key, 0, 20)) AS alias, COALESCE(node1.public_key, node1_public_key) AS public_key,
|
||||
channels.status, channels.node2_fee_rate,
|
||||
channels.capacity, channels.short_id, channels.id, channels.closing_reason,
|
||||
UNIX_TIMESTAMP(closing_date) as closing_date, UNIX_TIMESTAMP(channels.updated_at) as updated_at
|
||||
FROM channels
|
||||
LEFT JOIN nodes AS node1 ON node1.public_key = channels.node1_public_key
|
||||
WHERE node2_public_key = ? AND channels.status ${channelStatusFilter}
|
||||
`;
|
||||
const [channelsToNode]: any = await DB.query(query, [public_key]);
|
||||
|
||||
let allChannels = channelsFromNode.concat(channelsToNode);
|
||||
allChannels.sort((a, b) => {
|
||||
if (status === 'closed') {
|
||||
if (!b.closing_date && !a.closing_date) {
|
||||
return (b.updated_at ?? 0) - (a.updated_at ?? 0);
|
||||
} else {
|
||||
return (b.closing_date ?? 0) - (a.closing_date ?? 0);
|
||||
}
|
||||
} else {
|
||||
return b.capacity - a.capacity;
|
||||
}
|
||||
});
|
||||
|
||||
if (index >= 0) {
|
||||
allChannels = allChannels.slice(index, index + length);
|
||||
} else if (index === -1) { // Node channels tree chart
|
||||
allChannels = allChannels.slice(0, 1000);
|
||||
}
|
||||
|
||||
const channels: any[] = []
|
||||
for (const row of allChannels) {
|
||||
let channel;
|
||||
if (index >= 0) {
|
||||
const activeChannelsStats: any = await nodesApi.$getActiveChannelsStats(row.public_key);
|
||||
channel = {
|
||||
status: row.status,
|
||||
closing_reason: row.closing_reason,
|
||||
closing_date: row.closing_date,
|
||||
capacity: row.capacity ?? 0,
|
||||
short_id: row.short_id,
|
||||
id: row.id,
|
||||
fee_rate: row.node1_fee_rate ?? row.node2_fee_rate ?? 0,
|
||||
node: {
|
||||
alias: row.alias.length > 0 ? row.alias : row.public_key.slice(0, 20),
|
||||
public_key: row.public_key,
|
||||
channels: activeChannelsStats.active_channel_count ?? 0,
|
||||
capacity: activeChannelsStats.capacity ?? 0,
|
||||
}
|
||||
};
|
||||
} else if (index === -1) {
|
||||
channel = {
|
||||
capacity: row.capacity ?? 0,
|
||||
short_id: row.short_id,
|
||||
id: row.id,
|
||||
node: {
|
||||
alias: row.alias.length > 0 ? row.alias : row.public_key.slice(0, 20),
|
||||
public_key: row.public_key,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
channels.push(channel);
|
||||
}
|
||||
|
||||
return channels;
|
||||
} catch (e) {
|
||||
logger.err('$getChannelsForNode error: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getChannelsCountForNode(public_key: string, status: string): Promise<any> {
|
||||
try {
|
||||
// Default active and inactive channels
|
||||
let statusQuery = '< 2';
|
||||
// Closed channels only
|
||||
if (status === 'closed') {
|
||||
statusQuery = '= 2';
|
||||
}
|
||||
const query = `
|
||||
SELECT COUNT(*) AS count
|
||||
FROM channels
|
||||
WHERE (node1_public_key = ? OR node2_public_key = ?)
|
||||
AND status ${statusQuery}
|
||||
`;
|
||||
const [rows]: any = await DB.query(query, [public_key, public_key]);
|
||||
return rows[0]['count'];
|
||||
} catch (e) {
|
||||
logger.err('$getChannelsForNode error: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private convertChannel(channel: any): any {
|
||||
return {
|
||||
'id': channel.id,
|
||||
'short_id': channel.short_id,
|
||||
'capacity': channel.capacity,
|
||||
'transaction_id': channel.transaction_id,
|
||||
'transaction_vout': channel.transaction_vout,
|
||||
'closing_transaction_id': channel.closing_transaction_id,
|
||||
'closing_fee': channel.closing_fee,
|
||||
'closing_reason': channel.closing_reason,
|
||||
'closing_date': channel.closing_date,
|
||||
'updated_at': channel.updated_at,
|
||||
'created': channel.created,
|
||||
'status': channel.status,
|
||||
'funding_ratio': channel.funding_ratio,
|
||||
'closed_by': channel.closed_by,
|
||||
'single_funded': !!channel.single_funded,
|
||||
'node_left': {
|
||||
'alias': channel.alias_left,
|
||||
'public_key': channel.node1_public_key,
|
||||
'channels': channel.channels_left,
|
||||
'capacity': channel.capacity_left,
|
||||
'base_fee_mtokens': channel.node1_base_fee_mtokens,
|
||||
'cltv_delta': channel.node1_cltv_delta,
|
||||
'fee_rate': channel.node1_fee_rate,
|
||||
'is_disabled': channel.node1_is_disabled,
|
||||
'max_htlc_mtokens': channel.node1_max_htlc_mtokens,
|
||||
'min_htlc_mtokens': channel.node1_min_htlc_mtokens,
|
||||
'updated_at': channel.node1_updated_at,
|
||||
'longitude': channel.node1_longitude,
|
||||
'latitude': channel.node1_latitude,
|
||||
'funding_balance': channel.node1_funding_balance,
|
||||
'closing_balance': channel.node1_closing_balance,
|
||||
'initiated_close': channel.closed_by === channel.node1_public_key ? true : undefined,
|
||||
},
|
||||
'node_right': {
|
||||
'alias': channel.alias_right,
|
||||
'public_key': channel.node2_public_key,
|
||||
'channels': channel.channels_right,
|
||||
'capacity': channel.capacity_right,
|
||||
'base_fee_mtokens': channel.node2_base_fee_mtokens,
|
||||
'cltv_delta': channel.node2_cltv_delta,
|
||||
'fee_rate': channel.node2_fee_rate,
|
||||
'is_disabled': channel.node2_is_disabled,
|
||||
'max_htlc_mtokens': channel.node2_max_htlc_mtokens,
|
||||
'min_htlc_mtokens': channel.node2_min_htlc_mtokens,
|
||||
'updated_at': channel.node2_updated_at,
|
||||
'longitude': channel.node2_longitude,
|
||||
'latitude': channel.node2_latitude,
|
||||
'funding_balance': channel.node2_funding_balance,
|
||||
'closing_balance': channel.node2_closing_balance,
|
||||
'initiated_close': channel.closed_by === channel.node2_public_key ? true : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save or update a channel present in the graph
|
||||
*/
|
||||
public async $saveChannel(channel: ILightningApi.Channel, status = 1): Promise<void> {
|
||||
const [ txid, vout ] = channel.chan_point.split(':');
|
||||
|
||||
const policy1: Partial<ILightningApi.RoutingPolicy> = channel.node1_policy || {};
|
||||
const policy2: Partial<ILightningApi.RoutingPolicy> = channel.node2_policy || {};
|
||||
|
||||
// https://github.com/mempool/mempool/issues/3006
|
||||
if ((channel.last_update ?? 0) < 1514736061) { // January 1st 2018
|
||||
channel.last_update = null;
|
||||
}
|
||||
if ((policy1.last_update ?? 0) < 1514736061) { // January 1st 2018
|
||||
policy1.last_update = null;
|
||||
}
|
||||
if ((policy2.last_update ?? 0) < 1514736061) { // January 1st 2018
|
||||
policy2.last_update = null;
|
||||
}
|
||||
|
||||
const query = `INSERT INTO channels
|
||||
(
|
||||
id,
|
||||
short_id,
|
||||
capacity,
|
||||
transaction_id,
|
||||
transaction_vout,
|
||||
updated_at,
|
||||
status,
|
||||
node1_public_key,
|
||||
node1_base_fee_mtokens,
|
||||
node1_cltv_delta,
|
||||
node1_fee_rate,
|
||||
node1_is_disabled,
|
||||
node1_max_htlc_mtokens,
|
||||
node1_min_htlc_mtokens,
|
||||
node1_updated_at,
|
||||
node2_public_key,
|
||||
node2_base_fee_mtokens,
|
||||
node2_cltv_delta,
|
||||
node2_fee_rate,
|
||||
node2_is_disabled,
|
||||
node2_max_htlc_mtokens,
|
||||
node2_min_htlc_mtokens,
|
||||
node2_updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ${status}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
capacity = ?,
|
||||
updated_at = ?,
|
||||
status = ${status},
|
||||
node1_public_key = ?,
|
||||
node1_base_fee_mtokens = ?,
|
||||
node1_cltv_delta = ?,
|
||||
node1_fee_rate = ?,
|
||||
node1_is_disabled = ?,
|
||||
node1_max_htlc_mtokens = ?,
|
||||
node1_min_htlc_mtokens = ?,
|
||||
node1_updated_at = ?,
|
||||
node2_public_key = ?,
|
||||
node2_base_fee_mtokens = ?,
|
||||
node2_cltv_delta = ?,
|
||||
node2_fee_rate = ?,
|
||||
node2_is_disabled = ?,
|
||||
node2_max_htlc_mtokens = ?,
|
||||
node2_min_htlc_mtokens = ?,
|
||||
node2_updated_at = ?
|
||||
;`;
|
||||
|
||||
await DB.query(query, [
|
||||
Common.channelShortIdToIntegerId(channel.channel_id),
|
||||
Common.channelIntegerIdToShortId(channel.channel_id),
|
||||
channel.capacity,
|
||||
txid,
|
||||
vout,
|
||||
Common.utcDateToMysql(channel.last_update),
|
||||
channel.node1_pub,
|
||||
policy1.fee_base_msat,
|
||||
policy1.time_lock_delta,
|
||||
policy1.fee_rate_milli_msat,
|
||||
policy1.disabled,
|
||||
policy1.max_htlc_msat,
|
||||
policy1.min_htlc,
|
||||
Common.utcDateToMysql(policy1.last_update),
|
||||
channel.node2_pub,
|
||||
policy2.fee_base_msat,
|
||||
policy2.time_lock_delta,
|
||||
policy2.fee_rate_milli_msat,
|
||||
policy2.disabled,
|
||||
policy2.max_htlc_msat,
|
||||
policy2.min_htlc,
|
||||
Common.utcDateToMysql(policy2.last_update),
|
||||
channel.capacity,
|
||||
Common.utcDateToMysql(channel.last_update),
|
||||
channel.node1_pub,
|
||||
policy1.fee_base_msat,
|
||||
policy1.time_lock_delta,
|
||||
policy1.fee_rate_milli_msat,
|
||||
policy1.disabled,
|
||||
policy1.max_htlc_msat,
|
||||
policy1.min_htlc,
|
||||
Common.utcDateToMysql(policy1.last_update),
|
||||
channel.node2_pub,
|
||||
policy2.fee_base_msat,
|
||||
policy2.time_lock_delta,
|
||||
policy2.fee_rate_milli_msat,
|
||||
policy2.disabled,
|
||||
policy2.max_htlc_msat,
|
||||
policy2.min_htlc,
|
||||
Common.utcDateToMysql(policy2.last_update)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set all channels not in `graphChannelsIds` as inactive (status = 0)
|
||||
*/
|
||||
public async $setChannelsInactive(graphChannelsIds: string[]): Promise<void> {
|
||||
if (graphChannelsIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await DB.query<ResultSetHeader>(`
|
||||
UPDATE channels
|
||||
SET status = 0
|
||||
WHERE id NOT IN (
|
||||
${graphChannelsIds.map(id => `"${id}"`).join(',')}
|
||||
)
|
||||
AND status != 2
|
||||
`);
|
||||
if (result[0].changedRows ?? 0 > 0) {
|
||||
logger.debug(`Marked ${result[0].changedRows} channels as inactive because they are not in the graph`, logger.tags.ln);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err('$setChannelsInactive() error: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
public async $getLatestChannelUpdateForNode(publicKey: string): Promise<number> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT MAX(UNIX_TIMESTAMP(updated_at)) as updated_at
|
||||
FROM channels
|
||||
WHERE node1_public_key = ?
|
||||
`;
|
||||
const [rows]: any[] = await DB.query(query, [publicKey]);
|
||||
if (rows.length > 0) {
|
||||
return rows[0].updated_at;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err(`Can't getLatestChannelUpdateForNode for ${publicKey}. Reason ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export default new ChannelsApi();
|
||||
126
backend/src/api/explorer/channels.routes.ts
Normal file
126
backend/src/api/explorer/channels.routes.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import config from '../../config';
|
||||
import { Application, Request, Response } from 'express';
|
||||
import channelsApi from './channels.api';
|
||||
|
||||
class ChannelsRoutes {
|
||||
constructor() { }
|
||||
|
||||
public initRoutes(app: Application) {
|
||||
app
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels/txids', this.$getChannelsByTransactionIds)
|
||||
.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/channels-geo', this.$getAllChannelsGeo)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/channels-geo/:publicKey', this.$getAllChannelsGeo)
|
||||
;
|
||||
}
|
||||
|
||||
private async $searchChannelsById(req: Request, res: Response) {
|
||||
try {
|
||||
const channels = await channelsApi.$searchChannelsById(req.params.search);
|
||||
res.json(channels);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getChannel(req: Request, res: Response) {
|
||||
try {
|
||||
const channel = await channelsApi.$getChannel(req.params.short_id);
|
||||
if (!channel) {
|
||||
res.status(404).send('Channel not found');
|
||||
return;
|
||||
}
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
res.json(channel);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getChannelsForNode(req: Request, res: Response) {
|
||||
try {
|
||||
if (typeof req.query.public_key !== 'string') {
|
||||
res.status(400).send('Missing parameter: public_key');
|
||||
return;
|
||||
}
|
||||
|
||||
const index = parseInt(typeof req.query.index === 'string' ? req.query.index : '0', 10) || 0;
|
||||
const status: string = typeof req.query.status === 'string' ? req.query.status : '';
|
||||
|
||||
if (index < -1) {
|
||||
res.status(400).send('Invalid index');
|
||||
}
|
||||
if (['open', 'active', 'closed'].includes(status) === false) {
|
||||
res.status(400).send('Invalid status');
|
||||
}
|
||||
|
||||
const channels = await channelsApi.$getChannelsForNode(req.query.public_key, index, 10, status);
|
||||
const channelsCount = await channelsApi.$getChannelsCountForNode(req.query.public_key, status);
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
res.header('X-Total-Count', channelsCount.toString());
|
||||
res.json(channels);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getChannelsByTransactionIds(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
if (!Array.isArray(req.query.txId)) {
|
||||
res.status(400).send('Not an array');
|
||||
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());
|
||||
}
|
||||
}
|
||||
const channels = await channelsApi.$getChannelsByTransactionId(txIds);
|
||||
const result: any[] = [];
|
||||
for (const txid of txIds) {
|
||||
const inputs: any = {};
|
||||
const outputs: any = {};
|
||||
// Assuming that we only have one lightning close input in each transaction. This may not be true in the future
|
||||
const foundChannelsFromInput = channels.find((channel) => channel.closing_transaction_id === txid);
|
||||
if (foundChannelsFromInput) {
|
||||
inputs[0] = foundChannelsFromInput;
|
||||
}
|
||||
const foundChannelsFromOutputs = channels.filter((channel) => channel.transaction_id === txid);
|
||||
for (const output of foundChannelsFromOutputs) {
|
||||
outputs[output.transaction_vout] = output;
|
||||
}
|
||||
result.push({
|
||||
inputs,
|
||||
outputs,
|
||||
});
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} 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 : '';
|
||||
const channels = await channelsApi.$getAllChannelsGeo(req.params?.publicKey, style);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default new ChannelsRoutes();
|
||||
58
backend/src/api/explorer/general.routes.ts
Normal file
58
backend/src/api/explorer/general.routes.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import config from '../../config';
|
||||
import { Application, Request, Response } from 'express';
|
||||
import nodesApi from './nodes.api';
|
||||
import channelsApi from './channels.api';
|
||||
import statisticsApi from './statistics.api';
|
||||
class GeneralLightningRoutes {
|
||||
constructor() { }
|
||||
|
||||
public initRoutes(app: Application) {
|
||||
app
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/search', this.$searchNodesAndChannels)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/statistics/latest', this.$getGeneralStats)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/statistics/:interval', this.$getStatistics)
|
||||
;
|
||||
}
|
||||
|
||||
private async $searchNodesAndChannels(req: Request, res: Response) {
|
||||
if (typeof req.query.searchText !== 'string') {
|
||||
res.status(400).send('Missing parameter: searchText');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const nodes = await nodesApi.$searchNodeByPublicKeyOrAlias(req.query.searchText);
|
||||
const channels = await channelsApi.$searchChannelsById(req.query.searchText);
|
||||
res.json({
|
||||
nodes: nodes,
|
||||
channels: channels,
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getStatistics(req: Request, res: Response) {
|
||||
try {
|
||||
const statistics = await statisticsApi.$getStatistics(req.params.interval);
|
||||
const statisticsCount = await statisticsApi.$getStatisticsCount();
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.header('X-total-count', statisticsCount.toString());
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
res.json(statistics);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getGeneralStats(req: Request, res: Response) {
|
||||
try {
|
||||
const statistics = await statisticsApi.$getLatestStatistics();
|
||||
res.json(statistics);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new GeneralLightningRoutes();
|
||||
723
backend/src/api/explorer/nodes.api.ts
Normal file
723
backend/src/api/explorer/nodes.api.ts
Normal file
@@ -0,0 +1,723 @@
|
||||
import logger from '../../logger';
|
||||
import DB from '../../database';
|
||||
import { ResultSetHeader } from 'mysql2';
|
||||
import { ILightningApi } from '../lightning/lightning-api.interface';
|
||||
import { ITopNodesPerCapacity, ITopNodesPerChannels } from '../../mempool.interfaces';
|
||||
|
||||
class NodesApi {
|
||||
public async $getWorldNodes(): Promise<any> {
|
||||
try {
|
||||
let query = `
|
||||
SELECT nodes.public_key as publicKey, IF(nodes.alias = '', SUBSTRING(nodes.public_key, 1, 20), alias) as alias,
|
||||
CAST(COALESCE(nodes.capacity, 0) as INT) as capacity,
|
||||
CAST(COALESCE(nodes.channels, 0) as INT) as channels,
|
||||
nodes.longitude, nodes.latitude,
|
||||
geo_names_country.names as country, geo_names_iso.names as isoCode
|
||||
FROM nodes
|
||||
JOIN geo_names geo_names_country ON geo_names_country.id = nodes.country_id AND geo_names_country.type = 'country'
|
||||
JOIN geo_names geo_names_iso ON geo_names_iso.id = nodes.country_id AND geo_names_iso.type = 'country_iso_code'
|
||||
WHERE status = 1 AND nodes.as_number IS NOT NULL
|
||||
ORDER BY capacity
|
||||
`;
|
||||
|
||||
const [nodes]: any[] = await DB.query(query);
|
||||
|
||||
for (let i = 0; i < nodes.length; ++i) {
|
||||
nodes[i].country = JSON.parse(nodes[i].country);
|
||||
}
|
||||
|
||||
query = `
|
||||
SELECT MAX(nodes.capacity) as maxLiquidity, MAX(nodes.channels) as maxChannels
|
||||
FROM nodes
|
||||
WHERE status = 1 AND nodes.as_number IS NOT NULL
|
||||
`;
|
||||
|
||||
const [maximums]: any[] = await DB.query(query);
|
||||
|
||||
return {
|
||||
maxLiquidity: maximums[0].maxLiquidity,
|
||||
maxChannels: maximums[0].maxChannels,
|
||||
nodes: nodes.map(node => [
|
||||
node.longitude, node.latitude,
|
||||
node.publicKey, node.alias, node.capacity, node.channels,
|
||||
node.country, node.isoCode
|
||||
])
|
||||
};
|
||||
} catch (e) {
|
||||
logger.err(`Can't get world nodes list. Reason: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async $getNode(public_key: string): Promise<any> {
|
||||
try {
|
||||
// General info
|
||||
let query = `
|
||||
SELECT public_key, alias, UNIX_TIMESTAMP(first_seen) AS first_seen,
|
||||
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
|
||||
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
|
||||
LEFT JOIN geo_names geo_names_subdivision on geo_names_subdivision.id = subdivision_id
|
||||
LEFT JOIN geo_names geo_names_country on geo_names_country.id = country_id
|
||||
LEFT JOIN geo_names geo_names_iso ON geo_names_iso.id = nodes.country_id AND geo_names_iso.type = 'country_iso_code'
|
||||
WHERE public_key = ?
|
||||
`;
|
||||
let [rows]: any[] = await DB.query(query, [public_key]);
|
||||
if (rows.length === 0) {
|
||||
throw new Error(`This node does not exist, or our node is not seeing it yet`);
|
||||
}
|
||||
|
||||
const node = rows[0];
|
||||
node.as_organization = JSON.parse(node.as_organization);
|
||||
node.subdivision = JSON.parse(node.subdivision);
|
||||
node.city = JSON.parse(node.city);
|
||||
node.country = JSON.parse(node.country);
|
||||
|
||||
// Active channels and capacity
|
||||
const activeChannelsStats: any = await this.$getActiveChannelsStats(public_key);
|
||||
node.active_channel_count = activeChannelsStats.active_channel_count ?? 0;
|
||||
node.capacity = activeChannelsStats.capacity ?? 0;
|
||||
|
||||
// Opened channels count
|
||||
query = `
|
||||
SELECT count(short_id) as opened_channel_count
|
||||
FROM channels
|
||||
WHERE status != 2 AND (channels.node1_public_key = ? OR channels.node2_public_key = ?)
|
||||
`;
|
||||
[rows] = await DB.query(query, [public_key, public_key]);
|
||||
node.opened_channel_count = 0;
|
||||
if (rows.length > 0) {
|
||||
node.opened_channel_count = rows[0].opened_channel_count;
|
||||
}
|
||||
|
||||
// Closed channels count
|
||||
query = `
|
||||
SELECT count(short_id) as closed_channel_count
|
||||
FROM channels
|
||||
WHERE status = 2 AND (channels.node1_public_key = ? OR channels.node2_public_key = ?)
|
||||
`;
|
||||
[rows] = await DB.query(query, [public_key, public_key]);
|
||||
node.closed_channel_count = 0;
|
||||
if (rows.length > 0) {
|
||||
node.closed_channel_count = rows[0].closed_channel_count;
|
||||
}
|
||||
|
||||
// Custom records
|
||||
query = `
|
||||
SELECT type, payload
|
||||
FROM nodes_records
|
||||
WHERE public_key = ?
|
||||
`;
|
||||
[rows] = await DB.query(query, [public_key]);
|
||||
node.custom_records = {};
|
||||
for (const record of rows) {
|
||||
node.custom_records[record.type] = Buffer.from(record.payload, 'binary').toString('hex');
|
||||
}
|
||||
|
||||
return node;
|
||||
} catch (e) {
|
||||
logger.err(`Cannot get node information for ${public_key}. Reason: ${(e instanceof Error ? e.message : e)}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getActiveChannelsStats(node_public_key: string): Promise<unknown> {
|
||||
const query = `
|
||||
SELECT count(short_id) as active_channel_count, sum(capacity) as capacity
|
||||
FROM channels
|
||||
WHERE status = 1 AND (channels.node1_public_key = ? OR channels.node2_public_key = ?)
|
||||
`;
|
||||
const [rows]: any[] = await DB.query(query, [node_public_key, node_public_key]);
|
||||
if (rows.length > 0) {
|
||||
return {
|
||||
active_channel_count: rows[0].active_channel_count,
|
||||
capacity: rows[0].capacity
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getFeeHistogram(node_public_key: string): Promise<unknown> {
|
||||
try {
|
||||
const inQuery = `
|
||||
SELECT CASE WHEN fee_rate <= 10.0 THEN CEIL(fee_rate)
|
||||
WHEN (fee_rate > 10.0 and fee_rate <= 100.0) THEN CEIL(fee_rate / 10.0) * 10.0
|
||||
WHEN (fee_rate > 100.0 and fee_rate <= 1000.0) THEN CEIL(fee_rate / 100.0) * 100.0
|
||||
WHEN fee_rate > 1000.0 THEN CEIL(fee_rate / 1000.0) * 1000.0
|
||||
END as bucket,
|
||||
count(short_id) as count,
|
||||
sum(capacity) as capacity
|
||||
FROM (
|
||||
SELECT CASE WHEN node1_public_key = ? THEN node2_fee_rate WHEN node2_public_key = ? THEN node1_fee_rate END as fee_rate,
|
||||
short_id as short_id,
|
||||
capacity as capacity
|
||||
FROM channels
|
||||
WHERE status = 1 AND (channels.node1_public_key = ? OR channels.node2_public_key = ?)
|
||||
) as fee_rate_table
|
||||
GROUP BY bucket;
|
||||
`;
|
||||
const [inRows]: any[] = await DB.query(inQuery, [node_public_key, node_public_key, node_public_key, node_public_key]);
|
||||
|
||||
const outQuery = `
|
||||
SELECT CASE WHEN fee_rate <= 10.0 THEN CEIL(fee_rate)
|
||||
WHEN (fee_rate > 10.0 and fee_rate <= 100.0) THEN CEIL(fee_rate / 10.0) * 10.0
|
||||
WHEN (fee_rate > 100.0 and fee_rate <= 1000.0) THEN CEIL(fee_rate / 100.0) * 100.0
|
||||
WHEN fee_rate > 1000.0 THEN CEIL(fee_rate / 1000.0) * 1000.0
|
||||
END as bucket,
|
||||
count(short_id) as count,
|
||||
sum(capacity) as capacity
|
||||
FROM (
|
||||
SELECT CASE WHEN node1_public_key = ? THEN node1_fee_rate WHEN node2_public_key = ? THEN node2_fee_rate END as fee_rate,
|
||||
short_id as short_id,
|
||||
capacity as capacity
|
||||
FROM channels
|
||||
WHERE status = 1 AND (channels.node1_public_key = ? OR channels.node2_public_key = ?)
|
||||
) as fee_rate_table
|
||||
GROUP BY bucket;
|
||||
`;
|
||||
const [outRows]: any[] = await DB.query(outQuery, [node_public_key, node_public_key, node_public_key, node_public_key]);
|
||||
|
||||
return {
|
||||
incoming: inRows.length > 0 ? inRows : [],
|
||||
outgoing: outRows.length > 0 ? outRows : [],
|
||||
};
|
||||
} catch (e) {
|
||||
logger.err(`Cannot get node fee distribution for ${node_public_key}. Reason: ${(e instanceof Error ? e.message : e)}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getAllNodes(): Promise<any> {
|
||||
try {
|
||||
const query = `SELECT * FROM nodes`;
|
||||
const [rows]: any = await DB.query(query);
|
||||
return rows;
|
||||
} catch (e) {
|
||||
logger.err('$getAllNodes error: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getNodeStats(public_key: string): Promise<any> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT UNIX_TIMESTAMP(added) AS added, capacity, channels
|
||||
FROM node_stats
|
||||
WHERE public_key = ?
|
||||
ORDER BY added DESC
|
||||
`;
|
||||
const [rows]: any = await DB.query(query, [public_key]);
|
||||
return rows;
|
||||
} catch (e) {
|
||||
logger.err('$getNodeStats error: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getTopCapacityNodes(full: boolean): Promise<ITopNodesPerCapacity[]> {
|
||||
try {
|
||||
let rows: any;
|
||||
let query: string;
|
||||
if (full === false) {
|
||||
query = `
|
||||
SELECT nodes.public_key AS publicKey, IF(nodes.alias = '', SUBSTRING(nodes.public_key, 1, 20), alias) as alias,
|
||||
nodes.capacity
|
||||
FROM nodes
|
||||
ORDER BY capacity DESC
|
||||
LIMIT 6
|
||||
`;
|
||||
|
||||
[rows] = await DB.query(query);
|
||||
} else {
|
||||
query = `
|
||||
SELECT nodes.public_key AS publicKey, IF(nodes.alias = '', SUBSTRING(nodes.public_key, 1, 20), alias) as alias,
|
||||
CAST(COALESCE(nodes.capacity, 0) as INT) as capacity,
|
||||
CAST(COALESCE(nodes.channels, 0) as INT) as channels,
|
||||
UNIX_TIMESTAMP(nodes.first_seen) as firstSeen, UNIX_TIMESTAMP(nodes.updated_at) as updatedAt,
|
||||
geo_names_city.names as city, geo_names_country.names as country,
|
||||
geo_names_iso.names as iso_code, geo_names_subdivision.names as subdivision
|
||||
FROM nodes
|
||||
LEFT JOIN geo_names geo_names_country ON geo_names_country.id = nodes.country_id AND geo_names_country.type = 'country'
|
||||
LEFT JOIN geo_names geo_names_city ON geo_names_city.id = nodes.city_id AND geo_names_city.type = 'city'
|
||||
LEFT JOIN geo_names geo_names_iso ON geo_names_iso.id = nodes.country_id AND geo_names_iso.type = 'country_iso_code'
|
||||
LEFT JOIN geo_names geo_names_subdivision on geo_names_subdivision.id = nodes.subdivision_id AND geo_names_subdivision.type = 'division'
|
||||
ORDER BY capacity DESC
|
||||
LIMIT 100
|
||||
`;
|
||||
|
||||
[rows] = await DB.query(query);
|
||||
for (let i = 0; i < rows.length; ++i) {
|
||||
rows[i].country = JSON.parse(rows[i].country);
|
||||
rows[i].city = JSON.parse(rows[i].city);
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
} catch (e) {
|
||||
logger.err('$getTopCapacityNodes error: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getTopChannelsNodes(full: boolean): Promise<ITopNodesPerChannels[]> {
|
||||
try {
|
||||
let rows: any;
|
||||
let query: string;
|
||||
if (full === false) {
|
||||
query = `
|
||||
SELECT
|
||||
nodes.public_key as publicKey,
|
||||
IF(nodes.alias = '', SUBSTRING(nodes.public_key, 1, 20), alias) as alias,
|
||||
nodes.channels,
|
||||
geo_names_city.names as city, geo_names_country.names as country,
|
||||
geo_names_iso.names as iso_code, geo_names_subdivision.names as subdivision
|
||||
FROM nodes
|
||||
LEFT JOIN geo_names geo_names_country ON geo_names_country.id = nodes.country_id AND geo_names_country.type = 'country'
|
||||
LEFT JOIN geo_names geo_names_city ON geo_names_city.id = nodes.city_id AND geo_names_city.type = 'city'
|
||||
LEFT JOIN geo_names geo_names_iso ON geo_names_iso.id = nodes.country_id AND geo_names_iso.type = 'country_iso_code'
|
||||
LEFT JOIN geo_names geo_names_subdivision on geo_names_subdivision.id = nodes.subdivision_id AND geo_names_subdivision.type = 'division'
|
||||
ORDER BY channels DESC
|
||||
LIMIT 6;
|
||||
`;
|
||||
|
||||
[rows] = await DB.query(query);
|
||||
for (let i = 0; i < rows.length; ++i) {
|
||||
rows[i].country = JSON.parse(rows[i].country);
|
||||
rows[i].city = JSON.parse(rows[i].city);
|
||||
}
|
||||
} else {
|
||||
query = `
|
||||
SELECT nodes.public_key AS publicKey, IF(nodes.alias = '', SUBSTRING(nodes.public_key, 1, 20), alias) as alias,
|
||||
CAST(COALESCE(nodes.channels, 0) as INT) as channels,
|
||||
CAST(COALESCE(nodes.capacity, 0) as INT) as capacity,
|
||||
UNIX_TIMESTAMP(nodes.first_seen) as firstSeen, UNIX_TIMESTAMP(nodes.updated_at) as updatedAt,
|
||||
geo_names_city.names as city, geo_names_country.names as country,
|
||||
geo_names_iso.names as iso_code, geo_names_subdivision.names as subdivision
|
||||
FROM nodes
|
||||
LEFT JOIN geo_names geo_names_country ON geo_names_country.id = nodes.country_id AND geo_names_country.type = 'country'
|
||||
LEFT JOIN geo_names geo_names_city ON geo_names_city.id = nodes.city_id AND geo_names_city.type = 'city'
|
||||
LEFT JOIN geo_names geo_names_iso ON geo_names_iso.id = nodes.country_id AND geo_names_iso.type = 'country_iso_code'
|
||||
LEFT JOIN geo_names geo_names_subdivision on geo_names_subdivision.id = nodes.subdivision_id AND geo_names_subdivision.type = 'division'
|
||||
ORDER BY channels DESC
|
||||
LIMIT 100
|
||||
`;
|
||||
|
||||
[rows] = await DB.query(query);
|
||||
for (let i = 0; i < rows.length; ++i) {
|
||||
rows[i].country = JSON.parse(rows[i].country);
|
||||
rows[i].city = JSON.parse(rows[i].city);
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
} catch (e) {
|
||||
logger.err('$getTopChannelsNodes error: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getOldestNodes(full: boolean): Promise<ITopNodesPerChannels[]> {
|
||||
try {
|
||||
let [rows]: any[] = await DB.query('SELECT UNIX_TIMESTAMP(MAX(added)) as maxAdded FROM node_stats');
|
||||
const latestDate = rows[0].maxAdded;
|
||||
|
||||
let query: string;
|
||||
if (full === false) {
|
||||
query = `
|
||||
SELECT nodes.public_key, IF(nodes.alias = '', SUBSTRING(nodes.public_key, 1, 20), alias) as alias,
|
||||
node_stats.channels
|
||||
FROM node_stats
|
||||
JOIN nodes ON nodes.public_key = node_stats.public_key
|
||||
WHERE added = FROM_UNIXTIME(${latestDate})
|
||||
ORDER BY first_seen
|
||||
LIMIT 100;
|
||||
`;
|
||||
|
||||
[rows] = await DB.query(query);
|
||||
} else {
|
||||
query = `
|
||||
SELECT node_stats.public_key AS publicKey, IF(nodes.alias = '', SUBSTRING(node_stats.public_key, 1, 20), alias) as alias,
|
||||
CAST(COALESCE(node_stats.channels, 0) as INT) as channels,
|
||||
CAST(COALESCE(node_stats.capacity, 0) as INT) as capacity,
|
||||
UNIX_TIMESTAMP(nodes.first_seen) as firstSeen, UNIX_TIMESTAMP(nodes.updated_at) as updatedAt,
|
||||
geo_names_city.names as city, geo_names_country.names as country,
|
||||
geo_names_iso.names as iso_code, geo_names_subdivision.names as subdivision
|
||||
FROM node_stats
|
||||
RIGHT JOIN nodes ON nodes.public_key = node_stats.public_key
|
||||
LEFT JOIN geo_names geo_names_country ON geo_names_country.id = nodes.country_id AND geo_names_country.type = 'country'
|
||||
LEFT JOIN geo_names geo_names_city ON geo_names_city.id = nodes.city_id AND geo_names_city.type = 'city'
|
||||
LEFT JOIN geo_names geo_names_iso ON geo_names_iso.id = nodes.country_id AND geo_names_iso.type = 'country_iso_code'
|
||||
LEFT JOIN geo_names geo_names_subdivision on geo_names_subdivision.id = nodes.subdivision_id AND geo_names_subdivision.type = 'division'
|
||||
WHERE added = FROM_UNIXTIME(${latestDate})
|
||||
ORDER BY first_seen
|
||||
LIMIT 100
|
||||
`;
|
||||
|
||||
[rows] = await DB.query(query);
|
||||
for (let i = 0; i < rows.length; ++i) {
|
||||
rows[i].country = JSON.parse(rows[i].country);
|
||||
rows[i].city = JSON.parse(rows[i].city);
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
} catch (e) {
|
||||
logger.err('$getTopChannelsNodes error: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $searchNodeByPublicKeyOrAlias(search: string) {
|
||||
try {
|
||||
const publicKeySearch = search.replace('%', '') + '%';
|
||||
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.
|
||||
.split(' ')
|
||||
.filter(key => key.length)
|
||||
.map((search) => '+' + search + '*').join(' ');
|
||||
// %keyword% is wildcard search and can't be indexed so it's slower as the node database grow. keyword% can be indexed but then you can't search for "Nicehash" and get result for ln.nicehash.com. So we use fulltext index for words "ln, nicehash, com" and nicehash* will find it instantly.
|
||||
const query = `SELECT public_key, alias, capacity, channels, status FROM nodes WHERE public_key LIKE ? OR MATCH alias_search AGAINST (? IN BOOLEAN MODE) ORDER BY capacity DESC LIMIT 10`;
|
||||
const [rows]: any = await DB.query(query, [publicKeySearch, aliasSearch]);
|
||||
return rows;
|
||||
} catch (e) {
|
||||
logger.err('$searchNodeByPublicKeyOrAlias error: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getNodesISPRanking() {
|
||||
try {
|
||||
let query = '';
|
||||
|
||||
// List all channels and the two linked ISP
|
||||
query = `
|
||||
SELECT short_id, channels.capacity,
|
||||
channels.node1_public_key AS node1PublicKey, isp1.names AS isp1, isp1.id as isp1ID,
|
||||
channels.node2_public_key AS node2PublicKey, isp2.names AS isp2, isp2.id as isp2ID
|
||||
FROM channels
|
||||
JOIN nodes node1 ON node1.public_key = channels.node1_public_key
|
||||
JOIN nodes node2 ON node2.public_key = channels.node2_public_key
|
||||
JOIN geo_names isp1 ON isp1.id = node1.as_number
|
||||
JOIN geo_names isp2 ON isp2.id = node2.as_number
|
||||
WHERE channels.status = 1
|
||||
ORDER BY short_id DESC
|
||||
`;
|
||||
const [channelsIsp]: any = await DB.query(query);
|
||||
|
||||
// Sum channels capacity and node count per ISP
|
||||
const ispList = {};
|
||||
for (const channel of channelsIsp) {
|
||||
const isp1 = JSON.parse(channel.isp1);
|
||||
const isp2 = JSON.parse(channel.isp2);
|
||||
|
||||
if (!ispList[isp1]) {
|
||||
ispList[isp1] = {
|
||||
ids: [channel.isp1ID],
|
||||
capacity: 0,
|
||||
channels: 0,
|
||||
nodes: {},
|
||||
};
|
||||
} else if (ispList[isp1].ids.includes(channel.isp1ID) === false) {
|
||||
ispList[isp1].ids.push(channel.isp1ID);
|
||||
}
|
||||
|
||||
if (!ispList[isp2]) {
|
||||
ispList[isp2] = {
|
||||
ids: [channel.isp2ID],
|
||||
capacity: 0,
|
||||
channels: 0,
|
||||
nodes: {},
|
||||
};
|
||||
} else if (ispList[isp2].ids.includes(channel.isp2ID) === false) {
|
||||
ispList[isp2].ids.push(channel.isp2ID);
|
||||
}
|
||||
|
||||
ispList[isp1].capacity += channel.capacity;
|
||||
ispList[isp1].channels += 1;
|
||||
ispList[isp1].nodes[channel.node1PublicKey] = true;
|
||||
ispList[isp2].capacity += channel.capacity;
|
||||
ispList[isp2].channels += 1;
|
||||
ispList[isp2].nodes[channel.node2PublicKey] = true;
|
||||
}
|
||||
|
||||
const ispRanking: any[] = [];
|
||||
for (const isp of Object.keys(ispList)) {
|
||||
ispRanking.push([
|
||||
ispList[isp].ids.sort((a, b) => a - b).join(','),
|
||||
isp,
|
||||
ispList[isp].capacity,
|
||||
ispList[isp].channels,
|
||||
Object.keys(ispList[isp].nodes).length,
|
||||
]);
|
||||
}
|
||||
|
||||
// Total active channels capacity
|
||||
query = `SELECT SUM(capacity) AS capacity FROM channels WHERE status = 1`;
|
||||
const [totalCapacity]: any = await DB.query(query);
|
||||
|
||||
// Get the total capacity of all channels which have at least one node on clearnet
|
||||
query = `
|
||||
SELECT SUM(capacity) as capacity
|
||||
FROM (
|
||||
SELECT capacity, GROUP_CONCAT(socket1.type, socket2.type) as networks
|
||||
FROM channels
|
||||
JOIN nodes_sockets socket1 ON node1_public_key = socket1.public_key
|
||||
JOIN nodes_sockets socket2 ON node2_public_key = socket2.public_key
|
||||
AND channels.status = 1
|
||||
GROUP BY short_id
|
||||
) channels_tmp
|
||||
WHERE channels_tmp.networks LIKE '%ipv%'
|
||||
`;
|
||||
const [clearnetCapacity]: any = await DB.query(query);
|
||||
|
||||
// Get the total capacity of all channels which have both nodes on Tor
|
||||
query = `
|
||||
SELECT SUM(capacity) as capacity
|
||||
FROM (
|
||||
SELECT capacity, GROUP_CONCAT(socket1.type, socket2.type) as networks
|
||||
FROM channels
|
||||
JOIN nodes_sockets socket1 ON node1_public_key = socket1.public_key
|
||||
JOIN nodes_sockets socket2 ON node2_public_key = socket2.public_key
|
||||
AND channels.status = 1
|
||||
GROUP BY short_id
|
||||
) channels_tmp
|
||||
WHERE channels_tmp.networks NOT LIKE '%ipv%' AND
|
||||
channels_tmp.networks NOT LIKE '%dns%' AND
|
||||
channels_tmp.networks NOT LIKE '%websocket%'
|
||||
`;
|
||||
const [torCapacity]: any = await DB.query(query);
|
||||
|
||||
const clearnetCapacityValue = parseInt(clearnetCapacity[0].capacity, 10);
|
||||
const torCapacityValue = parseInt(torCapacity[0].capacity, 10);
|
||||
const unknownCapacityValue = parseInt(totalCapacity[0].capacity) - clearnetCapacityValue - torCapacityValue;
|
||||
|
||||
return {
|
||||
clearnetCapacity: clearnetCapacityValue,
|
||||
torCapacity: torCapacityValue,
|
||||
unknownCapacity: unknownCapacityValue,
|
||||
ispRanking: ispRanking,
|
||||
};
|
||||
} catch (e) {
|
||||
logger.err(`Cannot get LN ISP ranking. Reason: ${e instanceof Error ? e.message : e}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getNodesPerCountry(countryId: string) {
|
||||
try {
|
||||
const query = `
|
||||
SELECT nodes.public_key, CAST(COALESCE(nodes.capacity, 0) as INT) as capacity, CAST(COALESCE(nodes.channels, 0) as INT) as channels,
|
||||
nodes.alias, UNIX_TIMESTAMP(nodes.first_seen) as first_seen, UNIX_TIMESTAMP(nodes.updated_at) as updated_at,
|
||||
geo_names_city.names as city, geo_names_country.names as country,
|
||||
geo_names_iso.names as iso_code, geo_names_subdivision.names as subdivision,
|
||||
nodes.longitude, nodes.latitude, nodes.as_number, geo_names_isp.names as isp
|
||||
FROM nodes
|
||||
LEFT JOIN geo_names geo_names_country ON geo_names_country.id = nodes.country_id AND geo_names_country.type = 'country'
|
||||
LEFT JOIN geo_names geo_names_city ON geo_names_city.id = nodes.city_id AND geo_names_city.type = 'city'
|
||||
LEFT JOIN geo_names geo_names_iso ON geo_names_iso.id = nodes.country_id AND geo_names_iso.type = 'country_iso_code'
|
||||
LEFT JOIN geo_names geo_names_subdivision on geo_names_subdivision.id = nodes.subdivision_id AND geo_names_subdivision.type = 'division'
|
||||
LEFT JOIN geo_names geo_names_isp on geo_names_isp.id = nodes.as_number AND geo_names_isp.type = 'as_organization'
|
||||
WHERE geo_names_country.id = ?
|
||||
ORDER BY capacity DESC
|
||||
`;
|
||||
|
||||
const [rows]: any = await DB.query(query, [countryId]);
|
||||
for (let i = 0; i < rows.length; ++i) {
|
||||
rows[i].country = JSON.parse(rows[i].country);
|
||||
rows[i].city = JSON.parse(rows[i].city);
|
||||
rows[i].subdivision = JSON.parse(rows[i].subdivision);
|
||||
rows[i].isp = JSON.parse(rows[i].isp);
|
||||
}
|
||||
return rows;
|
||||
} catch (e) {
|
||||
logger.err(`Cannot get nodes for country id ${countryId}. Reason: ${e instanceof Error ? e.message : e}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getNodesPerISP(ISPId: string) {
|
||||
try {
|
||||
let query = `
|
||||
SELECT channels.node1_public_key AS node1PublicKey, isp1.id as isp1ID,
|
||||
channels.node2_public_key AS node2PublicKey, isp2.id as isp2ID
|
||||
FROM channels
|
||||
JOIN nodes node1 ON node1.public_key = channels.node1_public_key
|
||||
JOIN nodes node2 ON node2.public_key = channels.node2_public_key
|
||||
JOIN geo_names isp1 ON isp1.id = node1.as_number
|
||||
JOIN geo_names isp2 ON isp2.id = node2.as_number
|
||||
WHERE channels.status = 1 AND (node1.as_number IN (?) OR node2.as_number IN (?))
|
||||
ORDER BY short_id DESC
|
||||
`;
|
||||
|
||||
const IPSIds = ISPId.split(',');
|
||||
const [rows]: any = await DB.query(query, [IPSIds, IPSIds]);
|
||||
if (!rows || rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const nodes = {};
|
||||
|
||||
const intISPIds: number[] = [];
|
||||
for (const ispId of IPSIds) {
|
||||
intISPIds.push(parseInt(ispId, 10));
|
||||
}
|
||||
|
||||
for (const channel of rows) {
|
||||
if (intISPIds.includes(channel.isp1ID)) {
|
||||
nodes[channel.node1PublicKey] = true;
|
||||
}
|
||||
if (intISPIds.includes(channel.isp2ID)) {
|
||||
nodes[channel.node2PublicKey] = true;
|
||||
}
|
||||
}
|
||||
|
||||
query = `
|
||||
SELECT nodes.public_key, CAST(COALESCE(nodes.capacity, 0) as INT) as capacity, CAST(COALESCE(nodes.channels, 0) as INT) as channels,
|
||||
nodes.alias, UNIX_TIMESTAMP(nodes.first_seen) as first_seen, UNIX_TIMESTAMP(nodes.updated_at) as updated_at,
|
||||
geo_names_city.names as city, geo_names_country.names as country,
|
||||
geo_names_iso.names as iso_code, geo_names_subdivision.names as subdivision,
|
||||
nodes.longitude, nodes.latitude
|
||||
FROM nodes
|
||||
LEFT JOIN geo_names geo_names_country ON geo_names_country.id = nodes.country_id AND geo_names_country.type = 'country'
|
||||
LEFT JOIN geo_names geo_names_city ON geo_names_city.id = nodes.city_id AND geo_names_city.type = 'city'
|
||||
LEFT JOIN geo_names geo_names_iso ON geo_names_iso.id = nodes.country_id AND geo_names_iso.type = 'country_iso_code'
|
||||
LEFT JOIN geo_names geo_names_subdivision on geo_names_subdivision.id = nodes.subdivision_id AND geo_names_subdivision.type = 'division'
|
||||
WHERE nodes.public_key IN (?)
|
||||
ORDER BY capacity DESC
|
||||
`;
|
||||
|
||||
const [rows2]: any = await DB.query(query, [Object.keys(nodes)]);
|
||||
for (let i = 0; i < rows2.length; ++i) {
|
||||
rows2[i].country = JSON.parse(rows2[i].country);
|
||||
rows2[i].city = JSON.parse(rows2[i].city);
|
||||
rows2[i].subdivision = JSON.parse(rows2[i].subdivision);
|
||||
}
|
||||
return rows2;
|
||||
|
||||
} catch (e) {
|
||||
logger.err(`Cannot get nodes for ISP id ${ISPId}. Reason: ${e instanceof Error ? e.message : e}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getNodesCountries() {
|
||||
try {
|
||||
let query = `SELECT geo_names.names as names, geo_names_iso.names as iso_code, COUNT(DISTINCT nodes.public_key) as nodesCount, SUM(capacity) as capacity
|
||||
FROM nodes
|
||||
JOIN geo_names ON geo_names.id = nodes.country_id AND geo_names.type = 'country'
|
||||
JOIN geo_names geo_names_iso ON geo_names_iso.id = nodes.country_id AND geo_names_iso.type = 'country_iso_code'
|
||||
GROUP BY country_id
|
||||
ORDER BY COUNT(DISTINCT nodes.public_key) DESC
|
||||
`;
|
||||
const [nodesCountPerCountry]: any = await DB.query(query);
|
||||
|
||||
query = `SELECT COUNT(*) as total FROM nodes WHERE country_id IS NOT NULL`;
|
||||
const [nodesWithAS]: any = await DB.query(query);
|
||||
|
||||
const nodesPerCountry: any[] = [];
|
||||
for (const country of nodesCountPerCountry) {
|
||||
nodesPerCountry.push({
|
||||
name: JSON.parse(country.names),
|
||||
iso: country.iso_code,
|
||||
count: country.nodesCount,
|
||||
share: Math.floor(country.nodesCount / nodesWithAS[0].total * 10000) / 100,
|
||||
capacity: country.capacity,
|
||||
})
|
||||
}
|
||||
|
||||
return nodesPerCountry;
|
||||
} catch (e) {
|
||||
logger.err(`Cannot get nodes grouped by AS. Reason: ${e instanceof Error ? e.message : e}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save or update a node present in the graph
|
||||
*/
|
||||
public async $saveNode(node: ILightningApi.Node): Promise<void> {
|
||||
try {
|
||||
// https://github.com/mempool/mempool/issues/3006
|
||||
if ((node.last_update ?? 0) < 1514736061) { // January 1st 2018
|
||||
node.last_update = null;
|
||||
}
|
||||
|
||||
const sockets = (node.addresses?.map(a => a.addr).join(',')) ?? '';
|
||||
const query = `INSERT INTO nodes(
|
||||
public_key,
|
||||
first_seen,
|
||||
updated_at,
|
||||
alias,
|
||||
alias_search,
|
||||
color,
|
||||
sockets,
|
||||
status
|
||||
)
|
||||
VALUES (?, NOW(), FROM_UNIXTIME(?), ?, ?, ?, ?, 1)
|
||||
ON DUPLICATE KEY UPDATE updated_at = FROM_UNIXTIME(?), alias = ?, alias_search = ?, color = ?, sockets = ?, status = 1`;
|
||||
|
||||
await DB.query(query, [
|
||||
node.pub_key,
|
||||
node.last_update,
|
||||
node.alias,
|
||||
this.aliasToSearchText(node.alias),
|
||||
node.color,
|
||||
sockets,
|
||||
node.last_update,
|
||||
node.alias,
|
||||
this.aliasToSearchText(node.alias),
|
||||
node.color,
|
||||
sockets,
|
||||
]);
|
||||
} catch (e) {
|
||||
logger.err('$saveNode() error: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update node sockets
|
||||
*/
|
||||
public async $updateNodeSockets(publicKey: string, sockets: {network: string; addr: string}[]): Promise<void> {
|
||||
const formattedSockets = (sockets.map(a => a.addr).join(',')) ?? '';
|
||||
try {
|
||||
await DB.query(`UPDATE nodes SET sockets = ? WHERE public_key = ?`, [formattedSockets, publicKey]);
|
||||
} catch (e) {
|
||||
logger.err(`Cannot update node sockets for ${publicKey}. Reason: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set all nodes not in `nodesPubkeys` as inactive (status = 0)
|
||||
*/
|
||||
public async $setNodesInactive(graphNodesPubkeys: string[]): Promise<void> {
|
||||
if (graphNodesPubkeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await DB.query<ResultSetHeader>(`
|
||||
UPDATE nodes
|
||||
SET status = 0
|
||||
WHERE public_key NOT IN (
|
||||
${graphNodesPubkeys.map(pubkey => `"${pubkey}"`).join(',')}
|
||||
)
|
||||
`);
|
||||
if (result[0].changedRows ?? 0 > 0) {
|
||||
logger.debug(`Marked ${result[0].changedRows} nodes as inactive because they are not in the graph`, logger.tags.ln);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err('$setNodesInactive() error: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
private aliasToSearchText(str: string): string {
|
||||
return str.replace(/[-_.]/g, ' ').replace(/[^a-zA-Z0-9 ]/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
export default new NodesApi();
|
||||
316
backend/src/api/explorer/nodes.routes.ts
Normal file
316
backend/src/api/explorer/nodes.routes.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import config from '../../config';
|
||||
import { Application, Request, Response } from 'express';
|
||||
import nodesApi from './nodes.api';
|
||||
import DB from '../../database';
|
||||
import { INodesRanking } from '../../mempool.interfaces';
|
||||
|
||||
class NodesRoutes {
|
||||
constructor() { }
|
||||
|
||||
public initRoutes(app: Application) {
|
||||
app
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/world', this.$getWorldNodes)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/country/:country', this.$getNodesPerCountry)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/search/:search', this.$searchNode)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/isp-ranking', this.$getISPRanking)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/isp/:isp', this.$getNodesPerISP)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/countries', this.$getNodesCountries)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/rankings', this.$getNodesRanking)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/rankings/liquidity', this.$getTopNodesByCapacity)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/rankings/connectivity', this.$getTopNodesByChannels)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/rankings/age', this.$getOldestNodes)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key/statistics', this.$getHistoricalNodeStats)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key/fees/histogram', this.$getFeeHistogram)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/:public_key', this.$getNode)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'lightning/nodes/group/:name', this.$getNodeGroup)
|
||||
;
|
||||
}
|
||||
|
||||
private async $searchNode(req: Request, res: Response) {
|
||||
try {
|
||||
const nodes = await nodesApi.$searchNodeByPublicKeyOrAlias(req.params.search);
|
||||
res.json(nodes);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getNodeGroup(req: Request, res: Response) {
|
||||
try {
|
||||
let nodesList;
|
||||
let nodes: any[] = [];
|
||||
switch (config.MEMPOOL.NETWORK) {
|
||||
case 'testnet':
|
||||
nodesList = [
|
||||
'032c7c7819276c4f706a04df1a0f1e10a5495994a7be4c1d3d28ca766e5a2b957b',
|
||||
'025a7e38c2834dd843591a4d23d5f09cdeb77ddca85f673c2d944a14220ff14cf7',
|
||||
'0395e2731a1673ef21d7a16a727c4fc4d4c35a861c428ce2c819c53d2b81c8bd55',
|
||||
'032ab2028c0b614c6d87824e2373529652fd7e4221b4c70cc4da7c7005c49afcf0',
|
||||
'029001b22fe70b48bee12d014df91982eb85ff1bd404ec772d5c83c4ee3e88d2c3',
|
||||
'0212e2848d79f928411da5f2ff0a8c95ec6ccb5a09d2031b6f71e91309dcde63af',
|
||||
'03e871a2229523d34f76e6311ff197cfe7f26c2fbec13554b93a46f4e710c47dab',
|
||||
'032202ec98d976b0e928bd1d91924e8bd3eab07231fc39feb3737b010071073df8',
|
||||
'02fa7c5a948d03d563a9f36940c2205a814e594d17c0042ced242c71a857d72605',
|
||||
'039c14fdec2d958e3d14cebf657451bbd9e039196615785e82c917f274e3fb2205',
|
||||
'033589bbcb233ffc416cefd5437c7f37e9d7cb7942d405e39e72c4c846d9b37f18',
|
||||
'029293110441c6e2eacb57e1255bf6ef05c41a6a676fe474922d33c19f98a7d584',
|
||||
'0235ad0b56ed8c42c4354444c24e971c05e769ec0b5fb0ccea42880095dc02ea2c',
|
||||
'029700819a37afea630f80e6cc461f3fd3c4ace2598a21cfbbe64d1c78d0ee69a5',
|
||||
'02c2d8b2dbf87c7894af2f1d321290e2fe6db5446cd35323987cee98f06e2e0075',
|
||||
'030b0ca1ea7b1075716d2a555630e6fd47ef11bc7391fe68963ec06cf370a5e382',
|
||||
'031adb9eb2d66693f85fa31a4adca0319ba68219f3ad5f9a2ef9b34a6b40755fa1',
|
||||
'02ccd07faa47eda810ecf5591ccf5ca50f6c1034d0d175052898d32a00b9bae24f',
|
||||
];
|
||||
break;
|
||||
case 'signet':
|
||||
nodesList = [
|
||||
'03ddab321b760433cbf561b615ef62ac7d318630c5f51d523aaf5395b90b751956',
|
||||
'033d92c7bfd213ef1b34c90e985fb5dc77f9ec2409d391492484e57a44c4aca1de',
|
||||
'02ad010dda54253c1eb9efe38b0760657a3b43ecad62198c359c051c9d99d45781',
|
||||
'025196512905b8a3f1597428b867bec63ec9a95e5089eb7dc7e63e2d2691669029',
|
||||
'027c625aa1fbe3768db68ebcb05b53b6dc0ce68b7b54b8900d326d167363e684fe',
|
||||
'03f1629af3101fcc56b7aac2667016be84e3defbf3d0c8719f836c9b41c9a57a43',
|
||||
'02dfb81e2f7a3c4c9e8a51b70ef82b4a24549cc2fab1f5b2fd636501774a918991',
|
||||
'02d01ccf832944c68f10d39006093769c5b8bda886d561b128534e313d729fdb34',
|
||||
'02499ed23027d4698a6904ff4ec1b6085a61f10b9a6937f90438f9947e38e8ea86',
|
||||
'038310e3a786340f2bd7770704c7ccfe560fd163d9a1c99d67894597419d12cbf7',
|
||||
'03e5e9d879b72c7d67ecd483bae023bd33e695bb32b981a4021260f7b9d62bc761',
|
||||
'028d16e1a0ace4c0c0a421536d8d32ce484dfe6e2f726b7b0e7c30f12a195f8cc7',
|
||||
'02ff690d06c187ab994bf83c5a2114fe5bf50112c2c817af0f788f736be9fa2070',
|
||||
'02a9f570c51a2526a5ee85802e88f9281bed771eb66a0c8a7d898430dd5d0eae45',
|
||||
'038c3de773255d3bd7a50e31e58d423baac5c90826a74d75e64b74c95475de1097',
|
||||
'0242c7f7d315095f37ad1421ae0a2fc967d4cbe65b61b079c5395a769436959853',
|
||||
'02a909e70eb03742f12666ebb1f56ac42a5fbaab0c0e8b5b1df4aa9f10f8a09240',
|
||||
'03a26efa12489803c07f3ac2f1dba63812e38f0f6e866ce3ebb34df7de1f458cd2',
|
||||
];
|
||||
break;
|
||||
default:
|
||||
nodesList = [
|
||||
'03fbc17549ec667bccf397ababbcb4cdc0e3394345e4773079ab2774612ec9be61',
|
||||
'03da9a8623241ccf95f19cd645c6cecd4019ac91570e976eb0a128bebbc4d8a437',
|
||||
'03ca5340cf85cb2e7cf076e489f785410838de174e40be62723e8a60972ad75144',
|
||||
'0238bd27f02d67d6c51e269692bc8c9a32357a00e7777cba7f4f1f18a2a700b108',
|
||||
'03f983dcabed6baa1eab5b56c8b2e8fdc846ab3fd931155377897335e85a9fa57c',
|
||||
'03e399589533581e48796e29a825839a010036a61b20744fda929d6709fcbffcc5',
|
||||
'021f5288b5f72c42cd0d8801086af7ce09a816d8ee9a4c47a4b436399b26cb601a',
|
||||
'032b01b7585f781420cd4148841a82831ba37fa952342052cec16750852d4f2dd9',
|
||||
'02848036488d4b8fb1f1c4064261ec36151f43b085f0b51bd239ade3ddfc940c34',
|
||||
'02b6b1640fe029e304c216951af9fbefdb23b0bdc9baaf327540d31b6107841fdf',
|
||||
'03694289827203a5b3156d753071ddd5bf92e371f5a462943f9555eef6d2d6606c',
|
||||
'0283d850db7c3e8ea7cc9c4abc7afaab12bbdf72b677dcba1d608350d2537d7d43',
|
||||
'02521287789f851268a39c9eccc9d6180d2c614315b583c9e6ae0addbd6d79df06',
|
||||
'0258c2a7b7f8af2585b4411b1ec945f70988f30412bb1df179de941f14d0b1bc3e',
|
||||
'03c3389ff1a896f84d921ed01a19fc99c6724ce8dc4b960cd3b7b2362b62cd60d7',
|
||||
'038d118996b3eaa15dcd317b32a539c9ecfdd7698f204acf8a087336af655a9192',
|
||||
'02a928903d93d78877dacc3642b696128a3636e9566dd42d2d132325b2c8891c09',
|
||||
'0328cd17f3a9d3d90b532ade0d1a67e05eb8a51835b3dce0a2e38eac04b5a62a57',
|
||||
];
|
||||
}
|
||||
|
||||
for (let pubKey of nodesList) {
|
||||
try {
|
||||
const node = await nodesApi.$getNode(pubKey);
|
||||
if (node) {
|
||||
nodes.push(node);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
res.json(nodes);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getNode(req: Request, res: Response) {
|
||||
try {
|
||||
const node = await nodesApi.$getNode(req.params.public_key);
|
||||
if (!node) {
|
||||
res.status(404).send('Node not found');
|
||||
return;
|
||||
}
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
res.json(node);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getHistoricalNodeStats(req: Request, res: Response) {
|
||||
try {
|
||||
const statistics = await nodesApi.$getNodeStats(req.params.public_key);
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
res.json(statistics);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getFeeHistogram(req: Request, res: Response) {
|
||||
try {
|
||||
const node = await nodesApi.$getFeeHistogram(req.params.public_key);
|
||||
if (!node) {
|
||||
res.status(404).send('Node not found');
|
||||
return;
|
||||
}
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
res.json(node);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getNodesRanking(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const topCapacityNodes = await nodesApi.$getTopCapacityNodes(false);
|
||||
const topChannelsNodes = await nodesApi.$getTopChannelsNodes(false);
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
res.json(<INodesRanking>{
|
||||
topByCapacity: topCapacityNodes,
|
||||
topByChannels: topChannelsNodes,
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getTopNodesByCapacity(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const topCapacityNodes = await nodesApi.$getTopCapacityNodes(true);
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
res.json(topCapacityNodes);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getTopNodesByChannels(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const topCapacityNodes = await nodesApi.$getTopChannelsNodes(true);
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
res.json(topCapacityNodes);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getOldestNodes(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const topCapacityNodes = await nodesApi.$getOldestNodes(true);
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
res.json(topCapacityNodes);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getISPRanking(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const nodesPerAs = await nodesApi.$getNodesISPRanking();
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString());
|
||||
res.json(nodesPerAs);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getWorldNodes(req: Request, res: Response) {
|
||||
try {
|
||||
const worldNodes = await nodesApi.$getWorldNodes();
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString());
|
||||
res.json(worldNodes);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getNodesPerCountry(req: Request, res: Response) {
|
||||
try {
|
||||
const [country]: any[] = await DB.query(
|
||||
`SELECT geo_names.id, geo_names_country.names as country_names
|
||||
FROM geo_names
|
||||
JOIN geo_names geo_names_country on geo_names.id = geo_names_country.id AND geo_names_country.type = 'country'
|
||||
WHERE geo_names.type = 'country_iso_code' AND geo_names.names = ?`,
|
||||
[req.params.country]
|
||||
);
|
||||
|
||||
if (country.length === 0) {
|
||||
res.status(404).send(`This country does not exist or does not host any lightning nodes on clearnet`);
|
||||
return;
|
||||
}
|
||||
|
||||
const nodes = await nodesApi.$getNodesPerCountry(country[0].id);
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
res.json({
|
||||
country: JSON.parse(country[0].country_names),
|
||||
nodes: nodes,
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getNodesPerISP(req: Request, res: Response) {
|
||||
try {
|
||||
const [isp]: any[] = await DB.query(
|
||||
`SELECT geo_names.names as isp_name
|
||||
FROM geo_names
|
||||
WHERE geo_names.type = 'as_organization' AND geo_names.id = ?`,
|
||||
[req.params.isp]
|
||||
);
|
||||
|
||||
if (isp.length === 0) {
|
||||
res.status(404).send(`This ISP does not exist or does not host any lightning nodes on clearnet`);
|
||||
return;
|
||||
}
|
||||
|
||||
const nodes = await nodesApi.$getNodesPerISP(req.params.isp);
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
res.json({
|
||||
isp: JSON.parse(isp[0].isp_name),
|
||||
nodes: nodes,
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getNodesCountries(req: Request, res: Response) {
|
||||
try {
|
||||
const nodesPerAs = await nodesApi.$getNodesCountries();
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 600).toUTCString());
|
||||
res.json(nodesPerAs);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new NodesRoutes();
|
||||
53
backend/src/api/explorer/statistics.api.ts
Normal file
53
backend/src/api/explorer/statistics.api.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import logger from '../../logger';
|
||||
import DB from '../../database';
|
||||
import { Common } from '../common';
|
||||
|
||||
class StatisticsApi {
|
||||
public async $getStatistics(interval: string | null = null): Promise<any> {
|
||||
interval = Common.getSqlInterval(interval);
|
||||
|
||||
let query = `SELECT UNIX_TIMESTAMP(added) AS added, channel_count, total_capacity,
|
||||
tor_nodes, clearnet_nodes, unannounced_nodes, clearnet_tor_nodes
|
||||
FROM lightning_stats`;
|
||||
|
||||
if (interval) {
|
||||
query += ` WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
|
||||
}
|
||||
|
||||
query += ` ORDER BY added DESC`;
|
||||
|
||||
try {
|
||||
const [rows]: any = await DB.query(query);
|
||||
return rows;
|
||||
} catch (e) {
|
||||
logger.err('$getStatistics error: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getLatestStatistics(): Promise<any> {
|
||||
try {
|
||||
const [rows]: any = await DB.query(`SELECT * FROM lightning_stats ORDER BY added DESC LIMIT 1`);
|
||||
const [rows2]: any = await DB.query(`SELECT * FROM lightning_stats WHERE DATE(added) = DATE(NOW() - INTERVAL 7 DAY)`);
|
||||
return {
|
||||
latest: rows[0],
|
||||
previous: rows2[0],
|
||||
};
|
||||
} catch (e) {
|
||||
logger.err('$getLatestStatistics error: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getStatisticsCount(): Promise<number> {
|
||||
try {
|
||||
const [rows]: any = await DB.query(`SELECT count(*) as count FROM lightning_stats`);
|
||||
return rows[0].count;
|
||||
} catch (e) {
|
||||
logger.err('$getLatestStatistics error: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new StatisticsApi();
|
||||
37
backend/src/api/fetch-version.ts
Normal file
37
backend/src/api/fetch-version.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import fs from 'fs';
|
||||
import path from "path";
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
function getVersion(): string {
|
||||
const packageJson = fs.readFileSync('package.json').toString();
|
||||
return JSON.parse(packageJson).version;
|
||||
}
|
||||
|
||||
function getGitCommit(): string {
|
||||
if (process.env.MEMPOOL_COMMIT_HASH) {
|
||||
return process.env.MEMPOOL_COMMIT_HASH;
|
||||
} else {
|
||||
const gitRevParse = spawnSync('git', ['rev-parse', '--short', 'HEAD']);
|
||||
if (!gitRevParse.error) {
|
||||
const output = gitRevParse.stdout.toString('utf-8').replace(/[\n\r\s]+$/, '');
|
||||
if (output) {
|
||||
return output;
|
||||
} else {
|
||||
console.log('Could not fetch git commit: No repo available');
|
||||
}
|
||||
} else if (gitRevParse.error.code === 'ENOENT') {
|
||||
console.log('Could not fetch git commit: Command `git` is unavailable');
|
||||
}
|
||||
}
|
||||
return '?';
|
||||
}
|
||||
|
||||
const versionInfo = {
|
||||
version: getVersion(),
|
||||
gitCommit: getGitCommit()
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(__dirname, 'version.json'),
|
||||
JSON.stringify(versionInfo, null, 2) + "\n"
|
||||
);
|
||||
@@ -1,123 +0,0 @@
|
||||
import logger from '../logger';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { IConversionRates } from '../mempool.interfaces';
|
||||
import config from '../config';
|
||||
import backendInfo from './backend-info';
|
||||
import { SocksProxyAgent } from 'socks-proxy-agent';
|
||||
|
||||
class FiatConversion {
|
||||
private debasingFiatCurrencies = ['AED', 'AUD', 'BDT', 'BHD', 'BMD', 'BRL', 'CAD', 'CHF', 'CLP',
|
||||
'CNY', 'CZK', 'DKK', 'EUR', 'GBP', 'HKD', 'HUF', 'IDR', 'ILS', 'INR', 'JPY', 'KRW', 'KWD',
|
||||
'LKR', 'MMK', 'MXN', 'MYR', 'NGN', 'NOK', 'NZD', 'PHP', 'PKR', 'PLN', 'RUB', 'SAR', 'SEK',
|
||||
'SGD', 'THB', 'TRY', 'TWD', 'UAH', 'USD', 'VND', 'ZAR'];
|
||||
private conversionRates: IConversionRates = {};
|
||||
private ratesChangedCallback: ((rates: IConversionRates) => void) | undefined;
|
||||
public ratesInitialized = false; // If true, it means rates are ready for use
|
||||
|
||||
constructor() {
|
||||
for (const fiat of this.debasingFiatCurrencies) {
|
||||
this.conversionRates[fiat] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public setProgressChangedCallback(fn: (rates: IConversionRates) => void) {
|
||||
this.ratesChangedCallback = fn;
|
||||
}
|
||||
|
||||
public startService() {
|
||||
const fiatConversionUrl = (config.SOCKS5PROXY.ENABLED === true) && (config.SOCKS5PROXY.USE_ONION === true) ? config.PRICE_DATA_SERVER.TOR_URL : config.PRICE_DATA_SERVER.CLEARNET_URL;
|
||||
logger.info('Starting currency rates service');
|
||||
if (config.SOCKS5PROXY.ENABLED) {
|
||||
logger.info(`Currency rates service will be queried over the Tor network using ${fiatConversionUrl}`);
|
||||
} else {
|
||||
logger.info(`Currency rates service will be queried over clearnet using ${config.PRICE_DATA_SERVER.CLEARNET_URL}`);
|
||||
}
|
||||
setInterval(this.updateCurrency.bind(this), 1000 * config.MEMPOOL.PRICE_FEED_UPDATE_INTERVAL);
|
||||
this.updateCurrency();
|
||||
}
|
||||
|
||||
public getConversionRates() {
|
||||
return this.conversionRates;
|
||||
}
|
||||
|
||||
private async updateCurrency(): Promise<void> {
|
||||
type axiosOptions = {
|
||||
headers: {
|
||||
'User-Agent': string
|
||||
};
|
||||
timeout: number;
|
||||
httpAgent?: http.Agent;
|
||||
httpsAgent?: https.Agent;
|
||||
}
|
||||
const setDelay = (secs: number = 1): Promise<void> => new Promise(resolve => setTimeout(() => resolve(), secs * 1000));
|
||||
const fiatConversionUrl = (config.SOCKS5PROXY.ENABLED === true) && (config.SOCKS5PROXY.USE_ONION === true) ? config.PRICE_DATA_SERVER.TOR_URL : config.PRICE_DATA_SERVER.CLEARNET_URL;
|
||||
const isHTTP = (new URL(fiatConversionUrl).protocol.split(':')[0] === 'http') ? true : false;
|
||||
const axiosOptions: axiosOptions = {
|
||||
headers: {
|
||||
'User-Agent': (config.MEMPOOL.USER_AGENT === 'mempool') ? `mempool/v${backendInfo.getBackendInfo().version}` : `${config.MEMPOOL.USER_AGENT}`
|
||||
},
|
||||
timeout: config.SOCKS5PROXY.ENABLED ? 30000 : 10000
|
||||
};
|
||||
|
||||
let retry = 0;
|
||||
|
||||
while(retry < config.MEMPOOL.EXTERNAL_MAX_RETRY) {
|
||||
try {
|
||||
if (config.SOCKS5PROXY.ENABLED) {
|
||||
let socksOptions: any = {
|
||||
agentOptions: {
|
||||
keepAlive: true,
|
||||
},
|
||||
hostname: config.SOCKS5PROXY.HOST,
|
||||
port: config.SOCKS5PROXY.PORT
|
||||
};
|
||||
|
||||
if (config.SOCKS5PROXY.USERNAME && config.SOCKS5PROXY.PASSWORD) {
|
||||
socksOptions.username = config.SOCKS5PROXY.USERNAME;
|
||||
socksOptions.password = config.SOCKS5PROXY.PASSWORD;
|
||||
} else {
|
||||
// Retry with different tor circuits https://stackoverflow.com/a/64960234
|
||||
socksOptions.username = `circuit${retry}`;
|
||||
}
|
||||
|
||||
// Handle proxy agent for onion addresses
|
||||
if (isHTTP) {
|
||||
axiosOptions.httpAgent = new SocksProxyAgent(socksOptions);
|
||||
} else {
|
||||
axiosOptions.httpsAgent = new SocksProxyAgent(socksOptions);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('Querying currency rates service...');
|
||||
|
||||
const response: AxiosResponse = await axios.get(`${fiatConversionUrl}`, axiosOptions);
|
||||
|
||||
if (response.statusText === 'error' || !response.data) {
|
||||
throw new Error(`Could not fetch data from ${fiatConversionUrl}, Error: ${response.status}`);
|
||||
}
|
||||
|
||||
for (const rate of response.data.data) {
|
||||
if (this.debasingFiatCurrencies.includes(rate.currencyCode) && rate.provider === 'Bisq-Aggregate') {
|
||||
this.conversionRates[rate.currencyCode] = Math.round(100 * rate.price) / 100;
|
||||
}
|
||||
}
|
||||
|
||||
this.ratesInitialized = true;
|
||||
logger.debug(`USD Conversion Rate: ${this.conversionRates.USD}`);
|
||||
|
||||
if (this.ratesChangedCallback) {
|
||||
this.ratesChangedCallback(this.conversionRates);
|
||||
}
|
||||
break;
|
||||
} catch (e) {
|
||||
logger.err('Error updating fiat conversion rates: ' + (e instanceof Error ? e.message : e));
|
||||
await setDelay(config.MEMPOOL.EXTERNAL_RETRY_INTERVAL);
|
||||
retry++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new FiatConversion();
|
||||
270
backend/src/api/lightning/clightning/clightning-client.ts
Normal file
270
backend/src/api/lightning/clightning/clightning-client.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
// Imported from https://github.com/shesek/lightning-client-js
|
||||
|
||||
'use strict';
|
||||
|
||||
const methods = [
|
||||
'addgossip',
|
||||
'autocleaninvoice',
|
||||
'check',
|
||||
'checkmessage',
|
||||
'close',
|
||||
'connect',
|
||||
'createinvoice',
|
||||
'createinvoicerequest',
|
||||
'createoffer',
|
||||
'createonion',
|
||||
'decode',
|
||||
'decodepay',
|
||||
'delexpiredinvoice',
|
||||
'delinvoice',
|
||||
'delpay',
|
||||
'dev-listaddrs',
|
||||
'dev-rescan-outputs',
|
||||
'disableoffer',
|
||||
'disconnect',
|
||||
'estimatefees',
|
||||
'feerates',
|
||||
'fetchinvoice',
|
||||
'fundchannel',
|
||||
'fundchannel_cancel',
|
||||
'fundchannel_complete',
|
||||
'fundchannel_start',
|
||||
'fundpsbt',
|
||||
'getchaininfo',
|
||||
'getinfo',
|
||||
'getlog',
|
||||
'getrawblockbyheight',
|
||||
'getroute',
|
||||
'getsharedsecret',
|
||||
'getutxout',
|
||||
'help',
|
||||
'invoice',
|
||||
'keysend',
|
||||
'legacypay',
|
||||
'listchannels',
|
||||
'listconfigs',
|
||||
'listforwards',
|
||||
'listfunds',
|
||||
'listinvoices',
|
||||
'listnodes',
|
||||
'listoffers',
|
||||
'listpays',
|
||||
'listpeers',
|
||||
'listsendpays',
|
||||
'listtransactions',
|
||||
'multifundchannel',
|
||||
'multiwithdraw',
|
||||
'newaddr',
|
||||
'notifications',
|
||||
'offer',
|
||||
'offerout',
|
||||
'openchannel_abort',
|
||||
'openchannel_bump',
|
||||
'openchannel_init',
|
||||
'openchannel_signed',
|
||||
'openchannel_update',
|
||||
'pay',
|
||||
'payersign',
|
||||
'paystatus',
|
||||
'ping',
|
||||
'plugin',
|
||||
'reserveinputs',
|
||||
'sendinvoice',
|
||||
'sendonion',
|
||||
'sendonionmessage',
|
||||
'sendpay',
|
||||
'sendpsbt',
|
||||
'sendrawtransaction',
|
||||
'setchannelfee',
|
||||
'signmessage',
|
||||
'signpsbt',
|
||||
'stop',
|
||||
'txdiscard',
|
||||
'txprepare',
|
||||
'txsend',
|
||||
'unreserveinputs',
|
||||
'utxopsbt',
|
||||
'waitanyinvoice',
|
||||
'waitblockheight',
|
||||
'waitinvoice',
|
||||
'waitsendpay',
|
||||
'withdraw'
|
||||
];
|
||||
|
||||
|
||||
import EventEmitter from 'events';
|
||||
import { existsSync, statSync } from 'fs';
|
||||
import { createConnection, Socket } from 'net';
|
||||
import { homedir } from 'os';
|
||||
import path from 'path';
|
||||
import { createInterface, Interface } from 'readline';
|
||||
import logger from '../../../logger';
|
||||
import { AbstractLightningApi } from '../lightning-api-abstract-factory';
|
||||
import { ILightningApi } from '../lightning-api.interface';
|
||||
import { convertAndmergeBidirectionalChannels, convertNode } from './clightning-convert';
|
||||
|
||||
class LightningError extends Error {
|
||||
type: string = 'lightning';
|
||||
message: string = 'lightning-client error';
|
||||
|
||||
constructor(error) {
|
||||
super();
|
||||
this.type = error.type;
|
||||
this.message = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
const defaultRpcPath = path.join(homedir(), '.lightning')
|
||||
, fStat = (...p) => statSync(path.join(...p))
|
||||
, fExists = (...p) => existsSync(path.join(...p))
|
||||
|
||||
export default class CLightningClient extends EventEmitter implements AbstractLightningApi {
|
||||
private rpcPath: string;
|
||||
private reconnectWait: number;
|
||||
private reconnectTimeout;
|
||||
private reqcount: number;
|
||||
private client: Socket;
|
||||
private rl: Interface;
|
||||
private clientConnectionPromise: Promise<unknown>;
|
||||
|
||||
constructor(rpcPath = defaultRpcPath) {
|
||||
if (!path.isAbsolute(rpcPath)) {
|
||||
throw new Error('The rpcPath must be an absolute path');
|
||||
}
|
||||
|
||||
if (!fExists(rpcPath) || !fStat(rpcPath).isSocket()) {
|
||||
// network directory provided, use the lightning-rpc within in
|
||||
if (fExists(rpcPath, 'lightning-rpc')) {
|
||||
rpcPath = path.join(rpcPath, 'lightning-rpc');
|
||||
}
|
||||
|
||||
// main data directory provided, default to using the bitcoin mainnet subdirectory
|
||||
// to be removed in v0.2.0
|
||||
else if (fExists(rpcPath, 'bitcoin', 'lightning-rpc')) {
|
||||
logger.warn(`${rpcPath}/lightning-rpc is missing, using the bitcoin mainnet subdirectory at ${rpcPath}/bitcoin instead.`, logger.tags.ln)
|
||||
logger.warn(`specifying the main lightning data directory is deprecated, please specify the network directory explicitly.\n`, logger.tags.ln)
|
||||
rpcPath = path.join(rpcPath, 'bitcoin', 'lightning-rpc')
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`Connecting to ${rpcPath}`, logger.tags.ln);
|
||||
|
||||
super();
|
||||
this.rpcPath = rpcPath;
|
||||
this.reconnectWait = 0.5;
|
||||
this.reconnectTimeout = null;
|
||||
this.reqcount = 0;
|
||||
|
||||
const _self = this;
|
||||
|
||||
this.client = createConnection(rpcPath).on(
|
||||
'error', () => {
|
||||
_self.increaseWaitTime();
|
||||
_self.reconnect();
|
||||
}
|
||||
);
|
||||
this.rl = createInterface({ input: this.client }).on(
|
||||
'error', () => {
|
||||
_self.increaseWaitTime();
|
||||
_self.reconnect();
|
||||
}
|
||||
);
|
||||
|
||||
this.clientConnectionPromise = new Promise<void>(resolve => {
|
||||
_self.client.on('connect', () => {
|
||||
logger.info(`CLightning client connected`, logger.tags.ln);
|
||||
_self.reconnectWait = 1;
|
||||
resolve();
|
||||
});
|
||||
|
||||
_self.client.on('end', () => {
|
||||
logger.err(`CLightning client connection closed, reconnecting`, logger.tags.ln);
|
||||
_self.increaseWaitTime();
|
||||
_self.reconnect();
|
||||
});
|
||||
|
||||
_self.client.on('error', error => {
|
||||
logger.err(`CLightning client connection error: ${error}`, logger.tags.ln);
|
||||
_self.increaseWaitTime();
|
||||
_self.reconnect();
|
||||
});
|
||||
});
|
||||
|
||||
this.rl.on('line', line => {
|
||||
line = line.trim();
|
||||
if (!line) {
|
||||
return;
|
||||
}
|
||||
const data = JSON.parse(line);
|
||||
_self.emit('res:' + data.id, data);
|
||||
});
|
||||
}
|
||||
|
||||
increaseWaitTime(): void {
|
||||
if (this.reconnectWait >= 16) {
|
||||
this.reconnectWait = 16;
|
||||
} else {
|
||||
this.reconnectWait *= 2;
|
||||
}
|
||||
}
|
||||
|
||||
reconnect(): void {
|
||||
const _self = this;
|
||||
|
||||
if (this.reconnectTimeout) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectTimeout = setTimeout(() => {
|
||||
logger.debug(`Trying to reconnect...`, logger.tags.ln);
|
||||
|
||||
_self.client.connect(_self.rpcPath);
|
||||
_self.reconnectTimeout = null;
|
||||
}, this.reconnectWait * 1000);
|
||||
}
|
||||
|
||||
call(method, args = []): Promise<any> {
|
||||
const _self = this;
|
||||
|
||||
const callInt = ++this.reqcount;
|
||||
const sendObj = {
|
||||
jsonrpc: '2.0',
|
||||
method,
|
||||
params: args,
|
||||
id: '' + callInt
|
||||
};
|
||||
|
||||
|
||||
// Wait for the client to connect
|
||||
return this.clientConnectionPromise
|
||||
.then(() => new Promise((resolve, reject) => {
|
||||
// Wait for a response
|
||||
this.once('res:' + callInt, res => res.error == null
|
||||
? resolve(res.result)
|
||||
: reject(new LightningError(res.error))
|
||||
);
|
||||
|
||||
// Send the command
|
||||
_self.client.write(JSON.stringify(sendObj));
|
||||
}));
|
||||
}
|
||||
|
||||
async $getNetworkGraph(): Promise<ILightningApi.NetworkGraph> {
|
||||
const listnodes: any[] = await this.call('listnodes');
|
||||
const listchannels: any[] = await this.call('listchannels');
|
||||
const channelsList = await convertAndmergeBidirectionalChannels(listchannels['channels']);
|
||||
|
||||
return {
|
||||
nodes: listnodes['nodes'].map(node => convertNode(node)),
|
||||
edges: channelsList,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const protify = s => s.replace(/-([a-z])/g, m => m[1].toUpperCase());
|
||||
|
||||
methods.forEach(k => {
|
||||
CLightningClient.prototype[protify(k)] = function (...args: any) {
|
||||
return this.call(k, args);
|
||||
};
|
||||
});
|
||||
158
backend/src/api/lightning/clightning/clightning-convert.ts
Normal file
158
backend/src/api/lightning/clightning/clightning-convert.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
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 config from '../../../config';
|
||||
|
||||
/**
|
||||
* Convert a clightning "listnode" entry to a lnd node entry
|
||||
*/
|
||||
export function convertNode(clNode: any): ILightningApi.Node {
|
||||
let custom_records: { [type: number]: string } | undefined = undefined;
|
||||
if (clNode.option_will_fund) {
|
||||
try {
|
||||
custom_records = { '1': Buffer.from(clNode.option_will_fund.compact_lease || '', 'hex').toString('base64') };
|
||||
} catch (e) {
|
||||
logger.err(`Cannot decode option_will_fund compact_lease for ${clNode.nodeid}). Reason: ` + (e instanceof Error ? e.message : e));
|
||||
custom_records = undefined;
|
||||
}
|
||||
}
|
||||
return {
|
||||
alias: clNode.alias ?? '',
|
||||
color: `#${clNode.color ?? ''}`,
|
||||
features: [], // TODO parse and return clNode.feature
|
||||
pub_key: clNode.nodeid,
|
||||
addresses: clNode.addresses?.map((addr) => {
|
||||
let address = addr.address;
|
||||
if (addr.type === 'ipv6') {
|
||||
address = `[${address}]`;
|
||||
}
|
||||
return {
|
||||
network: addr.type,
|
||||
addr: `${address}:${addr.port}`
|
||||
};
|
||||
}) ?? [],
|
||||
last_update: clNode?.last_timestamp ?? 0,
|
||||
custom_records
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert clightning "listchannels" response to lnd "describegraph.edges" format
|
||||
*/
|
||||
export async function convertAndmergeBidirectionalChannels(clChannels: any[]): Promise<ILightningApi.Channel[]> {
|
||||
logger.debug(`Converting clightning nodes and channels to lnd graph format`, logger.tags.ln);
|
||||
|
||||
let loggerTimer = new Date().getTime() / 1000;
|
||||
let channelProcessed = 0;
|
||||
|
||||
const consolidatedChannelList: ILightningApi.Channel[] = [];
|
||||
const clChannelsDict = {};
|
||||
const clChannelsDictCount = {};
|
||||
|
||||
for (const clChannel of clChannels) {
|
||||
if (!clChannelsDict[clChannel.short_channel_id]) {
|
||||
clChannelsDict[clChannel.short_channel_id] = clChannel;
|
||||
clChannelsDictCount[clChannel.short_channel_id] = 1;
|
||||
} else {
|
||||
const fullChannel = await buildFullChannel(clChannel, clChannelsDict[clChannel.short_channel_id]);
|
||||
if (fullChannel !== null) {
|
||||
consolidatedChannelList.push(fullChannel);
|
||||
delete clChannelsDict[clChannel.short_channel_id];
|
||||
clChannelsDictCount[clChannel.short_channel_id]++;
|
||||
}
|
||||
}
|
||||
|
||||
const elapsedSeconds = Math.round((new Date().getTime() / 1000) - loggerTimer);
|
||||
if (elapsedSeconds > config.LIGHTNING.LOGGER_UPDATE_INTERVAL) {
|
||||
logger.info(`Building complete channels from clightning output. Channels processed: ${channelProcessed + 1} of ${clChannels.length}`, logger.tags.ln);
|
||||
loggerTimer = new Date().getTime() / 1000;
|
||||
}
|
||||
|
||||
++channelProcessed;
|
||||
}
|
||||
|
||||
channelProcessed = 0;
|
||||
const keys = Object.keys(clChannelsDict);
|
||||
for (const short_channel_id of keys) {
|
||||
const incompleteChannel = await buildIncompleteChannel(clChannelsDict[short_channel_id]);
|
||||
if (incompleteChannel !== null) {
|
||||
consolidatedChannelList.push(incompleteChannel);
|
||||
}
|
||||
|
||||
const elapsedSeconds = Math.round((new Date().getTime() / 1000) - loggerTimer);
|
||||
if (elapsedSeconds > config.LIGHTNING.LOGGER_UPDATE_INTERVAL) {
|
||||
logger.info(`Building partial channels from clightning output. Channels processed: ${channelProcessed + 1} of ${keys.length}`);
|
||||
loggerTimer = new Date().getTime() / 1000;
|
||||
}
|
||||
|
||||
channelProcessed++;
|
||||
}
|
||||
|
||||
return consolidatedChannelList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert two clightning "getchannels" entries into a full a lnd "describegraph.edges" format
|
||||
* In this case, clightning knows the channel policy for both nodes
|
||||
*/
|
||||
async function buildFullChannel(clChannelA: any, clChannelB: any): Promise<ILightningApi.Channel | null> {
|
||||
const lastUpdate = Math.max(clChannelA.last_update ?? 0, clChannelB.last_update ?? 0);
|
||||
|
||||
const tx = await FundingTxFetcher.$fetchChannelOpenTx(clChannelA.short_channel_id);
|
||||
if (!tx) {
|
||||
return null;
|
||||
}
|
||||
const parts = clChannelA.short_channel_id.split('x');
|
||||
const outputIdx = parts[2];
|
||||
|
||||
return {
|
||||
channel_id: Common.channelShortIdToIntegerId(clChannelA.short_channel_id),
|
||||
capacity: clChannelA.satoshis,
|
||||
last_update: lastUpdate,
|
||||
node1_policy: convertPolicy(clChannelA),
|
||||
node2_policy: convertPolicy(clChannelB),
|
||||
chan_point: `${tx.txid}:${outputIdx}`,
|
||||
node1_pub: clChannelA.source,
|
||||
node2_pub: clChannelB.source,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert one clightning "getchannels" entry into a full a lnd "describegraph.edges" format
|
||||
* In this case, clightning knows the channel policy of only one node
|
||||
*/
|
||||
async function buildIncompleteChannel(clChannel: any): Promise<ILightningApi.Channel | null> {
|
||||
const tx = await FundingTxFetcher.$fetchChannelOpenTx(clChannel.short_channel_id);
|
||||
if (!tx) {
|
||||
return null;
|
||||
}
|
||||
const parts = clChannel.short_channel_id.split('x');
|
||||
const outputIdx = parts[2];
|
||||
|
||||
return {
|
||||
channel_id: Common.channelShortIdToIntegerId(clChannel.short_channel_id),
|
||||
capacity: clChannel.satoshis,
|
||||
last_update: clChannel.last_update ?? 0,
|
||||
node1_policy: convertPolicy(clChannel),
|
||||
node2_policy: null,
|
||||
chan_point: `${tx.txid}:${outputIdx}`,
|
||||
node1_pub: clChannel.source,
|
||||
node2_pub: clChannel.destination,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a clightning "listnode" response to a lnd channel policy format
|
||||
*/
|
||||
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),
|
||||
fee_base_msat: clChannel.base_fee_millisatoshi,
|
||||
fee_rate_milli_msat: clChannel.fee_per_millionth,
|
||||
disabled: !clChannel.active,
|
||||
last_update: clChannel.last_update ?? 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ILightningApi } from './lightning-api.interface';
|
||||
|
||||
export interface AbstractLightningApi {
|
||||
$getNetworkGraph(): Promise<ILightningApi.NetworkGraph>;
|
||||
}
|
||||
16
backend/src/api/lightning/lightning-api-factory.ts
Normal file
16
backend/src/api/lightning/lightning-api-factory.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import config from '../../config';
|
||||
import CLightningClient from './clightning/clightning-client';
|
||||
import { AbstractLightningApi } from './lightning-api-abstract-factory';
|
||||
import LndApi from './lnd/lnd-api';
|
||||
|
||||
function lightningApiFactory(): AbstractLightningApi {
|
||||
switch (config.LIGHTNING.ENABLED === true && config.LIGHTNING.BACKEND) {
|
||||
case 'cln':
|
||||
return new CLightningClient(config.CLIGHTNING.SOCKET);
|
||||
case 'lnd':
|
||||
default:
|
||||
return new LndApi();
|
||||
}
|
||||
}
|
||||
|
||||
export default lightningApiFactory();
|
||||
92
backend/src/api/lightning/lightning-api.interface.ts
Normal file
92
backend/src/api/lightning/lightning-api.interface.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
export namespace ILightningApi {
|
||||
export interface NetworkInfo {
|
||||
graph_diameter: number;
|
||||
avg_out_degree: number;
|
||||
max_out_degree: number;
|
||||
num_nodes: number;
|
||||
num_channels: number;
|
||||
total_network_capacity: string;
|
||||
avg_channel_size: number;
|
||||
min_channel_size: string;
|
||||
max_channel_size: string;
|
||||
median_channel_size_sat: string;
|
||||
num_zombie_chans: string;
|
||||
}
|
||||
|
||||
export interface NetworkGraph {
|
||||
nodes: Node[];
|
||||
edges: Channel[];
|
||||
}
|
||||
|
||||
export interface Channel {
|
||||
channel_id: string;
|
||||
chan_point: string;
|
||||
last_update: number | null;
|
||||
node1_pub: string;
|
||||
node2_pub: string;
|
||||
capacity: string;
|
||||
node1_policy: RoutingPolicy | null;
|
||||
node2_policy: RoutingPolicy | null;
|
||||
}
|
||||
|
||||
export interface RoutingPolicy {
|
||||
time_lock_delta: number;
|
||||
min_htlc: string;
|
||||
fee_base_msat: string;
|
||||
fee_rate_milli_msat: string;
|
||||
disabled: boolean;
|
||||
max_htlc_msat: string;
|
||||
last_update: number | null;
|
||||
}
|
||||
|
||||
export interface Node {
|
||||
last_update: number | null;
|
||||
pub_key: string;
|
||||
alias: string;
|
||||
addresses: {
|
||||
network: string;
|
||||
addr: string;
|
||||
}[];
|
||||
color: string;
|
||||
features: { [key: number]: Feature };
|
||||
custom_records?: { [type: number]: string };
|
||||
}
|
||||
|
||||
export interface Info {
|
||||
identity_pubkey: string;
|
||||
alias: string;
|
||||
num_pending_channels: number;
|
||||
num_active_channels: number;
|
||||
num_peers: number;
|
||||
block_height: number;
|
||||
block_hash: string;
|
||||
synced_to_chain: boolean;
|
||||
testnet: boolean;
|
||||
uris: string[];
|
||||
best_header_timestamp: string;
|
||||
version: string;
|
||||
num_inactive_channels: number;
|
||||
chains: {
|
||||
chain: string;
|
||||
network: string;
|
||||
}[];
|
||||
color: string;
|
||||
synced_to_graph: boolean;
|
||||
features: { [key: number]: Feature };
|
||||
commit_hash: string;
|
||||
/** Available on LND since v0.15.0-beta */
|
||||
require_htlc_interceptor?: boolean;
|
||||
}
|
||||
|
||||
export interface Feature {
|
||||
name: string;
|
||||
is_required: boolean;
|
||||
is_known: boolean;
|
||||
}
|
||||
|
||||
export interface ForensicOutput {
|
||||
node?: 1 | 2;
|
||||
type: number;
|
||||
value: number;
|
||||
}
|
||||
}
|
||||
49
backend/src/api/lightning/lnd/lnd-api.ts
Normal file
49
backend/src/api/lightning/lnd/lnd-api.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import axios, { AxiosRequestConfig } from 'axios';
|
||||
import { Agent } from 'https';
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.axiosConfig = {
|
||||
headers: {
|
||||
'Grpc-Metadata-macaroon': fs.readFileSync(config.LND.MACAROON_PATH).toString('hex'),
|
||||
},
|
||||
httpsAgent: new Agent({
|
||||
ca: fs.readFileSync(config.LND.TLS_CERT_PATH)
|
||||
}),
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
async $getNetworkInfo(): Promise<ILightningApi.NetworkInfo> {
|
||||
return axios.get<ILightningApi.NetworkInfo>(config.LND.REST_API_URL + '/v1/graph/info', this.axiosConfig)
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
async $getInfo(): Promise<ILightningApi.Info> {
|
||||
return axios.get<ILightningApi.Info>(config.LND.REST_API_URL + '/v1/getinfo', this.axiosConfig)
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
async $getNetworkGraph(): Promise<ILightningApi.NetworkGraph> {
|
||||
return axios.get<ILightningApi.NetworkGraph>(config.LND.REST_API_URL + '/v1/graph', this.axiosConfig)
|
||||
.then((response) => response.data);
|
||||
}
|
||||
}
|
||||
|
||||
export default LndApi;
|
||||
73
backend/src/api/liquid/liquid.routes.ts
Normal file
73
backend/src/api/liquid/liquid.routes.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import axios from 'axios';
|
||||
import { Application, Request, Response } from 'express';
|
||||
import config from '../../config';
|
||||
import elementsParser from './elements-parser';
|
||||
import icons from './icons';
|
||||
|
||||
class LiquidRoutes {
|
||||
public initRoutes(app: Application) {
|
||||
app
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'assets/icons', this.getAllLiquidIcon)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'assets/featured', this.$getAllFeaturedLiquidAssets)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'asset/:assetId/icon', this.getLiquidIcon)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'assets/group/:id', this.$getAssetGroup)
|
||||
;
|
||||
|
||||
if (config.DATABASE.ENABLED) {
|
||||
app
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/pegs/month', this.$getElementsPegsByMonth)
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private getLiquidIcon(req: Request, res: Response) {
|
||||
const result = icons.getIconByAssetId(req.params.assetId);
|
||||
if (result) {
|
||||
res.setHeader('content-type', 'image/png');
|
||||
res.setHeader('content-length', result.length);
|
||||
res.send(result);
|
||||
} else {
|
||||
res.status(404).send('Asset icon not found');
|
||||
}
|
||||
}
|
||||
|
||||
private getAllLiquidIcon(req: Request, res: Response) {
|
||||
const result = icons.getAllIconIds();
|
||||
if (result) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(404).send('Asset icons not found');
|
||||
}
|
||||
}
|
||||
|
||||
private async $getAllFeaturedLiquidAssets(req: Request, res: Response) {
|
||||
try {
|
||||
const response = await axios.get(`${config.EXTERNAL_DATA_SERVER.LIQUID_API}/assets/featured`, { responseType: 'stream', timeout: 10000 });
|
||||
response.data.pipe(res);
|
||||
} catch (e) {
|
||||
res.status(500).end();
|
||||
}
|
||||
}
|
||||
|
||||
private async $getAssetGroup(req: Request, res: Response) {
|
||||
try {
|
||||
const response = await axios.get(`${config.EXTERNAL_DATA_SERVER.LIQUID_API}/assets/group/${parseInt(req.params.id, 10)}`,
|
||||
{ responseType: 'stream', timeout: 10000 });
|
||||
response.data.pipe(res);
|
||||
} catch (e) {
|
||||
res.status(500).end();
|
||||
}
|
||||
}
|
||||
|
||||
private async $getElementsPegsByMonth(req: Request, res: Response) {
|
||||
try {
|
||||
const pegs = await elementsParser.$getPegDataByMonth();
|
||||
res.json(pegs);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new LiquidRoutes();
|
||||
@@ -1,11 +1,14 @@
|
||||
import logger from '../logger';
|
||||
import { MempoolBlock, TransactionExtended, TransactionStripped, MempoolBlockWithTransactions, MempoolBlockDelta } from '../mempool.interfaces';
|
||||
import { MempoolBlock, TransactionExtended, ThreadTransaction, TransactionStripped, MempoolBlockWithTransactions, MempoolBlockDelta, Ancestor } from '../mempool.interfaces';
|
||||
import { Common } from './common';
|
||||
import config from '../config';
|
||||
import { Worker } from 'worker_threads';
|
||||
import path from 'path';
|
||||
|
||||
class MempoolBlocks {
|
||||
private mempoolBlocks: MempoolBlockWithTransactions[] = [];
|
||||
private mempoolBlockDeltas: MempoolBlockDelta[] = [];
|
||||
private txSelectionWorker: Worker | null = null;
|
||||
|
||||
constructor() {}
|
||||
|
||||
@@ -30,7 +33,7 @@ class MempoolBlocks {
|
||||
return this.mempoolBlockDeltas;
|
||||
}
|
||||
|
||||
public updateMempoolBlocks(memPool: { [txid: string]: TransactionExtended }): void {
|
||||
public updateMempoolBlocks(memPool: { [txid: string]: TransactionExtended }, saveResults: boolean = false): MempoolBlockWithTransactions[] {
|
||||
const latestMempool = memPool;
|
||||
const memPoolArray: TransactionExtended[] = [];
|
||||
for (const i in latestMempool) {
|
||||
@@ -71,15 +74,19 @@ class MempoolBlocks {
|
||||
const time = end - start;
|
||||
logger.debug('Mempool blocks calculated in ' + time / 1000 + ' seconds');
|
||||
|
||||
const { blocks, deltas } = this.calculateMempoolBlocks(memPoolArray, this.mempoolBlocks);
|
||||
this.mempoolBlocks = blocks;
|
||||
this.mempoolBlockDeltas = deltas;
|
||||
const blocks = this.calculateMempoolBlocks(memPoolArray, this.mempoolBlocks);
|
||||
|
||||
if (saveResults) {
|
||||
const deltas = this.calculateMempoolDeltas(this.mempoolBlocks, blocks);
|
||||
this.mempoolBlocks = blocks;
|
||||
this.mempoolBlockDeltas = deltas;
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private calculateMempoolBlocks(transactionsSorted: TransactionExtended[], prevBlocks: MempoolBlockWithTransactions[]):
|
||||
{ blocks: MempoolBlockWithTransactions[], deltas: MempoolBlockDelta[] } {
|
||||
private calculateMempoolBlocks(transactionsSorted: TransactionExtended[], prevBlocks: MempoolBlockWithTransactions[]): MempoolBlockWithTransactions[] {
|
||||
const mempoolBlocks: MempoolBlockWithTransactions[] = [];
|
||||
const mempoolBlockDeltas: MempoolBlockDelta[] = [];
|
||||
let blockWeight = 0;
|
||||
let blockSize = 0;
|
||||
let transactions: TransactionExtended[] = [];
|
||||
@@ -90,16 +97,21 @@ class MempoolBlocks {
|
||||
blockSize += tx.size;
|
||||
transactions.push(tx);
|
||||
} else {
|
||||
mempoolBlocks.push(this.dataToMempoolBlocks(transactions, blockSize, blockWeight, mempoolBlocks.length));
|
||||
mempoolBlocks.push(this.dataToMempoolBlocks(transactions, mempoolBlocks.length));
|
||||
blockWeight = tx.weight;
|
||||
blockSize = tx.size;
|
||||
transactions = [tx];
|
||||
}
|
||||
});
|
||||
if (transactions.length) {
|
||||
mempoolBlocks.push(this.dataToMempoolBlocks(transactions, blockSize, blockWeight, mempoolBlocks.length));
|
||||
mempoolBlocks.push(this.dataToMempoolBlocks(transactions, mempoolBlocks.length));
|
||||
}
|
||||
// Calculate change from previous block states
|
||||
|
||||
return 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 removed: string[] = [];
|
||||
@@ -132,14 +144,166 @@ class MempoolBlocks {
|
||||
removed
|
||||
});
|
||||
}
|
||||
return {
|
||||
blocks: mempoolBlocks,
|
||||
deltas: mempoolBlockDeltas
|
||||
};
|
||||
return mempoolBlockDeltas;
|
||||
}
|
||||
|
||||
private dataToMempoolBlocks(transactions: TransactionExtended[],
|
||||
blockSize: number, blockWeight: number, blocksIndex: number): MempoolBlockWithTransactions {
|
||||
public async makeBlockTemplates(newMempool: { [txid: string]: TransactionExtended }, saveResults: boolean = false): Promise<MempoolBlockWithTransactions[]> {
|
||||
// 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 } = {};
|
||||
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),
|
||||
};
|
||||
});
|
||||
|
||||
// (re)initialize tx selection worker thread
|
||||
if (!this.txSelectionWorker) {
|
||||
this.txSelectionWorker = new Worker(path.resolve(__dirname, './tx-selection-worker.js'));
|
||||
// if the thread throws an unexpected error, or exits for any other reason,
|
||||
// reset worker state so that it will be re-initialized on the next run
|
||||
this.txSelectionWorker.once('error', () => {
|
||||
this.txSelectionWorker = null;
|
||||
});
|
||||
this.txSelectionWorker.once('exit', () => {
|
||||
this.txSelectionWorker = null;
|
||||
});
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
threadErrorListener = reject;
|
||||
this.txSelectionWorker?.once('message', (result): void => {
|
||||
resolve(result);
|
||||
});
|
||||
this.txSelectionWorker?.once('error', reject);
|
||||
});
|
||||
this.txSelectionWorker.postMessage({ type: 'set', mempool: strippedMempool });
|
||||
const { blocks, clusters } = await workerResultPromise;
|
||||
|
||||
// clean up thread error listener
|
||||
this.txSelectionWorker?.removeListener('error', threadErrorListener);
|
||||
|
||||
return this.processBlockTemplates(newMempool, blocks, clusters, saveResults);
|
||||
} 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> {
|
||||
if (!this.txSelectionWorker) {
|
||||
// need to reset the worker
|
||||
this.makeBlockTemplates(newMempool, saveResults);
|
||||
return;
|
||||
}
|
||||
// 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 => {
|
||||
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),
|
||||
};
|
||||
});
|
||||
|
||||
// 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) => {
|
||||
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;
|
||||
|
||||
// clean up thread error listener
|
||||
this.txSelectionWorker?.removeListener('error', threadErrorListener);
|
||||
|
||||
this.processBlockTemplates(newMempool, blocks, clusters, saveResults);
|
||||
} 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -150,14 +314,14 @@ class MempoolBlocks {
|
||||
rangeLength = 8;
|
||||
}
|
||||
return {
|
||||
blockSize: blockSize,
|
||||
blockVSize: blockWeight / 4,
|
||||
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: transactions.map((tx) => Common.stripTransaction(tx)),
|
||||
transactions: fitTransactions.map((tx) => Common.stripTransaction(tx)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ class Mempool {
|
||||
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;
|
||||
|
||||
private txPerSecondArray: number[] = [];
|
||||
private txPerSecond: number = 0;
|
||||
@@ -29,6 +31,11 @@ class Mempool {
|
||||
private mempoolProtection = 0;
|
||||
private latestTransactions: any[] = [];
|
||||
|
||||
private ESPLORA_MISSING_TX_WARNING_THRESHOLD = 100;
|
||||
private SAMPLE_TIME = 10000; // In ms
|
||||
private timer = new Date().getTime();
|
||||
private missingTxCount = 0;
|
||||
|
||||
constructor() {
|
||||
setInterval(this.updateTxPerSecond.bind(this), 1000);
|
||||
setInterval(this.deleteExpiredTransactions.bind(this), 20000);
|
||||
@@ -63,6 +70,11 @@ class Mempool {
|
||||
this.mempoolChangedCallback = fn;
|
||||
}
|
||||
|
||||
public setAsyncMempoolChangedCallback(fn: (newMempool: { [txId: string]: TransactionExtended; },
|
||||
newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]) => Promise<void>) {
|
||||
this.asyncMempoolChangedCallback = fn;
|
||||
}
|
||||
|
||||
public getMempool(): { [txid: string]: TransactionExtended } {
|
||||
return this.mempoolCache;
|
||||
}
|
||||
@@ -72,6 +84,9 @@ class Mempool {
|
||||
if (this.mempoolChangedCallback) {
|
||||
this.mempoolChangedCallback(this.mempoolCache, [], []);
|
||||
}
|
||||
if (this.asyncMempoolChangedCallback) {
|
||||
this.asyncMempoolChangedCallback(this.mempoolCache, [], []);
|
||||
}
|
||||
}
|
||||
|
||||
public async $updateMemPoolInfo() {
|
||||
@@ -103,12 +118,11 @@ class Mempool {
|
||||
return txTimes;
|
||||
}
|
||||
|
||||
public async $updateMempool() {
|
||||
logger.debug('Updating mempool');
|
||||
public async $updateMempool(): Promise<void> {
|
||||
logger.debug(`Updating mempool...`);
|
||||
const start = new Date().getTime();
|
||||
let hasChange: boolean = false;
|
||||
const currentMempoolSize = Object.keys(this.mempoolCache).length;
|
||||
let txCount = 0;
|
||||
const transactions = await bitcoinApi.$getRawMempool();
|
||||
const diff = transactions.length - currentMempoolSize;
|
||||
const newTransactions: TransactionExtended[] = [];
|
||||
@@ -119,12 +133,21 @@ class Mempool {
|
||||
loadingIndicators.setProgress('mempool', Object.keys(this.mempoolCache).length / transactions.length * 100);
|
||||
}
|
||||
|
||||
// https://github.com/mempool/mempool/issues/3283
|
||||
const logEsplora404 = (missingTxCount, threshold, time) => {
|
||||
const log = `In the past ${time / 1000} seconds, esplora tx API replied ${missingTxCount} times with a 404 error code while updating nodejs backend mempool`;
|
||||
if (missingTxCount >= threshold) {
|
||||
logger.warn(log);
|
||||
} else if (missingTxCount > 0) {
|
||||
logger.debug(log);
|
||||
}
|
||||
};
|
||||
|
||||
for (const txid of transactions) {
|
||||
if (!this.mempoolCache[txid]) {
|
||||
try {
|
||||
const transaction = await transactionUtils.$getTransactionExtended(txid);
|
||||
this.mempoolCache[txid] = transaction;
|
||||
txCount++;
|
||||
if (this.inSync) {
|
||||
this.txPerSecondArray.push(new Date().getTime());
|
||||
this.vBytesPerSecondArray.push({
|
||||
@@ -133,14 +156,12 @@ class Mempool {
|
||||
});
|
||||
}
|
||||
hasChange = true;
|
||||
if (diff > 0) {
|
||||
logger.debug('Fetched transaction ' + txCount + ' / ' + diff);
|
||||
} else {
|
||||
logger.debug('Fetched transaction ' + txCount);
|
||||
}
|
||||
newTransactions.push(transaction);
|
||||
} catch (e) {
|
||||
logger.debug('Error finding transaction in mempool: ' + (e instanceof Error ? e.message : e));
|
||||
} 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +170,14 @@ class Mempool {
|
||||
}
|
||||
}
|
||||
|
||||
// Reset esplora 404 counter and log a warning if needed
|
||||
const elapsedTime = new Date().getTime() - this.timer;
|
||||
if (elapsedTime > this.SAMPLE_TIME) {
|
||||
logEsplora404(this.missingTxCount, this.ESPLORA_MISSING_TX_WARNING_THRESHOLD, elapsedTime);
|
||||
this.timer = new Date().getTime();
|
||||
this.missingTxCount = 0;
|
||||
}
|
||||
|
||||
// Prevent mempool from clear on bitcoind restart by delaying the deletion
|
||||
if (this.mempoolProtection === 0
|
||||
&& currentMempoolSize > 20000
|
||||
@@ -194,18 +223,20 @@ class Mempool {
|
||||
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);
|
||||
}
|
||||
|
||||
const end = new Date().getTime();
|
||||
const time = end - start;
|
||||
logger.debug(`New mempool size: ${Object.keys(this.mempoolCache).length} Change: ${diff}`);
|
||||
logger.debug('Mempool updated in ' + time / 1000 + ' seconds');
|
||||
logger.debug(`Mempool updated in ${time / 1000} seconds. New size: ${Object.keys(this.mempoolCache).length} (${diff > 0 ? '+' + diff : diff})`);
|
||||
}
|
||||
|
||||
public handleRbfTransactions(rbfTransactions: { [txid: string]: TransactionExtended; }) {
|
||||
for (const rbfTransaction in rbfTransactions) {
|
||||
if (this.mempoolCache[rbfTransaction]) {
|
||||
// Store replaced transactions
|
||||
rbfCache.add(rbfTransaction, rbfTransactions[rbfTransaction].txid);
|
||||
rbfCache.add(this.mempoolCache[rbfTransaction], rbfTransactions[rbfTransaction].txid);
|
||||
// Erase the replaced transactions from the local mempool
|
||||
delete this.mempoolCache[rbfTransaction];
|
||||
}
|
||||
@@ -231,6 +262,7 @@ class Mempool {
|
||||
const lazyDeleteAt = this.mempoolCache[tx].deleteAfter;
|
||||
if (lazyDeleteAt && lazyDeleteAt < now) {
|
||||
delete this.mempoolCache[tx];
|
||||
rbfCache.evict(tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,405 +0,0 @@
|
||||
import { PoolInfo, PoolStats, RewardStats } from '../mempool.interfaces';
|
||||
import BlocksRepository from '../repositories/BlocksRepository';
|
||||
import PoolsRepository from '../repositories/PoolsRepository';
|
||||
import HashratesRepository from '../repositories/HashratesRepository';
|
||||
import bitcoinClient from './bitcoin/bitcoin-client';
|
||||
import logger from '../logger';
|
||||
import { Common } from './common';
|
||||
import loadingIndicators from './loading-indicators';
|
||||
import { escape } from 'mysql2';
|
||||
|
||||
class Mining {
|
||||
constructor() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get historical block total fee
|
||||
*/
|
||||
public async $getHistoricalBlockFees(interval: string | null = null): Promise<any> {
|
||||
return await BlocksRepository.$getHistoricalBlockFees(
|
||||
this.getTimeRange(interval),
|
||||
Common.getSqlInterval(interval)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get historical block rewards
|
||||
*/
|
||||
public async $getHistoricalBlockRewards(interval: string | null = null): Promise<any> {
|
||||
return await BlocksRepository.$getHistoricalBlockRewards(
|
||||
this.getTimeRange(interval),
|
||||
Common.getSqlInterval(interval)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get historical block fee rates percentiles
|
||||
*/
|
||||
public async $getHistoricalBlockFeeRates(interval: string | null = null): Promise<any> {
|
||||
return await BlocksRepository.$getHistoricalBlockFeeRates(
|
||||
this.getTimeRange(interval),
|
||||
Common.getSqlInterval(interval)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get historical block sizes
|
||||
*/
|
||||
public async $getHistoricalBlockSizes(interval: string | null = null): Promise<any> {
|
||||
return await BlocksRepository.$getHistoricalBlockSizes(
|
||||
this.getTimeRange(interval),
|
||||
Common.getSqlInterval(interval)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get historical block weights
|
||||
*/
|
||||
public async $getHistoricalBlockWeights(interval: string | null = null): Promise<any> {
|
||||
return await BlocksRepository.$getHistoricalBlockWeights(
|
||||
this.getTimeRange(interval),
|
||||
Common.getSqlInterval(interval)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate high level overview of the pool ranks and general stats
|
||||
*/
|
||||
public async $getPoolsStats(interval: string | null): Promise<object> {
|
||||
const poolsStatistics = {};
|
||||
|
||||
const poolsInfo: PoolInfo[] = await PoolsRepository.$getPoolsInfo(interval);
|
||||
const emptyBlocks: any[] = await BlocksRepository.$countEmptyBlocks(null, interval);
|
||||
|
||||
const poolsStats: PoolStats[] = [];
|
||||
let rank = 1;
|
||||
|
||||
poolsInfo.forEach((poolInfo: PoolInfo) => {
|
||||
const emptyBlocksCount = emptyBlocks.filter((emptyCount) => emptyCount.poolId === poolInfo.poolId);
|
||||
const poolStat: PoolStats = {
|
||||
poolId: poolInfo.poolId, // mysql row id
|
||||
name: poolInfo.name,
|
||||
link: poolInfo.link,
|
||||
blockCount: poolInfo.blockCount,
|
||||
rank: rank++,
|
||||
emptyBlocks: emptyBlocksCount.length > 0 ? emptyBlocksCount[0]['count'] : 0,
|
||||
slug: poolInfo.slug,
|
||||
};
|
||||
poolsStats.push(poolStat);
|
||||
});
|
||||
|
||||
poolsStatistics['pools'] = poolsStats;
|
||||
|
||||
const blockCount: number = await BlocksRepository.$blockCount(null, interval);
|
||||
poolsStatistics['blockCount'] = blockCount;
|
||||
|
||||
const totalBlock24h: number = await BlocksRepository.$blockCount(null, '24h');
|
||||
|
||||
try {
|
||||
poolsStatistics['lastEstimatedHashrate'] = await bitcoinClient.getNetworkHashPs(totalBlock24h);
|
||||
} catch (e) {
|
||||
poolsStatistics['lastEstimatedHashrate'] = 0;
|
||||
logger.debug('Bitcoin Core is not available, using zeroed value for current hashrate');
|
||||
}
|
||||
|
||||
return poolsStatistics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all mining pool stats for a pool
|
||||
*/
|
||||
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));
|
||||
}
|
||||
|
||||
const blockCount: number = await BlocksRepository.$blockCount(pool.id);
|
||||
const totalBlock: number = await BlocksRepository.$blockCount(null, null);
|
||||
|
||||
const blockCount24h: number = await BlocksRepository.$blockCount(pool.id, '24h');
|
||||
const totalBlock24h: number = await BlocksRepository.$blockCount(null, '24h');
|
||||
|
||||
const blockCount1w: number = await BlocksRepository.$blockCount(pool.id, '1w');
|
||||
const totalBlock1w: number = await BlocksRepository.$blockCount(null, '1w');
|
||||
|
||||
let currentEstimatedHashrate = 0;
|
||||
try {
|
||||
currentEstimatedHashrate = await bitcoinClient.getNetworkHashPs(totalBlock24h);
|
||||
} catch (e) {
|
||||
logger.debug('Bitcoin Core is not available, using zeroed value for current hashrate');
|
||||
}
|
||||
|
||||
return {
|
||||
pool: pool,
|
||||
blockCount: {
|
||||
'all': blockCount,
|
||||
'24h': blockCount24h,
|
||||
'1w': blockCount1w,
|
||||
},
|
||||
blockShare: {
|
||||
'all': blockCount / totalBlock,
|
||||
'24h': blockCount24h / totalBlock24h,
|
||||
'1w': blockCount1w / totalBlock1w,
|
||||
},
|
||||
estimatedHashrate: currentEstimatedHashrate * (blockCount24h / totalBlock24h),
|
||||
reportedHashrate: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get miner reward stats
|
||||
*/
|
||||
public async $getRewardStats(blockCount: number): Promise<RewardStats> {
|
||||
return await BlocksRepository.$getBlockStats(blockCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* [INDEXING] Generate weekly mining pool hashrate history
|
||||
*/
|
||||
public async $generatePoolHashrateHistory(): Promise<void> {
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
const lastestRunDate = await HashratesRepository.$getLatestRun('last_weekly_hashrates_indexing');
|
||||
|
||||
// Run only if:
|
||||
// * lastestRunDate is set to 0 (node backend restart, reorg)
|
||||
// * we started a new week (around Monday midnight)
|
||||
const runIndexing = lastestRunDate === 0 || now.getUTCDay() === 1 && lastestRunDate !== now.getUTCDate();
|
||||
if (!runIndexing) {
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
const indexedTimestamp = await HashratesRepository.$getWeeklyHashrateTimestamps();
|
||||
const hashrates: any[] = [];
|
||||
const genesisTimestamp = 1231006505000; // bitcoin-cli getblock 000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f
|
||||
|
||||
const lastMonday = new Date(now.setDate(now.getDate() - (now.getDay() + 6) % 7));
|
||||
const lastMondayMidnight = this.getDateMidnight(lastMonday);
|
||||
let toTimestamp = lastMondayMidnight.getTime();
|
||||
|
||||
const totalWeekIndexed = (await BlocksRepository.$blockCount(null, null)) / 1008;
|
||||
let indexedThisRun = 0;
|
||||
let totalIndexed = 0;
|
||||
let newlyIndexed = 0;
|
||||
const startedAt = new Date().getTime() / 1000;
|
||||
let timer = new Date().getTime() / 1000;
|
||||
|
||||
logger.debug(`Indexing weekly mining pool hashrate`);
|
||||
loadingIndicators.setProgress('weekly-hashrate-indexing', 0);
|
||||
|
||||
while (toTimestamp > genesisTimestamp) {
|
||||
const fromTimestamp = toTimestamp - 604800000;
|
||||
|
||||
// Skip already indexed weeks
|
||||
if (indexedTimestamp.includes(toTimestamp / 1000)) {
|
||||
toTimestamp -= 604800000;
|
||||
++totalIndexed;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we have blocks for the previous week (which mean that the week
|
||||
// we are currently indexing has complete data)
|
||||
const blockStatsPreviousWeek: any = await BlocksRepository.$blockCountBetweenTimestamp(
|
||||
null, (fromTimestamp - 604800000) / 1000, (toTimestamp - 604800000) / 1000);
|
||||
if (blockStatsPreviousWeek.blockCount === 0) { // We are done indexing
|
||||
break;
|
||||
}
|
||||
|
||||
const blockStats: any = await BlocksRepository.$blockCountBetweenTimestamp(
|
||||
null, fromTimestamp / 1000, toTimestamp / 1000);
|
||||
const lastBlockHashrate = await bitcoinClient.getNetworkHashPs(blockStats.blockCount,
|
||||
blockStats.lastBlockHeight);
|
||||
|
||||
let pools = await PoolsRepository.$getPoolsInfoBetween(fromTimestamp / 1000, toTimestamp / 1000);
|
||||
const totalBlocks = pools.reduce((acc, pool) => acc + pool.blockCount, 0);
|
||||
pools = pools.map((pool: any) => {
|
||||
pool.hashrate = (pool.blockCount / totalBlocks) * lastBlockHashrate;
|
||||
pool.share = (pool.blockCount / totalBlocks);
|
||||
return pool;
|
||||
});
|
||||
|
||||
for (const pool of pools) {
|
||||
hashrates.push({
|
||||
hashrateTimestamp: toTimestamp / 1000,
|
||||
avgHashrate: pool['hashrate'],
|
||||
poolId: pool.poolId,
|
||||
share: pool['share'],
|
||||
type: 'weekly',
|
||||
});
|
||||
}
|
||||
|
||||
newlyIndexed += hashrates.length;
|
||||
await HashratesRepository.$saveHashrates(hashrates);
|
||||
hashrates.length = 0;
|
||||
|
||||
const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - timer));
|
||||
if (elapsedSeconds > 1) {
|
||||
const runningFor = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt));
|
||||
const weeksPerSeconds = Math.max(1, Math.round(indexedThisRun / elapsedSeconds));
|
||||
const progress = Math.round(totalIndexed / totalWeekIndexed * 10000) / 100;
|
||||
const timeLeft = Math.round((totalWeekIndexed - totalIndexed) / weeksPerSeconds);
|
||||
const formattedDate = new Date(fromTimestamp).toUTCString();
|
||||
logger.debug(`Getting weekly pool hashrate for ${formattedDate} | ~${weeksPerSeconds.toFixed(2)} weeks/sec | total: ~${totalIndexed}/${Math.round(totalWeekIndexed)} (${progress}%) | elapsed: ${runningFor} seconds | left: ~${timeLeft} seconds`);
|
||||
timer = new Date().getTime() / 1000;
|
||||
indexedThisRun = 0;
|
||||
loadingIndicators.setProgress('weekly-hashrate-indexing', progress, false);
|
||||
}
|
||||
|
||||
toTimestamp -= 604800000;
|
||||
++indexedThisRun;
|
||||
++totalIndexed;
|
||||
}
|
||||
await HashratesRepository.$setLatestRun('last_weekly_hashrates_indexing', new Date().getUTCDate());
|
||||
if (newlyIndexed > 0) {
|
||||
logger.info(`Indexed ${newlyIndexed} pools weekly hashrate`);
|
||||
}
|
||||
loadingIndicators.setProgress('weekly-hashrate-indexing', 100);
|
||||
} catch (e) {
|
||||
loadingIndicators.setProgress('weekly-hashrate-indexing', 100);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [INDEXING] Generate daily hashrate data
|
||||
*/
|
||||
public async $generateNetworkHashrateHistory(): Promise<void> {
|
||||
try {
|
||||
// We only run this once a day around midnight
|
||||
const latestRunDate = await HashratesRepository.$getLatestRun('last_hashrates_indexing');
|
||||
const now = new Date().getUTCDate();
|
||||
if (now === latestRunDate) {
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
const indexedTimestamp = (await HashratesRepository.$getNetworkDailyHashrate(null)).map(hashrate => hashrate.timestamp);
|
||||
const genesisTimestamp = 1231006505000; // bitcoin-cli getblock 000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f
|
||||
const lastMidnight = this.getDateMidnight(new Date());
|
||||
let toTimestamp = Math.round(lastMidnight.getTime());
|
||||
const hashrates: any[] = [];
|
||||
|
||||
const totalDayIndexed = (await BlocksRepository.$blockCount(null, null)) / 144;
|
||||
let indexedThisRun = 0;
|
||||
let totalIndexed = 0;
|
||||
let newlyIndexed = 0;
|
||||
const startedAt = new Date().getTime() / 1000;
|
||||
let timer = new Date().getTime() / 1000;
|
||||
|
||||
logger.debug(`Indexing daily network hashrate`);
|
||||
loadingIndicators.setProgress('daily-hashrate-indexing', 0);
|
||||
|
||||
while (toTimestamp > genesisTimestamp) {
|
||||
const fromTimestamp = toTimestamp - 86400000;
|
||||
|
||||
// Skip already indexed weeks
|
||||
if (indexedTimestamp.includes(toTimestamp / 1000)) {
|
||||
toTimestamp -= 86400000;
|
||||
++totalIndexed;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we have blocks for the previous day (which mean that the day
|
||||
// we are currently indexing has complete data)
|
||||
const blockStatsPreviousDay: any = await BlocksRepository.$blockCountBetweenTimestamp(
|
||||
null, (fromTimestamp - 86400000) / 1000, (toTimestamp - 86400000) / 1000);
|
||||
if (blockStatsPreviousDay.blockCount === 0) { // We are done indexing
|
||||
break;
|
||||
}
|
||||
|
||||
const blockStats: any = await BlocksRepository.$blockCountBetweenTimestamp(
|
||||
null, fromTimestamp / 1000, toTimestamp / 1000);
|
||||
const lastBlockHashrate = await bitcoinClient.getNetworkHashPs(blockStats.blockCount,
|
||||
blockStats.lastBlockHeight);
|
||||
|
||||
hashrates.push({
|
||||
hashrateTimestamp: toTimestamp / 1000,
|
||||
avgHashrate: lastBlockHashrate,
|
||||
poolId: 0,
|
||||
share: 1,
|
||||
type: 'daily',
|
||||
});
|
||||
|
||||
if (hashrates.length > 10) {
|
||||
newlyIndexed += hashrates.length;
|
||||
await HashratesRepository.$saveHashrates(hashrates);
|
||||
hashrates.length = 0;
|
||||
}
|
||||
|
||||
const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - timer));
|
||||
if (elapsedSeconds > 1) {
|
||||
const runningFor = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt));
|
||||
const daysPerSeconds = Math.max(1, Math.round(indexedThisRun / elapsedSeconds));
|
||||
const progress = Math.round(totalIndexed / totalDayIndexed * 10000) / 100;
|
||||
const timeLeft = Math.round((totalDayIndexed - totalIndexed) / daysPerSeconds);
|
||||
const formattedDate = new Date(fromTimestamp).toUTCString();
|
||||
logger.debug(`Getting network daily hashrate for ${formattedDate} | ~${daysPerSeconds.toFixed(2)} days/sec | total: ~${totalIndexed}/${Math.round(totalDayIndexed)} (${progress}%) | elapsed: ${runningFor} seconds | left: ~${timeLeft} seconds`);
|
||||
timer = new Date().getTime() / 1000;
|
||||
indexedThisRun = 0;
|
||||
loadingIndicators.setProgress('daily-hashrate-indexing', progress);
|
||||
}
|
||||
|
||||
toTimestamp -= 86400000;
|
||||
++indexedThisRun;
|
||||
++totalIndexed;
|
||||
}
|
||||
|
||||
// Add genesis block manually
|
||||
if (toTimestamp <= genesisTimestamp && !indexedTimestamp.includes(genesisTimestamp)) {
|
||||
hashrates.push({
|
||||
hashrateTimestamp: genesisTimestamp,
|
||||
avgHashrate: await bitcoinClient.getNetworkHashPs(1, 1),
|
||||
poolId: null,
|
||||
type: 'daily',
|
||||
});
|
||||
}
|
||||
|
||||
newlyIndexed += hashrates.length;
|
||||
await HashratesRepository.$saveHashrates(hashrates);
|
||||
|
||||
await HashratesRepository.$setLatestRun('last_hashrates_indexing', new Date().getUTCDate());
|
||||
if (newlyIndexed > 0) {
|
||||
logger.info(`Indexed ${newlyIndexed} day of network hashrate`);
|
||||
}
|
||||
loadingIndicators.setProgress('daily-hashrate-indexing', 100);
|
||||
} catch (e) {
|
||||
loadingIndicators.setProgress('daily-hashrate-indexing', 100);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private getDateMidnight(date: Date): Date {
|
||||
date.setUTCHours(0);
|
||||
date.setUTCMinutes(0);
|
||||
date.setUTCSeconds(0);
|
||||
date.setUTCMilliseconds(0);
|
||||
|
||||
return date;
|
||||
}
|
||||
|
||||
private getTimeRange(interval: string | null): number {
|
||||
switch (interval) {
|
||||
case '3y': return 43200; // 12h
|
||||
case '2y': return 28800; // 8h
|
||||
case '1y': return 28800; // 8h
|
||||
case '6m': return 10800; // 3h
|
||||
case '3m': return 7200; // 2h
|
||||
case '1m': return 1800; // 30min
|
||||
case '1w': return 300; // 5min
|
||||
case '3d': return 1;
|
||||
case '24h': return 1;
|
||||
default: return 86400; // 24h
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new Mining();
|
||||
329
backend/src/api/mining/mining-routes.ts
Normal file
329
backend/src/api/mining/mining-routes.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import { Application, Request, Response } from 'express';
|
||||
import config from "../../config";
|
||||
import logger from '../../logger';
|
||||
import BlocksAuditsRepository from '../../repositories/BlocksAuditsRepository';
|
||||
import BlocksRepository from '../../repositories/BlocksRepository';
|
||||
import DifficultyAdjustmentsRepository from '../../repositories/DifficultyAdjustmentsRepository';
|
||||
import HashratesRepository from '../../repositories/HashratesRepository';
|
||||
import bitcoinClient from '../bitcoin/bitcoin-client';
|
||||
import mining from "./mining";
|
||||
import PricesRepository from '../../repositories/PricesRepository';
|
||||
|
||||
class MiningRoutes {
|
||||
public initRoutes(app: Application) {
|
||||
app
|
||||
.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)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:slug/blocks/:height', this.$getPoolBlocks)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:slug', this.$getPool)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/hashrate/pools/:interval', this.$getPoolsHistoricalHashrate)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/hashrate/:interval', this.$getHistoricalHashrate)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/difficulty-adjustments', this.$getDifficultyAdjustments)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/reward-stats/:blockCount', this.$getRewardStats)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/fees/:interval', this.$getHistoricalBlockFees)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/rewards/:interval', this.$getHistoricalBlockRewards)
|
||||
.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/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)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/audit/:hash', this.$getBlockAudit)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/timestamp/:timestamp', this.$getHeightFromTimestamp)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'historical-price', this.$getHistoricalPrice)
|
||||
;
|
||||
}
|
||||
|
||||
private async $getHistoricalPrice(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString());
|
||||
if (req.query.timestamp) {
|
||||
res.status(200).send(await PricesRepository.$getNearestHistoricalPrice(
|
||||
parseInt(<string>req.query.timestamp ?? 0, 10)
|
||||
));
|
||||
} else {
|
||||
res.status(200).send(await PricesRepository.$getHistoricalPrices());
|
||||
}
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getPool(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const stats = await mining.$getPoolStat(req.params.slug);
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
res.json(stats);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.indexOf('This mining pool does not exist') > -1) {
|
||||
res.status(404).send(e.message);
|
||||
} else {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async $getPoolBlocks(req: Request, res: Response) {
|
||||
try {
|
||||
const poolBlocks = await BlocksRepository.$getBlocksByPool(
|
||||
req.params.slug,
|
||||
req.params.height === undefined ? undefined : parseInt(req.params.height, 10),
|
||||
);
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
res.json(poolBlocks);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.indexOf('This mining pool does not exist') > -1) {
|
||||
res.status(404).send(e.message);
|
||||
} else {
|
||||
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);
|
||||
const blockCount = await BlocksRepository.$blockCount(null, null);
|
||||
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(stats);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getPoolsHistoricalHashrate(req: Request, res: Response) {
|
||||
try {
|
||||
const hashrates = await HashratesRepository.$getPoolsWeeklyHashrate(req.params.interval);
|
||||
const blockCount = await BlocksRepository.$blockCount(null, null);
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.header('X-total-count', blockCount.toString());
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString());
|
||||
res.json(hashrates);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getPoolHistoricalHashrate(req: Request, res: Response) {
|
||||
try {
|
||||
const hashrates = await HashratesRepository.$getPoolWeeklyHashrate(req.params.slug);
|
||||
const blockCount = await BlocksRepository.$blockCount(null, null);
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.header('X-total-count', blockCount.toString());
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString());
|
||||
res.json(hashrates);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.indexOf('This mining pool does not exist') > -1) {
|
||||
res.status(404).send(e.message);
|
||||
} else {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async $getHistoricalHashrate(req: Request, res: Response) {
|
||||
let currentHashrate = 0, currentDifficulty = 0;
|
||||
try {
|
||||
currentHashrate = await bitcoinClient.getNetworkHashPs();
|
||||
currentDifficulty = await bitcoinClient.getDifficulty();
|
||||
} catch (e) {
|
||||
logger.debug('Bitcoin Core is not available, using zeroed value for current hashrate and difficulty');
|
||||
}
|
||||
|
||||
try {
|
||||
const hashrates = await HashratesRepository.$getNetworkDailyHashrate(req.params.interval);
|
||||
const difficulty = await DifficultyAdjustmentsRepository.$getAdjustments(req.params.interval, false);
|
||||
const blockCount = await BlocksRepository.$blockCount(null, null);
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.header('X-total-count', blockCount.toString());
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString());
|
||||
res.json({
|
||||
hashrates: hashrates,
|
||||
difficulty: difficulty,
|
||||
currentHashrate: currentHashrate,
|
||||
currentDifficulty: currentDifficulty,
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getHistoricalBlockFees(req: Request, res: Response) {
|
||||
try {
|
||||
const blockFees = await mining.$getHistoricalBlockFees(req.params.interval);
|
||||
const blockCount = await BlocksRepository.$blockCount(null, null);
|
||||
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(blockFees);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getHistoricalBlockRewards(req: Request, res: Response) {
|
||||
try {
|
||||
const blockRewards = await mining.$getHistoricalBlockRewards(req.params.interval);
|
||||
const blockCount = await BlocksRepository.$blockCount(null, null);
|
||||
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(blockRewards);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getHistoricalBlockFeeRates(req: Request, res: Response) {
|
||||
try {
|
||||
const blockFeeRates = await mining.$getHistoricalBlockFeeRates(req.params.interval);
|
||||
const blockCount = await BlocksRepository.$blockCount(null, null);
|
||||
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(blockFeeRates);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getHistoricalBlockSizeAndWeight(req: Request, res: Response) {
|
||||
try {
|
||||
const blockSizes = await mining.$getHistoricalBlockSizes(req.params.interval);
|
||||
const blockWeights = await mining.$getHistoricalBlockWeights(req.params.interval);
|
||||
const blockCount = await BlocksRepository.$blockCount(null, null);
|
||||
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({
|
||||
sizes: blockSizes,
|
||||
weights: blockWeights
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getDifficultyAdjustments(req: Request, res: Response) {
|
||||
try {
|
||||
const difficulty = await DifficultyAdjustmentsRepository.$getRawAdjustments(req.params.interval, true);
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString());
|
||||
res.json(difficulty.map(adj => [adj.time, adj.height, adj.difficulty, adj.adjustment]));
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getRewardStats(req: Request, res: Response) {
|
||||
try {
|
||||
const response = await mining.$getRewardStats(parseInt(req.params.blockCount, 10));
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
res.json(response);
|
||||
} catch (e) {
|
||||
res.status(500).end();
|
||||
}
|
||||
}
|
||||
|
||||
private async $getHistoricalBlockPrediction(req: Request, res: Response) {
|
||||
try {
|
||||
const blockPredictions = await mining.$getBlockPredictionsHistory(req.params.interval);
|
||||
const blockCount = await BlocksAuditsRepository.$getPredictionsCount();
|
||||
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]));
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
public async $getBlockAudit(req: Request, res: Response) {
|
||||
try {
|
||||
const audit = await BlocksAuditsRepository.$getBlockAudit(req.params.hash);
|
||||
|
||||
if (!audit) {
|
||||
res.status(204).send(`This block has not been audited.`);
|
||||
return;
|
||||
}
|
||||
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24).toUTCString());
|
||||
res.json(audit);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getHeightFromTimestamp(req: Request, res: Response) {
|
||||
try {
|
||||
const timestamp = parseInt(req.params.timestamp, 10);
|
||||
// This will prevent people from entering milliseconds etc.
|
||||
// Block timestamps are allowed to be up to 2 hours off, so 24 hours
|
||||
// will never put the maximum value before the most recent block
|
||||
const nowPlus1day = Math.floor(Date.now() / 1000) + 60 * 60 * 24;
|
||||
// Prevent non-integers that are not seconds
|
||||
if (!/^[1-9][0-9]*$/.test(req.params.timestamp) || timestamp > nowPlus1day) {
|
||||
throw new Error(`Invalid timestamp, value must be Unix seconds`);
|
||||
}
|
||||
const result = await BlocksRepository.$getBlockHeightFromTimestamp(
|
||||
timestamp,
|
||||
);
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString());
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getBlockAuditScores(req: Request, res: Response) {
|
||||
try {
|
||||
let height = req.params.height === undefined ? undefined : parseInt(req.params.height, 10);
|
||||
if (height == null) {
|
||||
height = await BlocksRepository.$mostRecentBlockHeight();
|
||||
}
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
res.json(await BlocksAuditsRepository.$getBlockAuditScores(height, height - 15));
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
public async $getBlockAuditScore(req: Request, res: Response) {
|
||||
try {
|
||||
const audit = await BlocksAuditsRepository.$getBlockAuditScore(req.params.hash);
|
||||
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24).toUTCString());
|
||||
res.json(audit || 'null');
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new MiningRoutes();
|
||||
592
backend/src/api/mining/mining.ts
Normal file
592
backend/src/api/mining/mining.ts
Normal file
@@ -0,0 +1,592 @@
|
||||
import { BlockPrice, PoolInfo, PoolStats, RewardStats } from '../../mempool.interfaces';
|
||||
import BlocksRepository from '../../repositories/BlocksRepository';
|
||||
import PoolsRepository from '../../repositories/PoolsRepository';
|
||||
import HashratesRepository from '../../repositories/HashratesRepository';
|
||||
import bitcoinClient from '../bitcoin/bitcoin-client';
|
||||
import logger from '../../logger';
|
||||
import { Common } from '../common';
|
||||
import loadingIndicators from '../loading-indicators';
|
||||
import { escape } from 'mysql2';
|
||||
import DifficultyAdjustmentsRepository from '../../repositories/DifficultyAdjustmentsRepository';
|
||||
import config from '../../config';
|
||||
import BlocksAuditsRepository from '../../repositories/BlocksAuditsRepository';
|
||||
import PricesRepository from '../../repositories/PricesRepository';
|
||||
import { bitcoinCoreApi } from '../bitcoin/bitcoin-api-factory';
|
||||
import { IEsploraApi } from '../bitcoin/esplora-api.interface';
|
||||
import database from '../../database';
|
||||
|
||||
class Mining {
|
||||
private blocksPriceIndexingRunning = false;
|
||||
public lastHashrateIndexingDate: number | null = null;
|
||||
public lastWeeklyHashrateIndexingDate: number | null = null;
|
||||
|
||||
/**
|
||||
* Get historical block predictions match rate
|
||||
*/
|
||||
public async $getBlockPredictionsHistory(interval: string | null = null): Promise<any> {
|
||||
return await BlocksAuditsRepository.$getBlockPredictionsHistory(
|
||||
this.getTimeRange(interval),
|
||||
Common.getSqlInterval(interval)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get historical block total fee
|
||||
*/
|
||||
public async $getHistoricalBlockFees(interval: string | null = null): Promise<any> {
|
||||
return await BlocksRepository.$getHistoricalBlockFees(
|
||||
this.getTimeRange(interval, 5),
|
||||
Common.getSqlInterval(interval)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get historical block rewards
|
||||
*/
|
||||
public async $getHistoricalBlockRewards(interval: string | null = null): Promise<any> {
|
||||
return await BlocksRepository.$getHistoricalBlockRewards(
|
||||
this.getTimeRange(interval),
|
||||
Common.getSqlInterval(interval)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get historical block fee rates percentiles
|
||||
*/
|
||||
public async $getHistoricalBlockFeeRates(interval: string | null = null): Promise<any> {
|
||||
return await BlocksRepository.$getHistoricalBlockFeeRates(
|
||||
this.getTimeRange(interval),
|
||||
Common.getSqlInterval(interval)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get historical block sizes
|
||||
*/
|
||||
public async $getHistoricalBlockSizes(interval: string | null = null): Promise<any> {
|
||||
return await BlocksRepository.$getHistoricalBlockSizes(
|
||||
this.getTimeRange(interval),
|
||||
Common.getSqlInterval(interval)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get historical block weights
|
||||
*/
|
||||
public async $getHistoricalBlockWeights(interval: string | null = null): Promise<any> {
|
||||
return await BlocksRepository.$getHistoricalBlockWeights(
|
||||
this.getTimeRange(interval),
|
||||
Common.getSqlInterval(interval)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate high level overview of the pool ranks and general stats
|
||||
*/
|
||||
public async $getPoolsStats(interval: string | null): Promise<object> {
|
||||
const poolsStatistics = {};
|
||||
|
||||
const poolsInfo: PoolInfo[] = await PoolsRepository.$getPoolsInfo(interval);
|
||||
const emptyBlocks: any[] = await BlocksRepository.$countEmptyBlocks(null, interval);
|
||||
|
||||
const poolsStats: PoolStats[] = [];
|
||||
let rank = 1;
|
||||
|
||||
poolsInfo.forEach((poolInfo: PoolInfo) => {
|
||||
const emptyBlocksCount = emptyBlocks.filter((emptyCount) => emptyCount.poolId === poolInfo.poolId);
|
||||
const poolStat: PoolStats = {
|
||||
poolId: poolInfo.poolId, // mysql row id
|
||||
name: poolInfo.name,
|
||||
link: poolInfo.link,
|
||||
blockCount: poolInfo.blockCount,
|
||||
rank: rank++,
|
||||
emptyBlocks: emptyBlocksCount.length > 0 ? emptyBlocksCount[0]['count'] : 0,
|
||||
slug: poolInfo.slug,
|
||||
avgMatchRate: poolInfo.avgMatchRate !== null ? Math.round(100 * poolInfo.avgMatchRate) / 100 : null,
|
||||
};
|
||||
poolsStats.push(poolStat);
|
||||
});
|
||||
|
||||
poolsStatistics['pools'] = poolsStats;
|
||||
|
||||
const blockCount: number = await BlocksRepository.$blockCount(null, interval);
|
||||
poolsStatistics['blockCount'] = blockCount;
|
||||
|
||||
const totalBlock24h: number = await BlocksRepository.$blockCount(null, '24h');
|
||||
|
||||
try {
|
||||
poolsStatistics['lastEstimatedHashrate'] = await bitcoinClient.getNetworkHashPs(totalBlock24h);
|
||||
} catch (e) {
|
||||
poolsStatistics['lastEstimatedHashrate'] = 0;
|
||||
logger.debug('Bitcoin Core is not available, using zeroed value for current hashrate', logger.tags.mining);
|
||||
}
|
||||
|
||||
return poolsStatistics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all mining pool stats for a pool
|
||||
*/
|
||||
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));
|
||||
}
|
||||
|
||||
const blockCount: number = await BlocksRepository.$blockCount(pool.id);
|
||||
const totalBlock: number = await BlocksRepository.$blockCount(null, null);
|
||||
|
||||
const blockCount24h: number = await BlocksRepository.$blockCount(pool.id, '24h');
|
||||
const totalBlock24h: number = await BlocksRepository.$blockCount(null, '24h');
|
||||
|
||||
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);
|
||||
} catch (e) {
|
||||
logger.debug('Bitcoin Core is not available, using zeroed value for current hashrate', logger.tags.mining);
|
||||
}
|
||||
|
||||
return {
|
||||
pool: pool,
|
||||
blockCount: {
|
||||
'all': blockCount,
|
||||
'24h': blockCount24h,
|
||||
'1w': blockCount1w,
|
||||
},
|
||||
blockShare: {
|
||||
'all': blockCount / totalBlock,
|
||||
'24h': blockCount24h / totalBlock24h,
|
||||
'1w': blockCount1w / totalBlock1w,
|
||||
},
|
||||
estimatedHashrate: currentEstimatedHashrate * (blockCount24h / totalBlock24h),
|
||||
reportedHashrate: null,
|
||||
avgBlockHealth: avgHealth,
|
||||
totalReward: totalReward,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get miner reward stats
|
||||
*/
|
||||
public async $getRewardStats(blockCount: number): Promise<RewardStats> {
|
||||
return await BlocksRepository.$getBlockStats(blockCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate weekly mining pool hashrate history
|
||||
*/
|
||||
public async $generatePoolHashrateHistory(): Promise<void> {
|
||||
const now = new Date();
|
||||
|
||||
// Run only if:
|
||||
// * this.lastWeeklyHashrateIndexingDate is set to null (node backend restart, reorg)
|
||||
// * we started a new week (around Monday midnight)
|
||||
const runIndexing = this.lastWeeklyHashrateIndexingDate === null ||
|
||||
now.getUTCDay() === 1 && this.lastWeeklyHashrateIndexingDate !== now.getUTCDate();
|
||||
if (!runIndexing) {
|
||||
logger.debug(`Pool hashrate history indexing is up to date, nothing to do`, logger.tags.mining);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const oldestConsecutiveBlockTimestamp = 1000 * (await BlocksRepository.$getOldestConsecutiveBlock()).timestamp;
|
||||
|
||||
const genesisBlock: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(await bitcoinClient.getBlockHash(0));
|
||||
const genesisTimestamp = genesisBlock.timestamp * 1000;
|
||||
|
||||
const indexedTimestamp = await HashratesRepository.$getWeeklyHashrateTimestamps();
|
||||
const hashrates: any[] = [];
|
||||
|
||||
const lastMonday = new Date(now.setDate(now.getDate() - (now.getDay() + 6) % 7));
|
||||
const lastMondayMidnight = this.getDateMidnight(lastMonday);
|
||||
let toTimestamp = lastMondayMidnight.getTime();
|
||||
|
||||
const totalWeekIndexed = (await BlocksRepository.$blockCount(null, null)) / 1008;
|
||||
let indexedThisRun = 0;
|
||||
let totalIndexed = 0;
|
||||
let newlyIndexed = 0;
|
||||
const startedAt = new Date().getTime() / 1000;
|
||||
let timer = new Date().getTime() / 1000;
|
||||
|
||||
logger.debug(`Indexing weekly mining pool hashrate`, logger.tags.mining);
|
||||
loadingIndicators.setProgress('weekly-hashrate-indexing', 0);
|
||||
|
||||
while (toTimestamp > genesisTimestamp && toTimestamp > oldestConsecutiveBlockTimestamp) {
|
||||
const fromTimestamp = toTimestamp - 604800000;
|
||||
|
||||
// Skip already indexed weeks
|
||||
if (indexedTimestamp.includes(toTimestamp / 1000)) {
|
||||
toTimestamp -= 604800000;
|
||||
++totalIndexed;
|
||||
continue;
|
||||
}
|
||||
|
||||
const blockStats: any = await BlocksRepository.$blockCountBetweenTimestamp(
|
||||
null, fromTimestamp / 1000, toTimestamp / 1000);
|
||||
const lastBlockHashrate = await bitcoinClient.getNetworkHashPs(blockStats.blockCount,
|
||||
blockStats.lastBlockHeight);
|
||||
|
||||
let pools = await PoolsRepository.$getPoolsInfoBetween(fromTimestamp / 1000, toTimestamp / 1000);
|
||||
const totalBlocks = pools.reduce((acc, pool) => acc + pool.blockCount, 0);
|
||||
if (totalBlocks > 0) {
|
||||
pools = pools.map((pool: any) => {
|
||||
pool.hashrate = (pool.blockCount / totalBlocks) * lastBlockHashrate;
|
||||
pool.share = (pool.blockCount / totalBlocks);
|
||||
return pool;
|
||||
});
|
||||
|
||||
for (const pool of pools) {
|
||||
hashrates.push({
|
||||
hashrateTimestamp: toTimestamp / 1000,
|
||||
avgHashrate: pool['hashrate'] ,
|
||||
poolId: pool.poolId,
|
||||
share: pool['share'],
|
||||
type: 'weekly',
|
||||
});
|
||||
}
|
||||
|
||||
newlyIndexed += hashrates.length / Math.max(1, pools.length);
|
||||
await HashratesRepository.$saveHashrates(hashrates);
|
||||
hashrates.length = 0;
|
||||
}
|
||||
|
||||
const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - timer));
|
||||
if (elapsedSeconds > 1) {
|
||||
const runningFor = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt));
|
||||
const weeksPerSeconds = Math.max(1, Math.round(indexedThisRun / elapsedSeconds));
|
||||
const progress = Math.round(totalIndexed / totalWeekIndexed * 10000) / 100;
|
||||
const formattedDate = new Date(fromTimestamp).toUTCString();
|
||||
logger.debug(`Getting weekly pool hashrate for ${formattedDate} | ~${weeksPerSeconds.toFixed(2)} weeks/sec | total: ~${totalIndexed}/${Math.round(totalWeekIndexed)} (${progress}%) | elapsed: ${runningFor} seconds`, logger.tags.mining);
|
||||
timer = new Date().getTime() / 1000;
|
||||
indexedThisRun = 0;
|
||||
loadingIndicators.setProgress('weekly-hashrate-indexing', progress, false);
|
||||
}
|
||||
|
||||
toTimestamp -= 604800000;
|
||||
++indexedThisRun;
|
||||
++totalIndexed;
|
||||
}
|
||||
this.lastWeeklyHashrateIndexingDate = new Date().getUTCDate();
|
||||
if (newlyIndexed > 0) {
|
||||
logger.info(`Weekly mining pools hashrates indexing completed: indexed ${newlyIndexed} weeks`, logger.tags.mining);
|
||||
} else {
|
||||
logger.debug(`Weekly mining pools hashrates indexing completed: indexed ${newlyIndexed} weeks`, logger.tags.mining);
|
||||
}
|
||||
loadingIndicators.setProgress('weekly-hashrate-indexing', 100);
|
||||
} catch (e) {
|
||||
loadingIndicators.setProgress('weekly-hashrate-indexing', 100);
|
||||
logger.err(`Weekly mining pools hashrates indexing failed. Trying again in 10 seconds. Reason: ${(e instanceof Error ? e.message : e)}`, logger.tags.mining);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate daily hashrate data
|
||||
*/
|
||||
public async $generateNetworkHashrateHistory(): Promise<void> {
|
||||
// We only run this once a day around midnight
|
||||
const today = new Date().getUTCDate();
|
||||
if (today === this.lastHashrateIndexingDate) {
|
||||
logger.debug(`Network hashrate history indexing is up to date, nothing to do`, logger.tags.mining);
|
||||
return;
|
||||
}
|
||||
|
||||
const oldestConsecutiveBlockTimestamp = 1000 * (await BlocksRepository.$getOldestConsecutiveBlock()).timestamp;
|
||||
|
||||
try {
|
||||
const genesisBlock: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(await bitcoinClient.getBlockHash(0));
|
||||
const genesisTimestamp = genesisBlock.timestamp * 1000;
|
||||
const indexedTimestamp = (await HashratesRepository.$getRawNetworkDailyHashrate(null)).map(hashrate => hashrate.timestamp);
|
||||
const lastMidnight = this.getDateMidnight(new Date());
|
||||
let toTimestamp = Math.round(lastMidnight.getTime());
|
||||
const hashrates: any[] = [];
|
||||
|
||||
const totalDayIndexed = (await BlocksRepository.$blockCount(null, null)) / 144;
|
||||
let indexedThisRun = 0;
|
||||
let totalIndexed = 0;
|
||||
let newlyIndexed = 0;
|
||||
const startedAt = new Date().getTime() / 1000;
|
||||
let timer = new Date().getTime() / 1000;
|
||||
|
||||
logger.debug(`Indexing daily network hashrate`, logger.tags.mining);
|
||||
loadingIndicators.setProgress('daily-hashrate-indexing', 0);
|
||||
|
||||
while (toTimestamp > genesisTimestamp && toTimestamp > oldestConsecutiveBlockTimestamp) {
|
||||
const fromTimestamp = toTimestamp - 86400000;
|
||||
|
||||
// Skip already indexed days
|
||||
if (indexedTimestamp.includes(toTimestamp / 1000)) {
|
||||
toTimestamp -= 86400000;
|
||||
++totalIndexed;
|
||||
continue;
|
||||
}
|
||||
|
||||
const blockStats: any = await BlocksRepository.$blockCountBetweenTimestamp(
|
||||
null, fromTimestamp / 1000, toTimestamp / 1000);
|
||||
const lastBlockHashrate = blockStats.blockCount === 0 ? 0 : await bitcoinClient.getNetworkHashPs(blockStats.blockCount,
|
||||
blockStats.lastBlockHeight);
|
||||
|
||||
hashrates.push({
|
||||
hashrateTimestamp: toTimestamp / 1000,
|
||||
avgHashrate: lastBlockHashrate,
|
||||
poolId: 0,
|
||||
share: 1,
|
||||
type: 'daily',
|
||||
});
|
||||
|
||||
if (hashrates.length > 10) {
|
||||
newlyIndexed += hashrates.length;
|
||||
await HashratesRepository.$saveHashrates(hashrates);
|
||||
hashrates.length = 0;
|
||||
}
|
||||
|
||||
const elapsedSeconds = Math.max(1, Math.round((new Date().getTime() / 1000) - timer));
|
||||
if (elapsedSeconds > 1) {
|
||||
const runningFor = Math.max(1, Math.round((new Date().getTime() / 1000) - startedAt));
|
||||
const daysPerSeconds = Math.max(1, Math.round(indexedThisRun / elapsedSeconds));
|
||||
const progress = Math.round(totalIndexed / totalDayIndexed * 10000) / 100;
|
||||
const formattedDate = new Date(fromTimestamp).toUTCString();
|
||||
logger.debug(`Getting network daily hashrate for ${formattedDate} | ~${daysPerSeconds.toFixed(2)} days/sec | total: ~${totalIndexed}/${Math.round(totalDayIndexed)} (${progress}%) | elapsed: ${runningFor} seconds`, logger.tags.mining);
|
||||
timer = new Date().getTime() / 1000;
|
||||
indexedThisRun = 0;
|
||||
loadingIndicators.setProgress('daily-hashrate-indexing', progress);
|
||||
}
|
||||
|
||||
toTimestamp -= 86400000;
|
||||
++indexedThisRun;
|
||||
++totalIndexed;
|
||||
}
|
||||
|
||||
// Add genesis block manually
|
||||
if (config.MEMPOOL.INDEXING_BLOCKS_AMOUNT === -1 && !indexedTimestamp.includes(genesisTimestamp / 1000)) {
|
||||
hashrates.push({
|
||||
hashrateTimestamp: genesisTimestamp / 1000,
|
||||
avgHashrate: await bitcoinClient.getNetworkHashPs(1, 1),
|
||||
poolId: 0,
|
||||
share: 1,
|
||||
type: 'daily',
|
||||
});
|
||||
}
|
||||
|
||||
newlyIndexed += hashrates.length;
|
||||
await HashratesRepository.$saveHashrates(hashrates);
|
||||
|
||||
this.lastHashrateIndexingDate = new Date().getUTCDate();
|
||||
if (newlyIndexed > 0) {
|
||||
logger.info(`Daily network hashrate indexing completed: indexed ${newlyIndexed} days`, logger.tags.mining);
|
||||
} else {
|
||||
logger.debug(`Daily network hashrate indexing completed: indexed ${newlyIndexed} days`, logger.tags.mining);
|
||||
}
|
||||
loadingIndicators.setProgress('daily-hashrate-indexing', 100);
|
||||
} catch (e) {
|
||||
loadingIndicators.setProgress('daily-hashrate-indexing', 100);
|
||||
logger.err(`Daily network hashrate indexing failed. Trying again later. Reason: ${(e instanceof Error ? e.message : e)}`, logger.tags.mining);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Index difficulty adjustments
|
||||
*/
|
||||
public async $indexDifficultyAdjustments(): Promise<void> {
|
||||
const indexedHeightsArray = await DifficultyAdjustmentsRepository.$getAdjustmentsHeights();
|
||||
const indexedHeights = {};
|
||||
for (const height of indexedHeightsArray) {
|
||||
indexedHeights[height] = true;
|
||||
}
|
||||
|
||||
const blocks: any = await BlocksRepository.$getBlocksDifficulty();
|
||||
const genesisBlock: IEsploraApi.Block = await bitcoinCoreApi.$getBlock(await bitcoinClient.getBlockHash(0));
|
||||
let currentDifficulty = genesisBlock.difficulty;
|
||||
let totalIndexed = 0;
|
||||
|
||||
if (config.MEMPOOL.INDEXING_BLOCKS_AMOUNT === -1 && indexedHeights[0] !== true) {
|
||||
await DifficultyAdjustmentsRepository.$saveAdjustments({
|
||||
time: genesisBlock.timestamp,
|
||||
height: 0,
|
||||
difficulty: currentDifficulty,
|
||||
adjustment: 0.0,
|
||||
});
|
||||
}
|
||||
|
||||
const oldestConsecutiveBlock = await BlocksRepository.$getOldestConsecutiveBlock();
|
||||
if (config.MEMPOOL.INDEXING_BLOCKS_AMOUNT !== -1) {
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
timer = new Date().getTime() / 1000;
|
||||
}
|
||||
}
|
||||
|
||||
if (totalIndexed > 0) {
|
||||
logger.info(`Indexed ${totalIndexed} difficulty adjustments`, logger.tags.mining);
|
||||
} else {
|
||||
logger.debug(`Indexed ${totalIndexed} difficulty adjustments`, logger.tags.mining);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a link between blocks and the latest price at when they were mined
|
||||
*/
|
||||
public async $indexBlockPrices(): Promise<void> {
|
||||
if (this.blocksPriceIndexingRunning === true) {
|
||||
return;
|
||||
}
|
||||
this.blocksPriceIndexingRunning = true;
|
||||
|
||||
try {
|
||||
const prices: any[] = await PricesRepository.$getPricesTimesAndId();
|
||||
const blocksWithoutPrices: any[] = await BlocksRepository.$getBlocksWithoutPrice();
|
||||
|
||||
let totalInserted = 0;
|
||||
const blocksPrices: BlockPrice[] = [];
|
||||
|
||||
for (const block of blocksWithoutPrices) {
|
||||
// Quick optimisation, out mtgox feed only goes back to 2010-07-19 02:00:00, so skip the first 68951 blocks
|
||||
if (['mainnet', 'testnet'].includes(config.MEMPOOL.NETWORK) && block.height < 68951) {
|
||||
blocksPrices.push({
|
||||
height: block.height,
|
||||
priceId: prices[0].id,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
for (const price of prices) {
|
||||
if (block.timestamp < price.time) {
|
||||
blocksPrices.push({
|
||||
height: block.height,
|
||||
priceId: price.id,
|
||||
});
|
||||
break;
|
||||
};
|
||||
}
|
||||
|
||||
if (blocksPrices.length >= 100000) {
|
||||
totalInserted += blocksPrices.length;
|
||||
let logStr = `Linking ${blocksPrices.length} blocks to their closest price`;
|
||||
if (blocksWithoutPrices.length > 200000) {
|
||||
logStr += ` | Progress ${Math.round(totalInserted / blocksWithoutPrices.length * 100)}%`;
|
||||
}
|
||||
logger.debug(logStr, logger.tags.mining);
|
||||
await BlocksRepository.$saveBlockPrices(blocksPrices);
|
||||
blocksPrices.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (blocksPrices.length > 0) {
|
||||
totalInserted += blocksPrices.length;
|
||||
let logStr = `Linking ${blocksPrices.length} blocks to their closest price`;
|
||||
if (blocksWithoutPrices.length > 200000) {
|
||||
logStr += ` | Progress ${Math.round(totalInserted / blocksWithoutPrices.length * 100)}%`;
|
||||
}
|
||||
logger.debug(logStr, logger.tags.mining);
|
||||
await BlocksRepository.$saveBlockPrices(blocksPrices);
|
||||
}
|
||||
} catch (e) {
|
||||
this.blocksPriceIndexingRunning = false;
|
||||
throw e;
|
||||
}
|
||||
|
||||
this.blocksPriceIndexingRunning = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index core coinstatsindex
|
||||
*/
|
||||
public async $indexCoinStatsIndex(): Promise<void> {
|
||||
let timer = new Date().getTime() / 1000;
|
||||
let totalIndexed = 0;
|
||||
|
||||
const blockchainInfo = await bitcoinClient.getBlockchainInfo();
|
||||
let currentBlockHeight = blockchainInfo.blocks;
|
||||
|
||||
while (currentBlockHeight > 0) {
|
||||
const indexedBlocks = await BlocksRepository.$getBlocksMissingCoinStatsIndex(
|
||||
currentBlockHeight, currentBlockHeight - 10000);
|
||||
|
||||
for (const block of indexedBlocks) {
|
||||
const txoutset = await bitcoinClient.getTxoutSetinfo('none', block.height);
|
||||
await BlocksRepository.$updateCoinStatsIndexData(block.hash, txoutset.txouts,
|
||||
Math.round(txoutset.block_info.prevout_spent * 100000000));
|
||||
++totalIndexed;
|
||||
|
||||
const elapsedSeconds = Math.max(1, new Date().getTime() / 1000 - timer);
|
||||
if (elapsedSeconds > 5) {
|
||||
logger.info(`Indexing coinstatsindex data for block #${block.height}. Indexed ${totalIndexed} blocks.`, logger.tags.mining);
|
||||
timer = new Date().getTime() / 1000;
|
||||
}
|
||||
}
|
||||
|
||||
currentBlockHeight -= 10000;
|
||||
}
|
||||
|
||||
if (totalIndexed) {
|
||||
logger.info(`Indexing missing coinstatsindex data completed`, logger.tags.mining);
|
||||
}
|
||||
}
|
||||
|
||||
private getDateMidnight(date: Date): Date {
|
||||
date.setUTCHours(0);
|
||||
date.setUTCMinutes(0);
|
||||
date.setUTCSeconds(0);
|
||||
date.setUTCMilliseconds(0);
|
||||
|
||||
return date;
|
||||
}
|
||||
|
||||
private getTimeRange(interval: string | null, scale = 1): number {
|
||||
switch (interval) {
|
||||
case '4y': return 43200 * scale; // 12h
|
||||
case '3y': return 43200 * scale; // 12h
|
||||
case '2y': return 28800 * scale; // 8h
|
||||
case '1y': return 28800 * scale; // 8h
|
||||
case '6m': return 10800 * scale; // 3h
|
||||
case '3m': return 7200 * scale; // 2h
|
||||
case '1m': return 1800 * scale; // 30min
|
||||
case '1w': return 300 * scale; // 5min
|
||||
case '3d': return 1 * scale;
|
||||
case '24h': return 1 * scale;
|
||||
default: return 86400 * scale;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new Mining();
|
||||
@@ -1,206 +1,162 @@
|
||||
import DB from '../database';
|
||||
import logger from '../logger';
|
||||
import config from '../config';
|
||||
|
||||
interface Pool {
|
||||
name: string;
|
||||
link: string;
|
||||
regexes: string[];
|
||||
addresses: string[];
|
||||
slug: string;
|
||||
}
|
||||
import PoolsRepository from '../repositories/PoolsRepository';
|
||||
import { PoolTag } from '../mempool.interfaces';
|
||||
import diskCache from './disk-cache';
|
||||
|
||||
class PoolsParser {
|
||||
miningPools: any[] = [];
|
||||
unknownPool: any = {
|
||||
'name': "Unknown",
|
||||
'link': "https://learnmeabitcoin.com/technical/coinbase-transaction",
|
||||
'regexes': "[]",
|
||||
'addresses': "[]",
|
||||
'id': 0,
|
||||
'name': 'Unknown',
|
||||
'link': 'https://learnmeabitcoin.com/technical/coinbase-transaction',
|
||||
'regexes': '[]',
|
||||
'addresses': '[]',
|
||||
'slug': 'unknown'
|
||||
};
|
||||
slugWarnFlag = false;
|
||||
private uniqueLogs: string[] = [];
|
||||
|
||||
private uniqueLog(loggerFunction: any, msg: string): void {
|
||||
if (this.uniqueLogs.includes(msg)) {
|
||||
return;
|
||||
}
|
||||
this.uniqueLogs.push(msg);
|
||||
loggerFunction(msg);
|
||||
}
|
||||
|
||||
public setMiningPools(pools): void {
|
||||
for (const pool of pools) {
|
||||
pool.regexes = pool.tags;
|
||||
pool.slug = pool.name.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
||||
delete(pool.tags);
|
||||
}
|
||||
this.miningPools = pools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the pools.json file, consolidate the data and dump it into the database
|
||||
* Populate our db with updated mining pool definition
|
||||
* @param pools
|
||||
*/
|
||||
public async migratePoolsJson(poolsJson: object) {
|
||||
if (['mainnet', 'testnet', 'signet'].includes(config.MEMPOOL.NETWORK) === false) {
|
||||
return;
|
||||
}
|
||||
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();
|
||||
|
||||
// First we save every entries without paying attention to pool duplication
|
||||
const poolsDuplicated: Pool[] = [];
|
||||
await this.$insertUnknownPool();
|
||||
|
||||
logger.debug('Parse coinbase_tags');
|
||||
const coinbaseTags = Object.entries(poolsJson['coinbase_tags']);
|
||||
for (let i = 0; i < coinbaseTags.length; ++i) {
|
||||
poolsDuplicated.push({
|
||||
'name': (<Pool>coinbaseTags[i][1]).name,
|
||||
'link': (<Pool>coinbaseTags[i][1]).link,
|
||||
'regexes': [coinbaseTags[i][0]],
|
||||
'addresses': [],
|
||||
'slug': ''
|
||||
});
|
||||
}
|
||||
logger.debug('Parse payout_addresses');
|
||||
const addressesTags = Object.entries(poolsJson['payout_addresses']);
|
||||
for (let i = 0; i < addressesTags.length; ++i) {
|
||||
poolsDuplicated.push({
|
||||
'name': (<Pool>addressesTags[i][1]).name,
|
||||
'link': (<Pool>addressesTags[i][1]).link,
|
||||
'regexes': [],
|
||||
'addresses': [addressesTags[i][0]],
|
||||
'slug': ''
|
||||
});
|
||||
}
|
||||
|
||||
// Then, we find unique mining pool names
|
||||
logger.debug('Identify unique mining pools');
|
||||
const poolNames: string[] = [];
|
||||
for (let i = 0; i < poolsDuplicated.length; ++i) {
|
||||
if (poolNames.indexOf(poolsDuplicated[i].name) === -1) {
|
||||
poolNames.push(poolsDuplicated[i].name);
|
||||
for (const pool of this.miningPools) {
|
||||
if (!pool.id) {
|
||||
logger.info(`Mining pool ${pool.name} has no unique 'id' defined. Skipping.`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
logger.debug(`Found ${poolNames.length} unique mining pools`);
|
||||
|
||||
// Get existing pools from the db
|
||||
let existingPools;
|
||||
try {
|
||||
if (config.DATABASE.ENABLED === true) {
|
||||
[existingPools] = await DB.query({ sql: 'SELECT * FROM pools;', timeout: 120000 });
|
||||
const poolDB = await PoolsRepository.$getPoolByUniqueId(pool.id, false);
|
||||
if (!poolDB) {
|
||||
// New mining pool
|
||||
const slug = pool.name.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
||||
logger.debug(`Inserting new mining pool ${pool.name}`);
|
||||
await PoolsRepository.$insertNewMiningPool(pool, slug);
|
||||
await this.$deleteUnknownBlocks();
|
||||
} else {
|
||||
existingPools = [];
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err('Cannot get existing pools from the database, skipping pools.json import');
|
||||
return;
|
||||
}
|
||||
|
||||
this.miningPools = [];
|
||||
|
||||
// Finally, we generate the final consolidated pools data
|
||||
const finalPoolDataAdd: Pool[] = [];
|
||||
const finalPoolDataUpdate: Pool[] = [];
|
||||
for (let i = 0; i < poolNames.length; ++i) {
|
||||
let allAddresses: string[] = [];
|
||||
let allRegexes: string[] = [];
|
||||
const match = poolsDuplicated.filter((pool: Pool) => pool.name === poolNames[i]);
|
||||
|
||||
for (let y = 0; y < match.length; ++y) {
|
||||
allAddresses = allAddresses.concat(match[y].addresses);
|
||||
allRegexes = allRegexes.concat(match[y].regexes);
|
||||
}
|
||||
|
||||
const finalPoolName = poolNames[i].replace(`'`, `''`); // To support single quote in names when doing db queries
|
||||
|
||||
let slug: string | undefined;
|
||||
try {
|
||||
slug = poolsJson['slugs'][poolNames[i]];
|
||||
} catch (e) {
|
||||
if (this.slugWarnFlag === false) {
|
||||
logger.warn(`pools.json does not seem to contain the 'slugs' object`);
|
||||
this.slugWarnFlag = true;
|
||||
if (poolDB.name !== pool.name) {
|
||||
// Pool has been renamed
|
||||
const newSlug = pool.name.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
||||
logger.warn(`Renaming ${poolDB.name} mining pool to ${pool.name}. Slug has been updated. Maybe you want to make a redirection from 'https://mempool.space/mining/pool/${poolDB.slug}' to 'https://mempool.space/mining/pool/${newSlug}`);
|
||||
await PoolsRepository.$renameMiningPool(poolDB.id, newSlug, pool.name);
|
||||
}
|
||||
if (poolDB.link !== pool.link) {
|
||||
// Pool link has changed
|
||||
logger.debug(`Updating link for ${pool.name} mining pool`);
|
||||
await PoolsRepository.$updateMiningPoolLink(poolDB.id, pool.link);
|
||||
}
|
||||
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`);
|
||||
await PoolsRepository.$updateMiningPoolTags(poolDB.id, pool.addresses, pool.regexes);
|
||||
await this.$deleteBlocksForPool(poolDB);
|
||||
}
|
||||
}
|
||||
|
||||
if (slug === undefined) {
|
||||
// Only keep alphanumerical
|
||||
slug = poolNames[i].replace(/[^a-z0-9]/gi, '').toLowerCase();
|
||||
logger.warn(`No slug found for '${poolNames[i]}', generating it => '${slug}'`);
|
||||
}
|
||||
|
||||
const poolObj = {
|
||||
'name': finalPoolName,
|
||||
'link': match[0].link,
|
||||
'regexes': allRegexes,
|
||||
'addresses': allAddresses,
|
||||
'slug': slug
|
||||
};
|
||||
|
||||
if (existingPools.find((pool) => pool.name === poolNames[i]) !== undefined) {
|
||||
finalPoolDataUpdate.push(poolObj);
|
||||
} else {
|
||||
logger.debug(`Add '${finalPoolName}' mining pool`);
|
||||
finalPoolDataAdd.push(poolObj);
|
||||
}
|
||||
|
||||
this.miningPools.push({
|
||||
'name': finalPoolName,
|
||||
'link': match[0].link,
|
||||
'regexes': JSON.stringify(allRegexes),
|
||||
'addresses': JSON.stringify(allAddresses),
|
||||
'slug': slug
|
||||
});
|
||||
}
|
||||
|
||||
if (config.DATABASE.ENABLED === false) { // Don't run db operations
|
||||
logger.info('Mining pools.json import completed (no database)');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(`Update pools table now`);
|
||||
|
||||
// Add new mining pools into the database
|
||||
let queryAdd: string = 'INSERT INTO pools(name, link, regexes, addresses, slug) VALUES ';
|
||||
for (let i = 0; i < finalPoolDataAdd.length; ++i) {
|
||||
queryAdd += `('${finalPoolDataAdd[i].name}', '${finalPoolDataAdd[i].link}',
|
||||
'${JSON.stringify(finalPoolDataAdd[i].regexes)}', '${JSON.stringify(finalPoolDataAdd[i].addresses)}',
|
||||
${JSON.stringify(finalPoolDataAdd[i].slug)}),`;
|
||||
}
|
||||
queryAdd = queryAdd.slice(0, -1) + ';';
|
||||
|
||||
// Updated existing mining pools in the database
|
||||
const updateQueries: string[] = [];
|
||||
for (let i = 0; i < finalPoolDataUpdate.length; ++i) {
|
||||
updateQueries.push(`
|
||||
UPDATE pools
|
||||
SET name='${finalPoolDataUpdate[i].name}', link='${finalPoolDataUpdate[i].link}',
|
||||
regexes='${JSON.stringify(finalPoolDataUpdate[i].regexes)}', addresses='${JSON.stringify(finalPoolDataUpdate[i].addresses)}',
|
||||
slug='${finalPoolDataUpdate[i].slug}'
|
||||
WHERE name='${finalPoolDataUpdate[i].name}'
|
||||
;`);
|
||||
}
|
||||
|
||||
try {
|
||||
if (finalPoolDataAdd.length > 0) {
|
||||
await DB.query({ sql: queryAdd, timeout: 120000 });
|
||||
}
|
||||
for (const query of updateQueries) {
|
||||
await DB.query({ sql: query, timeout: 120000 });
|
||||
}
|
||||
await this.insertUnknownPool();
|
||||
logger.info('Mining pools.json import completed');
|
||||
} catch (e) {
|
||||
logger.err(`Cannot import pools in the database`);
|
||||
throw e;
|
||||
}
|
||||
logger.info('Mining pools-v2.json import completed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually add the 'unknown pool'
|
||||
*/
|
||||
private async insertUnknownPool() {
|
||||
public async $insertUnknownPool(): Promise<void> {
|
||||
if (!config.DATABASE.ENABLED) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const [rows]: any[] = await DB.query({ sql: 'SELECT name from pools where name="Unknown"', timeout: 120000 });
|
||||
if (rows.length === 0) {
|
||||
await DB.query({
|
||||
sql: `INSERT INTO pools(name, link, regexes, addresses, slug)
|
||||
VALUES("Unknown", "https://learnmeabitcoin.com/technical/coinbase-transaction", "[]", "[]", "unknown");
|
||||
sql: `INSERT INTO pools(name, link, regexes, addresses, slug, unique_id)
|
||||
VALUES("${this.unknownPool.name}", "${this.unknownPool.link}", "[]", "[]", "${this.unknownPool.slug}", 0);
|
||||
`});
|
||||
} else {
|
||||
await DB.query(`UPDATE pools
|
||||
SET name='Unknown', link='https://learnmeabitcoin.com/technical/coinbase-transaction',
|
||||
SET name='${this.unknownPool.name}', link='${this.unknownPool.link}',
|
||||
regexes='[]', addresses='[]',
|
||||
slug='unknown'
|
||||
WHERE name='Unknown'
|
||||
slug='${this.unknownPool.slug}',
|
||||
unique_id=0
|
||||
WHERE slug='${this.unknownPool.slug}'
|
||||
`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err('Unable to insert "Unknown" mining pool');
|
||||
logger.err(`Unable to insert or update "Unknown" mining pool. Reason: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete indexed blocks for an updated mining pool
|
||||
*
|
||||
* @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(`
|
||||
SELECT height
|
||||
FROM blocks
|
||||
WHERE pool_id = ?
|
||||
ORDER BY height
|
||||
LIMIT 1`,
|
||||
[pool.id]
|
||||
);
|
||||
const oldestBlockHeight = oldestPoolBlock.length ?? 0 > 0 ? oldestPoolBlock[0].height : 130635;
|
||||
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(`
|
||||
DELETE FROM blocks
|
||||
WHERE pool_id = ? AND height >= ${oldestBlockHeight}`,
|
||||
[unknownPool[0].id]
|
||||
);
|
||||
logger.notice(`Deleting blocks from ${pool.name} mining pool for re-indexing`);
|
||||
await DB.query(`
|
||||
DELETE FROM blocks
|
||||
WHERE pool_id = ?`,
|
||||
[pool.id]
|
||||
);
|
||||
}
|
||||
|
||||
private async $deleteUnknownBlocks(): Promise<void> {
|
||||
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`);
|
||||
await DB.query(`
|
||||
DELETE FROM blocks
|
||||
WHERE pool_id = ? AND height >= 130635`,
|
||||
[unknownPool[0].id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default new PoolsParser();
|
||||
|
||||
@@ -1,31 +1,62 @@
|
||||
export interface CachedRbf {
|
||||
txid: string;
|
||||
expires: Date;
|
||||
}
|
||||
import { TransactionExtended } from "../mempool.interfaces";
|
||||
|
||||
class RbfCache {
|
||||
private cache: { [txid: string]: CachedRbf; } = {};
|
||||
private replacedBy: { [txid: string]: string; } = {};
|
||||
private replaces: { [txid: string]: string[] } = {};
|
||||
private txs: { [txid: string]: TransactionExtended } = {};
|
||||
private expiring: { [txid: string]: Date } = {};
|
||||
|
||||
constructor() {
|
||||
setInterval(this.cleanup.bind(this), 1000 * 60 * 60);
|
||||
}
|
||||
|
||||
public add(replacedTxId: string, newTxId: string): void {
|
||||
this.cache[replacedTxId] = {
|
||||
expires: new Date(Date.now() + 1000 * 604800), // 1 week
|
||||
txid: newTxId,
|
||||
};
|
||||
public add(replacedTx: TransactionExtended, newTxId: string): void {
|
||||
this.replacedBy[replacedTx.txid] = newTxId;
|
||||
this.txs[replacedTx.txid] = replacedTx;
|
||||
if (!this.replaces[newTxId]) {
|
||||
this.replaces[newTxId] = [];
|
||||
}
|
||||
this.replaces[newTxId].push(replacedTx.txid);
|
||||
}
|
||||
|
||||
public get(txId: string): CachedRbf | undefined {
|
||||
return this.cache[txId];
|
||||
public getReplacedBy(txId: string): string | undefined {
|
||||
return this.replacedBy[txId];
|
||||
}
|
||||
|
||||
public getReplaces(txId: string): string[] | undefined {
|
||||
return this.replaces[txId];
|
||||
}
|
||||
|
||||
public getTx(txId: string): TransactionExtended | undefined {
|
||||
return this.txs[txId];
|
||||
}
|
||||
|
||||
// flag a transaction as removed from the mempool
|
||||
public evict(txid): void {
|
||||
this.expiring[txid] = new Date(Date.now() + 1000 * 86400); // 24 hours
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
const currentDate = new Date();
|
||||
for (const c in this.cache) {
|
||||
if (this.cache[c].expires < currentDate) {
|
||||
delete this.cache[c];
|
||||
for (const txid in this.expiring) {
|
||||
if (this.expiring[txid] < currentDate) {
|
||||
delete this.expiring[txid];
|
||||
this.remove(txid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// recursively remove prior versions from the cache
|
||||
delete this.replacedBy[tx];
|
||||
delete this.txs[tx];
|
||||
this.remove(tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,160 +1,11 @@
|
||||
import memPool from './mempool';
|
||||
import DB from '../database';
|
||||
import logger from '../logger';
|
||||
import DB from '../../database';
|
||||
import logger from '../../logger';
|
||||
import { Statistic, OptimizedStatistic } from '../../mempool.interfaces';
|
||||
|
||||
import { Statistic, TransactionExtended, OptimizedStatistic } from '../mempool.interfaces';
|
||||
import config from '../config';
|
||||
import { Common } from './common';
|
||||
|
||||
class Statistics {
|
||||
protected intervalTimer: NodeJS.Timer | undefined;
|
||||
protected newStatisticsEntryCallback: ((stats: OptimizedStatistic) => void) | undefined;
|
||||
class StatisticsApi {
|
||||
protected queryTimeout = 120000;
|
||||
|
||||
public setNewStatisticsEntryCallback(fn: (stats: OptimizedStatistic) => void) {
|
||||
this.newStatisticsEntryCallback = fn;
|
||||
}
|
||||
|
||||
constructor() { }
|
||||
|
||||
public startStatistics(): void {
|
||||
logger.info('Starting statistics service');
|
||||
|
||||
const now = new Date();
|
||||
const nextInterval = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(),
|
||||
Math.floor(now.getMinutes() / 1) * 1 + 1, 0, 0);
|
||||
const difference = nextInterval.getTime() - now.getTime();
|
||||
|
||||
setTimeout(() => {
|
||||
this.runStatistics();
|
||||
this.intervalTimer = setInterval(() => {
|
||||
this.runStatistics();
|
||||
}, 1 * 60 * 1000);
|
||||
}, difference);
|
||||
}
|
||||
|
||||
private async runStatistics(): Promise<void> {
|
||||
if (!memPool.isInSync()) {
|
||||
return;
|
||||
}
|
||||
const currentMempool = memPool.getMempool();
|
||||
const txPerSecond = memPool.getTxPerSecond();
|
||||
const vBytesPerSecond = memPool.getVBytesPerSecond();
|
||||
|
||||
logger.debug('Running statistics');
|
||||
|
||||
let memPoolArray: TransactionExtended[] = [];
|
||||
for (const i in currentMempool) {
|
||||
if (currentMempool.hasOwnProperty(i)) {
|
||||
memPoolArray.push(currentMempool[i]);
|
||||
}
|
||||
}
|
||||
// Remove 0 and undefined
|
||||
memPoolArray = memPoolArray.filter((tx) => tx.effectiveFeePerVsize);
|
||||
|
||||
if (!memPoolArray.length) {
|
||||
try {
|
||||
const insertIdZeroed = await this.$createZeroedStatistic();
|
||||
if (this.newStatisticsEntryCallback && insertIdZeroed) {
|
||||
const newStats = await this.$get(insertIdZeroed);
|
||||
if (newStats) {
|
||||
this.newStatisticsEntryCallback(newStats);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err('Unable to insert zeroed statistics. ' + e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
memPoolArray.sort((a, b) => a.effectiveFeePerVsize - b.effectiveFeePerVsize);
|
||||
const totalWeight = memPoolArray.map((tx) => tx.vsize).reduce((acc, curr) => acc + curr) * 4;
|
||||
const totalFee = memPoolArray.map((tx) => tx.fee).reduce((acc, curr) => acc + curr);
|
||||
|
||||
const logFees = [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 175, 200,
|
||||
250, 300, 350, 400, 500, 600, 700, 800, 900, 1000, 1200, 1400, 1600, 1800, 2000];
|
||||
|
||||
const weightVsizeFees: { [feePerWU: number]: number } = {};
|
||||
const lastItem = logFees.length - 1;
|
||||
|
||||
memPoolArray.forEach((transaction) => {
|
||||
for (let i = 0; i < logFees.length; i++) {
|
||||
if (
|
||||
(Common.isLiquid() && (i === lastItem || transaction.effectiveFeePerVsize * 10 < logFees[i + 1]))
|
||||
||
|
||||
(!Common.isLiquid() && (i === lastItem || transaction.effectiveFeePerVsize < logFees[i + 1]))
|
||||
) {
|
||||
if (weightVsizeFees[logFees[i]]) {
|
||||
weightVsizeFees[logFees[i]] += transaction.vsize;
|
||||
} else {
|
||||
weightVsizeFees[logFees[i]] = transaction.vsize;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const insertId = await this.$create({
|
||||
added: 'NOW()',
|
||||
unconfirmed_transactions: memPoolArray.length,
|
||||
tx_per_second: txPerSecond,
|
||||
vbytes_per_second: Math.round(vBytesPerSecond),
|
||||
mempool_byte_weight: totalWeight,
|
||||
total_fee: totalFee,
|
||||
fee_data: '',
|
||||
vsize_1: weightVsizeFees['1'] || 0,
|
||||
vsize_2: weightVsizeFees['2'] || 0,
|
||||
vsize_3: weightVsizeFees['3'] || 0,
|
||||
vsize_4: weightVsizeFees['4'] || 0,
|
||||
vsize_5: weightVsizeFees['5'] || 0,
|
||||
vsize_6: weightVsizeFees['6'] || 0,
|
||||
vsize_8: weightVsizeFees['8'] || 0,
|
||||
vsize_10: weightVsizeFees['10'] || 0,
|
||||
vsize_12: weightVsizeFees['12'] || 0,
|
||||
vsize_15: weightVsizeFees['15'] || 0,
|
||||
vsize_20: weightVsizeFees['20'] || 0,
|
||||
vsize_30: weightVsizeFees['30'] || 0,
|
||||
vsize_40: weightVsizeFees['40'] || 0,
|
||||
vsize_50: weightVsizeFees['50'] || 0,
|
||||
vsize_60: weightVsizeFees['60'] || 0,
|
||||
vsize_70: weightVsizeFees['70'] || 0,
|
||||
vsize_80: weightVsizeFees['80'] || 0,
|
||||
vsize_90: weightVsizeFees['90'] || 0,
|
||||
vsize_100: weightVsizeFees['100'] || 0,
|
||||
vsize_125: weightVsizeFees['125'] || 0,
|
||||
vsize_150: weightVsizeFees['150'] || 0,
|
||||
vsize_175: weightVsizeFees['175'] || 0,
|
||||
vsize_200: weightVsizeFees['200'] || 0,
|
||||
vsize_250: weightVsizeFees['250'] || 0,
|
||||
vsize_300: weightVsizeFees['300'] || 0,
|
||||
vsize_350: weightVsizeFees['350'] || 0,
|
||||
vsize_400: weightVsizeFees['400'] || 0,
|
||||
vsize_500: weightVsizeFees['500'] || 0,
|
||||
vsize_600: weightVsizeFees['600'] || 0,
|
||||
vsize_700: weightVsizeFees['700'] || 0,
|
||||
vsize_800: weightVsizeFees['800'] || 0,
|
||||
vsize_900: weightVsizeFees['900'] || 0,
|
||||
vsize_1000: weightVsizeFees['1000'] || 0,
|
||||
vsize_1200: weightVsizeFees['1200'] || 0,
|
||||
vsize_1400: weightVsizeFees['1400'] || 0,
|
||||
vsize_1600: weightVsizeFees['1600'] || 0,
|
||||
vsize_1800: weightVsizeFees['1800'] || 0,
|
||||
vsize_2000: weightVsizeFees['2000'] || 0,
|
||||
});
|
||||
|
||||
if (this.newStatisticsEntryCallback && insertId) {
|
||||
const newStats = await this.$get(insertId);
|
||||
if (newStats) {
|
||||
this.newStatisticsEntryCallback(newStats);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err('Unable to insert statistics. ' + e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $createZeroedStatistic(): Promise<number | undefined> {
|
||||
public async $createZeroedStatistic(): Promise<number | undefined> {
|
||||
try {
|
||||
const query = `INSERT INTO statistics(
|
||||
added,
|
||||
@@ -212,7 +63,7 @@ class Statistics {
|
||||
}
|
||||
}
|
||||
|
||||
private async $create(statistics: Statistic): Promise<number | undefined> {
|
||||
public async $create(statistics: Statistic): Promise<number | undefined> {
|
||||
try {
|
||||
const query = `INSERT INTO statistics(
|
||||
added,
|
||||
@@ -413,7 +264,7 @@ class Statistics {
|
||||
ORDER BY statistics.added DESC;`;
|
||||
}
|
||||
|
||||
private async $get(id: number): Promise<OptimizedStatistic | undefined> {
|
||||
public async $get(id: number): Promise<OptimizedStatistic | undefined> {
|
||||
try {
|
||||
const query = `SELECT *, UNIX_TIMESTAMP(added) as added FROM statistics WHERE id = ?`;
|
||||
const [rows] = await DB.query(query, [id]);
|
||||
@@ -524,6 +375,17 @@ class Statistics {
|
||||
}
|
||||
}
|
||||
|
||||
public async $list4Y(): Promise<OptimizedStatistic[]> {
|
||||
try {
|
||||
const query = this.getQueryForDays(43200, '4 YEAR'); // 12h interval
|
||||
const [rows] = await DB.query({ sql: query, timeout: this.queryTimeout });
|
||||
return this.mapStatisticToOptimizedStatistic(rows as Statistic[]);
|
||||
} catch (e) {
|
||||
logger.err('$list4Y() error' + (e instanceof Error ? e.message : e));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private mapStatisticToOptimizedStatistic(statistic: Statistic[]): OptimizedStatistic[] {
|
||||
return statistic.map((s) => {
|
||||
return {
|
||||
@@ -574,7 +436,6 @@ class Statistics {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default new Statistics();
|
||||
export default new StatisticsApi();
|
||||
71
backend/src/api/statistics/statistics.routes.ts
Normal file
71
backend/src/api/statistics/statistics.routes.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Application, Request, Response } from 'express';
|
||||
import config from '../../config';
|
||||
import statisticsApi from './statistics-api';
|
||||
|
||||
class StatisticsRoutes {
|
||||
public initRoutes(app: Application) {
|
||||
app
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/2h', this.$getStatisticsByTime.bind(this, '2h'))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/24h', this.$getStatisticsByTime.bind(this, '24h'))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/1w', this.$getStatisticsByTime.bind(this, '1w'))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/1m', this.$getStatisticsByTime.bind(this, '1m'))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/3m', this.$getStatisticsByTime.bind(this, '3m'))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/6m', this.$getStatisticsByTime.bind(this, '6m'))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/1y', this.$getStatisticsByTime.bind(this, '1y'))
|
||||
.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'))
|
||||
;
|
||||
}
|
||||
|
||||
private async $getStatisticsByTime(time: '2h' | '24h' | '1w' | '1m' | '3m' | '6m' | '1y' | '2y' | '3y' | '4y', req: Request, res: Response) {
|
||||
res.header('Pragma', 'public');
|
||||
res.header('Cache-control', 'public');
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString());
|
||||
|
||||
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());
|
||||
break;
|
||||
case '1w':
|
||||
result = await statisticsApi.$list1W();
|
||||
break;
|
||||
case '1m':
|
||||
result = await statisticsApi.$list1M();
|
||||
break;
|
||||
case '3m':
|
||||
result = await statisticsApi.$list3M();
|
||||
break;
|
||||
case '6m':
|
||||
result = await statisticsApi.$list6M();
|
||||
break;
|
||||
case '1y':
|
||||
result = await statisticsApi.$list1Y();
|
||||
break;
|
||||
case '2y':
|
||||
result = await statisticsApi.$list2Y();
|
||||
break;
|
||||
case '3y':
|
||||
result = await statisticsApi.$list3Y();
|
||||
break;
|
||||
case '4y':
|
||||
result = await statisticsApi.$list4Y();
|
||||
break;
|
||||
default:
|
||||
result = await statisticsApi.$list2H();
|
||||
}
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new StatisticsRoutes();
|
||||
153
backend/src/api/statistics/statistics.ts
Normal file
153
backend/src/api/statistics/statistics.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import memPool from '../mempool';
|
||||
import logger from '../../logger';
|
||||
import { TransactionExtended, OptimizedStatistic } from '../../mempool.interfaces';
|
||||
import { Common } from '../common';
|
||||
import statisticsApi from './statistics-api';
|
||||
|
||||
class Statistics {
|
||||
protected intervalTimer: NodeJS.Timer | undefined;
|
||||
protected newStatisticsEntryCallback: ((stats: OptimizedStatistic) => void) | undefined;
|
||||
|
||||
public setNewStatisticsEntryCallback(fn: (stats: OptimizedStatistic) => void) {
|
||||
this.newStatisticsEntryCallback = fn;
|
||||
}
|
||||
|
||||
public startStatistics(): void {
|
||||
logger.info('Starting statistics service');
|
||||
|
||||
const now = new Date();
|
||||
const nextInterval = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(),
|
||||
Math.floor(now.getMinutes() / 1) * 1 + 1, 0, 0);
|
||||
const difference = nextInterval.getTime() - now.getTime();
|
||||
|
||||
setTimeout(() => {
|
||||
this.runStatistics();
|
||||
this.intervalTimer = setInterval(() => {
|
||||
this.runStatistics();
|
||||
}, 1 * 60 * 1000);
|
||||
}, difference);
|
||||
}
|
||||
|
||||
private async runStatistics(): Promise<void> {
|
||||
if (!memPool.isInSync()) {
|
||||
return;
|
||||
}
|
||||
const currentMempool = memPool.getMempool();
|
||||
const txPerSecond = memPool.getTxPerSecond();
|
||||
const vBytesPerSecond = memPool.getVBytesPerSecond();
|
||||
|
||||
logger.debug('Running statistics');
|
||||
|
||||
let memPoolArray: TransactionExtended[] = [];
|
||||
for (const i in currentMempool) {
|
||||
if (currentMempool.hasOwnProperty(i)) {
|
||||
memPoolArray.push(currentMempool[i]);
|
||||
}
|
||||
}
|
||||
// Remove 0 and undefined
|
||||
memPoolArray = memPoolArray.filter((tx) => tx.effectiveFeePerVsize);
|
||||
|
||||
if (!memPoolArray.length) {
|
||||
try {
|
||||
const insertIdZeroed = await statisticsApi.$createZeroedStatistic();
|
||||
if (this.newStatisticsEntryCallback && insertIdZeroed) {
|
||||
const newStats = await statisticsApi.$get(insertIdZeroed);
|
||||
if (newStats) {
|
||||
this.newStatisticsEntryCallback(newStats);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err('Unable to insert zeroed statistics. ' + e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
memPoolArray.sort((a, b) => a.effectiveFeePerVsize - b.effectiveFeePerVsize);
|
||||
const totalWeight = memPoolArray.map((tx) => tx.vsize).reduce((acc, curr) => acc + curr) * 4;
|
||||
const totalFee = memPoolArray.map((tx) => tx.fee).reduce((acc, curr) => acc + curr);
|
||||
|
||||
const logFees = [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 175, 200,
|
||||
250, 300, 350, 400, 500, 600, 700, 800, 900, 1000, 1200, 1400, 1600, 1800, 2000];
|
||||
|
||||
const weightVsizeFees: { [feePerWU: number]: number } = {};
|
||||
const lastItem = logFees.length - 1;
|
||||
|
||||
memPoolArray.forEach((transaction) => {
|
||||
for (let i = 0; i < logFees.length; i++) {
|
||||
if (
|
||||
(Common.isLiquid() && (i === lastItem || transaction.effectiveFeePerVsize * 10 < logFees[i + 1]))
|
||||
||
|
||||
(!Common.isLiquid() && (i === lastItem || transaction.effectiveFeePerVsize < logFees[i + 1]))
|
||||
) {
|
||||
if (weightVsizeFees[logFees[i]]) {
|
||||
weightVsizeFees[logFees[i]] += transaction.vsize;
|
||||
} else {
|
||||
weightVsizeFees[logFees[i]] = transaction.vsize;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const insertId = await statisticsApi.$create({
|
||||
added: 'NOW()',
|
||||
unconfirmed_transactions: memPoolArray.length,
|
||||
tx_per_second: txPerSecond,
|
||||
vbytes_per_second: Math.round(vBytesPerSecond),
|
||||
mempool_byte_weight: totalWeight,
|
||||
total_fee: totalFee,
|
||||
fee_data: '',
|
||||
vsize_1: weightVsizeFees['1'] || 0,
|
||||
vsize_2: weightVsizeFees['2'] || 0,
|
||||
vsize_3: weightVsizeFees['3'] || 0,
|
||||
vsize_4: weightVsizeFees['4'] || 0,
|
||||
vsize_5: weightVsizeFees['5'] || 0,
|
||||
vsize_6: weightVsizeFees['6'] || 0,
|
||||
vsize_8: weightVsizeFees['8'] || 0,
|
||||
vsize_10: weightVsizeFees['10'] || 0,
|
||||
vsize_12: weightVsizeFees['12'] || 0,
|
||||
vsize_15: weightVsizeFees['15'] || 0,
|
||||
vsize_20: weightVsizeFees['20'] || 0,
|
||||
vsize_30: weightVsizeFees['30'] || 0,
|
||||
vsize_40: weightVsizeFees['40'] || 0,
|
||||
vsize_50: weightVsizeFees['50'] || 0,
|
||||
vsize_60: weightVsizeFees['60'] || 0,
|
||||
vsize_70: weightVsizeFees['70'] || 0,
|
||||
vsize_80: weightVsizeFees['80'] || 0,
|
||||
vsize_90: weightVsizeFees['90'] || 0,
|
||||
vsize_100: weightVsizeFees['100'] || 0,
|
||||
vsize_125: weightVsizeFees['125'] || 0,
|
||||
vsize_150: weightVsizeFees['150'] || 0,
|
||||
vsize_175: weightVsizeFees['175'] || 0,
|
||||
vsize_200: weightVsizeFees['200'] || 0,
|
||||
vsize_250: weightVsizeFees['250'] || 0,
|
||||
vsize_300: weightVsizeFees['300'] || 0,
|
||||
vsize_350: weightVsizeFees['350'] || 0,
|
||||
vsize_400: weightVsizeFees['400'] || 0,
|
||||
vsize_500: weightVsizeFees['500'] || 0,
|
||||
vsize_600: weightVsizeFees['600'] || 0,
|
||||
vsize_700: weightVsizeFees['700'] || 0,
|
||||
vsize_800: weightVsizeFees['800'] || 0,
|
||||
vsize_900: weightVsizeFees['900'] || 0,
|
||||
vsize_1000: weightVsizeFees['1000'] || 0,
|
||||
vsize_1200: weightVsizeFees['1200'] || 0,
|
||||
vsize_1400: weightVsizeFees['1400'] || 0,
|
||||
vsize_1600: weightVsizeFees['1600'] || 0,
|
||||
vsize_1800: weightVsizeFees['1800'] || 0,
|
||||
vsize_2000: weightVsizeFees['2000'] || 0,
|
||||
});
|
||||
|
||||
if (this.newStatisticsEntryCallback && insertId) {
|
||||
const newStats = await statisticsApi.$get(insertId);
|
||||
if (newStats) {
|
||||
this.newStatisticsEntryCallback(newStats);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err('Unable to insert statistics. ' + e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new Statistics();
|
||||
@@ -1,8 +1,7 @@
|
||||
import bitcoinApi from './bitcoin/bitcoin-api-factory';
|
||||
import { TransactionExtended, TransactionMinerInfo } from '../mempool.interfaces';
|
||||
import { IEsploraApi } from './bitcoin/esplora-api.interface';
|
||||
import config from '../config';
|
||||
import { Common } from './common';
|
||||
import bitcoinApi, { bitcoinCoreApi } from './bitcoin/bitcoin-api-factory';
|
||||
|
||||
class TransactionUtils {
|
||||
constructor() { }
|
||||
@@ -15,14 +14,26 @@ class TransactionUtils {
|
||||
vout: tx.vout
|
||||
.map((vout) => ({
|
||||
scriptpubkey_address: vout.scriptpubkey_address,
|
||||
scriptpubkey_asm: vout.scriptpubkey_asm,
|
||||
value: vout.value
|
||||
}))
|
||||
.filter((vout) => vout.value)
|
||||
};
|
||||
}
|
||||
|
||||
public async $getTransactionExtended(txId: string, addPrevouts = false, lazyPrevouts = false): Promise<TransactionExtended> {
|
||||
const transaction: IEsploraApi.Transaction = await bitcoinApi.$getRawTransaction(txId, false, addPrevouts, 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> {
|
||||
let transaction: IEsploraApi.Transaction;
|
||||
if (forceCore === true) {
|
||||
transaction = await bitcoinCoreApi.$getRawTransaction(txId, true);
|
||||
} else {
|
||||
transaction = await bitcoinApi.$getRawTransaction(txId, false, addPrevouts, lazyPrevouts);
|
||||
}
|
||||
return this.extendTransaction(transaction);
|
||||
}
|
||||
|
||||
|
||||
294
backend/src/api/tx-selection-worker.ts
Normal file
294
backend/src/api/tx-selection-worker.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import config from '../config';
|
||||
import logger from '../logger';
|
||||
import { ThreadTransaction, MempoolBlockWithTransactions, AuditTransaction } from '../mempool.interfaces';
|
||||
import { PairingHeap } from '../utils/pairing-heap';
|
||||
import { Common } from './common';
|
||||
import { parentPort } from 'worker_threads';
|
||||
|
||||
let mempool: { [txid: string]: ThreadTransaction } = {};
|
||||
|
||||
if (parentPort) {
|
||||
parentPort.on('message', (params) => {
|
||||
if (params.type === 'set') {
|
||||
mempool = params.mempool;
|
||||
} else if (params.type === 'update') {
|
||||
params.added.forEach(tx => {
|
||||
mempool[tx.txid] = tx;
|
||||
});
|
||||
params.removed.forEach(txid => {
|
||||
delete mempool[txid];
|
||||
});
|
||||
}
|
||||
|
||||
const { blocks, clusters } = makeBlockTemplates(mempool);
|
||||
|
||||
// return the result to main thread.
|
||||
if (parentPort) {
|
||||
parentPort.postMessage({ blocks, clusters });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* 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[] } } {
|
||||
const start = Date.now();
|
||||
const auditPool: { [txid: string]: AuditTransaction } = {};
|
||||
const mempoolArray: AuditTransaction[] = [];
|
||||
const restOfArray: ThreadTransaction[] = [];
|
||||
const cpfpClusters: { [root: string]: string[] } = {};
|
||||
|
||||
// grab the top feerate txs up to maxWeight
|
||||
Object.values(mempool).sort((a, b) => b.feePerVsize - a.feePerVsize).forEach(tx => {
|
||||
// initializing everything up front helps V8 optimize property access later
|
||||
auditPool[tx.txid] = {
|
||||
txid: tx.txid,
|
||||
fee: tx.fee,
|
||||
weight: tx.weight,
|
||||
feePerVsize: tx.feePerVsize,
|
||||
effectiveFeePerVsize: tx.feePerVsize,
|
||||
vin: tx.vin,
|
||||
relativesSet: false,
|
||||
ancestorMap: new Map<string, AuditTransaction>(),
|
||||
children: new Set<AuditTransaction>(),
|
||||
ancestorFee: 0,
|
||||
ancestorWeight: 0,
|
||||
score: 0,
|
||||
used: false,
|
||||
modified: false,
|
||||
modifiedNode: null,
|
||||
};
|
||||
mempoolArray.push(auditPool[tx.txid]);
|
||||
});
|
||||
|
||||
// Build relatives graph & calculate ancestor scores
|
||||
for (const tx of mempoolArray) {
|
||||
if (!tx.relativesSet) {
|
||||
setRelatives(tx, auditPool);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by descending ancestor score
|
||||
mempoolArray.sort((a, b) => (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[][] = [];
|
||||
let blockWeight = 4000;
|
||||
let blockSize = 0;
|
||||
let transactions: AuditTransaction[] = [];
|
||||
const modified: PairingHeap<AuditTransaction> = new PairingHeap((a, b): boolean => (a.score || 0) > (b.score || 0));
|
||||
let overflow: AuditTransaction[] = [];
|
||||
let failures = 0;
|
||||
let top = 0;
|
||||
while ((top < mempoolArray.length || !modified.isEmpty())) {
|
||||
// skip invalid transactions
|
||||
while (top < mempoolArray.length && (mempoolArray[top].used || mempoolArray[top].modified)) {
|
||||
top++;
|
||||
}
|
||||
|
||||
// Select best next package
|
||||
let nextTx;
|
||||
const nextPoolTx = mempoolArray[top];
|
||||
const nextModifiedTx = modified.peek();
|
||||
if (nextPoolTx && (!nextModifiedTx || (nextPoolTx.score || 0) > (nextModifiedTx.score || 0))) {
|
||||
nextTx = nextPoolTx;
|
||||
top++;
|
||||
} else {
|
||||
modified.pop();
|
||||
if (nextModifiedTx) {
|
||||
nextTx = nextModifiedTx;
|
||||
nextTx.modifiedNode = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextTx && !nextTx?.used) {
|
||||
// Check if the package fits into this block
|
||||
if (blockWeight + nextTx.ancestorWeight < config.MEMPOOL.BLOCK_WEIGHT_UNITS) {
|
||||
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);
|
||||
isCluster = true;
|
||||
}
|
||||
const effectiveFeeRate = nextTx.ancestorFee / (nextTx.ancestorWeight / 4);
|
||||
const used: AuditTransaction[] = [];
|
||||
while (sortedTxSet.length) {
|
||||
const ancestor = sortedTxSet.pop();
|
||||
const mempoolTx = mempool[ancestor.txid];
|
||||
ancestor.used = true;
|
||||
ancestor.usedBy = nextTx.txid;
|
||||
// update original copy of this tx with effective fee rate & relatives data
|
||||
mempoolTx.effectiveFeePerVsize = effectiveFeeRate;
|
||||
if (isCluster) {
|
||||
mempoolTx.cpfpRoot = nextTx.txid;
|
||||
}
|
||||
mempoolTx.cpfpChecked = true;
|
||||
transactions.push(ancestor);
|
||||
blockSize += ancestor.size;
|
||||
blockWeight += ancestor.weight;
|
||||
used.push(ancestor);
|
||||
}
|
||||
|
||||
// remove these as valid package ancestors for any descendants remaining in the mempool
|
||||
if (used.length) {
|
||||
used.forEach(tx => {
|
||||
updateDescendants(tx, auditPool, modified);
|
||||
});
|
||||
}
|
||||
|
||||
failures = 0;
|
||||
} else {
|
||||
// hold this package in an overflow list while we check for smaller options
|
||||
overflow.push(nextTx);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
// this block is full
|
||||
const exceededPackageTries = failures > 1000 && blockWeight > (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]));
|
||||
}
|
||||
// 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
|
||||
for (const overflowTx of overflow.reverse()) {
|
||||
if (overflowTx.modified) {
|
||||
overflowTx.modifiedNode = modified.add(overflowTx);
|
||||
} else {
|
||||
top--;
|
||||
mempoolArray[top] = overflowTx;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
transactions = [];
|
||||
|
||||
const end = Date.now();
|
||||
const time = end - start;
|
||||
logger.debug('Mempool templates calculated in ' + time / 1000 + ' seconds');
|
||||
|
||||
return { blocks, 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 },
|
||||
): void {
|
||||
for (const parent of tx.vin) {
|
||||
const parentTx = mempool[parent];
|
||||
if (parentTx && !tx.ancestorMap?.has(parent)) {
|
||||
tx.ancestorMap.set(parent, parentTx);
|
||||
parentTx.children.add(tx);
|
||||
// visit each node only once
|
||||
if (!parentTx.relativesSet) {
|
||||
setRelatives(parentTx, mempool);
|
||||
}
|
||||
parentTx.ancestorMap.forEach((ancestor) => {
|
||||
tx.ancestorMap.set(ancestor.txid, ancestor);
|
||||
});
|
||||
}
|
||||
};
|
||||
tx.ancestorFee = tx.fee || 0;
|
||||
tx.ancestorWeight = tx.weight || 0;
|
||||
tx.ancestorMap.forEach((ancestor) => {
|
||||
tx.ancestorFee += ancestor.fee;
|
||||
tx.ancestorWeight += ancestor.weight;
|
||||
});
|
||||
tx.score = tx.ancestorFee / ((tx.ancestorWeight / 4) || 1);
|
||||
tx.relativesSet = true;
|
||||
}
|
||||
|
||||
// iterate over remaining descendants, removing the root as a valid ancestor & updating the ancestor score
|
||||
// avoids recursion to limit call stack depth
|
||||
function updateDescendants(
|
||||
rootTx: AuditTransaction,
|
||||
mempool: { [txid: string]: AuditTransaction },
|
||||
modified: PairingHeap<AuditTransaction>,
|
||||
): void {
|
||||
const descendantSet: Set<AuditTransaction> = new Set();
|
||||
// stack of nodes left to visit
|
||||
const descendants: AuditTransaction[] = [];
|
||||
let descendantTx;
|
||||
let tmpScore;
|
||||
rootTx.children.forEach(childTx => {
|
||||
if (!descendantSet.has(childTx)) {
|
||||
descendants.push(childTx);
|
||||
descendantSet.add(childTx);
|
||||
}
|
||||
});
|
||||
while (descendants.length) {
|
||||
descendantTx = descendants.pop();
|
||||
if (descendantTx && descendantTx.ancestorMap && descendantTx.ancestorMap.has(rootTx.txid)) {
|
||||
// remove tx as ancestor
|
||||
descendantTx.ancestorMap.delete(rootTx.txid);
|
||||
descendantTx.ancestorFee -= rootTx.fee;
|
||||
descendantTx.ancestorWeight -= rootTx.weight;
|
||||
tmpScore = descendantTx.score;
|
||||
descendantTx.score = descendantTx.ancestorFee / (descendantTx.ancestorWeight / 4);
|
||||
|
||||
if (!descendantTx.modifiedNode) {
|
||||
descendantTx.modified = true;
|
||||
descendantTx.modifiedNode = modified.add(descendantTx);
|
||||
} else {
|
||||
// rebalance modified heap if score has changed
|
||||
if (descendantTx.score < tmpScore) {
|
||||
modified.decreasePriority(descendantTx.modifiedNode);
|
||||
} else if (descendantTx.score > tmpScore) {
|
||||
modified.increasePriority(descendantTx.modifiedNode);
|
||||
}
|
||||
}
|
||||
|
||||
// add this node's children to the stack
|
||||
descendantTx.children.forEach(childTx => {
|
||||
// visit each node only once
|
||||
if (!descendantSet.has(childTx)) {
|
||||
descendants.push(childTx);
|
||||
descendantSet.add(childTx);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import logger from '../logger';
|
||||
import * as WebSocket from 'ws';
|
||||
import { BlockExtended, TransactionExtended, WebsocketResponse, MempoolBlock, MempoolBlockDelta,
|
||||
OptimizedStatistic, ILoadingIndicators, IConversionRates } from '../mempool.interfaces';
|
||||
import {
|
||||
BlockExtended, TransactionExtended, WebsocketResponse,
|
||||
OptimizedStatistic, ILoadingIndicators
|
||||
} from '../mempool.interfaces';
|
||||
import blocks from './blocks';
|
||||
import memPool from './mempool';
|
||||
import backendInfo from './backend-info';
|
||||
import mempoolBlocks from './mempool-blocks';
|
||||
import fiatConversion from './fiat-conversion';
|
||||
import { Common } from './common';
|
||||
import loadingIndicators from './loading-indicators';
|
||||
import config from '../config';
|
||||
@@ -14,6 +15,12 @@ import transactionUtils from './transaction-utils';
|
||||
import rbfCache from './rbf-cache';
|
||||
import difficultyAdjustment from './difficulty-adjustment';
|
||||
import feeApi from './fee-api';
|
||||
import BlocksAuditsRepository from '../repositories/BlocksAuditsRepository';
|
||||
import BlocksSummariesRepository from '../repositories/BlocksSummariesRepository';
|
||||
import Audit from './audit';
|
||||
import { deepClone } from '../utils/clone';
|
||||
import priceUpdater from '../tasks/price-updater';
|
||||
import { ApiPrice } from '../repositories/PricesRepository';
|
||||
|
||||
class WebsocketHandler {
|
||||
private wss: WebSocket.Server | undefined;
|
||||
@@ -53,10 +60,10 @@ class WebsocketHandler {
|
||||
client['track-tx'] = parsedMessage['track-tx'];
|
||||
// Client is telling the transaction wasn't found
|
||||
if (parsedMessage['watch-mempool']) {
|
||||
const rbfCacheTx = rbfCache.get(client['track-tx']);
|
||||
if (rbfCacheTx) {
|
||||
const rbfCacheTxid = rbfCache.getReplacedBy(client['track-tx']);
|
||||
if (rbfCacheTxid) {
|
||||
response['txReplaced'] = {
|
||||
txid: rbfCacheTx.txid,
|
||||
txid: rbfCacheTxid,
|
||||
};
|
||||
client['track-tx'] = null;
|
||||
} else {
|
||||
@@ -164,7 +171,7 @@ class WebsocketHandler {
|
||||
throw new Error('WebSocket.Server is not set');
|
||||
}
|
||||
|
||||
this.wss.clients.forEach((client: WebSocket) => {
|
||||
this.wss.clients.forEach((client) => {
|
||||
if (client.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
@@ -179,7 +186,7 @@ class WebsocketHandler {
|
||||
throw new Error('WebSocket.Server is not set');
|
||||
}
|
||||
|
||||
this.wss.clients.forEach((client: WebSocket) => {
|
||||
this.wss.clients.forEach((client) => {
|
||||
if (client.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
@@ -187,12 +194,12 @@ class WebsocketHandler {
|
||||
});
|
||||
}
|
||||
|
||||
handleNewConversionRates(conversionRates: IConversionRates) {
|
||||
handleNewConversionRates(conversionRates: ApiPrice) {
|
||||
if (!this.wss) {
|
||||
throw new Error('WebSocket.Server is not set');
|
||||
}
|
||||
|
||||
this.wss.clients.forEach((client: WebSocket) => {
|
||||
this.wss.clients.forEach((client) => {
|
||||
if (client.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
@@ -208,7 +215,7 @@ class WebsocketHandler {
|
||||
'mempoolInfo': memPool.getMempoolInfo(),
|
||||
'vBytesPerSecond': memPool.getVBytesPerSecond(),
|
||||
'blocks': _blocks,
|
||||
'conversions': fiatConversion.getConversionRates(),
|
||||
'conversions': priceUpdater.getLatestPrices(),
|
||||
'mempool-blocks': mempoolBlocks.getMempoolBlocks(),
|
||||
'transactions': memPool.getLatestTransactions(),
|
||||
'backendInfo': backendInfo.getBackendInfo(),
|
||||
@@ -224,7 +231,7 @@ class WebsocketHandler {
|
||||
throw new Error('WebSocket.Server is not set');
|
||||
}
|
||||
|
||||
this.wss.clients.forEach((client: WebSocket) => {
|
||||
this.wss.clients.forEach((client) => {
|
||||
if (client.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
@@ -239,13 +246,18 @@ class WebsocketHandler {
|
||||
});
|
||||
}
|
||||
|
||||
handleMempoolChange(newMempool: { [txid: string]: TransactionExtended },
|
||||
newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]) {
|
||||
async handleMempoolChange(newMempool: { [txid: string]: TransactionExtended },
|
||||
newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]): Promise<void> {
|
||||
if (!this.wss) {
|
||||
throw new Error('WebSocket.Server is not set');
|
||||
}
|
||||
|
||||
mempoolBlocks.updateMempoolBlocks(newMempool);
|
||||
if (config.MEMPOOL.ADVANCED_GBT_MEMPOOL) {
|
||||
await mempoolBlocks.updateBlockTemplates(newMempool, newTransactions, deletedTransactions.map(tx => tx.txid), true);
|
||||
} else {
|
||||
mempoolBlocks.updateMempoolBlocks(newMempool, true);
|
||||
}
|
||||
|
||||
const mBlocks = mempoolBlocks.getMempoolBlocks();
|
||||
const mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas();
|
||||
const mempoolInfo = memPool.getMempoolInfo();
|
||||
@@ -255,7 +267,7 @@ class WebsocketHandler {
|
||||
memPool.handleRbfTransactions(rbfTransactions);
|
||||
const recommendedFees = feeApi.getRecommendedFee();
|
||||
|
||||
this.wss.clients.forEach(async (client: WebSocket) => {
|
||||
this.wss.clients.forEach(async (client) => {
|
||||
if (client.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
@@ -400,37 +412,84 @@ class WebsocketHandler {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleNewBlock(block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) {
|
||||
|
||||
async handleNewBlock(block: BlockExtended, txIds: string[], transactions: TransactionExtended[]): Promise<void> {
|
||||
if (!this.wss) {
|
||||
throw new Error('WebSocket.Server is not set');
|
||||
}
|
||||
|
||||
let mBlocks: undefined | MempoolBlock[];
|
||||
let mBlockDeltas: undefined | MempoolBlockDelta[];
|
||||
let matchRate = 0;
|
||||
const _memPool = memPool.getMempool();
|
||||
const _mempoolBlocks = mempoolBlocks.getMempoolBlocksWithTransactions();
|
||||
|
||||
if (_mempoolBlocks[0]) {
|
||||
const matches: string[] = [];
|
||||
for (const txId of txIds) {
|
||||
if (_mempoolBlocks[0].transactionIds.indexOf(txId) > -1) {
|
||||
matches.push(txId);
|
||||
}
|
||||
delete _memPool[txId];
|
||||
if (config.MEMPOOL.AUDIT) {
|
||||
let projectedBlocks;
|
||||
// template calculation functions have mempool side effects, so calculate audits using
|
||||
// a cloned copy of the mempool if we're running a different algorithm for mempool updates
|
||||
const auditMempool = (config.MEMPOOL.ADVANCED_GBT_AUDIT === config.MEMPOOL.ADVANCED_GBT_MEMPOOL) ? _memPool : deepClone(_memPool);
|
||||
if (config.MEMPOOL.ADVANCED_GBT_AUDIT) {
|
||||
projectedBlocks = await mempoolBlocks.makeBlockTemplates(auditMempool, false);
|
||||
} else {
|
||||
projectedBlocks = mempoolBlocks.updateMempoolBlocks(auditMempool, false);
|
||||
}
|
||||
|
||||
matchRate = Math.round((matches.length / (txIds.length - 1)) * 100);
|
||||
mempoolBlocks.updateMempoolBlocks(_memPool);
|
||||
mBlocks = mempoolBlocks.getMempoolBlocks();
|
||||
mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas();
|
||||
if (Common.indexingEnabled() && memPool.isInSync()) {
|
||||
const { censored, added, fresh, score, similarity } = Audit.auditBlock(transactions, projectedBlocks, auditMempool);
|
||||
const matchRate = Math.round(score * 100 * 100) / 100;
|
||||
|
||||
const stripped = projectedBlocks[0]?.transactions ? projectedBlocks[0].transactions.map((tx) => {
|
||||
return {
|
||||
txid: tx.txid,
|
||||
vsize: tx.vsize,
|
||||
fee: tx.fee ? Math.round(tx.fee) : 0,
|
||||
value: tx.value,
|
||||
};
|
||||
}) : [];
|
||||
|
||||
BlocksSummariesRepository.$saveTemplate({
|
||||
height: block.height,
|
||||
template: {
|
||||
id: block.id,
|
||||
transactions: stripped
|
||||
}
|
||||
});
|
||||
|
||||
BlocksAuditsRepository.$saveAudit({
|
||||
time: block.timestamp,
|
||||
height: block.height,
|
||||
hash: block.id,
|
||||
addedTxs: added,
|
||||
missingTxs: censored,
|
||||
freshTxs: fresh,
|
||||
matchRate: matchRate,
|
||||
});
|
||||
|
||||
if (block.extras) {
|
||||
block.extras.matchRate = matchRate;
|
||||
block.extras.similarity = similarity;
|
||||
}
|
||||
}
|
||||
} else if (block.extras) {
|
||||
const mBlocks = mempoolBlocks.getMempoolBlocksWithTransactions();
|
||||
if (mBlocks?.length && mBlocks[0].transactions) {
|
||||
block.extras.similarity = Common.getSimilarity(mBlocks[0], transactions);
|
||||
}
|
||||
}
|
||||
|
||||
if (block.extras) {
|
||||
block.extras.matchRate = matchRate;
|
||||
const removed: string[] = [];
|
||||
// Update mempool to remove transactions included in the new block
|
||||
for (const txId of txIds) {
|
||||
delete _memPool[txId];
|
||||
removed.push(txId);
|
||||
rbfCache.evict(txId);
|
||||
}
|
||||
|
||||
if (config.MEMPOOL.ADVANCED_GBT_MEMPOOL) {
|
||||
await mempoolBlocks.updateBlockTemplates(_memPool, [], removed, true);
|
||||
} else {
|
||||
mempoolBlocks.updateMempoolBlocks(_memPool, true);
|
||||
}
|
||||
const mBlocks = mempoolBlocks.getMempoolBlocks();
|
||||
const mBlockDeltas = mempoolBlocks.getMempoolBlockDeltas();
|
||||
|
||||
const da = difficultyAdjustment.getDifficultyAdjustment();
|
||||
const fees = feeApi.getRecommendedFee();
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
const configFile = require('../mempool-config.json');
|
||||
const configFromFile = require(
|
||||
process.env.MEMPOOL_CONFIG_FILE ? process.env.MEMPOOL_CONFIG_FILE : '../mempool-config.json'
|
||||
);
|
||||
|
||||
interface IConfig {
|
||||
MEMPOOL: {
|
||||
ENABLED: boolean;
|
||||
NETWORK: 'mainnet' | 'testnet' | 'signet' | 'liquid' | 'liquidtestnet';
|
||||
BACKEND: 'esplora' | 'electrum' | 'none';
|
||||
HTTP_PORT: number;
|
||||
@@ -15,17 +18,45 @@ interface IConfig {
|
||||
INITIAL_BLOCKS_AMOUNT: number;
|
||||
MEMPOOL_BLOCKS_AMOUNT: number;
|
||||
INDEXING_BLOCKS_AMOUNT: number;
|
||||
PRICE_FEED_UPDATE_INTERVAL: number;
|
||||
BLOCKS_SUMMARIES_INDEXING: boolean;
|
||||
USE_SECOND_NODE_FOR_MINFEE: boolean;
|
||||
EXTERNAL_ASSETS: string[];
|
||||
EXTERNAL_MAX_RETRY: number;
|
||||
EXTERNAL_RETRY_INTERVAL: number;
|
||||
USER_AGENT: string;
|
||||
STDOUT_LOG_MIN_PRIORITY: 'emerg' | 'alert' | 'crit' | 'err' | 'warn' | 'notice' | 'info' | 'debug';
|
||||
AUTOMATIC_BLOCK_REINDEXING: boolean;
|
||||
POOLS_JSON_URL: string,
|
||||
POOLS_JSON_TREE_URL: string,
|
||||
AUDIT: boolean;
|
||||
ADVANCED_GBT_AUDIT: boolean;
|
||||
ADVANCED_GBT_MEMPOOL: boolean;
|
||||
CPFP_INDEXING: boolean;
|
||||
MAX_BLOCKS_BULK_QUERY: number;
|
||||
DISK_CACHE_BLOCK_INTERVAL: number;
|
||||
};
|
||||
ESPLORA: {
|
||||
REST_API_URL: string;
|
||||
};
|
||||
LIGHTNING: {
|
||||
ENABLED: boolean;
|
||||
BACKEND: 'lnd' | 'cln' | 'ldk';
|
||||
TOPOLOGY_FOLDER: string;
|
||||
STATS_REFRESH_INTERVAL: number;
|
||||
GRAPH_REFRESH_INTERVAL: number;
|
||||
LOGGER_UPDATE_INTERVAL: number;
|
||||
FORENSICS_INTERVAL: number;
|
||||
FORENSICS_RATE_LIMIT: number;
|
||||
};
|
||||
LND: {
|
||||
TLS_CERT_PATH: string;
|
||||
MACAROON_PATH: string;
|
||||
REST_API_URL: string;
|
||||
TIMEOUT: number;
|
||||
};
|
||||
CLIGHTNING: {
|
||||
SOCKET: string;
|
||||
};
|
||||
ELECTRUM: {
|
||||
HOST: string;
|
||||
PORT: number;
|
||||
@@ -36,12 +67,14 @@ interface IConfig {
|
||||
PORT: number;
|
||||
USERNAME: string;
|
||||
PASSWORD: string;
|
||||
TIMEOUT: number;
|
||||
};
|
||||
SECOND_CORE_RPC: {
|
||||
HOST: string;
|
||||
PORT: number;
|
||||
USERNAME: string;
|
||||
PASSWORD: string;
|
||||
TIMEOUT: number;
|
||||
};
|
||||
DATABASE: {
|
||||
ENABLED: boolean;
|
||||
@@ -87,10 +120,17 @@ interface IConfig {
|
||||
BISQ_URL: string;
|
||||
BISQ_ONION: string;
|
||||
};
|
||||
MAXMIND: {
|
||||
ENABLED: boolean;
|
||||
GEOLITE2_CITY: string;
|
||||
GEOLITE2_ASN: string;
|
||||
GEOIP2_ISP: string;
|
||||
},
|
||||
}
|
||||
|
||||
const defaults: IConfig = {
|
||||
'MEMPOOL': {
|
||||
'ENABLED': true,
|
||||
'NETWORK': 'mainnet',
|
||||
'BACKEND': 'none',
|
||||
'HTTP_PORT': 8999,
|
||||
@@ -104,13 +144,22 @@ const defaults: IConfig = {
|
||||
'INITIAL_BLOCKS_AMOUNT': 8,
|
||||
'MEMPOOL_BLOCKS_AMOUNT': 8,
|
||||
'INDEXING_BLOCKS_AMOUNT': 11000, // 0 = disable indexing, -1 = index all blocks
|
||||
'PRICE_FEED_UPDATE_INTERVAL': 600,
|
||||
'BLOCKS_SUMMARIES_INDEXING': false,
|
||||
'USE_SECOND_NODE_FOR_MINFEE': false,
|
||||
'EXTERNAL_ASSETS': [],
|
||||
'EXTERNAL_MAX_RETRY': 1,
|
||||
'EXTERNAL_RETRY_INTERVAL': 0,
|
||||
'USER_AGENT': 'mempool',
|
||||
'STDOUT_LOG_MIN_PRIORITY': 'debug',
|
||||
'AUTOMATIC_BLOCK_REINDEXING': false,
|
||||
'POOLS_JSON_URL': 'https://raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json',
|
||||
'POOLS_JSON_TREE_URL': 'https://api.github.com/repos/mempool/mining-pools/git/trees/master',
|
||||
'AUDIT': false,
|
||||
'ADVANCED_GBT_AUDIT': false,
|
||||
'ADVANCED_GBT_MEMPOOL': false,
|
||||
'CPFP_INDEXING': false,
|
||||
'MAX_BLOCKS_BULK_QUERY': 0,
|
||||
'DISK_CACHE_BLOCK_INTERVAL': 6,
|
||||
},
|
||||
'ESPLORA': {
|
||||
'REST_API_URL': 'http://127.0.0.1:3000',
|
||||
@@ -124,13 +173,15 @@ const defaults: IConfig = {
|
||||
'HOST': '127.0.0.1',
|
||||
'PORT': 8332,
|
||||
'USERNAME': 'mempool',
|
||||
'PASSWORD': 'mempool'
|
||||
'PASSWORD': 'mempool',
|
||||
'TIMEOUT': 60000,
|
||||
},
|
||||
'SECOND_CORE_RPC': {
|
||||
'HOST': '127.0.0.1',
|
||||
'PORT': 8332,
|
||||
'USERNAME': 'mempool',
|
||||
'PASSWORD': 'mempool'
|
||||
'PASSWORD': 'mempool',
|
||||
'TIMEOUT': 60000,
|
||||
},
|
||||
'DATABASE': {
|
||||
'ENABLED': true,
|
||||
@@ -156,6 +207,25 @@ const defaults: IConfig = {
|
||||
'ENABLED': false,
|
||||
'DATA_PATH': '/bisq/statsnode-data/btc_mainnet/db'
|
||||
},
|
||||
'LIGHTNING': {
|
||||
'ENABLED': false,
|
||||
'BACKEND': 'lnd',
|
||||
'TOPOLOGY_FOLDER': '',
|
||||
'STATS_REFRESH_INTERVAL': 600,
|
||||
'GRAPH_REFRESH_INTERVAL': 600,
|
||||
'LOGGER_UPDATE_INTERVAL': 30,
|
||||
'FORENSICS_INTERVAL': 43200,
|
||||
'FORENSICS_RATE_LIMIT': 20,
|
||||
},
|
||||
'LND': {
|
||||
'TLS_CERT_PATH': '',
|
||||
'MACAROON_PATH': '',
|
||||
'REST_API_URL': 'https://localhost:8080',
|
||||
'TIMEOUT': 10000,
|
||||
},
|
||||
'CLIGHTNING': {
|
||||
'SOCKET': '',
|
||||
},
|
||||
'SOCKS5PROXY': {
|
||||
'ENABLED': false,
|
||||
'USE_ONION': true,
|
||||
@@ -164,18 +234,24 @@ const defaults: IConfig = {
|
||||
'USERNAME': '',
|
||||
'PASSWORD': ''
|
||||
},
|
||||
"PRICE_DATA_SERVER": {
|
||||
'PRICE_DATA_SERVER': {
|
||||
'TOR_URL': 'http://wizpriceje6q5tdrxkyiazsgu7irquiqjy2dptezqhrtu7l2qelqktid.onion/getAllMarketPrices',
|
||||
'CLEARNET_URL': 'https://price.bisq.wiz.biz/getAllMarketPrices'
|
||||
},
|
||||
"EXTERNAL_DATA_SERVER": {
|
||||
'EXTERNAL_DATA_SERVER': {
|
||||
'MEMPOOL_API': 'https://mempool.space/api/v1',
|
||||
'MEMPOOL_ONION': 'http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api/v1',
|
||||
'LIQUID_API': 'https://liquid.network/api/v1',
|
||||
'LIQUID_ONION': 'http://liquidmom47f6s3m53ebfxn47p76a6tlnxib3wp6deux7wuzotdr6cyd.onion/api/v1',
|
||||
'BISQ_URL': 'https://bisq.markets/api',
|
||||
'BISQ_ONION': 'http://bisqmktse2cabavbr2xjq7xw3h6g5ottemo5rolfcwt6aly6tp5fdryd.onion/api'
|
||||
}
|
||||
},
|
||||
'MAXMIND': {
|
||||
'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'
|
||||
},
|
||||
};
|
||||
|
||||
class Config implements IConfig {
|
||||
@@ -188,12 +264,16 @@ class Config implements IConfig {
|
||||
SYSLOG: IConfig['SYSLOG'];
|
||||
STATISTICS: IConfig['STATISTICS'];
|
||||
BISQ: IConfig['BISQ'];
|
||||
LIGHTNING: IConfig['LIGHTNING'];
|
||||
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'];
|
||||
|
||||
constructor() {
|
||||
const configs = this.merge(configFile, defaults);
|
||||
const configs = this.merge(configFromFile, defaults);
|
||||
this.MEMPOOL = configs.MEMPOOL;
|
||||
this.ESPLORA = configs.ESPLORA;
|
||||
this.ELECTRUM = configs.ELECTRUM;
|
||||
@@ -203,9 +283,13 @@ class Config implements IConfig {
|
||||
this.SYSLOG = configs.SYSLOG;
|
||||
this.STATISTICS = configs.STATISTICS;
|
||||
this.BISQ = configs.BISQ;
|
||||
this.LIGHTNING = configs.LIGHTNING;
|
||||
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;
|
||||
}
|
||||
|
||||
merge = (...objects: object[]): IConfig => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import config from './config';
|
||||
import { createPool, Pool, PoolConnection } from 'mysql2/promise';
|
||||
import logger from './logger';
|
||||
import { PoolOptions } from 'mysql2/typings/mysql';
|
||||
import { FieldPacket, OkPacket, PoolOptions, ResultSetHeader, RowDataPacket } from 'mysql2/typings/mysql';
|
||||
|
||||
class DB {
|
||||
constructor() {
|
||||
@@ -24,11 +24,14 @@ import { PoolOptions } from 'mysql2/typings/mysql';
|
||||
|
||||
private checkDBFlag() {
|
||||
if (config.DATABASE.ENABLED === false) {
|
||||
logger.err('Trying to use DB feature but config.DATABASE.ENABLED is set to false, please open an issue');
|
||||
const stack = new Error().stack;
|
||||
logger.err(`Trying to use DB feature but config.DATABASE.ENABLED is set to false, please open an issue.\nStack trace: ${stack}}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async query(query, params?) {
|
||||
public async query<T extends RowDataPacket[][] | RowDataPacket[] | OkPacket |
|
||||
OkPacket[] | ResultSetHeader>(query, params?): Promise<[T, FieldPacket[]]>
|
||||
{
|
||||
this.checkDBFlag();
|
||||
const pool = await this.getPool();
|
||||
return pool.query(query, params);
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import { Express, Request, Response, NextFunction } from 'express';
|
||||
import * as express from 'express';
|
||||
import express from 'express';
|
||||
import { Application, Request, Response, NextFunction } from 'express';
|
||||
import * as http from 'http';
|
||||
import * as WebSocket from 'ws';
|
||||
import * as cluster from 'cluster';
|
||||
import axios from 'axios';
|
||||
|
||||
import cluster from 'cluster';
|
||||
import DB from './database';
|
||||
import config from './config';
|
||||
import routes from './routes';
|
||||
import blocks from './api/blocks';
|
||||
import memPool from './api/mempool';
|
||||
import diskCache from './api/disk-cache';
|
||||
import statistics from './api/statistics';
|
||||
import statistics from './api/statistics/statistics';
|
||||
import websocketHandler from './api/websocket-handler';
|
||||
import fiatConversion from './api/fiat-conversion';
|
||||
import bisq from './api/bisq/bisq';
|
||||
import bisqMarkets from './api/bisq/markets';
|
||||
import logger from './logger';
|
||||
@@ -27,13 +23,35 @@ import icons from './api/liquid/icons';
|
||||
import { Common } from './api/common';
|
||||
import poolsUpdater from './tasks/pools-updater';
|
||||
import indexer from './indexer';
|
||||
import nodesRoutes from './api/explorer/nodes.routes';
|
||||
import channelsRoutes from './api/explorer/channels.routes';
|
||||
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 miningRoutes from './api/mining/mining-routes';
|
||||
import bisqRoutes from './api/bisq/bisq.routes';
|
||||
import liquidRoutes from './api/liquid/liquid.routes';
|
||||
import bitcoinRoutes from './api/bitcoin/bitcoin.routes';
|
||||
import fundingTxFetcher from './tasks/lightning/sync-tasks/funding-tx-fetcher';
|
||||
import forensicsService from './tasks/lightning/forensics.service';
|
||||
import priceUpdater from './tasks/price-updater';
|
||||
import chainTips from './api/chain-tips';
|
||||
import { AxiosError } from 'axios';
|
||||
import v8 from 'v8';
|
||||
import { formatBytes, getBytesUnit } from './utils/format';
|
||||
|
||||
class Server {
|
||||
private wss: WebSocket.Server | undefined;
|
||||
private server: http.Server | undefined;
|
||||
private app: Express;
|
||||
private app: Application;
|
||||
private currentBackendRetryInterval = 5;
|
||||
|
||||
private maxHeapSize: number = 0;
|
||||
private heapLogInterval: number = 60;
|
||||
private warnedHeapCritical: boolean = false;
|
||||
private lastHeapLogTime: number | null = null;
|
||||
|
||||
constructor() {
|
||||
this.app = express();
|
||||
|
||||
@@ -42,7 +60,7 @@ class Server {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cluster.isMaster) {
|
||||
if (cluster.isPrimary) {
|
||||
logger.notice(`Mempool Server (Master) is running on port ${config.MEMPOOL.HTTP_PORT} (${backendInfo.getShortCommitHash()})`);
|
||||
|
||||
const numCPUs = config.MEMPOOL.SPAWN_CLUSTER_PROCS;
|
||||
@@ -66,45 +84,46 @@ class Server {
|
||||
}
|
||||
}
|
||||
|
||||
async startServer(worker = false) {
|
||||
async startServer(worker = false): Promise<void> {
|
||||
logger.notice(`Starting Mempool Server${worker ? ' (worker)' : ''}... (${backendInfo.getShortCommitHash()})`);
|
||||
|
||||
if (config.DATABASE.ENABLED) {
|
||||
await DB.checkDbConnection();
|
||||
try {
|
||||
if (process.env.npm_config_reindex_blocks === 'true') { // Re-index requests
|
||||
await databaseMigration.$blocksReindexingTruncate();
|
||||
}
|
||||
await databaseMigration.$initializeOrMigrateDatabase();
|
||||
} catch (e) {
|
||||
throw new Error(e instanceof Error ? e.message : 'Error');
|
||||
}
|
||||
}
|
||||
|
||||
this.app
|
||||
.use((req: Request, res: Response, next: NextFunction) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
next();
|
||||
})
|
||||
.use(express.urlencoded({ extended: true }))
|
||||
.use(express.text())
|
||||
;
|
||||
.use(express.text({ type: ['text/plain', 'application/base64'] }))
|
||||
;
|
||||
|
||||
if (config.DATABASE.ENABLED) {
|
||||
await priceUpdater.$initializeLatestPriceWithDb();
|
||||
}
|
||||
|
||||
this.server = http.createServer(this.app);
|
||||
this.wss = new WebSocket.Server({ server: this.server });
|
||||
|
||||
this.setUpWebsocketHandling();
|
||||
|
||||
await poolsUpdater.updatePoolsJson(); // Needs to be done before loading the disk cache because we sometimes wipe it
|
||||
await syncAssets.syncAssets$();
|
||||
diskCache.loadMempoolCache();
|
||||
|
||||
if (config.DATABASE.ENABLED) {
|
||||
await DB.checkDbConnection();
|
||||
try {
|
||||
if (process.env.npm_config_reindex !== undefined) { // Re-index requests
|
||||
const tables = process.env.npm_config_reindex.split(',');
|
||||
logger.warn(`Indexed data for "${process.env.npm_config_reindex}" tables will be erased in 5 seconds (using '--reindex')`);
|
||||
await Common.sleep$(5000);
|
||||
await databaseMigration.$truncateIndexedData(tables);
|
||||
}
|
||||
await databaseMigration.$initializeOrMigrateDatabase();
|
||||
if (Common.indexingEnabled()) {
|
||||
await indexer.$resetHashratesIndexingState();
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(e instanceof Error ? e.message : 'Error');
|
||||
}
|
||||
if (config.MEMPOOL.ENABLED) {
|
||||
diskCache.loadMempoolCache();
|
||||
}
|
||||
|
||||
if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED && cluster.isMaster) {
|
||||
if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED && cluster.isPrimary) {
|
||||
statistics.startStatistics();
|
||||
}
|
||||
|
||||
@@ -116,10 +135,16 @@ class Server {
|
||||
}
|
||||
}
|
||||
|
||||
fiatConversion.startService();
|
||||
priceUpdater.$run();
|
||||
await chainTips.updateOrphanedBlocks();
|
||||
|
||||
this.setUpHttpApiRoutes();
|
||||
this.runMainUpdateLoop();
|
||||
|
||||
if (config.MEMPOOL.ENABLED) {
|
||||
this.runMainUpdateLoop();
|
||||
}
|
||||
|
||||
setInterval(() => { this.healthCheck(); }, 2500);
|
||||
|
||||
if (config.BISQ.ENABLED) {
|
||||
bisq.startBisqService();
|
||||
@@ -128,6 +153,10 @@ class Server {
|
||||
bisqMarkets.startBisqService();
|
||||
}
|
||||
|
||||
if (config.LIGHTNING.ENABLED) {
|
||||
this.$runLightningBackend();
|
||||
}
|
||||
|
||||
this.server.listen(config.MEMPOOL.HTTP_PORT, () => {
|
||||
if (worker) {
|
||||
logger.info(`Mempool Server worker #${process.pid} started`);
|
||||
@@ -137,7 +166,7 @@ class Server {
|
||||
});
|
||||
}
|
||||
|
||||
async runMainUpdateLoop() {
|
||||
async runMainUpdateLoop(): Promise<void> {
|
||||
try {
|
||||
try {
|
||||
await memPool.$updateMemPoolInfo();
|
||||
@@ -149,29 +178,50 @@ class Server {
|
||||
logger.debug(msg);
|
||||
}
|
||||
}
|
||||
await poolsUpdater.updatePoolsJson();
|
||||
await blocks.$updateBlocks();
|
||||
await memPool.$updateMempool();
|
||||
indexer.$run();
|
||||
|
||||
setTimeout(this.runMainUpdateLoop.bind(this), config.MEMPOOL.POLL_RATE_MS);
|
||||
this.currentBackendRetryInterval = 5;
|
||||
} catch (e) {
|
||||
const loggerMsg = `runMainLoop error: ${(e instanceof Error ? e.message : e)}. Retrying in ${this.currentBackendRetryInterval} sec.`;
|
||||
} catch (e: any) {
|
||||
let loggerMsg = `Exception in runMainUpdateLoop(). 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) {
|
||||
logger.warn(loggerMsg);
|
||||
mempool.setOutOfSync();
|
||||
} else {
|
||||
logger.debug(loggerMsg);
|
||||
}
|
||||
logger.debug(JSON.stringify(e));
|
||||
if (e instanceof AxiosError) {
|
||||
logger.debug(`AxiosError: ${e?.message}`);
|
||||
}
|
||||
setTimeout(this.runMainUpdateLoop.bind(this), 1000 * this.currentBackendRetryInterval);
|
||||
this.currentBackendRetryInterval *= 2;
|
||||
this.currentBackendRetryInterval = Math.min(this.currentBackendRetryInterval, 60);
|
||||
}
|
||||
}
|
||||
|
||||
setUpWebsocketHandling() {
|
||||
async $runLightningBackend(): Promise<void> {
|
||||
try {
|
||||
await fundingTxFetcher.$init();
|
||||
await networkSyncService.$startService();
|
||||
await lightningStatsUpdater.$startService();
|
||||
await forensicsService.$startService();
|
||||
} catch(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) {
|
||||
websocketHandler.setWebsocketServer(this.wss);
|
||||
}
|
||||
@@ -185,175 +235,55 @@ class Server {
|
||||
});
|
||||
}
|
||||
websocketHandler.setupConnectionHandling();
|
||||
statistics.setNewStatisticsEntryCallback(websocketHandler.handleNewStatistic.bind(websocketHandler));
|
||||
blocks.setNewBlockCallback(websocketHandler.handleNewBlock.bind(websocketHandler));
|
||||
memPool.setMempoolChangedCallback(websocketHandler.handleMempoolChange.bind(websocketHandler));
|
||||
fiatConversion.setProgressChangedCallback(websocketHandler.handleNewConversionRates.bind(websocketHandler));
|
||||
if (config.MEMPOOL.ENABLED) {
|
||||
statistics.setNewStatisticsEntryCallback(websocketHandler.handleNewStatistic.bind(websocketHandler));
|
||||
memPool.setAsyncMempoolChangedCallback(websocketHandler.handleMempoolChange.bind(websocketHandler));
|
||||
blocks.setNewAsyncBlockCallback(websocketHandler.handleNewBlock.bind(websocketHandler));
|
||||
}
|
||||
priceUpdater.setRatesChangedCallback(websocketHandler.handleNewConversionRates.bind(websocketHandler));
|
||||
loadingIndicators.setProgressChangedCallback(websocketHandler.handleLoadingChanged.bind(websocketHandler));
|
||||
}
|
||||
|
||||
setUpHttpApiRoutes() {
|
||||
this.app
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'transaction-times', routes.getTransactionTimes)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'cpfp/:txId', routes.getCpfpInfo)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'difficulty-adjustment', routes.getDifficultyChange)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'fees/recommended', routes.getRecommendedFees)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'fees/mempool-blocks', routes.getMempoolBlocks)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'backend-info', routes.getBackendInfo)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'init-data', routes.getInitData)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'validate-address/:address', routes.validateAddress)
|
||||
.post(config.MEMPOOL.API_URL_PREFIX + 'tx/push', routes.$postTransactionForm)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'donations', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${config.EXTERNAL_DATA_SERVER.MEMPOOL_API}/donations`, { responseType: 'stream', timeout: 10000 });
|
||||
response.data.pipe(res);
|
||||
} catch (e) {
|
||||
res.status(500).end();
|
||||
}
|
||||
})
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'donations/images/:id', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${config.EXTERNAL_DATA_SERVER.MEMPOOL_API}/donations/images/${req.params.id}`, {
|
||||
responseType: 'stream', timeout: 10000
|
||||
});
|
||||
response.data.pipe(res);
|
||||
} catch (e) {
|
||||
res.status(500).end();
|
||||
}
|
||||
})
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'contributors', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${config.EXTERNAL_DATA_SERVER.MEMPOOL_API}/contributors`, { responseType: 'stream', timeout: 10000 });
|
||||
response.data.pipe(res);
|
||||
} catch (e) {
|
||||
res.status(500).end();
|
||||
}
|
||||
})
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'contributors/images/:id', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${config.EXTERNAL_DATA_SERVER.MEMPOOL_API}/contributors/images/${req.params.id}`, {
|
||||
responseType: 'stream', timeout: 10000
|
||||
});
|
||||
response.data.pipe(res);
|
||||
} catch (e) {
|
||||
res.status(500).end();
|
||||
}
|
||||
})
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'translators', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${config.EXTERNAL_DATA_SERVER.MEMPOOL_API}/translators`, { responseType: 'stream', timeout: 10000 });
|
||||
response.data.pipe(res);
|
||||
} catch (e) {
|
||||
res.status(500).end();
|
||||
}
|
||||
})
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'translators/images/:id', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${config.EXTERNAL_DATA_SERVER.MEMPOOL_API}/translators/images/${req.params.id}`, {
|
||||
responseType: 'stream', timeout: 10000
|
||||
});
|
||||
response.data.pipe(res);
|
||||
} catch (e) {
|
||||
res.status(500).end();
|
||||
}
|
||||
})
|
||||
;
|
||||
|
||||
if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED) {
|
||||
this.app
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/2h', routes.$getStatisticsByTime.bind(routes, '2h'))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/24h', routes.$getStatisticsByTime.bind(routes, '24h'))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/1w', routes.$getStatisticsByTime.bind(routes, '1w'))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/1m', routes.$getStatisticsByTime.bind(routes, '1m'))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/3m', routes.$getStatisticsByTime.bind(routes, '3m'))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/6m', routes.$getStatisticsByTime.bind(routes, '6m'))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/1y', routes.$getStatisticsByTime.bind(routes, '1y'))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/2y', routes.$getStatisticsByTime.bind(routes, '2y'))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/3y', routes.$getStatisticsByTime.bind(routes, '3y'))
|
||||
;
|
||||
|
||||
setUpHttpApiRoutes(): void {
|
||||
bitcoinRoutes.initRoutes(this.app);
|
||||
if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED && config.MEMPOOL.ENABLED) {
|
||||
statisticsRoutes.initRoutes(this.app);
|
||||
}
|
||||
|
||||
if (Common.indexingEnabled()) {
|
||||
this.app
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pools/:interval', routes.$getPools)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:slug/hashrate', routes.$getPoolHistoricalHashrate)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:slug/blocks', routes.$getPoolBlocks)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:slug/blocks/:height', routes.$getPoolBlocks)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/pool/:slug', routes.$getPool)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/hashrate/pools/:interval', routes.$getPoolsHistoricalHashrate)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/hashrate/:interval', routes.$getHistoricalHashrate)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/reward-stats/:blockCount', routes.$getRewardStats)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/fees/:interval', routes.$getHistoricalBlockFees)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/rewards/:interval', routes.$getHistoricalBlockRewards)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/fee-rates/:interval', routes.$getHistoricalBlockFeeRates)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mining/blocks/sizes-weights/:interval', routes.$getHistoricalBlockSizeAndWeight)
|
||||
;
|
||||
if (Common.indexingEnabled() && config.MEMPOOL.ENABLED) {
|
||||
miningRoutes.initRoutes(this.app);
|
||||
}
|
||||
|
||||
if (config.BISQ.ENABLED) {
|
||||
this.app
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/stats', routes.getBisqStats)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/tx/:txId', routes.getBisqTransaction)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/block/:hash', routes.getBisqBlock)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/blocks/tip/height', routes.getBisqTip)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/blocks/:index/:length', routes.getBisqBlocks)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/address/:address', routes.getBisqAddress)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/txs/:index/:length', routes.getBisqTransactions)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/currencies', routes.getBisqMarketCurrencies.bind(routes))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/depth', routes.getBisqMarketDepth.bind(routes))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/hloc', routes.getBisqMarketHloc.bind(routes))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/markets', routes.getBisqMarketMarkets.bind(routes))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/offers', routes.getBisqMarketOffers.bind(routes))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/ticker', routes.getBisqMarketTicker.bind(routes))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/trades', routes.getBisqMarketTrades.bind(routes))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/volumes', routes.getBisqMarketVolumes.bind(routes))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/volumes/7d', routes.getBisqMarketVolumes7d.bind(routes))
|
||||
;
|
||||
bisqRoutes.initRoutes(this.app);
|
||||
}
|
||||
|
||||
this.app
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks', routes.getBlocks.bind(routes))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks/:height', routes.getBlocks.bind(routes))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash', routes.getBlock);
|
||||
|
||||
if (config.MEMPOOL.BACKEND !== 'esplora') {
|
||||
this.app
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mempool', routes.getMempool)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mempool/txids', routes.getMempoolTxIds)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'mempool/recent', routes.getRecentMempoolTransactions)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId', routes.getTransaction)
|
||||
.post(config.MEMPOOL.API_URL_PREFIX + 'tx', routes.$postTransaction)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/hex', routes.getRawTransaction)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/status', routes.getTransactionStatus)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'tx/:txId/outspends', routes.getTransactionOutspends)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/header', routes.getBlockHeader)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks/tip/height', routes.getBlockTipHeight)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/txs', routes.getBlockTransactions)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/txs/:index', routes.getBlockTransactions)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/txids', routes.getTxIdsForBlock)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'block-height/:height', routes.getBlockHeight)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'address/:address', routes.getAddress)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'address/:address/txs', routes.getAddressTransactions)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'address/:address/txs/chain/:txId', routes.getAddressTransactions)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'address-prefix/:prefix', routes.getAddressPrefix)
|
||||
;
|
||||
}
|
||||
|
||||
if (Common.isLiquid()) {
|
||||
this.app
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'assets/icons', routes.getAllLiquidIcon)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'assets/featured', routes.$getAllFeaturedLiquidAssets)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'asset/:assetId/icon', routes.getLiquidIcon)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'assets/group/:id', routes.$getAssetGroup)
|
||||
;
|
||||
liquidRoutes.initRoutes(this.app);
|
||||
}
|
||||
if (config.LIGHTNING.ENABLED) {
|
||||
generalLightningRoutes.initRoutes(this.app);
|
||||
nodesRoutes.initRoutes(this.app);
|
||||
channelsRoutes.initRoutes(this.app);
|
||||
}
|
||||
}
|
||||
|
||||
if (Common.isLiquid() && config.DATABASE.ENABLED) {
|
||||
this.app
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/pegs/month', routes.$getElementsPegsByMonth)
|
||||
;
|
||||
healthCheck(): void {
|
||||
const now = Date.now();
|
||||
const stats = v8.getHeapStatistics();
|
||||
this.maxHeapSize = Math.max(stats.used_heap_size, this.maxHeapSize);
|
||||
const warnThreshold = 0.8 * stats.heap_size_limit;
|
||||
|
||||
const byteUnits = getBytesUnit(Math.max(this.maxHeapSize, stats.heap_size_limit));
|
||||
|
||||
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)})!`);
|
||||
}
|
||||
if (this.lastHeapLogTime === null || (now - this.lastHeapLogTime) > (this.heapLogInterval * 1000)) {
|
||||
logger.debug(`Memory usage: ${formatBytes(this.maxHeapSize, byteUnits)} / ${formatBytes(stats.heap_size_limit, byteUnits)}`);
|
||||
this.warnedHeapCritical = false;
|
||||
this.maxHeapSize = 0;
|
||||
this.lastHeapLogTime = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const server = new Server();
|
||||
((): Server => new Server())();
|
||||
|
||||
@@ -1,53 +1,152 @@
|
||||
import { Common } from './api/common';
|
||||
import blocks from './api/blocks';
|
||||
import mempool from './api/mempool';
|
||||
import mining from './api/mining';
|
||||
import mining from './api/mining/mining';
|
||||
import logger from './logger';
|
||||
import HashratesRepository from './repositories/HashratesRepository';
|
||||
import bitcoinClient from './api/bitcoin/bitcoin-client';
|
||||
import priceUpdater from './tasks/price-updater';
|
||||
import PricesRepository from './repositories/PricesRepository';
|
||||
|
||||
export interface CoreIndex {
|
||||
name: string;
|
||||
synced: boolean;
|
||||
best_block_height: number;
|
||||
}
|
||||
|
||||
class Indexer {
|
||||
runIndexer = true;
|
||||
indexerRunning = false;
|
||||
tasksRunning: string[] = [];
|
||||
coreIndexes: CoreIndex[] = [];
|
||||
|
||||
constructor() {
|
||||
/**
|
||||
* Check which core index is available for indexing
|
||||
*/
|
||||
public async checkAvailableCoreIndexes(): Promise<void> {
|
||||
const updatedCoreIndexes: CoreIndex[] = [];
|
||||
|
||||
const indexes: any = await bitcoinClient.getIndexInfo();
|
||||
for (const indexName in indexes) {
|
||||
const newState = {
|
||||
name: indexName,
|
||||
synced: indexes[indexName].synced,
|
||||
best_block_height: indexes[indexName].best_block_height,
|
||||
};
|
||||
logger.info(`Core index '${indexName}' is ${indexes[indexName].synced ? 'synced' : 'not synced'}. Best block height is ${indexes[indexName].best_block_height}`);
|
||||
updatedCoreIndexes.push(newState);
|
||||
|
||||
if (indexName === 'coinstatsindex' && newState.synced === true) {
|
||||
const previousState = this.isCoreIndexReady('coinstatsindex');
|
||||
// if (!previousState || previousState.synced === false) {
|
||||
this.runSingleTask('coinStatsIndex');
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
this.coreIndexes = updatedCoreIndexes;
|
||||
}
|
||||
|
||||
public reindex() {
|
||||
/**
|
||||
* Return the best block height if a core index is available, or 0 if not
|
||||
*
|
||||
* @param name
|
||||
* @returns
|
||||
*/
|
||||
public isCoreIndexReady(name: string): CoreIndex | null {
|
||||
for (const index of this.coreIndexes) {
|
||||
if (index.name === name && index.synced === true) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public reindex(): void {
|
||||
if (Common.indexingEnabled()) {
|
||||
this.runIndexer = true;
|
||||
}
|
||||
}
|
||||
|
||||
public async $run() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
public async $run(): Promise<void> {
|
||||
if (!Common.indexingEnabled() || this.runIndexer === false ||
|
||||
this.indexerRunning === true || mempool.hasPriority()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Do not attempt to index anything unless Bitcoin Core is fully synced
|
||||
const blockchainInfo = await bitcoinClient.getBlockchainInfo();
|
||||
if (blockchainInfo.blocks !== blockchainInfo.headers) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.runIndexer = false;
|
||||
this.indexerRunning = true;
|
||||
|
||||
logger.debug(`Running mining indexer`);
|
||||
|
||||
await this.checkAvailableCoreIndexes();
|
||||
|
||||
try {
|
||||
await blocks.$generateBlockDatabase();
|
||||
await this.$resetHashratesIndexingState();
|
||||
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
|
||||
logger.warn(`The chain of block hash is invalid, re-indexing invalid data in 10 seconds.`, logger.tags.mining);
|
||||
setTimeout(() => this.reindex(), 10000);
|
||||
this.indexerRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.runSingleTask('blocksPrices');
|
||||
await mining.$indexDifficultyAdjustments();
|
||||
await mining.$generateNetworkHashrateHistory();
|
||||
await mining.$generatePoolHashrateHistory();
|
||||
await blocks.$generateBlocksSummariesDatabase();
|
||||
await blocks.$generateCPFPDatabase();
|
||||
} catch (e) {
|
||||
this.reindex();
|
||||
logger.err(`Indexer failed, trying again later. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
this.indexerRunning = false;
|
||||
logger.err(`Indexer failed, trying again in 10 seconds. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
setTimeout(() => this.reindex(), 10000);
|
||||
this.indexerRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.indexerRunning = false;
|
||||
}
|
||||
|
||||
async $resetHashratesIndexingState() {
|
||||
try {
|
||||
await HashratesRepository.$setLatestRun('last_hashrates_indexing', 0);
|
||||
await HashratesRepository.$setLatestRun('last_weekly_hashrates_indexing', 0);
|
||||
} catch (e) {
|
||||
logger.err(`Cannot reset hashrate indexing timestamps. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
const runEvery = 1000 * 3600; // 1 hour
|
||||
logger.debug(`Indexing completed. Next run planned at ${new Date(new Date().getTime() + runEvery).toUTCString()}`);
|
||||
setTimeout(() => this.reindex(), runEvery);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,22 +32,27 @@ class Logger {
|
||||
local7: 23
|
||||
};
|
||||
|
||||
public tags = {
|
||||
mining: 'Mining',
|
||||
ln: 'Lightning',
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
public emerg: ((msg: string) => void);
|
||||
public emerg: ((msg: string, tag?: string) => void);
|
||||
// @ts-ignore
|
||||
public alert: ((msg: string) => void);
|
||||
public alert: ((msg: string, tag?: string) => void);
|
||||
// @ts-ignore
|
||||
public crit: ((msg: string) => void);
|
||||
public crit: ((msg: string, tag?: string) => void);
|
||||
// @ts-ignore
|
||||
public err: ((msg: string) => void);
|
||||
public err: ((msg: string, tag?: string) => void);
|
||||
// @ts-ignore
|
||||
public warn: ((msg: string) => void);
|
||||
public warn: ((msg: string, tag?: string) => void);
|
||||
// @ts-ignore
|
||||
public notice: ((msg: string) => void);
|
||||
public notice: ((msg: string, tag?: string) => void);
|
||||
// @ts-ignore
|
||||
public info: ((msg: string) => void);
|
||||
public info: ((msg: string, tag?: string) => void);
|
||||
// @ts-ignore
|
||||
public debug: ((msg: string) => void);
|
||||
public debug: ((msg: string, tag?: string) => void);
|
||||
|
||||
private name = 'mempool';
|
||||
private client: dgram.Socket;
|
||||
@@ -64,15 +69,22 @@ class Logger {
|
||||
this.network = this.getNetwork();
|
||||
}
|
||||
|
||||
public updateNetwork(): void {
|
||||
this.network = this.getNetwork();
|
||||
}
|
||||
|
||||
private addprio(prio): void {
|
||||
this[prio] = (function(_this) {
|
||||
return function(msg) {
|
||||
return _this.msg(prio, msg);
|
||||
return function(msg, tag?: string) {
|
||||
return _this.msg(prio, msg, tag);
|
||||
};
|
||||
})(this);
|
||||
}
|
||||
|
||||
private getNetwork(): string {
|
||||
if (config.LIGHTNING.ENABLED) {
|
||||
return config.MEMPOOL.NETWORK === 'mainnet' ? 'lightning' : `${config.MEMPOOL.NETWORK}-lightning`;
|
||||
}
|
||||
if (config.BISQ.ENABLED) {
|
||||
return 'bisq';
|
||||
}
|
||||
@@ -82,7 +94,7 @@ class Logger {
|
||||
return '';
|
||||
}
|
||||
|
||||
private msg(priority, msg) {
|
||||
private msg(priority, msg, tag?: string) {
|
||||
let consolemsg, prionum, syslogmsg;
|
||||
if (typeof msg === 'string' && msg.length > 0) {
|
||||
while (msg[msg.length - 1].charCodeAt(0) === 10) {
|
||||
@@ -91,10 +103,10 @@ class Logger {
|
||||
}
|
||||
const network = this.network ? ' <' + this.network + '>' : '';
|
||||
prionum = Logger.priorities[priority] || Logger.priorities.info;
|
||||
consolemsg = `${this.ts()} [${process.pid}] ${priority.toUpperCase()}:${network} ${msg}`;
|
||||
consolemsg = `${this.ts()} [${process.pid}] ${priority.toUpperCase()}:${network} ${tag ? '[' + tag + '] ' : ''}${msg}`;
|
||||
|
||||
if (config.SYSLOG.ENABLED && Logger.priorities[priority] <= Logger.priorities[config.SYSLOG.MIN_PRIORITY]) {
|
||||
syslogmsg = `<${(Logger.facilities[config.SYSLOG.FACILITY] * 8 + prionum)}> ${this.name}[${process.pid}]: ${priority.toUpperCase()}${network} ${msg}`;
|
||||
syslogmsg = `<${(Logger.facilities[config.SYSLOG.FACILITY] * 8 + prionum)}> ${this.name}[${process.pid}]: ${priority.toUpperCase()}${network} ${tag ? '[' + tag + '] ' : ''}${msg}`;
|
||||
this.syslog(syslogmsg);
|
||||
}
|
||||
if (Logger.priorities[priority] > Logger.priorities[config.MEMPOOL.STDOUT_LOG_MIN_PRIORITY]) {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { IEsploraApi } from './api/bitcoin/esplora-api.interface';
|
||||
import { OrphanedBlock } from './api/chain-tips';
|
||||
import { HeapNode } from './utils/pairing-heap';
|
||||
|
||||
export interface PoolTag {
|
||||
id: number; // mysql row id
|
||||
id: number;
|
||||
uniqueId: number;
|
||||
name: string;
|
||||
link: string;
|
||||
regexes: string; // JSON array
|
||||
@@ -15,6 +18,7 @@ export interface PoolInfo {
|
||||
link: string;
|
||||
blockCount: number;
|
||||
slug: string;
|
||||
avgMatchRate: number | null;
|
||||
}
|
||||
|
||||
export interface PoolStats extends PoolInfo {
|
||||
@@ -22,6 +26,21 @@ export interface PoolStats extends PoolInfo {
|
||||
emptyBlocks: number;
|
||||
}
|
||||
|
||||
export interface BlockAudit {
|
||||
time: number,
|
||||
height: number,
|
||||
hash: string,
|
||||
missingTxs: string[],
|
||||
freshTxs: string[],
|
||||
addedTxs: string[],
|
||||
matchRate: number,
|
||||
}
|
||||
|
||||
export interface AuditScore {
|
||||
hash: string,
|
||||
matchRate?: number,
|
||||
}
|
||||
|
||||
export interface MempoolBlock {
|
||||
blockSize: number;
|
||||
blockVSize: number;
|
||||
@@ -47,6 +66,7 @@ interface VinStrippedToScriptsig {
|
||||
|
||||
interface VoutStrippedToScriptPubkey {
|
||||
scriptpubkey_address: string | undefined;
|
||||
scriptpubkey_asm: string | undefined;
|
||||
value: number;
|
||||
}
|
||||
|
||||
@@ -56,17 +76,57 @@ export interface TransactionExtended extends IEsploraApi.Transaction {
|
||||
firstSeen?: number;
|
||||
effectiveFeePerVsize: number;
|
||||
ancestors?: Ancestor[];
|
||||
descendants?: Ancestor[];
|
||||
bestDescendant?: BestDescendant | null;
|
||||
cpfpChecked?: boolean;
|
||||
deleteAfter?: number;
|
||||
}
|
||||
|
||||
interface Ancestor {
|
||||
export interface AuditTransaction {
|
||||
txid: string;
|
||||
fee: number;
|
||||
weight: number;
|
||||
feePerVsize: number;
|
||||
effectiveFeePerVsize: number;
|
||||
vin: string[];
|
||||
relativesSet: boolean;
|
||||
ancestorMap: Map<string, AuditTransaction>;
|
||||
children: Set<AuditTransaction>;
|
||||
ancestorFee: number;
|
||||
ancestorWeight: number;
|
||||
score: number;
|
||||
used: boolean;
|
||||
modified: boolean;
|
||||
modifiedNode: HeapNode<AuditTransaction>;
|
||||
}
|
||||
|
||||
export interface ThreadTransaction {
|
||||
txid: string;
|
||||
fee: number;
|
||||
weight: number;
|
||||
feePerVsize: number;
|
||||
effectiveFeePerVsize?: number;
|
||||
vin: string[];
|
||||
cpfpRoot?: string;
|
||||
cpfpChecked?: boolean;
|
||||
}
|
||||
|
||||
export interface Ancestor {
|
||||
txid: string;
|
||||
weight: number;
|
||||
fee: number;
|
||||
}
|
||||
|
||||
export interface TransactionSet {
|
||||
fee: number;
|
||||
weight: number;
|
||||
score: number;
|
||||
children?: Set<string>;
|
||||
available?: boolean;
|
||||
modified?: boolean;
|
||||
modifiedNode?: HeapNode<string>;
|
||||
}
|
||||
|
||||
interface BestDescendant {
|
||||
txid: string;
|
||||
weight: number;
|
||||
@@ -75,7 +135,9 @@ interface BestDescendant {
|
||||
|
||||
export interface CpfpInfo {
|
||||
ancestors: Ancestor[];
|
||||
bestDescendant: BestDescendant | null;
|
||||
bestDescendant?: BestDescendant | null;
|
||||
descendants?: Ancestor[];
|
||||
effectiveFeePerVsize?: number;
|
||||
}
|
||||
|
||||
export interface TransactionStripped {
|
||||
@@ -86,26 +148,59 @@ export interface TransactionStripped {
|
||||
}
|
||||
|
||||
export interface BlockExtension {
|
||||
totalFees?: number;
|
||||
medianFee?: number;
|
||||
feeRange?: number[];
|
||||
reward?: number;
|
||||
coinbaseTx?: TransactionMinerInfo;
|
||||
matchRate?: number;
|
||||
pool?: {
|
||||
id: number;
|
||||
totalFees: number;
|
||||
medianFee: number; // median fee rate
|
||||
feeRange: number[]; // fee rate percentiles
|
||||
reward: number;
|
||||
matchRate: number | null;
|
||||
similarity?: number;
|
||||
pool: {
|
||||
id: number; // Note - This is the `unique_id`, not to mix with the auto increment `id`
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
avgFee?: number;
|
||||
avgFeeRate?: number;
|
||||
coinbaseRaw?: string;
|
||||
avgFee: number;
|
||||
avgFeeRate: number;
|
||||
coinbaseRaw: string;
|
||||
orphans: OrphanedBlock[] | null;
|
||||
coinbaseAddress: string | null;
|
||||
coinbaseSignature: string | null;
|
||||
coinbaseSignatureAscii: string | null;
|
||||
virtualSize: number;
|
||||
avgTxSize: number;
|
||||
totalInputs: number;
|
||||
totalOutputs: number;
|
||||
totalOutputAmt: number;
|
||||
medianFeeAmt: number | null; // median fee in sats
|
||||
feePercentiles: number[] | null, // fee percentiles in sats
|
||||
segwitTotalTxs: number;
|
||||
segwitTotalSize: number;
|
||||
segwitTotalWeight: number;
|
||||
header: string;
|
||||
utxoSetChange: number;
|
||||
// Requires coinstatsindex, will be set to NULL otherwise
|
||||
utxoSetSize: number | null;
|
||||
totalInputAmt: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Note: Everything that is added in here will be automatically returned through
|
||||
* /api/v1/block and /api/v1/blocks APIs
|
||||
*/
|
||||
export interface BlockExtended extends IEsploraApi.Block {
|
||||
extras: BlockExtension;
|
||||
}
|
||||
|
||||
export interface BlockSummary {
|
||||
id: string;
|
||||
transactions: TransactionStripped[];
|
||||
}
|
||||
|
||||
export interface BlockPrice {
|
||||
height: number;
|
||||
priceId: number;
|
||||
}
|
||||
|
||||
export interface TransactionMinerInfo {
|
||||
vin: VinStrippedToScriptsig[];
|
||||
vout: VoutStrippedToScriptPubkey[];
|
||||
@@ -199,12 +294,12 @@ interface RequiredParams {
|
||||
}
|
||||
|
||||
export interface ILoadingIndicators { [name: string]: number; }
|
||||
export interface IConversionRates { [currency: string]: number; }
|
||||
|
||||
export interface IBackendInfo {
|
||||
hostname: string;
|
||||
gitCommit: string;
|
||||
version: string;
|
||||
lightning: boolean;
|
||||
}
|
||||
|
||||
export interface IDifficultyAdjustment {
|
||||
@@ -219,8 +314,53 @@ export interface IDifficultyAdjustment {
|
||||
timeOffset: number;
|
||||
}
|
||||
|
||||
export interface IndexedDifficultyAdjustment {
|
||||
time: number; // UNIX timestamp
|
||||
height: number; // Block height
|
||||
difficulty: number;
|
||||
adjustment: number;
|
||||
}
|
||||
|
||||
export interface RewardStats {
|
||||
totalReward: number;
|
||||
totalFee: number;
|
||||
totalTx: number;
|
||||
}
|
||||
|
||||
export interface ITopNodesPerChannels {
|
||||
publicKey: string,
|
||||
alias: string,
|
||||
channels?: number,
|
||||
capacity: number,
|
||||
firstSeen?: number,
|
||||
updatedAt?: number,
|
||||
city?: any,
|
||||
country?: any,
|
||||
}
|
||||
|
||||
export interface ITopNodesPerCapacity {
|
||||
publicKey: string,
|
||||
alias: string,
|
||||
capacity: number,
|
||||
channels?: number,
|
||||
firstSeen?: number,
|
||||
updatedAt?: number,
|
||||
city?: any,
|
||||
country?: any,
|
||||
}
|
||||
|
||||
export interface INodesRanking {
|
||||
topByCapacity: ITopNodesPerCapacity[];
|
||||
topByChannels: ITopNodesPerChannels[];
|
||||
}
|
||||
|
||||
export interface IOldestNodes {
|
||||
publicKey: string,
|
||||
alias: string,
|
||||
firstSeen: number,
|
||||
channels?: number,
|
||||
capacity: number,
|
||||
updatedAt?: number,
|
||||
city?: any,
|
||||
country?: any,
|
||||
}
|
||||
|
||||
110
backend/src/repositories/BlocksAuditsRepository.ts
Normal file
110
backend/src/repositories/BlocksAuditsRepository.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import blocks from '../api/blocks';
|
||||
import DB from '../database';
|
||||
import logger from '../logger';
|
||||
import { BlockAudit, AuditScore } from '../mempool.interfaces';
|
||||
|
||||
class BlocksAuditRepositories {
|
||||
public async $saveAudit(audit: BlockAudit): Promise<void> {
|
||||
try {
|
||||
await DB.query(`INSERT INTO blocks_audits(time, height, hash, missing_txs, added_txs, fresh_txs, match_rate)
|
||||
VALUE (FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?)`, [audit.time, audit.height, audit.hash, JSON.stringify(audit.missingTxs),
|
||||
JSON.stringify(audit.addedTxs), JSON.stringify(audit.freshTxs), audit.matchRate]);
|
||||
} catch (e: any) {
|
||||
if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart
|
||||
logger.debug(`Cannot save block audit for block ${audit.hash} because it has already been indexed, ignoring`);
|
||||
} else {
|
||||
logger.err(`Cannot save block audit into db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async $getBlockPredictionsHistory(div: number, interval: string | null): Promise<any> {
|
||||
try {
|
||||
let query = `SELECT UNIX_TIMESTAMP(time) as time, height, match_rate FROM blocks_audits`;
|
||||
|
||||
if (interval !== null) {
|
||||
query += ` WHERE time BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
|
||||
}
|
||||
|
||||
query += ` GROUP BY UNIX_TIMESTAMP(time) DIV ${div} ORDER BY height`;
|
||||
|
||||
const [rows] = await DB.query(query);
|
||||
return rows;
|
||||
} catch (e: any) {
|
||||
logger.err(`Cannot fetch block prediction history. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getPredictionsCount(): Promise<number> {
|
||||
try {
|
||||
const [rows] = await DB.query(`SELECT count(hash) as count FROM blocks_audits`);
|
||||
return rows[0].count;
|
||||
} catch (e: any) {
|
||||
logger.err(`Cannot fetch block prediction history. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getBlockAudit(hash: string): Promise<any> {
|
||||
try {
|
||||
const [rows]: any[] = await DB.query(
|
||||
`SELECT blocks.height, blocks.hash as id, UNIX_TIMESTAMP(blocks.blockTimestamp) as timestamp, blocks.size,
|
||||
blocks.weight, blocks.tx_count,
|
||||
transactions, template, missing_txs as missingTxs, added_txs as addedTxs, fresh_txs as freshTxs, match_rate as matchRate
|
||||
FROM blocks_audits
|
||||
JOIN blocks ON blocks.hash = blocks_audits.hash
|
||||
JOIN blocks_summaries ON blocks_summaries.id = blocks_audits.hash
|
||||
WHERE blocks_audits.hash = "${hash}"
|
||||
`);
|
||||
|
||||
if (rows.length) {
|
||||
rows[0].missingTxs = JSON.parse(rows[0].missingTxs);
|
||||
rows[0].addedTxs = JSON.parse(rows[0].addedTxs);
|
||||
rows[0].freshTxs = JSON.parse(rows[0].freshTxs);
|
||||
rows[0].transactions = JSON.parse(rows[0].transactions);
|
||||
rows[0].template = JSON.parse(rows[0].template);
|
||||
|
||||
if (rows[0].transactions.length) {
|
||||
return rows[0];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e: any) {
|
||||
logger.err(`Cannot fetch block audit from db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getBlockAuditScore(hash: string): Promise<AuditScore> {
|
||||
try {
|
||||
const [rows]: any[] = await DB.query(
|
||||
`SELECT hash, match_rate as matchRate
|
||||
FROM blocks_audits
|
||||
WHERE blocks_audits.hash = "${hash}"
|
||||
`);
|
||||
return rows[0];
|
||||
} catch (e: any) {
|
||||
logger.err(`Cannot fetch block audit from db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getBlockAuditScores(maxHeight: number, minHeight: number): Promise<AuditScore[]> {
|
||||
try {
|
||||
const [rows]: any[] = await DB.query(
|
||||
`SELECT hash, match_rate as matchRate
|
||||
FROM blocks_audits
|
||||
WHERE blocks_audits.height BETWEEN ? AND ?
|
||||
`, [minHeight, maxHeight]);
|
||||
return rows;
|
||||
} catch (e: any) {
|
||||
logger.err(`Cannot fetch block audit from db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new BlocksAuditRepositories();
|
||||
|
||||
@@ -1,32 +1,98 @@
|
||||
import { BlockExtended } from '../mempool.interfaces';
|
||||
import { BlockExtended, BlockExtension, BlockPrice } from '../mempool.interfaces';
|
||||
import DB from '../database';
|
||||
import logger from '../logger';
|
||||
import { Common } from '../api/common';
|
||||
import { prepareBlock } from '../utils/blocks-utils';
|
||||
import PoolsRepository from './PoolsRepository';
|
||||
import HashratesRepository from './HashratesRepository';
|
||||
import { escape } from 'mysql2';
|
||||
import BlocksSummariesRepository from './BlocksSummariesRepository';
|
||||
import DifficultyAdjustmentsRepository from './DifficultyAdjustmentsRepository';
|
||||
import bitcoinClient from '../api/bitcoin/bitcoin-client';
|
||||
import config from '../config';
|
||||
import chainTips from '../api/chain-tips';
|
||||
import blocks from '../api/blocks';
|
||||
import BlocksAuditsRepository from './BlocksAuditsRepository';
|
||||
|
||||
const BLOCK_DB_FIELDS = `
|
||||
blocks.hash AS id,
|
||||
blocks.height,
|
||||
blocks.version,
|
||||
UNIX_TIMESTAMP(blocks.blockTimestamp) AS timestamp,
|
||||
blocks.bits,
|
||||
blocks.nonce,
|
||||
blocks.difficulty,
|
||||
blocks.merkle_root,
|
||||
blocks.tx_count,
|
||||
blocks.size,
|
||||
blocks.weight,
|
||||
blocks.previous_block_hash AS previousblockhash,
|
||||
UNIX_TIMESTAMP(blocks.median_timestamp) AS mediantime,
|
||||
blocks.fees AS totalFees,
|
||||
blocks.median_fee AS medianFee,
|
||||
blocks.fee_span AS feeRange,
|
||||
blocks.reward,
|
||||
pools.unique_id AS poolId,
|
||||
pools.name AS poolName,
|
||||
pools.slug AS poolSlug,
|
||||
blocks.avg_fee AS avgFee,
|
||||
blocks.avg_fee_rate AS avgFeeRate,
|
||||
blocks.coinbase_raw AS coinbaseRaw,
|
||||
blocks.coinbase_address AS coinbaseAddress,
|
||||
blocks.coinbase_signature AS coinbaseSignature,
|
||||
blocks.coinbase_signature_ascii AS coinbaseSignatureAscii,
|
||||
blocks.avg_tx_size AS avgTxSize,
|
||||
blocks.total_inputs AS totalInputs,
|
||||
blocks.total_outputs AS totalOutputs,
|
||||
blocks.total_output_amt AS totalOutputAmt,
|
||||
blocks.median_fee_amt AS medianFeeAmt,
|
||||
blocks.fee_percentiles AS feePercentiles,
|
||||
blocks.segwit_total_txs AS segwitTotalTxs,
|
||||
blocks.segwit_total_size AS segwitTotalSize,
|
||||
blocks.segwit_total_weight AS segwitTotalWeight,
|
||||
blocks.header,
|
||||
blocks.utxoset_change AS utxoSetChange,
|
||||
blocks.utxoset_size AS utxoSetSize,
|
||||
blocks.total_input_amt AS totalInputAmts
|
||||
`;
|
||||
|
||||
class BlocksRepository {
|
||||
/**
|
||||
* Save indexed block data in the database
|
||||
*/
|
||||
public async $saveBlockInDatabase(block: BlockExtended) {
|
||||
const truncatedCoinbaseSignature = block?.extras?.coinbaseSignature?.substring(0, 500);
|
||||
const truncatedCoinbaseSignatureAscii = block?.extras?.coinbaseSignatureAscii?.substring(0, 500);
|
||||
|
||||
try {
|
||||
const query = `INSERT INTO blocks(
|
||||
height, hash, blockTimestamp, size,
|
||||
weight, tx_count, coinbase_raw, difficulty,
|
||||
pool_id, fees, fee_span, median_fee,
|
||||
reward, version, bits, nonce,
|
||||
merkle_root, previous_block_hash, avg_fee, avg_fee_rate
|
||||
height, hash, blockTimestamp, size,
|
||||
weight, tx_count, coinbase_raw, difficulty,
|
||||
pool_id, fees, fee_span, median_fee,
|
||||
reward, version, bits, nonce,
|
||||
merkle_root, previous_block_hash, avg_fee, avg_fee_rate,
|
||||
median_timestamp, header, coinbase_address,
|
||||
coinbase_signature, utxoset_size, utxoset_change, avg_tx_size,
|
||||
total_inputs, total_outputs, total_input_amt, total_output_amt,
|
||||
fee_percentiles, segwit_total_txs, segwit_total_size, segwit_total_weight,
|
||||
median_fee_amt, coinbase_signature_ascii
|
||||
) VALUE (
|
||||
?, ?, FROM_UNIXTIME(?), ?,
|
||||
?, ?, ?, ?,
|
||||
?, ?, ?, ?,
|
||||
?, ?, ?, ?,
|
||||
?, ?, ?, ?
|
||||
?, ?, ?, ?,
|
||||
FROM_UNIXTIME(?), ?, ?,
|
||||
?, ?, ?, ?,
|
||||
?, ?, ?, ?,
|
||||
?, ?, ?, ?,
|
||||
?, ?
|
||||
)`;
|
||||
|
||||
const poolDbId = await PoolsRepository.$getPoolByUniqueId(block.extras.pool.id);
|
||||
if (!poolDbId) {
|
||||
throw Error(`Could not find a mining pool with the unique_id = ${block.extras.pool.id}. This error should never be printed.`);
|
||||
}
|
||||
|
||||
const params: any[] = [
|
||||
block.height,
|
||||
block.id,
|
||||
@@ -36,7 +102,7 @@ class BlocksRepository {
|
||||
block.tx_count,
|
||||
block.extras.coinbaseRaw,
|
||||
block.difficulty,
|
||||
block.extras.pool?.id, // Should always be set to something
|
||||
poolDbId.id,
|
||||
block.extras.totalFees,
|
||||
JSON.stringify(block.extras.feeRange),
|
||||
block.extras.medianFee,
|
||||
@@ -48,19 +114,63 @@ class BlocksRepository {
|
||||
block.previousblockhash,
|
||||
block.extras.avgFee,
|
||||
block.extras.avgFeeRate,
|
||||
block.mediantime,
|
||||
block.extras.header,
|
||||
block.extras.coinbaseAddress,
|
||||
truncatedCoinbaseSignature,
|
||||
block.extras.utxoSetSize,
|
||||
block.extras.utxoSetChange,
|
||||
block.extras.avgTxSize,
|
||||
block.extras.totalInputs,
|
||||
block.extras.totalOutputs,
|
||||
block.extras.totalInputAmt,
|
||||
block.extras.totalOutputAmt,
|
||||
block.extras.feePercentiles ? JSON.stringify(block.extras.feePercentiles) : null,
|
||||
block.extras.segwitTotalTxs,
|
||||
block.extras.segwitTotalSize,
|
||||
block.extras.segwitTotalWeight,
|
||||
block.extras.medianFeeAmt,
|
||||
truncatedCoinbaseSignatureAscii,
|
||||
];
|
||||
|
||||
await DB.query(query, params);
|
||||
} catch (e: any) {
|
||||
if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart
|
||||
logger.debug(`$saveBlockInDatabase() - Block ${block.height} has already been indexed, ignoring`);
|
||||
logger.debug(`$saveBlockInDatabase() - Block ${block.height} has already been indexed, ignoring`, logger.tags.mining);
|
||||
} else {
|
||||
logger.err('Cannot save indexed block into db. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
logger.err('Cannot save indexed block into db. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save newly indexed data from core coinstatsindex
|
||||
*
|
||||
* @param utxoSetSize
|
||||
* @param totalInputAmt
|
||||
*/
|
||||
public async $updateCoinStatsIndexData(blockHash: string, utxoSetSize: number,
|
||||
totalInputAmt: number
|
||||
) : Promise<void> {
|
||||
try {
|
||||
const query = `
|
||||
UPDATE blocks
|
||||
SET utxoset_size = ?, total_input_amt = ?
|
||||
WHERE hash = ?
|
||||
`;
|
||||
const params: any[] = [
|
||||
utxoSetSize,
|
||||
totalInputAmt,
|
||||
blockHash
|
||||
];
|
||||
await DB.query(query, params);
|
||||
} catch (e: any) {
|
||||
logger.err('Cannot update indexed block coinstatsindex. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all block height that have not been indexed between [startHeight, endHeight]
|
||||
*/
|
||||
@@ -220,6 +330,55 @@ class BlocksRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get average block health for all blocks for a single pool
|
||||
*/
|
||||
public async $getAvgBlockHealthPerPoolId(poolId: number): Promise<number> {
|
||||
const params: any[] = [];
|
||||
const query = `
|
||||
SELECT AVG(blocks_audits.match_rate) AS avg_match_rate
|
||||
FROM blocks
|
||||
JOIN blocks_audits ON blocks.height = blocks_audits.height
|
||||
WHERE blocks.pool_id = ?
|
||||
`;
|
||||
params.push(poolId);
|
||||
|
||||
try {
|
||||
const [rows] = await DB.query(query, params);
|
||||
if (!rows[0] || !rows[0].avg_match_rate) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round(rows[0].avg_match_rate * 100) / 100;
|
||||
} catch (e) {
|
||||
logger.err(`Cannot get average block health for pool id ${poolId}. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get average block health for all blocks for a single pool
|
||||
*/
|
||||
public async $getTotalRewardForPoolId(poolId: number): Promise<number> {
|
||||
const params: any[] = [];
|
||||
const query = `
|
||||
SELECT sum(reward) as total_reward
|
||||
FROM blocks
|
||||
WHERE blocks.pool_id = ?
|
||||
`;
|
||||
params.push(poolId);
|
||||
|
||||
try {
|
||||
const [rows] = await DB.query(query, params);
|
||||
if (!rows[0] || !rows[0].total_reward) {
|
||||
return 0;
|
||||
}
|
||||
return rows[0].total_reward;
|
||||
} catch (e) {
|
||||
logger.err(`Cannot get total reward for pool id ${poolId}. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the oldest indexed block
|
||||
*/
|
||||
@@ -246,34 +405,17 @@ class BlocksRepository {
|
||||
/**
|
||||
* Get blocks mined by a specific mining pool
|
||||
*/
|
||||
public async $getBlocksByPool(slug: string, startHeight?: number): Promise<object[]> {
|
||||
public async $getBlocksByPool(slug: string, startHeight?: number): Promise<BlockExtended[]> {
|
||||
const pool = await PoolsRepository.$getPool(slug);
|
||||
if (!pool) {
|
||||
throw new Error('This mining pool does not exist ' + escape(slug));
|
||||
}
|
||||
|
||||
const params: any[] = [];
|
||||
let query = ` SELECT
|
||||
height,
|
||||
hash as id,
|
||||
UNIX_TIMESTAMP(blocks.blockTimestamp) as blockTimestamp,
|
||||
size,
|
||||
weight,
|
||||
tx_count,
|
||||
coinbase_raw,
|
||||
difficulty,
|
||||
fees,
|
||||
fee_span,
|
||||
median_fee,
|
||||
reward,
|
||||
version,
|
||||
bits,
|
||||
nonce,
|
||||
merkle_root,
|
||||
previous_block_hash as previousblockhash,
|
||||
avg_fee,
|
||||
avg_fee_rate
|
||||
let query = `
|
||||
SELECT ${BLOCK_DB_FIELDS}
|
||||
FROM blocks
|
||||
JOIN pools ON blocks.pool_id = pools.id
|
||||
WHERE pool_id = ?`;
|
||||
params.push(pool.id);
|
||||
|
||||
@@ -286,11 +428,11 @@ class BlocksRepository {
|
||||
LIMIT 10`;
|
||||
|
||||
try {
|
||||
const [rows] = await DB.query(query, params);
|
||||
const [rows]: any[] = await DB.query(query, params);
|
||||
|
||||
const blocks: BlockExtended[] = [];
|
||||
for (const block of <object[]>rows) {
|
||||
blocks.push(prepareBlock(block));
|
||||
for (const block of rows) {
|
||||
blocks.push(await this.formatDbBlockIntoExtendedBlock(block));
|
||||
}
|
||||
|
||||
return blocks;
|
||||
@@ -303,46 +445,21 @@ class BlocksRepository {
|
||||
/**
|
||||
* Get one block by height
|
||||
*/
|
||||
public async $getBlockByHeight(height: number): Promise<object | null> {
|
||||
public async $getBlockByHeight(height: number): Promise<BlockExtended | null> {
|
||||
try {
|
||||
const [rows]: any[] = await DB.query(`SELECT
|
||||
height,
|
||||
hash,
|
||||
hash as id,
|
||||
UNIX_TIMESTAMP(blocks.blockTimestamp) as blockTimestamp,
|
||||
size,
|
||||
weight,
|
||||
tx_count,
|
||||
coinbase_raw,
|
||||
difficulty,
|
||||
pools.id as pool_id,
|
||||
pools.name as pool_name,
|
||||
pools.link as pool_link,
|
||||
pools.slug as pool_slug,
|
||||
pools.addresses as pool_addresses,
|
||||
pools.regexes as pool_regexes,
|
||||
fees,
|
||||
fee_span,
|
||||
median_fee,
|
||||
reward,
|
||||
version,
|
||||
bits,
|
||||
nonce,
|
||||
merkle_root,
|
||||
previous_block_hash as previousblockhash,
|
||||
avg_fee,
|
||||
avg_fee_rate
|
||||
const [rows]: any[] = await DB.query(`
|
||||
SELECT ${BLOCK_DB_FIELDS}
|
||||
FROM blocks
|
||||
JOIN pools ON blocks.pool_id = pools.id
|
||||
WHERE height = ${height};
|
||||
`);
|
||||
WHERE blocks.height = ?`,
|
||||
[height]
|
||||
);
|
||||
|
||||
if (rows.length <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
rows[0].fee_span = JSON.parse(rows[0].fee_span);
|
||||
return rows[0];
|
||||
return await this.formatDbBlockIntoExtendedBlock(rows[0]);
|
||||
} catch (e) {
|
||||
logger.err(`Cannot get indexed block ${height}. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
@@ -355,22 +472,18 @@ class BlocksRepository {
|
||||
public async $getBlockByHash(hash: string): Promise<object | null> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT *, UNIX_TIMESTAMP(blocks.blockTimestamp) as blockTimestamp, hash as id,
|
||||
pools.id as pool_id, pools.name as pool_name, pools.link as pool_link, pools.slug as pool_slug,
|
||||
pools.addresses as pool_addresses, pools.regexes as pool_regexes,
|
||||
previous_block_hash as previousblockhash
|
||||
SELECT ${BLOCK_DB_FIELDS}
|
||||
FROM blocks
|
||||
JOIN pools ON blocks.pool_id = pools.id
|
||||
WHERE hash = '${hash}';
|
||||
WHERE hash = ?;
|
||||
`;
|
||||
const [rows]: any[] = await DB.query(query);
|
||||
const [rows]: any[] = await DB.query(query, [hash]);
|
||||
|
||||
if (rows.length <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
rows[0].fee_span = JSON.parse(rows[0].fee_span);
|
||||
return rows[0];
|
||||
|
||||
return await this.formatDbBlockIntoExtendedBlock(rows[0]);
|
||||
} catch (e) {
|
||||
logger.err(`Cannot get indexed block ${hash}. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
@@ -380,51 +493,55 @@ class BlocksRepository {
|
||||
/**
|
||||
* Return blocks difficulty
|
||||
*/
|
||||
public async $getBlocksDifficulty(interval: string | null): Promise<object[]> {
|
||||
interval = Common.getSqlInterval(interval);
|
||||
|
||||
// :D ... Yeah don't ask me about this one https://stackoverflow.com/a/40303162
|
||||
// Basically, using temporary user defined fields, we are able to extract all
|
||||
// difficulty adjustments from the blocks tables.
|
||||
// This allow use to avoid indexing it in another table.
|
||||
let query = `
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
(
|
||||
SELECT
|
||||
UNIX_TIMESTAMP(blockTimestamp) as timestamp, difficulty, height,
|
||||
IF(@prevStatus = YT.difficulty, @rn := @rn + 1,
|
||||
IF(@prevStatus := YT.difficulty, @rn := 1, @rn := 1)
|
||||
) AS rn
|
||||
FROM blocks YT
|
||||
CROSS JOIN
|
||||
(
|
||||
SELECT @prevStatus := -1, @rn := 1
|
||||
) AS var
|
||||
`;
|
||||
|
||||
if (interval) {
|
||||
query += ` WHERE blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
|
||||
}
|
||||
|
||||
query += `
|
||||
ORDER BY YT.height
|
||||
) AS t
|
||||
WHERE t.rn = 1
|
||||
ORDER BY t.height
|
||||
`;
|
||||
|
||||
public async $getBlocksDifficulty(): Promise<object[]> {
|
||||
try {
|
||||
const [rows]: any[] = await DB.query(query);
|
||||
|
||||
for (const row of rows) {
|
||||
delete row['rn'];
|
||||
}
|
||||
|
||||
const [rows]: any[] = await DB.query(`SELECT UNIX_TIMESTAMP(blockTimestamp) as time, height, difficulty FROM blocks`);
|
||||
return rows;
|
||||
} catch (e) {
|
||||
logger.err('Cannot generate difficulty history. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
logger.err('Cannot get blocks difficulty list from the db. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first block at or directly after a given timestamp
|
||||
* @param timestamp number unix time in seconds
|
||||
* @returns The height and timestamp of a block (timestamp might vary from given timestamp)
|
||||
*/
|
||||
public async $getBlockHeightFromTimestamp(
|
||||
timestamp: number,
|
||||
): Promise<{ height: number; hash: string; timestamp: number }> {
|
||||
try {
|
||||
// Get first block at or after the given timestamp
|
||||
const query = `SELECT height, hash, blockTimestamp as timestamp FROM blocks
|
||||
WHERE blockTimestamp <= FROM_UNIXTIME(?)
|
||||
ORDER BY blockTimestamp DESC
|
||||
LIMIT 1`;
|
||||
const params = [timestamp];
|
||||
const [rows]: any[][] = await DB.query(query, params);
|
||||
if (rows.length === 0) {
|
||||
throw new Error(`No block was found before timestamp ${timestamp}`);
|
||||
}
|
||||
|
||||
return rows[0];
|
||||
} catch (e) {
|
||||
logger.err(
|
||||
'Cannot get block height from timestamp from the db. Reason: ' +
|
||||
(e instanceof Error ? e.message : e),
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return blocks height
|
||||
*/
|
||||
public async $getBlocksHeightsAndTimestamp(): Promise<object[]> {
|
||||
try {
|
||||
const [rows]: any[] = await DB.query(`SELECT height, blockTimestamp as timestamp FROM blocks`);
|
||||
return rows;
|
||||
} catch (e) {
|
||||
logger.err('Cannot get blocks height and timestamp from the db. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -451,34 +568,21 @@ class BlocksRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Check if the last 10 blocks chain is valid
|
||||
*/
|
||||
public async $validateRecentBlocks(): Promise<boolean> {
|
||||
try {
|
||||
const [lastBlocks]: any[] = await DB.query(`SELECT height, hash, previous_block_hash FROM blocks ORDER BY height DESC LIMIT 10`);
|
||||
|
||||
for (let i = 0; i < lastBlocks.length - 1; ++i) {
|
||||
if (lastBlocks[i].previous_block_hash !== lastBlocks[i + 1].hash) {
|
||||
logger.warn(`Chain divergence detected at block ${lastBlocks[i].height}, re-indexing most recent data`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
return true; // Don't do anything if there is a db error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the chain of block hash is valid and delete data from the stale branch if needed
|
||||
*/
|
||||
public async $validateChain(): Promise<boolean> {
|
||||
try {
|
||||
const start = new Date().getTime();
|
||||
const [blocks]: any[] = await DB.query(`SELECT height, hash, previous_block_hash,
|
||||
UNIX_TIMESTAMP(blockTimestamp) as timestamp FROM blocks ORDER BY height`);
|
||||
const [blocks]: any[] = await DB.query(`
|
||||
SELECT
|
||||
height,
|
||||
hash,
|
||||
previous_block_hash,
|
||||
UNIX_TIMESTAMP(blockTimestamp) AS timestamp
|
||||
FROM blocks
|
||||
ORDER BY height
|
||||
`);
|
||||
|
||||
let partialMsg = false;
|
||||
let idx = 1;
|
||||
@@ -493,15 +597,17 @@ class BlocksRepository {
|
||||
}
|
||||
|
||||
if (blocks[idx].previous_block_hash !== blocks[idx - 1].hash) {
|
||||
logger.warn(`Chain divergence detected at block ${blocks[idx - 1].height}, re-indexing newer blocks and hashrates`);
|
||||
logger.warn(`Chain divergence detected at block ${blocks[idx - 1].height}`);
|
||||
await this.$deleteBlocksFrom(blocks[idx - 1].height);
|
||||
await BlocksSummariesRepository.$deleteBlocksFrom(blocks[idx - 1].height);
|
||||
await HashratesRepository.$deleteHashratesFromTimestamp(blocks[idx - 1].timestamp - 604800);
|
||||
await DifficultyAdjustmentsRepository.$deleteAdjustementsFromHeight(blocks[idx - 1].height);
|
||||
return false;
|
||||
}
|
||||
++idx;
|
||||
}
|
||||
|
||||
logger.info(`${idx} blocks hash validated in ${new Date().getTime() - start} ms`);
|
||||
logger.debug(`${idx} blocks hash validated in ${new Date().getTime() - start} ms`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.err('Cannot validate chain of block hash. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
@@ -528,10 +634,14 @@ class BlocksRepository {
|
||||
public async $getHistoricalBlockFees(div: number, interval: string | null): Promise<any> {
|
||||
try {
|
||||
let query = `SELECT
|
||||
CAST(AVG(height) as INT) as avgHeight,
|
||||
CAST(AVG(blocks.height) as INT) as avgHeight,
|
||||
CAST(AVG(UNIX_TIMESTAMP(blockTimestamp)) as INT) as timestamp,
|
||||
CAST(AVG(fees) as INT) as avgFees
|
||||
FROM blocks`;
|
||||
CAST(AVG(fees) as INT) as avgFees,
|
||||
prices.USD
|
||||
FROM blocks
|
||||
JOIN blocks_prices on blocks_prices.height = blocks.height
|
||||
JOIN prices on prices.id = blocks_prices.price_id
|
||||
`;
|
||||
|
||||
if (interval !== null) {
|
||||
query += ` WHERE blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
|
||||
@@ -553,10 +663,14 @@ class BlocksRepository {
|
||||
public async $getHistoricalBlockRewards(div: number, interval: string | null): Promise<any> {
|
||||
try {
|
||||
let query = `SELECT
|
||||
CAST(AVG(height) as INT) as avgHeight,
|
||||
CAST(AVG(blocks.height) as INT) as avgHeight,
|
||||
CAST(AVG(UNIX_TIMESTAMP(blockTimestamp)) as INT) as timestamp,
|
||||
CAST(AVG(reward) as INT) as avgRewards
|
||||
FROM blocks`;
|
||||
CAST(AVG(reward) as INT) as avgRewards,
|
||||
prices.USD
|
||||
FROM blocks
|
||||
JOIN blocks_prices on blocks_prices.height = blocks.height
|
||||
JOIN prices on prices.id = blocks_prices.price_id
|
||||
`;
|
||||
|
||||
if (interval !== null) {
|
||||
query += ` WHERE blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
|
||||
@@ -652,6 +766,236 @@ class BlocksRepository {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of blocks that have been indexed
|
||||
*/
|
||||
public async $getIndexedBlocks(): Promise<any[]> {
|
||||
try {
|
||||
const [rows]: any = await DB.query(`SELECT height, hash FROM blocks ORDER BY height DESC`);
|
||||
return rows;
|
||||
} catch (e) {
|
||||
logger.err('Cannot generate block size and weight history. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of blocks that have not had CPFP data indexed
|
||||
*/
|
||||
public async $getCPFPUnindexedBlocks(): Promise<any[]> {
|
||||
try {
|
||||
const blockchainInfo = await bitcoinClient.getBlockchainInfo();
|
||||
const currentBlockHeight = blockchainInfo.blocks;
|
||||
let indexingBlockAmount = Math.min(config.MEMPOOL.INDEXING_BLOCKS_AMOUNT, currentBlockHeight);
|
||||
if (indexingBlockAmount <= -1) {
|
||||
indexingBlockAmount = currentBlockHeight + 1;
|
||||
}
|
||||
const minHeight = Math.max(0, currentBlockHeight - indexingBlockAmount + 1);
|
||||
|
||||
const [rows]: any[] = await DB.query(`
|
||||
SELECT height
|
||||
FROM compact_cpfp_clusters
|
||||
WHERE height <= ? AND height >= ?
|
||||
GROUP BY height
|
||||
ORDER BY height DESC;
|
||||
`, [currentBlockHeight, minHeight]);
|
||||
|
||||
const indexedHeights = {};
|
||||
rows.forEach((row) => { indexedHeights[row.height] = true; });
|
||||
const allHeights: number[] = Array.from(Array(currentBlockHeight - minHeight + 1).keys(), n => n + minHeight).reverse();
|
||||
const unindexedHeights = allHeights.filter(x => !indexedHeights[x]);
|
||||
|
||||
return unindexedHeights;
|
||||
} catch (e) {
|
||||
logger.err('Cannot fetch CPFP unindexed blocks. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the oldest block from a consecutive chain of block from the most recent one
|
||||
*/
|
||||
public async $getOldestConsecutiveBlock(): Promise<any> {
|
||||
try {
|
||||
const [rows]: any = await DB.query(`SELECT height, UNIX_TIMESTAMP(blockTimestamp) as timestamp, difficulty FROM blocks ORDER BY height DESC`);
|
||||
for (let i = 0; i < rows.length - 1; ++i) {
|
||||
if (rows[i].height - rows[i + 1].height > 1) {
|
||||
return rows[i];
|
||||
}
|
||||
}
|
||||
return rows[rows.length - 1];
|
||||
} catch (e) {
|
||||
logger.err('Cannot generate block size and weight history. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all blocks which have not be linked to a price yet
|
||||
*/
|
||||
public async $getBlocksWithoutPrice(): Promise<object[]> {
|
||||
try {
|
||||
const [rows]: any[] = await DB.query(`
|
||||
SELECT UNIX_TIMESTAMP(blocks.blockTimestamp) as timestamp, blocks.height
|
||||
FROM blocks
|
||||
LEFT JOIN blocks_prices ON blocks.height = blocks_prices.height
|
||||
WHERE blocks_prices.height IS NULL
|
||||
ORDER BY blocks.height
|
||||
`);
|
||||
return rows;
|
||||
} catch (e) {
|
||||
logger.err('Cannot get blocks height and timestamp from the db. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save block price by batch
|
||||
*/
|
||||
public async $saveBlockPrices(blockPrices: BlockPrice[]): Promise<void> {
|
||||
try {
|
||||
let query = `INSERT INTO blocks_prices(height, price_id) VALUES`;
|
||||
for (const price of blockPrices) {
|
||||
query += ` (${price.height}, ${price.priceId}),`;
|
||||
}
|
||||
query = query.slice(0, -1);
|
||||
await DB.query(query);
|
||||
} catch (e: any) {
|
||||
if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart
|
||||
logger.debug(`Cannot save blocks prices for blocks [${blockPrices[0].height} to ${blockPrices[blockPrices.length - 1].height}] because it has already been indexed, ignoring`);
|
||||
} else {
|
||||
logger.err(`Cannot save blocks prices for blocks [${blockPrices[0].height} to ${blockPrices[blockPrices.length - 1].height}] into db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all indexed blocsk with missing coinstatsindex data
|
||||
*/
|
||||
public async $getBlocksMissingCoinStatsIndex(maxHeight: number, minHeight: number): Promise<any> {
|
||||
try {
|
||||
const [blocks] = await DB.query(`
|
||||
SELECT height, hash
|
||||
FROM blocks
|
||||
WHERE height >= ${minHeight} AND height <= ${maxHeight} AND
|
||||
(utxoset_size IS NULL OR total_input_amt IS NULL)
|
||||
`);
|
||||
return blocks;
|
||||
} catch (e) {
|
||||
logger.err(`Cannot get blocks with missing coinstatsindex. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save indexed median fee to avoid recomputing it later
|
||||
*
|
||||
* @param id
|
||||
* @param feePercentiles
|
||||
*/
|
||||
public async $saveFeePercentilesForBlockId(id: string, feePercentiles: number[]): Promise<void> {
|
||||
try {
|
||||
await DB.query(`
|
||||
UPDATE blocks SET fee_percentiles = ?, median_fee_amt = ?
|
||||
WHERE hash = ?`,
|
||||
[JSON.stringify(feePercentiles), feePercentiles[3], id]
|
||||
);
|
||||
} catch (e) {
|
||||
logger.err(`Cannot update block fee_percentiles. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a mysql row block into a BlockExtended. Note that you
|
||||
* must provide the correct field into dbBlk object param
|
||||
*
|
||||
* @param dbBlk
|
||||
*/
|
||||
private async formatDbBlockIntoExtendedBlock(dbBlk: any): Promise<BlockExtended> {
|
||||
const blk: Partial<BlockExtended> = {};
|
||||
const extras: Partial<BlockExtension> = {};
|
||||
|
||||
// IEsploraApi.Block
|
||||
blk.id = dbBlk.id;
|
||||
blk.height = dbBlk.height;
|
||||
blk.version = dbBlk.version;
|
||||
blk.timestamp = dbBlk.timestamp;
|
||||
blk.bits = dbBlk.bits;
|
||||
blk.nonce = dbBlk.nonce;
|
||||
blk.difficulty = dbBlk.difficulty;
|
||||
blk.merkle_root = dbBlk.merkle_root;
|
||||
blk.tx_count = dbBlk.tx_count;
|
||||
blk.size = dbBlk.size;
|
||||
blk.weight = dbBlk.weight;
|
||||
blk.previousblockhash = dbBlk.previousblockhash;
|
||||
blk.mediantime = dbBlk.mediantime;
|
||||
|
||||
// BlockExtension
|
||||
extras.totalFees = dbBlk.totalFees;
|
||||
extras.medianFee = dbBlk.medianFee;
|
||||
extras.feeRange = JSON.parse(dbBlk.feeRange);
|
||||
extras.reward = dbBlk.reward;
|
||||
extras.pool = {
|
||||
id: dbBlk.poolId,
|
||||
name: dbBlk.poolName,
|
||||
slug: dbBlk.poolSlug,
|
||||
};
|
||||
extras.avgFee = dbBlk.avgFee;
|
||||
extras.avgFeeRate = dbBlk.avgFeeRate;
|
||||
extras.coinbaseRaw = dbBlk.coinbaseRaw;
|
||||
extras.coinbaseAddress = dbBlk.coinbaseAddress;
|
||||
extras.coinbaseSignature = dbBlk.coinbaseSignature;
|
||||
extras.coinbaseSignatureAscii = dbBlk.coinbaseSignatureAscii;
|
||||
extras.avgTxSize = dbBlk.avgTxSize;
|
||||
extras.totalInputs = dbBlk.totalInputs;
|
||||
extras.totalOutputs = dbBlk.totalOutputs;
|
||||
extras.totalOutputAmt = dbBlk.totalOutputAmt;
|
||||
extras.medianFeeAmt = dbBlk.medianFeeAmt;
|
||||
extras.feePercentiles = JSON.parse(dbBlk.feePercentiles);
|
||||
extras.segwitTotalTxs = dbBlk.segwitTotalTxs;
|
||||
extras.segwitTotalSize = dbBlk.segwitTotalSize;
|
||||
extras.segwitTotalWeight = dbBlk.segwitTotalWeight;
|
||||
extras.header = dbBlk.header,
|
||||
extras.utxoSetChange = dbBlk.utxoSetChange;
|
||||
extras.utxoSetSize = dbBlk.utxoSetSize;
|
||||
extras.totalInputAmt = dbBlk.totalInputAmt;
|
||||
extras.virtualSize = dbBlk.weight / 4.0;
|
||||
|
||||
// Re-org can happen after indexing so we need to always get the
|
||||
// latest state from core
|
||||
extras.orphans = chainTips.getOrphanedBlocksAtHeight(dbBlk.height);
|
||||
|
||||
// Match rate is not part of the blocks table, but it is part of APIs so we must include it
|
||||
extras.matchRate = null;
|
||||
if (config.MEMPOOL.AUDIT) {
|
||||
const auditScore = await BlocksAuditsRepository.$getBlockAuditScore(dbBlk.id);
|
||||
if (auditScore != null) {
|
||||
extras.matchRate = auditScore.matchRate;
|
||||
}
|
||||
}
|
||||
|
||||
// If we're missing block summary related field, check if we can populate them on the fly now
|
||||
if (Common.blocksSummariesIndexingEnabled() &&
|
||||
(extras.medianFeeAmt === null || extras.feePercentiles === null))
|
||||
{
|
||||
extras.feePercentiles = await BlocksSummariesRepository.$getFeePercentilesByBlockId(dbBlk.id);
|
||||
if (extras.feePercentiles === null) {
|
||||
const block = await bitcoinClient.getBlock(dbBlk.id, 2);
|
||||
const summary = blocks.summarizeBlock(block);
|
||||
await BlocksSummariesRepository.$saveSummary({ height: block.height, mined: summary });
|
||||
extras.feePercentiles = await BlocksSummariesRepository.$getFeePercentilesByBlockId(dbBlk.id);
|
||||
}
|
||||
if (extras.feePercentiles !== null) {
|
||||
extras.medianFeeAmt = extras.feePercentiles[3];
|
||||
}
|
||||
}
|
||||
|
||||
blk.extras = <BlockExtension>extras;
|
||||
return <BlockExtended>blk;
|
||||
}
|
||||
}
|
||||
|
||||
export default new BlocksRepository();
|
||||
|
||||
128
backend/src/repositories/BlocksSummariesRepository.ts
Normal file
128
backend/src/repositories/BlocksSummariesRepository.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import DB from '../database';
|
||||
import logger from '../logger';
|
||||
import { BlockSummary } from '../mempool.interfaces';
|
||||
|
||||
class BlocksSummariesRepository {
|
||||
public async $getByBlockId(id: string): Promise<BlockSummary | undefined> {
|
||||
try {
|
||||
const [summary]: any[] = await DB.query(`SELECT * from blocks_summaries WHERE id = ?`, [id]);
|
||||
if (summary.length > 0) {
|
||||
summary[0].transactions = JSON.parse(summary[0].transactions);
|
||||
return summary[0];
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err(`Cannot get block summary for block id ${id}. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public async $saveSummary(params: { height: number, mined?: BlockSummary}) {
|
||||
const blockId = params.mined?.id;
|
||||
try {
|
||||
const transactions = JSON.stringify(params.mined?.transactions || []);
|
||||
await DB.query(`
|
||||
INSERT INTO blocks_summaries (height, id, transactions, template)
|
||||
VALUE (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
transactions = ?
|
||||
`, [params.height, blockId, transactions, '[]', transactions]);
|
||||
} catch (e: any) {
|
||||
if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart
|
||||
logger.debug(`Cannot save block summary for ${blockId} because it has already been indexed, ignoring`);
|
||||
} else {
|
||||
logger.debug(`Cannot save block summary for ${blockId}. Reason: ${e instanceof Error ? e.message : e}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async $saveTemplate(params: { height: number, template: BlockSummary}) {
|
||||
const blockId = params.template?.id;
|
||||
try {
|
||||
const transactions = JSON.stringify(params.template?.transactions || []);
|
||||
await DB.query(`
|
||||
INSERT INTO blocks_summaries (height, id, transactions, template)
|
||||
VALUE (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
template = ?
|
||||
`, [params.height, blockId, '[]', transactions, transactions]);
|
||||
} catch (e: any) {
|
||||
if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart
|
||||
logger.debug(`Cannot save block template for ${blockId} because it has already been indexed, ignoring`);
|
||||
} else {
|
||||
logger.debug(`Cannot save block template for ${blockId}. Reason: ${e instanceof Error ? e.message : e}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async $getIndexedSummariesId(): Promise<string[]> {
|
||||
try {
|
||||
const [rows]: any[] = await DB.query(`SELECT id from blocks_summaries`);
|
||||
return rows.map(row => row.id);
|
||||
} catch (e) {
|
||||
logger.err(`Cannot get block summaries id list. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete blocks from the database from blockHeight
|
||||
*/
|
||||
public async $deleteBlocksFrom(blockHeight: number) {
|
||||
logger.info(`Delete newer blocks summary from height ${blockHeight} from the database`);
|
||||
|
||||
try {
|
||||
await DB.query(`DELETE FROM blocks_summaries where height >= ${blockHeight}`);
|
||||
} catch (e) {
|
||||
logger.err('Cannot delete indexed blocks summaries. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the fee percentiles if the block has already been indexed, [] otherwise
|
||||
*
|
||||
* @param id
|
||||
*/
|
||||
public async $getFeePercentilesByBlockId(id: string): Promise<number[] | null> {
|
||||
try {
|
||||
const [rows]: any[] = await DB.query(`
|
||||
SELECT transactions
|
||||
FROM blocks_summaries
|
||||
WHERE id = ?`,
|
||||
[id]
|
||||
);
|
||||
if (rows === null || rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const transactions = JSON.parse(rows[0].transactions);
|
||||
if (transactions === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
transactions.shift(); // Ignore coinbase
|
||||
transactions.sort((a: any, b: any) => a.fee - b.fee);
|
||||
const fees = transactions.map((t: any) => t.fee);
|
||||
|
||||
return [
|
||||
fees[0] ?? 0, // min
|
||||
fees[Math.max(0, Math.floor(fees.length * 0.1) - 1)] ?? 0, // 10th
|
||||
fees[Math.max(0, Math.floor(fees.length * 0.25) - 1)] ?? 0, // 25th
|
||||
fees[Math.max(0, Math.floor(fees.length * 0.5) - 1)] ?? 0, // median
|
||||
fees[Math.max(0, Math.floor(fees.length * 0.75) - 1)] ?? 0, // 75th
|
||||
fees[Math.max(0, Math.floor(fees.length * 0.9) - 1)] ?? 0, // 90th
|
||||
fees[fees.length - 1] ?? 0, // max
|
||||
];
|
||||
|
||||
} catch (e) {
|
||||
logger.err(`Cannot get block summaries transactions. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new BlocksSummariesRepository();
|
||||
|
||||
232
backend/src/repositories/CpfpRepository.ts
Normal file
232
backend/src/repositories/CpfpRepository.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import cluster, { Cluster } from 'cluster';
|
||||
import { RowDataPacket } from 'mysql2';
|
||||
import DB from '../database';
|
||||
import logger from '../logger';
|
||||
import { Ancestor } from '../mempool.interfaces';
|
||||
import transactionRepository from '../repositories/TransactionRepository';
|
||||
|
||||
class CpfpRepository {
|
||||
public async $saveCluster(clusterRoot: string, height: number, txs: Ancestor[], effectiveFeePerVsize: number): Promise<boolean> {
|
||||
if (!txs[0]) {
|
||||
return false;
|
||||
}
|
||||
// skip clusters of transactions with the same fees
|
||||
const roundedEffectiveFee = Math.round(effectiveFeePerVsize * 100) / 100;
|
||||
const equalFee = txs.reduce((acc, tx) => {
|
||||
return (acc && Math.round(((tx.fee || 0) / (tx.weight / 4)) * 100) / 100 === roundedEffectiveFee);
|
||||
}, true);
|
||||
if (equalFee) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const packedTxs = Buffer.from(this.pack(txs));
|
||||
await DB.query(
|
||||
`
|
||||
INSERT INTO compact_cpfp_clusters(root, height, txs, fee_rate)
|
||||
VALUE (UNHEX(?), ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
height = ?,
|
||||
txs = ?,
|
||||
fee_rate = ?
|
||||
`,
|
||||
[clusterRoot, height, packedTxs, effectiveFeePerVsize, height, packedTxs, effectiveFeePerVsize]
|
||||
);
|
||||
const maxChunk = 10;
|
||||
let chunkIndex = 0;
|
||||
while (chunkIndex < txs.length) {
|
||||
const chunk = txs.slice(chunkIndex, chunkIndex + maxChunk).map(tx => {
|
||||
return { txid: tx.txid, cluster: clusterRoot };
|
||||
});
|
||||
await transactionRepository.$batchSetCluster(chunk);
|
||||
chunkIndex += maxChunk;
|
||||
}
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
logger.err(`Cannot save cpfp cluster into db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $batchSaveClusters(clusters: { root: string, height: number, txs: any, effectiveFeePerVsize: number}[]): Promise<boolean> {
|
||||
try {
|
||||
const clusterValues: any[] = [];
|
||||
const txs: any[] = [];
|
||||
|
||||
for (const cluster of clusters) {
|
||||
if (cluster.txs?.length > 1) {
|
||||
const roundedEffectiveFee = Math.round(cluster.effectiveFeePerVsize * 100) / 100;
|
||||
const equalFee = cluster.txs.reduce((acc, tx) => {
|
||||
return (acc && Math.round(((tx.fee || 0) / (tx.weight / 4)) * 100) / 100 === roundedEffectiveFee);
|
||||
}, true);
|
||||
if (!equalFee) {
|
||||
clusterValues.push([
|
||||
cluster.root,
|
||||
cluster.height,
|
||||
Buffer.from(this.pack(cluster.txs)),
|
||||
cluster.effectiveFeePerVsize
|
||||
]);
|
||||
for (const tx of cluster.txs) {
|
||||
txs.push({ txid: tx.txid, cluster: cluster.root });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!clusterValues.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const maxChunk = 100;
|
||||
let chunkIndex = 0;
|
||||
// insert transactions in batches of up to 100 rows
|
||||
while (chunkIndex < txs.length) {
|
||||
const chunk = txs.slice(chunkIndex, chunkIndex + maxChunk);
|
||||
await transactionRepository.$batchSetCluster(chunk);
|
||||
chunkIndex += maxChunk;
|
||||
}
|
||||
|
||||
chunkIndex = 0;
|
||||
// insert clusters in batches of up to 100 rows
|
||||
while (chunkIndex < clusterValues.length) {
|
||||
const chunk = clusterValues.slice(chunkIndex, chunkIndex + maxChunk);
|
||||
let query = `
|
||||
INSERT IGNORE INTO compact_cpfp_clusters(root, height, txs, fee_rate)
|
||||
VALUES
|
||||
`;
|
||||
query += chunk.map(chunk => {
|
||||
return (' (UNHEX(?), ?, ?, ?)');
|
||||
}) + ';';
|
||||
const values = chunk.flat();
|
||||
await DB.query(
|
||||
query,
|
||||
values
|
||||
);
|
||||
chunkIndex += maxChunk;
|
||||
}
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
logger.err(`Cannot save cpfp clusters into db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getCluster(clusterRoot: string): Promise<Cluster | void> {
|
||||
const [clusterRows]: any = await DB.query(
|
||||
`
|
||||
SELECT *
|
||||
FROM compact_cpfp_clusters
|
||||
WHERE root = UNHEX(?)
|
||||
`,
|
||||
[clusterRoot]
|
||||
);
|
||||
const cluster = clusterRows[0];
|
||||
if (cluster?.txs) {
|
||||
cluster.txs = this.unpack(cluster.txs);
|
||||
return cluster;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
public async $deleteClustersFrom(height: number): Promise<void> {
|
||||
logger.info(`Delete newer cpfp clusters from height ${height} from the database`);
|
||||
try {
|
||||
const [rows] = await DB.query(
|
||||
`
|
||||
SELECT txs, height, root from compact_cpfp_clusters
|
||||
WHERE height >= ?
|
||||
`,
|
||||
[height]
|
||||
) as RowDataPacket[][];
|
||||
if (rows?.length) {
|
||||
for (const clusterToDelete of rows) {
|
||||
const txs = this.unpack(clusterToDelete?.txs);
|
||||
for (const tx of txs) {
|
||||
await transactionRepository.$removeTransaction(tx.txid);
|
||||
}
|
||||
}
|
||||
}
|
||||
await DB.query(
|
||||
`
|
||||
DELETE from compact_cpfp_clusters
|
||||
WHERE height >= ?
|
||||
`,
|
||||
[height]
|
||||
);
|
||||
} catch (e: any) {
|
||||
logger.err(`Cannot delete cpfp clusters from db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// insert a dummy row to mark that we've indexed as far as this block
|
||||
public async $insertProgressMarker(height: number): Promise<void> {
|
||||
try {
|
||||
const [rows]: any = await DB.query(
|
||||
`
|
||||
SELECT root
|
||||
FROM compact_cpfp_clusters
|
||||
WHERE height = ?
|
||||
`,
|
||||
[height]
|
||||
);
|
||||
if (!rows?.length) {
|
||||
const rootBuffer = Buffer.alloc(32);
|
||||
rootBuffer.writeInt32LE(height);
|
||||
await DB.query(
|
||||
`
|
||||
INSERT INTO compact_cpfp_clusters(root, height, fee_rate)
|
||||
VALUE (?, ?, ?)
|
||||
`,
|
||||
[rootBuffer, height, 0]
|
||||
);
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.err(`Cannot insert cpfp progress marker. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public pack(txs: Ancestor[]): ArrayBuffer {
|
||||
const buf = new ArrayBuffer(44 * txs.length);
|
||||
const view = new DataView(buf);
|
||||
txs.forEach((tx, i) => {
|
||||
const offset = i * 44;
|
||||
for (let x = 0; x < 32; x++) {
|
||||
// store txid in little-endian
|
||||
view.setUint8(offset + (31 - x), parseInt(tx.txid.slice(x * 2, (x * 2) + 2), 16));
|
||||
}
|
||||
view.setUint32(offset + 32, tx.weight);
|
||||
view.setBigUint64(offset + 36, BigInt(Math.round(tx.fee)));
|
||||
});
|
||||
return buf;
|
||||
}
|
||||
|
||||
public unpack(buf: Buffer): Ancestor[] {
|
||||
if (!buf) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const arrayBuffer = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
|
||||
const txs: Ancestor[] = [];
|
||||
const view = new DataView(arrayBuffer);
|
||||
for (let offset = 0; offset < arrayBuffer.byteLength; offset += 44) {
|
||||
const txid = Array.from(new Uint8Array(arrayBuffer, offset, 32)).reverse().map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
const weight = view.getUint32(offset + 32);
|
||||
const fee = Number(view.getBigUint64(offset + 36));
|
||||
txs.push({
|
||||
txid,
|
||||
weight,
|
||||
fee
|
||||
});
|
||||
}
|
||||
return txs;
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to unpack CPFP cluster. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new CpfpRepository();
|
||||
123
backend/src/repositories/DifficultyAdjustmentsRepository.ts
Normal file
123
backend/src/repositories/DifficultyAdjustmentsRepository.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Common } from '../api/common';
|
||||
import DB from '../database';
|
||||
import logger from '../logger';
|
||||
import { IndexedDifficultyAdjustment } from '../mempool.interfaces';
|
||||
|
||||
class DifficultyAdjustmentsRepository {
|
||||
public async $saveAdjustments(adjustment: IndexedDifficultyAdjustment): Promise<void> {
|
||||
if (adjustment.height === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const query = `INSERT INTO difficulty_adjustments(time, height, difficulty, adjustment) VALUE (FROM_UNIXTIME(?), ?, ?, ?)`;
|
||||
const params: any[] = [
|
||||
adjustment.time,
|
||||
adjustment.height,
|
||||
adjustment.difficulty,
|
||||
adjustment.adjustment,
|
||||
];
|
||||
await DB.query(query, params);
|
||||
} catch (e: any) {
|
||||
if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart
|
||||
logger.debug(`Cannot save difficulty adjustment at block ${adjustment.height}, already indexed, ignoring`, logger.tags.mining);
|
||||
} else {
|
||||
logger.err(`Cannot save difficulty adjustment at block ${adjustment.height}. Reason: ${e instanceof Error ? e.message : e}`, logger.tags.mining);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async $getAdjustments(interval: string | null, descOrder: boolean = false): Promise<IndexedDifficultyAdjustment[]> {
|
||||
interval = Common.getSqlInterval(interval);
|
||||
|
||||
let query = `SELECT
|
||||
CAST(AVG(UNIX_TIMESTAMP(time)) as INT) as time,
|
||||
CAST(AVG(height) AS INT) as height,
|
||||
CAST(AVG(difficulty) as DOUBLE) as difficulty,
|
||||
CAST(AVG(adjustment) as DOUBLE) as adjustment
|
||||
FROM difficulty_adjustments`;
|
||||
|
||||
if (interval) {
|
||||
query += ` WHERE time BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
|
||||
}
|
||||
|
||||
query += ` GROUP BY UNIX_TIMESTAMP(time) DIV ${86400}`;
|
||||
|
||||
if (descOrder === true) {
|
||||
query += ` ORDER BY height DESC`;
|
||||
} else {
|
||||
query += ` ORDER BY height`;
|
||||
}
|
||||
|
||||
try {
|
||||
const [rows] = await DB.query(query);
|
||||
return rows as IndexedDifficultyAdjustment[];
|
||||
} catch (e) {
|
||||
logger.err(`Cannot get difficulty adjustments from the database. Reason: ` + (e instanceof Error ? e.message : e), logger.tags.mining);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getRawAdjustments(interval: string | null, descOrder: boolean = false): Promise<IndexedDifficultyAdjustment[]> {
|
||||
interval = Common.getSqlInterval(interval);
|
||||
|
||||
let query = `SELECT
|
||||
UNIX_TIMESTAMP(time) as time,
|
||||
height as height,
|
||||
difficulty as difficulty,
|
||||
adjustment as adjustment
|
||||
FROM difficulty_adjustments`;
|
||||
|
||||
if (interval) {
|
||||
query += ` WHERE time BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
|
||||
}
|
||||
|
||||
if (descOrder === true) {
|
||||
query += ` ORDER BY height DESC`;
|
||||
} else {
|
||||
query += ` ORDER BY height`;
|
||||
}
|
||||
|
||||
try {
|
||||
const [rows] = await DB.query(query);
|
||||
return rows as IndexedDifficultyAdjustment[];
|
||||
} catch (e) {
|
||||
logger.err(`Cannot get difficulty adjustments from the database. Reason: ` + (e instanceof Error ? e.message : e), logger.tags.mining);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getAdjustmentsHeights(): Promise<number[]> {
|
||||
try {
|
||||
const [rows]: any[] = await DB.query(`SELECT height FROM difficulty_adjustments`);
|
||||
return rows.map(block => block.height);
|
||||
} catch (e: any) {
|
||||
logger.err(`Cannot get difficulty adjustment block heights. Reason: ${e instanceof Error ? e.message : e}`, logger.tags.mining);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $deleteAdjustementsFromHeight(height: number): Promise<void> {
|
||||
try {
|
||||
logger.info(`Delete newer difficulty adjustments from height ${height} from the database`, logger.tags.mining);
|
||||
await DB.query(`DELETE FROM difficulty_adjustments WHERE height >= ?`, [height]);
|
||||
} catch (e: any) {
|
||||
logger.err(`Cannot delete difficulty adjustments from the database. Reason: ${e instanceof Error ? e.message : e}`, logger.tags.mining);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $deleteLastAdjustment(): Promise<void> {
|
||||
try {
|
||||
logger.info(`Delete last difficulty adjustment from the database`, logger.tags.mining);
|
||||
await DB.query(`DELETE FROM difficulty_adjustments ORDER BY time LIMIT 1`);
|
||||
} catch (e: any) {
|
||||
logger.err(`Cannot delete last difficulty adjustment from the database. Reason: ${e instanceof Error ? e.message : e}`, logger.tags.mining);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new DifficultyAdjustmentsRepository();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { escape } from 'mysql2';
|
||||
import { Common } from '../api/common';
|
||||
import mining from '../api/mining/mining';
|
||||
import DB from '../database';
|
||||
import logger from '../logger';
|
||||
import PoolsRepository from './PoolsRepository';
|
||||
@@ -24,15 +25,17 @@ class HashratesRepository {
|
||||
try {
|
||||
await DB.query(query);
|
||||
} catch (e: any) {
|
||||
logger.err('Cannot save indexed hashrate into db. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
logger.err('Cannot save indexed hashrate into db. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getNetworkDailyHashrate(interval: string | null): Promise<any[]> {
|
||||
public async $getRawNetworkDailyHashrate(interval: string | null): Promise<any[]> {
|
||||
interval = Common.getSqlInterval(interval);
|
||||
|
||||
let query = `SELECT UNIX_TIMESTAMP(hashrate_timestamp) as timestamp, avg_hashrate as avgHashrate
|
||||
let query = `SELECT
|
||||
UNIX_TIMESTAMP(hashrate_timestamp) as timestamp,
|
||||
avg_hashrate as avgHashrate
|
||||
FROM hashrates`;
|
||||
|
||||
if (interval) {
|
||||
@@ -48,7 +51,34 @@ class HashratesRepository {
|
||||
const [rows]: any[] = await DB.query(query);
|
||||
return rows;
|
||||
} catch (e) {
|
||||
logger.err('Cannot fetch network hashrate history. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
logger.err('Cannot fetch network hashrate history. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getNetworkDailyHashrate(interval: string | null): Promise<any[]> {
|
||||
interval = Common.getSqlInterval(interval);
|
||||
|
||||
let query = `SELECT
|
||||
CAST(AVG(UNIX_TIMESTAMP(hashrate_timestamp)) as INT) as timestamp,
|
||||
CAST(AVG(avg_hashrate) as DOUBLE) as avgHashrate
|
||||
FROM hashrates`;
|
||||
|
||||
if (interval) {
|
||||
query += ` WHERE hashrate_timestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()
|
||||
AND hashrates.type = 'daily'`;
|
||||
} else {
|
||||
query += ` WHERE hashrates.type = 'daily'`;
|
||||
}
|
||||
|
||||
query += ` GROUP BY UNIX_TIMESTAMP(hashrate_timestamp) DIV ${86400}`;
|
||||
query += ` ORDER by hashrate_timestamp`;
|
||||
|
||||
try {
|
||||
const [rows]: any[] = await DB.query(query);
|
||||
return rows;
|
||||
} catch (e) {
|
||||
logger.err('Cannot fetch network hashrate history. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -63,7 +93,7 @@ class HashratesRepository {
|
||||
const [rows]: any[] = await DB.query(query);
|
||||
return rows.map(row => row.timestamp);
|
||||
} catch (e) {
|
||||
logger.err('Cannot retreive indexed weekly hashrate timestamps. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
logger.err('Cannot retreive indexed weekly hashrate timestamps. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -75,6 +105,9 @@ class HashratesRepository {
|
||||
interval = Common.getSqlInterval(interval);
|
||||
|
||||
const topPoolsId = (await PoolsRepository.$getPoolsInfo('1w')).map((pool) => pool.poolId);
|
||||
if (topPoolsId.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let query = `SELECT UNIX_TIMESTAMP(hashrate_timestamp) as timestamp, avg_hashrate as avgHashrate, share, pools.name as poolName
|
||||
FROM hashrates
|
||||
@@ -95,7 +128,7 @@ class HashratesRepository {
|
||||
const [rows]: any[] = await DB.query(query);
|
||||
return rows;
|
||||
} catch (e) {
|
||||
logger.err('Cannot fetch weekly pools hashrate history. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
logger.err('Cannot fetch weekly pools hashrate history. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -125,7 +158,7 @@ class HashratesRepository {
|
||||
const [rows]: any[] = await DB.query(query, [pool.id]);
|
||||
boundaries = rows[0];
|
||||
} catch (e) {
|
||||
logger.err('Cannot fetch hashrate start/end timestamps for this pool. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
logger.err('Cannot fetch hashrate start/end timestamps for this pool. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining);
|
||||
}
|
||||
|
||||
// Get hashrates entries between boundaries
|
||||
@@ -140,21 +173,7 @@ class HashratesRepository {
|
||||
const [rows]: any[] = await DB.query(query, [boundaries.firstTimestamp, boundaries.lastTimestamp, pool.id]);
|
||||
return rows;
|
||||
} catch (e) {
|
||||
logger.err('Cannot fetch pool hashrate history for this pool. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set latest run timestamp
|
||||
*/
|
||||
public async $setLatestRun(key: string, val: number) {
|
||||
const query = `UPDATE state SET number = ? WHERE name = ?`;
|
||||
|
||||
try {
|
||||
await DB.query(query, [val, key]);
|
||||
} catch (e) {
|
||||
logger.err(`Cannot set last indexing run for ${key}. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
logger.err('Cannot fetch pool hashrate history for this pool. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -173,7 +192,7 @@ class HashratesRepository {
|
||||
}
|
||||
return rows[0]['number'];
|
||||
} catch (e) {
|
||||
logger.err(`Cannot retrieve last indexing run for ${key}. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
logger.err(`Cannot retrieve last indexing run for ${key}. Reason: ` + (e instanceof Error ? e.message : e), logger.tags.mining);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -182,7 +201,7 @@ class HashratesRepository {
|
||||
* Delete most recent data points for re-indexing
|
||||
*/
|
||||
public async $deleteLastEntries() {
|
||||
logger.info(`Delete latest hashrates data points from the database`);
|
||||
logger.info(`Delete latest hashrates data points from the database`, logger.tags.mining);
|
||||
|
||||
try {
|
||||
const [rows]: any[] = await DB.query(`SELECT MAX(hashrate_timestamp) as timestamp FROM hashrates GROUP BY type`);
|
||||
@@ -190,10 +209,10 @@ class HashratesRepository {
|
||||
await DB.query(`DELETE FROM hashrates WHERE hashrate_timestamp = ?`, [row.timestamp]);
|
||||
}
|
||||
// Re-run the hashrate indexing to fill up missing data
|
||||
await this.$setLatestRun('last_hashrates_indexing', 0);
|
||||
await this.$setLatestRun('last_weekly_hashrates_indexing', 0);
|
||||
mining.lastHashrateIndexingDate = null;
|
||||
mining.lastWeeklyHashrateIndexingDate = null;
|
||||
} catch (e) {
|
||||
logger.err('Cannot delete latest hashrates data points. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
logger.err('Cannot delete latest hashrates data points. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,10 +225,10 @@ class HashratesRepository {
|
||||
try {
|
||||
await DB.query(`DELETE FROM hashrates WHERE hashrate_timestamp >= FROM_UNIXTIME(?)`, [timestamp]);
|
||||
// Re-run the hashrate indexing to fill up missing data
|
||||
await this.$setLatestRun('last_hashrates_indexing', 0);
|
||||
await this.$setLatestRun('last_weekly_hashrates_indexing', 0);
|
||||
mining.lastHashrateIndexingDate = null;
|
||||
mining.lastWeeklyHashrateIndexingDate = null;
|
||||
} catch (e) {
|
||||
logger.err('Cannot delete latest hashrates data points. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
logger.err('Cannot delete latest hashrates data points. Reason: ' + (e instanceof Error ? e.message : e), logger.tags.mining);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
67
backend/src/repositories/NodeRecordsRepository.ts
Normal file
67
backend/src/repositories/NodeRecordsRepository.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { ResultSetHeader, RowDataPacket } from 'mysql2';
|
||||
import DB from '../database';
|
||||
import logger from '../logger';
|
||||
|
||||
export interface NodeRecord {
|
||||
publicKey: string; // node public key
|
||||
type: number; // TLV extension record type
|
||||
payload: string; // base64 record payload
|
||||
}
|
||||
|
||||
class NodesRecordsRepository {
|
||||
public async $saveRecord(record: NodeRecord): Promise<void> {
|
||||
try {
|
||||
const payloadBytes = Buffer.from(record.payload, 'base64');
|
||||
await DB.query(`
|
||||
INSERT INTO nodes_records(public_key, type, payload)
|
||||
VALUE (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
payload = ?
|
||||
`, [record.publicKey, record.type, payloadBytes, payloadBytes]);
|
||||
} catch (e: any) {
|
||||
if (e.errno !== 1062) { // ER_DUP_ENTRY - Not an issue, just ignore this
|
||||
logger.err(`Cannot save node record (${[record.publicKey, record.type, record.payload]}) into db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
// We don't throw, not a critical issue if we miss some nodes records
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async $getRecordTypes(publicKey: string): Promise<any> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT type FROM nodes_records
|
||||
WHERE public_key = ?
|
||||
`;
|
||||
const [rows] = await DB.query<RowDataPacket[][]>(query, [publicKey]);
|
||||
return rows.map(row => row['type']);
|
||||
} catch (e) {
|
||||
logger.err(`Cannot retrieve custom records for ${publicKey} from db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async $deleteUnusedRecords(publicKey: string, recordTypes: number[]): Promise<number> {
|
||||
try {
|
||||
let query;
|
||||
if (recordTypes.length) {
|
||||
query = `
|
||||
DELETE FROM nodes_records
|
||||
WHERE public_key = ?
|
||||
AND type NOT IN (${recordTypes.map(type => `${type}`).join(',')})
|
||||
`;
|
||||
} else {
|
||||
query = `
|
||||
DELETE FROM nodes_records
|
||||
WHERE public_key = ?
|
||||
`;
|
||||
}
|
||||
const [result] = await DB.query<ResultSetHeader>(query, [publicKey]);
|
||||
return result.affectedRows;
|
||||
} catch (e) {
|
||||
logger.err(`Cannot delete unused custom records for ${publicKey} from db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new NodesRecordsRepository();
|
||||
45
backend/src/repositories/NodesSocketsRepository.ts
Normal file
45
backend/src/repositories/NodesSocketsRepository.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { ResultSetHeader } from 'mysql2';
|
||||
import DB from '../database';
|
||||
import logger from '../logger';
|
||||
|
||||
export interface NodeSocket {
|
||||
publicKey: string;
|
||||
network: string | null;
|
||||
addr: string;
|
||||
}
|
||||
|
||||
class NodesSocketsRepository {
|
||||
public async $saveSocket(socket: NodeSocket): Promise<void> {
|
||||
try {
|
||||
await DB.query(`
|
||||
INSERT INTO nodes_sockets(public_key, socket, type)
|
||||
VALUE (?, ?, ?)
|
||||
`, [socket.publicKey, socket.addr, socket.network]);
|
||||
} catch (e: any) {
|
||||
if (e.errno !== 1062) { // ER_DUP_ENTRY - Not an issue, just ignore this
|
||||
logger.err(`Cannot save node socket (${[socket.publicKey, socket.addr, socket.network]}) into db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
// We don't throw, not a critical issue if we miss some nodes sockets
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async $deleteUnusedSockets(publicKey: string, addresses: string[]): Promise<number> {
|
||||
if (addresses.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
const query = `
|
||||
DELETE FROM nodes_sockets
|
||||
WHERE public_key = ?
|
||||
AND socket NOT IN (${addresses.map(id => `"${id}"`).join(',')})
|
||||
`;
|
||||
const [result] = await DB.query<ResultSetHeader>(query, [publicKey]);
|
||||
return result.affectedRows;
|
||||
} catch (e) {
|
||||
logger.err(`Cannot delete unused sockets for ${publicKey} from db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new NodesSocketsRepository();
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Common } from '../api/common';
|
||||
import poolsParser from '../api/pools-parser';
|
||||
import config from '../config';
|
||||
import DB from '../database';
|
||||
import logger from '../logger';
|
||||
@@ -9,7 +10,7 @@ class PoolsRepository {
|
||||
* Get all pools tagging info
|
||||
*/
|
||||
public async $getPools(): Promise<PoolTag[]> {
|
||||
const [rows] = await DB.query('SELECT id, name, addresses, regexes, slug FROM pools;');
|
||||
const [rows] = await DB.query('SELECT id, unique_id as uniqueId, name, addresses, regexes, slug FROM pools');
|
||||
return <PoolTag[]>rows;
|
||||
}
|
||||
|
||||
@@ -17,7 +18,11 @@ class PoolsRepository {
|
||||
* Get unknown pool tagging info
|
||||
*/
|
||||
public async $getUnknownPool(): Promise<PoolTag> {
|
||||
const [rows] = await DB.query('SELECT id, name, slug FROM pools where name = "Unknown"');
|
||||
let [rows]: any[] = await DB.query('SELECT id, unique_id as uniqueId, name, slug FROM pools where name = "Unknown"');
|
||||
if (rows && rows.length === 0 && config.DATABASE.ENABLED) {
|
||||
await poolsParser.$insertUnknownPool();
|
||||
[rows] = await DB.query('SELECT id, unique_id as uniqueId, name, slug FROM pools where name = "Unknown"');
|
||||
}
|
||||
return <PoolTag>rows[0];
|
||||
}
|
||||
|
||||
@@ -27,16 +32,25 @@ class PoolsRepository {
|
||||
public async $getPoolsInfo(interval: string | null = null): Promise<PoolInfo[]> {
|
||||
interval = Common.getSqlInterval(interval);
|
||||
|
||||
let query = `SELECT COUNT(height) as blockCount, pool_id as poolId, pools.name as name, pools.link as link, slug
|
||||
let query = `
|
||||
SELECT
|
||||
COUNT(blocks.height) As blockCount,
|
||||
pool_id AS poolId,
|
||||
pools.name AS name,
|
||||
pools.link AS link,
|
||||
slug,
|
||||
AVG(blocks_audits.match_rate) AS avgMatchRate
|
||||
FROM blocks
|
||||
JOIN pools on pools.id = pool_id`;
|
||||
JOIN pools on pools.id = pool_id
|
||||
LEFT JOIN blocks_audits ON blocks_audits.height = blocks.height
|
||||
`;
|
||||
|
||||
if (interval) {
|
||||
query += ` WHERE blocks.blockTimestamp BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW()`;
|
||||
}
|
||||
|
||||
query += ` GROUP BY pool_id
|
||||
ORDER BY COUNT(height) DESC`;
|
||||
ORDER BY COUNT(blocks.height) DESC`;
|
||||
|
||||
try {
|
||||
const [rows] = await DB.query(query);
|
||||
@@ -50,7 +64,7 @@ class PoolsRepository {
|
||||
/**
|
||||
* Get basic pool info and block count between two timestamp
|
||||
*/
|
||||
public async $getPoolsInfoBetween(from: number, to: number): Promise<PoolInfo[]> {
|
||||
public async $getPoolsInfoBetween(from: number, to: number): Promise<PoolInfo[]> {
|
||||
const query = `SELECT COUNT(height) as blockCount, pools.id as poolId, pools.name as poolName
|
||||
FROM pools
|
||||
LEFT JOIN blocks on pools.id = blocks.pool_id AND blocks.blockTimestamp BETWEEN FROM_UNIXTIME(?) AND FROM_UNIXTIME(?)
|
||||
@@ -66,9 +80,9 @@ class PoolsRepository {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mining pool statistics for one pool
|
||||
* Get a mining pool info
|
||||
*/
|
||||
public async $getPool(slug: string): Promise<PoolTag | null> {
|
||||
public async $getPool(slug: string, parse: boolean = true): Promise<PoolTag | null> {
|
||||
const query = `
|
||||
SELECT *
|
||||
FROM pools
|
||||
@@ -81,10 +95,12 @@ class PoolsRepository {
|
||||
return null;
|
||||
}
|
||||
|
||||
rows[0].regexes = JSON.parse(rows[0].regexes);
|
||||
if (parse) {
|
||||
rows[0].regexes = JSON.parse(rows[0].regexes);
|
||||
}
|
||||
if (['testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) {
|
||||
rows[0].addresses = []; // pools.json only contains mainnet addresses
|
||||
} else {
|
||||
rows[0].addresses = []; // pools-v2.json only contains mainnet addresses
|
||||
} else if (parse) {
|
||||
rows[0].addresses = JSON.parse(rows[0].addresses);
|
||||
}
|
||||
|
||||
@@ -94,6 +110,116 @@ class PoolsRepository {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a mining pool info by its unique id
|
||||
*/
|
||||
public async $getPoolByUniqueId(id: number, parse: boolean = true): Promise<PoolTag | null> {
|
||||
const query = `
|
||||
SELECT *
|
||||
FROM pools
|
||||
WHERE pools.unique_id = ?`;
|
||||
|
||||
try {
|
||||
const [rows]: any[] = await DB.query(query, [id]);
|
||||
|
||||
if (rows.length < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parse) {
|
||||
rows[0].regexes = JSON.parse(rows[0].regexes);
|
||||
}
|
||||
if (['testnet', 'signet'].includes(config.MEMPOOL.NETWORK)) {
|
||||
rows[0].addresses = []; // pools.json only contains mainnet addresses
|
||||
} else if (parse) {
|
||||
rows[0].addresses = JSON.parse(rows[0].addresses);
|
||||
}
|
||||
|
||||
return rows[0];
|
||||
} catch (e) {
|
||||
logger.err('Cannot get pool from db. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a new mining pool in the database
|
||||
*
|
||||
* @param pool
|
||||
*/
|
||||
public async $insertNewMiningPool(pool: any, slug: string): Promise<void> {
|
||||
try {
|
||||
await DB.query(`
|
||||
INSERT INTO pools
|
||||
SET name = ?, link = ?, addresses = ?, regexes = ?, slug = ?, unique_id = ?`,
|
||||
[pool.name, pool.link, JSON.stringify(pool.addresses), JSON.stringify(pool.regexes), slug, pool.id]
|
||||
);
|
||||
} catch (e: any) {
|
||||
logger.err(`Cannot insert new mining pool into db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename an existing mining pool
|
||||
*
|
||||
* @param dbId
|
||||
* @param newSlug
|
||||
* @param newName
|
||||
*/
|
||||
public async $renameMiningPool(dbId: number, newSlug: string, newName: string): Promise<void> {
|
||||
try {
|
||||
await DB.query(`
|
||||
UPDATE pools
|
||||
SET slug = ?, name = ?
|
||||
WHERE id = ?`,
|
||||
[newSlug, newName, dbId]
|
||||
);
|
||||
} catch (e: any) {
|
||||
logger.err(`Cannot rename mining pool id ${dbId}. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an exisiting mining pool link
|
||||
*
|
||||
* @param dbId
|
||||
* @param newLink
|
||||
*/
|
||||
public async $updateMiningPoolLink(dbId: number, newLink: string): Promise<void> {
|
||||
try {
|
||||
await DB.query(`
|
||||
UPDATE pools
|
||||
SET link = ?
|
||||
WHERE id = ?`,
|
||||
[newLink, dbId]
|
||||
);
|
||||
} catch (e: any) {
|
||||
logger.err(`Cannot update link for mining pool id ${dbId}. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing mining pool addresses or coinbase tags
|
||||
*
|
||||
* @param dbId
|
||||
* @param addresses
|
||||
* @param regexes
|
||||
*/
|
||||
public async $updateMiningPoolTags(dbId: number, addresses: string, regexes: string): Promise<void> {
|
||||
try {
|
||||
await DB.query(`
|
||||
UPDATE pools
|
||||
SET addresses = ?, regexes = ?
|
||||
WHERE id = ?`,
|
||||
[JSON.stringify(addresses), JSON.stringify(regexes), dbId]
|
||||
);
|
||||
} catch (e: any) {
|
||||
logger.err(`Cannot update mining pool id ${dbId}. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default new PoolsRepository();
|
||||
|
||||
230
backend/src/repositories/PricesRepository.ts
Normal file
230
backend/src/repositories/PricesRepository.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import DB from '../database';
|
||||
import logger from '../logger';
|
||||
import priceUpdater from '../tasks/price-updater';
|
||||
|
||||
export interface ApiPrice {
|
||||
time?: number,
|
||||
USD: number,
|
||||
EUR: number,
|
||||
GBP: number,
|
||||
CAD: number,
|
||||
CHF: number,
|
||||
AUD: number,
|
||||
JPY: number,
|
||||
}
|
||||
const ApiPriceFields = `
|
||||
UNIX_TIMESTAMP(time) as time,
|
||||
USD,
|
||||
EUR,
|
||||
GBP,
|
||||
CAD,
|
||||
CHF,
|
||||
AUD,
|
||||
JPY
|
||||
`;
|
||||
|
||||
export interface ExchangeRates {
|
||||
USDEUR: number,
|
||||
USDGBP: number,
|
||||
USDCAD: number,
|
||||
USDCHF: number,
|
||||
USDAUD: number,
|
||||
USDJPY: number,
|
||||
}
|
||||
|
||||
export interface Conversion {
|
||||
prices: ApiPrice[],
|
||||
exchangeRates: ExchangeRates;
|
||||
}
|
||||
|
||||
export const MAX_PRICES = {
|
||||
USD: 100000000,
|
||||
EUR: 100000000,
|
||||
GBP: 100000000,
|
||||
CAD: 100000000,
|
||||
CHF: 100000000,
|
||||
AUD: 100000000,
|
||||
JPY: 10000000000,
|
||||
};
|
||||
|
||||
class PricesRepository {
|
||||
public async $savePrices(time: number, prices: ApiPrice): Promise<void> {
|
||||
if (prices.USD === -1) {
|
||||
// Some historical price entries have no USD prices, so we just ignore them to avoid future UX issues
|
||||
// As of today there are only 4 (on 2013-09-05, 2013-0909, 2013-09-12 and 2013-09-26) so that's fine
|
||||
return;
|
||||
}
|
||||
|
||||
// Sanity check
|
||||
for (const currency of Object.keys(prices)) {
|
||||
if (prices[currency] < -1 || prices[currency] > MAX_PRICES[currency]) { // We use -1 to mark a "missing data, so it's a valid entry"
|
||||
logger.info(`Ignore BTC${currency} price of ${prices[currency]}`);
|
||||
prices[currency] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await DB.query(`
|
||||
INSERT INTO prices(time, USD, EUR, GBP, CAD, CHF, AUD, JPY)
|
||||
VALUE (FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?, ? )`,
|
||||
[time, prices.USD, prices.EUR, prices.GBP, prices.CAD, prices.CHF, prices.AUD, prices.JPY]
|
||||
);
|
||||
} catch (e) {
|
||||
logger.err(`Cannot save exchange rate into db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getOldestPriceTime(): Promise<number> {
|
||||
const [oldestRow] = await DB.query(`
|
||||
SELECT UNIX_TIMESTAMP(time) AS time
|
||||
FROM prices
|
||||
ORDER BY time
|
||||
LIMIT 1
|
||||
`);
|
||||
return oldestRow[0] ? oldestRow[0].time : 0;
|
||||
}
|
||||
|
||||
public async $getLatestPriceId(): Promise<number | null> {
|
||||
const [oldestRow] = await DB.query(`
|
||||
SELECT id
|
||||
FROM prices
|
||||
ORDER BY time DESC
|
||||
LIMIT 1`
|
||||
);
|
||||
return oldestRow[0] ? oldestRow[0].id : null;
|
||||
}
|
||||
|
||||
public async $getLatestPriceTime(): Promise<number> {
|
||||
const [oldestRow] = await DB.query(`
|
||||
SELECT UNIX_TIMESTAMP(time) AS time
|
||||
FROM prices
|
||||
ORDER BY time DESC
|
||||
LIMIT 1`
|
||||
);
|
||||
return oldestRow[0] ? oldestRow[0].time : 0;
|
||||
}
|
||||
|
||||
public async $getPricesTimes(): Promise<number[]> {
|
||||
const [times] = await DB.query(`
|
||||
SELECT UNIX_TIMESTAMP(time) AS time
|
||||
FROM prices
|
||||
WHERE USD != -1
|
||||
ORDER BY time
|
||||
`);
|
||||
if (!Array.isArray(times)) {
|
||||
return [];
|
||||
}
|
||||
return times.map(time => time.time);
|
||||
}
|
||||
|
||||
public async $getPricesTimesAndId(): Promise<{time: number, id: number, USD: number}[]> {
|
||||
const [times] = await DB.query(`
|
||||
SELECT
|
||||
UNIX_TIMESTAMP(time) AS time,
|
||||
id,
|
||||
USD
|
||||
FROM prices
|
||||
ORDER BY time
|
||||
`);
|
||||
return times as {time: number, id: number, USD: number}[];
|
||||
}
|
||||
|
||||
public async $getLatestConversionRates(): Promise<ApiPrice> {
|
||||
const [rates] = await DB.query(`
|
||||
SELECT ${ApiPriceFields}
|
||||
FROM prices
|
||||
ORDER BY time DESC
|
||||
LIMIT 1`
|
||||
);
|
||||
|
||||
if (!Array.isArray(rates) || rates.length === 0) {
|
||||
return priceUpdater.getEmptyPricesObj();
|
||||
}
|
||||
return rates[0] as ApiPrice;
|
||||
}
|
||||
|
||||
public async $getNearestHistoricalPrice(timestamp: number | undefined): Promise<Conversion | null> {
|
||||
try {
|
||||
const [rates] = await DB.query(`
|
||||
SELECT ${ApiPriceFields}
|
||||
FROM prices
|
||||
WHERE UNIX_TIMESTAMP(time) < ?
|
||||
ORDER BY time DESC
|
||||
LIMIT 1`,
|
||||
[timestamp]
|
||||
);
|
||||
if (!Array.isArray(rates)) {
|
||||
throw Error(`Cannot get single historical price from the database`);
|
||||
}
|
||||
|
||||
// Compute fiat exchange rates
|
||||
let latestPrice = rates[0] as ApiPrice;
|
||||
if (latestPrice.USD === -1) {
|
||||
latestPrice = priceUpdater.getEmptyPricesObj();
|
||||
}
|
||||
|
||||
const computeFx = (usd: number, other: number): number =>
|
||||
Math.round(Math.max(other, 0) / Math.max(usd, 1) * 100) / 100;
|
||||
|
||||
const exchangeRates: ExchangeRates = {
|
||||
USDEUR: computeFx(latestPrice.USD, latestPrice.EUR),
|
||||
USDGBP: computeFx(latestPrice.USD, latestPrice.GBP),
|
||||
USDCAD: computeFx(latestPrice.USD, latestPrice.CAD),
|
||||
USDCHF: computeFx(latestPrice.USD, latestPrice.CHF),
|
||||
USDAUD: computeFx(latestPrice.USD, latestPrice.AUD),
|
||||
USDJPY: computeFx(latestPrice.USD, latestPrice.JPY),
|
||||
};
|
||||
|
||||
return {
|
||||
prices: rates as ApiPrice[],
|
||||
exchangeRates: exchangeRates
|
||||
};
|
||||
} catch (e) {
|
||||
logger.err(`Cannot fetch single historical prices from the db. Reason ${e instanceof Error ? e.message : e}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getHistoricalPrices(): Promise<Conversion | null> {
|
||||
try {
|
||||
const [rates] = await DB.query(`
|
||||
SELECT ${ApiPriceFields}
|
||||
FROM prices
|
||||
ORDER BY time DESC
|
||||
`);
|
||||
if (!Array.isArray(rates)) {
|
||||
throw Error(`Cannot get average historical price from the database`);
|
||||
}
|
||||
|
||||
// Compute fiat exchange rates
|
||||
let latestPrice = rates[0] as ApiPrice;
|
||||
if (latestPrice.USD === -1) {
|
||||
latestPrice = priceUpdater.getEmptyPricesObj();
|
||||
}
|
||||
|
||||
const computeFx = (usd: number, other: number): number =>
|
||||
Math.round(Math.max(other, 0) / Math.max(usd, 1) * 100) / 100;
|
||||
|
||||
const exchangeRates: ExchangeRates = {
|
||||
USDEUR: computeFx(latestPrice.USD, latestPrice.EUR),
|
||||
USDGBP: computeFx(latestPrice.USD, latestPrice.GBP),
|
||||
USDCAD: computeFx(latestPrice.USD, latestPrice.CAD),
|
||||
USDCHF: computeFx(latestPrice.USD, latestPrice.CHF),
|
||||
USDAUD: computeFx(latestPrice.USD, latestPrice.AUD),
|
||||
USDJPY: computeFx(latestPrice.USD, latestPrice.JPY),
|
||||
};
|
||||
|
||||
return {
|
||||
prices: rates as ApiPrice[],
|
||||
exchangeRates: exchangeRates
|
||||
};
|
||||
} catch (e) {
|
||||
logger.err(`Cannot fetch historical prices from the db. Reason ${e instanceof Error ? e.message : e}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new PricesRepository();
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import DB from '../database';
|
||||
import logger from '../logger';
|
||||
import { IConversionRates } from '../mempool.interfaces';
|
||||
|
||||
class RatesRepository {
|
||||
public async $saveRate(height: number, rates: IConversionRates) {
|
||||
try {
|
||||
await DB.query(`INSERT INTO rates(height, bisq_rates) VALUE (?, ?)`, [height, JSON.stringify(rates)]);
|
||||
} catch (e: any) {
|
||||
if (e.errno === 1062) { // ER_DUP_ENTRY - This scenario is possible upon node backend restart
|
||||
logger.debug(`Rate already exists for block ${height}, ignoring`);
|
||||
} else {
|
||||
logger.err(`Cannot save exchange rate into db for block ${height} Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new RatesRepository();
|
||||
|
||||
113
backend/src/repositories/TransactionRepository.ts
Normal file
113
backend/src/repositories/TransactionRepository.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import DB from '../database';
|
||||
import logger from '../logger';
|
||||
import { Ancestor, CpfpInfo } from '../mempool.interfaces';
|
||||
import cpfpRepository from './CpfpRepository';
|
||||
|
||||
class TransactionRepository {
|
||||
public async $setCluster(txid: string, clusterRoot: string): Promise<void> {
|
||||
try {
|
||||
await DB.query(
|
||||
`
|
||||
INSERT INTO compact_transactions
|
||||
(
|
||||
txid,
|
||||
cluster
|
||||
)
|
||||
VALUE (UNHEX(?), UNHEX(?))
|
||||
ON DUPLICATE KEY UPDATE
|
||||
cluster = UNHEX(?)
|
||||
;`,
|
||||
[txid, clusterRoot, clusterRoot]
|
||||
);
|
||||
} catch (e: any) {
|
||||
logger.err(`Cannot save transaction cpfp cluster into db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $batchSetCluster(txs): Promise<void> {
|
||||
try {
|
||||
let query = `
|
||||
INSERT IGNORE INTO compact_transactions
|
||||
(
|
||||
txid,
|
||||
cluster
|
||||
)
|
||||
VALUES
|
||||
`;
|
||||
query += txs.map(tx => {
|
||||
return (' (UNHEX(?), UNHEX(?))');
|
||||
}) + ';';
|
||||
const values = txs.map(tx => [tx.txid, tx.cluster]).flat();
|
||||
await DB.query(
|
||||
query,
|
||||
values
|
||||
);
|
||||
} catch (e: any) {
|
||||
logger.err(`Cannot save cpfp transactions into db. Reason: ` + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $getCpfpInfo(txid: string): Promise<CpfpInfo | void> {
|
||||
try {
|
||||
const [txRows]: any = await DB.query(
|
||||
`
|
||||
SELECT HEX(txid) as id, HEX(cluster) as root
|
||||
FROM compact_transactions
|
||||
WHERE txid = UNHEX(?)
|
||||
`,
|
||||
[txid]
|
||||
);
|
||||
if (txRows.length && txRows[0].root != null) {
|
||||
const txid = txRows[0].id.toLowerCase();
|
||||
const clusterId = txRows[0].root.toLowerCase();
|
||||
const cluster = await cpfpRepository.$getCluster(clusterId);
|
||||
if (cluster) {
|
||||
return this.convertCpfp(txid, cluster);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err('Cannot get transaction cpfp info from db. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async $removeTransaction(txid: string): Promise<void> {
|
||||
try {
|
||||
await DB.query(
|
||||
`
|
||||
DELETE FROM compact_transactions
|
||||
WHERE txid = UNHEX(?)
|
||||
`,
|
||||
[txid]
|
||||
);
|
||||
} catch (e) {
|
||||
logger.warn('Cannot delete transaction cpfp info from db. Reason: ' + (e instanceof Error ? e.message : e));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private convertCpfp(txid, cluster): CpfpInfo {
|
||||
const descendants: Ancestor[] = [];
|
||||
const ancestors: Ancestor[] = [];
|
||||
let matched = false;
|
||||
|
||||
for (const tx of (cluster?.txs || [])) {
|
||||
if (tx.txid === txid) {
|
||||
matched = true;
|
||||
} else if (!matched) {
|
||||
descendants.push(tx);
|
||||
} else {
|
||||
ancestors.push(tx);
|
||||
}
|
||||
}
|
||||
return {
|
||||
descendants,
|
||||
ancestors,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default new TransactionRepository();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -88,5 +88,7 @@ module.exports = {
|
||||
verifyTxOutProof: 'verifytxoutproof', // bitcoind v0.11.0+
|
||||
walletLock: 'walletlock',
|
||||
walletPassphrase: 'walletpassphrase',
|
||||
walletPassphraseChange: 'walletpassphrasechange'
|
||||
}
|
||||
walletPassphraseChange: 'walletpassphrasechange',
|
||||
getTxoutSetinfo: 'gettxoutsetinfo',
|
||||
getIndexInfo: 'getindexinfo',
|
||||
};
|
||||
|
||||
457
backend/src/tasks/lightning/forensics.service.ts
Normal file
457
backend/src/tasks/lightning/forensics.service.ts
Normal file
@@ -0,0 +1,457 @@
|
||||
import DB from '../../database';
|
||||
import logger from '../../logger';
|
||||
import channelsApi from '../../api/explorer/channels.api';
|
||||
import bitcoinApi from '../../api/bitcoin/bitcoin-api-factory';
|
||||
import config from '../../config';
|
||||
import { IEsploraApi } from '../../api/bitcoin/esplora-api.interface';
|
||||
import { Common } from '../../api/common';
|
||||
import { ILightningApi } from '../../api/lightning/lightning-api.interface';
|
||||
|
||||
const tempCacheSize = 10000;
|
||||
|
||||
class ForensicsService {
|
||||
loggerTimer = 0;
|
||||
closedChannelsScanBlock = 0;
|
||||
txCache: { [txid: string]: IEsploraApi.Transaction } = {};
|
||||
tempCached: string[] = [];
|
||||
|
||||
constructor() {}
|
||||
|
||||
public async $startService(): Promise<void> {
|
||||
logger.info('Starting lightning network forensics service');
|
||||
|
||||
this.loggerTimer = new Date().getTime() / 1000;
|
||||
|
||||
await this.$runTasks();
|
||||
}
|
||||
|
||||
private async $runTasks(): Promise<void> {
|
||||
try {
|
||||
logger.info(`Running forensics scans`);
|
||||
|
||||
if (config.MEMPOOL.BACKEND === 'esplora') {
|
||||
await this.$runClosedChannelsForensics(false);
|
||||
await this.$runOpenedChannelsForensics();
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
logger.err('ForensicsService.$runTasks() error: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
|
||||
setTimeout(() => { this.$runTasks(); }, 1000 * config.LIGHTNING.FORENSICS_INTERVAL);
|
||||
}
|
||||
|
||||
/*
|
||||
1. Mutually closed
|
||||
2. Forced closed
|
||||
3. Forced closed with penalty
|
||||
|
||||
┌────────────────────────────────────┐ ┌────────────────────────────┐
|
||||
│ outputs contain revocation script? ├──yes──► force close w/ penalty = 3 │
|
||||
└──────────────┬─────────────────────┘ └────────────────────────────┘
|
||||
no
|
||||
┌──────────────▼──────────────────────────┐
|
||||
│ outputs contain other lightning script? ├──┐
|
||||
└──────────────┬──────────────────────────┘ │
|
||||
no yes
|
||||
┌──────────────▼─────────────┐ │
|
||||
│ sequence starts with 0x80 │ ┌────────▼────────┐
|
||||
│ and ├──────► force close = 2 │
|
||||
│ locktime starts with 0x20? │ └─────────────────┘
|
||||
└──────────────┬─────────────┘
|
||||
no
|
||||
┌─────────▼────────┐
|
||||
│ mutual close = 1 │
|
||||
└──────────────────┘
|
||||
*/
|
||||
|
||||
public async $runClosedChannelsForensics(onlyNewChannels: boolean = false): Promise<void> {
|
||||
if (config.MEMPOOL.BACKEND !== 'esplora') {
|
||||
return;
|
||||
}
|
||||
|
||||
let progress = 0;
|
||||
|
||||
try {
|
||||
logger.info(`Started running closed channel forensics...`);
|
||||
let channels;
|
||||
if (onlyNewChannels) {
|
||||
channels = await channelsApi.$getClosedChannelsWithoutReason();
|
||||
} else {
|
||||
channels = await channelsApi.$getUnresolvedClosedChannels();
|
||||
}
|
||||
|
||||
for (const channel of channels) {
|
||||
let reason = 0;
|
||||
let resolvedForceClose = false;
|
||||
// Only Esplora backend can retrieve spent transaction outputs
|
||||
const cached: string[] = [];
|
||||
try {
|
||||
let outspends: IEsploraApi.Outspend[] | undefined;
|
||||
try {
|
||||
outspends = await bitcoinApi.$getOutspends(channel.closing_transaction_id);
|
||||
await Common.sleep$(config.LIGHTNING.FORENSICS_RATE_LIMIT);
|
||||
} catch (e) {
|
||||
logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + channel.closing_transaction_id + '/outspends'}. Reason ${e instanceof Error ? e.message : e}`);
|
||||
continue;
|
||||
}
|
||||
const lightningScriptReasons: number[] = [];
|
||||
for (const outspend of outspends) {
|
||||
if (outspend.spent && outspend.txid) {
|
||||
let spendingTx = await this.fetchTransaction(outspend.txid);
|
||||
if (!spendingTx) {
|
||||
continue;
|
||||
}
|
||||
cached.push(spendingTx.txid);
|
||||
const lightningScript = this.findLightningScript(spendingTx.vin[outspend.vin || 0]);
|
||||
lightningScriptReasons.push(lightningScript);
|
||||
}
|
||||
}
|
||||
const filteredReasons = lightningScriptReasons.filter((r) => r !== 1);
|
||||
if (filteredReasons.length) {
|
||||
if (filteredReasons.some((r) => r === 2 || r === 4)) {
|
||||
reason = 3;
|
||||
} else {
|
||||
reason = 2;
|
||||
resolvedForceClose = true;
|
||||
}
|
||||
} else {
|
||||
/*
|
||||
We can detect a commitment transaction (force close) by reading Sequence and Locktime
|
||||
https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction
|
||||
*/
|
||||
let closingTx = await this.fetchTransaction(channel.closing_transaction_id, true);
|
||||
if (!closingTx) {
|
||||
continue;
|
||||
}
|
||||
cached.push(closingTx.txid);
|
||||
const sequenceHex: string = closingTx.vin[0].sequence.toString(16);
|
||||
const locktimeHex: string = closingTx.locktime.toString(16);
|
||||
if (sequenceHex.substring(0, 2) === '80' && locktimeHex.substring(0, 2) === '20') {
|
||||
reason = 2; // Here we can't be sure if it's a penalty or not
|
||||
} else {
|
||||
reason = 1;
|
||||
}
|
||||
}
|
||||
if (reason) {
|
||||
logger.debug('Setting closing reason ' + reason + ' for channel: ' + channel.id + '.');
|
||||
await DB.query(`UPDATE channels SET closing_reason = ? WHERE id = ?`, [reason, channel.id]);
|
||||
if (reason === 2 && resolvedForceClose) {
|
||||
await DB.query(`UPDATE channels SET closing_resolved = ? WHERE id = ?`, [true, channel.id]);
|
||||
}
|
||||
if (reason !== 2 || resolvedForceClose) {
|
||||
cached.forEach(txid => {
|
||||
delete this.txCache[txid];
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err(`$runClosedChannelsForensics() failed for channel ${channel.short_id}. Reason: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
|
||||
++progress;
|
||||
const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer);
|
||||
if (elapsedSeconds > 10) {
|
||||
logger.info(`Updating channel closed channel forensics ${progress}/${channels.length}`);
|
||||
this.loggerTimer = new Date().getTime() / 1000;
|
||||
}
|
||||
}
|
||||
logger.info(`Closed channels forensics scan complete.`);
|
||||
} catch (e) {
|
||||
logger.err('$runClosedChannelsForensics() error: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
private findLightningScript(vin: IEsploraApi.Vin): number {
|
||||
const topElement = vin.witness?.length > 2 ? vin.witness[vin.witness.length - 2] : null;
|
||||
if (/^OP_IF OP_PUSHBYTES_33 \w{66} OP_ELSE OP_PUSH(NUM_\d+|BYTES_(1 \w{2}|2 \w{4})) OP_CSV OP_DROP OP_PUSHBYTES_33 \w{66} OP_ENDIF OP_CHECKSIG$/.test(vin.inner_witnessscript_asm)) {
|
||||
// https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction-outputs
|
||||
if (topElement === '01') {
|
||||
// top element is '01' to get in the revocation path
|
||||
// 'Revoked Lightning Force Close';
|
||||
// Penalty force closed
|
||||
return 2;
|
||||
} else {
|
||||
// top element is '', this is a delayed to_local output
|
||||
// 'Lightning Force Close';
|
||||
return 3;
|
||||
}
|
||||
} else if (
|
||||
/^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_NOTIF OP_DROP OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(vin.inner_witnessscript_asm) ||
|
||||
/^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_IF OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_DROP OP_PUSHBYTES_3 \w{6} OP_CLTV OP_DROP OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/.test(vin.inner_witnessscript_asm)
|
||||
) {
|
||||
// https://github.com/lightning/bolts/blob/master/03-transactions.md#offered-htlc-outputs
|
||||
// https://github.com/lightning/bolts/blob/master/03-transactions.md#received-htlc-outputs
|
||||
if (topElement?.length === 66) {
|
||||
// top element is a public key
|
||||
// 'Revoked Lightning HTLC'; Penalty force closed
|
||||
return 4;
|
||||
} else if (topElement) {
|
||||
// top element is a preimage
|
||||
// 'Lightning HTLC';
|
||||
return 5;
|
||||
} else {
|
||||
// top element is '' to get in the expiry of the script
|
||||
// 'Expired Lightning HTLC';
|
||||
return 6;
|
||||
}
|
||||
} else if (/^OP_PUSHBYTES_33 \w{66} OP_CHECKSIG OP_IFDUP OP_NOTIF OP_PUSHNUM_16 OP_CSV OP_ENDIF$/.test(vin.inner_witnessscript_asm)) {
|
||||
// https://github.com/lightning/bolts/blob/master/03-transactions.md#to_local_anchor-and-to_remote_anchor-output-option_anchors
|
||||
if (topElement) {
|
||||
// top element is a signature
|
||||
// 'Lightning Anchor';
|
||||
return 7;
|
||||
} else {
|
||||
// top element is '', it has been swept after 16 blocks
|
||||
// 'Swept Lightning Anchor';
|
||||
return 8;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// If a channel open tx spends funds from a another channel transaction,
|
||||
// we can attribute that output to a specific counterparty
|
||||
private async $runOpenedChannelsForensics(): Promise<void> {
|
||||
const runTimer = Date.now();
|
||||
let progress = 0;
|
||||
|
||||
try {
|
||||
logger.info(`Started running open channel forensics...`);
|
||||
const channels = await channelsApi.$getChannelsWithoutSourceChecked();
|
||||
|
||||
for (const openChannel of channels) {
|
||||
let openTx = await this.fetchTransaction(openChannel.transaction_id, true);
|
||||
if (!openTx) {
|
||||
continue;
|
||||
}
|
||||
for (const input of openTx.vin) {
|
||||
const closeChannel = await channelsApi.$getChannelByClosingId(input.txid);
|
||||
if (closeChannel) {
|
||||
// this input directly spends a channel close output
|
||||
await this.$attributeChannelBalances(closeChannel, openChannel, input);
|
||||
} else {
|
||||
const prevOpenChannels = await channelsApi.$getChannelsByOpeningId(input.txid);
|
||||
if (prevOpenChannels?.length) {
|
||||
// this input spends a channel open change output
|
||||
for (const prevOpenChannel of prevOpenChannels) {
|
||||
await this.$attributeChannelBalances(prevOpenChannel, openChannel, input, null, null, true);
|
||||
}
|
||||
} else {
|
||||
// check if this input spends any swept channel close outputs
|
||||
await this.$attributeSweptChannelCloses(openChannel, input);
|
||||
}
|
||||
}
|
||||
}
|
||||
// calculate how much of the total input value is attributable to the channel open output
|
||||
openChannel.funding_ratio = openTx.vout[openChannel.transaction_vout].value / ((openTx.vout.reduce((sum, v) => sum + v.value, 0) || 1) + openTx.fee);
|
||||
// save changes to the opening channel, and mark it as checked
|
||||
if (openTx?.vin?.length === 1) {
|
||||
openChannel.single_funded = true;
|
||||
}
|
||||
if (openChannel.node1_funding_balance || openChannel.node2_funding_balance || openChannel.node1_closing_balance || openChannel.node2_closing_balance || openChannel.closed_by) {
|
||||
await channelsApi.$updateOpeningInfo(openChannel);
|
||||
}
|
||||
await channelsApi.$markChannelSourceChecked(openChannel.id);
|
||||
|
||||
++progress;
|
||||
const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer);
|
||||
if (elapsedSeconds > 10) {
|
||||
logger.info(`Updating opened channel forensics ${progress}/${channels?.length}`);
|
||||
this.loggerTimer = new Date().getTime() / 1000;
|
||||
this.truncateTempCache();
|
||||
}
|
||||
if (Date.now() - runTimer > (config.LIGHTNING.FORENSICS_INTERVAL * 1000)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Open channels forensics scan complete.`);
|
||||
} catch (e) {
|
||||
logger.err('$runOpenedChannelsForensics() error: ' + (e instanceof Error ? e.message : e));
|
||||
} finally {
|
||||
this.clearTempCache();
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a channel open tx input spends the result of a swept channel close output
|
||||
private async $attributeSweptChannelCloses(openChannel: ILightningApi.Channel, input: IEsploraApi.Vin): Promise<void> {
|
||||
let sweepTx = await this.fetchTransaction(input.txid, true);
|
||||
if (!sweepTx) {
|
||||
logger.err(`couldn't find input transaction for channel forensics ${openChannel.channel_id} ${input.txid}`);
|
||||
return;
|
||||
}
|
||||
const openContribution = sweepTx.vout[input.vout].value;
|
||||
for (const sweepInput of sweepTx.vin) {
|
||||
const lnScriptType = this.findLightningScript(sweepInput);
|
||||
if (lnScriptType > 1) {
|
||||
const closeChannel = await channelsApi.$getChannelByClosingId(sweepInput.txid);
|
||||
if (closeChannel) {
|
||||
const initiator = (lnScriptType === 2 || lnScriptType === 4) ? 'remote' : (lnScriptType === 3 ? 'local' : null);
|
||||
await this.$attributeChannelBalances(closeChannel, openChannel, sweepInput, openContribution, initiator);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async $attributeChannelBalances(
|
||||
prevChannel, openChannel, input: IEsploraApi.Vin, openContribution: number | null = null,
|
||||
initiator: 'remote' | 'local' | null = null, linkedOpenings: boolean = false
|
||||
): Promise<void> {
|
||||
// figure out which node controls the input/output
|
||||
let openSide;
|
||||
let prevLocal;
|
||||
let prevRemote;
|
||||
let matched = false;
|
||||
let ambiguous = false; // if counterparties are the same in both channels, we can't tell them apart
|
||||
if (openChannel.node1_public_key === prevChannel.node1_public_key) {
|
||||
openSide = 1;
|
||||
prevLocal = 1;
|
||||
prevRemote = 2;
|
||||
matched = true;
|
||||
} else if (openChannel.node1_public_key === prevChannel.node2_public_key) {
|
||||
openSide = 1;
|
||||
prevLocal = 2;
|
||||
prevRemote = 1;
|
||||
matched = true;
|
||||
}
|
||||
if (openChannel.node2_public_key === prevChannel.node1_public_key) {
|
||||
openSide = 2;
|
||||
prevLocal = 1;
|
||||
prevRemote = 2;
|
||||
if (matched) {
|
||||
ambiguous = true;
|
||||
}
|
||||
matched = true;
|
||||
} else if (openChannel.node2_public_key === prevChannel.node2_public_key) {
|
||||
openSide = 2;
|
||||
prevLocal = 2;
|
||||
prevRemote = 1;
|
||||
if (matched) {
|
||||
ambiguous = true;
|
||||
}
|
||||
matched = true;
|
||||
}
|
||||
|
||||
if (matched && !ambiguous) {
|
||||
// fetch closing channel transaction and perform forensics on the outputs
|
||||
let prevChannelTx = await this.fetchTransaction(input.txid, true);
|
||||
let outspends: IEsploraApi.Outspend[] | undefined;
|
||||
try {
|
||||
outspends = await bitcoinApi.$getOutspends(input.txid);
|
||||
await Common.sleep$(config.LIGHTNING.FORENSICS_RATE_LIMIT);
|
||||
} catch (e) {
|
||||
logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + input.txid + '/outspends'}. Reason ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
if (!outspends || !prevChannelTx) {
|
||||
return;
|
||||
}
|
||||
if (!linkedOpenings) {
|
||||
if (!prevChannel.outputs || !prevChannel.outputs.length) {
|
||||
prevChannel.outputs = prevChannelTx.vout.map(vout => {
|
||||
return {
|
||||
type: 0,
|
||||
value: vout.value,
|
||||
};
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < outspends?.length; i++) {
|
||||
const outspend = outspends[i];
|
||||
const output = prevChannel.outputs[i];
|
||||
if (outspend.spent && outspend.txid) {
|
||||
try {
|
||||
const spendingTx = await this.fetchTransaction(outspend.txid, true);
|
||||
if (spendingTx) {
|
||||
output.type = this.findLightningScript(spendingTx.vin[outspend.vin || 0]);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + outspend.txid}. Reason ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
} else {
|
||||
output.type = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// attribute outputs to each counterparty, and sum up total known balances
|
||||
prevChannel.outputs[input.vout].node = prevLocal;
|
||||
const isPenalty = prevChannel.outputs.filter((out) => out.type === 2 || out.type === 4)?.length > 0;
|
||||
const normalOutput = [1,3].includes(prevChannel.outputs[input.vout].type);
|
||||
const mutualClose = ((prevChannel.status === 2 || prevChannel.status === 'closed') && prevChannel.closing_reason === 1);
|
||||
let localClosingBalance = 0;
|
||||
let remoteClosingBalance = 0;
|
||||
for (const output of prevChannel.outputs) {
|
||||
if (isPenalty) {
|
||||
// penalty close, so local node takes everything
|
||||
localClosingBalance += output.value;
|
||||
} else if (output.node) {
|
||||
// this output determinstically linked to one of the counterparties
|
||||
if (output.node === prevLocal) {
|
||||
localClosingBalance += output.value;
|
||||
} else {
|
||||
remoteClosingBalance += output.value;
|
||||
}
|
||||
} else if (normalOutput && (output.type === 1 || output.type === 3 || (mutualClose && prevChannel.outputs.length === 2))) {
|
||||
// local node had one main output, therefore remote node takes the other
|
||||
remoteClosingBalance += output.value;
|
||||
}
|
||||
}
|
||||
prevChannel[`node${prevLocal}_closing_balance`] = localClosingBalance;
|
||||
prevChannel[`node${prevRemote}_closing_balance`] = remoteClosingBalance;
|
||||
prevChannel.closing_fee = prevChannelTx.fee;
|
||||
|
||||
if (initiator && !linkedOpenings) {
|
||||
const initiatorSide = initiator === 'remote' ? prevRemote : prevLocal;
|
||||
prevChannel.closed_by = prevChannel[`node${initiatorSide}_public_key`];
|
||||
}
|
||||
|
||||
// save changes to the closing channel
|
||||
await channelsApi.$updateClosingInfo(prevChannel);
|
||||
} else {
|
||||
if (prevChannelTx.vin.length <= 1) {
|
||||
prevChannel[`node${prevLocal}_funding_balance`] = prevChannel.capacity;
|
||||
prevChannel.single_funded = true;
|
||||
prevChannel.funding_ratio = 1;
|
||||
// save changes to the closing channel
|
||||
await channelsApi.$updateOpeningInfo(prevChannel);
|
||||
}
|
||||
}
|
||||
openChannel[`node${openSide}_funding_balance`] = openChannel[`node${openSide}_funding_balance`] + (openContribution || prevChannelTx?.vout[input.vout]?.value || 0);
|
||||
}
|
||||
}
|
||||
|
||||
async fetchTransaction(txid: string, temp: boolean = false): Promise<IEsploraApi.Transaction | null> {
|
||||
let tx = this.txCache[txid];
|
||||
if (!tx) {
|
||||
try {
|
||||
tx = await bitcoinApi.$getRawTransaction(txid);
|
||||
this.txCache[txid] = tx;
|
||||
if (temp) {
|
||||
this.tempCached.push(txid);
|
||||
}
|
||||
await Common.sleep$(config.LIGHTNING.FORENSICS_RATE_LIMIT);
|
||||
} catch (e) {
|
||||
logger.err(`Failed to call ${config.ESPLORA.REST_API_URL + '/tx/' + txid + '/outspends'}. Reason ${e instanceof Error ? e.message : e}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return tx;
|
||||
}
|
||||
|
||||
clearTempCache(): void {
|
||||
for (const txid of this.tempCached) {
|
||||
delete this.txCache[txid];
|
||||
}
|
||||
this.tempCached = [];
|
||||
}
|
||||
|
||||
truncateTempCache(): void {
|
||||
if (this.tempCached.length > tempCacheSize) {
|
||||
const removed = this.tempCached.splice(0, this.tempCached.length - tempCacheSize);
|
||||
for (const txid of removed) {
|
||||
delete this.txCache[txid];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ForensicsService();
|
||||
316
backend/src/tasks/lightning/network-sync.service.ts
Normal file
316
backend/src/tasks/lightning/network-sync.service.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import DB from '../../database';
|
||||
import logger from '../../logger';
|
||||
import channelsApi from '../../api/explorer/channels.api';
|
||||
import bitcoinApi from '../../api/bitcoin/bitcoin-api-factory';
|
||||
import config from '../../config';
|
||||
import { IEsploraApi } from '../../api/bitcoin/esplora-api.interface';
|
||||
import { ILightningApi } from '../../api/lightning/lightning-api.interface';
|
||||
import { $lookupNodeLocation } from './sync-tasks/node-locations';
|
||||
import lightningApi from '../../api/lightning/lightning-api-factory';
|
||||
import nodesApi from '../../api/explorer/nodes.api';
|
||||
import { ResultSetHeader } from 'mysql2';
|
||||
import fundingTxFetcher from './sync-tasks/funding-tx-fetcher';
|
||||
import NodesSocketsRepository from '../../repositories/NodesSocketsRepository';
|
||||
import { Common } from '../../api/common';
|
||||
import blocks from '../../api/blocks';
|
||||
import NodeRecordsRepository from '../../repositories/NodeRecordsRepository';
|
||||
import forensicsService from './forensics.service';
|
||||
|
||||
class NetworkSyncService {
|
||||
loggerTimer = 0;
|
||||
closedChannelsScanBlock = 0;
|
||||
|
||||
constructor() {}
|
||||
|
||||
public async $startService(): Promise<void> {
|
||||
logger.info(`Starting lightning network sync service`, logger.tags.ln);
|
||||
|
||||
this.loggerTimer = new Date().getTime() / 1000;
|
||||
|
||||
await this.$runTasks();
|
||||
}
|
||||
|
||||
private async $runTasks(): Promise<void> {
|
||||
const taskStartTime = Date.now();
|
||||
try {
|
||||
logger.debug(`Updating nodes and channels`, logger.tags.ln);
|
||||
|
||||
const networkGraph = await lightningApi.$getNetworkGraph();
|
||||
if (networkGraph.nodes.length === 0 || networkGraph.edges.length === 0) {
|
||||
logger.info(`LN Network graph is empty, retrying in 10 seconds`, logger.tags.ln);
|
||||
setTimeout(() => { this.$runTasks(); }, 10000);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.$updateNodesList(networkGraph.nodes);
|
||||
await this.$updateChannelsList(networkGraph.edges);
|
||||
await this.$deactivateChannelsWithoutActiveNodes();
|
||||
await this.$lookUpCreationDateFromChain();
|
||||
await this.$updateNodeFirstSeen();
|
||||
await this.$scanForClosedChannels();
|
||||
|
||||
if (config.MEMPOOL.BACKEND === 'esplora') {
|
||||
// run forensics on new channels only
|
||||
await forensicsService.$runClosedChannelsForensics(true);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
logger.err(`$runTasks() error: ${e instanceof Error ? e.message : e}`, logger.tags.ln);
|
||||
}
|
||||
|
||||
setTimeout(() => { this.$runTasks(); }, Math.max(1, (1000 * config.LIGHTNING.GRAPH_REFRESH_INTERVAL) - (Date.now() - taskStartTime)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the `nodes` table to reflect the current network graph state
|
||||
*/
|
||||
private async $updateNodesList(nodes: ILightningApi.Node[]): Promise<void> {
|
||||
let progress = 0;
|
||||
|
||||
let deletedSockets = 0;
|
||||
let deletedRecords = 0;
|
||||
const graphNodesPubkeys: string[] = [];
|
||||
for (const node of nodes) {
|
||||
const latestUpdated = await channelsApi.$getLatestChannelUpdateForNode(node.pub_key);
|
||||
node.last_update = Math.max(node.last_update ?? 0, latestUpdated);
|
||||
|
||||
await nodesApi.$saveNode(node);
|
||||
graphNodesPubkeys.push(node.pub_key);
|
||||
++progress;
|
||||
|
||||
const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer);
|
||||
if (elapsedSeconds > config.LIGHTNING.LOGGER_UPDATE_INTERVAL) {
|
||||
logger.debug(`Updating node ${progress}/${nodes.length}`, logger.tags.ln);
|
||||
this.loggerTimer = new Date().getTime() / 1000;
|
||||
}
|
||||
|
||||
const addresses: string[] = [];
|
||||
for (const socket of node.addresses) {
|
||||
await NodesSocketsRepository.$saveSocket(Common.formatSocket(node.pub_key, socket));
|
||||
addresses.push(socket.addr);
|
||||
}
|
||||
deletedSockets += await NodesSocketsRepository.$deleteUnusedSockets(node.pub_key, addresses);
|
||||
|
||||
const oldRecordTypes = await NodeRecordsRepository.$getRecordTypes(node.pub_key);
|
||||
const customRecordTypes: number[] = [];
|
||||
for (const [type, payload] of Object.entries(node.custom_records || {})) {
|
||||
const numericalType = parseInt(type);
|
||||
await NodeRecordsRepository.$saveRecord({
|
||||
publicKey: node.pub_key,
|
||||
type: numericalType,
|
||||
payload,
|
||||
});
|
||||
customRecordTypes.push(numericalType);
|
||||
}
|
||||
if (oldRecordTypes.reduce((changed, type) => changed || customRecordTypes.indexOf(type) === -1, false)) {
|
||||
deletedRecords += await NodeRecordsRepository.$deleteUnusedRecords(node.pub_key, customRecordTypes);
|
||||
}
|
||||
}
|
||||
logger.debug(`${progress} nodes updated. ${deletedSockets} sockets deleted. ${deletedRecords} custom records deleted.`);
|
||||
|
||||
// If a channel if not present in the graph, mark it as inactive
|
||||
await nodesApi.$setNodesInactive(graphNodesPubkeys);
|
||||
|
||||
if (config.MAXMIND.ENABLED) {
|
||||
$lookupNodeLocation();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the `channels` table to reflect the current network graph state
|
||||
*/
|
||||
private async $updateChannelsList(channels: ILightningApi.Channel[]): Promise<void> {
|
||||
try {
|
||||
const [closedChannelsRaw]: any[] = await DB.query(`SELECT id FROM channels WHERE status = 2`);
|
||||
const closedChannels = {};
|
||||
for (const closedChannel of closedChannelsRaw) {
|
||||
closedChannels[closedChannel.id] = true;
|
||||
}
|
||||
|
||||
let progress = 0;
|
||||
|
||||
const graphChannelsIds: string[] = [];
|
||||
for (const channel of channels) {
|
||||
if (!closedChannels[channel.channel_id]) {
|
||||
await channelsApi.$saveChannel(channel);
|
||||
}
|
||||
graphChannelsIds.push(channel.channel_id);
|
||||
++progress;
|
||||
|
||||
const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer);
|
||||
if (elapsedSeconds > config.LIGHTNING.LOGGER_UPDATE_INTERVAL) {
|
||||
logger.debug(`Updating channel ${progress}/${channels.length}`, logger.tags.ln);
|
||||
this.loggerTimer = new Date().getTime() / 1000;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`${progress} channels updated`, logger.tags.ln);
|
||||
|
||||
// If a channel if not present in the graph, mark it as inactive
|
||||
await channelsApi.$setChannelsInactive(graphChannelsIds);
|
||||
} catch (e) {
|
||||
logger.err(` Cannot update channel list. Reason: ${(e instanceof Error ? e.message : e)}`, logger.tags.ln);
|
||||
}
|
||||
}
|
||||
|
||||
// This method look up the creation date of the earliest channel of the node
|
||||
// and update the node to that date in order to get the earliest first seen date
|
||||
private async $updateNodeFirstSeen(): Promise<void> {
|
||||
let progress = 0;
|
||||
let updated = 0;
|
||||
|
||||
try {
|
||||
const [nodes]: any[] = await DB.query(`
|
||||
SELECT nodes.public_key, UNIX_TIMESTAMP(nodes.first_seen) AS first_seen,
|
||||
(
|
||||
SELECT MIN(UNIX_TIMESTAMP(created))
|
||||
FROM channels
|
||||
WHERE channels.node1_public_key = nodes.public_key
|
||||
) AS created1,
|
||||
(
|
||||
SELECT MIN(UNIX_TIMESTAMP(created))
|
||||
FROM channels
|
||||
WHERE channels.node2_public_key = nodes.public_key
|
||||
) AS created2
|
||||
FROM nodes
|
||||
`);
|
||||
|
||||
for (const node of nodes) {
|
||||
const lowest = Math.min(
|
||||
node.created1 ?? Number.MAX_SAFE_INTEGER,
|
||||
node.created2 ?? Number.MAX_SAFE_INTEGER,
|
||||
node.first_seen ?? Number.MAX_SAFE_INTEGER
|
||||
);
|
||||
if (lowest < node.first_seen) {
|
||||
const query = `UPDATE nodes SET first_seen = FROM_UNIXTIME(?) WHERE public_key = ?`;
|
||||
const params = [lowest, node.public_key];
|
||||
++updated;
|
||||
await DB.query(query, params);
|
||||
}
|
||||
++progress;
|
||||
const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer);
|
||||
if (elapsedSeconds > config.LIGHTNING.LOGGER_UPDATE_INTERVAL) {
|
||||
logger.debug(`Updating node first seen date ${progress}/${nodes.length}`, logger.tags.ln);
|
||||
this.loggerTimer = new Date().getTime() / 1000;
|
||||
}
|
||||
}
|
||||
if (updated > 0) {
|
||||
logger.debug(`Updated ${updated} node first seen dates`, logger.tags.ln);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err(`$updateNodeFirstSeen() error: ${e instanceof Error ? e.message : e}`, logger.tags.ln);
|
||||
}
|
||||
}
|
||||
|
||||
private async $lookUpCreationDateFromChain(): Promise<void> {
|
||||
let progress = 0;
|
||||
|
||||
logger.debug(`Running channel creation date lookup`, logger.tags.ln);
|
||||
try {
|
||||
const channels = await channelsApi.$getChannelsWithoutCreatedDate();
|
||||
for (const channel of channels) {
|
||||
const transaction = await fundingTxFetcher.$fetchChannelOpenTx(channel.short_id);
|
||||
if (!transaction) {
|
||||
continue;
|
||||
}
|
||||
await DB.query(`
|
||||
UPDATE channels SET created = FROM_UNIXTIME(?) WHERE channels.id = ?`,
|
||||
[transaction.timestamp, channel.id]
|
||||
);
|
||||
++progress;
|
||||
const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer);
|
||||
if (elapsedSeconds > config.LIGHTNING.LOGGER_UPDATE_INTERVAL) {
|
||||
logger.debug(`Updating channel creation date ${progress}/${channels.length}`, logger.tags.ln);
|
||||
this.loggerTimer = new Date().getTime() / 1000;
|
||||
}
|
||||
}
|
||||
|
||||
if (channels.length > 0) {
|
||||
logger.debug(`Updated ${channels.length} channels' creation date`, logger.tags.ln);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err(`$lookUpCreationDateFromChain() error: ${e instanceof Error ? e.message : e}`, logger.tags.ln);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If a channel does not have any active node linked to it, then also
|
||||
* mark that channel as inactive
|
||||
*/
|
||||
private async $deactivateChannelsWithoutActiveNodes(): Promise<void> {
|
||||
logger.debug(`Find channels which nodes are offline`, logger.tags.ln);
|
||||
|
||||
try {
|
||||
const result = await DB.query<ResultSetHeader>(`
|
||||
UPDATE channels
|
||||
SET status = 0
|
||||
WHERE channels.status = 1
|
||||
AND (
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM nodes
|
||||
WHERE nodes.public_key = channels.node1_public_key
|
||||
AND nodes.status = 1
|
||||
) = 0
|
||||
OR (
|
||||
SELECT COUNT(*)
|
||||
FROM nodes
|
||||
WHERE nodes.public_key = channels.node2_public_key
|
||||
AND nodes.status = 1
|
||||
) = 0)
|
||||
`);
|
||||
|
||||
if (result[0].changedRows ?? 0 > 0) {
|
||||
logger.debug(`Marked ${result[0].changedRows} channels as inactive because they are not linked to any active node`, logger.tags.ln);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err(`$deactivateChannelsWithoutActiveNodes() error: ${e instanceof Error ? e.message : e}`, logger.tags.ln);
|
||||
}
|
||||
}
|
||||
|
||||
private async $scanForClosedChannels(): Promise<void> {
|
||||
if (this.closedChannelsScanBlock === blocks.getCurrentBlockHeight()) {
|
||||
logger.debug(`We've already scan closed channels for this block, skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
let progress = 0;
|
||||
|
||||
try {
|
||||
let log = `Starting closed channels scan`;
|
||||
if (this.closedChannelsScanBlock > 0) {
|
||||
log += `. Last scan was at block ${this.closedChannelsScanBlock}`;
|
||||
} else {
|
||||
log += ` for the first time`;
|
||||
}
|
||||
logger.info(`${log}`, logger.tags.ln);
|
||||
|
||||
const channels = await channelsApi.$getChannelsByStatus([0, 1]);
|
||||
for (const channel of channels) {
|
||||
const spendingTx = await bitcoinApi.$getOutspend(channel.transaction_id, channel.transaction_vout);
|
||||
if (spendingTx.spent === true && spendingTx.status?.confirmed === true) {
|
||||
logger.debug(`Marking channel: ${channel.id} as closed.`, logger.tags.ln);
|
||||
await DB.query(`UPDATE channels SET status = 2, closing_date = FROM_UNIXTIME(?) WHERE id = ?`,
|
||||
[spendingTx.status.block_time, channel.id]);
|
||||
if (spendingTx.txid && !channel.closing_transaction_id) {
|
||||
await DB.query(`UPDATE channels SET closing_transaction_id = ? WHERE id = ?`, [spendingTx.txid, channel.id]);
|
||||
}
|
||||
}
|
||||
|
||||
++progress;
|
||||
const elapsedSeconds = Math.round((new Date().getTime() / 1000) - this.loggerTimer);
|
||||
if (elapsedSeconds > config.LIGHTNING.LOGGER_UPDATE_INTERVAL) {
|
||||
logger.info(`Checking if channel has been closed ${progress}/${channels.length}`, logger.tags.ln);
|
||||
this.loggerTimer = new Date().getTime() / 1000;
|
||||
}
|
||||
}
|
||||
|
||||
this.closedChannelsScanBlock = blocks.getCurrentBlockHeight();
|
||||
logger.debug(`Closed channels scan completed at block ${this.closedChannelsScanBlock}`, logger.tags.ln);
|
||||
} catch (e) {
|
||||
logger.err(`$scanForClosedChannels() error: ${e instanceof Error ? e.message : e}`, logger.tags.ln);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new NetworkSyncService();
|
||||
37
backend/src/tasks/lightning/stats-updater.service.ts
Normal file
37
backend/src/tasks/lightning/stats-updater.service.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import logger from '../../logger';
|
||||
import lightningApi from '../../api/lightning/lightning-api-factory';
|
||||
import LightningStatsImporter from './sync-tasks/stats-importer';
|
||||
import config from '../../config';
|
||||
import { Common } from '../../api/common';
|
||||
|
||||
class LightningStatsUpdater {
|
||||
public async $startService(): Promise<void> {
|
||||
logger.info(`Starting Lightning Stats service`, logger.tags.ln);
|
||||
|
||||
await this.$runTasks();
|
||||
LightningStatsImporter.$run();
|
||||
}
|
||||
|
||||
private async $runTasks(): Promise<void> {
|
||||
await this.$logStatsDaily();
|
||||
|
||||
setTimeout(() => { this.$runTasks(); }, 1000 * config.LIGHTNING.STATS_REFRESH_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the latest entry for each node every config.LIGHTNING.STATS_REFRESH_INTERVAL seconds
|
||||
*/
|
||||
private async $logStatsDaily(): Promise<void> {
|
||||
try {
|
||||
const date = new Date();
|
||||
Common.setDateMidnight(date);
|
||||
const networkGraph = await lightningApi.$getNetworkGraph();
|
||||
await LightningStatsImporter.computeNetworkStats(date.getTime() / 1000, networkGraph);
|
||||
logger.debug(`Updated latest network stats`, logger.tags.ln);
|
||||
} catch (e) {
|
||||
logger.err(`Exception in $logStatsDaily. Reason: ${(e instanceof Error ? e.message : e)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new LightningStatsUpdater();
|
||||
122
backend/src/tasks/lightning/sync-tasks/funding-tx-fetcher.ts
Normal file
122
backend/src/tasks/lightning/sync-tasks/funding-tx-fetcher.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { existsSync, promises } from 'fs';
|
||||
import bitcoinClient from '../../../api/bitcoin/bitcoin-client';
|
||||
import { Common } from '../../../api/common';
|
||||
import config from '../../../config';
|
||||
import logger from '../../../logger';
|
||||
|
||||
const fsPromises = promises;
|
||||
|
||||
const BLOCKS_CACHE_MAX_SIZE = 100;
|
||||
const CACHE_FILE_NAME = config.MEMPOOL.CACHE_DIR + '/ln-funding-txs-cache.json';
|
||||
|
||||
class FundingTxFetcher {
|
||||
private running = false;
|
||||
private blocksCache = {};
|
||||
private channelNewlyProcessed = 0;
|
||||
public fundingTxCache = {};
|
||||
|
||||
async $init(): Promise<void> {
|
||||
// Load funding tx disk cache
|
||||
if (Object.keys(this.fundingTxCache).length === 0 && existsSync(CACHE_FILE_NAME)) {
|
||||
try {
|
||||
this.fundingTxCache = JSON.parse(await fsPromises.readFile(CACHE_FILE_NAME, 'utf-8'));
|
||||
} catch (e) {
|
||||
logger.err(`Unable to parse channels funding txs disk cache. Starting from scratch`, logger.tags.ln);
|
||||
this.fundingTxCache = {};
|
||||
}
|
||||
logger.debug(`Imported ${Object.keys(this.fundingTxCache).length} funding tx amount from the disk cache`, logger.tags.ln);
|
||||
}
|
||||
}
|
||||
|
||||
async $fetchChannelsFundingTxs(channelIds: string[]): Promise<void> {
|
||||
if (this.running) {
|
||||
return;
|
||||
}
|
||||
this.running = true;
|
||||
|
||||
const globalTimer = new Date().getTime() / 1000;
|
||||
let cacheTimer = new Date().getTime() / 1000;
|
||||
let loggerTimer = new Date().getTime() / 1000;
|
||||
let channelProcessed = 0;
|
||||
this.channelNewlyProcessed = 0;
|
||||
for (const channelId of channelIds) {
|
||||
await this.$fetchChannelOpenTx(channelId);
|
||||
++channelProcessed;
|
||||
|
||||
let elapsedSeconds = Math.round((new Date().getTime() / 1000) - loggerTimer);
|
||||
if (elapsedSeconds > config.LIGHTNING.LOGGER_UPDATE_INTERVAL) {
|
||||
elapsedSeconds = Math.round((new Date().getTime() / 1000) - globalTimer);
|
||||
logger.info(`Indexing channels funding tx ${channelProcessed + 1} of ${channelIds.length} ` +
|
||||
`(${Math.floor(channelProcessed / channelIds.length * 10000) / 100}%) | ` +
|
||||
`elapsed: ${elapsedSeconds} seconds`,
|
||||
logger.tags.ln
|
||||
);
|
||||
loggerTimer = new Date().getTime() / 1000;
|
||||
}
|
||||
|
||||
elapsedSeconds = Math.round((new Date().getTime() / 1000) - cacheTimer);
|
||||
if (elapsedSeconds > 60) {
|
||||
logger.debug(`Saving ${Object.keys(this.fundingTxCache).length} funding txs cache into disk`, logger.tags.ln);
|
||||
fsPromises.writeFile(CACHE_FILE_NAME, JSON.stringify(this.fundingTxCache));
|
||||
cacheTimer = new Date().getTime() / 1000;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.channelNewlyProcessed > 0) {
|
||||
logger.info(`Indexed ${this.channelNewlyProcessed} additional channels funding tx`, logger.tags.ln);
|
||||
logger.debug(`Saving ${Object.keys(this.fundingTxCache).length} funding txs cache into disk`, logger.tags.ln);
|
||||
fsPromises.writeFile(CACHE_FILE_NAME, JSON.stringify(this.fundingTxCache));
|
||||
}
|
||||
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
public async $fetchChannelOpenTx(channelId: string): Promise<{timestamp: number, txid: string, value: number} | null> {
|
||||
channelId = Common.channelIntegerIdToShortId(channelId);
|
||||
|
||||
if (this.fundingTxCache[channelId]) {
|
||||
return this.fundingTxCache[channelId];
|
||||
}
|
||||
|
||||
const parts = channelId.split('x');
|
||||
const blockHeight = parts[0];
|
||||
const txIdx = parts[1];
|
||||
const outputIdx = parts[2];
|
||||
|
||||
let block = this.blocksCache[blockHeight];
|
||||
// Fetch it from core
|
||||
if (!block) {
|
||||
const blockHash = await bitcoinClient.getBlockHash(parseInt(blockHeight, 10));
|
||||
block = await bitcoinClient.getBlock(blockHash, 1);
|
||||
}
|
||||
this.blocksCache[block.height] = block;
|
||||
|
||||
const blocksCacheHashes = Object.keys(this.blocksCache).sort((a, b) => parseInt(b) - parseInt(a)).reverse();
|
||||
if (blocksCacheHashes.length > BLOCKS_CACHE_MAX_SIZE) {
|
||||
for (let i = 0; i < 10; ++i) {
|
||||
delete this.blocksCache[blocksCacheHashes[i]];
|
||||
}
|
||||
}
|
||||
|
||||
const txid = block.tx[txIdx];
|
||||
const rawTx = await bitcoinClient.getRawTransaction(txid);
|
||||
const tx = await bitcoinClient.decodeRawTransaction(rawTx);
|
||||
|
||||
if (!tx || !tx.vout || tx.vout.length < parseInt(outputIdx, 10) + 1 || tx.vout[outputIdx].value === undefined) {
|
||||
logger.err(`Cannot find blockchain funding tx for channel id ${channelId}. Possible reasons are: bitcoin backend timeout or the channel shortId is not valid`);
|
||||
return null;
|
||||
}
|
||||
|
||||
this.fundingTxCache[channelId] = {
|
||||
timestamp: block.time,
|
||||
txid: txid,
|
||||
value: tx.vout[outputIdx].value,
|
||||
};
|
||||
|
||||
++this.channelNewlyProcessed;
|
||||
|
||||
return this.fundingTxCache[channelId];
|
||||
}
|
||||
}
|
||||
|
||||
export default new FundingTxFetcher;
|
||||
169
backend/src/tasks/lightning/sync-tasks/node-locations.ts
Normal file
169
backend/src/tasks/lightning/sync-tasks/node-locations.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import * as net from 'net';
|
||||
import maxmind, { CityResponse, AsnResponse, IspResponse } from 'maxmind';
|
||||
import nodesApi from '../../../api/explorer/nodes.api';
|
||||
import config from '../../../config';
|
||||
import DB from '../../../database';
|
||||
import logger from '../../../logger';
|
||||
import { ResultSetHeader } from 'mysql2';
|
||||
import * as IPCheck from '../../../utils/ipcheck.js';
|
||||
import { Reader } from 'mmdb-lib';
|
||||
|
||||
export async function $lookupNodeLocation(): Promise<void> {
|
||||
let loggerTimer = new Date().getTime() / 1000;
|
||||
let progress = 0;
|
||||
let nodesUpdated = 0;
|
||||
let geoNamesInserted = 0;
|
||||
|
||||
logger.debug(`Running node location updater using Maxmind`, logger.tags.ln);
|
||||
try {
|
||||
const nodes = await nodesApi.$getAllNodes();
|
||||
const lookupCity = await maxmind.open<CityResponse>(config.MAXMIND.GEOLITE2_CITY);
|
||||
const lookupAsn = await maxmind.open<AsnResponse>(config.MAXMIND.GEOLITE2_ASN);
|
||||
let lookupIsp: Reader<IspResponse> | null = null;
|
||||
try {
|
||||
lookupIsp = await maxmind.open<IspResponse>(config.MAXMIND.GEOIP2_ISP);
|
||||
} catch (e) { }
|
||||
|
||||
for (const node of nodes) {
|
||||
const sockets: string[] = node.sockets.split(',');
|
||||
for (const socket of sockets) {
|
||||
const ip = socket.substring(0, socket.lastIndexOf(':')).replace('[', '').replace(']', '');
|
||||
const hasClearnet = [4, 6].includes(net.isIP(ip));
|
||||
|
||||
if (hasClearnet && ip !== '127.0.1.1' && ip !== '127.0.0.1') {
|
||||
const city = lookupCity.get(ip);
|
||||
const asn = lookupAsn.get(ip);
|
||||
let isp: IspResponse | null = null;
|
||||
if (lookupIsp) {
|
||||
isp = lookupIsp.get(ip);
|
||||
}
|
||||
|
||||
let asOverwrite: any | undefined;
|
||||
if (asn && (IPCheck.match(ip, '170.75.160.0/20') || IPCheck.match(ip, '172.81.176.0/21'))) {
|
||||
asOverwrite = {
|
||||
asn: 394745,
|
||||
name: 'Lunanode',
|
||||
};
|
||||
}
|
||||
else if (asn && (IPCheck.match(ip, '50.7.0.0/16') || IPCheck.match(ip, '66.90.64.0/18'))) {
|
||||
asOverwrite = {
|
||||
asn: 30058,
|
||||
name: 'FDCservers.net',
|
||||
};
|
||||
}
|
||||
else if (asn && asn.autonomous_system_number === 174) {
|
||||
asOverwrite = {
|
||||
asn: 174,
|
||||
name: 'Cogent Communications',
|
||||
};
|
||||
}
|
||||
|
||||
if (city && (asn || isp)) {
|
||||
const query = `
|
||||
UPDATE nodes SET
|
||||
as_number = ?,
|
||||
city_id = ?,
|
||||
country_id = ?,
|
||||
subdivision_id = ?,
|
||||
longitude = ?,
|
||||
latitude = ?,
|
||||
accuracy_radius = ?
|
||||
WHERE public_key = ?
|
||||
`;
|
||||
|
||||
const params = [
|
||||
asOverwrite?.asn ?? isp?.autonomous_system_number ?? asn?.autonomous_system_number,
|
||||
city.city?.geoname_id,
|
||||
city.country?.geoname_id,
|
||||
city.subdivisions ? city.subdivisions[0].geoname_id : null,
|
||||
city.location?.longitude,
|
||||
city.location?.latitude,
|
||||
city.location?.accuracy_radius,
|
||||
node.public_key
|
||||
];
|
||||
let result = await DB.query<ResultSetHeader>(query, params);
|
||||
if (result[0].changedRows ?? 0 > 0) {
|
||||
++nodesUpdated;
|
||||
}
|
||||
|
||||
// Store Continent
|
||||
if (city.continent?.geoname_id) {
|
||||
result = await DB.query<ResultSetHeader>(
|
||||
`INSERT IGNORE INTO geo_names (id, type, names) VALUES (?, 'continent', ?)`,
|
||||
[city.continent?.geoname_id, JSON.stringify(city.continent?.names)]);
|
||||
if (result[0].changedRows ?? 0 > 0) {
|
||||
++geoNamesInserted;
|
||||
}
|
||||
}
|
||||
|
||||
// Store Country
|
||||
if (city.country?.geoname_id) {
|
||||
result = await DB.query<ResultSetHeader>(
|
||||
`INSERT IGNORE INTO geo_names (id, type, names) VALUES (?, 'country', ?)`,
|
||||
[city.country?.geoname_id, JSON.stringify(city.country?.names)]);
|
||||
if (result[0].changedRows ?? 0 > 0) {
|
||||
++geoNamesInserted;
|
||||
}
|
||||
}
|
||||
|
||||
// Store Country ISO code
|
||||
if (city.country?.iso_code) {
|
||||
result = await DB.query<ResultSetHeader>(
|
||||
`INSERT IGNORE INTO geo_names (id, type, names) VALUES (?, 'country_iso_code', ?)`,
|
||||
[city.country?.geoname_id, city.country?.iso_code]);
|
||||
if (result[0].changedRows ?? 0 > 0) {
|
||||
++geoNamesInserted;
|
||||
}
|
||||
}
|
||||
|
||||
// Store Division
|
||||
if (city.subdivisions && city.subdivisions[0]) {
|
||||
result = await DB.query<ResultSetHeader>(
|
||||
`INSERT IGNORE INTO geo_names (id, type, names) VALUES (?, 'division', ?)`,
|
||||
[city.subdivisions[0].geoname_id, JSON.stringify(city.subdivisions[0]?.names)]);
|
||||
if (result[0].changedRows ?? 0 > 0) {
|
||||
++geoNamesInserted;
|
||||
}
|
||||
}
|
||||
|
||||
// Store City
|
||||
if (city.city?.geoname_id) {
|
||||
result = await DB.query<ResultSetHeader>(
|
||||
`INSERT IGNORE INTO geo_names (id, type, names) VALUES (?, 'city', ?)`,
|
||||
[city.city?.geoname_id, JSON.stringify(city.city?.names)]);
|
||||
if (result[0].changedRows ?? 0 > 0) {
|
||||
++geoNamesInserted;
|
||||
}
|
||||
}
|
||||
|
||||
// Store AS name
|
||||
if (isp?.autonomous_system_organization ?? asn?.autonomous_system_organization) {
|
||||
result = await DB.query<ResultSetHeader>(
|
||||
`INSERT IGNORE INTO geo_names (id, type, names) VALUES (?, 'as_organization', ?)`,
|
||||
[
|
||||
asOverwrite?.asn ?? isp?.autonomous_system_number ?? asn?.autonomous_system_number,
|
||||
JSON.stringify(asOverwrite?.name ?? isp?.isp ?? asn?.autonomous_system_organization)
|
||||
]);
|
||||
if (result[0].changedRows ?? 0 > 0) {
|
||||
++geoNamesInserted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
++progress;
|
||||
const elapsedSeconds = Math.round((new Date().getTime() / 1000) - loggerTimer);
|
||||
if (elapsedSeconds > config.LIGHTNING.LOGGER_UPDATE_INTERVAL) {
|
||||
logger.debug(`Updating node location data ${progress}/${nodes.length}`);
|
||||
loggerTimer = new Date().getTime() / 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nodesUpdated > 0) {
|
||||
logger.debug(`${nodesUpdated} nodes maxmind data updated, ${geoNamesInserted} geo names inserted`, logger.tags.ln);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err('$lookupNodeLocation() error: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
546
backend/src/tasks/lightning/sync-tasks/stats-importer.ts
Normal file
546
backend/src/tasks/lightning/sync-tasks/stats-importer.ts
Normal file
@@ -0,0 +1,546 @@
|
||||
import DB from '../../../database';
|
||||
import { promises } from 'fs';
|
||||
import logger from '../../../logger';
|
||||
import fundingTxFetcher from './funding-tx-fetcher';
|
||||
import config from '../../../config';
|
||||
import { ILightningApi } from '../../../api/lightning/lightning-api.interface';
|
||||
import { isIP } from 'net';
|
||||
import { Common } from '../../../api/common';
|
||||
import channelsApi from '../../../api/explorer/channels.api';
|
||||
import nodesApi from '../../../api/explorer/nodes.api';
|
||||
|
||||
const fsPromises = promises;
|
||||
|
||||
class LightningStatsImporter {
|
||||
topologiesFolder = config.LIGHTNING.TOPOLOGY_FOLDER;
|
||||
|
||||
async $run(): Promise<void> {
|
||||
const [channels]: any[] = await DB.query('SELECT short_id from channels;');
|
||||
logger.info(`Caching funding txs for currently existing channels`, logger.tags.ln);
|
||||
await fundingTxFetcher.$fetchChannelsFundingTxs(channels.map(channel => channel.short_id));
|
||||
|
||||
if (config.MEMPOOL.NETWORK !== 'mainnet' || config.DATABASE.ENABLED === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.$importHistoricalLightningStats();
|
||||
await this.$cleanupIncorrectSnapshot();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate LN network stats for one day
|
||||
*/
|
||||
public async computeNetworkStats(timestamp: number,
|
||||
networkGraph: ILightningApi.NetworkGraph, isHistorical: boolean = false): Promise<unknown> {
|
||||
// Node counts and network shares
|
||||
let clearnetNodes = 0;
|
||||
let torNodes = 0;
|
||||
let clearnetTorNodes = 0;
|
||||
let unannouncedNodes = 0;
|
||||
|
||||
const [nodesInDbRaw]: any[] = await DB.query(`SELECT public_key FROM nodes`);
|
||||
const nodesInDb = {};
|
||||
for (const node of nodesInDbRaw) {
|
||||
nodesInDb[node.public_key] = node;
|
||||
}
|
||||
|
||||
for (const node of networkGraph.nodes) {
|
||||
// If we don't know about this node, insert it in db
|
||||
if (isHistorical === true && !nodesInDb[node.pub_key]) {
|
||||
await nodesApi.$saveNode({
|
||||
last_update: node.last_update,
|
||||
pub_key: node.pub_key,
|
||||
alias: node.alias,
|
||||
addresses: node.addresses,
|
||||
color: node.color,
|
||||
features: node.features,
|
||||
});
|
||||
nodesInDb[node.pub_key] = node;
|
||||
} else {
|
||||
await nodesApi.$updateNodeSockets(node.pub_key, node.addresses);
|
||||
}
|
||||
|
||||
let hasOnion = false;
|
||||
let hasClearnet = false;
|
||||
let isUnnanounced = true;
|
||||
|
||||
for (const socket of (node.addresses ?? [])) {
|
||||
if (!socket.network?.length && !socket.addr?.length) {
|
||||
continue;
|
||||
}
|
||||
hasOnion = hasOnion || ['torv2', 'torv3'].includes(socket.network) || socket.addr.indexOf('onion') !== -1 || socket.addr.indexOf('torv2') !== -1 || socket.addr.indexOf('torv3') !== -1;
|
||||
hasClearnet = hasClearnet || ['ipv4', 'ipv6'].includes(socket.network) || [4, 6].includes(isIP(socket.addr.split(':')[0])) || socket.addr.indexOf('ipv4') !== -1 || socket.addr.indexOf('ipv6') !== -1;;
|
||||
}
|
||||
if (hasOnion && hasClearnet) {
|
||||
clearnetTorNodes++;
|
||||
isUnnanounced = false;
|
||||
} else if (hasOnion) {
|
||||
torNodes++;
|
||||
isUnnanounced = false;
|
||||
} else if (hasClearnet) {
|
||||
clearnetNodes++;
|
||||
isUnnanounced = false;
|
||||
}
|
||||
if (isUnnanounced) {
|
||||
unannouncedNodes++;
|
||||
}
|
||||
}
|
||||
|
||||
// Channels and node historical stats
|
||||
const nodeStats = {};
|
||||
let capacity = 0;
|
||||
let avgFeeRate = 0;
|
||||
let avgBaseFee = 0;
|
||||
const capacities: number[] = [];
|
||||
const feeRates: number[] = [];
|
||||
const baseFees: number[] = [];
|
||||
const alreadyCountedChannels = {};
|
||||
|
||||
const [channelsInDbRaw]: any[] = await DB.query(`SELECT short_id FROM channels`);
|
||||
const channelsInDb = {};
|
||||
for (const channel of channelsInDbRaw) {
|
||||
channelsInDb[channel.short_id] = channel;
|
||||
}
|
||||
|
||||
for (const channel of networkGraph.edges) {
|
||||
const short_id = Common.channelIntegerIdToShortId(channel.channel_id);
|
||||
|
||||
const tx = await fundingTxFetcher.$fetchChannelOpenTx(short_id);
|
||||
if (!tx) {
|
||||
logger.err(`Unable to fetch funding tx for channel ${short_id}. Capacity and creation date is unknown. Skipping channel.`, logger.tags.ln);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we don't know about this channel, insert it in db
|
||||
if (isHistorical === true && !channelsInDb[short_id]) {
|
||||
await channelsApi.$saveChannel({
|
||||
channel_id: short_id,
|
||||
chan_point: `${tx.txid}:${short_id.split('x')[2]}`,
|
||||
last_update: channel.last_update,
|
||||
node1_pub: channel.node1_pub,
|
||||
node2_pub: channel.node2_pub,
|
||||
capacity: (tx.value * 100000000).toString(),
|
||||
node1_policy: null,
|
||||
node2_policy: null,
|
||||
}, 0);
|
||||
channelsInDb[channel.channel_id] = channel;
|
||||
}
|
||||
|
||||
if (!nodeStats[channel.node1_pub]) {
|
||||
nodeStats[channel.node1_pub] = {
|
||||
capacity: 0,
|
||||
channels: 0,
|
||||
};
|
||||
}
|
||||
if (!nodeStats[channel.node2_pub]) {
|
||||
nodeStats[channel.node2_pub] = {
|
||||
capacity: 0,
|
||||
channels: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (!alreadyCountedChannels[short_id]) {
|
||||
capacity += Math.round(tx.value * 100000000);
|
||||
capacities.push(Math.round(tx.value * 100000000));
|
||||
alreadyCountedChannels[short_id] = true;
|
||||
|
||||
nodeStats[channel.node1_pub].capacity += Math.round(tx.value * 100000000);
|
||||
nodeStats[channel.node1_pub].channels++;
|
||||
nodeStats[channel.node2_pub].capacity += Math.round(tx.value * 100000000);
|
||||
nodeStats[channel.node2_pub].channels++;
|
||||
}
|
||||
|
||||
if (isHistorical === false) { // Coming from the node
|
||||
for (const policy of [channel.node1_policy, channel.node2_policy]) {
|
||||
if (policy && parseInt(policy.fee_rate_milli_msat, 10) < 5000) {
|
||||
avgFeeRate += parseInt(policy.fee_rate_milli_msat, 10);
|
||||
feeRates.push(parseInt(policy.fee_rate_milli_msat, 10));
|
||||
}
|
||||
if (policy && parseInt(policy.fee_base_msat, 10) < 5000) {
|
||||
avgBaseFee += parseInt(policy.fee_base_msat, 10);
|
||||
baseFees.push(parseInt(policy.fee_base_msat, 10));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// @ts-ignore
|
||||
if (channel.node1_policy.fee_rate_milli_msat < 5000) {
|
||||
// @ts-ignore
|
||||
avgFeeRate += parseInt(channel.node1_policy.fee_rate_milli_msat, 10);
|
||||
// @ts-ignore
|
||||
feeRates.push(parseInt(channel.node1_policy.fee_rate_milli_msat), 10);
|
||||
}
|
||||
// @ts-ignore
|
||||
if (channel.node1_policy.fee_base_msat < 5000) {
|
||||
// @ts-ignore
|
||||
avgBaseFee += parseInt(channel.node1_policy.fee_base_msat, 10);
|
||||
// @ts-ignore
|
||||
baseFees.push(parseInt(channel.node1_policy.fee_base_msat), 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let medCapacity = 0;
|
||||
let medFeeRate = 0;
|
||||
let medBaseFee = 0;
|
||||
let avgCapacity = 0;
|
||||
|
||||
avgFeeRate /= Math.max(networkGraph.edges.length, 1);
|
||||
avgBaseFee /= Math.max(networkGraph.edges.length, 1);
|
||||
|
||||
if (capacities.length > 0) {
|
||||
medCapacity = capacities.sort((a, b) => b - a)[Math.round(capacities.length / 2 - 1)];
|
||||
avgCapacity = Math.round(capacity / Math.max(capacities.length, 1));
|
||||
}
|
||||
if (feeRates.length > 0) {
|
||||
medFeeRate = feeRates.sort((a, b) => b - a)[Math.round(feeRates.length / 2 - 1)];
|
||||
}
|
||||
if (baseFees.length > 0) {
|
||||
medBaseFee = baseFees.sort((a, b) => b - a)[Math.round(baseFees.length / 2 - 1)];
|
||||
}
|
||||
|
||||
let query = `INSERT INTO lightning_stats(
|
||||
added,
|
||||
channel_count,
|
||||
node_count,
|
||||
total_capacity,
|
||||
tor_nodes,
|
||||
clearnet_nodes,
|
||||
unannounced_nodes,
|
||||
clearnet_tor_nodes,
|
||||
avg_capacity,
|
||||
avg_fee_rate,
|
||||
avg_base_fee_mtokens,
|
||||
med_capacity,
|
||||
med_fee_rate,
|
||||
med_base_fee_mtokens
|
||||
)
|
||||
VALUES (FROM_UNIXTIME(?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
added = FROM_UNIXTIME(?),
|
||||
channel_count = ?,
|
||||
node_count = ?,
|
||||
total_capacity = ?,
|
||||
tor_nodes = ?,
|
||||
clearnet_nodes = ?,
|
||||
unannounced_nodes = ?,
|
||||
clearnet_tor_nodes = ?,
|
||||
avg_capacity = ?,
|
||||
avg_fee_rate = ?,
|
||||
avg_base_fee_mtokens = ?,
|
||||
med_capacity = ?,
|
||||
med_fee_rate = ?,
|
||||
med_base_fee_mtokens = ?
|
||||
`;
|
||||
|
||||
await DB.query(query, [
|
||||
timestamp,
|
||||
capacities.length,
|
||||
networkGraph.nodes.length,
|
||||
capacity,
|
||||
torNodes,
|
||||
clearnetNodes,
|
||||
unannouncedNodes,
|
||||
clearnetTorNodes,
|
||||
avgCapacity,
|
||||
avgFeeRate,
|
||||
avgBaseFee,
|
||||
medCapacity,
|
||||
medFeeRate,
|
||||
medBaseFee,
|
||||
timestamp,
|
||||
capacities.length,
|
||||
networkGraph.nodes.length,
|
||||
capacity,
|
||||
torNodes,
|
||||
clearnetNodes,
|
||||
unannouncedNodes,
|
||||
clearnetTorNodes,
|
||||
avgCapacity,
|
||||
avgFeeRate,
|
||||
avgBaseFee,
|
||||
medCapacity,
|
||||
medFeeRate,
|
||||
medBaseFee,
|
||||
]);
|
||||
|
||||
for (const public_key of Object.keys(nodeStats)) {
|
||||
query = `INSERT INTO node_stats(
|
||||
public_key,
|
||||
added,
|
||||
capacity,
|
||||
channels
|
||||
)
|
||||
VALUES (?, FROM_UNIXTIME(?), ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
added = FROM_UNIXTIME(?),
|
||||
capacity = ?,
|
||||
channels = ?
|
||||
`;
|
||||
|
||||
await DB.query(query, [
|
||||
public_key,
|
||||
timestamp,
|
||||
nodeStats[public_key].capacity,
|
||||
nodeStats[public_key].channels,
|
||||
timestamp,
|
||||
nodeStats[public_key].capacity,
|
||||
nodeStats[public_key].channels,
|
||||
]);
|
||||
|
||||
if (!isHistorical) {
|
||||
await DB.query(
|
||||
`UPDATE nodes SET capacity = ?, channels = ? WHERE public_key = ?`,
|
||||
[
|
||||
nodeStats[public_key].capacity,
|
||||
nodeStats[public_key].channels,
|
||||
public_key,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
added: timestamp,
|
||||
node_count: networkGraph.nodes.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Import topology files LN historical data into the database
|
||||
*/
|
||||
async $importHistoricalLightningStats(): Promise<void> {
|
||||
if (!config.LIGHTNING.TOPOLOGY_FOLDER) {
|
||||
logger.info(`Lightning topology folder is not set. Not importing historical LN stats`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('Run the historical importer');
|
||||
try {
|
||||
let fileList: string[] = [];
|
||||
try {
|
||||
fileList = await fsPromises.readdir(this.topologiesFolder);
|
||||
} catch (e) {
|
||||
logger.err(`Unable to open topology folder at ${this.topologiesFolder}`, logger.tags.ln);
|
||||
throw e;
|
||||
}
|
||||
// Insert history from the most recent to the oldest
|
||||
// This also put the .json cached files first
|
||||
fileList.sort().reverse();
|
||||
|
||||
const [rows]: any[] = await DB.query(`
|
||||
SELECT UNIX_TIMESTAMP(added) AS added
|
||||
FROM lightning_stats
|
||||
ORDER BY added DESC
|
||||
`);
|
||||
const existingStatsTimestamps = {};
|
||||
for (const row of rows) {
|
||||
existingStatsTimestamps[row.added] = row;
|
||||
}
|
||||
|
||||
// For logging purpose
|
||||
let processed = 10;
|
||||
let totalProcessed = 0;
|
||||
let logStarted = false;
|
||||
|
||||
for (const filename of fileList) {
|
||||
processed++;
|
||||
|
||||
const timestamp = parseInt(filename.split('_')[1], 10);
|
||||
|
||||
// Stats exist already, don't calculate/insert them
|
||||
if (existingStatsTimestamps[timestamp] !== undefined) {
|
||||
totalProcessed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (filename.indexOf('topology_') === -1) {
|
||||
totalProcessed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.debug(`Reading ${this.topologiesFolder}/${filename}`, logger.tags.ln);
|
||||
let fileContent = '';
|
||||
try {
|
||||
fileContent = await fsPromises.readFile(`${this.topologiesFolder}/${filename}`, 'utf8');
|
||||
} catch (e: any) {
|
||||
if (e.errno == -1) { // EISDIR - Ignore directorie
|
||||
totalProcessed++;
|
||||
continue;
|
||||
}
|
||||
logger.err(`Unable to open ${this.topologiesFolder}/${filename}`, logger.tags.ln);
|
||||
totalProcessed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let graph;
|
||||
try {
|
||||
graph = JSON.parse(fileContent);
|
||||
graph = await this.cleanupTopology(graph);
|
||||
} catch (e) {
|
||||
logger.debug(`Invalid topology file ${this.topologiesFolder}/${filename}, cannot parse the content. Reason: ${e instanceof Error ? e.message : e}`, logger.tags.ln);
|
||||
totalProcessed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.isIncorrectSnapshot(timestamp, graph)) {
|
||||
logger.debug(`Ignoring ${this.topologiesFolder}/${filename}, because we defined it as an incorrect snapshot`);
|
||||
++totalProcessed;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!logStarted) {
|
||||
logger.info(`Founds a topology file that we did not import. Importing historical lightning stats now.`, logger.tags.ln);
|
||||
logStarted = true;
|
||||
}
|
||||
|
||||
const datestr = `${new Date(timestamp * 1000).toUTCString()} (${timestamp})`;
|
||||
logger.debug(`${datestr}: Found ${graph.nodes.length} nodes and ${graph.edges.length} channels`, logger.tags.ln);
|
||||
|
||||
totalProcessed++;
|
||||
|
||||
if (processed > 10) {
|
||||
logger.info(`Generating LN network stats for ${datestr}. Processed ${totalProcessed}/${fileList.length} files`, logger.tags.ln);
|
||||
processed = 0;
|
||||
} else {
|
||||
logger.debug(`Generating LN network stats for ${datestr}. Processed ${totalProcessed}/${fileList.length} files`, logger.tags.ln);
|
||||
}
|
||||
await fundingTxFetcher.$fetchChannelsFundingTxs(graph.edges.map(channel => channel.channel_id.slice(0, -2)));
|
||||
const stat = await this.computeNetworkStats(timestamp, graph, true);
|
||||
|
||||
existingStatsTimestamps[timestamp] = stat;
|
||||
}
|
||||
|
||||
if (totalProcessed > 0) {
|
||||
logger.info(`Lightning network stats historical import completed`, logger.tags.ln);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err(`Lightning network stats historical failed. Reason: ${e instanceof Error ? e.message : e}`, logger.tags.ln);
|
||||
}
|
||||
}
|
||||
|
||||
cleanupTopology(graph): ILightningApi.NetworkGraph {
|
||||
const newGraph = {
|
||||
nodes: <ILightningApi.Node[]>[],
|
||||
edges: <ILightningApi.Channel[]>[],
|
||||
};
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
const addressesParts = (node.addresses ?? '').split(',');
|
||||
const addresses: any[] = [];
|
||||
for (const address of addressesParts) {
|
||||
const formatted = Common.findSocketNetwork(address);
|
||||
addresses.push({
|
||||
network: formatted.network,
|
||||
addr: formatted.url
|
||||
});
|
||||
}
|
||||
|
||||
let rgb = node.rgb_color ?? '#000000';
|
||||
if (rgb.indexOf('#') === -1) {
|
||||
rgb = `#${rgb}`;
|
||||
}
|
||||
newGraph.nodes.push({
|
||||
last_update: node.timestamp ?? 0,
|
||||
pub_key: node.id ?? null,
|
||||
alias: node.alias ?? node.id.slice(0, 20),
|
||||
addresses: addresses,
|
||||
color: rgb,
|
||||
features: {},
|
||||
});
|
||||
}
|
||||
|
||||
for (const adjacency of graph.adjacency) {
|
||||
if (adjacency.length === 0) {
|
||||
continue;
|
||||
} else {
|
||||
for (const edge of adjacency) {
|
||||
newGraph.edges.push({
|
||||
channel_id: edge.scid,
|
||||
chan_point: '',
|
||||
last_update: edge.timestamp,
|
||||
node1_pub: edge.source ?? null,
|
||||
node2_pub: edge.destination ?? null,
|
||||
capacity: '0', // Will be fetch later
|
||||
node1_policy: {
|
||||
time_lock_delta: edge.cltv_expiry_delta,
|
||||
min_htlc: edge.htlc_minimim_msat,
|
||||
fee_base_msat: edge.fee_base_msat,
|
||||
fee_rate_milli_msat: edge.fee_proportional_millionths,
|
||||
max_htlc_msat: edge.htlc_maximum_msat,
|
||||
last_update: edge.timestamp,
|
||||
disabled: false,
|
||||
},
|
||||
node2_policy: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newGraph;
|
||||
}
|
||||
|
||||
private isIncorrectSnapshot(timestamp, graph): boolean {
|
||||
if (timestamp >= 1549065600 /* 2019-02-02 */ && timestamp <= 1550620800 /* 2019-02-20 */ && graph.nodes.length < 2600) {
|
||||
return true;
|
||||
}
|
||||
if (timestamp >= 1552953600 /* 2019-03-19 */ && timestamp <= 1556323200 /* 2019-05-27 */ && graph.nodes.length < 4000) {
|
||||
return true;
|
||||
}
|
||||
if (timestamp >= 1557446400 /* 2019-05-10 */ && timestamp <= 1560470400 /* 2019-06-14 */ && graph.nodes.length < 4000) {
|
||||
return true;
|
||||
}
|
||||
if (timestamp >= 1561680000 /* 2019-06-28 */ && timestamp <= 1563148800 /* 2019-07-15 */ && graph.nodes.length < 4000) {
|
||||
return true;
|
||||
}
|
||||
if (timestamp >= 1571270400 /* 2019-11-17 */ && timestamp <= 1580601600 /* 2020-02-02 */ && graph.nodes.length < 4500) {
|
||||
return true;
|
||||
}
|
||||
if (timestamp >= 1591142400 /* 2020-06-03 */ && timestamp <= 1592006400 /* 2020-06-13 */ && graph.nodes.length < 5500) {
|
||||
return true;
|
||||
}
|
||||
if (timestamp >= 1632787200 /* 2021-09-28 */ && timestamp <= 1633564800 /* 2021-10-07 */ && graph.nodes.length < 13000) {
|
||||
return true;
|
||||
}
|
||||
if (timestamp >= 1634256000 /* 2021-10-15 */ && timestamp <= 1645401600 /* 2022-02-21 */ && graph.nodes.length < 17000) {
|
||||
return true;
|
||||
}
|
||||
if (timestamp >= 1654992000 /* 2022-06-12 */ && timestamp <= 1661472000 /* 2022-08-26 */ && graph.nodes.length < 14000) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async $cleanupIncorrectSnapshot(): Promise<void> {
|
||||
// We do not run this one automatically because those stats are not supposed to be inserted in the first
|
||||
// place, but I write them here to remind us we manually run those queries
|
||||
|
||||
// DELETE FROM lightning_stats
|
||||
// WHERE (
|
||||
// UNIX_TIMESTAMP(added) >= 1549065600 AND UNIX_TIMESTAMP(added) <= 1550620800 AND node_count < 2600 OR
|
||||
// UNIX_TIMESTAMP(added) >= 1552953600 AND UNIX_TIMESTAMP(added) <= 1556323200 AND node_count < 4000 OR
|
||||
// UNIX_TIMESTAMP(added) >= 1557446400 AND UNIX_TIMESTAMP(added) <= 1560470400 AND node_count < 4000 OR
|
||||
// UNIX_TIMESTAMP(added) >= 1561680000 AND UNIX_TIMESTAMP(added) <= 1563148800 AND node_count < 4000 OR
|
||||
// UNIX_TIMESTAMP(added) >= 1571270400 AND UNIX_TIMESTAMP(added) <= 1580601600 AND node_count < 4500 OR
|
||||
// UNIX_TIMESTAMP(added) >= 1591142400 AND UNIX_TIMESTAMP(added) <= 1592006400 AND node_count < 5500 OR
|
||||
// UNIX_TIMESTAMP(added) >= 1632787200 AND UNIX_TIMESTAMP(added) <= 1633564800 AND node_count < 13000 OR
|
||||
// UNIX_TIMESTAMP(added) >= 1634256000 AND UNIX_TIMESTAMP(added) <= 1645401600 AND node_count < 17000 OR
|
||||
// UNIX_TIMESTAMP(added) >= 1654992000 AND UNIX_TIMESTAMP(added) <= 1661472000 AND node_count < 14000
|
||||
// )
|
||||
|
||||
// DELETE FROM node_stats
|
||||
// WHERE (
|
||||
// UNIX_TIMESTAMP(added) >= 1549065600 AND UNIX_TIMESTAMP(added) <= 1550620800 OR
|
||||
// UNIX_TIMESTAMP(added) >= 1552953600 AND UNIX_TIMESTAMP(added) <= 1556323200 OR
|
||||
// UNIX_TIMESTAMP(added) >= 1557446400 AND UNIX_TIMESTAMP(added) <= 1560470400 OR
|
||||
// UNIX_TIMESTAMP(added) >= 1561680000 AND UNIX_TIMESTAMP(added) <= 1563148800 OR
|
||||
// UNIX_TIMESTAMP(added) >= 1571270400 AND UNIX_TIMESTAMP(added) <= 1580601600 OR
|
||||
// UNIX_TIMESTAMP(added) >= 1591142400 AND UNIX_TIMESTAMP(added) <= 1592006400 OR
|
||||
// UNIX_TIMESTAMP(added) >= 1632787200 AND UNIX_TIMESTAMP(added) <= 1633564800 OR
|
||||
// UNIX_TIMESTAMP(added) >= 1634256000 AND UNIX_TIMESTAMP(added) <= 1645401600 OR
|
||||
// UNIX_TIMESTAMP(added) >= 1654992000 AND UNIX_TIMESTAMP(added) <= 1661472000
|
||||
// )
|
||||
}
|
||||
}
|
||||
|
||||
export default new LightningStatsImporter;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user