Compare commits
1242 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb525b0bd3 | ||
|
|
53c8cdee83 | ||
|
|
b9067ed912 | ||
|
|
11cc14f5b0 | ||
|
|
4133bf31c6 | ||
|
|
bfeee747c2 | ||
|
|
31fb6f70ab | ||
|
|
cb38258cf7 | ||
|
|
2a16dc5a7f | ||
|
|
20476e1366 | ||
|
|
57b0ccee60 | ||
|
|
80ec15193c | ||
|
|
d61eba8c68 | ||
|
|
4787b6353a | ||
|
|
debcd1808e | ||
|
|
85f471ad08 | ||
|
|
c7fa785346 | ||
|
|
a710934830 | ||
|
|
69e006f640 | ||
|
|
78c32af062 | ||
|
|
9a47191e10 | ||
|
|
ace5da94a4 | ||
|
|
e7f2f75b05 | ||
|
|
5b39ad2130 | ||
|
|
ee1985bb3d | ||
|
|
9caa57e81d | ||
|
|
8797ef261f | ||
|
|
fb9a548dfc | ||
|
|
ad4bfefee7 | ||
|
|
cd9157488f | ||
|
|
b501f7228c | ||
|
|
11483852da | ||
|
|
a6fadc840d | ||
|
|
af8c8a2088 | ||
|
|
573cb8f993 | ||
|
|
d70c610741 | ||
|
|
985d19778f | ||
|
|
2cb50c2351 | ||
|
|
359e111ae4 | ||
|
|
548f38292f | ||
|
|
5f2350b763 | ||
|
|
831cd580e0 | ||
|
|
47a6969dc9 | ||
|
|
29581f325f | ||
|
|
fbce72b7fc | ||
|
|
a894fa5bc0 | ||
|
|
9ac3c420eb | ||
|
|
10df6985fc | ||
|
|
27ce863735 | ||
|
|
d840d79aea | ||
|
|
80dfc81900 | ||
|
|
31c911cb59 | ||
|
|
0d4160b232 | ||
|
|
f0022f6af9 | ||
|
|
a16decfb94 | ||
|
|
ea2a2310a0 | ||
|
|
7f17ade65c | ||
|
|
c8d38740cc | ||
|
|
efffd1a929 | ||
|
|
f0c53a4e5b | ||
|
|
a9c1dc3726 | ||
|
|
2944f0b805 | ||
|
|
f494bd6d6a | ||
|
|
ae2cb05dc5 | ||
|
|
4e322fe006 | ||
|
|
5d06d02d64 | ||
|
|
7eabbe30e6 | ||
|
|
c232f6a11d | ||
|
|
04ffa6d7bb | ||
|
|
d46655e5f4 | ||
|
|
1438300763 | ||
|
|
cce49bdb7e | ||
|
|
fc878b696d | ||
|
|
c09fdb656f | ||
|
|
9ac9eb9cc8 | ||
|
|
ff5367b0e7 | ||
|
|
503adc20dc | ||
|
|
2f5cad9d0a | ||
|
|
871329e0fd | ||
|
|
7825b8d732 | ||
|
|
6bfd9da08c | ||
|
|
ce8518ad58 | ||
|
|
865fe488bf | ||
|
|
467cac7d4d | ||
|
|
3a0fb2015a | ||
|
|
bfb5abaa71 | ||
|
|
6cb2625303 | ||
|
|
2d292e27b9 | ||
|
|
9b6d679739 | ||
|
|
8099349dcc | ||
|
|
b1df17d7a3 | ||
|
|
02798db449 | ||
|
|
4b71cb6e28 | ||
|
|
cee52e69f1 | ||
|
|
a4a8fb64b1 | ||
|
|
0e6cc67c0a | ||
|
|
cc621b10ce | ||
|
|
2eaea44182 | ||
|
|
50734bafbf | ||
|
|
745b7d6f65 | ||
|
|
4ca730697c | ||
|
|
dc06a3f62a | ||
|
|
1e78326ee4 | ||
|
|
45542d5f06 | ||
|
|
0106f44129 | ||
|
|
ba895559bf | ||
|
|
513886f6d2 | ||
|
|
09fe7346bc | ||
|
|
4173486f4d | ||
|
|
d809e85dde | ||
|
|
6414f0045e | ||
|
|
39c5393e3b | ||
|
|
d2cd396c75 | ||
|
|
ccbb28c8a0 | ||
|
|
afbced3f4d | ||
|
|
08f2287def | ||
|
|
5175027948 | ||
|
|
d0cda447c0 | ||
|
|
fd288cd106 | ||
|
|
2d0d7df704 | ||
|
|
c41ac34978 | ||
|
|
47307bc755 | ||
|
|
bfe5d3ae49 | ||
|
|
a060816e2c | ||
|
|
898ff5da23 | ||
|
|
d78d2c0eca | ||
|
|
a08e77ff3e | ||
|
|
1e39eb0fa5 | ||
|
|
5de133ae6a | ||
|
|
d27b125848 | ||
|
|
ad36d53bb5 | ||
|
|
24f76f2f37 | ||
|
|
691bdda523 | ||
|
|
81bb31090e | ||
|
|
cc0a0719b6 | ||
|
|
7dca8ae1a0 | ||
|
|
84027d5568 | ||
|
|
4116186c1a | ||
|
|
358301020f | ||
|
|
642022bfd8 | ||
|
|
70f25b6c9c | ||
|
|
c778e84247 | ||
|
|
4de1d017ad | ||
|
|
61851be23a | ||
|
|
5de949eaed | ||
|
|
de6434a5ba | ||
|
|
c8639ec71d | ||
|
|
e1275c62cc | ||
|
|
be45e88056 | ||
|
|
990ab3da5f | ||
|
|
d1d74ebf37 | ||
|
|
6ab79b3c35 | ||
|
|
4f21fc0d87 | ||
|
|
10c4e47091 | ||
|
|
dd49ff0084 | ||
|
|
853314ba58 | ||
|
|
784e2470df | ||
|
|
350b4922da | ||
|
|
40fb1792f4 | ||
|
|
7ce1cc5103 | ||
|
|
71a4e24900 | ||
|
|
a48c2c07b0 | ||
|
|
d89d7efbe6 | ||
|
|
5ea4b043d9 | ||
|
|
dd4710b602 | ||
|
|
832c0cb3cc | ||
|
|
04216e952a | ||
|
|
951d0f0039 | ||
|
|
706f4bbc55 | ||
|
|
3fd96e412b | ||
|
|
766803ded1 | ||
|
|
504f46cad9 | ||
|
|
fd34761a93 | ||
|
|
96e8f45e5b | ||
|
|
195fae670b | ||
|
|
dd767f9468 | ||
|
|
bc8104eeb4 | ||
|
|
2c61eb6227 | ||
|
|
5d360d4156 | ||
|
|
91e30fbc3c | ||
|
|
5b22e2a000 | ||
|
|
533653e54a | ||
|
|
3dc0dc13ad | ||
|
|
e332789afc | ||
|
|
e4a9fd06b4 | ||
|
|
5845f2380e | ||
|
|
c29311d831 | ||
|
|
252db109bc | ||
|
|
b1c9334119 | ||
|
|
ab04247726 | ||
|
|
e94a85b989 | ||
|
|
a4569788f8 | ||
|
|
b455814e90 | ||
|
|
7afd0f3fe7 | ||
|
|
a2a85469cf | ||
|
|
94488a6029 | ||
|
|
8e4829146a | ||
|
|
08f185525c | ||
|
|
d6b00fe39e | ||
|
|
cec3baeaa4 | ||
|
|
6e59733cac | ||
|
|
c5b705ede7 | ||
|
|
2819e24efe | ||
|
|
5f9bc4497a | ||
|
|
086b14e816 | ||
|
|
958bfe6d25 | ||
|
|
e01ab449cf | ||
|
|
eeb0f403a3 | ||
|
|
9a18019d9d | ||
|
|
bcbc60b456 | ||
|
|
0d14c30892 | ||
|
|
5d8c970351 | ||
|
|
89fede9e48 | ||
|
|
f8a54784d0 | ||
|
|
010381aac4 | ||
|
|
49c30c7237 | ||
|
|
7ba0055c61 | ||
|
|
72b631a4dc | ||
|
|
1a8fd23b05 | ||
|
|
b9a2143a5a | ||
|
|
3ae46e6ba1 | ||
|
|
815fb62e7d | ||
|
|
7a19da560e | ||
|
|
43a1af4509 | ||
|
|
40f1949654 | ||
|
|
cf1471acca | ||
|
|
a2a69e522c | ||
|
|
4939941c88 | ||
|
|
a643f50016 | ||
|
|
836444a1c5 | ||
|
|
24d4b643e5 | ||
|
|
7f045ae5b9 | ||
|
|
96a2c8ab4e | ||
|
|
47103d85d3 | ||
|
|
0af5d733c4 | ||
|
|
bc7bbf5fe5 | ||
|
|
4bc141cd67 | ||
|
|
1c6b3a46c6 | ||
|
|
b41e32915f | ||
|
|
adb5bfe93f | ||
|
|
f4b7bbc91c | ||
|
|
687b4af272 | ||
|
|
e67f552fbd | ||
|
|
28d3f190ff | ||
|
|
2281116504 | ||
|
|
92d745168c | ||
|
|
cf0af20947 | ||
|
|
aee319ed51 | ||
|
|
dc5ced416d | ||
|
|
c9f5002dc2 | ||
|
|
6e4985602e | ||
|
|
11577842a2 | ||
|
|
1cc8a9469a | ||
|
|
7e7dbdbaf2 | ||
|
|
fb152b6d7b | ||
|
|
9e8a741d97 | ||
|
|
2b0d543ce7 | ||
|
|
5e729373bb | ||
|
|
41f3f0ab46 | ||
|
|
37722fe165 | ||
|
|
cbd187d06f | ||
|
|
833dd3ef9d | ||
|
|
bd19496350 | ||
|
|
ec420d8f3e | ||
|
|
9510c7d2ea | ||
|
|
92d2eb9727 | ||
|
|
72f7284311 | ||
|
|
2b3463519a | ||
|
|
00352d7e36 | ||
|
|
63e799b225 | ||
|
|
3c1e264464 | ||
|
|
ff148f15c4 | ||
|
|
a5c7f152aa | ||
|
|
f0be19409f | ||
|
|
936a306dc4 | ||
|
|
8ff5748368 | ||
|
|
271726bf58 | ||
|
|
65e11c1aa1 | ||
|
|
0ccc992e4b | ||
|
|
52397cb341 | ||
|
|
f7a9b691a8 | ||
|
|
8f8c9c1fbc | ||
|
|
9e8ee4fedc | ||
|
|
30ffc0b56e | ||
|
|
053fb4d1b2 | ||
|
|
53cfda533d | ||
|
|
2d557195de | ||
|
|
ed9b46bae0 | ||
|
|
23c9bf489a | ||
|
|
7c89dde5d4 | ||
|
|
87ca5b85d7 | ||
|
|
a2a43f4647 | ||
|
|
9ac77f7113 | ||
|
|
b6215dd54f | ||
|
|
69d5cfe98e | ||
|
|
73d9cd7ab7 | ||
|
|
84a6d29914 | ||
|
|
4633164f99 | ||
|
|
b22b63c9a2 | ||
|
|
40cd2a1ac3 | ||
|
|
e28f5cc403 | ||
|
|
1594fed853 | ||
|
|
41c61ef506 | ||
|
|
53b0bc0e48 | ||
|
|
e00234dfb9 | ||
|
|
8412e28073 | ||
|
|
10a110e682 | ||
|
|
1e40ec4fd0 | ||
|
|
9998a64fa5 | ||
|
|
d1e3c55a28 | ||
|
|
047220bd32 | ||
|
|
1ce05a3ac9 | ||
|
|
0271584b69 | ||
|
|
01da46daca | ||
|
|
73f558db6e | ||
|
|
feb8e35ec3 | ||
|
|
79e44479e9 | ||
|
|
9a39d3207f | ||
|
|
7e55e836fd | ||
|
|
571bf3fa64 | ||
|
|
0b1cf052a8 | ||
|
|
b301840bb8 | ||
|
|
a0e5a79ec4 | ||
|
|
8da89230c9 | ||
|
|
2408e81537 | ||
|
|
9377d80dbb | ||
|
|
8c200bc0e4 | ||
|
|
35ca8c846b | ||
|
|
4b1acfc77e | ||
|
|
8569523b89 | ||
|
|
9bef7da210 | ||
|
|
6f6b2a4355 | ||
|
|
783530b3de | ||
|
|
36777a8c5b | ||
|
|
638d6d896a | ||
|
|
2afb9d359f | ||
|
|
60a8d0ce6e | ||
|
|
e3aeb784ad | ||
|
|
8124274880 | ||
|
|
836f0f065d | ||
|
|
062fdb3658 | ||
|
|
7cfbd2c70d | ||
|
|
5b9ae2eaf5 | ||
|
|
d534c42c47 | ||
|
|
15a0644bd1 | ||
|
|
6ad4e655ea | ||
|
|
598920d6a9 | ||
|
|
d816e67ce4 | ||
|
|
662aafff57 | ||
|
|
cfec9345c8 | ||
|
|
9cb203f98a | ||
|
|
3bab50a43b | ||
|
|
0639ce9b07 | ||
|
|
34c9ca4197 | ||
|
|
14d015a904 | ||
|
|
38412753fe | ||
|
|
475f9344a0 | ||
|
|
d9cf6a2604 | ||
|
|
9eb159617f | ||
|
|
2dc930fad2 | ||
|
|
c478478f86 | ||
|
|
7a07f67be5 | ||
|
|
a3de75ebf4 | ||
|
|
46a2854f67 | ||
|
|
033f066abf | ||
|
|
97244c7b35 | ||
|
|
3d92369ed6 | ||
|
|
2bee46951c | ||
|
|
61ffcd0065 | ||
|
|
88e79c220f | ||
|
|
a2c21e9036 | ||
|
|
8a0316a562 | ||
|
|
055c1f2aa5 | ||
|
|
88e48df8a9 | ||
|
|
b81d296635 | ||
|
|
9f68f57d8a | ||
|
|
26a540a57c | ||
|
|
ad8398e3d4 | ||
|
|
0fabf8dbc3 | ||
|
|
cd63c6c0c1 | ||
|
|
c95f75254b | ||
|
|
9e4ad51b79 | ||
|
|
6e35102b3a | ||
|
|
d1e72c0cc0 | ||
|
|
1925023eb2 | ||
|
|
377eb0cae5 | ||
|
|
e33e4b1d71 | ||
|
|
b8f3c1f124 | ||
|
|
5333ec0b99 | ||
|
|
f761c5d966 | ||
|
|
3b6d07cace | ||
|
|
cbeeef3b2c | ||
|
|
5dab44e6c4 | ||
|
|
5139ffb4df | ||
|
|
06706be18f | ||
|
|
9264f3cf4f | ||
|
|
d6a9017267 | ||
|
|
bad75cfd4e | ||
|
|
68240e4f5c | ||
|
|
9d9ff6ed91 | ||
|
|
f19b84090a | ||
|
|
85c4574cbd | ||
|
|
e2c4e42c81 | ||
|
|
dd6c26b079 | ||
|
|
837992f7ea | ||
|
|
40914236d4 | ||
|
|
f52c36093e | ||
|
|
a9f4418e1a | ||
|
|
1a37c8b116 | ||
|
|
39d231bb3c | ||
|
|
4046c3176f | ||
|
|
c257fbfdcb | ||
|
|
1659be0d9f | ||
|
|
6f9762d50b | ||
|
|
9bf475bc97 | ||
|
|
e59a318cad | ||
|
|
57b64f64ad | ||
|
|
af3af5f099 | ||
|
|
fec603d5c5 | ||
|
|
ed2ebb1c70 | ||
|
|
14d2f8dd97 | ||
|
|
bf563cc195 | ||
|
|
f66e0a2c12 | ||
|
|
a43cd48795 | ||
|
|
44339daedf | ||
|
|
14b7b6427a | ||
|
|
a2e866d15a | ||
|
|
c2f288a861 | ||
|
|
e1c943d0a7 | ||
|
|
fa2d2e60b5 | ||
|
|
c919980652 | ||
|
|
b48389ae7d | ||
|
|
2bac7f9987 | ||
|
|
acf6fd9db5 | ||
|
|
74a9b65e81 | ||
|
|
822c840e54 | ||
|
|
6e93ef68fe | ||
|
|
3006deae6e | ||
|
|
740f5c2003 | ||
|
|
5c9d44e9eb | ||
|
|
88527b41e7 | ||
|
|
83cce0c3a7 | ||
|
|
e144d0c8e5 | ||
|
|
d72dbc1415 | ||
|
|
b857a7c37f | ||
|
|
c72c287b27 | ||
|
|
18e0a17d26 | ||
|
|
87eeef5d41 | ||
|
|
76a2fdeea7 | ||
|
|
792eb3727c | ||
|
|
2e0845847d | ||
|
|
8f774e55a8 | ||
|
|
28418640bb | ||
|
|
8aae5c1c9c | ||
|
|
92048964d1 | ||
|
|
b2140c2abe | ||
|
|
d0a8509194 | ||
|
|
aa0c3e6fed | ||
|
|
f0462114f3 | ||
|
|
9e0f9840aa | ||
|
|
d763c30f6a | ||
|
|
92b69657da | ||
|
|
d9ec0c1a36 | ||
|
|
4bf9f8b062 | ||
|
|
eefd8104bb | ||
|
|
16e807c4b0 | ||
|
|
461296e002 | ||
|
|
86c877c8e9 | ||
|
|
80fcceef73 | ||
|
|
b0e54818ae | ||
|
|
acbd7f0bde | ||
|
|
9a6efceb34 | ||
|
|
5ce7b55441 | ||
|
|
a3edaf17cc | ||
|
|
5695019216 | ||
|
|
7ab1ce8fc4 | ||
|
|
1f8ec2bd8e | ||
|
|
78b488466e | ||
|
|
66630743f6 | ||
|
|
ffa18bbe71 | ||
|
|
8cb1c5c88c | ||
|
|
bb07031362 | ||
|
|
31a0d44543 | ||
|
|
f90e19c767 | ||
|
|
800625d80e | ||
|
|
552540f510 | ||
|
|
bbee2dcb5b | ||
|
|
e4e338b05a | ||
|
|
061a55b236 | ||
|
|
9f0f9230fb | ||
|
|
40956c0a23 | ||
|
|
f29e35b325 | ||
|
|
d88efb8b0d | ||
|
|
b9489525c6 | ||
|
|
8ddcd298b0 | ||
|
|
69df6e4dcb | ||
|
|
f3c8e2134b | ||
|
|
0e25c52e67 | ||
|
|
60f41d3181 | ||
|
|
50c5244abf | ||
|
|
605c1a980c | ||
|
|
0d67bc36ee | ||
|
|
aa39bbd091 | ||
|
|
641d2ad028 | ||
|
|
d602b20f56 | ||
|
|
138f6e4e39 | ||
|
|
3e788ecbf9 | ||
|
|
2236c6d9a6 | ||
|
|
2a0a1b0213 | ||
|
|
e6e49fd5d6 | ||
|
|
f6d5f44469 | ||
|
|
51e09ff64f | ||
|
|
07ba2f6ecc | ||
|
|
bc42552bec | ||
|
|
c6b44a3be9 | ||
|
|
e1f4de0de3 | ||
|
|
d22dc0888a | ||
|
|
75fb27c690 | ||
|
|
401506a103 | ||
|
|
ab27ea28f0 | ||
|
|
6e579ce0b6 | ||
|
|
e7030cca32 | ||
|
|
2c496e9a50 | ||
|
|
014d6dee66 | ||
|
|
47ae306a75 | ||
|
|
8b8b06e6ab | ||
|
|
fa7a45421e | ||
|
|
d376ba1c61 | ||
|
|
388aa7fbe3 | ||
|
|
34695146ee | ||
|
|
9020c618f0 | ||
|
|
584d091d4e | ||
|
|
f434e50a2c | ||
|
|
1a7decb91d | ||
|
|
3574f8639e | ||
|
|
9b956ff88d | ||
|
|
1a98a14541 | ||
|
|
0088e58c14 | ||
|
|
3fad765269 | ||
|
|
2e122a9be1 | ||
|
|
2978d16148 | ||
|
|
fc58bcb3bc | ||
|
|
1696623e2f | ||
|
|
251a1af442 | ||
|
|
9bdf42530a | ||
|
|
8525fbb177 | ||
|
|
63a3568481 | ||
|
|
e4941740de | ||
|
|
25bd33f7da | ||
|
|
2d007b9100 | ||
|
|
b71330c606 | ||
|
|
844b640c8c | ||
|
|
1277e58e68 | ||
|
|
fde6fe324a | ||
|
|
4ed114a4d5 | ||
|
|
8c2dfea6a6 | ||
|
|
a0624df06b | ||
|
|
1eedcf900b | ||
|
|
0b077d6fda | ||
|
|
80047313e7 | ||
|
|
71229b94c8 | ||
|
|
c256daf8c8 | ||
|
|
ba0fb996d2 | ||
|
|
5977e96034 | ||
|
|
a151c5cddd | ||
|
|
0323fd966d | ||
|
|
beb834bc30 | ||
|
|
ad6503c7b3 | ||
|
|
f8c11c8b6b | ||
|
|
ba5421e77b | ||
|
|
20fa803cee | ||
|
|
393fa78a43 | ||
|
|
3f290dae06 | ||
|
|
24d18b9f2f | ||
|
|
79ef8ca371 | ||
|
|
ec12f21113 | ||
|
|
2e8ecc7277 | ||
|
|
fc28b06a0f | ||
|
|
8fdbfdc04c | ||
|
|
bdfcfc96a8 | ||
|
|
bb8649bc81 | ||
|
|
777e3d58b7 | ||
|
|
c552f1aab6 | ||
|
|
c0f2fa3042 | ||
|
|
05936f82bd | ||
|
|
c7db81c97c | ||
|
|
bd1a37b8ef | ||
|
|
efc4e6a8ed | ||
|
|
dd5d87e91e | ||
|
|
ca6df488c5 | ||
|
|
1e018a6aa5 | ||
|
|
0a627f96be | ||
|
|
17a8e67d8a | ||
|
|
815c2c5ad5 | ||
|
|
4376de85ff | ||
|
|
7e89de4612 | ||
|
|
4b72a14706 | ||
|
|
b34f6fedb6 | ||
|
|
58af0d78af | ||
|
|
ca13d9109c | ||
|
|
e103fb5876 | ||
|
|
58178f4563 | ||
|
|
04f1879fd1 | ||
|
|
f5bc9ced0a | ||
|
|
7fe9993f91 | ||
|
|
7c95339324 | ||
|
|
006442f9de | ||
|
|
e20100e437 | ||
|
|
d7cf2b37d5 | ||
|
|
278c2b9aae | ||
|
|
944246fcf5 | ||
|
|
03d87f4993 | ||
|
|
cf8cab5f77 | ||
|
|
49d1376647 | ||
|
|
de5518d262 | ||
|
|
7e4c51f47f | ||
|
|
fe1d153632 | ||
|
|
a98f9ab80e | ||
|
|
867afaf265 | ||
|
|
3d2ec64b14 | ||
|
|
bb407c0b42 | ||
|
|
83c3d901c7 | ||
|
|
901cee903c | ||
|
|
250ea09c7e | ||
|
|
648d59631b | ||
|
|
ed06e3c491 | ||
|
|
3e8d646edd | ||
|
|
9c2c698575 | ||
|
|
e2b0a286a4 | ||
|
|
154809f0f9 | ||
|
|
8d9a51a7c4 | ||
|
|
b3294369d4 | ||
|
|
53730920e3 | ||
|
|
d73b814277 | ||
|
|
dd0050c066 | ||
|
|
ae51ee3e26 | ||
|
|
4b16e5d65f | ||
|
|
4f73bba132 | ||
|
|
3c229602e4 | ||
|
|
c74c902ebc | ||
|
|
8bfd315ba3 | ||
|
|
9d75c47792 | ||
|
|
e183be1a5c | ||
|
|
7e273ce63d | ||
|
|
6d070e75b0 | ||
|
|
af5e0d7cd6 | ||
|
|
a2f1003916 | ||
|
|
f4f96fd18e | ||
|
|
f3b470b63e | ||
|
|
398a72c1a6 | ||
|
|
ddd6420d9b | ||
|
|
d76f42296a | ||
|
|
45af88774f | ||
|
|
71c6b0e11d | ||
|
|
47a6118ffb | ||
|
|
994eb378af | ||
|
|
34d46e8ca5 | ||
|
|
a940f7e3b4 | ||
|
|
8c29395533 | ||
|
|
8208bbf0b7 | ||
|
|
dbd205b73f | ||
|
|
7ef4be26ed | ||
|
|
1223c58a98 | ||
|
|
7d3757676f | ||
|
|
073bd60ed8 | ||
|
|
18c38fc1c1 | ||
|
|
0eb95447bb | ||
|
|
c6b1979391 | ||
|
|
0f390e65a4 | ||
|
|
5dc0f4e270 | ||
|
|
223288cc52 | ||
|
|
72a35200b3 | ||
|
|
11817c04f7 | ||
|
|
7a8b2db3fb | ||
|
|
6d910a5e24 | ||
|
|
e1f07884b9 | ||
|
|
e00e61edfa | ||
|
|
4f988e186a | ||
|
|
1aa54faa35 | ||
|
|
99adccf43c | ||
|
|
0bb9247609 | ||
|
|
d841933b21 | ||
|
|
bc8b78a01b | ||
|
|
b0c708659b | ||
|
|
e31b906084 | ||
|
|
7249620471 | ||
|
|
dc9d5d0be3 | ||
|
|
a9009d4de2 | ||
|
|
a265787cd4 | ||
|
|
c6e72be483 | ||
|
|
4680519d2e | ||
|
|
5b17f88de2 | ||
|
|
a6d34ba4f1 | ||
|
|
508c8b0be3 | ||
|
|
ef7dd6c8fb | ||
|
|
f03249761b | ||
|
|
cb5877ba0a | ||
|
|
96f14d2781 | ||
|
|
8eb70416da | ||
|
|
b9246a72f2 | ||
|
|
43e222b9df | ||
|
|
5548d08a9e | ||
|
|
10fa39634e | ||
|
|
e6b90385b2 | ||
|
|
61181c6791 | ||
|
|
d2cccd2422 | ||
|
|
b05ebe1598 | ||
|
|
d92827a411 | ||
|
|
d061f7589c | ||
|
|
1c01094e07 | ||
|
|
f28a85f91b | ||
|
|
15903faf49 | ||
|
|
4895343d4e | ||
|
|
a0559cbb24 | ||
|
|
0293ba4a52 | ||
|
|
8b0d1db776 | ||
|
|
1908b1a5a6 | ||
|
|
037f472f8c | ||
|
|
a32c1f40b1 | ||
|
|
837e714b1f | ||
|
|
91a37d8fe8 | ||
|
|
a00aa27ae4 | ||
|
|
226e72451c | ||
|
|
544be77bdc | ||
|
|
b8a110a772 | ||
|
|
7788a2d6bd | ||
|
|
da17fd16fa | ||
|
|
e670f80fed | ||
|
|
857a5ff6fc | ||
|
|
2de28b9926 | ||
|
|
e6f8cf6cc8 | ||
|
|
d7586af392 | ||
|
|
35881b2457 | ||
|
|
59cd80b6d1 | ||
|
|
735c2ba587 | ||
|
|
be1ef43cd1 | ||
|
|
34ad88d3d0 | ||
|
|
751c7d6e69 | ||
|
|
60d8697b09 | ||
|
|
41aa1248be | ||
|
|
cedd94c654 | ||
|
|
bf13994d28 | ||
|
|
8a44ccc55d | ||
|
|
81df40681f | ||
|
|
9e46cde9b7 | ||
|
|
723034b3d3 | ||
|
|
59898f1269 | ||
|
|
195b9bf542 | ||
|
|
0333d91b15 | ||
|
|
f0bd487ea9 | ||
|
|
cd8e308870 | ||
|
|
f6a889298c | ||
|
|
11f5e99187 | ||
|
|
334f9358b0 | ||
|
|
820561610a | ||
|
|
2c895e7b03 | ||
|
|
f36f48b11c | ||
|
|
f12f1b4a4e | ||
|
|
037d6a75ea | ||
|
|
775323de3e | ||
|
|
d91dfa2f41 | ||
|
|
3ac06bb983 | ||
|
|
1ba0075829 | ||
|
|
95436d398d | ||
|
|
f2f5749769 | ||
|
|
cb90b09a0e | ||
|
|
2e54f4ca94 | ||
|
|
853e2fcb8f | ||
|
|
9e0a5300b0 | ||
|
|
1b5930887c | ||
|
|
5b39c018db | ||
|
|
ad08c3a907 | ||
|
|
08328cbf0f | ||
|
|
03ce592ab0 | ||
|
|
21db5a4102 | ||
|
|
7234734056 | ||
|
|
7bf9d604b9 | ||
|
|
08fd4a4835 | ||
|
|
9a715871c5 | ||
|
|
d405334109 | ||
|
|
38aee1a897 | ||
|
|
52aea12f22 | ||
|
|
ecbd18087b | ||
|
|
d13e18a72a | ||
|
|
8749b8b0fa | ||
|
|
f2e0a71b01 | ||
|
|
b4eea3dc72 | ||
|
|
cdfc03f352 | ||
|
|
2c5ccab77c | ||
|
|
80d76ad1f4 | ||
|
|
9a2428ad79 | ||
|
|
71cf41362f | ||
|
|
652f88770e | ||
|
|
7de2cf89f4 | ||
|
|
d7a827ba7f | ||
|
|
9dae7020c8 | ||
|
|
3ae3df6722 | ||
|
|
2e2e6aa01f | ||
|
|
1e9f131a2a | ||
|
|
5197a15e31 | ||
|
|
1d29fad986 | ||
|
|
eb6db6caf3 | ||
|
|
78c44eedbc | ||
|
|
b48a48a6be | ||
|
|
8e1aae1bbf | ||
|
|
807d4b0327 | ||
|
|
df588695ec | ||
|
|
da13349b14 | ||
|
|
f6e4907128 | ||
|
|
6be733490f | ||
|
|
fdf15c39a6 | ||
|
|
3b020046b7 | ||
|
|
8574ee6edd | ||
|
|
f937ea5745 | ||
|
|
741a020579 | ||
|
|
33d37a9b5b | ||
|
|
446bdfebea | ||
|
|
ca91afe45b | ||
|
|
33a5be5a7d | ||
|
|
6a4eee3711 | ||
|
|
13931ceec6 | ||
|
|
0c418a9e33 | ||
|
|
6f8b95a17f | ||
|
|
389c1d794c | ||
|
|
fca66f1b9f | ||
|
|
4c7d0cd2e5 | ||
|
|
1016586992 | ||
|
|
38c8f3acb4 | ||
|
|
962023fbc4 | ||
|
|
b4f8bb2f48 | ||
|
|
c26461fada | ||
|
|
1a996e1640 | ||
|
|
c80532b420 | ||
|
|
74c49b9ae7 | ||
|
|
3f03c9c2b6 | ||
|
|
f00e727e68 | ||
|
|
4338dd6c3f | ||
|
|
8385c50605 | ||
|
|
93c4b1caf1 | ||
|
|
49810b6a47 | ||
|
|
28d685a661 | ||
|
|
95d3d0feaf | ||
|
|
cbc5d67f62 | ||
|
|
87575bc0a2 | ||
|
|
8f74ef58f8 | ||
|
|
2475c67d5b | ||
|
|
bf45bf7b39 | ||
|
|
a1f0417997 | ||
|
|
237f265aab | ||
|
|
0087700aa5 | ||
|
|
861344ed6d | ||
|
|
9e343b346a | ||
|
|
e857dbc874 | ||
|
|
a10cd09ba8 | ||
|
|
f30777934f | ||
|
|
4f6bf297bf | ||
|
|
0121052f0b | ||
|
|
1bd0c40c15 | ||
|
|
2ee96cae44 | ||
|
|
28c8d7dba0 | ||
|
|
9b05ecedc6 | ||
|
|
8fbd273733 | ||
|
|
dec8ae2930 | ||
|
|
353b0e8729 | ||
|
|
71bfcea8a6 | ||
|
|
c54c30209e | ||
|
|
abc6b1519e | ||
|
|
4dcda2cf47 | ||
|
|
d055fabfeb | ||
|
|
dbb365f5e3 | ||
|
|
efb5deda43 | ||
|
|
a4cd6450e3 | ||
|
|
edad15da0d | ||
|
|
e70fd0045d | ||
|
|
794bc99cb6 | ||
|
|
cd1ec53af0 | ||
|
|
3e435d1394 | ||
|
|
50b94f8b72 | ||
|
|
f6f5b69487 | ||
|
|
66b27b9dd0 | ||
|
|
71fa2d67cb | ||
|
|
5cd2cfa097 | ||
|
|
cfd13b3655 | ||
|
|
3ffa60db1f | ||
|
|
4442964124 | ||
|
|
cb034020ef | ||
|
|
5aa57d6df9 | ||
|
|
c1a79e3a33 | ||
|
|
bbd21c9401 | ||
|
|
ad22f9cb46 | ||
|
|
939955fb84 | ||
|
|
63e67dba38 | ||
|
|
8a1230623e | ||
|
|
f20c73af7b | ||
|
|
12c99b86b7 | ||
|
|
934dd67384 | ||
|
|
870bd54b38 | ||
|
|
89300dae98 | ||
|
|
482a891cec | ||
|
|
098ab7d3a7 | ||
|
|
147d44d14b | ||
|
|
8ccdf3973c | ||
|
|
c09eb651ef | ||
|
|
ac91d814d6 | ||
|
|
be2f024da1 | ||
|
|
f137f45cef | ||
|
|
90784deacc | ||
|
|
8ed664e3a9 | ||
|
|
17b6916f31 | ||
|
|
b778d96910 | ||
|
|
5b2eb16d1c | ||
|
|
af61357ced | ||
|
|
f281e84396 | ||
|
|
0dc255edf9 | ||
|
|
2f8f3ca2e9 | ||
|
|
39bb93970b | ||
|
|
72d01a0b67 | ||
|
|
0b4da88802 | ||
|
|
d2fe000ad0 | ||
|
|
dcedc8a5ff | ||
|
|
0d03a9e6cc | ||
|
|
24b7acdc60 | ||
|
|
1000f4dd4d | ||
|
|
d5dba9128e | ||
|
|
84b0375c0c | ||
|
|
bf23a6649c | ||
|
|
aea35d4c86 | ||
|
|
52b7efdd53 | ||
|
|
492abad7a6 | ||
|
|
f566eae471 | ||
|
|
2f2be5c64b | ||
|
|
5d1af0a86e | ||
|
|
5cd5280b21 | ||
|
|
3a957ece05 | ||
|
|
3ead05fa51 | ||
|
|
8a838cd4dc | ||
|
|
b05f731332 | ||
|
|
06fd821bf8 | ||
|
|
6dbfcc9d1a | ||
|
|
001bddd529 | ||
|
|
56518b9655 | ||
|
|
da050ee3dc | ||
|
|
5878a2e631 | ||
|
|
c1fc08196b | ||
|
|
95a80157a7 | ||
|
|
165aa6eee2 | ||
|
|
b8fe7b621c | ||
|
|
04ec5e9564 | ||
|
|
2d4dff6de8 | ||
|
|
5cb98b9813 | ||
|
|
d4508bd876 | ||
|
|
6ccac1df79 | ||
|
|
b38fc824e6 | ||
|
|
cdbe90c182 | ||
|
|
6b5b80f866 | ||
|
|
d74677628b | ||
|
|
f0d46d6ed8 | ||
|
|
220d9afd97 | ||
|
|
dfd88a7ff9 | ||
|
|
eec36ae4e6 | ||
|
|
0a07a16650 | ||
|
|
e62ee72149 | ||
|
|
117f5410d7 | ||
|
|
f6ea45b61f | ||
|
|
90b06833ba | ||
|
|
221fccf3bc | ||
|
|
3740980007 | ||
|
|
d1b53f4c3a | ||
|
|
d51ea54ab9 | ||
|
|
3300f0e8d3 | ||
|
|
cd1273981d | ||
|
|
fd0ffd2a39 | ||
|
|
d60bc10941 | ||
|
|
5085e0c420 | ||
|
|
3dbddedf91 | ||
|
|
e255bec7ad | ||
|
|
cbe79d7051 | ||
|
|
344d1247bd | ||
|
|
089bb38e6a | ||
|
|
2077126064 | ||
|
|
fcf7955d63 | ||
|
|
7a4ad0ee2f | ||
|
|
4bb68d0163 | ||
|
|
f80a11d1f4 | ||
|
|
f61e3d8cec | ||
|
|
7fab42baa5 | ||
|
|
4ab4581393 | ||
|
|
4c8d261da0 | ||
|
|
88c9fd0c7b | ||
|
|
f1c5f83412 | ||
|
|
2d9b9b5c5d | ||
|
|
c7c4895eab | ||
|
|
979b870c9c | ||
|
|
041aa2a163 | ||
|
|
1bb990b796 | ||
|
|
a6e7e1966e | ||
|
|
db263b8db4 | ||
|
|
0289620262 | ||
|
|
d8ef0cd3ac | ||
|
|
68be897379 | ||
|
|
2016d4bca6 | ||
|
|
0c2d88960c | ||
|
|
e3e1cddd2f | ||
|
|
00b564149d | ||
|
|
dee614f6ac | ||
|
|
896a4cbcfc | ||
|
|
e731077077 | ||
|
|
4a907f9dc6 | ||
|
|
b0baf6aa0d | ||
|
|
8dddfe38a9 | ||
|
|
0f9f905fd1 | ||
|
|
bc8e3109fa | ||
|
|
56b0eab9b4 | ||
|
|
5914d99283 | ||
|
|
d942cb48a5 | ||
|
|
4d0429b786 | ||
|
|
2b1c511611 | ||
|
|
8a86b63693 | ||
|
|
2a2c1a6291 | ||
|
|
ce1860b7d1 | ||
|
|
37e01c5e91 | ||
|
|
0ff05d5551 | ||
|
|
9b428821f6 | ||
|
|
21bb879fc1 | ||
|
|
8a97880cdb | ||
|
|
ca267744a6 | ||
|
|
a4253301dc | ||
|
|
402c5e3444 | ||
|
|
f12eb333d2 | ||
|
|
a0b50d7735 | ||
|
|
3967ce0854 | ||
|
|
ed55e86a9d | ||
|
|
c93adba276 | ||
|
|
1ae002385d | ||
|
|
e05ca7d691 | ||
|
|
dc36bfcfe4 | ||
|
|
e688948e42 | ||
|
|
5148de8f17 | ||
|
|
6f1cdd0c8b | ||
|
|
d3c53c7406 | ||
|
|
b2d08d69cf | ||
|
|
b85c2a6e0f | ||
|
|
f1f847a9f8 | ||
|
|
8b5d3dabe7 | ||
|
|
ac4588cdab | ||
|
|
baa75b77a7 | ||
|
|
f2a08444fe | ||
|
|
c866fbf6df | ||
|
|
da77dbece1 | ||
|
|
6a07eb0d91 | ||
|
|
057a96001d | ||
|
|
f173b17b90 | ||
|
|
8e29a4cefd | ||
|
|
6b47895aec | ||
|
|
146fcfc16d | ||
|
|
308dd2c7ad | ||
|
|
4cbf2e0eb4 | ||
|
|
1d4ed85d50 | ||
|
|
a530d8b17f | ||
|
|
e32066affd | ||
|
|
d5cc558670 | ||
|
|
a52f98c39d | ||
|
|
7beb832007 | ||
|
|
3e1c4a7e59 | ||
|
|
3b8d3221cf | ||
|
|
b594a9d249 | ||
|
|
d20cec4e59 | ||
|
|
e5972aa181 | ||
|
|
b0912064cc | ||
|
|
84737bca6e | ||
|
|
14db7e4c8b | ||
|
|
d99fd5d59a | ||
|
|
9624801716 | ||
|
|
904cf62c78 | ||
|
|
92e2df4627 | ||
|
|
f157a50952 | ||
|
|
c3927c9f0a | ||
|
|
45505c86d9 | ||
|
|
4fbab41cc8 | ||
|
|
b2769d2af3 | ||
|
|
2fca34faaa | ||
|
|
8fa672e312 | ||
|
|
2c5cf94982 | ||
|
|
72ded16543 | ||
|
|
7d67c8ea6e | ||
|
|
1051919a8a | ||
|
|
8ca6f06650 | ||
|
|
906189c43a | ||
|
|
0ba6d651c0 | ||
|
|
2a7b4f9aed | ||
|
|
892cebd8f4 | ||
|
|
2e8bd7f32e | ||
|
|
275895bedd | ||
|
|
ec699f28fb | ||
|
|
38e866995f | ||
|
|
85ad0aaa27 | ||
|
|
eeb7447988 | ||
|
|
9e2581d734 | ||
|
|
be0fd7c582 | ||
|
|
26b4bd899b | ||
|
|
a3d9e87f0e | ||
|
|
4a24a173d3 | ||
|
|
05098b1141 | ||
|
|
6a7d5fbe6a | ||
|
|
75b8ec855b | ||
|
|
b89630953c | ||
|
|
04c42b82f4 | ||
|
|
6ec07e5315 | ||
|
|
ea5dc8738c | ||
|
|
619eee9492 | ||
|
|
c9f2bd4029 | ||
|
|
795fb9342a | ||
|
|
0b04dbbca2 | ||
|
|
55df5dbd35 | ||
|
|
0b79aaaf2f | ||
|
|
7d858a8abd | ||
|
|
32af9420dc | ||
|
|
7195d6ea31 | ||
|
|
a111dc044f | ||
|
|
563ae8540b | ||
|
|
279cba5d79 | ||
|
|
32c740e572 | ||
|
|
28bc08c5a8 | ||
|
|
a0e3468c51 | ||
|
|
d50e25bed7 | ||
|
|
7f8329aa4d | ||
|
|
6619f92502 | ||
|
|
edce0d118a | ||
|
|
26ba41cb91 | ||
|
|
475baf5634 | ||
|
|
6866b12e84 | ||
|
|
2743b35ea2 | ||
|
|
d39207e097 | ||
|
|
033d1451d0 | ||
|
|
befb192651 | ||
|
|
7489d3360a | ||
|
|
2641ae0c8e | ||
|
|
32290d1e0d | ||
|
|
584ef87fc8 | ||
|
|
63b52b9d9b | ||
|
|
5f6b7d94b1 | ||
|
|
95a1c002eb | ||
|
|
2085833720 | ||
|
|
20ff62779d | ||
|
|
181cb8e03f | ||
|
|
cef6bc8345 | ||
|
|
a67d82ea94 | ||
|
|
89efd410fb | ||
|
|
eb0c20dd92 | ||
|
|
c733497e52 | ||
|
|
378a56b2c7 | ||
|
|
8e42d573ee | ||
|
|
a7adf3a345 | ||
|
|
ccdf41c5b6 | ||
|
|
0c979e9440 | ||
|
|
de035dc1b5 | ||
|
|
18d08ce4bf | ||
|
|
35f5efaa2e | ||
|
|
96b3ca6a0b | ||
|
|
4e4f655be4 | ||
|
|
985df53af6 | ||
|
|
ace480e3c7 | ||
|
|
e670172979 | ||
|
|
7ddbf5d3df | ||
|
|
264bca9c56 | ||
|
|
b53d364877 | ||
|
|
ed17203a5f | ||
|
|
999163d7ad | ||
|
|
276117fba9 | ||
|
|
448cb8e264 | ||
|
|
6782229a3d | ||
|
|
543fe8c735 | ||
|
|
f3e97e2e2d | ||
|
|
03179e34fb | ||
|
|
a36b5c660d | ||
|
|
2d872bda47 | ||
|
|
91d4017aa6 | ||
|
|
6efe055003 | ||
|
|
f39d90547e | ||
|
|
c26737ffd6 | ||
|
|
09f5f552bf | ||
|
|
feb5e96323 | ||
|
|
f7ff6336f2 | ||
|
|
2a5a4ddac0 | ||
|
|
fd869c732d | ||
|
|
d3646aa45e | ||
|
|
f3c18b152a | ||
|
|
6a9c4d82ec | ||
|
|
35521f4871 | ||
|
|
9b9c605cbe | ||
|
|
21d32dec41 | ||
|
|
54a276439d | ||
|
|
42ad068dd2 | ||
|
|
c5c2222b8c | ||
|
|
9d317082e1 | ||
|
|
edf8f1fc8a | ||
|
|
ad52e7fe7e | ||
|
|
c94f004425 | ||
|
|
371433b2da | ||
|
|
3256198ab0 | ||
|
|
da533097d9 | ||
|
|
47dd1f2d0b | ||
|
|
5f1f06fecf | ||
|
|
b08225dab5 | ||
|
|
9d02ab1eb5 | ||
|
|
e86b7c7258 | ||
|
|
c9e175a0cc | ||
|
|
cca95bbd66 | ||
|
|
86902d6f33 | ||
|
|
e214eedf23 | ||
|
|
39613cc2a2 | ||
|
|
dad122625f | ||
|
|
f049e3abc4 | ||
|
|
bf28dc1eea | ||
|
|
4d47388e25 | ||
|
|
6507b5e003 | ||
|
|
47a449e1d9 | ||
|
|
5b268794af | ||
|
|
fb41f58f7c | ||
|
|
e82c89a985 | ||
|
|
494119d119 | ||
|
|
9900f4da80 | ||
|
|
a158794e2c | ||
|
|
1a04b088fb | ||
|
|
17b1325b3f | ||
|
|
0336c6256a | ||
|
|
642e54b057 | ||
|
|
23323be24a | ||
|
|
011f35ec94 | ||
|
|
9751af096d | ||
|
|
1e81355e7d | ||
|
|
eff4d2c8cd | ||
|
|
d49c347413 | ||
|
|
8aa3379ba5 | ||
|
|
ec845a6ac2 | ||
|
|
46b7e6961e | ||
|
|
fc709058c1 | ||
|
|
627e8e5e9a | ||
|
|
2b55ee1e07 | ||
|
|
695da602b8 | ||
|
|
3e09755c47 | ||
|
|
4694a31f55 |
4
.github/FUNDING.yml
vendored
4
.github/FUNDING.yml
vendored
@@ -1,6 +1,6 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
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
|
||||
@@ -9,4 +9,4 @@ community_bridge: # Replace with a single Community Bridge project-name e.g., cl
|
||||
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/about'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
custom: ['https://mempool.space/sponsor'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
---
|
||||
name: 🐛 Bug Report
|
||||
about: Report bugs or other issues to us on GitHub
|
||||
---
|
||||
|
||||
<!--
|
||||
SUPPORT REQUESTS: This is for reporting bugs in Mempool.
|
||||
If you have a support request, please join our Keybase group:
|
||||
SUPPORT REQUESTS:
|
||||
This is for reporting bugs in Mempool, not for support requests.
|
||||
If you have a support request, please join our Keybase or Matrix:
|
||||
https://keybase.io/team/mempool
|
||||
https://matrix.to/#/#mempool:bitcoin.kyoto
|
||||
-->
|
||||
|
||||
### Description
|
||||
@@ -14,11 +21,11 @@
|
||||
|
||||
### Steps to reproduce
|
||||
|
||||
<!--if you can reliably reproduce the bug, list the steps here -->
|
||||
<!-- if you can reliably reproduce the bug, list the steps here -->
|
||||
|
||||
### Expected behaviour
|
||||
|
||||
<!--description of the expected behavior -->
|
||||
<!-- description of the expected behavior -->
|
||||
|
||||
### Actual behaviour
|
||||
|
||||
@@ -26,7 +33,7 @@
|
||||
|
||||
### Screenshots
|
||||
|
||||
<!--Screenshots if gui related, drag and drop to add to the issue -->
|
||||
<!-- Screenshots if gui related, drag and drop to add to the issue -->
|
||||
|
||||
#### Device or machine
|
||||
|
||||
28
.github/ISSUE_TEMPLATE/30-feature-request.md
vendored
Normal file
28
.github/ISSUE_TEMPLATE/30-feature-request.md
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: ✨ Feature Request
|
||||
about: Request a feature or suggest other enhancements 💡
|
||||
---
|
||||
|
||||
<!--
|
||||
SUPPORT REQUESTS:
|
||||
This is for requesting features in Mempool, not for support requests.
|
||||
If you have a support request, please join our Keybase or Matrix:
|
||||
https://keybase.io/team/mempool
|
||||
https://matrix.to/#/#mempool:bitcoin.kyoto
|
||||
-->
|
||||
|
||||
### Description
|
||||
|
||||
<!-- brief description of the feature request -->
|
||||
|
||||
### Problem to be solved
|
||||
|
||||
<!-- description of the the problem you're having -->
|
||||
|
||||
### Proposed solution
|
||||
|
||||
<!-- explain how you think we should solve the problem -->
|
||||
|
||||
#### Additional info
|
||||
|
||||
<!-- Additional information useful for implementing (e.g. docs, links, etc.) -->
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 💬 Need help? Chat with us on Matrix
|
||||
url: https://matrix.to/#/#mempool:bitcoin.kyoto
|
||||
about: For support requests or general questions
|
||||
- name: 💬 Need help? Chat with us on Keybase
|
||||
url: https://keybase.io/team/mempool
|
||||
about: For support requests or general questions
|
||||
80
.github/workflows/cypress.yml
vendored
Normal file
80
.github/workflows/cypress.yml
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
name: Cypress Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
cypress:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
containers: [1, 2, 3, 4, 5]
|
||||
os: ["ubuntu-latest"]
|
||||
browser: [chrome]
|
||||
name: E2E tests on ${{ matrix.browser }} - ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 16.10.0
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: ${{ matrix.browser }} browser tests (Mempool)
|
||||
uses: cypress-io/github-action@v2
|
||||
with:
|
||||
working-directory: frontend
|
||||
build: npm run config:defaults:mempool
|
||||
start: npm run start:local-prod
|
||||
wait-on: 'http://localhost:4200'
|
||||
wait-on-timeout: 120
|
||||
record: true
|
||||
parallel: true
|
||||
group: Tests on ${{ matrix.browser }} (Mempool)
|
||||
browser: ${{ matrix.browser }}
|
||||
ci-build-id: '${{ github.sha }}-${{ github.workflow }}-${{ github.event_name }}'
|
||||
env:
|
||||
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
|
||||
|
||||
- name: ${{ matrix.browser }} browser tests (Liquid)
|
||||
uses: cypress-io/github-action@v2
|
||||
if: always()
|
||||
with:
|
||||
working-directory: frontend
|
||||
build: npm run config:defaults:liquid
|
||||
start: npm run start:local-prod
|
||||
wait-on: 'http://localhost:4200'
|
||||
wait-on-timeout: 120
|
||||
record: true
|
||||
parallel: true
|
||||
spec: cypress/integration/liquid/liquid.spec.ts
|
||||
group: Tests on ${{ matrix.browser }} (Liquid)
|
||||
browser: ${{ matrix.browser }}
|
||||
ci-build-id: '${{ github.sha }}-${{ github.workflow }}-${{ github.event_name }}'
|
||||
env:
|
||||
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@v2
|
||||
if: always()
|
||||
with:
|
||||
working-directory: frontend
|
||||
build: npm run config:defaults:bisq
|
||||
start: npm run start:local-prod
|
||||
wait-on: 'http://localhost:4200'
|
||||
wait-on-timeout: 120
|
||||
record: true
|
||||
parallel: true
|
||||
spec: cypress/integration/bisq/bisq.spec.ts
|
||||
group: Tests on ${{ matrix.browser }} (Bisq)
|
||||
browser: ${{ matrix.browser }}
|
||||
ci-build-id: '${{ github.sha }}-${{ github.workflow }}-${{ github.event_name }}'
|
||||
env:
|
||||
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
|
||||
72
.github/workflows/on-tag.yml
vendored
Normal file
72
.github/workflows/on-tag.yml
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
name: Docker build on tag
|
||||
env:
|
||||
DOCKER_CLI_EXPERIMENTAL: enabled
|
||||
TAG_FMT: '^refs/tags/(((.?[0-9]+){3,4}))$'
|
||||
DOCKER_BUILDKIT: 0
|
||||
COMPOSE_DOCKER_CLI_BUILD: 0
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v[0-9]+.[0-9]+.[0-9]+
|
||||
- v[0-9]+.[0-9]+.[0-9]+-*
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
service:
|
||||
- frontend
|
||||
- backend
|
||||
runs-on: ubuntu-18.04
|
||||
name: Build and push to DockerHub
|
||||
steps:
|
||||
- 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
|
||||
|
||||
- name: Login to Docker for building
|
||||
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Checkout project
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Init repo for Dockerization
|
||||
run: docker/init.sh "$TAG"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
id: qemu
|
||||
|
||||
- name: Setup Docker buildx action
|
||||
uses: docker/setup-buildx-action@v1
|
||||
id: buildx
|
||||
|
||||
- name: Available platforms
|
||||
run: echo ${{ steps.buildx.outputs.platforms }}
|
||||
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v2
|
||||
id: cache
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
|
||||
- name: Run Docker buildx for ${{ matrix.service }} against tag
|
||||
run: |
|
||||
docker buildx build \
|
||||
--cache-from "type=local,src=/tmp/.buildx-cache" \
|
||||
--cache-to "type=local,dest=/tmp/.buildx-cache" \
|
||||
--platform linux/amd64,linux/arm64,linux/arm/v7 \
|
||||
--tag ${{ secrets.DOCKER_HUB_USER }}/${{ matrix.service }}:$TAG \
|
||||
--tag ${{ secrets.DOCKER_HUB_USER }}/${{ matrix.service }}:latest \
|
||||
--output "type=registry" ./${{ matrix.service }}/ \
|
||||
--build-arg commitHash=$SHORT_SHA
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1 +1,4 @@
|
||||
sitemap
|
||||
data
|
||||
docker-compose.yml
|
||||
backend/mempool-config.json
|
||||
|
||||
4
.vscode/settings.json
vendored
Normal file
4
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"editor.tabSize": 2,
|
||||
"typescript.tsdk": "./backend/node_modules/typescript/lib"
|
||||
}
|
||||
47
GNUmakefile
Executable file
47
GNUmakefile
Executable file
@@ -0,0 +1,47 @@
|
||||
# If you see pwd_unknown showing up check permissions
|
||||
PWD ?= pwd_unknown
|
||||
|
||||
# DATABASE DEPLOY FOLDER CONFIG - default ./data
|
||||
ifeq ($(data),)
|
||||
DATA := data
|
||||
export DATA
|
||||
else
|
||||
DATA := $(data)
|
||||
export DATA
|
||||
endif
|
||||
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo ''
|
||||
@echo ''
|
||||
@echo ' Usage: make [COMMAND]'
|
||||
@echo ''
|
||||
@echo ' make all # build init mempool and electrs'
|
||||
@echo ' make init # setup some useful configs'
|
||||
@echo ' make mempool # build q dockerized mempool.space'
|
||||
@echo ' make electrs # build a docker electrs image'
|
||||
@echo ''
|
||||
|
||||
.PHONY: init
|
||||
init:
|
||||
@echo ''
|
||||
mkdir -p $(DATA) $(DATA)/mysql $(DATA)/mysql/data
|
||||
#REF: https://github.com/mempool/mempool/blob/master/docker/README.md
|
||||
cat docker/docker-compose.yml > docker-compose.yml
|
||||
cat backend/mempool-config.sample.json > backend/mempool-config.json
|
||||
.PHONY: mempool
|
||||
mempool: init
|
||||
@echo ''
|
||||
docker-compose up --force-recreate --always-recreate-deps
|
||||
@echo ''
|
||||
.PHONY: electrs
|
||||
electrum:
|
||||
#REF: https://hub.docker.com/r/beli/electrum
|
||||
@echo ''
|
||||
docker build -f docker/electrum/Dockerfile .
|
||||
@echo ''
|
||||
.PHONY: all
|
||||
all: init
|
||||
make mempool
|
||||
#######################
|
||||
-include Makefile
|
||||
52
LICENSE
52
LICENSE
@@ -1,35 +1,29 @@
|
||||
MIT License with Commons Clause License Condition v1.0
|
||||
The Mempool Open Source Project
|
||||
Copyright (c) 2019-2021 The Mempool Open Source Project Developers
|
||||
|
||||
Copyright (c) 2019-2020 The Mempool Open Source Project
|
||||
This program is free software; you can redistribute it and/or modify it under
|
||||
the terms of (at your option) either:
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, and/or sublicense
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
1) the GNU Affero General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License or any later version approved by a
|
||||
proxy statement published on <https://mempool.space/about>; or
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
2) the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License or any later version approved by a
|
||||
proxy statement published on <https://mempool.space/about>.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
However, this copyright license does not include an implied right or license to
|
||||
use our trademarks: The Mempool Open Source Project™, mempool.space™, the
|
||||
mempool Logo™, the mempool.space Vertical Logo™, the mempool.space Horizontal
|
||||
Logo™, the mempool Square Logo™, and the mempool Blocks logo™ are registered
|
||||
trademarks or trademarks of Mempool Space K.K in Japan, the United States,
|
||||
and/or other countries. See our full Trademark Policy and Guidelines for more
|
||||
details, published on <https://mempool.space/trademark-policy>.
|
||||
|
||||
Commons Clause License Condition v1.0
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. See the full license terms for more details.
|
||||
|
||||
Without limiting other conditions in the License, the grant of rights under
|
||||
the License will not include, and the License does not grant to you, the
|
||||
right to Sell the Software.
|
||||
|
||||
For purposes of the foregoing, “Sell” means practicing any or all of the
|
||||
rights granted to you under the License to provide to third parties, for a
|
||||
fee or other consideration (including without limitation fees for hosting or
|
||||
consulting/ support services related to the Software), a product or service
|
||||
whose value derives, entirely or substantially, from the functionality of
|
||||
the Software. Any license notice or attribution required by the License must
|
||||
also include this Commons Cause License Condition notice.
|
||||
You should have received a copy of both the GNU Affero General Public License
|
||||
and the GNU General Public License along with this program. If not, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
|
||||
660
LICENSE.AGPL-3.md
Normal file
660
LICENSE.AGPL-3.md
Normal file
@@ -0,0 +1,660 @@
|
||||
### GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc.
|
||||
<https://fsf.org/>
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this
|
||||
license document, but changing it is not allowed.
|
||||
|
||||
### Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains
|
||||
free software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing
|
||||
under this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
### TERMS AND CONDITIONS
|
||||
|
||||
#### 0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public
|
||||
License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds
|
||||
of works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of
|
||||
an exact copy. The resulting work is called a "modified version" of
|
||||
the earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user
|
||||
through a computer network, with no transfer of a copy, is not
|
||||
conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices" to
|
||||
the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
#### 1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work for
|
||||
making modifications to it. "Object code" means any non-source form of
|
||||
a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users can
|
||||
regenerate automatically from other parts of the Corresponding Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that same
|
||||
work.
|
||||
|
||||
#### 2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not convey,
|
||||
without conditions so long as your license otherwise remains in force.
|
||||
You may convey covered works to others for the sole purpose of having
|
||||
them make modifications exclusively for you, or provide you with
|
||||
facilities for running those works, provided that you comply with the
|
||||
terms of this License in conveying all material for which you do not
|
||||
control copyright. Those thus making or running the covered works for
|
||||
you must do so exclusively on your behalf, under your direction and
|
||||
control, on terms that prohibit them from making any copies of your
|
||||
copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under the
|
||||
conditions stated below. Sublicensing is not allowed; section 10 makes
|
||||
it unnecessary.
|
||||
|
||||
#### 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such
|
||||
circumvention is effected by exercising rights under this License with
|
||||
respect to the covered work, and you disclaim any intention to limit
|
||||
operation or modification of the work as a means of enforcing, against
|
||||
the work's users, your or third parties' legal rights to forbid
|
||||
circumvention of technological measures.
|
||||
|
||||
#### 4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
#### 5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these
|
||||
conditions:
|
||||
|
||||
- a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
- b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under
|
||||
section 7. This requirement modifies the requirement in section 4
|
||||
to "keep intact all notices".
|
||||
- c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
- d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
#### 6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms of
|
||||
sections 4 and 5, provided that you also convey the machine-readable
|
||||
Corresponding Source under the terms of this License, in one of these
|
||||
ways:
|
||||
|
||||
- a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
- b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the Corresponding
|
||||
Source from a network server at no charge.
|
||||
- c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
- d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
- e) Convey the object code using peer-to-peer transmission,
|
||||
provided you inform other peers where the object code and
|
||||
Corresponding Source of the work are being offered to the general
|
||||
public at no charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal,
|
||||
family, or household purposes, or (2) anything designed or sold for
|
||||
incorporation into a dwelling. In determining whether a product is a
|
||||
consumer product, doubtful cases shall be resolved in favor of
|
||||
coverage. For a particular product received by a particular user,
|
||||
"normally used" refers to a typical or common use of that class of
|
||||
product, regardless of the status of the particular user or of the way
|
||||
in which the particular user actually uses, or expects or is expected
|
||||
to use, the product. A product is a consumer product regardless of
|
||||
whether the product has substantial commercial, industrial or
|
||||
non-consumer uses, unless such uses represent the only significant
|
||||
mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to
|
||||
install and execute modified versions of a covered work in that User
|
||||
Product from a modified version of its Corresponding Source. The
|
||||
information must suffice to ensure that the continued functioning of
|
||||
the modified object code is in no case prevented or interfered with
|
||||
solely because modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or
|
||||
updates for a work that has been modified or installed by the
|
||||
recipient, or for the User Product in which it has been modified or
|
||||
installed. Access to a network may be denied when the modification
|
||||
itself materially and adversely affects the operation of the network
|
||||
or violates the rules and protocols for communication across the
|
||||
network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
#### 7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders
|
||||
of that material) supplement the terms of this License with terms:
|
||||
|
||||
- a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
- b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
- c) Prohibiting misrepresentation of the origin of that material,
|
||||
or requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
- d) Limiting the use for publicity purposes of names of licensors
|
||||
or authors of the material; or
|
||||
- e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
- f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions
|
||||
of it) with contractual assumptions of liability to the recipient,
|
||||
for any liability that these contractual assumptions directly
|
||||
impose on those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions; the
|
||||
above requirements apply either way.
|
||||
|
||||
#### 8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your license
|
||||
from a particular copyright holder is reinstated (a) provisionally,
|
||||
unless and until the copyright holder explicitly and finally
|
||||
terminates your license, and (b) permanently, if the copyright holder
|
||||
fails to notify you of the violation by some reasonable means prior to
|
||||
60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
#### 9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or run
|
||||
a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
#### 10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
#### 11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims owned
|
||||
or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within the
|
||||
scope of its coverage, prohibits the exercise of, or is conditioned on
|
||||
the non-exercise of one or more of the rights that are specifically
|
||||
granted under this License. You may not convey a covered work if you
|
||||
are a party to an arrangement with a third party that is in the
|
||||
business of distributing software, under which you make payment to the
|
||||
third party based on the extent of your activity of conveying the
|
||||
work, and under which the third party grants, to any of the parties
|
||||
who would receive the covered work from you, a discriminatory patent
|
||||
license (a) in connection with copies of the covered work conveyed by
|
||||
you (or copies made from those copies), or (b) primarily for and in
|
||||
connection with specific products or compilations that contain the
|
||||
covered work, unless you entered into that arrangement, or that patent
|
||||
license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
#### 12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under
|
||||
this License and any other pertinent obligations, then as a
|
||||
consequence you may not convey it at all. For example, if you agree to
|
||||
terms that obligate you to collect a royalty for further conveying
|
||||
from those to whom you convey the Program, the only way you could
|
||||
satisfy both those terms and this License would be to refrain entirely
|
||||
from conveying the Program.
|
||||
|
||||
#### 13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your
|
||||
version supports such interaction) an opportunity to receive the
|
||||
Corresponding Source of your version by providing access to the
|
||||
Corresponding Source from a network server at no charge, through some
|
||||
standard or customary means of facilitating copying of software. This
|
||||
Corresponding Source shall include the Corresponding Source for any
|
||||
work covered by version 3 of the GNU General Public License that is
|
||||
incorporated pursuant to the following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
#### 14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU Affero General Public License from time to time. Such new
|
||||
versions will be similar in spirit to the present version, but may
|
||||
differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever
|
||||
published by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future versions
|
||||
of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
#### 15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT
|
||||
WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND
|
||||
PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE
|
||||
DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR
|
||||
CORRECTION.
|
||||
|
||||
#### 16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR
|
||||
CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES
|
||||
ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT
|
||||
NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR
|
||||
LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM
|
||||
TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER
|
||||
PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
#### 17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
### How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these
|
||||
terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest to
|
||||
attach them to the start of each source file to most effectively state
|
||||
the exclusion of warranty; and each file should have at least the
|
||||
"copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper
|
||||
mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for
|
||||
the specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. For more information on this, and how to apply and follow
|
||||
the GNU AGPL, see <https://www.gnu.org/licenses/>.
|
||||
675
LICENSE.GPL-3.md
Normal file
675
LICENSE.GPL-3.md
Normal file
@@ -0,0 +1,675 @@
|
||||
### GNU GENERAL PUBLIC LICENSE
|
||||
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc.
|
||||
<https://fsf.org/>
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this
|
||||
license document, but changing it is not allowed.
|
||||
|
||||
### Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom
|
||||
to share and change all versions of a program--to make sure it remains
|
||||
free software for all its users. We, the Free Software Foundation, use
|
||||
the GNU General Public License for most of our software; it applies
|
||||
also to any other work released this way by its authors. You can apply
|
||||
it to your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you
|
||||
have certain responsibilities if you distribute copies of the
|
||||
software, or if you modify it: responsibilities to respect the freedom
|
||||
of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the
|
||||
manufacturer can do so. This is fundamentally incompatible with the
|
||||
aim of protecting users' freedom to change the software. The
|
||||
systematic pattern of such abuse occurs in the area of products for
|
||||
individuals to use, which is precisely where it is most unacceptable.
|
||||
Therefore, we have designed this version of the GPL to prohibit the
|
||||
practice for those products. If such problems arise substantially in
|
||||
other domains, we stand ready to extend this provision to those
|
||||
domains in future versions of the GPL, as needed to protect the
|
||||
freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish
|
||||
to avoid the special danger that patents applied to a free program
|
||||
could make it effectively proprietary. To prevent this, the GPL
|
||||
assures that patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
### TERMS AND CONDITIONS
|
||||
|
||||
#### 0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds
|
||||
of works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of
|
||||
an exact copy. The resulting work is called a "modified version" of
|
||||
the earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user
|
||||
through a computer network, with no transfer of a copy, is not
|
||||
conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices" to
|
||||
the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
#### 1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work for
|
||||
making modifications to it. "Object code" means any non-source form of
|
||||
a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users can
|
||||
regenerate automatically from other parts of the Corresponding Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that same
|
||||
work.
|
||||
|
||||
#### 2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not convey,
|
||||
without conditions so long as your license otherwise remains in force.
|
||||
You may convey covered works to others for the sole purpose of having
|
||||
them make modifications exclusively for you, or provide you with
|
||||
facilities for running those works, provided that you comply with the
|
||||
terms of this License in conveying all material for which you do not
|
||||
control copyright. Those thus making or running the covered works for
|
||||
you must do so exclusively on your behalf, under your direction and
|
||||
control, on terms that prohibit them from making any copies of your
|
||||
copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under the
|
||||
conditions stated below. Sublicensing is not allowed; section 10 makes
|
||||
it unnecessary.
|
||||
|
||||
#### 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such
|
||||
circumvention is effected by exercising rights under this License with
|
||||
respect to the covered work, and you disclaim any intention to limit
|
||||
operation or modification of the work as a means of enforcing, against
|
||||
the work's users, your or third parties' legal rights to forbid
|
||||
circumvention of technological measures.
|
||||
|
||||
#### 4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
#### 5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these
|
||||
conditions:
|
||||
|
||||
- a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
- b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under
|
||||
section 7. This requirement modifies the requirement in section 4
|
||||
to "keep intact all notices".
|
||||
- c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
- d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
#### 6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms of
|
||||
sections 4 and 5, provided that you also convey the machine-readable
|
||||
Corresponding Source under the terms of this License, in one of these
|
||||
ways:
|
||||
|
||||
- a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
- b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the Corresponding
|
||||
Source from a network server at no charge.
|
||||
- c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
- d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
- e) Convey the object code using peer-to-peer transmission,
|
||||
provided you inform other peers where the object code and
|
||||
Corresponding Source of the work are being offered to the general
|
||||
public at no charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal,
|
||||
family, or household purposes, or (2) anything designed or sold for
|
||||
incorporation into a dwelling. In determining whether a product is a
|
||||
consumer product, doubtful cases shall be resolved in favor of
|
||||
coverage. For a particular product received by a particular user,
|
||||
"normally used" refers to a typical or common use of that class of
|
||||
product, regardless of the status of the particular user or of the way
|
||||
in which the particular user actually uses, or expects or is expected
|
||||
to use, the product. A product is a consumer product regardless of
|
||||
whether the product has substantial commercial, industrial or
|
||||
non-consumer uses, unless such uses represent the only significant
|
||||
mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to
|
||||
install and execute modified versions of a covered work in that User
|
||||
Product from a modified version of its Corresponding Source. The
|
||||
information must suffice to ensure that the continued functioning of
|
||||
the modified object code is in no case prevented or interfered with
|
||||
solely because modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or
|
||||
updates for a work that has been modified or installed by the
|
||||
recipient, or for the User Product in which it has been modified or
|
||||
installed. Access to a network may be denied when the modification
|
||||
itself materially and adversely affects the operation of the network
|
||||
or violates the rules and protocols for communication across the
|
||||
network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
#### 7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders
|
||||
of that material) supplement the terms of this License with terms:
|
||||
|
||||
- a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
- b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
- c) Prohibiting misrepresentation of the origin of that material,
|
||||
or requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
- d) Limiting the use for publicity purposes of names of licensors
|
||||
or authors of the material; or
|
||||
- e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
- f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions
|
||||
of it) with contractual assumptions of liability to the recipient,
|
||||
for any liability that these contractual assumptions directly
|
||||
impose on those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions; the
|
||||
above requirements apply either way.
|
||||
|
||||
#### 8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your license
|
||||
from a particular copyright holder is reinstated (a) provisionally,
|
||||
unless and until the copyright holder explicitly and finally
|
||||
terminates your license, and (b) permanently, if the copyright holder
|
||||
fails to notify you of the violation by some reasonable means prior to
|
||||
60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
#### 9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or run
|
||||
a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
#### 10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
#### 11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims owned
|
||||
or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within the
|
||||
scope of its coverage, prohibits the exercise of, or is conditioned on
|
||||
the non-exercise of one or more of the rights that are specifically
|
||||
granted under this License. You may not convey a covered work if you
|
||||
are a party to an arrangement with a third party that is in the
|
||||
business of distributing software, under which you make payment to the
|
||||
third party based on the extent of your activity of conveying the
|
||||
work, and under which the third party grants, to any of the parties
|
||||
who would receive the covered work from you, a discriminatory patent
|
||||
license (a) in connection with copies of the covered work conveyed by
|
||||
you (or copies made from those copies), or (b) primarily for and in
|
||||
connection with specific products or compilations that contain the
|
||||
covered work, unless you entered into that arrangement, or that patent
|
||||
license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
#### 12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under
|
||||
this License and any other pertinent obligations, then as a
|
||||
consequence you may not convey it at all. For example, if you agree to
|
||||
terms that obligate you to collect a royalty for further conveying
|
||||
from those to whom you convey the Program, the only way you could
|
||||
satisfy both those terms and this License would be to refrain entirely
|
||||
from conveying the Program.
|
||||
|
||||
#### 13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
#### 14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in
|
||||
detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies that a certain numbered version of the GNU General Public
|
||||
License "or any later version" applies to it, you have the option of
|
||||
following the terms and conditions either of that numbered version or
|
||||
of any later version published by the Free Software Foundation. If the
|
||||
Program does not specify a version number of the GNU General Public
|
||||
License, you may choose any version ever published by the Free
|
||||
Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future versions
|
||||
of the GNU General Public License can be used, that proxy's public
|
||||
statement of acceptance of a version permanently authorizes you to
|
||||
choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
#### 15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT
|
||||
WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND
|
||||
PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE
|
||||
DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR
|
||||
CORRECTION.
|
||||
|
||||
#### 16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR
|
||||
CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES
|
||||
ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT
|
||||
NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR
|
||||
LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM
|
||||
TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER
|
||||
PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
#### 17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
### How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these
|
||||
terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest to
|
||||
attach them to the start of each source file to most effectively state
|
||||
the exclusion of warranty; and each file should have at least the
|
||||
"copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper
|
||||
mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands \`show w' and \`show c' should show the
|
||||
appropriate parts of the General Public License. Of course, your
|
||||
program's commands might be different; for a GUI interface, you would
|
||||
use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. For more information on this, and how to apply and follow
|
||||
the GNU GPL, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your
|
||||
program into proprietary programs. If your program is a subroutine
|
||||
library, you may consider it more useful to permit linking proprietary
|
||||
applications with the library. If this is what you want to do, use the
|
||||
GNU Lesser General Public License instead of this License. But first,
|
||||
please read <https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
302
README.md
302
README.md
@@ -1,18 +1,262 @@
|
||||
# The Mempool Open Source Project
|
||||
# The Mempool Open Source Project™
|
||||
|
||||
Mempool is the fully featured mempool visualizer and block explorer website and API service running on [mempool.space](https://mempool.space/). The instructions below are for most users at home running on low-powered Raspberry Pi devices, but if you want to run a production website on a powerful server, see the [production setup guide](https://github.com/mempool/mempool/tree/master/production)
|
||||
Mempool is the fully featured visualizer, explorer, and API service running on [mempool.space](https://mempool.space/), an open source project developed and operated for the benefit of the Bitcoin community, with a focus on the emerging transaction fee market to help our transition into a multi-layer ecosystem.
|
||||
|
||||

|
||||

|
||||
|
||||
# Installation
|
||||
## 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 distro, all the way to an advanced high availability cluster of powerful servers for a production instance. We support the following installation methods, ranked in order from simple to advanced:
|
||||
|
||||
1) One-click installation on: [Umbrel](https://github.com/getumbrel/umbrel), [RaspiBlitz](https://github.com/rootzoll/raspiblitz), [RoninDojo](https://code.samourai.io/ronindojo/RoninDojo), or [MyNode](https://github.com/mynodebtc/mynode).
|
||||
2) [Docker installation on Linux using docker-compose](https://github.com/mempool/mempool/tree/master/docker)
|
||||
3) [Manual installation on Linux or FreeBSD](https://github.com/mempool/mempool#manual-installation)
|
||||
4) [Production installation on a powerful FreeBSD server](https://github.com/mempool/mempool/tree/master/production)
|
||||
5) [High Availability cluster using powerful FreeBSD servers](https://github.com/mempool/mempool/tree/master/production#high-availability)
|
||||
|
||||
# Docker Installation
|
||||
|
||||
The `docker` directory contains the Dockerfiles used to build and release the official images and a `docker-compose.yml` file that is intended for end users to run a Mempool instance with minimal effort.
|
||||
|
||||
## bitcoind only configuration
|
||||
|
||||
To run an instance with the default settings, use the following command:
|
||||
|
||||
```bash
|
||||
$ docker-compose up
|
||||
```
|
||||
|
||||
The default configuration will allow you to run Mempool using `bitcoind` as the backend, so address lookups will be disabled. It assumes you have added RPC credentials for the `mempool` user with a `mempool` password in your `bitcoin.conf` file:
|
||||
|
||||
```
|
||||
rpcuser=mempool
|
||||
rpcpassword=mempool
|
||||
```
|
||||
|
||||
If you want to use your current credentials, update them in the `docker-compose.yml` file:
|
||||
|
||||
```
|
||||
api:
|
||||
environment:
|
||||
MEMPOOL_BACKEND: "none"
|
||||
RPC_HOST: "172.27.0.1"
|
||||
RPC_PORT: "8332"
|
||||
RPC_USER: "mempool"
|
||||
RPC_PASS: "mempool"
|
||||
```
|
||||
|
||||
Note: the IP in the example above refers to Docker's default gateway IP address so the container can hit the `bitcoind` instance running on the host machine. If your setup is different, update it accordingly.
|
||||
|
||||
You can check if the instance is running by visiting http://localhost - the graphs will be populated as new transactions are detected.
|
||||
|
||||
## bitcoind+electrum configuration
|
||||
|
||||
In order to run with a `electrum` compatible server as the backend, in addition to the settings required for running with `bitcoind` above, you will need to make the following changes to the `docker-compose.yml` file:
|
||||
|
||||
- Under the `api` service, change the value of the `MEMPOOL_BACKEND` key from `none` to `electrum`:
|
||||
|
||||
```
|
||||
api:
|
||||
environment:
|
||||
MEMPOOL_BACKEND: "none"
|
||||
```
|
||||
|
||||
- Under the `api` service, set the `ELECTRUM_HOST` and `ELECTRUM_PORT` keys to your Docker host IP address and set `ELECTRUM_TLS_ENABLED` to `false`:
|
||||
|
||||
```
|
||||
api:
|
||||
environment:
|
||||
ELECTRUM_HOST: "172.27.0.1"
|
||||
ELECTRUM_PORT: "50002"
|
||||
ELECTRUM_TLS_ENABLED: "false"
|
||||
```
|
||||
|
||||
You can update any of the backend settings in the `mempool-config.json` file using the following environment variables to override them under the same `api` `environment` section.
|
||||
|
||||
JSON:
|
||||
```
|
||||
"MEMPOOL": {
|
||||
"NETWORK": "mainnet",
|
||||
"BACKEND": "electrum",
|
||||
"HTTP_PORT": 8999,
|
||||
"SPAWN_CLUSTER_PROCS": 0,
|
||||
"API_URL_PREFIX": "/api/v1/",
|
||||
"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,
|
||||
"PRICE_FEED_UPDATE_INTERVAL": 3600,
|
||||
"USE_SECOND_NODE_FOR_MINFEE": false,
|
||||
"EXTERNAL_ASSETS": []
|
||||
},
|
||||
```
|
||||
|
||||
docker-compose overrides::
|
||||
```
|
||||
MEMPOOL_NETWORK: ""
|
||||
MEMPOOL_BACKEND: ""
|
||||
MEMPOOL_HTTP_PORT: ""
|
||||
MEMPOOL_SPAWN_CLUSTER_PROCS: ""
|
||||
MEMPOOL_API_URL_PREFIX: ""
|
||||
MEMPOOL_POLL_RATE_MS: ""
|
||||
MEMPOOL_CACHE_DIR: ""
|
||||
MEMPOOL_CLEAR_PROTECTION_MINUTES: ""
|
||||
MEMPOOL_RECOMMENDED_FEE_PERCENTILE: ""
|
||||
MEMPOOL_BLOCK_WEIGHT_UNITS: ""
|
||||
MEMPOOL_INITIAL_BLOCKS_AMOUNT: ""
|
||||
MEMPOOL_MEMPOOL_BLOCKS_AMOUNT: ""
|
||||
MEMPOOL_PRICE_FEED_UPDATE_INTERVAL: ""
|
||||
MEMPOOL_USE_SECOND_NODE_FOR_MINFEE: ""
|
||||
MEMPOOL_EXTERNAL_ASSETS: ""
|
||||
```
|
||||
|
||||
JSON:
|
||||
```
|
||||
"CORE_RPC": {
|
||||
"HOST": "127.0.0.1",
|
||||
"PORT": 8332,
|
||||
"USERNAME": "mempool",
|
||||
"PASSWORD": "mempool"
|
||||
},
|
||||
```
|
||||
docker-compose overrides:
|
||||
```
|
||||
CORE_RPC_HOST: ""
|
||||
CORE_RPC_PORT: ""
|
||||
CORE_RPC_USERNAME: ""
|
||||
CORE_RPC_PASSWORD: ""
|
||||
```
|
||||
|
||||
JSON:
|
||||
```
|
||||
"ELECTRUM": {
|
||||
"HOST": "127.0.0.1",
|
||||
"PORT": 50002,
|
||||
"TLS_ENABLED": true
|
||||
},
|
||||
```
|
||||
|
||||
docker-compose overrides:
|
||||
```
|
||||
ELECTRUM_HOST: ""
|
||||
ELECTRUM_PORT: ""
|
||||
ELECTRUM_TLS: ""
|
||||
```
|
||||
|
||||
JSON:
|
||||
```
|
||||
"ESPLORA": {
|
||||
"REST_API_URL": "http://127.0.0.1:3000"
|
||||
},
|
||||
```
|
||||
docker-compose overrides:
|
||||
```
|
||||
ESPLORA_REST_API_URL: ""
|
||||
```
|
||||
|
||||
JSON:
|
||||
```
|
||||
"SECOND_CORE_RPC": {
|
||||
"HOST": "127.0.0.1",
|
||||
"PORT": 8332,
|
||||
"USERNAME": "mempool",
|
||||
"PASSWORD": "mempool"
|
||||
},
|
||||
```
|
||||
|
||||
docker-compose overrides:
|
||||
```
|
||||
SECOND_CORE_RPC_HOST: ""
|
||||
SECOND_CORE_RPC_PORT: ""
|
||||
SECOND_CORE_RPC_USERNAME: ""
|
||||
SECOND_CORE_RPC_PASSWORD: ""
|
||||
```
|
||||
|
||||
JSON:
|
||||
```
|
||||
"DATABASE": {
|
||||
"ENABLED": true,
|
||||
"HOST": "127.0.0.1",
|
||||
"PORT": 3306,
|
||||
"DATABASE": "mempool",
|
||||
"USERNAME": "mempool",
|
||||
"PASSWORD": "mempool"
|
||||
},
|
||||
```
|
||||
|
||||
docker-compose overrides:
|
||||
```
|
||||
DATABASE_ENABLED: ""
|
||||
DATABASE_HOST: ""
|
||||
DATABASE_PORT: ""
|
||||
DATABASE_DATABASE: ""
|
||||
DATABASE_USERAME: ""
|
||||
DATABASE_PASSWORD: ""
|
||||
```
|
||||
|
||||
JSON:
|
||||
```
|
||||
"SYSLOG": {
|
||||
"ENABLED": true,
|
||||
"HOST": "127.0.0.1",
|
||||
"PORT": 514,
|
||||
"MIN_PRIORITY": "info",
|
||||
"FACILITY": "local7"
|
||||
},
|
||||
```
|
||||
|
||||
docker-compose overrides:
|
||||
```
|
||||
SYSLOG_ENABLED: ""
|
||||
SYSLOG_HOST: ""
|
||||
SYSLOG_PORT: ""
|
||||
SYSLOG_MIN_PRIORITY: ""
|
||||
SYSLOG_FACILITY: ""
|
||||
```
|
||||
|
||||
JSON:
|
||||
```
|
||||
"STATISTICS": {
|
||||
"ENABLED": true,
|
||||
"TX_PER_SECOND_SAMPLE_PERIOD": 150
|
||||
},
|
||||
```
|
||||
|
||||
docker-compose overrides:
|
||||
```
|
||||
STATISTICS_ENABLED: ""
|
||||
STATISTICS_TX_PER_SECOND_SAMPLE_PERIOD: ""
|
||||
```
|
||||
|
||||
JSON:
|
||||
```
|
||||
"BISQ": {
|
||||
"ENABLED": false,
|
||||
"DATA_PATH": "/bisq/statsnode-data/btc_mainnet/db"
|
||||
}
|
||||
```
|
||||
|
||||
docker-compose overrides:
|
||||
```
|
||||
BISQ_ENABLED: ""
|
||||
BISQ_DATA_PATH: ""
|
||||
```
|
||||
|
||||
|
||||
# Manual Installation
|
||||
|
||||
The following instructions are for a manual installation on Linux or FreeBSD. The file and directory paths may need to be changed to match your OS.
|
||||
|
||||
## Dependencies
|
||||
|
||||
* Bitcoin Core (no pruning, txindex=1)
|
||||
* Electrum Server (romanz/electrs)
|
||||
* NodeJS (official stable LTS)
|
||||
* MariaDB (default config)
|
||||
* Nginx (use supplied nginx.conf and nginx-mempool.conf)
|
||||
* [Bitcoin](https://github.com/bitcoin/bitcoin)
|
||||
* [Electrum](https://github.com/romanz/electrs)
|
||||
* [NodeJS](https://github.com/nodejs/node)
|
||||
* [MariaDB](https://github.com/mariadb/server)
|
||||
* [Nginx](https://github.com/nginx/nginx)
|
||||
|
||||
## Mempool
|
||||
|
||||
@@ -29,7 +273,7 @@ Clone the mempool repo, and checkout the latest release tag:
|
||||
Enable RPC and txindex in `bitcoin.conf`:
|
||||
```bash
|
||||
rpcuser=mempool
|
||||
rpcpassword=71b61986da5b03a5694d7c7d5165ece5
|
||||
rpcpassword=mempool
|
||||
txindex=1
|
||||
```
|
||||
|
||||
@@ -42,7 +286,7 @@ Install MariaDB from OS package manager:
|
||||
|
||||
# macOS
|
||||
brew install mariadb
|
||||
brew services start mariadb
|
||||
mysql.server start
|
||||
```
|
||||
|
||||
Create database and grant privileges:
|
||||
@@ -53,22 +297,17 @@ Create database and grant privileges:
|
||||
MariaDB [(none)]> create database mempool;
|
||||
Query OK, 1 row affected (0.00 sec)
|
||||
|
||||
MariaDB [(none)]> grant all privileges on mempool.* to 'mempool' identified by 'mempool';
|
||||
MariaDB [(none)]> grant all privileges on mempool.* to 'mempool'@'%' identified by 'mempool';
|
||||
Query OK, 0 rows affected (0.00 sec)
|
||||
```
|
||||
|
||||
From the mempool repo's top-level folder, import the database structure:
|
||||
```bash
|
||||
mysql -u mempool -p mempool < mariadb-structure.sql
|
||||
```
|
||||
|
||||
## Mempool Backend
|
||||
Install mempool dependencies from npm and build the backend:
|
||||
|
||||
```bash
|
||||
# backend
|
||||
cd ../backend/
|
||||
npm install
|
||||
cd backend
|
||||
npm install --prod
|
||||
npm run build
|
||||
```
|
||||
|
||||
@@ -84,31 +323,26 @@ Edit `mempool-config.json` to add your Bitcoin Core node RPC credentials:
|
||||
"MEMPOOL": {
|
||||
"NETWORK": "mainnet",
|
||||
"BACKEND": "electrum",
|
||||
"HTTP_PORT": 8999,
|
||||
"API_URL_PREFIX": "/api/v1/",
|
||||
"POLL_RATE_MS": 2000
|
||||
"HTTP_PORT": 8999
|
||||
},
|
||||
"CORE_RPC": {
|
||||
"HOST": "127.0.0.1",
|
||||
"PORT": 8332,
|
||||
"USERNAME": "mempool",
|
||||
"PASSWORD": "71b61986da5b03a5694d7c7d5165ece5"
|
||||
"PASSWORD": "mempool"
|
||||
},
|
||||
"ELECTRUM": {
|
||||
"HOST": "127.0.0.1",
|
||||
"PORT": 50002,
|
||||
"TLS_ENABLED": true,
|
||||
"TX_LOOKUPS": false
|
||||
"TLS_ENABLED": true
|
||||
},
|
||||
"DATABASE": {
|
||||
"ENABLED": true,
|
||||
"HOST": "localhost",
|
||||
"HOST": "127.0.0.1",
|
||||
"PORT": 3306,
|
||||
"USERNAME": "mempool",
|
||||
"PASSWORD": "mempool",
|
||||
"DATABASE": "mempool"
|
||||
},
|
||||
"STATISTICS": {
|
||||
"ENABLED": true,
|
||||
"TX_PER_SECOND_SAMPLE_PERIOD": 150
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -149,14 +383,14 @@ Install mempool dependencies from npm and build the frontend static HTML/CSS/JS:
|
||||
```bash
|
||||
# frontend
|
||||
cd frontend
|
||||
npm install
|
||||
npm install --prod
|
||||
npm run build
|
||||
```
|
||||
|
||||
Install the output into nginx webroot folder:
|
||||
|
||||
```bash
|
||||
sudo rsync -av --delete dist/mempool/ /var/www/html/
|
||||
sudo rsync -av --delete dist/ /var/www/
|
||||
```
|
||||
|
||||
## nginx + certbot
|
||||
@@ -165,10 +399,10 @@ Install the supplied nginx.conf and nginx-mempool.conf in /etc/nginx
|
||||
|
||||
```bash
|
||||
# install nginx and certbot
|
||||
apt-get install -y nginx python-certbot-nginx
|
||||
apt-get install -y nginx python3-certbot-nginx
|
||||
|
||||
# install the mempool configuration for nginx
|
||||
cp nginx.conf nginx-mempool.conf /etc/nginx/nginx.conf
|
||||
cp nginx.conf nginx-mempool.conf /etc/nginx/
|
||||
|
||||
# replace example.com with your domain name
|
||||
certbot --nginx -d example.com
|
||||
|
||||
10
backend/.gitignore
vendored
10
backend/.gitignore
vendored
@@ -1,7 +1,10 @@
|
||||
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
||||
|
||||
# production config
|
||||
mempool-config.json
|
||||
# production config and external assets
|
||||
*.json
|
||||
!mempool-config.sample.json
|
||||
|
||||
icons.json
|
||||
|
||||
# compiled output
|
||||
/dist
|
||||
@@ -41,6 +44,3 @@ testem.log
|
||||
#System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
cache.json
|
||||
cache2.json
|
||||
4
backend/.vscode/settings.json
vendored
Normal file
4
backend/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"editor.tabSize": 2,
|
||||
"typescript.tsdk": "../backend/node_modules/typescript/lib"
|
||||
}
|
||||
1
backend/cache/.gitignore
vendored
Normal file
1
backend/cache/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.json
|
||||
@@ -5,7 +5,16 @@
|
||||
"HTTP_PORT": 8999,
|
||||
"SPAWN_CLUSTER_PROCS": 0,
|
||||
"API_URL_PREFIX": "/api/v1/",
|
||||
"POLL_RATE_MS": 2000
|
||||
"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,
|
||||
"PRICE_FEED_UPDATE_INTERVAL": 3600,
|
||||
"USE_SECOND_NODE_FOR_MINFEE": false,
|
||||
"EXTERNAL_ASSETS": []
|
||||
},
|
||||
"CORE_RPC": {
|
||||
"HOST": "127.0.0.1",
|
||||
@@ -16,12 +25,17 @@
|
||||
"ELECTRUM": {
|
||||
"HOST": "127.0.0.1",
|
||||
"PORT": 50002,
|
||||
"TLS_ENABLED": true,
|
||||
"TX_LOOKUPS": false
|
||||
"TLS_ENABLED": true
|
||||
},
|
||||
"ESPLORA": {
|
||||
"REST_API_URL": "http://127.0.0.1:3000"
|
||||
},
|
||||
"SECOND_CORE_RPC": {
|
||||
"HOST": "127.0.0.1",
|
||||
"PORT": 8332,
|
||||
"USERNAME": "mempool",
|
||||
"PASSWORD": "mempool"
|
||||
},
|
||||
"DATABASE": {
|
||||
"ENABLED": true,
|
||||
"HOST": "127.0.0.1",
|
||||
@@ -30,23 +44,19 @@
|
||||
"USERNAME": "mempool",
|
||||
"PASSWORD": "mempool"
|
||||
},
|
||||
"SYSLOG": {
|
||||
"ENABLED": true,
|
||||
"HOST": "127.0.0.1",
|
||||
"PORT": 514,
|
||||
"MIN_PRIORITY": "info",
|
||||
"FACILITY": "local7"
|
||||
},
|
||||
"STATISTICS": {
|
||||
"ENABLED": true,
|
||||
"TX_PER_SECOND_SAMPLE_PERIOD": 150
|
||||
},
|
||||
"BISQ_BLOCKS": {
|
||||
"ENABLED": false,
|
||||
"DATA_PATH": "/bisq/statsnode-data/btc_mainnet/db/json"
|
||||
},
|
||||
"BISQ_MARKETS": {
|
||||
"BISQ": {
|
||||
"ENABLED": false,
|
||||
"DATA_PATH": "/bisq/statsnode-data/btc_mainnet/db"
|
||||
},
|
||||
"SPONSORS": {
|
||||
"ENABLED": false,
|
||||
"BTCPAY_URL": "",
|
||||
"BTCPAY_AUTH": "",
|
||||
"BTCPAY_WEBHOOK_URL": "",
|
||||
"TWITTER_BEARER_AUTH": ""
|
||||
}
|
||||
}
|
||||
|
||||
612
backend/package-lock.json
generated
612
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "mempool-backend",
|
||||
"version": "2.0.0",
|
||||
"version": "2.3.1",
|
||||
"description": "Bitcoin mempool visualizer and blockchain explorer backend",
|
||||
"license": "MIT",
|
||||
"license": "GNU Affero General Public License v3.0",
|
||||
"homepage": "https://mempool.space",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -23,27 +23,28 @@
|
||||
"ng": "./node_modules/@angular/cli/bin/ng",
|
||||
"tsc": "./node_modules/typescript/bin/tsc",
|
||||
"build": "npm run tsc",
|
||||
"start": "node --max-old-space-size=4096 dist/index.js",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mempool/bitcoin": "^3.0.2",
|
||||
"@mempool/bitcoin": "^3.0.3",
|
||||
"@mempool/electrum-client": "^1.1.7",
|
||||
"axios": "^0.21.1",
|
||||
"bitcoinjs-lib": "^5.2.0",
|
||||
"@types/ws": "8.2.2",
|
||||
"axios": "0.24.0",
|
||||
"bitcoinjs-lib": "6.0.1",
|
||||
"crypto-js": "^4.0.0",
|
||||
"express": "^4.17.1",
|
||||
"locutus": "^2.0.12",
|
||||
"mysql2": "^1.6.1",
|
||||
"node-worker-threads-pool": "^1.4.2",
|
||||
"ws": "^7.3.1"
|
||||
"mysql2": "2.3.3",
|
||||
"node-worker-threads-pool": "^1.4.3",
|
||||
"typescript": "4.4.4",
|
||||
"ws": "8.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/compression": "^1.0.1",
|
||||
"@types/express": "^4.17.2",
|
||||
"@types/locutus": "^0.0.6",
|
||||
"@types/ws": "^6.0.4",
|
||||
"tslint": "~6.1.0",
|
||||
"typescript": "~3.9.7"
|
||||
"tslint": "^6.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import logger from '../logger';
|
||||
import { IBackendInfo } from '../mempool.interfaces';
|
||||
|
||||
class BackendInfo {
|
||||
gitCommitHash = '';
|
||||
hostname = '';
|
||||
private gitCommitHash = '';
|
||||
private hostname = '';
|
||||
private version = '';
|
||||
|
||||
constructor() {
|
||||
this.setLatestCommitHash();
|
||||
this.setVersion();
|
||||
this.hostname = os.hostname();
|
||||
}
|
||||
|
||||
public getBackendInfo() {
|
||||
public getBackendInfo(): IBackendInfo {
|
||||
return {
|
||||
'hostname': this.hostname,
|
||||
'git-commit': this.gitCommitHash,
|
||||
hostname: this.hostname,
|
||||
gitCommit: this.gitCommitHash,
|
||||
version: this.version,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,7 +30,16 @@ class BackendInfo {
|
||||
try {
|
||||
this.gitCommitHash = fs.readFileSync('../.git/refs/heads/master').toString().trim();
|
||||
} catch (e) {
|
||||
logger.err('Could not load git commit info: ' + e.message || e);
|
||||
logger.err('Could not load git commit info: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,10 @@ import { StaticPool } from 'node-worker-threads-pool';
|
||||
import logger from '../../logger';
|
||||
|
||||
class Bisq {
|
||||
private static BLOCKS_JSON_FILE_PATH = config.BISQ_BLOCKS.DATA_PATH + '/all/blocks.json';
|
||||
private static BLOCKS_JSON_FILE_PATH = config.BISQ.DATA_PATH + '/json/all/blocks.json';
|
||||
private latestBlockHeight = 0;
|
||||
private blocks: BisqBlock[] = [];
|
||||
private allBlocks: BisqBlock[] = [];
|
||||
private transactions: BisqTransaction[] = [];
|
||||
private transactionIndex: { [txId: string]: BisqTransaction } = {};
|
||||
private blockIndex: { [hash: string]: BisqBlock } = {};
|
||||
@@ -43,7 +44,7 @@ class Bisq {
|
||||
}
|
||||
|
||||
handleNewBitcoinBlock(block: BlockExtended): void {
|
||||
if (block.height - 2 > this.latestBlockHeight && this.latestBlockHeight !== 0) {
|
||||
if (block.height - 10 > this.latestBlockHeight && this.latestBlockHeight !== 0) {
|
||||
logger.warn(`Bitcoin block height (#${block.height}) has diverged from the latest Bisq block height (#${this.latestBlockHeight}). Restarting watchers...`);
|
||||
this.startTopDirectoryWatcher();
|
||||
this.startSubDirectoryWatcher();
|
||||
@@ -98,7 +99,7 @@ class Bisq {
|
||||
this.topDirectoryWatcher.close();
|
||||
}
|
||||
let fsWait: NodeJS.Timeout | null = null;
|
||||
this.topDirectoryWatcher = fs.watch(config.BISQ_BLOCKS.DATA_PATH, () => {
|
||||
this.topDirectoryWatcher = fs.watch(config.BISQ.DATA_PATH + '/json', () => {
|
||||
if (fsWait) {
|
||||
clearTimeout(fsWait);
|
||||
}
|
||||
@@ -126,7 +127,7 @@ class Bisq {
|
||||
return;
|
||||
}
|
||||
let fsWait: NodeJS.Timeout | null = null;
|
||||
this.subdirectoryWatcher = fs.watch(config.BISQ_BLOCKS.DATA_PATH + '/all', () => {
|
||||
this.subdirectoryWatcher = fs.watch(config.BISQ.DATA_PATH + '/json/all', () => {
|
||||
if (fsWait) {
|
||||
clearTimeout(fsWait);
|
||||
}
|
||||
@@ -138,7 +139,7 @@ class Bisq {
|
||||
}
|
||||
|
||||
private updatePrice() {
|
||||
axios.get<BisqTrade[]>('https://bisq.markets/api/trades/?market=bsq_btc')
|
||||
axios.get<BisqTrade[]>('https://bisq.markets/api/trades/?market=bsq_btc', { timeout: 10000 })
|
||||
.then((response) => {
|
||||
const prices: number[] = [];
|
||||
response.data.forEach((trade) => {
|
||||
@@ -161,7 +162,7 @@ class Bisq {
|
||||
this.buildIndex();
|
||||
this.calculateStats();
|
||||
} catch (e) {
|
||||
logger.err('loadBisqDumpFile() error.' + e.message || e);
|
||||
logger.info('loadBisqDumpFile() error.' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +172,7 @@ class Bisq {
|
||||
this.transactionIndex = {};
|
||||
this.addressIndex = {};
|
||||
|
||||
this.blocks.forEach((block) => {
|
||||
this.allBlocks.forEach((block) => {
|
||||
/* Build block index */
|
||||
if (!this.blockIndex[block.hash]) {
|
||||
this.blockIndex[block.hash] = block;
|
||||
@@ -233,8 +234,8 @@ class Bisq {
|
||||
|
||||
this.stats = {
|
||||
addresses: Object.keys(this.addressIndex).length,
|
||||
minted: minted,
|
||||
burnt: burned,
|
||||
minted: minted / 100,
|
||||
burnt: burned / 100,
|
||||
spent_txos: spent,
|
||||
unspent_txos: unspent,
|
||||
};
|
||||
@@ -245,9 +246,10 @@ class Bisq {
|
||||
if (cacheData && cacheData.length !== 0) {
|
||||
logger.debug('Processing Bisq data dump...');
|
||||
const data: BisqBlocks = await this.jsonParsePool.exec(cacheData);
|
||||
if (data.blocks && data.blocks.length !== this.blocks.length) {
|
||||
this.blocks = data.blocks.filter((block) => block.txs.length > 0);
|
||||
this.blocks.reverse();
|
||||
if (data.blocks && data.blocks.length !== this.allBlocks.length) {
|
||||
this.allBlocks = data.blocks;
|
||||
this.allBlocks.reverse();
|
||||
this.blocks = this.allBlocks.filter((block) => block.txs.length > 0);
|
||||
this.latestBlockHeight = data.chainHeight;
|
||||
const time = new Date().getTime() - start;
|
||||
logger.debug('Bisq dump processed in ' + time + ' ms (worker thread)');
|
||||
|
||||
@@ -71,7 +71,7 @@ interface BisqScriptPubKey {
|
||||
addresses: string[];
|
||||
asm: string;
|
||||
hex: string;
|
||||
reqSigs: number;
|
||||
reqSigs?: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -457,6 +457,30 @@ class BisqMarketsApi {
|
||||
}
|
||||
}
|
||||
|
||||
getVolumesByTime(time: number): MarketVolume[] {
|
||||
const timestamp_from = new Date().getTime() / 1000 - time;
|
||||
const timestamp_to = new Date().getTime() / 1000;
|
||||
|
||||
const trades = this.getTradesByCriteria(undefined, timestamp_to, timestamp_from,
|
||||
undefined, undefined, undefined, 'asc', Number.MAX_SAFE_INTEGER);
|
||||
|
||||
const markets: any = {};
|
||||
|
||||
for (const trade of trades) {
|
||||
if (!markets[trade._market]) {
|
||||
markets[trade._market] = {
|
||||
'volume': 0,
|
||||
'num_trades': 0,
|
||||
};
|
||||
}
|
||||
|
||||
markets[trade._market]['volume'] += this.fiatCurrenciesIndexed[trade.currency] ? trade._tradeAmount : trade._tradeVolume;
|
||||
markets[trade._market]['num_trades']++;
|
||||
}
|
||||
|
||||
return markets;
|
||||
}
|
||||
|
||||
private getTradesSummarized(trades: TradesData[], timestamp_from: number, interval?: string): SummarizedIntervals {
|
||||
const intervals: any = {};
|
||||
const intervals_prices: any = {};
|
||||
|
||||
@@ -6,7 +6,7 @@ import logger from '../../logger';
|
||||
|
||||
class Bisq {
|
||||
private static FOLDER_WATCH_CHANGE_DETECTION_DEBOUNCE = 4000;
|
||||
private static MARKET_JSON_PATH = config.BISQ_MARKETS.DATA_PATH;
|
||||
private static MARKET_JSON_PATH = config.BISQ.DATA_PATH;
|
||||
private static MARKET_JSON_FILE_PATHS = {
|
||||
activeCryptoCurrency: '/active_crypto_currency_list.json',
|
||||
activeFiatCurrency: '/active_fiat_currency_list.json',
|
||||
@@ -102,7 +102,7 @@ class Bisq {
|
||||
logger.debug('Bisq market data updated in ' + time + ' ms');
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err('loadBisqMarketDataDumpFile() error.' + e.message || e);
|
||||
logger.err('loadBisqMarketDataDumpFile() error.' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,21 @@ import { IEsploraApi } from './esplora-api.interface';
|
||||
export interface AbstractBitcoinApi {
|
||||
$getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]>;
|
||||
$getRawTransaction(txId: string, skipConversion?: boolean, addPrevout?: boolean): Promise<IEsploraApi.Transaction>;
|
||||
$getRawTransactionBitcoind(txId: string, skipConversion?: boolean, addPrevout?: boolean): Promise<IEsploraApi.Transaction>;
|
||||
$getBlockHeightTip(): Promise<number>;
|
||||
$getTxIdsForBlock(hash: string): Promise<string[]>;
|
||||
$getBlockHash(height: number): Promise<string>;
|
||||
$getBlockHeader(hash: string): Promise<string>;
|
||||
$getBlock(hash: string): Promise<IEsploraApi.Block>;
|
||||
$getAddress(address: string): Promise<IEsploraApi.Address>;
|
||||
$getAddressTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]>;
|
||||
$getAddressPrefix(prefix: string): string[];
|
||||
$sendRawTransaction(rawTransaction: string): Promise<string>;
|
||||
$getOutspends(txId: string): Promise<IEsploraApi.Outspend[]>;
|
||||
}
|
||||
export interface BitcoinRpcCredentials {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
pass: string;
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
@@ -3,16 +3,17 @@ import { AbstractBitcoinApi } from './bitcoin-api-abstract-factory';
|
||||
import EsploraApi from './esplora-api';
|
||||
import BitcoinApi from './bitcoin-api';
|
||||
import ElectrumApi from './electrum-api';
|
||||
import bitcoinClient from './bitcoin-client';
|
||||
|
||||
function bitcoinApiFactory(): AbstractBitcoinApi {
|
||||
switch (config.MEMPOOL.BACKEND) {
|
||||
case 'esplora':
|
||||
return new EsploraApi();
|
||||
case 'electrum':
|
||||
return new ElectrumApi();
|
||||
return new ElectrumApi(bitcoinClient);
|
||||
case 'none':
|
||||
default:
|
||||
return new BitcoinApi();
|
||||
return new BitcoinApi(bitcoinClient);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ export namespace IBitcoinApi {
|
||||
time: number; // (numeric) Same as blocktime
|
||||
}
|
||||
|
||||
interface Vin {
|
||||
export interface Vin {
|
||||
txid?: string; // (string) The transaction id
|
||||
vout?: number; // (string)
|
||||
scriptSig?: { // (json object) The script
|
||||
@@ -82,28 +82,36 @@ export namespace IBitcoinApi {
|
||||
sequence: number; // (numeric) The script sequence number
|
||||
txinwitness?: string[]; // (string) hex-encoded witness data
|
||||
coinbase?: string;
|
||||
is_pegin?: boolean; // (boolean) Elements peg-in
|
||||
}
|
||||
|
||||
interface Vout {
|
||||
export interface Vout {
|
||||
value: number; // (numeric) The value in BTC
|
||||
n: number; // (numeric) index
|
||||
asset?: string; // (string) Elements asset id
|
||||
scriptPubKey: { // (json object)
|
||||
asm: string; // (string) the asm
|
||||
hex: string; // (string) the hex
|
||||
reqSigs: number; // (numeric) The required sigs
|
||||
reqSigs?: number; // (numeric) The required sigs
|
||||
type: string; // (string) The type, eg 'pubkeyhash'
|
||||
addresses: string[] // (string) bitcoin address
|
||||
address?: string; // (string) bitcoin address
|
||||
addresses?: string[]; // (string) bitcoin addresses
|
||||
pegout_chain?: string; // (string) Elements peg-out chain
|
||||
pegout_addresses?: string[]; // (string) Elements peg-out addresses
|
||||
};
|
||||
}
|
||||
|
||||
export interface AddressInformation {
|
||||
isvalid: boolean; // (boolean) If the address is valid or not. If not, this is the only property returned.
|
||||
isvalid_parent?: boolean; // (boolean) Elements only
|
||||
address: string; // (string) The bitcoin address validated
|
||||
scriptPubKey: string; // (string) The hex-encoded scriptPubKey generated by the address
|
||||
isscript: boolean; // (boolean) If the key is a script
|
||||
iswitness: boolean; // (boolean) If the address is a witness
|
||||
witness_version?: boolean; // (numeric, optional) The version number of the witness program
|
||||
witness_version?: number; // (numeric, optional) The version number of the witness program
|
||||
witness_program: string; // (string, optional) The hex value of the witness program
|
||||
confidential_key?: string; // (string) Elements only
|
||||
unconfidential?: string; // (string) Elements only
|
||||
}
|
||||
|
||||
export interface ChainTips {
|
||||
@@ -113,4 +121,46 @@ export namespace IBitcoinApi {
|
||||
status: 'invalid' | 'headers-only' | 'valid-headers' | 'valid-fork' | 'active';
|
||||
}
|
||||
|
||||
export interface BlockchainInfo {
|
||||
chain: number; // (string) current network name as defined in BIP70 (main, test, regtest)
|
||||
blocks: number; // (numeric) the current number of blocks processed in the server
|
||||
headers: number; // (numeric) the current number of headers we have validated
|
||||
bestblockhash: string, // (string) the hash of the currently best block
|
||||
difficulty: number; // (numeric) the current difficulty
|
||||
mediantime: number; // (numeric) median time for the current best block
|
||||
verificationprogress: number; // (numeric) estimate of verification progress [0..1]
|
||||
initialblockdownload: boolean; // (bool) (debug information) estimate of whether this node is in Initial Block Download mode.
|
||||
chainwork: string // (string) total amount of work in active chain, in hexadecimal
|
||||
size_on_disk: number; // (numeric) the estimated size of the block and undo files on disk
|
||||
pruned: number; // (boolean) if the blocks are subject to pruning
|
||||
pruneheight: number; // (numeric) lowest-height complete block stored (only present if pruning is enabled)
|
||||
automatic_pruning: number; // (boolean) whether automatic pruning is enabled (only present if pruning is enabled)
|
||||
prune_target_size: number; // (numeric) the target size used by pruning (only present if automatic pruning is enabled)
|
||||
softforks: SoftFork[]; // (array) status of softforks in progress
|
||||
bip9_softforks: { [name: string]: Bip9SoftForks[] } // (object) status of BIP9 softforks in progress
|
||||
warnings: string; // (string) any network and blockchain warnings.
|
||||
}
|
||||
|
||||
interface SoftFork {
|
||||
id: string; // (string) name of softfork
|
||||
version: number; // (numeric) block version
|
||||
reject: { // (object) progress toward rejecting pre-softfork blocks
|
||||
status: boolean; // (boolean) true if threshold reached
|
||||
},
|
||||
}
|
||||
interface Bip9SoftForks {
|
||||
status: number; // (string) one of defined, started, locked_in, active, failed
|
||||
bit: number; // (numeric) the bit (0-28) in the block version field used to signal this softfork (only for started status)
|
||||
startTime: number; // (numeric) the minimum median time past of a block at which the bit gains its meaning
|
||||
timeout: number; // (numeric) the median time past of a block at which the deployment is considered failed if not yet locked in
|
||||
since: number; // (numeric) height of the first block to which the status applies
|
||||
statistics: { // (object) numeric statistics about BIP9 signalling for a softfork (only for started status)
|
||||
period: number; // (numeric) the length in blocks of the BIP9 signalling period
|
||||
threshold: number; // (numeric) the number of blocks with the version bit set required to activate the feature
|
||||
elapsed: number; // (numeric) the number of blocks elapsed since the beginning of the current period
|
||||
count: number; // (numeric) the number of blocks with the version bit set in the current period
|
||||
possible: boolean; // (boolean) returns false if there are not enough blocks left in this period to pass activation threshold
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,36 +1,17 @@
|
||||
import config from '../../config';
|
||||
import * as bitcoin from '@mempool/bitcoin';
|
||||
import * as bitcoinjs from 'bitcoinjs-lib';
|
||||
import { AbstractBitcoinApi } from './bitcoin-api-abstract-factory';
|
||||
import { IBitcoinApi } from './bitcoin-api.interface';
|
||||
import { IEsploraApi } from './esplora-api.interface';
|
||||
import blocks from '../blocks';
|
||||
import bitcoinBaseApi from './bitcoin-base.api';
|
||||
import mempool from '../mempool';
|
||||
import { TransactionExtended } from '../../mempool.interfaces';
|
||||
|
||||
class BitcoinApi implements AbstractBitcoinApi {
|
||||
private rawMempoolCache: IBitcoinApi.RawMempool | null = null;
|
||||
private bitcoindClient: any;
|
||||
protected bitcoindClient: any;
|
||||
|
||||
constructor() {
|
||||
this.bitcoindClient = new bitcoin.Client({
|
||||
host: config.CORE_RPC.HOST,
|
||||
port: config.CORE_RPC.PORT,
|
||||
user: config.CORE_RPC.USERNAME,
|
||||
pass: config.CORE_RPC.PASSWORD,
|
||||
timeout: 60000,
|
||||
});
|
||||
}
|
||||
|
||||
$getRawTransactionBitcoind(txId: string, skipConversion = false, addPrevout = false): Promise<IEsploraApi.Transaction> {
|
||||
return this.bitcoindClient.getRawTransaction(txId, true)
|
||||
.then((transaction: IBitcoinApi.Transaction) => {
|
||||
if (skipConversion) {
|
||||
return transaction;
|
||||
}
|
||||
return this.$convertTransaction(transaction, addPrevout);
|
||||
});
|
||||
constructor(bitcoinClient: any) {
|
||||
this.bitcoindClient = bitcoinClient;
|
||||
}
|
||||
|
||||
$getRawTransaction(txId: string, skipConversion = false, addPrevout = false): Promise<IEsploraApi.Transaction> {
|
||||
@@ -48,6 +29,9 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
return this.bitcoindClient.getRawTransaction(txId, true)
|
||||
.then((transaction: IBitcoinApi.Transaction) => {
|
||||
if (skipConversion) {
|
||||
transaction.vout.forEach((vout) => {
|
||||
vout.value = vout.value * 100000000;
|
||||
});
|
||||
return transaction;
|
||||
}
|
||||
return this.$convertTransaction(transaction, addPrevout);
|
||||
@@ -64,10 +48,18 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
.then((rpcBlock: IBitcoinApi.Block) => rpcBlock.tx);
|
||||
}
|
||||
|
||||
$getRawBlock(hash: string): Promise<string> {
|
||||
return this.bitcoindClient.getBlock(hash, 0);
|
||||
}
|
||||
|
||||
$getBlockHash(height: number): Promise<string> {
|
||||
return this.bitcoindClient.getBlockHash(height);
|
||||
}
|
||||
|
||||
$getBlockHeader(hash: string): Promise<string> {
|
||||
return this.bitcoindClient.getBlockHeader(hash, false);
|
||||
}
|
||||
|
||||
async $getBlock(hash: string): Promise<IEsploraApi.Block> {
|
||||
const foundBlock = blocks.getBlocks().find((block) => block.id === hash);
|
||||
if (foundBlock) {
|
||||
@@ -106,6 +98,22 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
return found;
|
||||
}
|
||||
|
||||
$sendRawTransaction(rawTransaction: string): Promise<string> {
|
||||
return this.bitcoindClient.sendRawTransaction(rawTransaction);
|
||||
}
|
||||
|
||||
async $getOutspends(txId: string): Promise<IEsploraApi.Outspend[]> {
|
||||
const outSpends: IEsploraApi.Outspend[] = [];
|
||||
const tx = await this.$getRawTransaction(txId, true, false);
|
||||
for (let i = 0; i < tx.vout.length; i++) {
|
||||
const txOut = await this.bitcoindClient.getTxOut(txId, i);
|
||||
outSpends.push({
|
||||
spent: txOut === null,
|
||||
});
|
||||
}
|
||||
return outSpends;
|
||||
}
|
||||
|
||||
protected async $convertTransaction(transaction: IBitcoinApi.Transaction, addPrevout: boolean): Promise<IEsploraApi.Transaction> {
|
||||
let esploraTransaction: IEsploraApi.Transaction = {
|
||||
txid: transaction.txid,
|
||||
@@ -123,7 +131,8 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
return {
|
||||
value: vout.value * 100000000,
|
||||
scriptpubkey: vout.scriptPubKey.hex,
|
||||
scriptpubkey_address: vout.scriptPubKey && vout.scriptPubKey.addresses ? vout.scriptPubKey.addresses[0] : '',
|
||||
scriptpubkey_address: vout.scriptPubKey && vout.scriptPubKey.address ? vout.scriptPubKey.address
|
||||
: vout.scriptPubKey.addresses ? vout.scriptPubKey.addresses[0] : '',
|
||||
scriptpubkey_asm: vout.scriptPubKey.asm ? this.convertScriptSigAsm(vout.scriptPubKey.asm) : '',
|
||||
scriptpubkey_type: this.translateScriptPubKeyType(vout.scriptPubKey.type),
|
||||
};
|
||||
@@ -155,6 +164,9 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
esploraTransaction = await this.$calculateFeeFromInputs(esploraTransaction, addPrevout);
|
||||
} else {
|
||||
esploraTransaction = await this.$appendMempoolFeeData(esploraTransaction);
|
||||
if (addPrevout) {
|
||||
esploraTransaction = await this.$calculateFeeFromInputs(esploraTransaction, addPrevout);
|
||||
}
|
||||
}
|
||||
|
||||
return esploraTransaction;
|
||||
@@ -202,12 +214,12 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
}
|
||||
let mempoolEntry: IBitcoinApi.MempoolEntry;
|
||||
if (!mempool.isInSync() && !this.rawMempoolCache) {
|
||||
this.rawMempoolCache = await bitcoinBaseApi.$getRawMempoolVerbose();
|
||||
this.rawMempoolCache = await this.$getRawMempoolVerbose();
|
||||
}
|
||||
if (this.rawMempoolCache && this.rawMempoolCache[transaction.txid]) {
|
||||
mempoolEntry = this.rawMempoolCache[transaction.txid];
|
||||
} else {
|
||||
mempoolEntry = await bitcoinBaseApi.$getMempoolEntry(transaction.txid);
|
||||
mempoolEntry = await this.$getMempoolEntry(transaction.txid);
|
||||
}
|
||||
transaction.fee = mempoolEntry.fees.base * 100000000;
|
||||
return transaction;
|
||||
@@ -234,8 +246,12 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
});
|
||||
}
|
||||
|
||||
protected $validateAddress(address: string): Promise<IBitcoinApi.AddressInformation> {
|
||||
return this.bitcoindClient.validateAddress(address);
|
||||
private $getMempoolEntry(txid: string): Promise<IBitcoinApi.MempoolEntry> {
|
||||
return this.bitcoindClient.getMempoolEntry(txid);
|
||||
}
|
||||
|
||||
private $getRawMempoolVerbose(): Promise<IBitcoinApi.RawMempool> {
|
||||
return this.bitcoindClient.getRawMemPool(true);
|
||||
}
|
||||
|
||||
private async $calculateFeeFromInputs(transaction: IEsploraApi.Transaction, addPrevout: boolean): Promise<IEsploraApi.Transaction> {
|
||||
@@ -285,12 +301,21 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
if (vin.prevout.scriptpubkey_type === 'p2sh') {
|
||||
const redeemScript = vin.scriptsig_asm.split(' ').reverse()[0];
|
||||
vin.inner_redeemscript_asm = this.convertScriptSigAsm(bitcoinjs.script.toASM(Buffer.from(redeemScript, 'hex')));
|
||||
if (vin.witness && vin.witness.length > 2) {
|
||||
const witnessScript = vin.witness[vin.witness.length - 1];
|
||||
vin.inner_witnessscript_asm = this.convertScriptSigAsm(bitcoinjs.script.toASM(Buffer.from(witnessScript, 'hex')));
|
||||
}
|
||||
}
|
||||
|
||||
if (vin.prevout.scriptpubkey_type === 'v0_p2wsh' && vin.witness) {
|
||||
const witnessScript = vin.witness[vin.witness.length - 1];
|
||||
vin.inner_witnessscript_asm = this.convertScriptSigAsm(bitcoinjs.script.toASM(Buffer.from(witnessScript, 'hex')));
|
||||
}
|
||||
|
||||
if (vin.prevout.scriptpubkey_type === 'v1_p2tr' && vin.witness && vin.witness.length > 1) {
|
||||
const witnessScript = vin.witness[vin.witness.length - 2];
|
||||
vin.inner_witnessscript_asm = this.convertScriptSigAsm(bitcoinjs.script.toASM(Buffer.from(witnessScript, 'hex')));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import config from '../../config';
|
||||
import * as bitcoin from '@mempool/bitcoin';
|
||||
import { IBitcoinApi } from './bitcoin-api.interface';
|
||||
|
||||
class BitcoinBaseApi {
|
||||
bitcoindClient: any;
|
||||
|
||||
constructor() {
|
||||
this.bitcoindClient = new bitcoin.Client({
|
||||
host: config.CORE_RPC.HOST,
|
||||
port: config.CORE_RPC.PORT,
|
||||
user: config.CORE_RPC.USERNAME,
|
||||
pass: config.CORE_RPC.PASSWORD,
|
||||
timeout: 60000,
|
||||
});
|
||||
}
|
||||
|
||||
$getMempoolInfo(): Promise<IBitcoinApi.MempoolInfo> {
|
||||
return this.bitcoindClient.getMempoolInfo();
|
||||
}
|
||||
|
||||
$getRawTransaction(txId: string): Promise<IBitcoinApi.Transaction> {
|
||||
return this.bitcoindClient.getRawTransaction(txId, true);
|
||||
}
|
||||
|
||||
$getMempoolEntry(txid: string): Promise<IBitcoinApi.MempoolEntry> {
|
||||
return this.bitcoindClient.getMempoolEntry(txid);
|
||||
}
|
||||
|
||||
$getRawMempoolVerbose(): Promise<IBitcoinApi.RawMempool> {
|
||||
return this.bitcoindClient.getRawMemPool(true);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default new BitcoinBaseApi();
|
||||
13
backend/src/api/bitcoin/bitcoin-client.ts
Normal file
13
backend/src/api/bitcoin/bitcoin-client.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import config from '../../config';
|
||||
import * as bitcoin from '@mempool/bitcoin';
|
||||
import { BitcoinRpcCredentials } from './bitcoin-api-abstract-factory';
|
||||
|
||||
const nodeRpcCredentials: BitcoinRpcCredentials = {
|
||||
host: config.CORE_RPC.HOST,
|
||||
port: config.CORE_RPC.PORT,
|
||||
user: config.CORE_RPC.USERNAME,
|
||||
pass: config.CORE_RPC.PASSWORD,
|
||||
timeout: 60000,
|
||||
};
|
||||
|
||||
export default new bitcoin.Client(nodeRpcCredentials);
|
||||
13
backend/src/api/bitcoin/bitcoin-second-client.ts
Normal file
13
backend/src/api/bitcoin/bitcoin-second-client.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import config from '../../config';
|
||||
import * as bitcoin from '@mempool/bitcoin';
|
||||
import { BitcoinRpcCredentials } from './bitcoin-api-abstract-factory';
|
||||
|
||||
const nodeRpcCredentials: BitcoinRpcCredentials = {
|
||||
host: config.SECOND_CORE_RPC.HOST,
|
||||
port: config.SECOND_CORE_RPC.PORT,
|
||||
user: config.SECOND_CORE_RPC.USERNAME,
|
||||
pass: config.SECOND_CORE_RPC.PASSWORD,
|
||||
timeout: 60000,
|
||||
};
|
||||
|
||||
export default new bitcoin.Client(nodeRpcCredentials);
|
||||
@@ -1,10 +1,8 @@
|
||||
import config from '../../config';
|
||||
import { AbstractBitcoinApi } from './bitcoin-api-abstract-factory';
|
||||
import { IBitcoinApi } from './bitcoin-api.interface';
|
||||
import { IEsploraApi } from './esplora-api.interface';
|
||||
import { IElectrumApi } from './electrum-api.interface';
|
||||
import BitcoinApi from './bitcoin-api';
|
||||
import mempool from '../mempool';
|
||||
import logger from '../../logger';
|
||||
import * as ElectrumClient from '@mempool/electrum-client';
|
||||
import * as sha256 from 'crypto-js/sha256';
|
||||
@@ -15,8 +13,8 @@ import memoryCache from '../memory-cache';
|
||||
class BitcoindElectrsApi extends BitcoinApi implements AbstractBitcoinApi {
|
||||
private electrumClient: any;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
constructor(bitcoinClient: any) {
|
||||
super(bitcoinClient);
|
||||
|
||||
const electrumConfig = { client: 'mempool-v2', version: '1.4' };
|
||||
const electrumPersistencePolicy = { retryPeriod: 10000, maxRetry: 1000, callback: null };
|
||||
@@ -43,27 +41,8 @@ class BitcoindElectrsApi extends BitcoinApi implements AbstractBitcoinApi {
|
||||
});
|
||||
}
|
||||
|
||||
async $getRawTransaction(txId: string, skipConversion = false, addPrevout = false): Promise<IEsploraApi.Transaction> {
|
||||
if (!config.ELECTRUM.TX_LOOKUPS) {
|
||||
return super.$getRawTransaction(txId, skipConversion, addPrevout);
|
||||
}
|
||||
const txInMempool = mempool.getMempool()[txId];
|
||||
if (txInMempool && addPrevout) {
|
||||
return this.$addPrevouts(txInMempool);
|
||||
}
|
||||
const transaction: IBitcoinApi.Transaction = await this.electrumClient.blockchainTransaction_get(txId, true);
|
||||
if (!transaction) {
|
||||
throw new Error('Unable to get transaction: ' + txId);
|
||||
}
|
||||
if (skipConversion) {
|
||||
// @ts-ignore
|
||||
return transaction;
|
||||
}
|
||||
return this.$convertTransaction(transaction, addPrevout);
|
||||
}
|
||||
|
||||
async $getAddress(address: string): Promise<IEsploraApi.Address> {
|
||||
const addressInfo = await this.$validateAddress(address);
|
||||
const addressInfo = await this.bitcoindClient.validateAddress(address);
|
||||
if (!addressInfo || !addressInfo.isvalid) {
|
||||
return ({
|
||||
'address': address,
|
||||
@@ -105,18 +84,16 @@ class BitcoindElectrsApi extends BitcoinApi implements AbstractBitcoinApi {
|
||||
'spent_txo_count': 0,
|
||||
'spent_txo_sum': balance.unconfirmed < 0 ? -balance.unconfirmed : 0,
|
||||
'tx_count': unconfirmed,
|
||||
}
|
||||
},
|
||||
'electrum': true,
|
||||
};
|
||||
} catch (e) {
|
||||
if (e === 'failed to get confirmed status') {
|
||||
e = 'The number of transactions on this address exceeds the Electrum server limit';
|
||||
}
|
||||
throw new Error(e);
|
||||
} catch (e: any) {
|
||||
throw new Error(typeof e === 'string' ? e : e && e.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
async $getAddressTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]> {
|
||||
const addressInfo = await this.$validateAddress(address);
|
||||
const addressInfo = await this.bitcoindClient.validateAddress(address);
|
||||
if (!addressInfo || !addressInfo.isvalid) {
|
||||
return [];
|
||||
}
|
||||
@@ -144,12 +121,9 @@ class BitcoindElectrsApi extends BitcoinApi implements AbstractBitcoinApi {
|
||||
}
|
||||
|
||||
return transactions;
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
loadingIndicators.setProgress('address-' + address, 100);
|
||||
if (e === 'failed to get confirmed status') {
|
||||
e = 'The number of transactions on this address exceeds the Electrum server limit';
|
||||
}
|
||||
throw new Error(e);
|
||||
throw new Error(typeof e === 'string' ? e : e && e.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export namespace IEsploraApi {
|
||||
vin: Vin[];
|
||||
vout: Vout[];
|
||||
status: Status;
|
||||
hex?: string;
|
||||
}
|
||||
|
||||
export interface Recent {
|
||||
@@ -91,6 +92,7 @@ export namespace IEsploraApi {
|
||||
address: string;
|
||||
chain_stats: ChainStats;
|
||||
mempool_stats: MempoolStats;
|
||||
electrum?: boolean;
|
||||
}
|
||||
|
||||
export interface ChainStats {
|
||||
@@ -111,9 +113,9 @@ export namespace IEsploraApi {
|
||||
|
||||
export interface Outspend {
|
||||
spent: boolean;
|
||||
txid: string;
|
||||
vin: number;
|
||||
status: Status;
|
||||
txid?: string;
|
||||
vin?: number;
|
||||
status?: Status;
|
||||
}
|
||||
|
||||
export interface Asset {
|
||||
|
||||
@@ -1,39 +1,47 @@
|
||||
import config from '../../config';
|
||||
import axios from 'axios';
|
||||
import axios, { AxiosRequestConfig } from 'axios';
|
||||
import { AbstractBitcoinApi } from './bitcoin-api-abstract-factory';
|
||||
import { IEsploraApi } from './esplora-api.interface';
|
||||
|
||||
class ElectrsApi implements AbstractBitcoinApi {
|
||||
axiosConfig: AxiosRequestConfig = {
|
||||
timeout: 10000,
|
||||
};
|
||||
|
||||
constructor() { }
|
||||
|
||||
$getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]> {
|
||||
return axios.get<IEsploraApi.Transaction['txid'][]>(config.ESPLORA.REST_API_URL + '/mempool/txids')
|
||||
return axios.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)
|
||||
return axios.get<IEsploraApi.Transaction>(config.ESPLORA.REST_API_URL + '/tx/' + txId, this.axiosConfig)
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
$getBlockHeightTip(): Promise<number> {
|
||||
return axios.get<number>(config.ESPLORA.REST_API_URL + '/blocks/tip/height')
|
||||
return axios.get<number>(config.ESPLORA.REST_API_URL + '/blocks/tip/height', this.axiosConfig)
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
$getTxIdsForBlock(hash: string): Promise<string[]> {
|
||||
return axios.get<string[]>(config.ESPLORA.REST_API_URL + '/block/' + hash + '/txids')
|
||||
return axios.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)
|
||||
return axios.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)
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
$getBlock(hash: string): Promise<IEsploraApi.Block> {
|
||||
return axios.get<IEsploraApi.Block>(config.ESPLORA.REST_API_URL + '/block/' + hash)
|
||||
return axios.get<IEsploraApi.Block>(config.ESPLORA.REST_API_URL + '/block/' + hash, this.axiosConfig)
|
||||
.then((response) => response.data);
|
||||
}
|
||||
|
||||
@@ -45,12 +53,15 @@ class ElectrsApi implements AbstractBitcoinApi {
|
||||
throw new Error('Method getAddressTransactions not implemented.');
|
||||
}
|
||||
|
||||
$getRawTransactionBitcoind(txId: string): Promise<IEsploraApi.Transaction> {
|
||||
return axios.get<IEsploraApi.Transaction>(config.ESPLORA.REST_API_URL + '/tx/' + txId)
|
||||
.then((response) => response.data);
|
||||
$getAddressPrefix(prefix: string): string[] {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
$getAddressPrefix(prefix: string): string[] {
|
||||
$sendRawTransaction(rawTransaction: string): Promise<string> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
$getOutspends(): Promise<IEsploraApi.Outspend[]> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,14 @@ import { BlockExtended, TransactionExtended } from '../mempool.interfaces';
|
||||
import { Common } from './common';
|
||||
import diskCache from './disk-cache';
|
||||
import transactionUtils from './transaction-utils';
|
||||
import bitcoinClient from './bitcoin/bitcoin-client';
|
||||
|
||||
class Blocks {
|
||||
private static INITIAL_BLOCK_AMOUNT = 8;
|
||||
private blocks: BlockExtended[] = [];
|
||||
private currentBlockHeight = 0;
|
||||
private currentDifficulty = 0;
|
||||
private lastDifficultyAdjustmentTime = 0;
|
||||
private previousDifficultyRetarget = 0;
|
||||
private newBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void)[] = [];
|
||||
|
||||
constructor() { }
|
||||
@@ -32,21 +34,32 @@ class Blocks {
|
||||
const blockHeightTip = await bitcoinApi.$getBlockHeightTip();
|
||||
|
||||
if (this.blocks.length === 0) {
|
||||
this.currentBlockHeight = blockHeightTip - Blocks.INITIAL_BLOCK_AMOUNT;
|
||||
this.currentBlockHeight = blockHeightTip - config.MEMPOOL.INITIAL_BLOCKS_AMOUNT;
|
||||
} else {
|
||||
this.currentBlockHeight = this.blocks[this.blocks.length - 1].height;
|
||||
}
|
||||
|
||||
if (blockHeightTip - this.currentBlockHeight > Blocks.INITIAL_BLOCK_AMOUNT * 2) {
|
||||
logger.info(`${blockHeightTip - this.currentBlockHeight} blocks since tip. Fast forwarding to the ${Blocks.INITIAL_BLOCK_AMOUNT} recent blocks`);
|
||||
this.currentBlockHeight = blockHeightTip - Blocks.INITIAL_BLOCK_AMOUNT;
|
||||
if (blockHeightTip - this.currentBlockHeight > config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 2) {
|
||||
logger.info(`${blockHeightTip - this.currentBlockHeight} blocks since tip. Fast forwarding to the ${config.MEMPOOL.INITIAL_BLOCKS_AMOUNT} recent blocks`);
|
||||
this.currentBlockHeight = blockHeightTip - config.MEMPOOL.INITIAL_BLOCKS_AMOUNT;
|
||||
}
|
||||
|
||||
if (!this.lastDifficultyAdjustmentTime) {
|
||||
const heightDiff = blockHeightTip % 2016;
|
||||
const blockHash = await bitcoinApi.$getBlockHash(blockHeightTip - heightDiff);
|
||||
const block = await bitcoinApi.$getBlock(blockHash);
|
||||
this.lastDifficultyAdjustmentTime = block.timestamp;
|
||||
const blockchainInfo = await bitcoinClient.getBlockchainInfo();
|
||||
if (blockchainInfo.blocks === blockchainInfo.headers) {
|
||||
const heightDiff = blockHeightTip % 2016;
|
||||
const blockHash = await bitcoinApi.$getBlockHash(blockHeightTip - heightDiff);
|
||||
const block = await bitcoinApi.$getBlock(blockHash);
|
||||
this.lastDifficultyAdjustmentTime = block.timestamp;
|
||||
this.currentDifficulty = block.difficulty;
|
||||
|
||||
const previousPeriodBlockHash = await bitcoinApi.$getBlockHash(blockHeightTip - heightDiff - 2016);
|
||||
const previousPeriodBlock = await bitcoinApi.$getBlock(previousPeriodBlockHash);
|
||||
this.previousDifficultyRetarget = (block.difficulty - previousPeriodBlock.difficulty) / previousPeriodBlock.difficulty * 100;
|
||||
logger.debug(`Initial difficulty adjustment data set.`);
|
||||
} else {
|
||||
logger.debug(`Blockchain headers (${blockchainInfo.headers}) and blocks (${blockchainInfo.blocks}) not in sync. Waiting...`);
|
||||
}
|
||||
}
|
||||
|
||||
while (this.currentBlockHeight < blockHeightTip) {
|
||||
@@ -67,56 +80,56 @@ class Blocks {
|
||||
let transactionsFound = 0;
|
||||
|
||||
for (let i = 0; i < txIds.length; i++) {
|
||||
// When using bitcoind, just fetch the coinbase tx for now
|
||||
if (config.MEMPOOL.BACKEND !== 'esplora' && i === 0) {
|
||||
let txFound = false;
|
||||
let findCoinbaseTxTries = 0;
|
||||
// It takes Electrum Server a few seconds to index the transaction after a block is found
|
||||
while (findCoinbaseTxTries < 5 && !txFound) {
|
||||
const tx = await transactionUtils.$getTransactionExtended(txIds[i]);
|
||||
if (tx) {
|
||||
txFound = true;
|
||||
transactions.push(tx);
|
||||
} else {
|
||||
await Common.sleep(1000);
|
||||
findCoinbaseTxTries++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mempool[txIds[i]]) {
|
||||
transactions.push(mempool[txIds[i]]);
|
||||
transactionsFound++;
|
||||
} else if (config.MEMPOOL.BACKEND === 'esplora') {
|
||||
} else if (config.MEMPOOL.BACKEND === 'esplora' || memPool.isInSync() || i === 0) {
|
||||
logger.debug(`Fetching block tx ${i} of ${txIds.length}`);
|
||||
const tx = await transactionUtils.$getTransactionExtended(txIds[i]);
|
||||
if (tx) {
|
||||
try {
|
||||
const tx = await transactionUtils.$getTransactionExtended(txIds[i]);
|
||||
transactions.push(tx);
|
||||
} catch (e) {
|
||||
logger.debug('Error fetching block tx: ' + (e instanceof Error ? e.message : e));
|
||||
if (i === 0) {
|
||||
throw new Error('Failed to fetch Coinbase transaction: ' + txIds[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
transactions.forEach((tx) => {
|
||||
if (!tx.cpfpChecked) {
|
||||
Common.setRelativesAndGetCpfpInfo(tx, mempool);
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug(`${transactionsFound} of ${txIds.length} found in mempool. ${txIds.length - transactionsFound} not found.`);
|
||||
|
||||
const blockExtended: BlockExtended = Object.assign({}, block);
|
||||
blockExtended.reward = transactions[0].vout.reduce((acc, curr) => acc + curr.value, 0);
|
||||
blockExtended.coinbaseTx = transactionUtils.stripCoinbaseTransaction(transactions[0]);
|
||||
transactions.sort((a, b) => b.feePerVsize - a.feePerVsize);
|
||||
blockExtended.medianFee = transactions.length > 1 ? Common.median(transactions.map((tx) => tx.feePerVsize)) : 0;
|
||||
blockExtended.feeRange = transactions.length > 1 ? Common.getFeesInRange(transactions.slice(0, transactions.length - 1), 8) : [0, 0];
|
||||
transactions.shift();
|
||||
transactions.sort((a, b) => b.effectiveFeePerVsize - a.effectiveFeePerVsize);
|
||||
blockExtended.medianFee = transactions.length > 0 ? Common.median(transactions.map((tx) => tx.effectiveFeePerVsize)) : 0;
|
||||
blockExtended.feeRange = transactions.length > 0 ? Common.getFeesInRange(transactions, 8) : [0, 0];
|
||||
|
||||
if (block.height % 2016 === 0) {
|
||||
this.previousDifficultyRetarget = (block.difficulty - this.currentDifficulty) / this.currentDifficulty * 100;
|
||||
this.lastDifficultyAdjustmentTime = block.timestamp;
|
||||
this.currentDifficulty = block.difficulty;
|
||||
}
|
||||
|
||||
this.blocks.push(blockExtended);
|
||||
if (this.blocks.length > Blocks.INITIAL_BLOCK_AMOUNT * 4) {
|
||||
this.blocks = this.blocks.slice(-Blocks.INITIAL_BLOCK_AMOUNT * 4);
|
||||
if (this.blocks.length > config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4) {
|
||||
this.blocks = this.blocks.slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4);
|
||||
}
|
||||
|
||||
if (this.newBlockCallbacks.length) {
|
||||
this.newBlockCallbacks.forEach((cb) => cb(blockExtended, txIds, transactions));
|
||||
}
|
||||
diskCache.$saveCacheToDisk();
|
||||
if (memPool.isInSync()) {
|
||||
diskCache.$saveCacheToDisk();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +137,10 @@ class Blocks {
|
||||
return this.lastDifficultyAdjustmentTime;
|
||||
}
|
||||
|
||||
public getPreviousDifficultyRetarget(): number {
|
||||
return this.previousDifficultyRetarget;
|
||||
}
|
||||
|
||||
public getCurrentBlockHeight(): number {
|
||||
return this.currentBlockHeight;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { TransactionExtended, TransactionStripped } from '../mempool.interfaces';
|
||||
|
||||
import { CpfpInfo, TransactionExtended, TransactionStripped } from '../mempool.interfaces';
|
||||
import config from '../config';
|
||||
export class Common {
|
||||
static nativeAssetId = config.MEMPOOL.NETWORK === 'liquidtestnet' ?
|
||||
'144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49'
|
||||
: '6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d';
|
||||
static _isLiquid = config.MEMPOOL.NETWORK === 'liquid' || config.MEMPOOL.NETWORK === 'liquidtestnet';
|
||||
|
||||
static isLiquid(): boolean {
|
||||
return this._isLiquid;
|
||||
}
|
||||
|
||||
static median(numbers: number[]) {
|
||||
let medianNr = 0;
|
||||
const numsLen = numbers.length;
|
||||
@@ -12,17 +21,28 @@ export class Common {
|
||||
return medianNr;
|
||||
}
|
||||
|
||||
static percentile(numbers: number[], percentile: number) {
|
||||
if (percentile === 50) {
|
||||
return this.median(numbers);
|
||||
}
|
||||
const index = Math.ceil(numbers.length * (100 - percentile) * 1e-2);
|
||||
if (index < 0 || index > numbers.length - 1) {
|
||||
return 0;
|
||||
}
|
||||
return numbers[index];
|
||||
}
|
||||
|
||||
static getFeesInRange(transactions: TransactionExtended[], rangeLength: number) {
|
||||
const arr = [transactions[transactions.length - 1].feePerVsize];
|
||||
const arr = [transactions[transactions.length - 1].effectiveFeePerVsize];
|
||||
const chunk = 1 / (rangeLength - 1);
|
||||
let itemsToAdd = rangeLength - 2;
|
||||
|
||||
while (itemsToAdd > 0) {
|
||||
arr.push(transactions[Math.floor(transactions.length * chunk * itemsToAdd)].feePerVsize);
|
||||
arr.push(transactions[Math.floor(transactions.length * chunk * itemsToAdd)].effectiveFeePerVsize);
|
||||
itemsToAdd--;
|
||||
}
|
||||
|
||||
arr.push(transactions[0].feePerVsize);
|
||||
arr.push(transactions[0].effectiveFeePerVsize);
|
||||
return arr;
|
||||
}
|
||||
|
||||
@@ -52,7 +72,7 @@ export class Common {
|
||||
return {
|
||||
txid: tx.txid,
|
||||
fee: tx.fee,
|
||||
weight: tx.weight,
|
||||
vsize: tx.weight / 4,
|
||||
value: tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0),
|
||||
};
|
||||
}
|
||||
@@ -64,4 +84,74 @@ export class Common {
|
||||
}, ms);
|
||||
});
|
||||
}
|
||||
|
||||
static shuffleArray(array: any[]) {
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[array[i], array[j]] = [array[j], array[i]];
|
||||
}
|
||||
}
|
||||
|
||||
static setRelativesAndGetCpfpInfo(tx: TransactionExtended, memPool: { [txid: string]: TransactionExtended }): CpfpInfo {
|
||||
const parents = this.findAllParents(tx, memPool);
|
||||
const lowerFeeParents = parents.filter((parent) => parent.feePerVsize < tx.effectiveFeePerVsize);
|
||||
|
||||
let totalWeight = tx.weight + lowerFeeParents.reduce((prev, val) => prev + val.weight, 0);
|
||||
let totalFees = tx.fee + lowerFeeParents.reduce((prev, val) => prev + val.fee, 0);
|
||||
|
||||
tx.ancestors = parents
|
||||
.map((t) => {
|
||||
return {
|
||||
txid: t.txid,
|
||||
weight: t.weight,
|
||||
fee: t.fee,
|
||||
};
|
||||
});
|
||||
|
||||
// Add high (high fee) decendant weight and fees
|
||||
if (tx.bestDescendant) {
|
||||
totalWeight += tx.bestDescendant.weight;
|
||||
totalFees += tx.bestDescendant.fee;
|
||||
}
|
||||
|
||||
tx.effectiveFeePerVsize = Math.max(Common.isLiquid() ? 0.1 : 1, totalFees / (totalWeight / 4));
|
||||
tx.cpfpChecked = true;
|
||||
|
||||
return {
|
||||
ancestors: tx.ancestors,
|
||||
bestDescendant: tx.bestDescendant || null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
private static findAllParents(tx: TransactionExtended, memPool: { [txid: string]: TransactionExtended }): TransactionExtended[] {
|
||||
let parents: TransactionExtended[] = [];
|
||||
tx.vin.forEach((parent) => {
|
||||
if (parents.find((p) => p.txid === parent.txid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentTx = memPool[parent.txid];
|
||||
if (parentTx) {
|
||||
if (tx.bestDescendant && tx.bestDescendant.fee / (tx.bestDescendant.weight / 4) > parentTx.feePerVsize) {
|
||||
if (parentTx.bestDescendant && parentTx.bestDescendant.fee < tx.fee + tx.bestDescendant.fee) {
|
||||
parentTx.bestDescendant = {
|
||||
weight: tx.weight + tx.bestDescendant.weight,
|
||||
fee: tx.fee + tx.bestDescendant.fee,
|
||||
txid: tx.txid,
|
||||
};
|
||||
}
|
||||
} else if (tx.feePerVsize > parentTx.feePerVsize) {
|
||||
parentTx.bestDescendant = {
|
||||
weight: tx.weight,
|
||||
fee: tx.fee,
|
||||
txid: tx.txid
|
||||
};
|
||||
}
|
||||
parents.push(parentTx);
|
||||
parents = parents.concat(this.findAllParents(parentTx, memPool));
|
||||
}
|
||||
});
|
||||
return parents;
|
||||
}
|
||||
}
|
||||
|
||||
339
backend/src/api/database-migration.ts
Normal file
339
backend/src/api/database-migration.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import { PoolConnection } from 'mysql2/promise';
|
||||
import config from '../config';
|
||||
import { DB } from '../database';
|
||||
import logger from '../logger';
|
||||
|
||||
const sleep = (ms: number) => new Promise( res => setTimeout(res, ms));
|
||||
|
||||
class DatabaseMigration {
|
||||
private static currentVersion = 2;
|
||||
private queryTimeout = 120000;
|
||||
private statisticsAddedIndexed = false;
|
||||
|
||||
constructor() { }
|
||||
/**
|
||||
* Entry point
|
||||
*/
|
||||
public async $initializeOrMigrateDatabase(): Promise<void> {
|
||||
logger.info('MIGRATIONS: Running migrations');
|
||||
|
||||
await this.$printDatabaseVersion();
|
||||
|
||||
// First of all, if the `state` database does not exist, create it so we can track migration version
|
||||
if (!await this.$checkIfTableExists('state')) {
|
||||
logger.info('MIGRATIONS: `state` table does not exist. Creating it.');
|
||||
try {
|
||||
await this.$createMigrationStateTable();
|
||||
} catch (e) {
|
||||
logger.err('MIGRATIONS: Unable to create `state` table, aborting in 10 seconds. ' + e);
|
||||
await sleep(10000);
|
||||
process.exit(-1);
|
||||
}
|
||||
logger.info('MIGRATIONS: `state` table initialized.');
|
||||
}
|
||||
|
||||
let databaseSchemaVersion = 0;
|
||||
try {
|
||||
databaseSchemaVersion = await this.$getSchemaVersionFromDatabase();
|
||||
} catch (e) {
|
||||
logger.err('MIGRATIONS: Unable to get current database migration version, aborting in 10 seconds. ' + e);
|
||||
await sleep(10000);
|
||||
process.exit(-1);
|
||||
}
|
||||
|
||||
logger.info('MIGRATIONS: Current state.schema_version ' + databaseSchemaVersion);
|
||||
logger.info('MIGRATIONS: Latest DatabaseMigration.version is ' + DatabaseMigration.currentVersion);
|
||||
if (databaseSchemaVersion >= DatabaseMigration.currentVersion) {
|
||||
logger.info('MIGRATIONS: Nothing to do.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Now, create missing tables. Those queries cannot be wrapped into a transaction unfortunately
|
||||
try {
|
||||
await this.$createMissingTablesAndIndexes(databaseSchemaVersion);
|
||||
} catch (e) {
|
||||
logger.err('MIGRATIONS: Unable to create required tables, aborting in 10 seconds. ' + e);
|
||||
await sleep(10000);
|
||||
process.exit(-1);
|
||||
}
|
||||
|
||||
if (DatabaseMigration.currentVersion > databaseSchemaVersion) {
|
||||
logger.info('MIGRATIONS: Upgrading datababse schema');
|
||||
try {
|
||||
await this.$migrateTableSchemaFromVersion(databaseSchemaVersion);
|
||||
logger.info(`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);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create all missing tables
|
||||
*/
|
||||
private async $createMissingTablesAndIndexes(databaseSchemaVersion: number) {
|
||||
await this.$setStatisticsAddedIndexedFlag(databaseSchemaVersion);
|
||||
|
||||
const connection = await DB.pool.getConnection();
|
||||
try {
|
||||
await this.$executeQuery(connection, this.getCreateElementsTableQuery(), await this.$checkIfTableExists('elements_pegs'));
|
||||
await this.$executeQuery(connection, this.getCreateStatisticsQuery(), await this.$checkIfTableExists('statistics'));
|
||||
if (databaseSchemaVersion < 2 && this.statisticsAddedIndexed === false) {
|
||||
await this.$executeQuery(connection, `CREATE INDEX added ON statistics (added);`);
|
||||
}
|
||||
connection.release();
|
||||
} catch (e) {
|
||||
connection.release();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Special case here for the `statistics` table - It appeared that somehow some dbs already had the `added` field indexed
|
||||
* while it does not appear in previous schemas. The mariadb command "CREATE INDEX IF NOT EXISTS" is not supported on
|
||||
* older mariadb version. Therefore we set a flag here in order to know if the index needs to be created or not before
|
||||
* running the migration process
|
||||
*/
|
||||
private async $setStatisticsAddedIndexedFlag(databaseSchemaVersion: number) {
|
||||
if (databaseSchemaVersion >= 2) {
|
||||
this.statisticsAddedIndexed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = await DB.pool.getConnection();
|
||||
|
||||
try {
|
||||
// We don't use "CREATE INDEX IF NOT EXISTS" because it is not supported on old mariadb version 5.X
|
||||
const query = `SELECT COUNT(1) hasIndex FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE table_schema=DATABASE() AND table_name='statistics' AND index_name='added';`;
|
||||
const [rows] = await this.$executeQuery(connection, query, true);
|
||||
if (rows[0].hasIndex === 0) {
|
||||
logger.info('MIGRATIONS: `statistics.added` is not indexed');
|
||||
this.statisticsAddedIndexed = false;
|
||||
} else if (rows[0].hasIndex === 1) {
|
||||
logger.info('MIGRATIONS: `statistics.added` is already indexed');
|
||||
this.statisticsAddedIndexed = true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Should really never happen but just in case it fails, we just don't execute
|
||||
// any query related to this indexing so it won't fail if the index actually already exists
|
||||
logger.err('MIGRATIONS: Unable to check if `statistics.added` INDEX exist or not.');
|
||||
this.statisticsAddedIndexed = true;
|
||||
}
|
||||
|
||||
connection.release();
|
||||
}
|
||||
|
||||
/**
|
||||
* Small query execution wrapper to log all executed queries
|
||||
*/
|
||||
private async $executeQuery(connection: PoolConnection, query: string, silent: boolean = false): Promise<any> {
|
||||
if (!silent) {
|
||||
logger.info('MIGRATIONS: Execute query:\n' + query);
|
||||
}
|
||||
return connection.query<any>({ sql: query, timeout: this.queryTimeout });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if 'table' exists in the database
|
||||
*/
|
||||
private async $checkIfTableExists(table: string): Promise<boolean> {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = `SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '${config.DATABASE.DATABASE}' AND TABLE_NAME = '${table}'`;
|
||||
const [rows] = await connection.query<any>({ sql: query, timeout: this.queryTimeout });
|
||||
connection.release();
|
||||
return rows[0]['COUNT(*)'] === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current database version
|
||||
*/
|
||||
private async $getSchemaVersionFromDatabase(): Promise<number> {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = `SELECT number FROM state WHERE name = 'schema_version';`;
|
||||
const [rows] = await this.$executeQuery(connection, query, true);
|
||||
connection.release();
|
||||
return rows[0]['number'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the `state` table
|
||||
*/
|
||||
private async $createMigrationStateTable(): Promise<void> {
|
||||
const connection = await DB.pool.getConnection();
|
||||
|
||||
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(connection, query);
|
||||
|
||||
// Set initial values
|
||||
await this.$executeQuery(connection, `INSERT INTO state VALUES('schema_version', 0, NULL);`);
|
||||
await this.$executeQuery(connection, `INSERT INTO state VALUES('last_elements_block', 0, NULL);`);
|
||||
|
||||
connection.release();
|
||||
} catch (e) {
|
||||
connection.release();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* We actually execute the migrations queries here
|
||||
*/
|
||||
private async $migrateTableSchemaFromVersion(version: number): Promise<void> {
|
||||
const transactionQueries: string[] = [];
|
||||
for (const query of this.getMigrationQueriesFromVersion(version)) {
|
||||
transactionQueries.push(query);
|
||||
}
|
||||
transactionQueries.push(this.getUpdateToLatestSchemaVersionQuery());
|
||||
|
||||
const connection = await DB.pool.getConnection();
|
||||
try {
|
||||
await this.$executeQuery(connection, 'START TRANSACTION;');
|
||||
for (const query of transactionQueries) {
|
||||
await this.$executeQuery(connection, query);
|
||||
}
|
||||
await this.$executeQuery(connection, 'COMMIT;');
|
||||
|
||||
connection.release();
|
||||
} catch (e) {
|
||||
await this.$executeQuery(connection, 'ROLLBACK;');
|
||||
connection.release();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate migration queries based on schema version
|
||||
*/
|
||||
private getMigrationQueriesFromVersion(version: number): string[] {
|
||||
const queries: string[] = [];
|
||||
|
||||
if (version < 1) {
|
||||
if (config.MEMPOOL.NETWORK !== 'liquid' && config.MEMPOOL.NETWORK !== 'liquidtestnet') {
|
||||
queries.push(this.getShiftStatisticsQuery());
|
||||
}
|
||||
}
|
||||
|
||||
return queries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the schema version in the database
|
||||
*/
|
||||
private getUpdateToLatestSchemaVersionQuery(): string {
|
||||
return `UPDATE state SET number = ${DatabaseMigration.currentVersion} WHERE name = 'schema_version';`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print current database version
|
||||
*/
|
||||
private async $printDatabaseVersion() {
|
||||
const connection = await DB.pool.getConnection();
|
||||
try {
|
||||
const [rows] = await this.$executeQuery(connection, 'SELECT VERSION() as version;', true);
|
||||
logger.info(`MIGRATIONS: Database engine version '${rows[0].version}'`);
|
||||
} catch (e) {
|
||||
logger.info(`MIGRATIONS: Could not fetch database engine version. ` + e);
|
||||
}
|
||||
connection.release();
|
||||
}
|
||||
|
||||
// Couple of wrappers to clean the main logic
|
||||
private getShiftStatisticsQuery(): string {
|
||||
return `UPDATE statistics SET
|
||||
vsize_1 = vsize_1 + vsize_2, vsize_2 = vsize_3,
|
||||
vsize_3 = vsize_4, vsize_4 = vsize_5,
|
||||
vsize_5 = vsize_6, vsize_6 = vsize_8,
|
||||
vsize_8 = vsize_10, vsize_10 = vsize_12,
|
||||
vsize_12 = vsize_15, vsize_15 = vsize_20,
|
||||
vsize_20 = vsize_30, vsize_30 = vsize_40,
|
||||
vsize_40 = vsize_50, vsize_50 = vsize_60,
|
||||
vsize_60 = vsize_70, vsize_70 = vsize_80,
|
||||
vsize_80 = vsize_90, vsize_90 = vsize_100,
|
||||
vsize_100 = vsize_125, vsize_125 = vsize_150,
|
||||
vsize_150 = vsize_175, vsize_175 = vsize_200,
|
||||
vsize_200 = vsize_250, vsize_250 = vsize_300,
|
||||
vsize_300 = vsize_350, vsize_350 = vsize_400,
|
||||
vsize_400 = vsize_500, vsize_500 = vsize_600,
|
||||
vsize_600 = vsize_700, vsize_700 = vsize_800,
|
||||
vsize_800 = vsize_900, vsize_900 = vsize_1000,
|
||||
vsize_1000 = vsize_1200, vsize_1200 = vsize_1400,
|
||||
vsize_1400 = vsize_1800, vsize_1800 = vsize_2000, vsize_2000 = 0;`;
|
||||
}
|
||||
|
||||
private getCreateStatisticsQuery(): string {
|
||||
return `CREATE TABLE IF NOT EXISTS statistics (
|
||||
id int(11) NOT NULL AUTO_INCREMENT,
|
||||
added datetime NOT NULL,
|
||||
unconfirmed_transactions int(11) UNSIGNED NOT NULL,
|
||||
tx_per_second float UNSIGNED NOT NULL,
|
||||
vbytes_per_second int(10) UNSIGNED NOT NULL,
|
||||
mempool_byte_weight int(10) UNSIGNED NOT NULL,
|
||||
fee_data longtext NOT NULL,
|
||||
total_fee double UNSIGNED NOT NULL,
|
||||
vsize_1 int(11) NOT NULL,
|
||||
vsize_2 int(11) NOT NULL,
|
||||
vsize_3 int(11) NOT NULL,
|
||||
vsize_4 int(11) NOT NULL,
|
||||
vsize_5 int(11) NOT NULL,
|
||||
vsize_6 int(11) NOT NULL,
|
||||
vsize_8 int(11) NOT NULL,
|
||||
vsize_10 int(11) NOT NULL,
|
||||
vsize_12 int(11) NOT NULL,
|
||||
vsize_15 int(11) NOT NULL,
|
||||
vsize_20 int(11) NOT NULL,
|
||||
vsize_30 int(11) NOT NULL,
|
||||
vsize_40 int(11) NOT NULL,
|
||||
vsize_50 int(11) NOT NULL,
|
||||
vsize_60 int(11) NOT NULL,
|
||||
vsize_70 int(11) NOT NULL,
|
||||
vsize_80 int(11) NOT NULL,
|
||||
vsize_90 int(11) NOT NULL,
|
||||
vsize_100 int(11) NOT NULL,
|
||||
vsize_125 int(11) NOT NULL,
|
||||
vsize_150 int(11) NOT NULL,
|
||||
vsize_175 int(11) NOT NULL,
|
||||
vsize_200 int(11) NOT NULL,
|
||||
vsize_250 int(11) NOT NULL,
|
||||
vsize_300 int(11) NOT NULL,
|
||||
vsize_350 int(11) NOT NULL,
|
||||
vsize_400 int(11) NOT NULL,
|
||||
vsize_500 int(11) NOT NULL,
|
||||
vsize_600 int(11) NOT NULL,
|
||||
vsize_700 int(11) NOT NULL,
|
||||
vsize_800 int(11) NOT NULL,
|
||||
vsize_900 int(11) NOT NULL,
|
||||
vsize_1000 int(11) NOT NULL,
|
||||
vsize_1200 int(11) NOT NULL,
|
||||
vsize_1400 int(11) NOT NULL,
|
||||
vsize_1600 int(11) NOT NULL,
|
||||
vsize_1800 int(11) NOT NULL,
|
||||
vsize_2000 int(11) NOT NULL,
|
||||
CONSTRAINT PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||
}
|
||||
|
||||
private getCreateElementsTableQuery(): string {
|
||||
return `CREATE TABLE IF NOT EXISTS elements_pegs (
|
||||
block int(11) NOT NULL,
|
||||
datetime int(11) NOT NULL,
|
||||
amount bigint(20) NOT NULL,
|
||||
txid varchar(65) NOT NULL,
|
||||
txindex int(11) NOT NULL,
|
||||
bitcoinaddress varchar(100) NOT NULL,
|
||||
bitcointxid varchar(65) NOT NULL,
|
||||
bitcoinindex int(11) NOT NULL,
|
||||
final_tx int(11) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;`;
|
||||
}
|
||||
}
|
||||
|
||||
export default new DatabaseMigration();
|
||||
@@ -1,65 +1,59 @@
|
||||
import * as fs from 'fs';
|
||||
const fsPromises = fs.promises;
|
||||
import * as process from 'process';
|
||||
import * as cluster from 'cluster';
|
||||
import memPool from './mempool';
|
||||
import blocks from './blocks';
|
||||
import logger from '../logger';
|
||||
import config from '../config';
|
||||
import { TransactionExtended } from '../mempool.interfaces';
|
||||
import { Common } from './common';
|
||||
|
||||
class DiskCache {
|
||||
private static FILE_NAME = './cache.json';
|
||||
private static FILE_NAME_2 = './cache2.json';
|
||||
private static CHUNK_SIZE = 50000;
|
||||
constructor() {
|
||||
if (!cluster.isMaster) {
|
||||
return;
|
||||
}
|
||||
process.on('SIGINT', () => {
|
||||
this.saveCacheToDiskSync();
|
||||
process.exit(2);
|
||||
});
|
||||
process.on('SIGTERM', () => {
|
||||
this.saveCacheToDiskSync();
|
||||
process.exit(2);
|
||||
});
|
||||
}
|
||||
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() { }
|
||||
|
||||
async $saveCacheToDisk(): Promise<void> {
|
||||
if (!cluster.isMaster) {
|
||||
return;
|
||||
}
|
||||
if (this.isWritingCache) {
|
||||
logger.debug('Saving cache already in progress. Skipping.')
|
||||
return;
|
||||
}
|
||||
try {
|
||||
logger.debug('Writing mempool and blocks data to disk cache (async)...');
|
||||
const mempoolChunk_1 = Object.fromEntries(Object.entries(memPool.getMempool()).splice(0, DiskCache.CHUNK_SIZE));
|
||||
const mempoolChunk_2 = Object.fromEntries(Object.entries(memPool.getMempool()).splice(DiskCache.CHUNK_SIZE));
|
||||
this.isWritingCache = true;
|
||||
|
||||
const mempool = memPool.getMempool();
|
||||
const mempoolArray: TransactionExtended[] = [];
|
||||
for (const tx in mempool) {
|
||||
mempoolArray.push(mempool[tx]);
|
||||
}
|
||||
|
||||
Common.shuffleArray(mempoolArray);
|
||||
|
||||
const chunkSize = Math.floor(mempoolArray.length / DiskCache.CHUNK_FILES);
|
||||
|
||||
await fsPromises.writeFile(DiskCache.FILE_NAME, JSON.stringify({
|
||||
blocks: blocks.getBlocks(),
|
||||
mempool: mempoolChunk_1
|
||||
}), {flag: 'w'});
|
||||
await fsPromises.writeFile(DiskCache.FILE_NAME_2, JSON.stringify({
|
||||
mempool: mempoolChunk_2
|
||||
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({
|
||||
mempool: {},
|
||||
mempoolArray: mempoolArray.splice(0, chunkSize),
|
||||
}), {flag: 'w'});
|
||||
}
|
||||
logger.debug('Mempool and blocks data saved to disk cache');
|
||||
this.isWritingCache = false;
|
||||
} catch (e) {
|
||||
logger.warn('Error writing to cache file: ' + e.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
saveCacheToDiskSync(): void {
|
||||
try {
|
||||
logger.debug('Writing mempool and blocks data to disk cache...');
|
||||
const mempoolChunk_1 = Object.fromEntries(Object.entries(memPool.getMempool()).splice(0, DiskCache.CHUNK_SIZE));
|
||||
const mempoolChunk_2 = Object.fromEntries(Object.entries(memPool.getMempool()).splice(DiskCache.CHUNK_SIZE));
|
||||
fs.writeFileSync(DiskCache.FILE_NAME, JSON.stringify({
|
||||
blocks: blocks.getBlocks(),
|
||||
mempool: mempoolChunk_1
|
||||
}), {flag: 'w'});
|
||||
fs.writeFileSync(DiskCache.FILE_NAME_2, JSON.stringify({
|
||||
mempool: mempoolChunk_2
|
||||
}), {flag: 'w'});
|
||||
logger.debug('Mempool and blocks data saved to disk cache');
|
||||
} catch (e) {
|
||||
logger.warn('Error writing to cache file: ' + e.message || e);
|
||||
logger.warn('Error writing to cache file: ' + (e instanceof Error ? e.message : e));
|
||||
this.isWritingCache = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,20 +61,42 @@ class DiskCache {
|
||||
if (!fs.existsSync(DiskCache.FILE_NAME)) {
|
||||
return;
|
||||
}
|
||||
let data: any = {};
|
||||
const cacheData = fs.readFileSync(DiskCache.FILE_NAME, 'utf8');
|
||||
if (cacheData) {
|
||||
logger.info('Restoring mempool and blocks data from disk cache');
|
||||
data = JSON.parse(cacheData);
|
||||
}
|
||||
try {
|
||||
let data: any = {};
|
||||
const cacheData = fs.readFileSync(DiskCache.FILE_NAME, 'utf8');
|
||||
if (cacheData) {
|
||||
logger.info('Restoring mempool and blocks data from disk cache');
|
||||
data = JSON.parse(cacheData);
|
||||
if (data.mempoolArray) {
|
||||
for (const tx of data.mempoolArray) {
|
||||
data.mempool[tx.txid] = tx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync(DiskCache.FILE_NAME_2)) {
|
||||
const cacheData2 = JSON.parse(fs.readFileSync(DiskCache.FILE_NAME_2, 'utf8'));
|
||||
Object.assign(data.mempool, cacheData2.mempool);
|
||||
}
|
||||
for (let i = 1; i < DiskCache.CHUNK_FILES; i++) {
|
||||
const fileName = DiskCache.FILE_NAMES.replace('{number}', i.toString());
|
||||
try {
|
||||
if (fs.existsSync(fileName)) {
|
||||
const cacheData2 = JSON.parse(fs.readFileSync(fileName, 'utf8'));
|
||||
if (cacheData2.mempoolArray) {
|
||||
for (const tx of cacheData2.mempoolArray) {
|
||||
data.mempool[tx.txid] = tx;
|
||||
}
|
||||
} else {
|
||||
Object.assign(data.mempool, cacheData2.mempool);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.debug('Error parsing ' + fileName + '. Skipping.');
|
||||
}
|
||||
}
|
||||
|
||||
memPool.setMempool(data.mempool);
|
||||
blocks.setBlocks(data.blocks);
|
||||
memPool.setMempool(data.mempool);
|
||||
blocks.setBlocks(data.blocks);
|
||||
} catch (e) {
|
||||
logger.warn('Failed to parse mempoool and blocks cache. Skipping.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
import config from '../config';
|
||||
import axios from 'axios';
|
||||
import { DB } from '../database';
|
||||
import logger from '../logger';
|
||||
|
||||
class Donations {
|
||||
private notifyDonationStatusCallback: ((invoiceId: string) => void) | undefined;
|
||||
private options = {
|
||||
baseURL: config.SPONSORS.BTCPAY_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': config.SPONSORS.BTCPAY_AUTH,
|
||||
},
|
||||
};
|
||||
|
||||
sponsorsCache: any[] = [];
|
||||
|
||||
constructor() {
|
||||
if (!config.SPONSORS.ENABLED) {
|
||||
return;
|
||||
}
|
||||
this.$updateCache();
|
||||
}
|
||||
|
||||
async $updateCache() {
|
||||
try {
|
||||
this.sponsorsCache = await this.$getDonationsFromDatabase('handle, image');
|
||||
} catch (e) {
|
||||
logger.warn('Setting sponsorsCache failed ' + e.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
setNotfyDonationStatusCallback(fn: any): void {
|
||||
this.notifyDonationStatusCallback = fn;
|
||||
}
|
||||
|
||||
async $createRequest(amount: number, orderId: string): Promise<any> {
|
||||
logger.notice('New invoice request. Handle: ' + orderId + ' Amount: ' + amount + ' BTC');
|
||||
|
||||
const postData = {
|
||||
'price': amount,
|
||||
'orderId': orderId,
|
||||
'currency': 'BTC',
|
||||
'itemDesc': 'Sponsor mempool.space',
|
||||
'notificationUrl': config.SPONSORS.BTCPAY_WEBHOOK_URL,
|
||||
'redirectURL': 'https://mempool.space/about',
|
||||
};
|
||||
const response = await axios.post('/invoices', postData, this.options);
|
||||
return {
|
||||
id: response.data.data.id,
|
||||
amount: parseFloat(response.data.data.btcPrice),
|
||||
addresses: response.data.data.addresses,
|
||||
};
|
||||
}
|
||||
|
||||
async $handleWebhookRequest(data: any): Promise<void> {
|
||||
if (!data || !data.id) {
|
||||
return;
|
||||
}
|
||||
const response = await this.$getStatus(data.id);
|
||||
logger.notice(`Received BTCPayServer webhook. Invoice ID: ${data.id} Status: ${response.status} BTC Paid: ${response.btcPaid}`);
|
||||
if (response.status !== 'complete' && response.status !== 'confirmed' && response.status !== 'paid') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.notifyDonationStatusCallback) {
|
||||
this.notifyDonationStatusCallback(data.id);
|
||||
}
|
||||
|
||||
if (parseFloat(response.btcPaid) < 0.01) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.orderId !== '') {
|
||||
try {
|
||||
const userData = await this.$getTwitterUserData(response.orderId);
|
||||
const imageUrl = userData.profile_image_url.replace('normal', '200x200');
|
||||
const imageBlob = await this.$downloadProfileImageBlob(imageUrl);
|
||||
|
||||
logger.debug('Creating database entry for donation with invoice id: ' + response.id);
|
||||
await this.$addDonationToDatabase(response.btcPaid, userData.screen_name, userData.id, response.id, imageUrl, imageBlob);
|
||||
this.$updateCache();
|
||||
} catch (e) {
|
||||
logger.err(`Error fetching twitter data for handle ${response.orderId}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getSponsorImage(id: string): any | undefined {
|
||||
const sponsor = this.sponsorsCache.find((s) => s.handle === id);
|
||||
if (sponsor) {
|
||||
return sponsor.image;
|
||||
}
|
||||
}
|
||||
|
||||
async $getDonationsFromDatabase(fields: string): Promise<any[]> {
|
||||
try {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = `SELECT ${fields} FROM donations ORDER BY id DESC`;
|
||||
const [rows] = await connection.query<any>(query);
|
||||
connection.release();
|
||||
return rows;
|
||||
} catch (e) {
|
||||
logger.err('$getDonationsFromDatabase() error: ' + e.message || e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async $getOldDonations(): Promise<any[]> {
|
||||
try {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = `SELECT * FROM donations WHERE twitter_id IS NULL AND handle != ''`;
|
||||
const [rows] = await connection.query<any>(query);
|
||||
connection.release();
|
||||
return rows;
|
||||
} catch (e) {
|
||||
logger.err('$getLegacyDonations() error' + e.message || e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async $getStatus(id: string): Promise<any> {
|
||||
logger.debug('Fetching status for invoice: ' + id);
|
||||
const response = await axios.get('/invoices/' + id, this.options);
|
||||
logger.debug('Invoice status received: ' + JSON.stringify(response.data));
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
private async $addDonationToDatabase(btcPaid: number, handle: string, twitter_id: number | null,
|
||||
orderId: string, imageUrl: string, image: string): Promise<void> {
|
||||
try {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = `INSERT IGNORE INTO donations(added, amount, handle, twitter_id, order_id, imageUrl, image) VALUES (NOW(), ?, ?, ?, ?, ?, FROM_BASE64(?))`;
|
||||
const params: (string | number | null)[] = [
|
||||
btcPaid,
|
||||
handle,
|
||||
twitter_id,
|
||||
orderId,
|
||||
imageUrl,
|
||||
image,
|
||||
];
|
||||
const [result]: any = await connection.query(query, params);
|
||||
connection.release();
|
||||
} catch (e) {
|
||||
logger.err('$addDonationToDatabase() error' + e.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $updateDonation(id: number, handle: string, twitterId: number, imageUrl: string, image: string): Promise<void> {
|
||||
try {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = `UPDATE donations SET handle = ?, twitter_id = ?, imageUrl = ?, image = FROM_BASE64(?) WHERE id = ?`;
|
||||
const params: (string | number)[] = [
|
||||
handle,
|
||||
twitterId,
|
||||
imageUrl,
|
||||
image,
|
||||
id,
|
||||
];
|
||||
const [result]: any = await connection.query(query, params);
|
||||
connection.release();
|
||||
} catch (e) {
|
||||
logger.err('$updateDonation() error' + e.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
private async $getTwitterUserData(handle: string): Promise<any> {
|
||||
logger.debug('Fetching Twitter API data...');
|
||||
const res = await axios.get(`https://api.twitter.com/1.1/users/show.json?screen_name=${handle}`, {
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + config.SPONSORS.TWITTER_BEARER_AUTH
|
||||
}
|
||||
});
|
||||
logger.debug('Twitter user data fetched:' + JSON.stringify(res.data));
|
||||
return res.data;
|
||||
}
|
||||
|
||||
private async $downloadProfileImageBlob(url: string): Promise<string> {
|
||||
logger.debug('Fetching image blob...');
|
||||
const res = await axios.get(url, { responseType: 'arraybuffer' });
|
||||
logger.debug('Image downloaded.');
|
||||
return Buffer.from(res.data, 'utf8').toString('base64');
|
||||
}
|
||||
|
||||
private async refreshSponsors(): Promise<void> {
|
||||
const oldDonations = await this.$getOldDonations();
|
||||
oldDonations.forEach(async (donation: any) => {
|
||||
logger.debug('Migrating donation for handle: ' + donation.handle);
|
||||
try {
|
||||
const twitterData = await this.$getTwitterUserData(donation.handle);
|
||||
const imageUrl = twitterData.profile_image_url.replace('normal', '200x200');
|
||||
const imageBlob = await this.$downloadProfileImageBlob(imageUrl);
|
||||
await this.$updateDonation(donation.id, twitterData.screen_name, twitterData.id, imageUrl, imageBlob);
|
||||
} catch (e) {
|
||||
logger.err('Failed to migrate donation for handle: ' + donation.handle + '. ' + (e.message || e));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new Donations();
|
||||
@@ -1,44 +1,50 @@
|
||||
import config from '../config';
|
||||
import { MempoolBlock } from '../mempool.interfaces';
|
||||
import { Common } from './common';
|
||||
import mempool from './mempool';
|
||||
import projectedBlocks from './mempool-blocks';
|
||||
|
||||
class FeeApi {
|
||||
constructor() { }
|
||||
|
||||
defaultFee = config.MEMPOOL.NETWORK === 'liquid' ? 0.1 : 1;
|
||||
defaultFee = Common.isLiquid() ? 0.1 : 1;
|
||||
|
||||
public getRecommendedFee() {
|
||||
const pBlocks = projectedBlocks.getMempoolBlocks();
|
||||
const mPool = mempool.getMempoolInfo();
|
||||
const minimumFee = Math.ceil(mPool.mempoolminfee * 100000);
|
||||
|
||||
if (!pBlocks.length) {
|
||||
return {
|
||||
'fastestFee': this.defaultFee,
|
||||
'halfHourFee': this.defaultFee,
|
||||
'hourFee': this.defaultFee,
|
||||
'minimumFee': minimumFee,
|
||||
};
|
||||
}
|
||||
|
||||
const firstMedianFee = this.optimizeMedianFee(pBlocks[0]);
|
||||
const secondMedianFee = pBlocks[1] ? this.optimizeMedianFee(pBlocks[1], firstMedianFee) : this.defaultFee;
|
||||
const thirdMedianFee = pBlocks[2] ? this.optimizeMedianFee(pBlocks[2], secondMedianFee) : this.defaultFee;
|
||||
const firstMedianFee = this.optimizeMedianFee(pBlocks[0], pBlocks[1]);
|
||||
const secondMedianFee = pBlocks[1] ? this.optimizeMedianFee(pBlocks[1], pBlocks[2], firstMedianFee) : this.defaultFee;
|
||||
const thirdMedianFee = pBlocks[2] ? this.optimizeMedianFee(pBlocks[2], pBlocks[3], secondMedianFee) : this.defaultFee;
|
||||
|
||||
return {
|
||||
'fastestFee': firstMedianFee,
|
||||
'halfHourFee': secondMedianFee,
|
||||
'hourFee': thirdMedianFee,
|
||||
'minimumFee': minimumFee,
|
||||
};
|
||||
}
|
||||
|
||||
private optimizeMedianFee(pBlock: MempoolBlock, previousFee?: number): number {
|
||||
private optimizeMedianFee(pBlock: MempoolBlock, nextBlock: MempoolBlock | undefined, previousFee?: number): number {
|
||||
const useFee = previousFee ? (pBlock.medianFee + previousFee) / 2 : pBlock.medianFee;
|
||||
if (pBlock.blockVSize <= 500000) {
|
||||
return this.defaultFee;
|
||||
}
|
||||
if (pBlock.blockVSize <= 950000) {
|
||||
if (pBlock.blockVSize <= 950000 && !nextBlock) {
|
||||
const multiplier = (pBlock.blockVSize - 500000) / 500000;
|
||||
return Math.max(Math.round(useFee * multiplier), this.defaultFee);
|
||||
}
|
||||
return Math.round(useFee);
|
||||
return Math.ceil(useFee);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logger from '../logger';
|
||||
import axios from 'axios';
|
||||
import { IConversionRates } from '../mempool.interfaces';
|
||||
import config from '../config';
|
||||
|
||||
class FiatConversion {
|
||||
private conversionRates: IConversionRates = {
|
||||
@@ -16,7 +17,7 @@ class FiatConversion {
|
||||
|
||||
public startService() {
|
||||
logger.info('Starting currency rates service');
|
||||
setInterval(this.updateCurrency.bind(this), 1000 * 60 * 60);
|
||||
setInterval(this.updateCurrency.bind(this), 1000 * config.MEMPOOL.PRICE_FEED_UPDATE_INTERVAL);
|
||||
this.updateCurrency();
|
||||
}
|
||||
|
||||
@@ -26,7 +27,7 @@ class FiatConversion {
|
||||
|
||||
private async updateCurrency(): Promise<void> {
|
||||
try {
|
||||
const response = await axios.get('https://price.bisq.wiz.biz/getAllMarketPrices');
|
||||
const response = await axios.get('https://price.bisq.wiz.biz/getAllMarketPrices', { timeout: 10000 });
|
||||
const usd = response.data.data.find((item: any) => item.currencyCode === 'USD');
|
||||
this.conversionRates = {
|
||||
'USD': usd.price,
|
||||
@@ -35,7 +36,7 @@ class FiatConversion {
|
||||
this.ratesChangedCallback(this.conversionRates);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err('Error updating fiat conversion rates: ' + e);
|
||||
logger.err('Error updating fiat conversion rates: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
111
backend/src/api/liquid/elements-parser.ts
Normal file
111
backend/src/api/liquid/elements-parser.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { IBitcoinApi } from '../bitcoin/bitcoin-api.interface';
|
||||
import bitcoinClient from '../bitcoin/bitcoin-client';
|
||||
import bitcoinSecondClient from '../bitcoin/bitcoin-second-client';
|
||||
import { Common } from '../common';
|
||||
import { DB } from '../../database';
|
||||
import logger from '../../logger';
|
||||
|
||||
class ElementsParser {
|
||||
private isRunning = false;
|
||||
|
||||
constructor() { }
|
||||
|
||||
public async $parse() {
|
||||
if (this.isRunning) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.isRunning = true;
|
||||
const result = await bitcoinClient.getChainTips();
|
||||
const tip = result[0].height;
|
||||
const latestBlockHeight = await this.$getLatestBlockHeightFromDatabase();
|
||||
for (let height = latestBlockHeight + 1; height <= tip; height++) {
|
||||
const blockHash: IBitcoinApi.ChainTips = await bitcoinClient.getBlockHash(height);
|
||||
const block: IBitcoinApi.Block = await bitcoinClient.getBlock(blockHash, 2);
|
||||
await this.$parseBlock(block);
|
||||
await this.$saveLatestBlockToDatabase(block.height);
|
||||
}
|
||||
this.isRunning = false;
|
||||
} catch (e) {
|
||||
this.isRunning = false;
|
||||
throw new Error(e instanceof Error ? e.message : 'Error');
|
||||
}
|
||||
}
|
||||
|
||||
public async $getPegDataByMonth(): Promise<any> {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = `SELECT SUM(amount) AS amount, DATE_FORMAT(FROM_UNIXTIME(datetime), '%Y-%m-01') AS date FROM elements_pegs GROUP BY DATE_FORMAT(FROM_UNIXTIME(datetime), '%Y%m')`;
|
||||
const [rows] = await connection.query<any>(query);
|
||||
connection.release();
|
||||
return rows;
|
||||
}
|
||||
|
||||
protected async $parseBlock(block: IBitcoinApi.Block) {
|
||||
for (const tx of block.tx) {
|
||||
await this.$parseInputs(tx, block);
|
||||
await this.$parseOutputs(tx, block);
|
||||
}
|
||||
}
|
||||
|
||||
protected async $parseInputs(tx: IBitcoinApi.Transaction, block: IBitcoinApi.Block) {
|
||||
for (const [index, input] of tx.vin.entries()) {
|
||||
if (input.is_pegin) {
|
||||
await this.$parsePegIn(input, index, tx.txid, block);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async $parsePegIn(input: IBitcoinApi.Vin, vindex: number, txid: string, block: IBitcoinApi.Block) {
|
||||
const bitcoinTx: IBitcoinApi.Transaction = await bitcoinSecondClient.getRawTransaction(input.txid, true);
|
||||
const prevout = bitcoinTx.vout[input.vout || 0];
|
||||
const outputAddress = prevout.scriptPubKey.address || (prevout.scriptPubKey.addresses && prevout.scriptPubKey.addresses[0]) || '';
|
||||
await this.$savePegToDatabase(block.height, block.time, prevout.value * 100000000, txid, vindex,
|
||||
outputAddress, bitcoinTx.txid, prevout.n, 1);
|
||||
}
|
||||
|
||||
protected async $parseOutputs(tx: IBitcoinApi.Transaction, block: IBitcoinApi.Block) {
|
||||
for (const output of tx.vout) {
|
||||
if (output.scriptPubKey.pegout_chain) {
|
||||
await this.$savePegToDatabase(block.height, block.time, 0 - output.value * 100000000, tx.txid, output.n,
|
||||
(output.scriptPubKey.pegout_addresses && output.scriptPubKey.pegout_addresses[0] || ''), '', 0, 0);
|
||||
}
|
||||
if (!output.scriptPubKey.pegout_chain && output.scriptPubKey.type === 'nulldata'
|
||||
&& output.value && output.value > 0 && output.asset && output.asset === Common.nativeAssetId) {
|
||||
await this.$savePegToDatabase(block.height, block.time, 0 - output.value * 100000000, tx.txid, output.n,
|
||||
(output.scriptPubKey.pegout_addresses && output.scriptPubKey.pegout_addresses[0] || ''), '', 0, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async $savePegToDatabase(height: number, blockTime: number, amount: number, txid: string,
|
||||
txindex: number, bitcoinaddress: string, bitcointxid: string, bitcoinindex: number, final_tx: number): Promise<void> {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = `INSERT INTO elements_pegs(
|
||||
block, datetime, amount, txid, txindex, bitcoinaddress, bitcointxid, bitcoinindex, final_tx
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
||||
|
||||
const params: (string | number)[] = [
|
||||
height, blockTime, amount, txid, txindex, bitcoinaddress, bitcointxid, bitcoinindex, final_tx
|
||||
];
|
||||
await connection.query(query, params);
|
||||
connection.release();
|
||||
logger.debug(`Saved L-BTC peg from block height #${height} with TXID ${txid}.`);
|
||||
}
|
||||
|
||||
protected async $getLatestBlockHeightFromDatabase(): Promise<number> {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = `SELECT number FROM state WHERE name = 'last_elements_block'`;
|
||||
const [rows] = await connection.query<any>(query);
|
||||
connection.release();
|
||||
return rows[0]['number'];
|
||||
}
|
||||
|
||||
protected async $saveLatestBlockToDatabase(blockHeight: number) {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = `UPDATE state SET number = ? WHERE name = 'last_elements_block'`;
|
||||
await connection.query<any>(query, [blockHeight]);
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
export default new ElementsParser();
|
||||
39
backend/src/api/liquid/icons.ts
Normal file
39
backend/src/api/liquid/icons.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import * as fs from 'fs';
|
||||
import config from '../../config';
|
||||
import logger from '../../logger';
|
||||
|
||||
class Icons {
|
||||
private static FILE_NAME = './icons.json';
|
||||
private iconIds: string[] = [];
|
||||
private icons: { [assetId: string]: string; } = {};
|
||||
|
||||
constructor() {}
|
||||
|
||||
public loadIcons() {
|
||||
if (!fs.existsSync(Icons.FILE_NAME)) {
|
||||
logger.warn(`${Icons.FILE_NAME} does not exist. No Liquid icons loaded.`);
|
||||
return;
|
||||
}
|
||||
const cacheData = fs.readFileSync(Icons.FILE_NAME, 'utf8');
|
||||
this.icons = JSON.parse(cacheData);
|
||||
|
||||
for (const i in this.icons) {
|
||||
this.iconIds.push(i);
|
||||
}
|
||||
logger.debug(`Liquid icons has been loaded.`);
|
||||
}
|
||||
|
||||
public getIconByAssetId(assetId: string): Buffer | undefined {
|
||||
const icon = this.icons[assetId];
|
||||
if (icon) {
|
||||
return Buffer.from(icon, 'base64');
|
||||
}
|
||||
}
|
||||
|
||||
public getAllIconIds() {
|
||||
return this.iconIds;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default new Icons();
|
||||
@@ -1,8 +1,9 @@
|
||||
import logger from '../logger';
|
||||
import { MempoolBlock, TransactionExtended, MempoolBlockWithTransactions } from '../mempool.interfaces';
|
||||
import { Common } from './common';
|
||||
import config from '../config';
|
||||
|
||||
class MempoolBlocks {
|
||||
private static DEFAULT_PROJECTED_BLOCKS_AMOUNT = 8;
|
||||
private mempoolBlocks: MempoolBlockWithTransactions[] = [];
|
||||
|
||||
constructor() {}
|
||||
@@ -32,36 +33,68 @@ class MempoolBlocks {
|
||||
memPoolArray.push(latestMempool[i]);
|
||||
}
|
||||
}
|
||||
const start = new Date().getTime();
|
||||
|
||||
// Clear bestDescendants & ancestors
|
||||
memPoolArray.forEach((tx) => {
|
||||
tx.bestDescendant = null;
|
||||
tx.ancestors = [];
|
||||
tx.cpfpChecked = false;
|
||||
if (!tx.effectiveFeePerVsize) {
|
||||
tx.effectiveFeePerVsize = tx.feePerVsize;
|
||||
}
|
||||
});
|
||||
|
||||
// First sort
|
||||
memPoolArray.sort((a, b) => b.feePerVsize - a.feePerVsize);
|
||||
const transactionsSorted = memPoolArray.filter((tx) => tx.feePerVsize);
|
||||
this.mempoolBlocks = this.calculateMempoolBlocks(transactionsSorted);
|
||||
|
||||
// Loop through and traverse all ancestors and sum up all the sizes + fees
|
||||
// Pass down size + fee to all unconfirmed children
|
||||
let sizes = 0;
|
||||
memPoolArray.forEach((tx, i) => {
|
||||
sizes += tx.weight;
|
||||
if (sizes > 4000000 * 8) {
|
||||
return;
|
||||
}
|
||||
Common.setRelativesAndGetCpfpInfo(tx, memPool);
|
||||
});
|
||||
|
||||
// Final sort, by effective fee
|
||||
memPoolArray.sort((a, b) => b.effectiveFeePerVsize - a.effectiveFeePerVsize);
|
||||
|
||||
const end = new Date().getTime();
|
||||
const time = end - start;
|
||||
logger.debug('Mempool blocks calculated in ' + time / 1000 + ' seconds');
|
||||
|
||||
this.mempoolBlocks = this.calculateMempoolBlocks(memPoolArray);
|
||||
}
|
||||
|
||||
private calculateMempoolBlocks(transactionsSorted: TransactionExtended[]): MempoolBlockWithTransactions[] {
|
||||
const mempoolBlocks: MempoolBlockWithTransactions[] = [];
|
||||
let blockVSize = 0;
|
||||
let blockWeight = 0;
|
||||
let blockSize = 0;
|
||||
let transactions: TransactionExtended[] = [];
|
||||
transactionsSorted.forEach((tx) => {
|
||||
if (blockVSize + tx.vsize <= 1000000 || mempoolBlocks.length === MempoolBlocks.DEFAULT_PROJECTED_BLOCKS_AMOUNT - 1) {
|
||||
blockVSize += tx.vsize;
|
||||
if (blockWeight + tx.weight <= config.MEMPOOL.BLOCK_WEIGHT_UNITS
|
||||
|| mempoolBlocks.length === config.MEMPOOL.MEMPOOL_BLOCKS_AMOUNT - 1) {
|
||||
blockWeight += tx.weight;
|
||||
blockSize += tx.size;
|
||||
transactions.push(tx);
|
||||
} else {
|
||||
mempoolBlocks.push(this.dataToMempoolBlocks(transactions, blockSize, blockVSize, mempoolBlocks.length));
|
||||
blockVSize = tx.vsize;
|
||||
mempoolBlocks.push(this.dataToMempoolBlocks(transactions, blockSize, blockWeight, mempoolBlocks.length));
|
||||
blockWeight = tx.weight;
|
||||
blockSize = tx.size;
|
||||
transactions = [tx];
|
||||
}
|
||||
});
|
||||
if (transactions.length) {
|
||||
mempoolBlocks.push(this.dataToMempoolBlocks(transactions, blockSize, blockVSize, mempoolBlocks.length));
|
||||
mempoolBlocks.push(this.dataToMempoolBlocks(transactions, blockSize, blockWeight, mempoolBlocks.length));
|
||||
}
|
||||
return mempoolBlocks;
|
||||
}
|
||||
|
||||
private dataToMempoolBlocks(transactions: TransactionExtended[],
|
||||
blockSize: number, blockVSize: number, blocksIndex: number): MempoolBlockWithTransactions {
|
||||
blockSize: number, blockWeight: number, blocksIndex: number): MempoolBlockWithTransactions {
|
||||
let rangeLength = 4;
|
||||
if (blocksIndex === 0) {
|
||||
rangeLength = 8;
|
||||
@@ -73,10 +106,10 @@ class MempoolBlocks {
|
||||
}
|
||||
return {
|
||||
blockSize: blockSize,
|
||||
blockVSize: blockVSize,
|
||||
blockVSize: blockWeight / 4,
|
||||
nTx: transactions.length,
|
||||
totalFees: transactions.reduce((acc, cur) => acc + cur.fee, 0),
|
||||
medianFee: Common.median(transactions.map((tx) => tx.feePerVsize)),
|
||||
medianFee: Common.percentile(transactions.map((tx) => tx.effectiveFeePerVsize), config.MEMPOOL.RECOMMENDED_FEE_PERCENTILE),
|
||||
feeRange: Common.getFeesInRange(transactions, rangeLength),
|
||||
transactionIds: transactions.map((tx) => tx.txid),
|
||||
};
|
||||
|
||||
@@ -5,15 +5,17 @@ import logger from '../logger';
|
||||
import { Common } from './common';
|
||||
import transactionUtils from './transaction-utils';
|
||||
import { IBitcoinApi } from './bitcoin/bitcoin-api.interface';
|
||||
import bitcoinBaseApi from './bitcoin/bitcoin-base.api';
|
||||
import loadingIndicators from './loading-indicators';
|
||||
import bitcoinClient from './bitcoin/bitcoin-client';
|
||||
import bitcoinSecondClient from './bitcoin/bitcoin-second-client';
|
||||
|
||||
class Mempool {
|
||||
private static WEBSOCKET_REFRESH_RATE_MS = 10000;
|
||||
private static LAZY_DELETE_AFTER_SECONDS = 30;
|
||||
private inSync: boolean = false;
|
||||
private mempoolCache: { [txId: string]: TransactionExtended } = {};
|
||||
private mempoolInfo: IBitcoinApi.MempoolInfo = { loaded: false, size: 0, bytes: 0, usage: 0,
|
||||
maxmempool: 0, mempoolminfee: 0, minrelaytxfee: 0 };
|
||||
maxmempool: 300000000, mempoolminfee: 0.00001000, minrelaytxfee: 0.00001000 };
|
||||
private mempoolChangedCallback: ((newMempool: {[txId: string]: TransactionExtended; }, newTransactions: TransactionExtended[],
|
||||
deletedTransactions: TransactionExtended[]) => void) | undefined;
|
||||
|
||||
@@ -27,12 +29,18 @@ class Mempool {
|
||||
|
||||
constructor() {
|
||||
setInterval(this.updateTxPerSecond.bind(this), 1000);
|
||||
setInterval(this.deleteExpiredTransactions.bind(this), 20000);
|
||||
}
|
||||
|
||||
public isInSync() {
|
||||
public isInSync(): boolean {
|
||||
return this.inSync;
|
||||
}
|
||||
|
||||
public setOutOfSync(): void {
|
||||
this.inSync = false;
|
||||
loadingIndicators.setProgress('mempool', 99);
|
||||
}
|
||||
|
||||
public getLatestTransactions() {
|
||||
return this.latestTransactions;
|
||||
}
|
||||
@@ -54,10 +62,10 @@ class Mempool {
|
||||
}
|
||||
|
||||
public async $updateMemPoolInfo() {
|
||||
this.mempoolInfo = await bitcoinBaseApi.$getMempoolInfo();
|
||||
this.mempoolInfo = await this.$getMempoolInfo();
|
||||
}
|
||||
|
||||
public getMempoolInfo(): IBitcoinApi.MempoolInfo | undefined {
|
||||
public getMempoolInfo(): IBitcoinApi.MempoolInfo {
|
||||
return this.mempoolInfo;
|
||||
}
|
||||
|
||||
@@ -98,8 +106,8 @@ class Mempool {
|
||||
|
||||
for (const txid of transactions) {
|
||||
if (!this.mempoolCache[txid]) {
|
||||
const transaction = await transactionUtils.$getTransactionExtended(txid, true);
|
||||
if (transaction) {
|
||||
try {
|
||||
const transaction = await transactionUtils.$getTransactionExtended(txid);
|
||||
this.mempoolCache[txid] = transaction;
|
||||
txCount++;
|
||||
if (this.inSync) {
|
||||
@@ -116,8 +124,8 @@ class Mempool {
|
||||
logger.debug('Fetched transaction ' + txCount);
|
||||
}
|
||||
newTransactions.push(transaction);
|
||||
} else {
|
||||
logger.debug('Error finding transaction in mempool.');
|
||||
} catch (e) {
|
||||
logger.debug('Error finding transaction in mempool: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +136,6 @@ class Mempool {
|
||||
|
||||
// Prevent mempool from clear on bitcoind restart by delaying the deletion
|
||||
if (this.mempoolProtection === 0
|
||||
&& config.MEMPOOL.BACKEND === 'esplora'
|
||||
&& currentMempoolSize > 20000
|
||||
&& transactions.length / currentMempoolSize <= 0.80
|
||||
) {
|
||||
@@ -138,10 +145,9 @@ class Mempool {
|
||||
setTimeout(() => {
|
||||
this.mempoolProtection = 2;
|
||||
logger.warn('Mempool clear protection resumed.');
|
||||
}, 1000 * 60 * 2);
|
||||
}, 1000 * 60 * config.MEMPOOL.CLEAR_PROTECTION_MINUTES);
|
||||
}
|
||||
|
||||
let newMempool = {};
|
||||
const deletedTransactions: TransactionExtended[] = [];
|
||||
|
||||
if (this.mempoolProtection !== 1) {
|
||||
@@ -150,35 +156,31 @@ class Mempool {
|
||||
const transactionsObject = {};
|
||||
transactions.forEach((txId) => transactionsObject[txId] = true);
|
||||
|
||||
// Replace mempool to separate deleted transactions
|
||||
// Flag transactions for lazy deletion
|
||||
for (const tx in this.mempoolCache) {
|
||||
if (transactionsObject[tx]) {
|
||||
newMempool[tx] = this.mempoolCache[tx];
|
||||
} else {
|
||||
if (!transactionsObject[tx] && !this.mempoolCache[tx].deleteAfter) {
|
||||
deletedTransactions.push(this.mempoolCache[tx]);
|
||||
this.mempoolCache[tx].deleteAfter = new Date().getTime() + Mempool.LAZY_DELETE_AFTER_SECONDS * 1000;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
newMempool = this.mempoolCache;
|
||||
}
|
||||
|
||||
const newTransactionsStripped = newTransactions.map((tx) => Common.stripTransaction(tx));
|
||||
this.latestTransactions = newTransactionsStripped.concat(this.latestTransactions).slice(0, 6);
|
||||
|
||||
if (!this.inSync && transactions.length === Object.keys(newMempool).length) {
|
||||
if (!this.inSync && transactions.length === Object.keys(this.mempoolCache).length) {
|
||||
this.inSync = true;
|
||||
logger.info('The mempool is now in sync!');
|
||||
logger.notice('The mempool is now in sync!');
|
||||
loadingIndicators.setProgress('mempool', 100);
|
||||
}
|
||||
|
||||
if (this.mempoolChangedCallback && (hasChange || deletedTransactions.length)) {
|
||||
this.mempoolCache = newMempool;
|
||||
this.mempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions);
|
||||
}
|
||||
|
||||
const end = new Date().getTime();
|
||||
const time = end - start;
|
||||
logger.debug(`New mempool size: ${Object.keys(newMempool).length} Change: ${diff}`);
|
||||
logger.debug(`New mempool size: ${Object.keys(this.mempoolCache).length} Change: ${diff}`);
|
||||
logger.debug('Mempool updated in ' + time / 1000 + ' seconds');
|
||||
}
|
||||
|
||||
@@ -194,6 +196,31 @@ class Mempool {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private deleteExpiredTransactions() {
|
||||
const now = new Date().getTime();
|
||||
for (const tx in this.mempoolCache) {
|
||||
const lazyDeleteAt = this.mempoolCache[tx].deleteAfter;
|
||||
if (lazyDeleteAt && lazyDeleteAt < now) {
|
||||
delete this.mempoolCache[tx];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private $getMempoolInfo() {
|
||||
if (config.MEMPOOL.USE_SECOND_NODE_FOR_MINFEE) {
|
||||
return Promise.all([
|
||||
bitcoinClient.getMempoolInfo(),
|
||||
bitcoinSecondClient.getMempoolInfo()
|
||||
]).then(([mempoolInfo, secondMempoolInfo]) => {
|
||||
mempoolInfo.maxmempool = secondMempoolInfo.maxmempool;
|
||||
mempoolInfo.mempoolminfee = secondMempoolInfo.mempoolminfee;
|
||||
mempoolInfo.minrelaytxfee = secondMempoolInfo.minrelaytxfee;
|
||||
return mempoolInfo;
|
||||
});
|
||||
}
|
||||
return bitcoinClient.getMempoolInfo();
|
||||
}
|
||||
}
|
||||
|
||||
export default new Mempool();
|
||||
|
||||
@@ -3,10 +3,13 @@ import { DB } from '../database';
|
||||
import logger from '../logger';
|
||||
|
||||
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;
|
||||
protected queryTimeout = 120000;
|
||||
|
||||
public setNewStatisticsEntryCallback(fn: (stats: OptimizedStatistic) => void) {
|
||||
this.newStatisticsEntryCallback = fn;
|
||||
@@ -47,13 +50,13 @@ class Statistics {
|
||||
}
|
||||
}
|
||||
// Remove 0 and undefined
|
||||
memPoolArray = memPoolArray.filter((tx) => tx.feePerVsize);
|
||||
memPoolArray = memPoolArray.filter((tx) => tx.effectiveFeePerVsize);
|
||||
|
||||
if (!memPoolArray.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
memPoolArray.sort((a, b) => a.feePerVsize - b.feePerVsize);
|
||||
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);
|
||||
|
||||
@@ -61,10 +64,15 @@ class Statistics {
|
||||
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 ((logFees[i] === 2000 && transaction.feePerVsize >= 2000) || transaction.feePerVsize <= logFees[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 {
|
||||
@@ -234,14 +242,62 @@ class Statistics {
|
||||
connection.release();
|
||||
return result.insertId;
|
||||
} catch (e) {
|
||||
logger.err('$create() error' + e.message || e);
|
||||
logger.err('$create() error' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
private getQueryForDays(div: number) {
|
||||
return `SELECT id, added, unconfirmed_transactions,
|
||||
tx_per_second,
|
||||
vbytes_per_second,
|
||||
private getQueryForDaysAvg(div: number, interval: string) {
|
||||
return `SELECT
|
||||
UNIX_TIMESTAMP(added) as added,
|
||||
CAST(avg(vbytes_per_second) as DOUBLE) as vbytes_per_second,
|
||||
CAST(avg(vsize_1) as DOUBLE) as vsize_1,
|
||||
CAST(avg(vsize_2) as DOUBLE) as vsize_2,
|
||||
CAST(avg(vsize_3) as DOUBLE) as vsize_3,
|
||||
CAST(avg(vsize_4) as DOUBLE) as vsize_4,
|
||||
CAST(avg(vsize_5) as DOUBLE) as vsize_5,
|
||||
CAST(avg(vsize_6) as DOUBLE) as vsize_6,
|
||||
CAST(avg(vsize_8) as DOUBLE) as vsize_8,
|
||||
CAST(avg(vsize_10) as DOUBLE) as vsize_10,
|
||||
CAST(avg(vsize_12) as DOUBLE) as vsize_12,
|
||||
CAST(avg(vsize_15) as DOUBLE) as vsize_15,
|
||||
CAST(avg(vsize_20) as DOUBLE) as vsize_20,
|
||||
CAST(avg(vsize_30) as DOUBLE) as vsize_30,
|
||||
CAST(avg(vsize_40) as DOUBLE) as vsize_40,
|
||||
CAST(avg(vsize_50) as DOUBLE) as vsize_50,
|
||||
CAST(avg(vsize_60) as DOUBLE) as vsize_60,
|
||||
CAST(avg(vsize_70) as DOUBLE) as vsize_70,
|
||||
CAST(avg(vsize_80) as DOUBLE) as vsize_80,
|
||||
CAST(avg(vsize_90) as DOUBLE) as vsize_90,
|
||||
CAST(avg(vsize_100) as DOUBLE) as vsize_100,
|
||||
CAST(avg(vsize_125) as DOUBLE) as vsize_125,
|
||||
CAST(avg(vsize_150) as DOUBLE) as vsize_150,
|
||||
CAST(avg(vsize_175) as DOUBLE) as vsize_175,
|
||||
CAST(avg(vsize_200) as DOUBLE) as vsize_200,
|
||||
CAST(avg(vsize_250) as DOUBLE) as vsize_250,
|
||||
CAST(avg(vsize_300) as DOUBLE) as vsize_300,
|
||||
CAST(avg(vsize_350) as DOUBLE) as vsize_350,
|
||||
CAST(avg(vsize_400) as DOUBLE) as vsize_400,
|
||||
CAST(avg(vsize_500) as DOUBLE) as vsize_500,
|
||||
CAST(avg(vsize_600) as DOUBLE) as vsize_600,
|
||||
CAST(avg(vsize_700) as DOUBLE) as vsize_700,
|
||||
CAST(avg(vsize_800) as DOUBLE) as vsize_800,
|
||||
CAST(avg(vsize_900) as DOUBLE) as vsize_900,
|
||||
CAST(avg(vsize_1000) as DOUBLE) as vsize_1000,
|
||||
CAST(avg(vsize_1200) as DOUBLE) as vsize_1200,
|
||||
CAST(avg(vsize_1400) as DOUBLE) as vsize_1400,
|
||||
CAST(avg(vsize_1600) as DOUBLE) as vsize_1600,
|
||||
CAST(avg(vsize_1800) as DOUBLE) as vsize_1800,
|
||||
CAST(avg(vsize_2000) as DOUBLE) as vsize_2000 \
|
||||
FROM statistics \
|
||||
WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW() \
|
||||
GROUP BY UNIX_TIMESTAMP(added) DIV ${div} \
|
||||
ORDER BY statistics.added DESC;`;
|
||||
}
|
||||
|
||||
private getQueryForDays(div: number, interval: string) {
|
||||
return `SELECT
|
||||
UNIX_TIMESTAMP(added) as added,
|
||||
CAST(avg(vbytes_per_second) as DOUBLE) as vbytes_per_second,
|
||||
vsize_1,
|
||||
vsize_2,
|
||||
vsize_3,
|
||||
@@ -279,32 +335,36 @@ class Statistics {
|
||||
vsize_1400,
|
||||
vsize_1600,
|
||||
vsize_1800,
|
||||
vsize_2000 FROM statistics GROUP BY UNIX_TIMESTAMP(added) DIV ${div} ORDER BY id DESC LIMIT 480`;
|
||||
vsize_2000 \
|
||||
FROM statistics \
|
||||
WHERE added BETWEEN DATE_SUB(NOW(), INTERVAL ${interval}) AND NOW() \
|
||||
GROUP BY UNIX_TIMESTAMP(added) DIV ${div} \
|
||||
ORDER BY statistics.added DESC;`;
|
||||
}
|
||||
|
||||
public async $get(id: number): Promise<OptimizedStatistic | undefined> {
|
||||
private async $get(id: number): Promise<OptimizedStatistic | undefined> {
|
||||
try {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = `SELECT * FROM statistics WHERE id = ?`;
|
||||
const query = `SELECT *, UNIX_TIMESTAMP(added) as added FROM statistics WHERE id = ?`;
|
||||
const [rows] = await connection.query<any>(query, [id]);
|
||||
connection.release();
|
||||
if (rows[0]) {
|
||||
return this.mapStatisticToOptimizedStatistic([rows[0]])[0];
|
||||
}
|
||||
} catch (e) {
|
||||
logger.err('$list2H() error' + e.message || e);
|
||||
logger.err('$list2H() error' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
public async $list2H(): Promise<OptimizedStatistic[]> {
|
||||
try {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = `SELECT * FROM statistics ORDER BY id DESC LIMIT 120`;
|
||||
const [rows] = await connection.query<any>(query);
|
||||
const query = `SELECT *, UNIX_TIMESTAMP(added) as added FROM statistics ORDER BY statistics.added DESC LIMIT 120`;
|
||||
const [rows] = await connection.query<any>({ sql: query, timeout: this.queryTimeout });
|
||||
connection.release();
|
||||
return this.mapStatisticToOptimizedStatistic(rows);
|
||||
} catch (e) {
|
||||
logger.err('$list2H() error' + e.message || e);
|
||||
logger.err('$list2H() error' + (e instanceof Error ? e.message : e));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -312,12 +372,12 @@ class Statistics {
|
||||
public async $list24H(): Promise<OptimizedStatistic[]> {
|
||||
try {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = this.getQueryForDays(180);
|
||||
const [rows] = await connection.query<any>(query);
|
||||
const query = `SELECT *, UNIX_TIMESTAMP(added) as added FROM statistics ORDER BY statistics.added DESC LIMIT 1440`;
|
||||
const [rows] = await connection.query<any>({ sql: query, timeout: this.queryTimeout });
|
||||
connection.release();
|
||||
return this.mapStatisticToOptimizedStatistic(rows);
|
||||
} catch (e) {
|
||||
logger.err('$list24h() error' + e.message || e);
|
||||
logger.err('$list24h() error' + (e instanceof Error ? e.message : e));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -325,12 +385,12 @@ class Statistics {
|
||||
public async $list1W(): Promise<OptimizedStatistic[]> {
|
||||
try {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = this.getQueryForDays(1260);
|
||||
const [rows] = await connection.query<any>(query);
|
||||
const query = this.getQueryForDaysAvg(300, '1 WEEK'); // 5m interval
|
||||
const [rows] = await connection.query<any>({ sql: query, timeout: this.queryTimeout });
|
||||
connection.release();
|
||||
return this.mapStatisticToOptimizedStatistic(rows);
|
||||
} catch (e) {
|
||||
logger.err('$list1W() error' + e);
|
||||
logger.err('$list1W() error' + (e instanceof Error ? e.message : e));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -338,12 +398,12 @@ class Statistics {
|
||||
public async $list1M(): Promise<OptimizedStatistic[]> {
|
||||
try {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = this.getQueryForDays(5040);
|
||||
const [rows] = await connection.query<any>(query);
|
||||
const query = this.getQueryForDaysAvg(1800, '1 MONTH'); // 30m interval
|
||||
const [rows] = await connection.query<any>({ sql: query, timeout: this.queryTimeout });
|
||||
connection.release();
|
||||
return this.mapStatisticToOptimizedStatistic(rows);
|
||||
} catch (e) {
|
||||
logger.err('$list1M() error' + e);
|
||||
logger.err('$list1M() error' + (e instanceof Error ? e.message : e));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -351,12 +411,12 @@ class Statistics {
|
||||
public async $list3M(): Promise<OptimizedStatistic[]> {
|
||||
try {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = this.getQueryForDays(15120);
|
||||
const [rows] = await connection.query<any>(query);
|
||||
const query = this.getQueryForDaysAvg(7200, '3 MONTH'); // 2h interval
|
||||
const [rows] = await connection.query<any>({ sql: query, timeout: this.queryTimeout });
|
||||
connection.release();
|
||||
return this.mapStatisticToOptimizedStatistic(rows);
|
||||
} catch (e) {
|
||||
logger.err('$list3M() error' + e);
|
||||
logger.err('$list3M() error' + (e instanceof Error ? e.message : e));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -364,12 +424,12 @@ class Statistics {
|
||||
public async $list6M(): Promise<OptimizedStatistic[]> {
|
||||
try {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = this.getQueryForDays(30240);
|
||||
const [rows] = await connection.query<any>(query);
|
||||
const query = this.getQueryForDaysAvg(10800, '6 MONTH'); // 3h interval
|
||||
const [rows] = await connection.query<any>({ sql: query, timeout: this.queryTimeout });
|
||||
connection.release();
|
||||
return this.mapStatisticToOptimizedStatistic(rows);
|
||||
} catch (e) {
|
||||
logger.err('$list6M() error' + e);
|
||||
logger.err('$list6M() error' + (e instanceof Error ? e.message : e));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -377,22 +437,46 @@ class Statistics {
|
||||
public async $list1Y(): Promise<OptimizedStatistic[]> {
|
||||
try {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = this.getQueryForDays(60480);
|
||||
const [rows] = await connection.query<any>(query);
|
||||
const query = this.getQueryForDays(28800, '1 YEAR'); // 8h interval
|
||||
const [rows] = await connection.query<any>({ sql: query, timeout: this.queryTimeout });
|
||||
connection.release();
|
||||
return this.mapStatisticToOptimizedStatistic(rows);
|
||||
} catch (e) {
|
||||
logger.err('$list6M() error' + e);
|
||||
logger.err('$list1Y() error' + (e instanceof Error ? e.message : e));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async $list2Y(): Promise<OptimizedStatistic[]> {
|
||||
try {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = this.getQueryForDays(28800, "2 YEAR"); // 8h interval
|
||||
const [rows] = await connection.query<any>({ sql: query, timeout: this.queryTimeout });
|
||||
connection.release();
|
||||
return this.mapStatisticToOptimizedStatistic(rows);
|
||||
} catch (e) {
|
||||
logger.err('$list2Y() error' + (e instanceof Error ? e.message : e));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async $list3Y(): Promise<OptimizedStatistic[]> {
|
||||
try {
|
||||
const connection = await DB.pool.getConnection();
|
||||
const query = this.getQueryForDays(43200, "3 YEAR"); // 12h interval
|
||||
const [rows] = await connection.query<any>({ sql: query, timeout: this.queryTimeout });
|
||||
connection.release();
|
||||
return this.mapStatisticToOptimizedStatistic(rows);
|
||||
} catch (e) {
|
||||
logger.err('$list3Y() error' + (e instanceof Error ? e.message : e));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private mapStatisticToOptimizedStatistic(statistic: Statistic[]): OptimizedStatistic[] {
|
||||
return statistic.map((s) => {
|
||||
return {
|
||||
id: s.id || 0,
|
||||
added: s.added,
|
||||
unconfirmed_transactions: s.unconfirmed_transactions,
|
||||
tx_per_second: s.tx_per_second,
|
||||
vbytes_per_second: s.vbytes_per_second,
|
||||
mempool_byte_weight: s.mempool_byte_weight,
|
||||
total_fee: s.total_fee,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import bitcoinApi from './bitcoin/bitcoin-api-factory';
|
||||
import logger from '../logger';
|
||||
import { TransactionExtended, TransactionMinerInfo } from '../mempool.interfaces';
|
||||
import { IEsploraApi } from './bitcoin/esplora-api.interface';
|
||||
import config from '../config';
|
||||
import { Common } from './common';
|
||||
|
||||
class TransactionUtils {
|
||||
constructor() { }
|
||||
@@ -20,26 +21,23 @@ class TransactionUtils {
|
||||
};
|
||||
}
|
||||
|
||||
public async $getTransactionExtended(txId: string, forceBitcoind = false, addPrevouts = false): Promise<TransactionExtended | null> {
|
||||
try {
|
||||
let transaction: IEsploraApi.Transaction;
|
||||
if (forceBitcoind) {
|
||||
transaction = await bitcoinApi.$getRawTransactionBitcoind(txId, false, addPrevouts);
|
||||
} else {
|
||||
transaction = await bitcoinApi.$getRawTransaction(txId, false, addPrevouts);
|
||||
}
|
||||
return this.extendTransaction(transaction);
|
||||
} catch (e) {
|
||||
logger.debug('getTransactionExtended error: ' + (e.message || e));
|
||||
logger.debug(JSON.stringify(e));
|
||||
return null;
|
||||
}
|
||||
public async $getTransactionExtended(txId: string, addPrevouts = false): Promise<TransactionExtended> {
|
||||
const transaction: IEsploraApi.Transaction = await bitcoinApi.$getRawTransaction(txId, false, addPrevouts);
|
||||
return this.extendTransaction(transaction);
|
||||
}
|
||||
|
||||
private extendTransaction(transaction: IEsploraApi.Transaction): TransactionExtended {
|
||||
// @ts-ignore
|
||||
if (transaction.vsize) {
|
||||
// @ts-ignore
|
||||
return transaction;
|
||||
}
|
||||
const feePerVbytes = Math.max(Common.isLiquid() ? 0.1 : 1,
|
||||
(transaction.fee || 0) / (transaction.weight / 4));
|
||||
const transactionExtended: TransactionExtended = Object.assign({
|
||||
vsize: Math.round(transaction.weight / 4),
|
||||
feePerVsize: Math.max(1, (transaction.fee || 0) / (transaction.weight / 4)),
|
||||
feePerVsize: feePerVbytes,
|
||||
effectiveFeePerVsize: feePerVbytes,
|
||||
}, transaction);
|
||||
if (!transaction.status.confirmed) {
|
||||
transactionExtended.firstSeen = Math.round((new Date().getTime() / 1000));
|
||||
|
||||
@@ -14,7 +14,6 @@ import transactionUtils from './transaction-utils';
|
||||
|
||||
class WebsocketHandler {
|
||||
private wss: WebSocket.Server | undefined;
|
||||
private nativeAssetId = '6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d';
|
||||
private extraInitProperties = {};
|
||||
|
||||
constructor() { }
|
||||
@@ -34,7 +33,7 @@ class WebsocketHandler {
|
||||
|
||||
this.wss.on('connection', (client: WebSocket) => {
|
||||
client.on('error', logger.info);
|
||||
client.on('message', (message: string) => {
|
||||
client.on('message', async (message: string) => {
|
||||
try {
|
||||
const parsedMessage: WebsocketResponse = JSON.parse(message);
|
||||
const response = {};
|
||||
@@ -53,9 +52,25 @@ class WebsocketHandler {
|
||||
if (parsedMessage['watch-mempool']) {
|
||||
const tx = memPool.getMempool()[client['track-tx']];
|
||||
if (tx) {
|
||||
response['tx'] = tx;
|
||||
if (config.MEMPOOL.BACKEND === 'esplora') {
|
||||
response['tx'] = tx;
|
||||
} else {
|
||||
// tx.prevouts is missing from transactions when in bitcoind mode
|
||||
try {
|
||||
const fullTx = await transactionUtils.$getTransactionExtended(tx.txid, true);
|
||||
response['tx'] = fullTx;
|
||||
} catch (e) {
|
||||
logger.debug('Error finding transaction: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
client['track-mempool-tx'] = parsedMessage['track-tx'];
|
||||
try {
|
||||
const fullTx = await transactionUtils.$getTransactionExtended(client['track-tx'], true);
|
||||
response['tx'] = fullTx;
|
||||
} catch (e) {
|
||||
logger.debug('Error finding transaction. ' + (e instanceof Error ? e.message : e));
|
||||
client['track-mempool-tx'] = parsedMessage['track-tx'];
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -64,9 +79,13 @@ class WebsocketHandler {
|
||||
}
|
||||
|
||||
if (parsedMessage && parsedMessage['track-address']) {
|
||||
if (/^([a-km-zA-HJ-NP-Z1-9]{26,35}|[a-km-zA-HJ-NP-Z1-9]{80}|[a-z]{2,5}1[ac-hj-np-z02-9]{8,87})$/
|
||||
if (/^([a-km-zA-HJ-NP-Z1-9]{26,35}|[a-km-zA-HJ-NP-Z1-9]{80}|[a-z]{2,5}1[ac-hj-np-z02-9]{8,100}|[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100})$/
|
||||
.test(parsedMessage['track-address'])) {
|
||||
client['track-address'] = parsedMessage['track-address'];
|
||||
let matchedAddress = parsedMessage['track-address'];
|
||||
if (/^[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100}$/.test(parsedMessage['track-address'])) {
|
||||
matchedAddress = matchedAddress.toLowerCase();
|
||||
}
|
||||
client['track-address'] = matchedAddress;
|
||||
} else {
|
||||
client['track-address'] = null;
|
||||
}
|
||||
@@ -81,7 +100,7 @@ class WebsocketHandler {
|
||||
}
|
||||
|
||||
if (parsedMessage.action === 'init') {
|
||||
const _blocks = blocks.getBlocks().slice(-8);
|
||||
const _blocks = blocks.getBlocks().slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT);
|
||||
if (!_blocks) {
|
||||
return;
|
||||
}
|
||||
@@ -96,11 +115,19 @@ class WebsocketHandler {
|
||||
client['track-donation'] = parsedMessage['track-donation'];
|
||||
}
|
||||
|
||||
if (parsedMessage['track-bisq-market']) {
|
||||
if (/^[a-z]{3}_[a-z]{3}$/.test(parsedMessage['track-bisq-market'])) {
|
||||
client['track-bisq-market'] = parsedMessage['track-bisq-market'];
|
||||
} else {
|
||||
client['track-bisq-market'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(response).length) {
|
||||
client.send(JSON.stringify(response));
|
||||
}
|
||||
} catch (e) {
|
||||
logger.debug('Error parsing websocket message: ' + e.message || e);
|
||||
logger.debug('Error parsing websocket message: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -149,18 +176,18 @@ class WebsocketHandler {
|
||||
|
||||
getInitData(_blocks?: BlockExtended[]) {
|
||||
if (!_blocks) {
|
||||
_blocks = blocks.getBlocks().slice(-8);
|
||||
_blocks = blocks.getBlocks().slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT);
|
||||
}
|
||||
return {
|
||||
'mempoolInfo': memPool.getMempoolInfo(),
|
||||
'vBytesPerSecond': memPool.getVBytesPerSecond(),
|
||||
'lastDifficultyAdjustment': blocks.getLastDifficultyAdjustmentTime(),
|
||||
'previousRetarget': blocks.getPreviousDifficultyRetarget(),
|
||||
'blocks': _blocks,
|
||||
'conversions': fiatConversion.getConversionRates(),
|
||||
'mempool-blocks': mempoolBlocks.getMempoolBlocks(),
|
||||
'transactions': memPool.getLatestTransactions(),
|
||||
'git-commit': backendInfo.gitCommitHash,
|
||||
'hostname': backendInfo.hostname,
|
||||
'backendInfo': backendInfo.getBackendInfo(),
|
||||
'loadingIndicators': loadingIndicators.getLoadingIndicators(),
|
||||
...this.extraInitProperties
|
||||
};
|
||||
@@ -194,10 +221,15 @@ class WebsocketHandler {
|
||||
|
||||
mempoolBlocks.updateMempoolBlocks(newMempool);
|
||||
const mBlocks = mempoolBlocks.getMempoolBlocks();
|
||||
const mempool = memPool.getMempool();
|
||||
const mempoolInfo = memPool.getMempoolInfo();
|
||||
const vBytesPerSecond = memPool.getVBytesPerSecond();
|
||||
const rbfTransactions = Common.findRbfTransactions(newTransactions, deletedTransactions);
|
||||
|
||||
for (const rbfTransaction in rbfTransactions) {
|
||||
delete mempool[rbfTransaction];
|
||||
}
|
||||
|
||||
this.wss.clients.forEach(async (client: WebSocket) => {
|
||||
if (client.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
@@ -219,9 +251,11 @@ class WebsocketHandler {
|
||||
const tx = newTransactions.find((t) => t.txid === client['track-mempool-tx']);
|
||||
if (tx) {
|
||||
if (config.MEMPOOL.BACKEND !== 'esplora') {
|
||||
const fullTx = await transactionUtils.$getTransactionExtended(tx.txid, false, true);
|
||||
if (fullTx) {
|
||||
try {
|
||||
const fullTx = await transactionUtils.$getTransactionExtended(tx.txid, true);
|
||||
response['tx'] = fullTx;
|
||||
} catch (e) {
|
||||
logger.debug('Error finding transaction in mempool: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
} else {
|
||||
response['tx'] = tx;
|
||||
@@ -237,9 +271,11 @@ class WebsocketHandler {
|
||||
const someVin = tx.vin.some((vin) => !!vin.prevout && vin.prevout.scriptpubkey_address === client['track-address']);
|
||||
if (someVin) {
|
||||
if (config.MEMPOOL.BACKEND !== 'esplora') {
|
||||
const fullTx = await transactionUtils.$getTransactionExtended(tx.txid, false, true);
|
||||
if (fullTx) {
|
||||
try {
|
||||
const fullTx = await transactionUtils.$getTransactionExtended(tx.txid, true);
|
||||
foundTransactions.push(fullTx);
|
||||
} catch (e) {
|
||||
logger.debug('Error finding transaction in mempool: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
} else {
|
||||
foundTransactions.push(tx);
|
||||
@@ -249,9 +285,11 @@ class WebsocketHandler {
|
||||
const someVout = tx.vout.some((vout) => vout.scriptpubkey_address === client['track-address']);
|
||||
if (someVout) {
|
||||
if (config.MEMPOOL.BACKEND !== 'esplora') {
|
||||
const fullTx = await transactionUtils.$getTransactionExtended(tx.txid, false, true);
|
||||
if (fullTx) {
|
||||
try {
|
||||
const fullTx = await transactionUtils.$getTransactionExtended(tx.txid, true);
|
||||
foundTransactions.push(fullTx);
|
||||
} catch (e) {
|
||||
logger.debug('Error finding transaction in mempool: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
} else {
|
||||
foundTransactions.push(tx);
|
||||
@@ -269,7 +307,7 @@ class WebsocketHandler {
|
||||
|
||||
newTransactions.forEach((tx) => {
|
||||
|
||||
if (client['track-asset'] === this.nativeAssetId) {
|
||||
if (client['track-asset'] === Common.nativeAssetId) {
|
||||
if (tx.vin.some((vin) => !!vin.is_pegin)) {
|
||||
foundTransactions.push(tx);
|
||||
return;
|
||||
@@ -298,9 +336,11 @@ class WebsocketHandler {
|
||||
if (client['track-tx'] === rbfTransaction) {
|
||||
const rbfTx = rbfTransactions[rbfTransaction];
|
||||
if (config.MEMPOOL.BACKEND !== 'esplora') {
|
||||
const fullTx = await transactionUtils.$getTransactionExtended(rbfTransaction, false, true);
|
||||
if (fullTx) {
|
||||
try {
|
||||
const fullTx = await transactionUtils.$getTransactionExtended(rbfTransaction, true);
|
||||
response['rbfTransaction'] = fullTx;
|
||||
} catch (e) {
|
||||
logger.debug('Error finding transaction in mempool: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
} else {
|
||||
response['rbfTransaction'] = rbfTx;
|
||||
@@ -321,28 +361,23 @@ class WebsocketHandler {
|
||||
throw new Error('WebSocket.Server is not set');
|
||||
}
|
||||
|
||||
// Check how many transactions in the new block matches the latest projected mempool block
|
||||
// If it's more than 0, recalculate the mempool blocks and send to client in the same update
|
||||
let mBlocks: undefined | MempoolBlock[];
|
||||
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];
|
||||
}
|
||||
|
||||
matchRate = Math.round((matches.length / (txIds.length - 1)) * 100);
|
||||
if (matchRate > 0) {
|
||||
const currentMemPool = memPool.getMempool();
|
||||
for (const txId of matches) {
|
||||
delete currentMemPool[txId];
|
||||
}
|
||||
mempoolBlocks.updateMempoolBlocks(currentMemPool);
|
||||
mBlocks = mempoolBlocks.getMempoolBlocks();
|
||||
}
|
||||
mempoolBlocks.updateMempoolBlocks(_memPool);
|
||||
mBlocks = mempoolBlocks.getMempoolBlocks();
|
||||
}
|
||||
|
||||
block.matchRate = matchRate;
|
||||
@@ -360,6 +395,7 @@ class WebsocketHandler {
|
||||
'block': block,
|
||||
'mempoolInfo': memPool.getMempoolInfo(),
|
||||
'lastDifficultyAdjustment': blocks.getLastDifficultyAdjustmentTime(),
|
||||
'previousRetarget': blocks.getPreviousDifficultyRetarget(),
|
||||
};
|
||||
|
||||
if (mBlocks && client['want-mempool-blocks']) {
|
||||
@@ -402,7 +438,7 @@ class WebsocketHandler {
|
||||
const foundTransactions: TransactionExtended[] = [];
|
||||
|
||||
transactions.forEach((tx) => {
|
||||
if (client['track-asset'] === this.nativeAssetId) {
|
||||
if (client['track-asset'] === Common.nativeAssetId) {
|
||||
if (tx.vin && tx.vin.some((vin) => !!vin.is_pegin)) {
|
||||
foundTransactions.push(tx);
|
||||
return;
|
||||
|
||||
@@ -2,12 +2,21 @@ const configFile = require('../mempool-config.json');
|
||||
|
||||
interface IConfig {
|
||||
MEMPOOL: {
|
||||
NETWORK: 'mainnet' | 'testnet' | 'liquid';
|
||||
NETWORK: 'mainnet' | 'testnet' | 'signet' | 'liquid' | 'liquidtestnet';
|
||||
BACKEND: 'esplora' | 'electrum' | 'none';
|
||||
HTTP_PORT: number;
|
||||
SPAWN_CLUSTER_PROCS: number;
|
||||
API_URL_PREFIX: string;
|
||||
POLL_RATE_MS: number;
|
||||
CACHE_DIR: string;
|
||||
CLEAR_PROTECTION_MINUTES: number;
|
||||
RECOMMENDED_FEE_PERCENTILE: number;
|
||||
BLOCK_WEIGHT_UNITS: number;
|
||||
INITIAL_BLOCKS_AMOUNT: number;
|
||||
MEMPOOL_BLOCKS_AMOUNT: number;
|
||||
PRICE_FEED_UPDATE_INTERVAL: number;
|
||||
USE_SECOND_NODE_FOR_MINFEE: boolean;
|
||||
EXTERNAL_ASSETS: string[];
|
||||
};
|
||||
ESPLORA: {
|
||||
REST_API_URL: string;
|
||||
@@ -16,7 +25,6 @@ interface IConfig {
|
||||
HOST: string;
|
||||
PORT: number;
|
||||
TLS_ENABLED: boolean;
|
||||
TX_LOOKUPS: boolean;
|
||||
};
|
||||
CORE_RPC: {
|
||||
HOST: string;
|
||||
@@ -24,6 +32,12 @@ interface IConfig {
|
||||
USERNAME: string;
|
||||
PASSWORD: string;
|
||||
};
|
||||
SECOND_CORE_RPC: {
|
||||
HOST: string;
|
||||
PORT: number;
|
||||
USERNAME: string;
|
||||
PASSWORD: string;
|
||||
};
|
||||
DATABASE: {
|
||||
ENABLED: boolean;
|
||||
HOST: string,
|
||||
@@ -32,25 +46,21 @@ interface IConfig {
|
||||
USERNAME: string;
|
||||
PASSWORD: string;
|
||||
};
|
||||
SYSLOG: {
|
||||
ENABLED: boolean;
|
||||
HOST: string;
|
||||
PORT: number;
|
||||
MIN_PRIORITY: 'emerg' | 'alert' | 'crit' | 'err' |'warn' | 'notice' | 'info' | 'debug';
|
||||
FACILITY: string;
|
||||
};
|
||||
STATISTICS: {
|
||||
ENABLED: boolean;
|
||||
TX_PER_SECOND_SAMPLE_PERIOD: number;
|
||||
};
|
||||
BISQ_BLOCKS: {
|
||||
BISQ: {
|
||||
ENABLED: boolean;
|
||||
DATA_PATH: string;
|
||||
};
|
||||
BISQ_MARKETS: {
|
||||
ENABLED: boolean;
|
||||
DATA_PATH: string;
|
||||
};
|
||||
SPONSORS: {
|
||||
ENABLED: boolean;
|
||||
BTCPAY_URL: string;
|
||||
BTCPAY_AUTH: string;
|
||||
BTCPAY_WEBHOOK_URL: string;
|
||||
TWITTER_BEARER_AUTH: string;
|
||||
};
|
||||
}
|
||||
|
||||
const defaults: IConfig = {
|
||||
@@ -60,7 +70,16 @@ const defaults: IConfig = {
|
||||
'HTTP_PORT': 8999,
|
||||
'SPAWN_CLUSTER_PROCS': 0,
|
||||
'API_URL_PREFIX': '/api/v1/',
|
||||
'POLL_RATE_MS': 2000
|
||||
'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,
|
||||
'PRICE_FEED_UPDATE_INTERVAL': 3600,
|
||||
'USE_SECOND_NODE_FOR_MINFEE': false,
|
||||
'EXTERNAL_ASSETS': [],
|
||||
},
|
||||
'ESPLORA': {
|
||||
'REST_API_URL': 'http://127.0.0.1:3000',
|
||||
@@ -69,7 +88,6 @@ const defaults: IConfig = {
|
||||
'HOST': '127.0.0.1',
|
||||
'PORT': 3306,
|
||||
'TLS_ENABLED': true,
|
||||
'TX_LOOKUPS': false
|
||||
},
|
||||
'CORE_RPC': {
|
||||
'HOST': '127.0.0.1',
|
||||
@@ -77,33 +95,35 @@ const defaults: IConfig = {
|
||||
'USERNAME': 'mempool',
|
||||
'PASSWORD': 'mempool'
|
||||
},
|
||||
'SECOND_CORE_RPC': {
|
||||
'HOST': '127.0.0.1',
|
||||
'PORT': 8332,
|
||||
'USERNAME': 'mempool',
|
||||
'PASSWORD': 'mempool'
|
||||
},
|
||||
'DATABASE': {
|
||||
'ENABLED': true,
|
||||
'HOST': 'localhost',
|
||||
'HOST': '127.0.0.1',
|
||||
'PORT': 3306,
|
||||
'DATABASE': 'mempool',
|
||||
'USERNAME': 'mempool',
|
||||
'PASSWORD': 'mempool'
|
||||
},
|
||||
'SYSLOG': {
|
||||
'ENABLED': true,
|
||||
'HOST': '127.0.0.1',
|
||||
'PORT': 514,
|
||||
'MIN_PRIORITY': 'info',
|
||||
'FACILITY': 'local7'
|
||||
},
|
||||
'STATISTICS': {
|
||||
'ENABLED': true,
|
||||
'TX_PER_SECOND_SAMPLE_PERIOD': 150
|
||||
},
|
||||
'BISQ_BLOCKS': {
|
||||
'ENABLED': false,
|
||||
'DATA_PATH': '/bisq/statsnode-data/btc_mainnet/db/json'
|
||||
},
|
||||
'BISQ_MARKETS': {
|
||||
'BISQ': {
|
||||
'ENABLED': false,
|
||||
'DATA_PATH': '/bisq/statsnode-data/btc_mainnet/db'
|
||||
},
|
||||
'SPONSORS': {
|
||||
'ENABLED': false,
|
||||
'BTCPAY_URL': '',
|
||||
'BTCPAY_AUTH': '',
|
||||
'BTCPAY_WEBHOOK_URL': '',
|
||||
'TWITTER_BEARER_AUTH': ''
|
||||
}
|
||||
};
|
||||
|
||||
class Config implements IConfig {
|
||||
@@ -111,11 +131,11 @@ class Config implements IConfig {
|
||||
ESPLORA: IConfig['ESPLORA'];
|
||||
ELECTRUM: IConfig['ELECTRUM'];
|
||||
CORE_RPC: IConfig['CORE_RPC'];
|
||||
SECOND_CORE_RPC: IConfig['SECOND_CORE_RPC'];
|
||||
DATABASE: IConfig['DATABASE'];
|
||||
SYSLOG: IConfig['SYSLOG'];
|
||||
STATISTICS: IConfig['STATISTICS'];
|
||||
BISQ_BLOCKS: IConfig['BISQ_BLOCKS'];
|
||||
BISQ_MARKETS: IConfig['BISQ_MARKETS'];
|
||||
SPONSORS: IConfig['SPONSORS'];
|
||||
BISQ: IConfig['BISQ'];
|
||||
|
||||
constructor() {
|
||||
const configs = this.merge(configFile, defaults);
|
||||
@@ -123,11 +143,11 @@ class Config implements IConfig {
|
||||
this.ESPLORA = configs.ESPLORA;
|
||||
this.ELECTRUM = configs.ELECTRUM;
|
||||
this.CORE_RPC = configs.CORE_RPC;
|
||||
this.SECOND_CORE_RPC = configs.SECOND_CORE_RPC;
|
||||
this.DATABASE = configs.DATABASE;
|
||||
this.SYSLOG = configs.SYSLOG;
|
||||
this.STATISTICS = configs.STATISTICS;
|
||||
this.BISQ_BLOCKS = configs.BISQ_BLOCKS;
|
||||
this.BISQ_MARKETS = configs.BISQ_MARKETS;
|
||||
this.SPONSORS = configs.SPONSORS;
|
||||
this.BISQ = configs.BISQ;
|
||||
}
|
||||
|
||||
merge = (...objects: object[]): IConfig => {
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function checkDbConnection() {
|
||||
logger.info('Database connection established.');
|
||||
connection.release();
|
||||
} catch (e) {
|
||||
logger.err('Could not connect to database: ' + e.message || e);
|
||||
logger.err('Could not connect to database: ' + (e instanceof Error ? e.message : e));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Express, Request, Response, NextFunction } from 'express';
|
||||
import * as express from 'express';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import * as WebSocket from 'ws';
|
||||
import * as cluster from 'cluster';
|
||||
import axios from 'axios';
|
||||
@@ -17,14 +16,19 @@ 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 donations from './api/donations';
|
||||
import logger from './logger';
|
||||
import backendInfo from './api/backend-info';
|
||||
import loadingIndicators from './api/loading-indicators';
|
||||
import mempool from './api/mempool';
|
||||
import elementsParser from './api/liquid/elements-parser';
|
||||
import databaseMigration from './api/database-migration';
|
||||
import syncAssets from './sync-assets';
|
||||
import icons from './api/liquid/icons';
|
||||
import { Common } from './api/common';
|
||||
|
||||
class Server {
|
||||
private wss: WebSocket.Server | undefined;
|
||||
private server: https.Server | http.Server | undefined;
|
||||
private server: http.Server | undefined;
|
||||
private app: Express;
|
||||
private currentBackendRetryInterval = 5;
|
||||
|
||||
@@ -60,40 +64,52 @@ class Server {
|
||||
}
|
||||
}
|
||||
|
||||
startServer(worker = false) {
|
||||
async startServer(worker = false) {
|
||||
logger.debug(`Starting Mempool Server${worker ? ' (worker)' : ''}... (${backendInfo.getShortCommitHash()})`);
|
||||
|
||||
this.app
|
||||
.use((req: Request, res: Response, next: NextFunction) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
next();
|
||||
})
|
||||
.use(express.urlencoded({ extended: true }))
|
||||
.use(express.json());
|
||||
.use(express.text())
|
||||
;
|
||||
|
||||
this.server = http.createServer(this.app);
|
||||
this.wss = new WebSocket.Server({ server: this.server });
|
||||
|
||||
this.setUpWebsocketHandling();
|
||||
|
||||
await syncAssets.syncAssets();
|
||||
diskCache.loadMempoolCache();
|
||||
|
||||
if (config.DATABASE.ENABLED) {
|
||||
checkDbConnection();
|
||||
await checkDbConnection();
|
||||
try {
|
||||
await databaseMigration.$initializeOrMigrateDatabase();
|
||||
} catch (e) {
|
||||
throw new Error(e instanceof Error ? e.message : 'Error');
|
||||
}
|
||||
}
|
||||
|
||||
if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED) {
|
||||
if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED && cluster.isMaster) {
|
||||
statistics.startStatistics();
|
||||
}
|
||||
|
||||
this.setUpHttpApiRoutes();
|
||||
this.setUpWebsocketHandling();
|
||||
this.runMainUpdateLoop();
|
||||
if (Common.isLiquid()) {
|
||||
icons.loadIcons();
|
||||
}
|
||||
|
||||
fiatConversion.startService();
|
||||
diskCache.loadMempoolCache();
|
||||
|
||||
if (config.BISQ_BLOCKS.ENABLED) {
|
||||
this.setUpHttpApiRoutes();
|
||||
this.runMainUpdateLoop();
|
||||
|
||||
if (config.BISQ.ENABLED) {
|
||||
bisq.startBisqService();
|
||||
bisq.setPriceCallbackFunction((price) => websocketHandler.setExtraInitProperties('bsq-price', price));
|
||||
blocks.setNewBlockCallback(bisq.handleNewBitcoinBlock.bind(bisq));
|
||||
}
|
||||
|
||||
if (config.BISQ_MARKETS.ENABLED) {
|
||||
bisqMarkets.startBisqService();
|
||||
}
|
||||
|
||||
@@ -101,22 +117,32 @@ class Server {
|
||||
if (worker) {
|
||||
logger.info(`Mempool Server worker #${process.pid} started`);
|
||||
} else {
|
||||
logger.notice(`Mempool Server is running on port ${config.MEMPOOL.HTTP_PORT} (${backendInfo.getShortCommitHash()})`);
|
||||
logger.notice(`Mempool Server is running on port ${config.MEMPOOL.HTTP_PORT}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async runMainUpdateLoop() {
|
||||
try {
|
||||
await memPool.$updateMemPoolInfo();
|
||||
try {
|
||||
await memPool.$updateMemPoolInfo();
|
||||
} catch (e) {
|
||||
const msg = `updateMempoolInfo: ${(e instanceof Error ? e.message : e)}`;
|
||||
if (config.MEMPOOL.USE_SECOND_NODE_FOR_MINFEE) {
|
||||
logger.warn(msg);
|
||||
} else {
|
||||
logger.debug(msg);
|
||||
}
|
||||
}
|
||||
await blocks.$updateBlocks();
|
||||
await memPool.$updateMempool();
|
||||
setTimeout(this.runMainUpdateLoop.bind(this), config.MEMPOOL.POLL_RATE_MS);
|
||||
this.currentBackendRetryInterval = 5;
|
||||
} catch (e) {
|
||||
const loggerMsg = `runMainLoop error: ${(e.message || e)}. Retrying in ${this.currentBackendRetryInterval} sec.`;
|
||||
const loggerMsg = `runMainLoop error: ${(e instanceof Error ? e.message : e)}. Retrying in ${this.currentBackendRetryInterval} sec.`;
|
||||
if (this.currentBackendRetryInterval > 5) {
|
||||
logger.warn(loggerMsg);
|
||||
mempool.setOutOfSync();
|
||||
} else {
|
||||
logger.debug(loggerMsg);
|
||||
}
|
||||
@@ -131,11 +157,19 @@ class Server {
|
||||
if (this.wss) {
|
||||
websocketHandler.setWebsocketServer(this.wss);
|
||||
}
|
||||
if (Common.isLiquid() && config.DATABASE.ENABLED) {
|
||||
blocks.setNewBlockCallback(async () => {
|
||||
try {
|
||||
await elementsParser.$parse();
|
||||
} catch (e) {
|
||||
logger.warn('Elements parsing error: ' + (e instanceof Error ? e.message : e));
|
||||
}
|
||||
});
|
||||
}
|
||||
websocketHandler.setupConnectionHandling();
|
||||
statistics.setNewStatisticsEntryCallback(websocketHandler.handleNewStatistic.bind(websocketHandler));
|
||||
blocks.setNewBlockCallback(websocketHandler.handleNewBlock.bind(websocketHandler));
|
||||
memPool.setMempoolChangedCallback(websocketHandler.handleMempoolChange.bind(websocketHandler));
|
||||
donations.setNotfyDonationStatusCallback(websocketHandler.handleNewDonation.bind(websocketHandler));
|
||||
fiatConversion.setProgressChangedCallback(websocketHandler.handleNewConversionRates.bind(websocketHandler));
|
||||
loadingIndicators.setProgressChangedCallback(websocketHandler.handleLoadingChanged.bind(websocketHandler));
|
||||
}
|
||||
@@ -143,25 +177,85 @@ class Server {
|
||||
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('https://mempool.space/api/v1/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('https://mempool.space/api/v1/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('https://mempool.space/api/v1/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('https://mempool.space/api/v1/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('https://mempool.space/api/v1/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('https://mempool.space/api/v1/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.get2HStatistics)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/24h', routes.get24HStatistics.bind(routes))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/1w', routes.get1WHStatistics.bind(routes))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/1m', routes.get1MStatistics.bind(routes))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/3m', routes.get3MStatistics.bind(routes))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/6m', routes.get6MStatistics.bind(routes))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/1y', routes.get1YStatistics.bind(routes))
|
||||
.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'))
|
||||
;
|
||||
}
|
||||
|
||||
if (config.BISQ_BLOCKS.ENABLED) {
|
||||
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)
|
||||
@@ -170,11 +264,6 @@ class Server {
|
||||
.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)
|
||||
;
|
||||
}
|
||||
|
||||
if (config.BISQ_MARKETS.ENABLED) {
|
||||
this.app
|
||||
.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))
|
||||
@@ -183,44 +272,28 @@ class Server {
|
||||
.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))
|
||||
;
|
||||
}
|
||||
|
||||
if (config.SPONSORS.ENABLED) {
|
||||
this.app
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'donations', routes.getDonations.bind(routes))
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'donations/images/:id', routes.getSponsorImage.bind(routes))
|
||||
.post(config.MEMPOOL.API_URL_PREFIX + 'donations', routes.createDonationRequest.bind(routes))
|
||||
.post(config.MEMPOOL.API_URL_PREFIX + 'donations-webhook', routes.donationWebhook.bind(routes))
|
||||
;
|
||||
} else {
|
||||
this.app
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'donations', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get('https://mempool.space/api/v1/donations', { responseType: 'stream' });
|
||||
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('https://mempool.space/api/v1/donations/images/' + req.params.id, { responseType: 'stream' });
|
||||
response.data.pipe(res);
|
||||
} catch (e) {
|
||||
res.status(500).end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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', routes.getBlock)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks/:height', routes.getBlocks)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'block/:hash/header', routes.getBlockHeader)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks', routes.getBlocks)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'blocks/:height', routes.getBlocks)
|
||||
.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)
|
||||
@@ -228,6 +301,19 @@ class Server {
|
||||
.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 + 'asset/:assetId/icon', routes.getLiquidIcon)
|
||||
;
|
||||
}
|
||||
|
||||
if (Common.isLiquid() && config.DATABASE.ENABLED) {
|
||||
this.app
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/pegs/month', routes.$getElementsPegsByMonth)
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,17 +50,11 @@ class Logger {
|
||||
public debug: ((msg: string) => void);
|
||||
|
||||
private name = 'mempool';
|
||||
private fac: any;
|
||||
private loghost: string;
|
||||
private logport: number;
|
||||
private client: dgram.Socket;
|
||||
private network: string;
|
||||
|
||||
constructor(fac) {
|
||||
constructor() {
|
||||
let prio;
|
||||
this.fac = fac != null ? fac : Logger.facilities.local0;
|
||||
this.loghost = '127.0.0.1';
|
||||
this.logport = 514;
|
||||
for (prio in Logger.priorities) {
|
||||
if (true) {
|
||||
this.addprio(prio);
|
||||
@@ -79,7 +73,7 @@ class Logger {
|
||||
}
|
||||
|
||||
private getNetwork(): string {
|
||||
if (config.BISQ_BLOCKS.ENABLED) {
|
||||
if (config.BISQ.ENABLED) {
|
||||
return 'bisq';
|
||||
}
|
||||
if (config.MEMPOOL.NETWORK && config.MEMPOOL.NETWORK !== 'mainnet') {
|
||||
@@ -97,10 +91,12 @@ class Logger {
|
||||
}
|
||||
const network = this.network ? ' <' + this.network + '>' : '';
|
||||
prionum = Logger.priorities[priority] || Logger.priorities.info;
|
||||
syslogmsg = `<${(this.fac * 8 + prionum)}> ${this.name}[${process.pid}]: ${priority.toUpperCase()}${network} ${msg}`;
|
||||
consolemsg = `${this.ts()} [${process.pid}] ${priority.toUpperCase()}:${network} ${msg}`;
|
||||
|
||||
this.syslog(syslogmsg);
|
||||
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}`;
|
||||
this.syslog(syslogmsg);
|
||||
}
|
||||
if (priority === 'warning') {
|
||||
priority = 'warn';
|
||||
}
|
||||
@@ -116,7 +112,7 @@ class Logger {
|
||||
private syslog(msg) {
|
||||
let msgbuf;
|
||||
msgbuf = Buffer.from(msg);
|
||||
this.client.send(msgbuf, 0, msgbuf.length, this.logport, this.loghost, function(err, bytes) {
|
||||
this.client.send(msgbuf, 0, msgbuf.length, config.SYSLOG.PORT, config.SYSLOG.HOST, function(err, bytes) {
|
||||
if (err) {
|
||||
console.log(err);
|
||||
}
|
||||
@@ -146,4 +142,4 @@ class Logger {
|
||||
}
|
||||
}
|
||||
|
||||
export default new Logger(Logger.facilities.local7);
|
||||
export default new Logger();
|
||||
|
||||
@@ -26,12 +26,34 @@ export interface TransactionExtended extends IEsploraApi.Transaction {
|
||||
vsize: number;
|
||||
feePerVsize: number;
|
||||
firstSeen?: number;
|
||||
effectiveFeePerVsize: number;
|
||||
ancestors?: Ancestor[];
|
||||
bestDescendant?: BestDescendant | null;
|
||||
cpfpChecked?: boolean;
|
||||
deleteAfter?: number;
|
||||
}
|
||||
|
||||
interface Ancestor {
|
||||
txid: string;
|
||||
weight: number;
|
||||
fee: number;
|
||||
}
|
||||
|
||||
interface BestDescendant {
|
||||
txid: string;
|
||||
weight: number;
|
||||
fee: number;
|
||||
}
|
||||
|
||||
export interface CpfpInfo {
|
||||
ancestors: Ancestor[];
|
||||
bestDescendant: BestDescendant | null;
|
||||
}
|
||||
|
||||
export interface TransactionStripped {
|
||||
txid: string;
|
||||
fee: number;
|
||||
weight: number;
|
||||
vsize: number;
|
||||
value: number;
|
||||
}
|
||||
export interface BlockExtended extends IEsploraApi.Block {
|
||||
@@ -106,10 +128,7 @@ export interface Statistic {
|
||||
}
|
||||
|
||||
export interface OptimizedStatistic {
|
||||
id: number;
|
||||
added: string;
|
||||
unconfirmed_transactions: number;
|
||||
tx_per_second: number;
|
||||
vbytes_per_second: number;
|
||||
total_fee: number;
|
||||
mempool_byte_weight: number;
|
||||
@@ -122,6 +141,7 @@ export interface WebsocketResponse {
|
||||
'track-tx': string;
|
||||
'track-address': string;
|
||||
'watch-mempool': boolean;
|
||||
'track-bisq-market': string;
|
||||
}
|
||||
|
||||
export interface VbytesPerSecond {
|
||||
@@ -138,3 +158,9 @@ interface RequiredParams {
|
||||
|
||||
export interface ILoadingIndicators { [name: string]: number; }
|
||||
export interface IConversionRates { [currency: string]: number; }
|
||||
|
||||
export interface IBackendInfo {
|
||||
hostname: string;
|
||||
gitCommit: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
@@ -8,77 +8,78 @@ import mempool from './api/mempool';
|
||||
import bisq from './api/bisq/bisq';
|
||||
import websocketHandler from './api/websocket-handler';
|
||||
import bisqMarket from './api/bisq/markets-api';
|
||||
import { OptimizedStatistic, RequiredSpec, TransactionExtended } from './mempool.interfaces';
|
||||
import { RequiredSpec, TransactionExtended } from './mempool.interfaces';
|
||||
import { MarketsApiError } from './api/bisq/interfaces';
|
||||
import { IEsploraApi } from './api/bitcoin/esplora-api.interface';
|
||||
import donations from './api/donations';
|
||||
import logger from './logger';
|
||||
import bitcoinApi from './api/bitcoin/bitcoin-api-factory';
|
||||
import transactionUtils from './api/transaction-utils';
|
||||
import blocks from './api/blocks';
|
||||
import loadingIndicators from './api/loading-indicators';
|
||||
import { Common } from './api/common';
|
||||
import bitcoinClient from './api/bitcoin/bitcoin-client';
|
||||
import elementsParser from './api/liquid/elements-parser';
|
||||
import icons from './api/liquid/icons';
|
||||
|
||||
class Routes {
|
||||
private cache: { [date: string]: OptimizedStatistic[] } = {
|
||||
'24h': [], '1w': [], '1m': [], '3m': [], '6m': [], '1y': [],
|
||||
};
|
||||
constructor() {}
|
||||
|
||||
constructor() {
|
||||
if (config.DATABASE.ENABLED && config.STATISTICS.ENABLED) {
|
||||
this.createCache();
|
||||
setInterval(this.createCache.bind(this), 600000);
|
||||
public async $getStatisticsByTime(time: '2h' | '24h' | '1w' | '1m' | '3m' | '6m' | '1y' | '2y' | '3y', 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 statistics.$list2H();
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 30).toUTCString());
|
||||
break;
|
||||
case '24h':
|
||||
result = await statistics.$list24H();
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 60).toUTCString());
|
||||
break;
|
||||
case '1w':
|
||||
result = await statistics.$list1W();
|
||||
break;
|
||||
case '1m':
|
||||
result = await statistics.$list1M();
|
||||
break;
|
||||
case '3m':
|
||||
result = await statistics.$list3M();
|
||||
break;
|
||||
case '6m':
|
||||
result = await statistics.$list6M();
|
||||
break;
|
||||
case '1y':
|
||||
result = await statistics.$list1Y();
|
||||
break;
|
||||
case '2y':
|
||||
result = await statistics.$list2Y();
|
||||
break;
|
||||
case '3y':
|
||||
result = await statistics.$list3Y();
|
||||
break;
|
||||
default:
|
||||
result = await statistics.$list2H();
|
||||
}
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async createCache() {
|
||||
this.cache['24h'] = await statistics.$list24H();
|
||||
this.cache['1w'] = await statistics.$list1W();
|
||||
this.cache['1m'] = await statistics.$list1M();
|
||||
this.cache['3m'] = await statistics.$list3M();
|
||||
this.cache['6m'] = await statistics.$list6M();
|
||||
this.cache['1y'] = await statistics.$list1Y();
|
||||
logger.debug('Statistics cache created');
|
||||
}
|
||||
|
||||
public async get2HStatistics(req: Request, res: Response) {
|
||||
const result = await statistics.$list2H();
|
||||
res.json(result);
|
||||
}
|
||||
|
||||
public get24HStatistics(req: Request, res: Response) {
|
||||
res.json(this.cache['24h']);
|
||||
}
|
||||
|
||||
public get1WHStatistics(req: Request, res: Response) {
|
||||
res.json(this.cache['1w']);
|
||||
}
|
||||
|
||||
public get1MStatistics(req: Request, res: Response) {
|
||||
res.json(this.cache['1m']);
|
||||
}
|
||||
|
||||
public get3MStatistics(req: Request, res: Response) {
|
||||
res.json(this.cache['3m']);
|
||||
}
|
||||
|
||||
public get6MStatistics(req: Request, res: Response) {
|
||||
res.json(this.cache['6m']);
|
||||
}
|
||||
|
||||
public get1YStatistics(req: Request, res: Response) {
|
||||
res.json(this.cache['1y']);
|
||||
}
|
||||
|
||||
public getInitData(req: Request, res: Response) {
|
||||
try {
|
||||
const result = websocketHandler.getInitData();
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).send(e.message);
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
public async getRecommendedFees(req: Request, res: Response) {
|
||||
public getRecommendedFees(req: Request, res: Response) {
|
||||
if (!mempool.isInSync()) {
|
||||
res.statusCode = 503;
|
||||
res.send('Service Unavailable');
|
||||
@@ -93,7 +94,7 @@ class Routes {
|
||||
const result = mempoolBlocks.getMempoolBlocks();
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).send(e.message);
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,83 +114,35 @@ class Routes {
|
||||
res.json(times);
|
||||
}
|
||||
|
||||
public 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) {
|
||||
res.status(404).send(`Transaction doesn't exist in the mempool.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (tx.cpfpChecked) {
|
||||
res.json({
|
||||
ancestors: tx.ancestors,
|
||||
bestDescendant: tx.bestDescendant || null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const cpfpInfo = Common.setRelativesAndGetCpfpInfo(tx, mempool.getMempool());
|
||||
|
||||
res.json(cpfpInfo);
|
||||
}
|
||||
|
||||
public getBackendInfo(req: Request, res: Response) {
|
||||
res.json(backendInfo.getBackendInfo());
|
||||
}
|
||||
|
||||
public async createDonationRequest(req: Request, res: Response) {
|
||||
const constraints: RequiredSpec = {
|
||||
'amount': {
|
||||
required: true,
|
||||
types: ['@float']
|
||||
},
|
||||
'orderId': {
|
||||
required: true,
|
||||
types: ['@string']
|
||||
}
|
||||
};
|
||||
|
||||
const p = this.parseRequestParameters(req.body, constraints);
|
||||
if (p.error) {
|
||||
res.status(400).send(p.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (p.orderId !== '' && !/^(@|)[a-zA-Z0-9_]{1,15}$/.test(p.orderId)) {
|
||||
res.status(400).send('Invalid Twitter handle');
|
||||
return;
|
||||
}
|
||||
|
||||
if (p.amount < 0.001) {
|
||||
res.status(400).send('Amount needs to be at least 0.001');
|
||||
return;
|
||||
}
|
||||
|
||||
if (p.amount > 1000) {
|
||||
res.status(400).send('Amount too large');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await donations.$createRequest(p.amount, p.orderId);
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).send(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
public async getDonations(req: Request, res: Response) {
|
||||
try {
|
||||
const result = await donations.$getDonationsFromDatabase('handle, imageUrl');
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).send(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
public async getSponsorImage(req: Request, res: Response) {
|
||||
try {
|
||||
const result = await donations.getSponsorImage(req.params.id);
|
||||
if (result) {
|
||||
res.set('Content-Type', 'image/jpeg');
|
||||
res.send(result);
|
||||
} else {
|
||||
res.status(404).end();
|
||||
}
|
||||
} catch (e) {
|
||||
res.status(500).send(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
public async donationWebhook(req: Request, res: Response) {
|
||||
try {
|
||||
donations.$handleWebhookRequest(req.body);
|
||||
res.end();
|
||||
} catch (e) {
|
||||
res.status(500).send(e);
|
||||
}
|
||||
}
|
||||
|
||||
public getBisqStats(req: Request, res: Response) {
|
||||
const result = bisq.getStats();
|
||||
res.json(result);
|
||||
@@ -493,6 +446,15 @@ class Routes {
|
||||
}
|
||||
}
|
||||
|
||||
public getBisqMarketVolumes7d(req: Request, res: Response) {
|
||||
const result = bisqMarket.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) {
|
||||
@@ -531,15 +493,41 @@ class Routes {
|
||||
|
||||
public async getTransaction(req: Request, res: Response) {
|
||||
try {
|
||||
const transaction = await transactionUtils.$getTransactionExtended(req.params.txId, false, true);
|
||||
|
||||
if (transaction) {
|
||||
res.json(transaction);
|
||||
} else {
|
||||
res.status(500).send('Error fetching transaction.');
|
||||
}
|
||||
const transaction = await transactionUtils.$getTransactionExtended(req.params.txId, true);
|
||||
res.json(transaction);
|
||||
} catch (e) {
|
||||
res.status(500).send(e.message || 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);
|
||||
}
|
||||
}
|
||||
|
||||
public 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);
|
||||
}
|
||||
}
|
||||
|
||||
public 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,7 +536,17 @@ class Routes {
|
||||
const result = await bitcoinApi.$getBlock(req.params.hash);
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).send(e.message || e);
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
public 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -585,7 +583,7 @@ class Routes {
|
||||
res.json(returnBlocks);
|
||||
} catch (e) {
|
||||
loadingIndicators.setProgress('blocks', 100);
|
||||
res.status(500).send(e.message || e);
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -595,20 +593,22 @@ class Routes {
|
||||
|
||||
const txIds = await bitcoinApi.$getTxIdsForBlock(req.params.hash);
|
||||
const transactions: TransactionExtended[] = [];
|
||||
const startingIndex = Math.max(0, parseInt(req.params.index, 10));
|
||||
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++) {
|
||||
const transaction = await transactionUtils.$getTransactionExtended(txIds[i], false, true);
|
||||
if (transaction) {
|
||||
try {
|
||||
const transaction = await transactionUtils.$getTransactionExtended(txIds[i], true);
|
||||
transactions.push(transaction);
|
||||
loadingIndicators.setProgress('blocktxs-' + req.params.hash, (i + 1) / endIndex * 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.message || e);
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -617,7 +617,7 @@ class Routes {
|
||||
const blockHash = await bitcoinApi.$getBlockHash(parseInt(req.params.height, 10));
|
||||
res.send(blockHash);
|
||||
} catch (e) {
|
||||
res.status(500).send(e.message || e);
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -631,10 +631,10 @@ class Routes {
|
||||
const addressData = await bitcoinApi.$getAddress(req.params.address);
|
||||
res.json(addressData);
|
||||
} catch (e) {
|
||||
if (e.message && e.message.indexOf('exceeds') > 0) {
|
||||
return res.status(413).send(e.message);
|
||||
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.message || e);
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,10 +648,10 @@ class Routes {
|
||||
const transactions = await bitcoinApi.$getAddressTransactions(req.params.address, req.params.txId);
|
||||
res.json(transactions);
|
||||
} catch (e) {
|
||||
if (e.message && e.message.indexOf('exceeds') > 0) {
|
||||
return res.status(413).send(e.message);
|
||||
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.message || e);
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -664,13 +664,184 @@ class Routes {
|
||||
const blockHash = await bitcoinApi.$getAddressPrefix(req.params.prefix);
|
||||
res.send(blockHash);
|
||||
} catch (e) {
|
||||
res.status(500).send(e.message || e);
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
public getTransactionOutspends(req: Request, res: Response) {
|
||||
public 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);
|
||||
}
|
||||
|
||||
public async getMempool(req: Request, res: Response) {
|
||||
res.status(501).send('Not implemented');
|
||||
}
|
||||
|
||||
public 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);
|
||||
}
|
||||
}
|
||||
|
||||
public 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);
|
||||
}
|
||||
}
|
||||
|
||||
public 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);
|
||||
}
|
||||
}
|
||||
|
||||
public 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);
|
||||
}
|
||||
}
|
||||
|
||||
public 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);
|
||||
}
|
||||
}
|
||||
|
||||
public getDifficultyChange(req: Request, res: Response) {
|
||||
try {
|
||||
const DATime = blocks.getLastDifficultyAdjustmentTime();
|
||||
const previousRetarget = blocks.getPreviousDifficultyRetarget();
|
||||
const blockHeight = blocks.getCurrentBlockHeight();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const timeAvgDiff = difficultyChange * 0.1;
|
||||
|
||||
let timeAvgMins = 10;
|
||||
if (timeAvgDiff > 0) {
|
||||
timeAvgMins -= Math.abs(timeAvgDiff);
|
||||
} else {
|
||||
timeAvgMins += Math.abs(timeAvgDiff);
|
||||
}
|
||||
|
||||
const timeAvg = timeAvgMins * 60;
|
||||
const remainingTime = remainingBlocks * timeAvg;
|
||||
const estimatedRetargetDate = remainingTime + now;
|
||||
|
||||
const result = {
|
||||
progressPercent,
|
||||
difficultyChange,
|
||||
estimatedRetargetDate,
|
||||
remainingBlocks,
|
||||
remainingTime,
|
||||
previousRetarget,
|
||||
nextRetargetHeight,
|
||||
timeAvg,
|
||||
};
|
||||
res.json(result);
|
||||
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
public 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);
|
||||
}
|
||||
}
|
||||
|
||||
public 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'));
|
||||
}
|
||||
}
|
||||
|
||||
public 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'));
|
||||
}
|
||||
}
|
||||
|
||||
public 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');
|
||||
}
|
||||
}
|
||||
|
||||
public getAllLiquidIcon(req: Request, res: Response) {
|
||||
const result = icons.getAllIconIds();
|
||||
if (result) {
|
||||
res.json(result);
|
||||
} else {
|
||||
res.status(404).send('Asset icons not found');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new Routes();
|
||||
|
||||
32
backend/src/sync-assets.ts
Normal file
32
backend/src/sync-assets.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import axios from 'axios';
|
||||
import * as fs from 'fs';
|
||||
const fsPromises = fs.promises;
|
||||
import config from './config';
|
||||
import logger from './logger';
|
||||
|
||||
const PATH = './';
|
||||
|
||||
class SyncAssets {
|
||||
constructor() { }
|
||||
|
||||
public async syncAssets() {
|
||||
for (const url of config.MEMPOOL.EXTERNAL_ASSETS) {
|
||||
await this.downloadFile(url);
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadFile(url: string) {
|
||||
const fileName = url.split('/').slice(-1)[0];
|
||||
logger.info(`Downloading external asset: ${fileName}...`);
|
||||
try {
|
||||
const response = await axios.get(url, {
|
||||
responseType: 'stream', timeout: 30000
|
||||
});
|
||||
await fsPromises.writeFile(PATH + fileName, response.data);
|
||||
} catch (e: any) {
|
||||
throw new Error(`Failed to download external asset. ` + e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new SyncAssets();
|
||||
@@ -2,7 +2,7 @@
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "esnext",
|
||||
"lib": ["es2019"],
|
||||
"lib": ["es2019", "dom"],
|
||||
"strict": true,
|
||||
"noImplicitAny": false,
|
||||
"sourceMap": false,
|
||||
|
||||
26
docker/backend/Dockerfile
Normal file
26
docker/backend/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
FROM node:16.10.0-buster-slim AS builder
|
||||
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y build-essential python3 pkg-config
|
||||
RUN npm install
|
||||
RUN npm run build
|
||||
|
||||
FROM node:16.10.0-buster-slim
|
||||
|
||||
WORKDIR /backend
|
||||
|
||||
COPY --from=builder /build/ .
|
||||
|
||||
RUN chmod +x /backend/start.sh
|
||||
RUN chmod +x /backend/wait-for-it.sh
|
||||
|
||||
RUN chown -R 1000:1000 /backend && chmod -R 755 /backend
|
||||
|
||||
USER 1000
|
||||
|
||||
EXPOSE 8999
|
||||
|
||||
CMD ["/backend/start.sh"]
|
||||
62
docker/backend/mempool-config.json
Normal file
62
docker/backend/mempool-config.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"MEMPOOL": {
|
||||
"NETWORK": "__MEMPOOL_NETWORK__",
|
||||
"BACKEND": "__MEMPOOL_BACKEND__",
|
||||
"HTTP_PORT": __MEMPOOL_HTTP_PORT__,
|
||||
"SPAWN_CLUSTER_PROCS": __MEMPOOL_SPAWN_CLUSTER_PROCS__,
|
||||
"API_URL_PREFIX": "__MEMPOOL_API_URL_PREFIX__",
|
||||
"POLL_RATE_MS": __MEMPOOL_POLL_RATE_MS__,
|
||||
"CACHE_DIR": "__MEMPOOL_CACHE_DIR__",
|
||||
"CLEAR_PROTECTION_MINUTES": __MEMPOOL_CLEAR_PROTECTION_MINUTES__,
|
||||
"RECOMMENDED_FEE_PERCENTILE": __MEMPOOL_RECOMMENDED_FEE_PERCENTILE__,
|
||||
"BLOCK_WEIGHT_UNITS": __MEMPOOL_BLOCK_WEIGHT_UNITS__,
|
||||
"INITIAL_BLOCKS_AMOUNT": __MEMPOOL_INITIAL_BLOCKS_AMOUNT__,
|
||||
"MEMPOOL_BLOCKS_AMOUNT": __MEMPOOL_MEMPOOL_BLOCKS_AMOUNT__,
|
||||
"PRICE_FEED_UPDATE_INTERVAL": __MEMPOOL_PRICE_FEED_UPDATE_INTERVAL__,
|
||||
"USE_SECOND_NODE_FOR_MINFEE": __MEMPOOL_USE_SECOND_NODE_FOR_MINFEE__,
|
||||
"EXTERNAL_ASSETS": __MEMPOOL_EXTERNAL_ASSETS__
|
||||
},
|
||||
"CORE_RPC": {
|
||||
"HOST": "__CORE_RPC_HOST__",
|
||||
"PORT": __CORE_RPC_PORT__,
|
||||
"USERNAME": "__CORE_RPC_USERNAME__",
|
||||
"PASSWORD": "__CORE_RPC_PASSWORD__"
|
||||
},
|
||||
"ELECTRUM": {
|
||||
"HOST": "__ELECTRUM_HOST__",
|
||||
"PORT": __ELECTRUM_PORT__,
|
||||
"TLS_ENABLED": __ELECTRUM_TLS_ENABLED__
|
||||
},
|
||||
"ESPLORA": {
|
||||
"REST_API_URL": "__ESPLORA_REST_API_URL__"
|
||||
},
|
||||
"SECOND_CORE_RPC": {
|
||||
"HOST": "__SECOND_CORE_RPC_HOST__",
|
||||
"PORT": __SECOND_CORE_RPC_PORT__,
|
||||
"USERNAME": "__SECOND_CORE_RPC_USERNAME__",
|
||||
"PASSWORD": "__SECOND_CORE_RPC_PASSWORD__"
|
||||
},
|
||||
"DATABASE": {
|
||||
"ENABLED": __DATABASE_ENABLED__,
|
||||
"HOST": "__DATABASE_HOST__",
|
||||
"PORT": __DATABASE_PORT__,
|
||||
"DATABASE": "__DATABASE_DATABASE__",
|
||||
"USERNAME": "__DATABASE_USERNAME__",
|
||||
"PASSWORD": "__DATABASE_PASSWORD__"
|
||||
},
|
||||
"SYSLOG": {
|
||||
"ENABLED": __SYSLOG_ENABLED__,
|
||||
"HOST": "__SYSLOG_HOST__",
|
||||
"PORT": __SYSLOG_PORT__,
|
||||
"MIN_PRIORITY": "__SYSLOG_MIN_PRIORITY__",
|
||||
"FACILITY": "__SYSLOG_FACILITY__"
|
||||
},
|
||||
"STATISTICS": {
|
||||
"ENABLED": __STATISTICS_ENABLED__,
|
||||
"TX_PER_SECOND_SAMPLE_PERIOD": __STATISTICS_TX_PER_SECOND_SAMPLE_PERIOD__
|
||||
},
|
||||
"BISQ": {
|
||||
"ENABLED": __BISQ_ENABLED__,
|
||||
"DATA_PATH": "__BISQ_DATA_PATH__"
|
||||
}
|
||||
}
|
||||
116
docker/backend/start.sh
Normal file
116
docker/backend/start.sh
Normal file
@@ -0,0 +1,116 @@
|
||||
#!/bin/sh
|
||||
|
||||
# MEMPOOL
|
||||
__MEMPOOL_NETWORK__=${MEMPOOL_NETWORK:=mainnet}
|
||||
__MEMPOOL_BACKEND__=${MEMPOOL_BACKEND:=electrum}
|
||||
__MEMPOOL_HTTP_PORT__=${BACKEND_HTTP_PORT:=8999}
|
||||
__MEMPOOL_SPAWN_CLUSTER_PROCS__=${MEMPOOL_SPAWN_CLUSTER_PROCS:=0}
|
||||
__MEMPOOL_API_URL_PREFIX__=${MEMPOOL_API_URL_PREFIX:=/api/v1/}
|
||||
__MEMPOOL_POLL_RATE_MS__=${MEMPOOL_POLL_RATE_MS:=2000}
|
||||
__MEMPOOL_CACHE_DIR__=${MEMPOOL_CACHE_DIR:=./cache}
|
||||
__MEMPOOL_CLEAR_PROTECTION_MINUTES__=${MEMPOOL_CLEAR_PROTECTION_MINUTES:=20}
|
||||
__MEMPOOL_RECOMMENDED_FEE_PERCENTILE__=${MEMPOOL_RECOMMENDED_FEE_PERCENTILE:=50}
|
||||
__MEMPOOL_BLOCK_WEIGHT_UNITS__=${MEMPOOL_BLOCK_WEIGHT_UNITS:=4000000}
|
||||
__MEMPOOL_INITIAL_BLOCKS_AMOUNT__=${MEMPOOL_INITIAL_BLOCKS_AMOUNT:=8}
|
||||
__MEMPOOL_MEMPOOL_BLOCKS_AMOUNT__=${MEMPOOL_MEMPOOL_BLOCKS_AMOUNT:=8}
|
||||
__MEMPOOL_PRICE_FEED_UPDATE_INTERVAL__=${MEMPOOL_PRICE_FEED_UPDATE_INTERVAL:=3600}
|
||||
__MEMPOOL_USE_SECOND_NODE_FOR_MINFEE__=${MEMPOOL_USE_SECOND_NODE_FOR_MINFEE:=false}
|
||||
__MEMPOOL_EXTERNAL_ASSETS__=${MEMPOOL_EXTERNAL_ASSETS:=[]}
|
||||
|
||||
# CORE_RPC
|
||||
__CORE_RPC_HOST__=${CORE_RPC_HOST:=127.0.0.1}
|
||||
__CORE_RPC_PORT__=${CORE_RPC_PORT:=8332}
|
||||
__CORE_RPC_USERNAME__=${CORE_RPC_USERNAME:=mempool}
|
||||
__CORE_RPC_PASSWORD__=${CORE_RPC_PASSWORD:=mempool}
|
||||
|
||||
# ELECTRUM
|
||||
__ELECTRUM_HOST__=${ELECTRUM_HOST:=127.0.0.1}
|
||||
__ELECTRUM_PORT__=${ELECTRUM_PORT:=50002}
|
||||
__ELECTRUM_TLS_ENABLED__=${ELECTRUM_TLS:=false}
|
||||
|
||||
# ESPLORA
|
||||
__ESPLORA_REST_API_URL__=${ESPLORA_REST_API_URL:=http://127.0.0.1:3000}
|
||||
|
||||
# SECOND_CORE_RPC
|
||||
__SECOND_CORE_RPC_HOST__=${SECOND_CORE_RPC_HOST:=127.0.0.1}
|
||||
__SECOND_CORE_RPC_PORT__=${SECOND_CORE_RPC_PORT:=8332}
|
||||
__SECOND_CORE_RPC_USERNAME__=${SECOND_CORE_RPC_USERNAME:=mempool}
|
||||
__SECOND_CORE_RPC_PASSWORD__=${SECOND_CORE_RPC_PASSWORD:=mempool}
|
||||
|
||||
# DATABASE
|
||||
__DATABASE_ENABLED__=${DATABASE_ENABLED:=true}
|
||||
__DATABASE_HOST__=${DATABASE_HOST:=127.0.0.1}
|
||||
__DATABASE_PORT__=${DATABASE_PORT:=3306}
|
||||
__DATABASE_DATABASE__=${DATABASE_DATABASE:=mempool}
|
||||
__DATABASE_USERNAME__=${DATABASE_USERNAME:=mempool}
|
||||
__DATABASE_PASSWORD__=${DATABASE_PASSWORD:=mempool}
|
||||
|
||||
# SYSLOG
|
||||
__SYSLOG_ENABLED__=${SYSLOG_ENABLED:=false}
|
||||
__SYSLOG_HOST__=${SYSLOG_HOST:=127.0.0.1}
|
||||
__SYSLOG_PORT__=${SYSLOG_PORT:=514}
|
||||
__SYSLOG_MIN_PRIORITY__=${SYSLOG_MIN_PRIORITY:=info}
|
||||
__SYSLOG_FACILITY__=${SYSLOG_FACILITY:=local7}
|
||||
|
||||
# STATISTICS
|
||||
__STATISTICS_ENABLED__=${STATISTICS_ENABLED:=true}
|
||||
__STATISTICS_TX_PER_SECOND_SAMPLE_PERIOD__=${STATISTICS_TX_PER_SECOND_SAMPLE_PERIOD:=150}
|
||||
|
||||
# BISQ
|
||||
__BISQ_ENABLED__=${BISQ_ENABLED:=false}
|
||||
__BISQ_DATA_PATH__=${BISQ_DATA_PATH:=/bisq/statsnode-data/btc_mainnet/db}
|
||||
|
||||
mkdir -p "${__MEMPOOL_CACHE_DIR__}"
|
||||
|
||||
sed -i "s/__MEMPOOL_NETWORK__/${__MEMPOOL_NETWORK__}/g" mempool-config.json
|
||||
sed -i "s/__MEMPOOL_BACKEND__/${__MEMPOOL_BACKEND__}/g" mempool-config.json
|
||||
sed -i "s/__MEMPOOL_HTTP_PORT__/${__MEMPOOL_HTTP_PORT__}/g" mempool-config.json
|
||||
sed -i "s/__MEMPOOL_SPAWN_CLUSTER_PROCS__/${__MEMPOOL_SPAWN_CLUSTER_PROCS__}/g" mempool-config.json
|
||||
sed -i "s!__MEMPOOL_API_URL_PREFIX__!${__MEMPOOL_API_URL_PREFIX__}!g" mempool-config.json
|
||||
sed -i "s/__MEMPOOL_POLL_RATE_MS__/${__MEMPOOL_POLL_RATE_MS__}/g" mempool-config.json
|
||||
sed -i "s!__MEMPOOL_CACHE_DIR__!${__MEMPOOL_CACHE_DIR__}!g" mempool-config.json
|
||||
sed -i "s/__MEMPOOL_CLEAR_PROTECTION_MINUTES__/${__MEMPOOL_CLEAR_PROTECTION_MINUTES__}/g" mempool-config.json
|
||||
sed -i "s/__MEMPOOL_RECOMMENDED_FEE_PERCENTILE__/${__MEMPOOL_RECOMMENDED_FEE_PERCENTILE__}/g" mempool-config.json
|
||||
sed -i "s/__MEMPOOL_BLOCK_WEIGHT_UNITS__/${__MEMPOOL_BLOCK_WEIGHT_UNITS__}/g" mempool-config.json
|
||||
sed -i "s/__MEMPOOL_INITIAL_BLOCKS_AMOUNT__/${__MEMPOOL_INITIAL_BLOCKS_AMOUNT__}/g" mempool-config.json
|
||||
sed -i "s/__MEMPOOL_MEMPOOL_BLOCKS_AMOUNT__/${__MEMPOOL_MEMPOOL_BLOCKS_AMOUNT__}/g" mempool-config.json
|
||||
sed -i "s/__MEMPOOL_PRICE_FEED_UPDATE_INTERVAL__/${__MEMPOOL_PRICE_FEED_UPDATE_INTERVAL__}/g" mempool-config.json
|
||||
sed -i "s/__MEMPOOL_USE_SECOND_NODE_FOR_MINFEE__/${__MEMPOOL_USE_SECOND_NODE_FOR_MINFEE__}/g" mempool-config.json
|
||||
sed -i "s/__MEMPOOL_EXTERNAL_ASSETS__/${__MEMPOOL_EXTERNAL_ASSETS__}/g" mempool-config.json
|
||||
|
||||
sed -i "s/__CORE_RPC_HOST__/${__CORE_RPC_HOST__}/g" mempool-config.json
|
||||
sed -i "s/__CORE_RPC_PORT__/${__CORE_RPC_PORT__}/g" mempool-config.json
|
||||
sed -i "s/__CORE_RPC_USERNAME__/${__CORE_RPC_USERNAME__}/g" mempool-config.json
|
||||
sed -i "s/__CORE_RPC_PASSWORD__/${__CORE_RPC_PASSWORD__}/g" mempool-config.json
|
||||
|
||||
sed -i "s/__ELECTRUM_HOST__/${__ELECTRUM_HOST__}/g" mempool-config.json
|
||||
sed -i "s/__ELECTRUM_PORT__/${__ELECTRUM_PORT__}/g" mempool-config.json
|
||||
sed -i "s/__ELECTRUM_TLS_ENABLED__/${__ELECTRUM_TLS_ENABLED__}/g" mempool-config.json
|
||||
|
||||
sed -i "s!__ESPLORA_REST_API_URL__!${__ESPLORA_REST_API_URL__}!g" mempool-config.json
|
||||
|
||||
sed -i "s/__SECOND_CORE_RPC_HOST__/${__SECOND_CORE_RPC_HOST__}/g" mempool-config.json
|
||||
sed -i "s/__SECOND_CORE_RPC_PORT__/${__SECOND_CORE_RPC_PORT__}/g" mempool-config.json
|
||||
sed -i "s/__SECOND_CORE_RPC_USERNAME__/${__SECOND_CORE_RPC_USERNAME__}/g" mempool-config.json
|
||||
sed -i "s/__SECOND_CORE_RPC_PASSWORD__/${__SECOND_CORE_RPC_PASSWORD__}/g" mempool-config.json
|
||||
|
||||
sed -i "s/__DATABASE_ENABLED__/${__DATABASE_ENABLED__}/g" mempool-config.json
|
||||
sed -i "s/__DATABASE_HOST__/${__DATABASE_HOST__}/g" mempool-config.json
|
||||
sed -i "s/__DATABASE_PORT__/${__DATABASE_PORT__}/g" mempool-config.json
|
||||
sed -i "s/__DATABASE_DATABASE__/${__DATABASE_DATABASE__}/g" mempool-config.json
|
||||
sed -i "s/__DATABASE_USERNAME__/${__DATABASE_USERNAME__}/g" mempool-config.json
|
||||
sed -i "s/__DATABASE_PASSWORD__/${__DATABASE_PASSWORD__}/g" mempool-config.json
|
||||
|
||||
sed -i "s/__SYSLOG_ENABLED__/${__SYSLOG_ENABLED__}/g" mempool-config.json
|
||||
sed -i "s/__SYSLOG_HOST__/${__SYSLOG_HOST__}/g" mempool-config.json
|
||||
sed -i "s/__SYSLOG_PORT__/${__SYSLOG_PORT__}/g" mempool-config.json
|
||||
sed -i "s/__SYSLOG_MIN_PRIORITY__/${__SYSLOG_MIN_PRIORITY__}/g" mempool-config.json
|
||||
sed -i "s/__SYSLOG_FACILITY__/${__SYSLOG_FACILITY__}/g" mempool-config.json
|
||||
|
||||
sed -i "s/__STATISTICS_ENABLED__/${__STATISTICS_ENABLED__}/g" mempool-config.json
|
||||
sed -i "s/__STATISTICS_TX_PER_SECOND_SAMPLE_PERIOD__/${__STATISTICS_TX_PER_SECOND_SAMPLE_PERIOD__}/g" mempool-config.json
|
||||
|
||||
sed -i "s/__BISQ_ENABLED__/${__BISQ_ENABLED__}/g" mempool-config.json
|
||||
sed -i "s!__BISQ_DATA_PATH__!${__BISQ_DATA_PATH__}!g" mempool-config.json
|
||||
|
||||
node /backend/dist/index.js
|
||||
182
docker/backend/wait-for-it.sh
Normal file
182
docker/backend/wait-for-it.sh
Normal file
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env bash
|
||||
# Use this script to test if a given TCP host/port are available
|
||||
|
||||
WAITFORIT_cmdname=${0##*/}
|
||||
|
||||
echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi }
|
||||
|
||||
usage()
|
||||
{
|
||||
cat << USAGE >&2
|
||||
Usage:
|
||||
$WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args]
|
||||
-h HOST | --host=HOST Host or IP under test
|
||||
-p PORT | --port=PORT TCP port under test
|
||||
Alternatively, you specify the host and port as host:port
|
||||
-s | --strict Only execute subcommand if the test succeeds
|
||||
-q | --quiet Don't output any status messages
|
||||
-t TIMEOUT | --timeout=TIMEOUT
|
||||
Timeout in seconds, zero for no timeout
|
||||
-- COMMAND ARGS Execute command with args after the test finishes
|
||||
USAGE
|
||||
exit 1
|
||||
}
|
||||
|
||||
wait_for()
|
||||
{
|
||||
if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then
|
||||
echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT"
|
||||
else
|
||||
echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout"
|
||||
fi
|
||||
WAITFORIT_start_ts=$(date +%s)
|
||||
while :
|
||||
do
|
||||
if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then
|
||||
nc -z $WAITFORIT_HOST $WAITFORIT_PORT
|
||||
WAITFORIT_result=$?
|
||||
else
|
||||
(echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1
|
||||
WAITFORIT_result=$?
|
||||
fi
|
||||
if [[ $WAITFORIT_result -eq 0 ]]; then
|
||||
WAITFORIT_end_ts=$(date +%s)
|
||||
echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
return $WAITFORIT_result
|
||||
}
|
||||
|
||||
wait_for_wrapper()
|
||||
{
|
||||
# In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692
|
||||
if [[ $WAITFORIT_QUIET -eq 1 ]]; then
|
||||
timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &
|
||||
else
|
||||
timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &
|
||||
fi
|
||||
WAITFORIT_PID=$!
|
||||
trap "kill -INT -$WAITFORIT_PID" INT
|
||||
wait $WAITFORIT_PID
|
||||
WAITFORIT_RESULT=$?
|
||||
if [[ $WAITFORIT_RESULT -ne 0 ]]; then
|
||||
echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT"
|
||||
fi
|
||||
return $WAITFORIT_RESULT
|
||||
}
|
||||
|
||||
# process arguments
|
||||
while [[ $# -gt 0 ]]
|
||||
do
|
||||
case "$1" in
|
||||
*:* )
|
||||
WAITFORIT_hostport=(${1//:/ })
|
||||
WAITFORIT_HOST=${WAITFORIT_hostport[0]}
|
||||
WAITFORIT_PORT=${WAITFORIT_hostport[1]}
|
||||
shift 1
|
||||
;;
|
||||
--child)
|
||||
WAITFORIT_CHILD=1
|
||||
shift 1
|
||||
;;
|
||||
-q | --quiet)
|
||||
WAITFORIT_QUIET=1
|
||||
shift 1
|
||||
;;
|
||||
-s | --strict)
|
||||
WAITFORIT_STRICT=1
|
||||
shift 1
|
||||
;;
|
||||
-h)
|
||||
WAITFORIT_HOST="$2"
|
||||
if [[ $WAITFORIT_HOST == "" ]]; then break; fi
|
||||
shift 2
|
||||
;;
|
||||
--host=*)
|
||||
WAITFORIT_HOST="${1#*=}"
|
||||
shift 1
|
||||
;;
|
||||
-p)
|
||||
WAITFORIT_PORT="$2"
|
||||
if [[ $WAITFORIT_PORT == "" ]]; then break; fi
|
||||
shift 2
|
||||
;;
|
||||
--port=*)
|
||||
WAITFORIT_PORT="${1#*=}"
|
||||
shift 1
|
||||
;;
|
||||
-t)
|
||||
WAITFORIT_TIMEOUT="$2"
|
||||
if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi
|
||||
shift 2
|
||||
;;
|
||||
--timeout=*)
|
||||
WAITFORIT_TIMEOUT="${1#*=}"
|
||||
shift 1
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
WAITFORIT_CLI=("$@")
|
||||
break
|
||||
;;
|
||||
--help)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
echoerr "Unknown argument: $1"
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then
|
||||
echoerr "Error: you need to provide a host and port to test."
|
||||
usage
|
||||
fi
|
||||
|
||||
WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15}
|
||||
WAITFORIT_STRICT=${WAITFORIT_STRICT:-0}
|
||||
WAITFORIT_CHILD=${WAITFORIT_CHILD:-0}
|
||||
WAITFORIT_QUIET=${WAITFORIT_QUIET:-0}
|
||||
|
||||
# Check to see if timeout is from busybox?
|
||||
WAITFORIT_TIMEOUT_PATH=$(type -p timeout)
|
||||
WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH)
|
||||
|
||||
WAITFORIT_BUSYTIMEFLAG=""
|
||||
if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then
|
||||
WAITFORIT_ISBUSY=1
|
||||
# Check if busybox timeout uses -t flag
|
||||
# (recent Alpine versions don't support -t anymore)
|
||||
if timeout &>/dev/stdout | grep -q -e '-t '; then
|
||||
WAITFORIT_BUSYTIMEFLAG="-t"
|
||||
fi
|
||||
else
|
||||
WAITFORIT_ISBUSY=0
|
||||
fi
|
||||
|
||||
if [[ $WAITFORIT_CHILD -gt 0 ]]; then
|
||||
wait_for
|
||||
WAITFORIT_RESULT=$?
|
||||
exit $WAITFORIT_RESULT
|
||||
else
|
||||
if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then
|
||||
wait_for_wrapper
|
||||
WAITFORIT_RESULT=$?
|
||||
else
|
||||
wait_for
|
||||
WAITFORIT_RESULT=$?
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $WAITFORIT_CLI != "" ]]; then
|
||||
if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then
|
||||
echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess"
|
||||
exit $WAITFORIT_RESULT
|
||||
fi
|
||||
exec "${WAITFORIT_CLI[@]}"
|
||||
else
|
||||
exit $WAITFORIT_RESULT
|
||||
fi
|
||||
46
docker/docker-compose.yml
Normal file
46
docker/docker-compose.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
version: "3.7"
|
||||
|
||||
services:
|
||||
web:
|
||||
environment:
|
||||
FRONTEND_HTTP_PORT: "8080"
|
||||
BACKEND_MAINNET_HTTP_HOST: "api"
|
||||
image: mempool/frontend:latest
|
||||
user: "1000:1000"
|
||||
restart: on-failure
|
||||
stop_grace_period: 1m
|
||||
command: "./wait-for db:3306 --timeout=720 -- nginx -g 'daemon off;'"
|
||||
ports:
|
||||
- 80:8080
|
||||
api:
|
||||
environment:
|
||||
MEMPOOL_BACKEND: "none"
|
||||
CORE_RPC_HOST: "172.27.0.1"
|
||||
CORE_RPC_PORT: "8332"
|
||||
CORE_RPC_USERNAME: "mempool"
|
||||
CORE_RPC_PASSWORD: "mempool"
|
||||
DATABASE_ENABLED: "true"
|
||||
DATABASE_HOST: "db"
|
||||
DATABASE_DATABASE: "mempool"
|
||||
DATABASE_USERNAME: "mempool"
|
||||
DATABASE_PASSWORD: "mempool"
|
||||
STATISTICS_ENABLED: "true"
|
||||
image: mempool/backend:latest
|
||||
user: "1000:1000"
|
||||
restart: on-failure
|
||||
stop_grace_period: 1m
|
||||
command: "./wait-for-it.sh db:3306 --timeout=720 --strict -- ./start.sh"
|
||||
volumes:
|
||||
- ./data:/backend/cache
|
||||
db:
|
||||
environment:
|
||||
MYSQL_DATABASE: "mempool"
|
||||
MYSQL_USER: "mempool"
|
||||
MYSQL_PASSWORD: "mempool"
|
||||
MYSQL_ROOT_PASSWORD: "admin"
|
||||
image: mariadb:10.5.8
|
||||
user: "1000:1000"
|
||||
restart: on-failure
|
||||
stop_grace_period: 1m
|
||||
volumes:
|
||||
- ./mysql/data:/var/lib/mysql
|
||||
32
docker/electrum/Dockerfile
Normal file
32
docker/electrum/Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
||||
FROM ubuntu:18.04
|
||||
MAINTAINER mempool.space developers
|
||||
EXPOSE 50002
|
||||
|
||||
# runs as UID 1000 GID 1000 inside the container
|
||||
|
||||
ENV VERSION 4.0.9
|
||||
RUN set -x \
|
||||
&& apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends gpg gpg-agent dirmngr \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends wget xpra python3-pyqt5 python3-wheel python3-pip python3-setuptools libsecp256k1-0 libsecp256k1-dev python3-numpy python3-dev build-essential \
|
||||
&& wget -O /tmp/Electrum-${VERSION}.tar.gz https://download.electrum.org/${VERSION}/Electrum-${VERSION}.tar.gz \
|
||||
&& wget -O /tmp/Electrum-${VERSION}.tar.gz.asc https://download.electrum.org/${VERSION}/Electrum-${VERSION}.tar.gz.asc \
|
||||
&& gpg --keyserver keys.gnupg.net --recv-keys 6694D8DE7BE8EE5631BED9502BD5824B7F9470E6 \
|
||||
&& gpg --verify /tmp/Electrum-${VERSION}.tar.gz.asc /tmp/Electrum-${VERSION}.tar.gz \
|
||||
&& pip3 install /tmp/Electrum-${VERSION}.tar.gz \
|
||||
&& test -f /usr/local/bin/electrum \
|
||||
&& rm -vrf /tmp/Electrum-${VERSION}.tar.gz /tmp/Electrum-${VERSION}.tar.gz.asc ${HOME}/.gnupg \
|
||||
&& apt-get purge --autoremove -y python3-wheel python3-pip python3-setuptools python3-dev build-essential libsecp256k1-dev curl gpg gpg-agent dirmngr \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd -d /home/mempool -m mempool \
|
||||
&& mkdir /electrum \
|
||||
&& ln -s /electrum /home/mempool/.electrum \
|
||||
&& chown mempool:mempool /electrum
|
||||
|
||||
USER mempool
|
||||
ENV HOME /home/mempool
|
||||
WORKDIR /home/mempool
|
||||
VOLUME /electrum
|
||||
|
||||
CMD ["/usr/bin/xpra", "start", ":100", "--start-child=/usr/local/bin/electrum", "--bind-tcp=0.0.0.0:50002","--daemon=yes", "--notifications=no", "--mdns=no", "--pulseaudio=no", "--html=off", "--speaker=disabled", "--microphone=disabled", "--webcam=no", "--printing=no", "--dbus-launch=", "--exit-with-children"]
|
||||
ENTRYPOINT ["electrum"]
|
||||
38
docker/frontend/Dockerfile
Normal file
38
docker/frontend/Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
||||
FROM node:16.10.0-buster-slim AS builder
|
||||
|
||||
ARG commitHash
|
||||
ENV DOCKER_COMMIT_HASH=${commitHash}
|
||||
ENV CYPRESS_INSTALL_BINARY=0
|
||||
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y build-essential rsync
|
||||
RUN npm i
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.17.8-alpine
|
||||
|
||||
WORKDIR /patch
|
||||
|
||||
COPY --from=builder /build/entrypoint.sh .
|
||||
COPY --from=builder /build/wait-for .
|
||||
COPY --from=builder /build/dist/mempool /var/www/mempool
|
||||
COPY --from=builder /build/nginx.conf /etc/nginx/
|
||||
COPY --from=builder /build/nginx-mempool.conf /etc/nginx/conf.d/
|
||||
|
||||
RUN chmod +x /patch/entrypoint.sh
|
||||
RUN chmod +x /patch/wait-for
|
||||
|
||||
RUN chown -R 1000:1000 /patch && chmod -R 755 /patch && \
|
||||
chown -R 1000:1000 /var/cache/nginx && \
|
||||
chown -R 1000:1000 /var/log/nginx && \
|
||||
chown -R 1000:1000 /etc/nginx/nginx.conf && \
|
||||
chown -R 1000:1000 /etc/nginx/conf.d
|
||||
RUN touch /var/run/nginx.pid && \
|
||||
chown -R 1000:1000 /var/run/nginx.pid
|
||||
|
||||
USER 1000
|
||||
|
||||
ENTRYPOINT ["/patch/entrypoint.sh"]
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
13
docker/frontend/entrypoint.sh
Normal file
13
docker/frontend/entrypoint.sh
Normal file
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__=${BACKEND_MAINNET_HTTP_HOST:=127.0.0.1}
|
||||
__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__=${BACKEND_MAINNET_HTTP_PORT:=8999}
|
||||
__MEMPOOL_FRONTEND_HTTP_PORT__=${FRONTEND_HTTP_PORT:=8080}
|
||||
|
||||
sed -i "s/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__/${__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__}/g" /etc/nginx/conf.d/nginx-mempool.conf
|
||||
sed -i "s/__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__/${__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__}/g" /etc/nginx/conf.d/nginx-mempool.conf
|
||||
|
||||
cp /etc/nginx/nginx.conf /patch/nginx.conf
|
||||
sed -i "s/__MEMPOOL_FRONTEND_HTTP_PORT__/${__MEMPOOL_FRONTEND_HTTP_PORT__}/g" /patch/nginx.conf
|
||||
cat /patch/nginx.conf > /etc/nginx/nginx.conf
|
||||
|
||||
exec "$@"
|
||||
84
docker/frontend/wait-for
Normal file
84
docker/frontend/wait-for
Normal file
@@ -0,0 +1,84 @@
|
||||
#!/bin/sh
|
||||
|
||||
TIMEOUT=15
|
||||
QUIET=0
|
||||
|
||||
echoerr() {
|
||||
if [ "$QUIET" -ne 1 ]; then printf "%s\n" "$*" 1>&2; fi
|
||||
}
|
||||
|
||||
usage() {
|
||||
exitcode="$1"
|
||||
cat << USAGE >&2
|
||||
Usage:
|
||||
$cmdname host:port [-t timeout] [-- command args]
|
||||
-q | --quiet Do not output any status messages
|
||||
-t TIMEOUT | --timeout=timeout Timeout in seconds, zero for no timeout
|
||||
-- COMMAND ARGS Execute command with args after the test finishes
|
||||
USAGE
|
||||
exit "$exitcode"
|
||||
}
|
||||
|
||||
wait_for() {
|
||||
if ! command -v nc >/dev/null; then
|
||||
echoerr 'nc command is missing!'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for i in `seq $TIMEOUT` ; do
|
||||
nc -z "$HOST" "$PORT" > /dev/null 2>&1
|
||||
|
||||
result=$?
|
||||
if [ $result -eq 0 ] ; then
|
||||
if [ $# -gt 0 ] ; then
|
||||
exec "$@"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "Operation timed out" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]
|
||||
do
|
||||
case "$1" in
|
||||
*:* )
|
||||
HOST=$(printf "%s\n" "$1"| cut -d : -f 1)
|
||||
PORT=$(printf "%s\n" "$1"| cut -d : -f 2)
|
||||
shift 1
|
||||
;;
|
||||
-q | --quiet)
|
||||
QUIET=1
|
||||
shift 1
|
||||
;;
|
||||
-t)
|
||||
TIMEOUT="$2"
|
||||
if [ "$TIMEOUT" = "" ]; then break; fi
|
||||
shift 2
|
||||
;;
|
||||
--timeout=*)
|
||||
TIMEOUT="${1#*=}"
|
||||
shift 1
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
break
|
||||
;;
|
||||
--help)
|
||||
usage 0
|
||||
;;
|
||||
*)
|
||||
echoerr "Unknown argument: $1"
|
||||
usage 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$HOST" = "" -o "$PORT" = "" ]; then
|
||||
echoerr "Error: you need to provide a host and port to test."
|
||||
usage 2
|
||||
fi
|
||||
|
||||
wait_for "$@"
|
||||
18
docker/init.sh
Executable file
18
docker/init.sh
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
|
||||
#backend
|
||||
gitMaster="\.\.\/\.git\/refs\/heads\/master"
|
||||
git ls-remote https://github.com/mempool/mempool.git "$1^{}" | awk '{ print $1}' > ./backend/master
|
||||
cp ./docker/backend/* ./backend/
|
||||
sed -i "s/${gitMaster}/master/g" ./backend/src/api/backend-info.ts
|
||||
|
||||
#frontend
|
||||
localhostIP="127.0.0.1"
|
||||
cp ./docker/frontend/* ./frontend
|
||||
cp ./nginx.conf ./frontend/
|
||||
cp ./nginx-mempool.conf ./frontend/
|
||||
sed -i "s/${localhostIP}:80/0.0.0.0:__MEMPOOL_FRONTEND_HTTP_PORT__/g" ./frontend/nginx.conf
|
||||
sed -i "s/${localhostIP}/0.0.0.0/g" ./frontend/nginx.conf
|
||||
sed -i "s/user nobody;//g" ./frontend/nginx.conf
|
||||
sed -i "s!/etc/nginx/nginx-mempool.conf!/etc/nginx/conf.d/nginx-mempool.conf!g" ./frontend/nginx.conf
|
||||
sed -i "s/${localhostIP}:8999/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__/g" ./frontend/nginx-mempool.conf
|
||||
18
docker/scripts/get_image_digest.sh
Executable file
18
docker/scripts/get_image_digest.sh
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
|
||||
VERSION=$1
|
||||
IMAGE=""
|
||||
|
||||
if [ -z "${VERSION}" ]; then
|
||||
echo "no version provided (i.e, v2.2.0), using latest tag"
|
||||
VERSION="latest"
|
||||
fi
|
||||
|
||||
for package in frontend backend; do
|
||||
PACKAGE=mempool/"$package"
|
||||
IMAGE="$PACKAGE":"$VERSION"
|
||||
HASH=`docker pull $IMAGE > /dev/null && docker inspect $IMAGE | sed -n '/RepoDigests/{n;p;}' | grep -o '[0-9a-f]\{64\}'`
|
||||
if [ -n "${HASH}" ]; then
|
||||
echo "$IMAGE"@sha256:"$HASH"
|
||||
fi
|
||||
done
|
||||
11
frontend/.gitignore
vendored
11
frontend/.gitignore
vendored
@@ -34,6 +34,7 @@ speed-measure-plugin.json
|
||||
.history/*
|
||||
|
||||
# misc
|
||||
/.angular/cache
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
/coverage
|
||||
@@ -49,8 +50,18 @@ Thumbs.db
|
||||
|
||||
src/resources/assets.json
|
||||
src/resources/assets.minimal.json
|
||||
src/resources/assets-testnet.json
|
||||
src/resources/assets-testnet.minimal.json
|
||||
src/resources/pools.json
|
||||
|
||||
# environment config
|
||||
mempool-frontend-config.json
|
||||
generated-config.js
|
||||
|
||||
# e2e results
|
||||
cypress/videos
|
||||
cypress/screenshots
|
||||
|
||||
# Base index
|
||||
src/index.html
|
||||
|
||||
|
||||
@@ -1,11 +1,50 @@
|
||||
# mempool-frontend
|
||||
|
||||
## Transifex Project
|
||||
## Contributing
|
||||
|
||||
This package is used for the https://mempool.space, https://liquid.network and https://bisq.markets websites - there are npm scripts to setup all three, which effectively change how BASE_MODULE is configured:
|
||||
|
||||
```
|
||||
$ npm run config:defaults:mempool
|
||||
$ npm run config:defaults:liquid
|
||||
$ npm run config:defaults:bisq
|
||||
```
|
||||
|
||||
Changes that affect the frontend codebase only can be done using the production backend so you don't need to spin up the entire Mempool infrastructure. This is very convenient in case you want to quickly improve the UI, fix typos or implement new features that don't require any backend changes.
|
||||
|
||||
Make your changes, install the project dependencies and run the frontend server as follows:
|
||||
|
||||
```
|
||||
$ npm install
|
||||
$ npm run serve:local-prod
|
||||
```
|
||||
|
||||
The frontend will be available at http://localhost:4200/ and all API requests will be proxied to the production server at https://mempool.space
|
||||
|
||||
After making your changes, you can run our end-to-end automation suite and check for possible regressions:
|
||||
|
||||
Headless:
|
||||
|
||||
```
|
||||
$ npm run config:defaults:mempool && npm run cypress:run
|
||||
```
|
||||
|
||||
Interactive:
|
||||
|
||||
```
|
||||
$ npm run config:defaults:mempool && npm run cypress:open
|
||||
```
|
||||
|
||||
This will open the Cypress test runner, where you can select any of the test files to run.
|
||||
|
||||
If all tests are green, submit your PR and it will be reviewed by someone on the team as soon as possible.
|
||||
|
||||
## Translations: Transifex Project
|
||||
|
||||
The mempool frontend strings are localized into 20+ locales:
|
||||
https://www.transifex.com/mempool/mempool/dashboard/
|
||||
|
||||
## Translators
|
||||
### Translators
|
||||
|
||||
* Arabic @baro0k
|
||||
* Czech @pixelmade2
|
||||
@@ -15,15 +54,23 @@ https://www.transifex.com/mempool/mempool/dashboard/
|
||||
* Persian @techmix
|
||||
* French @Bayernatoor
|
||||
* Korean @kcalvinalvinn
|
||||
* Italian @HodlBits
|
||||
* Hebrew @Sh0ham
|
||||
* Georgian @wyd_idk
|
||||
* Hungarian @btcdragonlord
|
||||
* Dutch @m__btc
|
||||
* Japanese @wiz @japananon
|
||||
* Norwegian @T82771355
|
||||
* Polish @maciejsoltysiak
|
||||
* Portugese @jgcastro1985
|
||||
* Slovenian @thepkbadger
|
||||
* Finnish @bio_bitcoin
|
||||
* Swedish @softsimon_
|
||||
* Thai @Gusb3ll
|
||||
* Turkish @stackmore
|
||||
* Ukrainian @volbil
|
||||
* Vietnamese @bitcoin_vietnam
|
||||
* Chinese @wdljt
|
||||
* Russian @TonyCrusoe @Bitconan
|
||||
* Romanian @mirceavesa
|
||||
* Macedonian @SkechBoy
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"cli": {
|
||||
"analytics": false
|
||||
},
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
@@ -15,14 +18,18 @@
|
||||
"prefix": "app",
|
||||
"i18n": {
|
||||
"sourceLocale": {
|
||||
"code":"en-US",
|
||||
"baseHref":"/"
|
||||
"code": "en-US",
|
||||
"baseHref": "/"
|
||||
},
|
||||
"locales": {
|
||||
"ar": {
|
||||
"translation": "src/locale/messages.ar.xlf",
|
||||
"baseHref": "/ar/"
|
||||
},
|
||||
"ca": {
|
||||
"translation": "src/locale/messages.ca.xlf",
|
||||
"baseHref": "/ca/"
|
||||
},
|
||||
"cs": {
|
||||
"translation": "src/locale/messages.cs.xlf",
|
||||
"baseHref": "/cs/"
|
||||
@@ -55,6 +62,14 @@
|
||||
"translation": "src/locale/messages.ko.xlf",
|
||||
"baseHref": "/ko/"
|
||||
},
|
||||
"it": {
|
||||
"translation": "src/locale/messages.it.xlf",
|
||||
"baseHref": "/it/"
|
||||
},
|
||||
"he": {
|
||||
"translation": "src/locale/messages.he.xlf",
|
||||
"baseHref": "/he/"
|
||||
},
|
||||
"nl": {
|
||||
"translation": "src/locale/messages.nl.xlf",
|
||||
"baseHref": "/nl/"
|
||||
@@ -63,6 +78,10 @@
|
||||
"translation": "src/locale/messages.nb.xlf",
|
||||
"baseHref": "/nb/"
|
||||
},
|
||||
"pl": {
|
||||
"translation": "src/locale/messages.pl.xlf",
|
||||
"baseHref": "/pl/"
|
||||
},
|
||||
"pt": {
|
||||
"translation": "src/locale/messages.pt.xlf",
|
||||
"baseHref": "/pt/"
|
||||
@@ -75,6 +94,10 @@
|
||||
"translation": "src/locale/messages.sv.xlf",
|
||||
"baseHref": "/sv/"
|
||||
},
|
||||
"th": {
|
||||
"translation": "src/locale/messages.th.xlf",
|
||||
"baseHref": "/th/"
|
||||
},
|
||||
"tr": {
|
||||
"translation": "src/locale/messages.tr.xlf",
|
||||
"baseHref": "/tr/"
|
||||
@@ -91,9 +114,29 @@
|
||||
"translation": "src/locale/messages.vi.xlf",
|
||||
"baseHref": "/vi/"
|
||||
},
|
||||
"hu": {
|
||||
"translation": "src/locale/messages.hu.xlf",
|
||||
"baseHref": "/hu/"
|
||||
},
|
||||
"mk": {
|
||||
"translation": "src/locale/messages.mk.xlf",
|
||||
"baseHref": "/mk/"
|
||||
},
|
||||
"zh": {
|
||||
"translation": "src/locale/messages.zh.xlf",
|
||||
"baseHref": "/zh/"
|
||||
},
|
||||
"ro": {
|
||||
"translation": "src/locale/messages.ro.xlf",
|
||||
"baseHref": "/ro/"
|
||||
},
|
||||
"ru": {
|
||||
"translation": "src/locale/messages.ru.xlf",
|
||||
"baseHref": "/ru/"
|
||||
},
|
||||
"hi": {
|
||||
"translation": "src/locale/messages.hi.xlf",
|
||||
"baseHref": "/hi/"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -106,7 +149,6 @@
|
||||
"main": "src/main.ts",
|
||||
"polyfills": "src/polyfills.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"aot": true,
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/resources",
|
||||
@@ -118,7 +160,13 @@
|
||||
],
|
||||
"scripts": [
|
||||
"generated-config.js"
|
||||
]
|
||||
],
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false,
|
||||
"buildOptimizer": false,
|
||||
"sourceMap": true,
|
||||
"optimization": false,
|
||||
"namedChunks": true
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
@@ -128,10 +176,16 @@
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"optimization": true,
|
||||
"optimization": {
|
||||
"scripts": true,
|
||||
"styles": {
|
||||
"minify": true,
|
||||
"inlineCritical": false
|
||||
},
|
||||
"fonts": true
|
||||
},
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"extractCss": true,
|
||||
"namedChunks": false,
|
||||
"extractLicenses": true,
|
||||
"vendorChunk": false,
|
||||
@@ -148,7 +202,8 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": ""
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
@@ -158,6 +213,22 @@
|
||||
"configurations": {
|
||||
"production": {
|
||||
"browserTarget": "mempool:build:production"
|
||||
},
|
||||
"local": {
|
||||
"proxyConfig": "proxy.conf.local.js",
|
||||
"verbose": true
|
||||
},
|
||||
"staging": {
|
||||
"proxyConfig": "proxy.conf.js",
|
||||
"disableHostCheck": true,
|
||||
"host": "0.0.0.0",
|
||||
"verbose": true
|
||||
},
|
||||
"local-prod": {
|
||||
"proxyConfig": "proxy.conf.js",
|
||||
"disableHostCheck": true,
|
||||
"host": "0.0.0.0",
|
||||
"verbose": false
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -184,25 +255,12 @@
|
||||
"scripts": []
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-devkit/build-angular:tslint",
|
||||
"options": {
|
||||
"tsConfig": [
|
||||
"tsconfig.app.json",
|
||||
"tsconfig.spec.json",
|
||||
"e2e/tsconfig.json",
|
||||
"tsconfig.server.json"
|
||||
],
|
||||
"exclude": [
|
||||
"**/node_modules/**"
|
||||
]
|
||||
}
|
||||
},
|
||||
"e2e": {
|
||||
"builder": "@angular-devkit/build-angular:protractor",
|
||||
"builder": "@cypress/schematic:cypress",
|
||||
"options": {
|
||||
"protractorConfig": "e2e/protractor.conf.js",
|
||||
"devServerTarget": "mempool:serve"
|
||||
"devServerTarget": "mempool:serve:local-prod",
|
||||
"watch": true,
|
||||
"headless": false
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
@@ -215,7 +273,9 @@
|
||||
"options": {
|
||||
"outputPath": "dist/mempool/server",
|
||||
"main": "server.ts",
|
||||
"tsConfig": "tsconfig.server.json"
|
||||
"tsConfig": "tsconfig.server.json",
|
||||
"sourceMap": true,
|
||||
"optimization": false
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
@@ -230,7 +290,8 @@
|
||||
"localize": true,
|
||||
"optimization": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": ""
|
||||
},
|
||||
"serve-ssr": {
|
||||
"builder": "@nguniversal/builders:ssr-dev-server",
|
||||
@@ -257,8 +318,27 @@
|
||||
"configurations": {
|
||||
"production": {}
|
||||
}
|
||||
},
|
||||
"cypress-run": {
|
||||
"builder": "@cypress/schematic:cypress",
|
||||
"options": {
|
||||
"devServerTarget": "mempool:serve"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"devServerTarget": "mempool:serve:production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cypress-open": {
|
||||
"builder": "@cypress/schematic:cypress",
|
||||
"options": {
|
||||
"watch": true,
|
||||
"headless": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}},
|
||||
}
|
||||
},
|
||||
"defaultProject": "mempool"
|
||||
}
|
||||
|
||||
16
frontend/cypress.json
Normal file
16
frontend/cypress.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"projectId": "ry4br7",
|
||||
"integrationFolder": "cypress/integration",
|
||||
"supportFile": "cypress/support/index.ts",
|
||||
"videosFolder": "cypress/videos",
|
||||
"screenshotsFolder": "cypress/screenshots",
|
||||
"pluginsFile": "cypress/plugins/index.js",
|
||||
"fixturesFolder": "cypress/fixtures",
|
||||
"baseUrl": "http://localhost:4200",
|
||||
"video": false,
|
||||
"retries": {
|
||||
"runMode": 3,
|
||||
"openMode": 0
|
||||
},
|
||||
"chromeWebSecurity": false
|
||||
}
|
||||
119
frontend/cypress/fixtures/assets.json
Normal file
119
frontend/cypress/fixtures/assets.json
Normal file
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"f59c5f3e8141f322276daa63ed5f307085808aea6d4ef9ba61e28154533fdec7": {
|
||||
"asset_id": "f59c5f3e8141f322276daa63ed5f307085808aea6d4ef9ba61e28154533fdec7",
|
||||
"contract": {
|
||||
"entity": {
|
||||
"domain": "listedreserve.com"
|
||||
},
|
||||
"issuer_pubkey": "031cc579d142a03b33cdd745922112821c16e5e8b74e3bd57f16f7fda872b6f1d0",
|
||||
"name": "Liquid AUD",
|
||||
"precision": 2,
|
||||
"ticker": "AUDL",
|
||||
"version": 0
|
||||
},
|
||||
"issuance_txin": {
|
||||
"txid": "e5c5144ba3dc48259ae29023fe9f7775dec1fc049f456dd3d1f7178e31901fb5",
|
||||
"vin": 0
|
||||
},
|
||||
"issuance_prevout": {
|
||||
"txid": "ed48be2e035ffa425d2c6faaa82b6a7b648aed1246b6ac76c72e0408db8cf057",
|
||||
"vout": 1
|
||||
},
|
||||
"name": "Liquid AUD",
|
||||
"ticker": "AUDL",
|
||||
"precision": 2,
|
||||
"entity": {
|
||||
"domain": "listedreserve.com"
|
||||
},
|
||||
"version": 0,
|
||||
"issuer_pubkey": "031cc579d142a03b33cdd745922112821c16e5e8b74e3bd57f16f7fda872b6f1d0"
|
||||
},
|
||||
"0e99c1a6da379d1f4151fb9df90449d40d0608f6cb33a5bcbfc8c265f42bab0a": {
|
||||
"asset_id": "0e99c1a6da379d1f4151fb9df90449d40d0608f6cb33a5bcbfc8c265f42bab0a",
|
||||
"contract": {
|
||||
"entity": {
|
||||
"domain": "lcad.bullbitcoin.com"
|
||||
},
|
||||
"issuer_pubkey": "027fa34026195b05f3aa217335416811dca4f5b579d00271a1bb6304c0152458a8",
|
||||
"name": "Liquid CAD",
|
||||
"precision": 8,
|
||||
"ticker": "LCAD",
|
||||
"version": 0
|
||||
},
|
||||
"issuance_txin": {
|
||||
"txid": "238badf029cadcf546d90ce23c7eafc2fa2082585c9bd62dc26f1aa11c7bd850",
|
||||
"vin": 0
|
||||
},
|
||||
"issuance_prevout": {
|
||||
"txid": "a87f13917c08c7ccd8eddb1830c5c9a2bcd59c7d167e9d528659ba40808a6b76",
|
||||
"vout": 0
|
||||
},
|
||||
"name": "Liquid CAD",
|
||||
"ticker": "LCAD",
|
||||
"precision": 8,
|
||||
"entity": {
|
||||
"domain": "lcad.bullbitcoin.com"
|
||||
},
|
||||
"version": 0,
|
||||
"issuer_pubkey": "027fa34026195b05f3aa217335416811dca4f5b579d00271a1bb6304c0152458a8"
|
||||
},
|
||||
"3438ecb49fc45c08e687de4749ed628c511e326460ea4336794e1cf02741329e": {
|
||||
"asset_id": "3438ecb49fc45c08e687de4749ed628c511e326460ea4336794e1cf02741329e",
|
||||
"contract": {
|
||||
"entity": {
|
||||
"domain": "settlenet.io"
|
||||
},
|
||||
"issuer_pubkey": "037b09d542bf7cea6a19fa624b4441790c1a6e44823597bf190e981a846a196541",
|
||||
"name": "SETTLENET JPY Stablecoin by Crypto Garage",
|
||||
"precision": 0,
|
||||
"ticker": "JPYS",
|
||||
"version": 0
|
||||
},
|
||||
"issuance_txin": {
|
||||
"txid": "e33ad5ce8879297d8bfa7daa193920b94abd3fb12f4e8dade9543dbb292387cb",
|
||||
"vin": 0
|
||||
},
|
||||
"issuance_prevout": {
|
||||
"txid": "328c4fadd817ea75e634e3648eb4be0bf7e669539b8da921c0f77af3bc148894",
|
||||
"vout": 1
|
||||
},
|
||||
"name": "SETTLENET JPY Stablecoin by Crypto Garage",
|
||||
"ticker": "JPYS",
|
||||
"precision": 0,
|
||||
"entity": {
|
||||
"domain": "settlenet.io"
|
||||
},
|
||||
"version": 0,
|
||||
"issuer_pubkey": "037b09d542bf7cea6a19fa624b4441790c1a6e44823597bf190e981a846a196541"
|
||||
},
|
||||
"ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2": {
|
||||
"asset_id": "ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2",
|
||||
"contract": {
|
||||
"entity": {
|
||||
"domain": "tether.to"
|
||||
},
|
||||
"issuer_pubkey": "0337cceec0beea0232ebe14cba0197a9fbd45fcf2ec946749de920e71434c2b904",
|
||||
"name": "Tether USD",
|
||||
"precision": 8,
|
||||
"ticker": "USDt",
|
||||
"version": 0
|
||||
},
|
||||
"issuance_txin": {
|
||||
"txid": "abb4080d91849e933ee2ed65da6b436f7c385cf363fb4aa08399f1e27c58ff3d",
|
||||
"vin": 0
|
||||
},
|
||||
"issuance_prevout": {
|
||||
"txid": "9596d259270ef5bac0020435e6d859aea633409483ba64e232b8ba04ce288668",
|
||||
"vout": 0
|
||||
},
|
||||
"name": "Tether USD",
|
||||
"ticker": "USDt",
|
||||
"precision": 8,
|
||||
"entity": {
|
||||
"domain": "tether.to"
|
||||
},
|
||||
"version": 0,
|
||||
"issuer_pubkey": "0337cceec0beea0232ebe14cba0197a9fbd45fcf2ec946749de920e71434c2b904"
|
||||
}
|
||||
}
|
||||
|
||||
33
frontend/cypress/fixtures/assets.minimal.json
Normal file
33
frontend/cypress/fixtures/assets.minimal.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"f59c5f3e8141f322276daa63ed5f307085808aea6d4ef9ba61e28154533fdec7": [
|
||||
"listedreserve.com",
|
||||
"AUDL",
|
||||
"Liquid AUD",
|
||||
2
|
||||
],
|
||||
"0e99c1a6da379d1f4151fb9df90449d40d0608f6cb33a5bcbfc8c265f42bab0a": [
|
||||
"lcad.bullbitcoin.com",
|
||||
"LCAD",
|
||||
"Liquid CAD",
|
||||
8
|
||||
],
|
||||
"6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d": [
|
||||
null,
|
||||
"L-BTC",
|
||||
"Liquid Bitcoin",
|
||||
8
|
||||
],
|
||||
"ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2": [
|
||||
"tether.to",
|
||||
"USDt",
|
||||
"Tether USD",
|
||||
8
|
||||
],
|
||||
"3438ecb49fc45c08e687de4749ed628c511e326460ea4336794e1cf02741329e": [
|
||||
"settlenet.io",
|
||||
"JPYS",
|
||||
"SETTLENET JPY Stablecoin by Crypto Garage",
|
||||
0
|
||||
]
|
||||
}
|
||||
|
||||
1
frontend/cypress/fixtures/mainnet_live2hchart.json
Normal file
1
frontend/cypress/fixtures/mainnet_live2hchart.json
Normal file
@@ -0,0 +1 @@
|
||||
{"live-2h-chart":{"id":1319298,"added":"2021-07-23T18:27:34.000Z","unconfirmed_transactions":546,"tx_per_second":3.93333,"vbytes_per_second":1926,"mempool_byte_weight":1106656,"total_fee":6198583,"vsizes":[255,18128,43701,58534,17144,5532,4483,1759,2394,1089,1683,7409,751,101010,1151,592,1497,703,1369,4747,800,1221,0,0,712,0,0,0,0,0,0,0,0,0,0,0,0,0]}}
|
||||
1
frontend/cypress/fixtures/mainnet_mempoolInfo.json
Normal file
1
frontend/cypress/fixtures/mainnet_mempoolInfo.json
Normal file
File diff suppressed because one or more lines are too long
52
frontend/cypress/fixtures/mainnet_rbf.json
Normal file
52
frontend/cypress/fixtures/mainnet_rbf.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"rbfTransaction": {
|
||||
"txid": "8913ec7ba0ede285dbd120e46f6d61a28f2903c10814a6f6c4f97d0edf3e1f46",
|
||||
"version": 2,
|
||||
"locktime": 632699,
|
||||
"vin": [
|
||||
{
|
||||
"txid": "02238126a63ea2669c5f378012180ef8b54402a949316f9b2f1352c51730a086",
|
||||
"vout": 0,
|
||||
"prevout": {
|
||||
"scriptpubkey": "a914f8e495456956c833e5e8c69b9a9dc041aa14c72f87",
|
||||
"scriptpubkey_asm": "OP_HASH160 OP_PUSHBYTES_20 f8e495456956c833e5e8c69b9a9dc041aa14c72f OP_EQUAL",
|
||||
"scriptpubkey_type": "p2sh",
|
||||
"scriptpubkey_address": "3QP3LMD8veT5GtWV83Nosif2Bhr73857VB",
|
||||
"value": 25000000
|
||||
},
|
||||
"scriptsig": "22002043288fbbc0fc5efa86c229dbb7d88ab78d57957c65b5d5ceaece70838976ad1b",
|
||||
"scriptsig_asm": "OP_PUSHBYTES_34 002043288fbbc0fc5efa86c229dbb7d88ab78d57957c65b5d5ceaece70838976ad1b",
|
||||
"witness": [
|
||||
"",
|
||||
"3044022009e2d3a8e645f65bc89c8492cd9c08e6fb02609fd402214884a754a1970145340220575bb325429def59f3a3f1e22d9740a3feecbe97438ff3bb5796b2c46b3c477f01",
|
||||
"3044022039c34372882da8fc1c1243bd72b5e7e5e6870301ef56bdebb87bc647fb50f9b5022071a704ee77d742f78b10e45be675d4c45a5f31e884139e75c975144fde70e41701",
|
||||
"522102346eb7133f11e0dc279bc592d5ac948a91676372a6144c9ae2085625d7fbf70421021b9508a458f9d59be4eb8cc87ad582c3b494106fb1d4ec22801569be0700eb7b52ae"
|
||||
],
|
||||
"is_coinbase": false,
|
||||
"sequence": 4294967293,
|
||||
"inner_redeemscript_asm": "OP_0 OP_PUSHBYTES_32 43288fbbc0fc5efa86c229dbb7d88ab78d57957c65b5d5ceaece70838976ad1b",
|
||||
"inner_witnessscript_asm": "OP_PUSHNUM_2 OP_PUSHBYTES_33 02346eb7133f11e0dc279bc592d5ac948a91676372a6144c9ae2085625d7fbf704 OP_PUSHBYTES_33 021b9508a458f9d59be4eb8cc87ad582c3b494106fb1d4ec22801569be0700eb7b OP_PUSHNUM_2 OP_CHECKMULTISIG"
|
||||
}
|
||||
],
|
||||
"vout": [
|
||||
{
|
||||
"scriptpubkey": "a914fd4e5e59dd5cf2dc48eaedf1a2a1650ca1ce9d7f87",
|
||||
"scriptpubkey_asm": "OP_HASH160 OP_PUSHBYTES_20 fd4e5e59dd5cf2dc48eaedf1a2a1650ca1ce9d7f OP_EQUAL",
|
||||
"scriptpubkey_type": "p2sh",
|
||||
"scriptpubkey_address": "3QnNmDhZS7toHA7bhhbTPBdtpLJoeecq5c",
|
||||
"value": 13986350
|
||||
},
|
||||
{
|
||||
"scriptpubkey": "76a914edc93d0446deec1c2d514f3a490f050096e74e0e88ac",
|
||||
"scriptpubkey_asm": "OP_DUP OP_HASH160 OP_PUSHBYTES_20 edc93d0446deec1c2d514f3a490f050096e74e0e OP_EQUALVERIFY OP_CHECKSIG",
|
||||
"scriptpubkey_type": "p2pkh",
|
||||
"scriptpubkey_address": "1NgJDkTUqJxxCAAZrrsC87kWag5kphrRtM",
|
||||
"value": 11000000
|
||||
}
|
||||
],
|
||||
"size": 372,
|
||||
"weight": 828,
|
||||
"fee": 1.5,
|
||||
"status": { "confirmed": false }
|
||||
}
|
||||
}
|
||||
1178
frontend/cypress/fixtures/pools.json
Normal file
1178
frontend/cypress/fixtures/pools.json
Normal file
File diff suppressed because it is too large
Load Diff
87
frontend/cypress/integration/bisq/bisq.spec.ts
Normal file
87
frontend/cypress/integration/bisq/bisq.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
describe('Bisq', () => {
|
||||
const baseModule = Cypress.env("BASE_MODULE");
|
||||
const basePath = '';
|
||||
|
||||
beforeEach(() => {
|
||||
cy.intercept('/sockjs-node/info*').as('socket');
|
||||
cy.intercept('/bisq/api/markets/hloc?market=btc_usd&interval=day').as('hloc');
|
||||
cy.intercept('/bisq/api/markets/ticker').as('ticker');
|
||||
cy.intercept('/bisq/api/markets/markets').as('markets');
|
||||
cy.intercept('/bisq/api/markets/volumes/7d').as('7d');
|
||||
cy.intercept('/bisq/api/markets/trades?market=all').as('trades');
|
||||
cy.intercept('/bisq/api/txs/*/*').as('txs');
|
||||
cy.intercept('/bisq/api/blocks/*/*').as('blocks');
|
||||
cy.intercept('/bisq/api/stats').as('stats');
|
||||
|
||||
Cypress.Commands.add('waitForDashboard', () => {
|
||||
cy.wait('@socket');
|
||||
cy.wait('@hloc');
|
||||
cy.wait('@ticker');
|
||||
cy.wait('@markets');
|
||||
cy.wait('@7d');
|
||||
cy.wait('@trades');
|
||||
});
|
||||
});
|
||||
|
||||
if (baseModule === 'bisq') {
|
||||
|
||||
it('loads the dashboard', () => {
|
||||
cy.visit(`${basePath}`);
|
||||
cy.waitForSkeletonGone();
|
||||
});
|
||||
|
||||
it('loads the transactions screen', () => {
|
||||
cy.visit(`${basePath}`);
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('li:nth-of-type(2) > a').click().then(() => {
|
||||
cy.get('.table > tr').should('have.length', 50);
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the blocks screen', () => {
|
||||
cy.visit(`${basePath}`);
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('li:nth-of-type(3) > a').click().then(() => {
|
||||
cy.wait('@blocks');
|
||||
cy.get('tbody tr').should('have.length', 10);
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the stats screen', () => {
|
||||
cy.visit(`${basePath}`);
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('li:nth-of-type(4) > a').click().then(() => {
|
||||
cy.wait('@stats');
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the api screen', () => {
|
||||
cy.visit(`${basePath}`);
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('li:nth-of-type(5) > a').click().then(() => {
|
||||
cy.get('.section-header').should('have.length.at.least', 1);
|
||||
cy.get('.endpoint-container').should('have.length.at.least', 1);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows blocks pagination with 5 pages (desktop)', () => {
|
||||
cy.viewport(760, 800);
|
||||
cy.visit(`${basePath}/blocks`);
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('tbody tr').should('have.length', 10);
|
||||
// 5 pages + 4 buttons = 9 buttons
|
||||
cy.get('.pagination-container ul.pagination').first().children().should('have.length', 9);
|
||||
});
|
||||
|
||||
it('shows blocks pagination with 3 pages (mobile)', () => {
|
||||
cy.viewport(669, 800);
|
||||
cy.visit(`${basePath}/blocks`);
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('tbody tr').should('have.length', 10);
|
||||
// 3 pages + 4 buttons = 7 buttons
|
||||
cy.get('.pagination-container ul.pagination').first().children().should('have.length', 7);
|
||||
});
|
||||
} else {
|
||||
it.skip(`Tests cannot be run on the selected BASE_MODULE ${baseModule}`);
|
||||
}
|
||||
});
|
||||
201
frontend/cypress/integration/liquid/liquid.spec.ts
Normal file
201
frontend/cypress/integration/liquid/liquid.spec.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
describe('Liquid', () => {
|
||||
const baseModule = Cypress.env("BASE_MODULE");
|
||||
const basePath = '';
|
||||
|
||||
beforeEach(() => {
|
||||
cy.intercept('/liquid/api/block/**').as('block');
|
||||
cy.intercept('/liquid/api/blocks/').as('blocks');
|
||||
cy.intercept('/liquid/api/tx/**/outspends').as('outspends');
|
||||
cy.intercept('/liquid/api/block/**/txs/**').as('block-txs');
|
||||
cy.intercept('/resources/pools.json').as('pools');
|
||||
|
||||
Cypress.Commands.add('waitForBlockData', () => {
|
||||
cy.wait('@socket');
|
||||
cy.wait('@block');
|
||||
cy.wait('@outspends');
|
||||
});
|
||||
});
|
||||
|
||||
if (baseModule === 'liquid') {
|
||||
|
||||
it('check first mempool block after skeleton loads', () => {
|
||||
cy.visit(`${basePath}`);
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('#mempool-block-0 > .blockLink').should('exist');
|
||||
});
|
||||
|
||||
it('loads the dashboard', () => {
|
||||
cy.visit(`${basePath}`);
|
||||
cy.waitForSkeletonGone();
|
||||
});
|
||||
|
||||
it('loads the blocks page', () => {
|
||||
cy.visit(`${basePath}/blocks`);
|
||||
cy.waitForSkeletonGone();
|
||||
});
|
||||
|
||||
it('loads a specific block page', () => {
|
||||
cy.visit(`${basePath}/block/7e1369a23a5ab861e7bdede2aadcccae4ea873ffd9caf11c7c5541eb5bcdff54`);
|
||||
cy.waitForSkeletonGone();
|
||||
});
|
||||
|
||||
it('loads the graphs page', () => {
|
||||
cy.visit(`${basePath}/graphs`);
|
||||
cy.waitForSkeletonGone();
|
||||
});
|
||||
|
||||
it('loads the tv page - desktop', () => {
|
||||
cy.visit(`${basePath}`);
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('li:nth-of-type(3) > a').click().then(() => {
|
||||
cy.wait(1000);
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the graphs page - mobile', () => {
|
||||
cy.visit(`${basePath}`)
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('li:nth-of-type(3) > a').click().then(() => {
|
||||
cy.viewport('iphone-6');
|
||||
cy.wait(1000);
|
||||
cy.get('.tv-only').should('not.exist');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders unconfidential addresses correctly on mobile', () => {
|
||||
cy.viewport('iphone-6');
|
||||
cy.visit(`${basePath}/address/ex1qqmmjdwrlg59c8q4l75sj6wedjx57tj5grt8pat`);
|
||||
cy.waitForSkeletonGone();
|
||||
//TODO: Add proper IDs for these selectors
|
||||
const firstRowSelector = '.container-xl > :nth-child(3) > div > :nth-child(1) > .table > tbody';
|
||||
const thirdRowSelector = '.container-xl > :nth-child(3) > div > :nth-child(3)';
|
||||
cy.get(firstRowSelector).invoke('css', 'width').then(firstRowWidth => {
|
||||
cy.get(thirdRowSelector).invoke('css', 'width').then(thirdRowWidth => {
|
||||
expect(parseInt(firstRowWidth)).to.be.lessThan(parseInt(thirdRowWidth));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('peg in/peg out', () => {
|
||||
it('loads peg in addresses', () => {
|
||||
cy.visit(`${basePath}/tx/fe764f7bedfc2a37b29d9c8aef67d64a57d253a6b11c5a55555cfd5826483a58`);
|
||||
cy.waitForSkeletonGone();
|
||||
//TODO: Change to an element id so we don't assert on a string
|
||||
cy.get('#table-tx-vin').should('contain', 'Peg-in');
|
||||
cy.get('#table-tx-vin a').click().then(() => {
|
||||
cy.waitForSkeletonGone();
|
||||
if (baseModule === 'liquid') {
|
||||
cy.url().should('eq', 'https://mempool.space/tx/f148c0d854db4174ea420655235f910543f0ec3680566dcfdf84fb0a1697b592');
|
||||
} else {
|
||||
//TODO: Use an environment variable to get the hostname
|
||||
cy.url().should('eq', 'http://localhost:4200/tx/f148c0d854db4174ea420655235f910543f0ec3680566dcfdf84fb0a1697b592');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('loads peg out addresses', () => {
|
||||
cy.visit(`${basePath}/tx/ecf6eba04ffb3946faa172343c87162df76f1a57b07b0d6dc6ad956b13376dc8`);
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('#table-tx-vout a').first().click().then(() => {
|
||||
cy.waitForSkeletonGone();
|
||||
if (baseModule === 'liquid') {
|
||||
cy.url().should('eq', 'https://mempool.space/address/1BxoGcMg14oaH3CwHD2hF4gU9VcfgX5yoR');
|
||||
} else {
|
||||
//TODO: Use an environment variable to get the hostname
|
||||
cy.url().should('eq', 'http://localhost:4200/address/1BxoGcMg14oaH3CwHD2hF4gU9VcfgX5yoR');
|
||||
}
|
||||
//TODO: Add a custom class so we don't assert on a string
|
||||
cy.get('.badge').should('contain','Liquid Peg Out');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('assets', () => {
|
||||
it('shows the assets screen', () => {
|
||||
cy.visit(`${basePath}/assets`);
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('table tr').should('have.length.at.least', 5);
|
||||
});
|
||||
|
||||
it('allows searching assets', () => {
|
||||
cy.visit(`${basePath}/assets`);
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('.container-xl input').click().type('Liquid Bitcoin').then(() => {
|
||||
cy.get('table tr').should('have.length', 1);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a specific asset ID', () => {
|
||||
cy.visit(`${basePath}/assets`);
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('.container-xl input').click().type('Liquid AUD').then(() => {
|
||||
cy.get('table tr td:nth-of-type(1) a').click();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('unblinded TX', () => {
|
||||
|
||||
it('should not show an unblinding error message for regular txs', () => {
|
||||
cy.visit(`${basePath}/tx/82a479043ec3841e0d3f829afc8df4f0e2bbd675a13f013ea611b2fde0027d45`);
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('.error-unblinded' ).should('not.exist');
|
||||
});
|
||||
|
||||
it('show unblinded TX', () => {
|
||||
cy.visit(`${basePath}/tx/f2f41c0850e8e7e3f1af233161fd596662e67c11ef10ed15943884186fbb7f46#blinded=100000,6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d,0ab9f70650f16b1db8dfada05237f7d0d65191c3a13183da8a2ddddfbde9a2ad,fd98b2edc5530d76acd553f206a431f4c1fab27e10e290ad719582af878e98fc,2364760,6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d,90c7a43b15b905bca045ca42a01271cfe71d2efe3133f4197792c24505cb32ed,12eb5959d9293b8842e7dd8bc9aa9639fd3fd031c5de3ba911adeca94eb57a3a`);
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('#table-tx-vin tr').should('have.class', 'assetBox');
|
||||
cy.get('#table-tx-vout tr').should('have.class', 'assetBox');
|
||||
});
|
||||
|
||||
it('show empty unblinded TX', () => {
|
||||
cy.visit(`${basePath}/tx/f2f41c0850e8e7e3f1af233161fd596662e67c11ef10ed15943884186fbb7f46#blinded=`);
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('#table-tx-vin tr').should('have.class', '');
|
||||
cy.get('#table-tx-vout tr').should('have.class', '');
|
||||
});
|
||||
|
||||
it('show invalid unblinded TX hex', () => {
|
||||
cy.visit(`${basePath}/tx/f2f41c0850e8e7e3f1af233161fd596662e67c11ef10ed15943884186fbb7f46#blinded=123`);
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('#table-tx-vin tr').should('have.class', '');
|
||||
cy.get('#table-tx-vout tr').should('have.class', '');
|
||||
cy.get('.error-unblinded' ).contains('Error: Invalid blinding data (invalid hex)');
|
||||
});
|
||||
|
||||
it('show first unblinded vout', () => {
|
||||
cy.visit(`${basePath}/tx/f2f41c0850e8e7e3f1af233161fd596662e67c11ef10ed15943884186fbb7f46#blinded=100000,6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d,0ab9f70650f16b1db8dfada05237f7d0d65191c3a13183da8a2ddddfbde9a2ad,fd98b2edc5530d76acd553f206a431f4c1fab27e10e290ad719582af878e98fc`);
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('#table-tx-vout tr:first-child()').should('have.class', 'assetBox');
|
||||
});
|
||||
|
||||
it('show second unblinded vout', () => {
|
||||
cy.visit(`${basePath}/tx/f2f41c0850e8e7e3f1af233161fd596662e67c11ef10ed15943884186fbb7f46#blinded=2364760,6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d,90c7a43b15b905bca045ca42a01271cfe71d2efe3133f4197792c24505cb32ed,12eb5959d9293b8842e7dd8bc9aa9639fd3fd031c5de3ba911adeca94eb57a3a`);
|
||||
cy.get('#table-tx-vout tr').should('have.class', 'assetBox');
|
||||
});
|
||||
|
||||
it('show invalid error unblinded TX', () => {
|
||||
cy.visit(`${basePath}/tx/f2f41c0850e8e7e3f1af233161fd596662e67c11ef10ed15943884186fbb7f46#blinded=100000,6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d,0ab9f70650f16b1db8dfada05237f7d0d65191c3a13183da8a2ddddfbde9a2ad,fd98b2edc5530d76acd553f206a431f4c1fab27e10e290ad719582af878e98fc,2364760,6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d,90c7a43b15b905bca045ca42a01271cfe71d2efe3133f4197792c24505cb32ed,12eb5959d9293b8842e7dd8bc9aa9639fd3fd031c5de3ba911adeca94eb57a3c`);
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('#table-tx-vout tr').should('have.class', 'assetBox');
|
||||
cy.get('.error-unblinded' ).contains('Error: Invalid blinding data.');
|
||||
});
|
||||
|
||||
it('shows asset peg in/out and burn transactions', () => {
|
||||
cy.visit(`${basePath}/asset/6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d`);
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('#table-tx-vout tr').not('.assetBox');
|
||||
cy.get('#table-tx-vin tr').not('.assetBox');
|
||||
});
|
||||
|
||||
it('prevents regressing issue #644', () => {
|
||||
cy.visit(`${basePath}/tx/393b890966f305e7c440fcfb12a13f51a7a9011cc59ff5f14f6f93214261bd82`);
|
||||
cy.waitForSkeletonGone();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
it.skip(`Tests cannot be run on the selected BASE_MODULE ${baseModule}`);
|
||||
}
|
||||
});
|
||||
534
frontend/cypress/integration/mainnet/mainnet.spec.ts
Normal file
534
frontend/cypress/integration/mainnet/mainnet.spec.ts
Normal file
@@ -0,0 +1,534 @@
|
||||
import { emitMempoolInfo, dropWebSocket } from "../../support/websocket";
|
||||
|
||||
const baseModule = Cypress.env("BASE_MODULE");
|
||||
|
||||
|
||||
//Credit: https://github.com/bahmutov/cypress-examples/blob/6cedb17f83a3bb03ded13cf1d6a3f0656ca2cdf5/docs/recipes/overlapping-elements.md
|
||||
|
||||
/**
|
||||
* Returns true if two DOM rectangles are overlapping
|
||||
* @param {DOMRect} rect1 the bounding client rectangle of the first element
|
||||
* @param {DOMRect} rect2 the bounding client rectangle of the second element
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const areOverlapping = (rect1, rect2) => {
|
||||
// if one rectangle is on the left side of the other
|
||||
if (rect1.right < rect2.left || rect2.right < rect1.left) {
|
||||
return false
|
||||
}
|
||||
|
||||
// if one rectangle is above the other
|
||||
if (rect1.bottom < rect2.top || rect2.bottom < rect1.top) {
|
||||
return false
|
||||
}
|
||||
|
||||
// the rectangles must overlap
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the bounding rectangle of the first DOM
|
||||
* element in the given jQuery object.
|
||||
*/
|
||||
const getRectangle = ($el) => $el[0].getBoundingClientRect();
|
||||
|
||||
describe('Mainnet', () => {
|
||||
beforeEach(() => {
|
||||
//cy.intercept('/sockjs-node/info*').as('socket');
|
||||
cy.intercept('/api/block-height/*').as('block-height');
|
||||
cy.intercept('/api/block/*').as('block');
|
||||
cy.intercept('/api/block/*/txs/0').as('block-txs');
|
||||
cy.intercept('/api/tx/*/outspends').as('tx-outspends');
|
||||
cy.intercept('/resources/pools.json').as('pools');
|
||||
|
||||
// Search Auto Complete
|
||||
cy.intercept('/api/address-prefix/1wiz').as('search-1wiz');
|
||||
cy.intercept('/api/address-prefix/1wizS').as('search-1wizS');
|
||||
cy.intercept('/api/address-prefix/1wizSA').as('search-1wizSA');
|
||||
|
||||
Cypress.Commands.add('waitForBlockData', () => {
|
||||
cy.wait('@tx-outspends');
|
||||
cy.wait('@pools');
|
||||
});
|
||||
});
|
||||
|
||||
if (baseModule === 'mempool') {
|
||||
|
||||
it('check first mempool block after skeleton loads', () => {
|
||||
cy.visit('/');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('#mempool-block-0 > .blockLink').should('exist');
|
||||
});
|
||||
|
||||
it('loads the status screen', () => {
|
||||
cy.visit('/status');
|
||||
cy.get('#mempool-block-0').should('be.visible');
|
||||
cy.get('[id^="bitcoin-block-"]').should('have.length', 8);
|
||||
cy.get('.footer').should('be.visible');
|
||||
cy.get('.row > :nth-child(1)').invoke('text').then((text) => {
|
||||
expect(text).to.match(/Tx vBytes per second:.* vB\/s/);
|
||||
});
|
||||
cy.get('.row > :nth-child(2)').invoke('text').then((text) => {
|
||||
expect(text).to.match(/Unconfirmed:(.*)/);
|
||||
});
|
||||
cy.get('.row > :nth-child(3)').invoke('text').then((text) => {
|
||||
expect(text).to.match(/Mempool size:(.*) (kB|MB) \((\d+) (block|blocks)\)/);
|
||||
});
|
||||
});
|
||||
|
||||
it('loads dashboard, drop websocket and reconnect', () => {
|
||||
cy.viewport('macbook-16');
|
||||
cy.mockMempoolSocket();
|
||||
cy.visit('/');
|
||||
cy.get('.badge').should('not.exist');
|
||||
dropWebSocket();
|
||||
cy.get('.badge').should('be.visible');
|
||||
cy.get('.badge', {timeout: 25000}).should('not.exist');
|
||||
emitMempoolInfo({
|
||||
'params': {
|
||||
command: 'init'
|
||||
}
|
||||
});
|
||||
cy.get(':nth-child(1) > #bitcoin-block-0').should('not.exist');
|
||||
cy.get(':nth-child(2) > #bitcoin-block-0').should('not.exist');
|
||||
cy.get(':nth-child(3) > #bitcoin-block-0').should('not.exist');
|
||||
});
|
||||
|
||||
it('loads the dashboard', () => {
|
||||
cy.visit('/');
|
||||
cy.waitForSkeletonGone();
|
||||
});
|
||||
|
||||
it('check op_return tx tooltip', () => {
|
||||
cy.visit('/block/00000000000000000003c5f542bed265319c6cf64238cf1f1bb9bca3ebf686d2');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('tbody > :nth-child(2) > :nth-child(1) > a').first().trigger('onmouseover');
|
||||
cy.get('tbody > :nth-child(2) > :nth-child(1) > a').first().trigger('mouseenter');
|
||||
cy.get('.tooltip-inner').should('be.visible');
|
||||
});
|
||||
|
||||
it('check op_return coinbase tooltip', () => {
|
||||
cy.visit('/block/00000000000000000003c5f542bed265319c6cf64238cf1f1bb9bca3ebf686d2');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('div > a > .badge').first().trigger('onmouseover');
|
||||
cy.get('div > a > .badge').first().trigger('mouseenter');
|
||||
cy.get('.tooltip-inner').should('be.visible');
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('allows searching for partial Bitcoin addresses', () => {
|
||||
cy.visit('/');
|
||||
cy.get('.search-box-container > .form-control').type('1wiz').then(() => {
|
||||
cy.wait('@search-1wiz');
|
||||
cy.get('ngb-typeahead-window button.dropdown-item').should('have.length', 10);
|
||||
});
|
||||
|
||||
cy.get('.search-box-container > .form-control').type('S').then(() => {
|
||||
cy.wait('@search-1wizS');
|
||||
cy.get('ngb-typeahead-window button.dropdown-item').should('have.length', 5);
|
||||
});
|
||||
|
||||
cy.get('.search-box-container > .form-control').type('A').then(() => {
|
||||
cy.wait('@search-1wizSA');
|
||||
cy.get('ngb-typeahead-window button.dropdown-item').should('have.length', 1)
|
||||
});
|
||||
|
||||
cy.get('ngb-typeahead-window button.dropdown-item.active').click().then(() => {
|
||||
cy.url().should('include', '/address/1wizSAYSbuyXbt9d8JV8ytm5acqq2TorC');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('.text-center').should('not.have.text', 'Invalid Bitcoin address');
|
||||
});
|
||||
});
|
||||
|
||||
['BC1PQYQSZQ', 'bc1PqYqSzQ'].forEach((searchTerm) => {
|
||||
it(`allows searching for partial case insensitive bech32m addresses: ${searchTerm}`, () => {
|
||||
cy.visit('/');
|
||||
cy.get('.search-box-container > .form-control').type(searchTerm).then(() => {
|
||||
cy.get('ngb-typeahead-window button.dropdown-item').should('have.length', 1);
|
||||
cy.get('ngb-typeahead-window button.dropdown-item.active').click().then(() => {
|
||||
cy.url().should('include', '/address/bc1pqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsyjer9e');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('.text-center').should('not.have.text', 'Invalid Bitcoin address');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
['BC1Q000375VXCU', 'bC1q000375vXcU'].forEach((searchTerm) => {
|
||||
it(`allows searching for partial case insensitive bech32 addresses: ${searchTerm}`, () => {
|
||||
cy.visit('/');
|
||||
cy.get('.search-box-container > .form-control').type(searchTerm).then(() => {
|
||||
cy.get('ngb-typeahead-window button.dropdown-item').should('have.length', 1);
|
||||
cy.get('ngb-typeahead-window button.dropdown-item.active').click().then(() => {
|
||||
cy.url().should('include', '/address/bc1q000375vxcuf5v04lmwy22vy2thvhqkxghgq7dy');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('.text-center').should('not.have.text', 'Invalid Bitcoin address');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('blocks navigation', () => {
|
||||
|
||||
describe('keyboard events', () => {
|
||||
it('loads first blockchain blocks visible and keypress arrow right', () => {
|
||||
cy.viewport('macbook-16');
|
||||
cy.visit('/');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('.blockchain-blocks-0 > a').click().then(() => {
|
||||
cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').should('not.exist');
|
||||
cy.get('[ngbtooltip="Previous Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible');
|
||||
cy.waitForPageIdle();
|
||||
cy.document().right();
|
||||
cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible');
|
||||
cy.get('[ngbtooltip="Previous Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible');
|
||||
});
|
||||
});
|
||||
|
||||
it('loads first blockchain blocks visible and keypress arrow left', () => {
|
||||
cy.viewport('macbook-16');
|
||||
cy.visit('/');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('.blockchain-blocks-0 > a').click().then(() => {
|
||||
cy.waitForPageIdle();
|
||||
cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').should('not.exist');
|
||||
cy.get('[ngbtooltip="Previous Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible');
|
||||
cy.document().left();
|
||||
cy.get('.title-block h1').invoke('text').should('equal', 'Next block');
|
||||
});
|
||||
});
|
||||
|
||||
it('loads last blockchain blocks and keypress arrow right', () => {
|
||||
cy.viewport('macbook-16');
|
||||
cy.visit('/');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('.blockchain-blocks-4 > a').click().then(() => {
|
||||
cy.waitForPageIdle();
|
||||
|
||||
// block 6
|
||||
cy.document().right();
|
||||
cy.wait(5000);
|
||||
cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible');
|
||||
cy.get('[ngbtooltip="Previous Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible');
|
||||
|
||||
// block 7
|
||||
cy.document().right();
|
||||
cy.wait(5000);
|
||||
cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible');
|
||||
cy.get('[ngbtooltip="Previous Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible');
|
||||
|
||||
// block 8 - last visible block
|
||||
cy.document().right();
|
||||
cy.wait(5000);
|
||||
cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible');
|
||||
cy.get('[ngbtooltip="Previous Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible');
|
||||
|
||||
// block 9 - not visible at the blochchain blocks visible block
|
||||
cy.document().right();
|
||||
cy.wait(5000);
|
||||
cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible');
|
||||
cy.get('[ngbtooltip="Previous Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible');
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
it('loads genesis block and keypress arrow right', () => {
|
||||
cy.viewport('macbook-16');
|
||||
cy.visit('/block/0');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.waitForPageIdle();
|
||||
|
||||
cy.document().right();
|
||||
cy.wait(5000);
|
||||
cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible');
|
||||
cy.get('[ngbtooltip="Previous Block"] > .ng-fa-icon > .svg-inline--fa').should('not.exist');
|
||||
});
|
||||
|
||||
it('loads genesis block and keypress arrow left', () => {
|
||||
cy.viewport('macbook-16');
|
||||
cy.visit('/block/0');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.waitForPageIdle();
|
||||
|
||||
cy.document().left();
|
||||
cy.wait(5000);
|
||||
cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible');
|
||||
cy.get('[ngbtooltip="Previous Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible');
|
||||
});
|
||||
});
|
||||
describe('mouse events', () => {
|
||||
it('loads first blockchain blocks visible and click on the arrow right', () => {
|
||||
cy.viewport('macbook-16');
|
||||
cy.visit('/');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('.blockchain-blocks-0 > a').click().then(() => {
|
||||
cy.waitForPageIdle();
|
||||
cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').should('not.exist');
|
||||
cy.get('[ngbtooltip="Previous Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible');
|
||||
cy.get('[ngbtooltip="Previous Block"] > .ng-fa-icon > .svg-inline--fa').click().then(() => {
|
||||
cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible');
|
||||
cy.get('[ngbtooltip="Previous Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('loads genesis block and click on the arrow left', () => {
|
||||
cy.viewport('macbook-16');
|
||||
cy.visit('/block/0');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.waitForPageIdle();
|
||||
cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible');
|
||||
cy.get('[ngbtooltip="Previous Block"] > .ng-fa-icon > .svg-inline--fa').should('not.exist');
|
||||
cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').click().then(() => {
|
||||
cy.get('[ngbtooltip="Next Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible');
|
||||
cy.get('[ngbtooltip="Previous Block"] > .ng-fa-icon > .svg-inline--fa').should('be.visible');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('loads skeleton when changes between networks', () => {
|
||||
cy.visit('/');
|
||||
cy.waitForSkeletonGone();
|
||||
|
||||
cy.changeNetwork("testnet");
|
||||
cy.changeNetwork("signet");
|
||||
cy.changeNetwork("mainnet");
|
||||
});
|
||||
|
||||
it.skip('loads the dashboard with the skeleton blocks', () => {
|
||||
cy.mockMempoolSocket();
|
||||
cy.visit("/");
|
||||
cy.get(':nth-child(1) > #bitcoin-block-0').should('be.visible');
|
||||
cy.get(':nth-child(2) > #bitcoin-block-0').should('be.visible');
|
||||
cy.get(':nth-child(3) > #bitcoin-block-0').should('be.visible');
|
||||
cy.get('#mempool-block-0').should('be.visible');
|
||||
cy.get('#mempool-block-1').should('be.visible');
|
||||
cy.get('#mempool-block-2').should('be.visible');
|
||||
|
||||
emitMempoolInfo({
|
||||
'params': {
|
||||
command: 'init'
|
||||
}
|
||||
});
|
||||
|
||||
cy.get(':nth-child(1) > #bitcoin-block-0').should('not.exist');
|
||||
cy.get(':nth-child(2) > #bitcoin-block-0').should('not.exist');
|
||||
cy.get(':nth-child(3) > #bitcoin-block-0').should('not.exist');
|
||||
});
|
||||
|
||||
it('loads the blocks screen', () => {
|
||||
cy.visit('/');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('li:nth-of-type(2) > a').click().then(() => {
|
||||
cy.waitForPageIdle();
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the graphs screen', () => {
|
||||
cy.visit('/');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('li:nth-of-type(3) > a').click().then(() => {
|
||||
cy.wait(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('graphs page', () => {
|
||||
it('check buttons - mobile', () => {
|
||||
cy.viewport('iphone-6');
|
||||
cy.visit('/graphs');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('.small-buttons > :nth-child(2)').should('be.visible');
|
||||
cy.get('#dropdownFees').should('be.visible');
|
||||
cy.get('.btn-group').should('be.visible');
|
||||
});
|
||||
it('check buttons - tablet', () => {
|
||||
cy.viewport('ipad-2');
|
||||
cy.visit('/graphs');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('.small-buttons > :nth-child(2)').should('be.visible');
|
||||
cy.get('#dropdownFees').should('be.visible');
|
||||
cy.get('.btn-group').should('be.visible');
|
||||
});
|
||||
it('check buttons - desktop', () => {
|
||||
cy.viewport('macbook-16');
|
||||
cy.visit('/graphs');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('.small-buttons > :nth-child(2)').should('be.visible');
|
||||
cy.get('#dropdownFees').should('be.visible');
|
||||
cy.get('.btn-group').should('be.visible');
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the tv screen - desktop', () => {
|
||||
cy.viewport('macbook-16');
|
||||
cy.visit('/');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('li:nth-of-type(4) > a').click().then(() => {
|
||||
cy.viewport('macbook-16');
|
||||
cy.get('.chart-holder');
|
||||
cy.get('.blockchain-wrapper').should('be.visible');
|
||||
cy.get('#mempool-block-0').should('be.visible');
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the tv screen - mobile', () => {
|
||||
cy.viewport('iphone-6');
|
||||
cy.visit('/tv');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('.chart-holder');
|
||||
cy.get('.blockchain-wrapper').should('not.visible');
|
||||
});
|
||||
|
||||
it('loads the api screen', () => {
|
||||
cy.visit('/');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('li:nth-of-type(5) > a').click().then(() => {
|
||||
cy.wait(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('blocks', () => {
|
||||
it('shows empty blocks properly', () => {
|
||||
cy.visit('/block/0000000000000000000bd14f744ef2e006e61c32214670de7eb891a5732ee775');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.waitForPageIdle();
|
||||
cy.get('h2').invoke('text').should('equal', '1 transaction');
|
||||
});
|
||||
|
||||
it('expands and collapses the block details', () => {
|
||||
cy.visit('/block/0');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.waitForPageIdle();
|
||||
cy.get('.btn.btn-outline-info').click().then(() => {
|
||||
cy.get('#details').should('be.visible');
|
||||
});
|
||||
|
||||
cy.get('.btn.btn-outline-info').click().then(() => {
|
||||
cy.get('#details').should('not.be.visible');
|
||||
});
|
||||
});
|
||||
it('shows blocks with no pagination', () => {
|
||||
cy.visit('/block/00000000000000000001ba40caf1ad4cec0ceb77692662315c151953bfd7c4c4');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.waitForPageIdle();
|
||||
cy.get('.block-tx-title h2').invoke('text').should('equal', '19 transactions');
|
||||
cy.get('.pagination-container ul.pagination').first().children().should('have.length', 5);
|
||||
});
|
||||
|
||||
it('supports pagination on the block screen', () => {
|
||||
// 41 txs
|
||||
cy.visit('/block/00000000000000000009f9b7b0f63ad50053ad12ec3b7f5ca951332f134f83d8');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('.pagination-container a').invoke('text').then((text1) => {
|
||||
cy.get('.active + li').first().click().then(() => {
|
||||
cy.waitForSkeletonGone();
|
||||
cy.waitForPageIdle();
|
||||
cy.get('.header-bg.box > a').invoke('text').then((text2) => {
|
||||
expect(text1).not.to.eq(text2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('shows blocks pagination with 5 pages (desktop)', () => {
|
||||
cy.viewport(760, 800);
|
||||
cy.visit('/block/000000000000000000049281946d26fcba7d99fdabc1feac524bc3a7003d69b3').then(() => {
|
||||
cy.waitForSkeletonGone();
|
||||
cy.waitForPageIdle();
|
||||
});
|
||||
|
||||
// 5 pages + 4 buttons = 9 buttons
|
||||
cy.get('.pagination-container ul.pagination').first().children().should('have.length', 9);
|
||||
});
|
||||
|
||||
it('shows blocks pagination with 3 pages (mobile)', () => {
|
||||
cy.viewport(669, 800);
|
||||
cy.visit('/block/000000000000000000049281946d26fcba7d99fdabc1feac524bc3a7003d69b3').then(() => {
|
||||
cy.waitForSkeletonGone();
|
||||
cy.waitForPageIdle();
|
||||
});
|
||||
|
||||
// 3 pages + 4 buttons = 7 buttons
|
||||
cy.get('.pagination-container ul.pagination').first().children().should('have.length', 7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RBF transactions', () => {
|
||||
it('shows RBF transactions properly (mobile)', () => {
|
||||
cy.viewport('iphone-xr');
|
||||
cy.mockMempoolSocket();
|
||||
cy.visit('/tx/f81a08699b62b2070ad8fe0f2a076f8bea0386a2fdcd8124caee42cbc564a0d5');
|
||||
|
||||
cy.waitForSkeletonGone();
|
||||
|
||||
emitMempoolInfo({
|
||||
'params': {
|
||||
command: 'init'
|
||||
}
|
||||
});
|
||||
|
||||
cy.get('#mempool-block-0');
|
||||
|
||||
emitMempoolInfo({
|
||||
'params': {
|
||||
command: 'rbfTransaction'
|
||||
}
|
||||
});
|
||||
|
||||
cy.get('.alert-mempool').should('be.visible');
|
||||
cy.get('.alert-mempool').invoke('css', 'width').then((alertWidth) => {
|
||||
cy.get('.container-xl > :nth-child(3)').invoke('css', 'width').should('equal', alertWidth);
|
||||
});
|
||||
|
||||
cy.get('.btn-success').then(getRectangle).then((rectA) => {
|
||||
cy.get('.alert-mempool').then(getRectangle).then((rectB) => {
|
||||
expect(areOverlapping(rectA, rectB), 'Confirmations box and RBF alert are overlapping').to.be.false;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('shows RBF transactions properly (desktop)', () => {
|
||||
cy.viewport('macbook-16');
|
||||
cy.mockMempoolSocket();
|
||||
cy.visit('/tx/f81a08699b62b2070ad8fe0f2a076f8bea0386a2fdcd8124caee42cbc564a0d5');
|
||||
|
||||
cy.waitForSkeletonGone();
|
||||
|
||||
emitMempoolInfo({
|
||||
'params': {
|
||||
command: 'init'
|
||||
}
|
||||
});
|
||||
|
||||
cy.get('#mempool-block-0');
|
||||
|
||||
emitMempoolInfo({
|
||||
'params': {
|
||||
command: 'rbfTransaction'
|
||||
}
|
||||
});
|
||||
|
||||
cy.get('.alert-mempool').should('be.visible');
|
||||
|
||||
const alertLocator = '.alert-mempool';
|
||||
const tableLocator = '.container-xl > :nth-child(3)';
|
||||
|
||||
cy.get(tableLocator).invoke('css', 'width').then((firstWidth) => {
|
||||
cy.get(alertLocator).invoke('css', 'width').should('equal', firstWidth);
|
||||
});
|
||||
|
||||
cy.get('.btn-success').then(getRectangle).then((rectA) => {
|
||||
cy.get('.alert-mempool').then(getRectangle).then((rectB) => {
|
||||
expect(areOverlapping(rectA, rectB), 'Confirmations box and RBF alert are overlapping').to.be.false;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
it.skip(`Tests cannot be run on the selected BASE_MODULE ${baseModule}`);
|
||||
}
|
||||
});
|
||||
139
frontend/cypress/integration/signet/signet.spec.ts
Normal file
139
frontend/cypress/integration/signet/signet.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { emitMempoolInfo } from "../../support/websocket";
|
||||
|
||||
const baseModule = Cypress.env("BASE_MODULE");
|
||||
|
||||
describe('Signet', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('/api/block-height/*').as('block-height');
|
||||
cy.intercept('/api/block/*').as('block');
|
||||
cy.intercept('/api/block/*/txs/0').as('block-txs');
|
||||
cy.intercept('/api/tx/*/outspends').as('tx-outspends');
|
||||
});
|
||||
|
||||
|
||||
if (baseModule === 'mempool') {
|
||||
it('loads the dashboard', () => {
|
||||
cy.visit('/signet');
|
||||
cy.waitForSkeletonGone();
|
||||
});
|
||||
|
||||
it('check first mempool block after skeleton loads', () => {
|
||||
cy.visit('/');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('#mempool-block-0 > .blockLink').should('exist');
|
||||
});
|
||||
|
||||
it.skip('loads the dashboard with the skeleton blocks', () => {
|
||||
cy.mockMempoolSocket();
|
||||
cy.visit("/signet");
|
||||
cy.get(':nth-child(1) > #bitcoin-block-0').should('be.visible');
|
||||
cy.get(':nth-child(2) > #bitcoin-block-0').should('be.visible');
|
||||
cy.get(':nth-child(3) > #bitcoin-block-0').should('be.visible');
|
||||
cy.get('#mempool-block-0').should('be.visible');
|
||||
cy.get('#mempool-block-1').should('be.visible');
|
||||
cy.get('#mempool-block-2').should('be.visible');
|
||||
|
||||
emitMempoolInfo({
|
||||
'params': {
|
||||
"network": "signet"
|
||||
}
|
||||
});
|
||||
|
||||
cy.get(':nth-child(1) > #bitcoin-block-0').should('not.exist');
|
||||
cy.get(':nth-child(2) > #bitcoin-block-0').should('not.exist');
|
||||
cy.get(':nth-child(3) > #bitcoin-block-0').should('not.exist');
|
||||
});
|
||||
|
||||
it('loads the blocks screen', () => {
|
||||
cy.visit('/signet');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('li:nth-of-type(2) > a').click().then(() => {
|
||||
cy.wait(1000);
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the graphs screen', () => {
|
||||
cy.visit('/signet');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('li:nth-of-type(3) > a').click().then(() => {
|
||||
cy.wait(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tv mode', () => {
|
||||
it('loads the tv screen - desktop', () => {
|
||||
cy.viewport('macbook-16');
|
||||
cy.visit('/signet');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('li:nth-of-type(4) > a').click().then(() => {
|
||||
cy.get('.chart-holder').should('be.visible');
|
||||
cy.get('#mempool-block-0').should('be.visible');
|
||||
cy.get('.tv-only').should('not.exist');
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the tv screen - mobile', () => {
|
||||
cy.visit('/signet');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('li:nth-of-type(4) > a').click().then(() => {
|
||||
cy.viewport('iphone-8');
|
||||
cy.get('.chart-holder').should('be.visible');
|
||||
//TODO: Remove comment when the bug is fixed
|
||||
//cy.get('#mempool-block-0').should('be.visible');
|
||||
cy.get('.tv-only').should('not.exist');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('loads the api screen', () => {
|
||||
cy.visit('/signet');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('li:nth-of-type(5) > a').click().then(() => {
|
||||
cy.wait(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('blocks', () => {
|
||||
it('shows empty blocks properly', () => {
|
||||
cy.visit('/signet/block/00000133d54e4589f6436703b067ec23209e0a21b8a9b12f57d0592fd85f7a42');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('h2').invoke('text').should('equal', '1 transaction');
|
||||
});
|
||||
|
||||
it('expands and collapses the block details', () => {
|
||||
cy.visit('/signet/block/0');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('.btn.btn-outline-info').click().then(() => {
|
||||
cy.get('#details').should('be.visible');
|
||||
});
|
||||
|
||||
cy.get('.btn.btn-outline-info').click().then(() => {
|
||||
cy.get('#details').should('not.be.visible');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows blocks with no pagination', () => {
|
||||
cy.visit('/signet/block/00000078f920a96a69089877b934ce7fd009ab55e3170920a021262cb258e7cc');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('h2').invoke('text').should('equal', '13 transactions');
|
||||
cy.get('ul.pagination').first().children().should('have.length', 5);
|
||||
});
|
||||
|
||||
it('supports pagination on the block screen', () => {
|
||||
// 43 txs
|
||||
cy.visit('/signet/block/00000094bd52f73bdbfc4bece3a94c21fec2dc968cd54210496e69e4059d66a6');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('.header-bg.box > a').invoke('text').then((text1) => {
|
||||
cy.get('.active + li').first().click().then(() => {
|
||||
cy.get('.header-bg.box > a').invoke('text').then((text2) => {
|
||||
expect(text1).not.to.eq(text2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
it.skip(`Tests cannot be run on the selected BASE_MODULE ${baseModule}`);
|
||||
}
|
||||
});
|
||||
136
frontend/cypress/integration/testnet/testnet.spec.ts
Normal file
136
frontend/cypress/integration/testnet/testnet.spec.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { confirmAddress, emitMempoolInfo, sendWsMock, showNewTx, startTrackingAddress } from "../../support/websocket";
|
||||
|
||||
const baseModule = Cypress.env("BASE_MODULE");
|
||||
|
||||
describe('Testnet', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('/api/block-height/*').as('block-height');
|
||||
cy.intercept('/api/block/*').as('block');
|
||||
cy.intercept('/api/block/*/txs/0').as('block-txs');
|
||||
cy.intercept('/api/tx/*/outspends').as('tx-outspends');
|
||||
});
|
||||
|
||||
if (baseModule === 'mempool') {
|
||||
|
||||
it('loads the dashboard', () => {
|
||||
cy.visit('/testnet');
|
||||
cy.waitForSkeletonGone();
|
||||
});
|
||||
|
||||
it('check first mempool block after skeleton loads', () => {
|
||||
cy.visit('/');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('#mempool-block-0 > .blockLink').should('exist');
|
||||
});
|
||||
|
||||
it.skip('loads the dashboard with the skeleton blocks', () => {
|
||||
cy.mockMempoolSocket();
|
||||
cy.visit("/testnet");
|
||||
cy.get(':nth-child(1) > #bitcoin-block-0').should('be.visible');
|
||||
cy.get(':nth-child(2) > #bitcoin-block-0').should('be.visible');
|
||||
cy.get(':nth-child(3) > #bitcoin-block-0').should('be.visible');
|
||||
cy.get('#mempool-block-0').should('be.visible');
|
||||
cy.get('#mempool-block-1').should('be.visible');
|
||||
cy.get('#mempool-block-2').should('be.visible');
|
||||
|
||||
emitMempoolInfo({
|
||||
'params': {
|
||||
loaded: true
|
||||
}
|
||||
});
|
||||
|
||||
cy.get(':nth-child(1) > #bitcoin-block-0').should('not.exist');
|
||||
cy.get(':nth-child(2) > #bitcoin-block-0').should('not.exist');
|
||||
cy.get(':nth-child(3) > #bitcoin-block-0').should('not.exist');
|
||||
});
|
||||
|
||||
it('loads the blocks screen', () => {
|
||||
cy.visit('/testnet');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('li:nth-of-type(2) > a').click().then(() => {
|
||||
cy.wait(1000);
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the graphs screen', () => {
|
||||
cy.visit('/testnet');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('li:nth-of-type(3) > a').click().then(() => {
|
||||
cy.wait(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tv mode', () => {
|
||||
it('loads the tv screen - desktop', () => {
|
||||
cy.viewport('macbook-16');
|
||||
cy.visit('/testnet');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('li:nth-of-type(4) > a').click().then(() => {
|
||||
cy.wait(1000);
|
||||
cy.get('.tv-only').should('not.exist');
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the tv screen - mobile', () => {
|
||||
cy.visit('/testnet');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('li:nth-of-type(4) > a').click().then(() => {
|
||||
cy.viewport('iphone-6');
|
||||
cy.wait(1000);
|
||||
cy.get('.tv-only').should('not.exist');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('loads the api screen', () => {
|
||||
cy.visit('/testnet');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('li:nth-of-type(5) > a').click().then(() => {
|
||||
cy.wait(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('blocks', () => {
|
||||
it('shows empty blocks properly', () => {
|
||||
cy.visit('/testnet/block/0');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('h2').invoke('text').should('equal', '1 transaction');
|
||||
});
|
||||
|
||||
it('expands and collapses the block details', () => {
|
||||
cy.visit('/testnet/block/0');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('.btn.btn-outline-info').click().then(() => {
|
||||
cy.get('#details').should('be.visible');
|
||||
});
|
||||
|
||||
cy.get('.btn.btn-outline-info').click().then(() => {
|
||||
cy.get('#details').should('not.be.visible');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows blocks with no pagination', () => {
|
||||
cy.visit('/testnet/block/000000000000002f8ce27716e74ecc7ad9f7b5101fed12d09e28bb721b9460ea');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('h2').invoke('text').should('equal', '11 transactions');
|
||||
cy.get('ul.pagination').first().children().should('have.length', 5);
|
||||
});
|
||||
|
||||
it('supports pagination on the block screen', () => {
|
||||
// 48 txs
|
||||
cy.visit('/testnet/block/000000000000002ca3878ebd98b313a1c2d531f2e70a6575d232ca7564dea7a9');
|
||||
cy.waitForSkeletonGone();
|
||||
cy.get('.header-bg.box > a').invoke('text').then((text1) => {
|
||||
cy.get('.active + li').first().click().then(() => {
|
||||
cy.get('.header-bg.box > a').invoke('text').then((text2) => {
|
||||
expect(text1).not.to.eq(text2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
it.skip(`Tests cannot be run on the selected BASE_MODULE ${baseModule}`);
|
||||
}
|
||||
});
|
||||
13
frontend/cypress/plugins/index.js
Normal file
13
frontend/cypress/plugins/index.js
Normal file
@@ -0,0 +1,13 @@
|
||||
const fs = require('fs');
|
||||
|
||||
const CONFIG_FILE = 'mempool-frontend-config.json';
|
||||
|
||||
module.exports = (on, config) => {
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
let contents = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
||||
config.env.BASE_MODULE = contents.BASE_MODULE ? contents.BASE_MODULE : 'mempool';
|
||||
} else {
|
||||
config.env.BASE_MODULE = 'mempool';
|
||||
}
|
||||
return config;
|
||||
}
|
||||
63
frontend/cypress/support/PageIdleDetector.ts
Normal file
63
frontend/cypress/support/PageIdleDetector.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// source: chrisp_68 @ https://stackoverflow.com/questions/50525143/how-do-you-reliably-wait-for-page-idle-in-cypress-io-test
|
||||
export class PageIdleDetector
|
||||
{
|
||||
defaultOptions: Object = { timeout: 60000 };
|
||||
|
||||
public WaitForPageToBeIdle(): void
|
||||
{
|
||||
this.WaitForPageToLoad();
|
||||
this.WaitForAngularRequestsToComplete();
|
||||
this.WaitForAngularDigestCycleToComplete();
|
||||
this.WaitForAnimationsToStop();
|
||||
}
|
||||
|
||||
public WaitForPageToLoad(options: Object = this.defaultOptions): void
|
||||
{
|
||||
cy.document(options).should((myDocument: any) =>
|
||||
{
|
||||
expect(myDocument.readyState, "WaitForPageToLoad").to.be.oneOf(["interactive", "complete"]);
|
||||
});
|
||||
}
|
||||
|
||||
public WaitForAngularRequestsToComplete(options: Object = this.defaultOptions): void
|
||||
{
|
||||
cy.window(options).should((myWindow: any) =>
|
||||
{
|
||||
if (!!myWindow.angular)
|
||||
{
|
||||
expect(this.NumberOfPendingAngularRequests(myWindow), "WaitForAngularRequestsToComplete").to.have.length(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public WaitForAngularDigestCycleToComplete(options: Object = this.defaultOptions): void
|
||||
{
|
||||
cy.window(options).should((myWindow: any) =>
|
||||
{
|
||||
if (!!myWindow.angular)
|
||||
{
|
||||
expect(this.AngularRootScopePhase(myWindow), "WaitForAngularDigestCycleToComplete").to.be.null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public WaitForAnimationsToStop(options: Object = this.defaultOptions): void
|
||||
{
|
||||
cy.get(":animated", options).should("not.exist");
|
||||
}
|
||||
|
||||
private getInjector(myWindow: any)
|
||||
{
|
||||
return myWindow.angular.element(myWindow.document.body).injector();
|
||||
}
|
||||
|
||||
private NumberOfPendingAngularRequests(myWindow: any)
|
||||
{
|
||||
return this.getInjector(myWindow).get('$http').pendingRequests;
|
||||
}
|
||||
|
||||
private AngularRootScopePhase(myWindow: any)
|
||||
{
|
||||
return this.getInjector(myWindow).get("$rootScope").$$phase;
|
||||
}
|
||||
}
|
||||
147
frontend/cypress/support/commands.ts
Normal file
147
frontend/cypress/support/commands.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
// ***********************************************
|
||||
// This example namespace declaration will help
|
||||
// with Intellisense and code completion in your
|
||||
// IDE or Text Editor.
|
||||
// ***********************************************
|
||||
// declare namespace Cypress {
|
||||
// interface Chainable<Subject = any> {
|
||||
// customCommand(param: any): typeof customCommand;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// function customCommand(param: any): void {
|
||||
// console.warn(param);
|
||||
// }
|
||||
//
|
||||
// NOTE: You can use it like so:
|
||||
// Cypress.Commands.add('customCommand', customCommand);
|
||||
//
|
||||
// ***********************************************
|
||||
// This example commands.js shows you how to
|
||||
// create various custom commands and overwrite
|
||||
// existing commands.
|
||||
//
|
||||
// For more comprehensive examples of custom
|
||||
// commands please read more here:
|
||||
// https://on.cypress.io/custom-commands
|
||||
// ***********************************************
|
||||
//
|
||||
//
|
||||
// -- This is a parent command --
|
||||
// Cypress.Commands.add("login", (email, password) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a child command --
|
||||
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a dual command --
|
||||
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This will overwrite an existing command --
|
||||
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
|
||||
|
||||
'use strict'
|
||||
|
||||
import 'cypress-wait-until';
|
||||
import { PageIdleDetector } from './PageIdleDetector';
|
||||
import { mockWebSocket } from './websocket';
|
||||
|
||||
/* global Cypress */
|
||||
const codes = {
|
||||
ArrowLeft: 37,
|
||||
ArrowUp: 38,
|
||||
ArrowRight: 39,
|
||||
ArrowDown: 40
|
||||
}
|
||||
|
||||
Cypress.Commands.add('waitForSkeletonGone', () => {
|
||||
cy.waitUntil(() => {
|
||||
return Cypress.$('.skeleton-loader').length === 0;
|
||||
}, { verbose: true, description: "waitForSkeletonGone", errorMsg: "skeleton loaders never went away", timeout: 15000, interval: 50});
|
||||
});
|
||||
|
||||
Cypress.Commands.add(
|
||||
"waitForPageIdle",
|
||||
() => {
|
||||
console.warn("Waiting for page idle state");
|
||||
const pageIdleDetector = new PageIdleDetector();
|
||||
pageIdleDetector.WaitForPageToBeIdle();
|
||||
}
|
||||
);
|
||||
|
||||
Cypress.Commands.add('mockMempoolSocket', () => {
|
||||
mockWebSocket();
|
||||
});
|
||||
|
||||
Cypress.Commands.add('changeNetwork', (network: "testnet"|"signet"|"liquid"|"bisq"|"mainnet" ) => {
|
||||
cy.get('.dropdown-toggle').click().then(() => {
|
||||
cy.get(`.${network}`).click().then(() => {
|
||||
cy.waitForPageIdle();
|
||||
if(network !== 'bisq'){
|
||||
cy.waitForSkeletonGone();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/bahmutov/cypress-arrows/blob/8f0303842a343550fbeaf01528d01d1ff213b70c/src/index.js
|
||||
function keydownCommand ($el, key) {
|
||||
const message = `sending the "${key}" keydown event`
|
||||
const log = Cypress.log({
|
||||
name: `keydown: ${key}`,
|
||||
message: message,
|
||||
consoleProps: function () {
|
||||
return {
|
||||
Subject: $el
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const e = $el.createEvent('KeyboardEvent')
|
||||
|
||||
Object.defineProperty(e, 'key', {
|
||||
get: function () {
|
||||
return key
|
||||
}
|
||||
})
|
||||
|
||||
Object.defineProperty(e, 'keyCode', {
|
||||
get: function () {
|
||||
return this.keyCodeVal
|
||||
}
|
||||
})
|
||||
Object.defineProperty(e, 'which', {
|
||||
get: function () {
|
||||
return this.keyCodeVal
|
||||
}
|
||||
})
|
||||
var metaKey = false
|
||||
|
||||
Object.defineProperty(e, 'metaKey', {
|
||||
get: function () {
|
||||
return metaKey
|
||||
}
|
||||
})
|
||||
|
||||
Object.defineProperty(e, 'shiftKey', {
|
||||
get: function () {
|
||||
return false
|
||||
}
|
||||
})
|
||||
e.keyCodeVal = codes[key]
|
||||
|
||||
e.initKeyboardEvent('keydown', true, true,
|
||||
$el.defaultView, false, false, false, false, e.keyCodeVal, e.keyCodeVal)
|
||||
|
||||
$el.dispatchEvent(e)
|
||||
log.snapshot().end()
|
||||
return $el
|
||||
}
|
||||
|
||||
Cypress.Commands.add('keydown', { prevSubject: "dom" }, keydownCommand)
|
||||
Cypress.Commands.add('left', { prevSubject: "dom" }, $el => keydownCommand($el, 'ArrowLeft'))
|
||||
Cypress.Commands.add('right', { prevSubject: "dom" }, $el => keydownCommand($el, 'ArrowRight'))
|
||||
Cypress.Commands.add('up', { prevSubject: "dom" }, $el => keydownCommand($el, 'ArrowUp'))
|
||||
Cypress.Commands.add('down', { prevSubject: "dom" }, $el => keydownCommand($el, 'ArrowDown'))
|
||||
10
frontend/cypress/support/index.d.ts
vendored
Normal file
10
frontend/cypress/support/index.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
|
||||
/// <reference types="cypress" />
|
||||
declare namespace Cypress {
|
||||
interface Chainable<Subject> {
|
||||
waitForSkeletonGone(): Chainable<any>
|
||||
waitForPageIdle(): Chainable<any>
|
||||
mockMempoolSocket(): Chainable<any>
|
||||
changeNetwork(network: "testnet"|"signet"|"liquid"|"bisq"|"mainnet"): Chainable<any>
|
||||
}
|
||||
}
|
||||
20
frontend/cypress/support/index.ts
Normal file
20
frontend/cypress/support/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// ***********************************************************
|
||||
// This example support/index.js is processed and
|
||||
// loaded automatically before your test files.
|
||||
//
|
||||
// This is a great place to put global configuration and
|
||||
// behavior that modifies Cypress.
|
||||
//
|
||||
// You can change the location of this file or turn off
|
||||
// automatically serving support files with the
|
||||
// 'supportFile' configuration option.
|
||||
//
|
||||
// You can read more here:
|
||||
// https://on.cypress.io/configuration
|
||||
// ***********************************************************
|
||||
|
||||
// When a command from ./commands is ready to use, import with `import './commands'` syntax
|
||||
import './commands';
|
||||
import failOnConsoleError from 'cypress-fail-on-console-error';
|
||||
|
||||
failOnConsoleError();
|
||||
108
frontend/cypress/support/websocket.ts
Normal file
108
frontend/cypress/support/websocket.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { WebSocket, Server } from 'mock-socket';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
mockServer: Server;
|
||||
mockSocket: WebSocket;
|
||||
}
|
||||
}
|
||||
|
||||
const mocks: { [key: string]: { server: Server; websocket: WebSocket } } = {};
|
||||
|
||||
const cleanupMock = (url: string) => {
|
||||
if (mocks[url]) {
|
||||
mocks[url].websocket.close();
|
||||
mocks[url].server.stop();
|
||||
delete mocks[url];
|
||||
}
|
||||
};
|
||||
|
||||
const createMock = (url: string) => {
|
||||
cleanupMock(url);
|
||||
const server = new Server(url);
|
||||
const websocket = new WebSocket(url);
|
||||
mocks[url] = { server, websocket };
|
||||
|
||||
return mocks[url];
|
||||
};
|
||||
|
||||
export const mockWebSocket = () => {
|
||||
cy.on('window:before:load', (win) => {
|
||||
const winWebSocket = win.WebSocket;
|
||||
cy.stub(win, 'WebSocket').callsFake((url) => {
|
||||
console.log(url);
|
||||
if ((new URL(url).pathname.indexOf('/sockjs-node/') !== 0)) {
|
||||
const { server, websocket } = createMock(url);
|
||||
|
||||
win.mockServer = server;
|
||||
win.mockServer.on('connection', (socket) => {
|
||||
win.mockSocket = socket;
|
||||
win.mockSocket.send('{"action":"init"}');
|
||||
});
|
||||
|
||||
win.mockServer.on('message', (message) => {
|
||||
console.log(message);
|
||||
});
|
||||
|
||||
return websocket;
|
||||
} else {
|
||||
return new winWebSocket(url);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
cy.on('window:before:unload', () => {
|
||||
for (const url in mocks) {
|
||||
cleanupMock(url);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const emitMempoolInfo = ({
|
||||
params
|
||||
}: { params?: any } = {}) => {
|
||||
cy.window().then((win) => {
|
||||
//TODO: Refactor to take into account different parameterized mocking scenarios
|
||||
switch (params.network) {
|
||||
//TODO: Use network specific mocks
|
||||
case "signet":
|
||||
case "testnet":
|
||||
case "mainnet":
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
switch (params.command) {
|
||||
case "init": {
|
||||
win.mockSocket.send('{"action":"init"}');
|
||||
win.mockSocket.send('{"action":"want","data":["blocks","stats","mempool-blocks","live-2h-chart"]}');
|
||||
win.mockSocket.send('{"conversions":{"USD":32365.338815782445}}');
|
||||
cy.readFile('cypress/fixtures/mainnet_live2hchart.json', 'ascii').then((fixture) => {
|
||||
win.mockSocket.send(JSON.stringify(fixture));
|
||||
});
|
||||
cy.readFile('cypress/fixtures/mainnet_mempoolInfo.json', 'ascii').then((fixture) => {
|
||||
win.mockSocket.send(JSON.stringify(fixture));
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "rbfTransaction": {
|
||||
cy.readFile('cypress/fixtures/mainnet_rbf.json', 'ascii').then((fixture) => {
|
||||
win.mockSocket.send(JSON.stringify(fixture));
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
cy.waitForSkeletonGone();
|
||||
return cy.get('#mempool-block-0');
|
||||
};
|
||||
|
||||
export const dropWebSocket = (() => {
|
||||
cy.window().then((win) => {
|
||||
win.mockServer.simulate("error");
|
||||
});
|
||||
return cy.wait(500);
|
||||
});
|
||||
10
frontend/cypress/tsconfig.json
Normal file
10
frontend/cypress/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"include": ["**/*.ts"],
|
||||
"compilerOptions": {
|
||||
"types": ["cypress"],
|
||||
"lib": ["es2015", "dom"],
|
||||
"allowJs": true,
|
||||
"noEmit": true,
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
// @ts-check
|
||||
// Protractor configuration file, see link for more information
|
||||
// https://github.com/angular/protractor/blob/master/lib/config.ts
|
||||
|
||||
const { SpecReporter } = require('jasmine-spec-reporter');
|
||||
|
||||
/**
|
||||
* @type { import("protractor").Config }
|
||||
*/
|
||||
exports.config = {
|
||||
allScriptsTimeout: 11000,
|
||||
specs: [
|
||||
'./src/**/*.e2e-spec.ts'
|
||||
],
|
||||
capabilities: {
|
||||
'browserName': 'chrome'
|
||||
},
|
||||
directConnect: true,
|
||||
baseUrl: 'http://localhost:4200/',
|
||||
framework: 'jasmine',
|
||||
jasmineNodeOpts: {
|
||||
showColors: true,
|
||||
defaultTimeoutInterval: 30000,
|
||||
print: function() {}
|
||||
},
|
||||
onPrepare() {
|
||||
require('ts-node').register({
|
||||
project: require('path').join(__dirname, './tsconfig.json')
|
||||
});
|
||||
jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
|
||||
}
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
import { AppPage } from './app.po';
|
||||
import { browser, logging } from 'protractor';
|
||||
|
||||
describe('workspace-project App', () => {
|
||||
let page: AppPage;
|
||||
|
||||
beforeEach(() => {
|
||||
page = new AppPage();
|
||||
});
|
||||
|
||||
it('should display welcome message', () => {
|
||||
page.navigateTo();
|
||||
expect(page.getTitleText()).toEqual('Welcome to mempool!');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Assert that there are no errors emitted from the browser
|
||||
const logs = await browser.manage().logs().get(logging.Type.BROWSER);
|
||||
expect(logs).not.toContain(jasmine.objectContaining({
|
||||
level: logging.Level.SEVERE,
|
||||
} as logging.Entry));
|
||||
});
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
import { browser, by, element } from 'protractor';
|
||||
|
||||
export class AppPage {
|
||||
navigateTo() {
|
||||
return browser.get(browser.baseUrl) as Promise<any>;
|
||||
}
|
||||
|
||||
getTitleText() {
|
||||
return element(by.css('app-root h1')).getText() as Promise<string>;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../out-tsc/e2e",
|
||||
"module": "commonjs",
|
||||
"target": "es2018",
|
||||
"types": [
|
||||
"jasmine",
|
||||
"jasminewd2",
|
||||
"node"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,44 @@
|
||||
var fs = require('fs');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const CONFIG_FILE_NAME = 'mempool-frontend-config.json';
|
||||
const GENERATED_CONFIG_FILE_NAME = 'generated-config.js';
|
||||
|
||||
let settings = [];
|
||||
let configContent = {};
|
||||
let gitCommitHash = '';
|
||||
let packetJsonVersion = '';
|
||||
|
||||
try {
|
||||
const rawConfig = fs.readFileSync(CONFIG_FILE_NAME);
|
||||
configContent = JSON.parse(rawConfig);
|
||||
console.log(`${CONFIG_FILE_NAME} file found, using provided config`);
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') {
|
||||
throw new Error(e);
|
||||
} else {
|
||||
console.log(`${CONFIG_FILE_NAME} file not found, using default config`);
|
||||
}
|
||||
}
|
||||
|
||||
const indexFilePath = configContent.BASE_MODULE ? 'src/index.' + configContent.BASE_MODULE + '.html' : 'src/index.mempool.html';
|
||||
|
||||
try {
|
||||
fs.copyFileSync(indexFilePath, 'src/index.html');
|
||||
console.log('Copied ' + indexFilePath + ' to src/index.html');
|
||||
} catch (e) {
|
||||
console.log('Error copying the index file');
|
||||
throw new Error(e);
|
||||
}
|
||||
|
||||
try {
|
||||
const packageJson = fs.readFileSync('package.json');
|
||||
packetJsonVersion = JSON.parse(packageJson).version;
|
||||
console.log(`mempool version ${packetJsonVersion}`);
|
||||
} catch (e) {
|
||||
throw new Error(e);
|
||||
}
|
||||
|
||||
for (setting in configContent) {
|
||||
settings.push({
|
||||
key: setting,
|
||||
@@ -22,15 +46,64 @@ for (setting in configContent) {
|
||||
});
|
||||
}
|
||||
|
||||
const code = `(function (window) {
|
||||
window.__env = window.__env || {};${settings.reduce((str, obj) => `${str}
|
||||
window.__env.${obj.key} = ${ typeof obj.value === 'string' ? `'${obj.value}'` : obj.value };`, '')}
|
||||
}(global || this));`;
|
||||
|
||||
try {
|
||||
fs.writeFileSync(GENERATED_CONFIG_FILE_NAME, code, 'utf8');
|
||||
} catch (e) {
|
||||
throw new Error(e);
|
||||
if (process.env.DOCKER_COMMIT_HASH) {
|
||||
gitCommitHash = process.env.DOCKER_COMMIT_HASH;
|
||||
} else {
|
||||
try {
|
||||
const gitRevParse = spawnSync('git', ['rev-parse', '--short', 'HEAD']);
|
||||
|
||||
if (!gitRevParse.error) {
|
||||
gitCommitHash = gitRevParse.stdout.toString('utf-8').replace(/[\n\r\s]+$/, '');
|
||||
console.log(`mempool revision ${gitCommitHash}`);
|
||||
} else if (gitRevParse.error.code === 'ENOENT') {
|
||||
console.log('git not found, cannot parse git hash');
|
||||
gitCommitHash = '?';
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Could not load git commit info: ' + e.message);
|
||||
gitCommitHash = '?';
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Config file generated');
|
||||
const newConfig = `(function (window) {
|
||||
window.__env = window.__env || {};${settings.reduce((str, obj) => `${str}
|
||||
window.__env.${obj.key} = ${ typeof obj.value === 'string' ? `'${obj.value}'` : obj.value };`, '')}
|
||||
window.__env.GIT_COMMIT_HASH = '${gitCommitHash}';
|
||||
window.__env.PACKAGE_JSON_VERSION = '${packetJsonVersion}';
|
||||
}(global || this));`;
|
||||
|
||||
function readConfig(path) {
|
||||
try {
|
||||
const currentConfig = fs.readFileSync(path).toString().trim();
|
||||
return currentConfig;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeConfig(path, config) {
|
||||
try {
|
||||
fs.writeFileSync(path, config, 'utf8');
|
||||
} catch (e) {
|
||||
throw new Error(e);
|
||||
}
|
||||
}
|
||||
|
||||
const currentConfig = readConfig(GENERATED_CONFIG_FILE_NAME);
|
||||
|
||||
if (currentConfig && currentConfig === newConfig) {
|
||||
console.log(`No configuration updates, skipping ${GENERATED_CONFIG_FILE_NAME} file update`);
|
||||
return;
|
||||
} else if (!currentConfig) {
|
||||
console.log(`${GENERATED_CONFIG_FILE_NAME} file not found, creating new config file`);
|
||||
console.log('CONFIG: ', newConfig);
|
||||
writeConfig(GENERATED_CONFIG_FILE_NAME, newConfig);
|
||||
console.log(`${GENERATED_CONFIG_FILE_NAME} file saved`);
|
||||
return;
|
||||
} else {
|
||||
console.log(`Configuration changes detected, updating ${GENERATED_CONFIG_FILE_NAME} file`);
|
||||
console.log('OLD CONFIG: ', currentConfig);
|
||||
console.log('NEW CONFIG: ', newConfig);
|
||||
writeConfig(GENERATED_CONFIG_FILE_NAME, newConfig);
|
||||
console.log(`${GENERATED_CONFIG_FILE_NAME} file updated`);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
{
|
||||
"TESTNET_ENABLED": false,
|
||||
"SIGNET_ENABLED": false,
|
||||
"LIQUID_ENABLED": false,
|
||||
"LIQUID_TESTNET_ENABLED": false,
|
||||
"BISQ_ENABLED": false,
|
||||
"BISQ_SEPARATE_BACKEND": false,
|
||||
"ITEMS_PER_PAGE": 10,
|
||||
"KEEP_BLOCKS_AMOUNT": 8,
|
||||
"SPONSORS_ENABLED": false,
|
||||
"NGINX_PROTOCOL": "http",
|
||||
"NGINX_HOSTNAME": "127.0.0.1",
|
||||
"NGINX_PORT": "80"
|
||||
"NGINX_PORT": "80",
|
||||
"BLOCK_WEIGHT_UNITS": 4000000,
|
||||
"MEMPOOL_BLOCKS_AMOUNT": 8,
|
||||
"BASE_MODULE": "mempool",
|
||||
"MEMPOOL_WEBSITE_URL": "https://mempool.space",
|
||||
"LIQUID_WEBSITE_URL": "https://liquid.network",
|
||||
"BISQ_WEBSITE_URL": "https://bisq.markets"
|
||||
}
|
||||
|
||||
36019
frontend/package-lock.json
generated
36019
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user