Compare commits
1601 Commits
v1.0
...
v2.3.0-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3e47e1438 | ||
|
|
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 | ||
|
|
ab8cb033e6 | ||
|
|
beb99bcfc6 | ||
|
|
a05af48059 | ||
|
|
f462cb27c6 | ||
|
|
103740ec45 | ||
|
|
cc166cab75 | ||
|
|
29e0c337fe | ||
|
|
217666f455 | ||
|
|
579ccaf21d | ||
|
|
4f1049bace | ||
|
|
c31d4e35f6 | ||
|
|
3e2a49c08e | ||
|
|
0a0e7fad3a | ||
|
|
e76ee93bbb | ||
|
|
27d2f5bd5a | ||
|
|
20406fa522 | ||
|
|
905ddbb363 | ||
|
|
38d534caee | ||
|
|
e36646ac7c | ||
|
|
9689ccf2ac | ||
|
|
c4d1fad853 | ||
|
|
9a23d2c6b0 | ||
|
|
065c21da1f | ||
|
|
6a58717694 | ||
|
|
b4e61634bc | ||
|
|
b91516a1c1 | ||
|
|
dc63fd9428 | ||
|
|
29dd6e5d8d | ||
|
|
9e1ef1b747 | ||
|
|
632c243b34 | ||
|
|
f46728080d | ||
|
|
1a6c2e79e6 | ||
|
|
7729ad8b79 | ||
|
|
a25125091d | ||
|
|
89b4de2484 | ||
|
|
5390629e41 | ||
|
|
62c78f5b08 | ||
|
|
ae87694bc3 | ||
|
|
a3644e23a7 | ||
|
|
3c0fa71a10 | ||
|
|
bb28a56622 | ||
|
|
8d0db12abe | ||
|
|
8b1768f745 | ||
|
|
8f3db3690c | ||
|
|
87fbdc063b | ||
|
|
b261243f85 | ||
|
|
2385ec7cde | ||
|
|
6491d4576a | ||
|
|
f84b9e6582 | ||
|
|
51ed2a1f31 | ||
|
|
0d50bbdc0a | ||
|
|
df63117088 | ||
|
|
de50816990 | ||
|
|
ff0d1a7589 | ||
|
|
ecc0f316cc | ||
|
|
5dbf6789a7 | ||
|
|
5a4a976d55 | ||
|
|
1f5af33f14 | ||
|
|
8f3901b6d4 | ||
|
|
6dc10f438a | ||
|
|
006e942109 | ||
|
|
71c405d8bc | ||
|
|
73bb712311 | ||
|
|
e04c4cfa38 | ||
|
|
cd9f8cbf93 | ||
|
|
4b1ac88a5c | ||
|
|
1309bfc56e | ||
|
|
ecf49c4365 | ||
|
|
d0e0ab7c24 | ||
|
|
95c9b21149 | ||
|
|
306197c6e9 | ||
|
|
56556b6c52 | ||
|
|
d8e1023848 | ||
|
|
c9f485a775 | ||
|
|
e6be450aaa | ||
|
|
c1bcf49f37 | ||
|
|
c4734357c4 | ||
|
|
e0adc505db | ||
|
|
260735665b | ||
|
|
5b8bcd5b7d | ||
|
|
0a6f6a14b6 | ||
|
|
8e5efc47cd | ||
|
|
eb9aaac0aa | ||
|
|
2c1514b6cf | ||
|
|
06a0abdb79 | ||
|
|
29b9fec3b9 | ||
|
|
40ac83ee88 | ||
|
|
5659a8c086 | ||
|
|
f9e8dfb079 | ||
|
|
ae886e14b8 | ||
|
|
8d5ccf94f3 | ||
|
|
91cb2adb95 | ||
|
|
f70f94fede | ||
|
|
1006adabce | ||
|
|
e2d19a174e | ||
|
|
c19e21449d | ||
|
|
c819ddc64a | ||
|
|
a2740aaa02 | ||
|
|
8d565f5c62 | ||
|
|
f93e1c460a | ||
|
|
0247c7137e | ||
|
|
159d9c71a1 | ||
|
|
805636229a | ||
|
|
c5cd80ac48 | ||
|
|
94a5e8edae | ||
|
|
1af1f7211e | ||
|
|
4d54723472 | ||
|
|
1fc1b4b86e | ||
|
|
5774b395a7 | ||
|
|
f4c27e4c26 | ||
|
|
336e45a7b1 | ||
|
|
6acacc7792 | ||
|
|
10b9778b3c | ||
|
|
9ebb9a5d65 | ||
|
|
6476d1e77c | ||
|
|
67df21fe50 | ||
|
|
1e8167212f | ||
|
|
ea496915c4 | ||
|
|
035f9a4d67 | ||
|
|
232fc65af2 | ||
|
|
768fbdfbfa | ||
|
|
974d89e40b | ||
|
|
92d163f495 | ||
|
|
62363802be | ||
|
|
e49ecd9368 | ||
|
|
ea521651fd | ||
|
|
73db9c5023 | ||
|
|
cc451d29ab | ||
|
|
a831d04694 | ||
|
|
fb11d73751 | ||
|
|
9509891abc | ||
|
|
129f122993 | ||
|
|
8875579c08 | ||
|
|
83501cfdbe | ||
|
|
2b2bc2fc41 | ||
|
|
6b498b9601 | ||
|
|
e35eb5aad2 | ||
|
|
2bba51e6c1 | ||
|
|
d3cb1d607b | ||
|
|
352afbc029 | ||
|
|
2ef87603fd | ||
|
|
88b702d6d8 | ||
|
|
0d2e4000e1 | ||
|
|
9148d30fcc | ||
|
|
3f990ff706 | ||
|
|
4658b47007 | ||
|
|
f9c2bc1fb5 | ||
|
|
f151eb81c8 | ||
|
|
9b89d9977f | ||
|
|
b6150a3237 | ||
|
|
dee9fa2d9a | ||
|
|
42d1fcbdee | ||
|
|
d0da875e69 | ||
|
|
c7abb7a08a | ||
|
|
5248fadcea | ||
|
|
d613aec395 | ||
|
|
9e716aa913 | ||
|
|
2cbf1474c3 | ||
|
|
824bc21035 | ||
|
|
e51a4ce028 | ||
|
|
2054c951ae | ||
|
|
5a70234370 | ||
|
|
e5959f14bc | ||
|
|
aaf9d9be9f | ||
|
|
fdf9cf7977 | ||
|
|
bd67eec777 | ||
|
|
2fd559a7e1 | ||
|
|
9059c0c7e4 | ||
|
|
8fab153fb0 | ||
|
|
827c5d12a3 | ||
|
|
c0d2430a84 | ||
|
|
c36addd8c1 | ||
|
|
83126b83f1 | ||
|
|
75726df275 | ||
|
|
1521d47cc7 | ||
|
|
6bc6966019 | ||
|
|
01689c8433 | ||
|
|
11d67cf756 | ||
|
|
30fb0bad78 | ||
|
|
742ce7c429 | ||
|
|
9e83fdc9f2 | ||
|
|
36022680cb | ||
|
|
a19ff7d3a7 | ||
|
|
9f9c0b1114 | ||
|
|
ab6ed227e3 | ||
|
|
e029f91d4c | ||
|
|
ca540d902a | ||
|
|
76238f5943 | ||
|
|
d9803e3f3d | ||
|
|
c9e63a723a | ||
|
|
4136e8d332 | ||
|
|
ffb08e5e01 | ||
|
|
c24e44f6fa | ||
|
|
1676a78c13 | ||
|
|
379db7b211 | ||
|
|
e01718db22 | ||
|
|
df81035ebc | ||
|
|
0b35f784c4 | ||
|
|
ddea10b160 | ||
|
|
0616b3c3b0 | ||
|
|
e7ddedaeb6 | ||
|
|
12b3ecd078 | ||
|
|
f8ffd6ec6b | ||
|
|
1e39daafb3 | ||
|
|
c03f5d8393 | ||
|
|
65c29ddff2 | ||
|
|
640a77e846 | ||
|
|
5aa17e001c | ||
|
|
ff8f2fafe8 | ||
|
|
ea04dfa62f | ||
|
|
0361044352 | ||
|
|
b39d12f64e | ||
|
|
14c9d0c409 | ||
|
|
b89a549a75 | ||
|
|
203374bce2 | ||
|
|
d8c4f5a6ac | ||
|
|
1877b40413 | ||
|
|
6f6d7bc4d2 | ||
|
|
f1463b914d | ||
|
|
c03073bddd | ||
|
|
ce582eefc6 | ||
|
|
d46ff35dfb | ||
|
|
6865e00738 | ||
|
|
73acec23ae | ||
|
|
72325b683e | ||
|
|
8dd257aebd | ||
|
|
b21016efef | ||
|
|
200e68f15a | ||
|
|
411b75471c | ||
|
|
5d7a39a8f2 | ||
|
|
fcb51fef20 | ||
|
|
5feaff130f | ||
|
|
29ff029b07 | ||
|
|
6494d6daf8 | ||
|
|
396ff6a375 | ||
|
|
e61574c630 | ||
|
|
edf2d4205d | ||
|
|
645772c01a | ||
|
|
d260a1ed73 | ||
|
|
6a8deff706 | ||
|
|
018e95e648 | ||
|
|
1fc4e9530d | ||
|
|
9a94fccf40 | ||
|
|
99162f5ec9 | ||
|
|
b544af14e4 | ||
|
|
75ce300332 | ||
|
|
fb47f5606a | ||
|
|
9d7b52a104 | ||
|
|
c5c2d67fce | ||
|
|
53bc7725ab | ||
|
|
0fe32835c9 | ||
|
|
aa8d3798ea | ||
|
|
b4a17693af | ||
|
|
c6d0571be8 | ||
|
|
56b551ea8e | ||
|
|
bdeac55c97 | ||
|
|
53f7839131 | ||
|
|
32095cd6b3 | ||
|
|
d8521a9e21 | ||
|
|
962c024ecb | ||
|
|
4c82e292be | ||
|
|
eb00b92996 | ||
|
|
cbef2ae6d0 | ||
|
|
45efb604c1 | ||
|
|
0abe62128e | ||
|
|
49f70ca28a | ||
|
|
2ba7cd9ebd | ||
|
|
70da8248cc | ||
|
|
716b1235ee | ||
|
|
e732f0f1dc | ||
|
|
fff8120daa | ||
|
|
9d4659c3ba | ||
|
|
95bb0fc265 | ||
|
|
2c3f425797 | ||
|
|
fb9b3202ca | ||
|
|
7bf9810c48 | ||
|
|
2715d02cf9 | ||
|
|
0fca6f3a3b | ||
|
|
2d3ed5f8cb | ||
|
|
988ed7e8af | ||
|
|
c22d6e741a | ||
|
|
15fdb69b96 | ||
|
|
e7a7b45ad0 | ||
|
|
d8857f1073 | ||
|
|
b767a0a33e | ||
|
|
472b1d35c2 | ||
|
|
86c654f22f | ||
|
|
372c116283 | ||
|
|
94e06a3a6b | ||
|
|
3fd5277912 | ||
|
|
056fe5712d | ||
|
|
4399c5e8e9 | ||
|
|
cbcfbe5b36 | ||
|
|
b98b979dc8 | ||
|
|
3d374fd9d9 | ||
|
|
4a14085908 | ||
|
|
04ac820ed7 | ||
|
|
4dacf292c2 | ||
|
|
4c203631db | ||
|
|
ed3811bf2c | ||
|
|
55646b5732 | ||
|
|
f5e270c770 | ||
|
|
e37a9de71d | ||
|
|
7673bf13b9 | ||
|
|
88cb0d020d | ||
|
|
db16dbbc9d | ||
|
|
1d8d39db6b | ||
|
|
024b2d58f7 | ||
|
|
bb3842fc10 | ||
|
|
8cd98b42fe | ||
|
|
c8d22dc536 | ||
|
|
a8a1f4e976 | ||
|
|
ba315648be | ||
|
|
761eff62c5 | ||
|
|
0e5f2dd1a4 | ||
|
|
dfda0d1890 | ||
|
|
784f00b725 | ||
|
|
ad144a34ac | ||
|
|
9255f1c007 | ||
|
|
87dc1e5db4 | ||
|
|
1f7483687f | ||
|
|
0cbc7e2ab6 | ||
|
|
c48a151e21 | ||
|
|
5b8dbfca74 | ||
|
|
b6738dd9e8 | ||
|
|
0ee2753100 | ||
|
|
17dd03682b | ||
|
|
a07a4de255 | ||
|
|
774893f2fc | ||
|
|
98c398272c | ||
|
|
beee916658 | ||
|
|
15bb5a966b | ||
|
|
766bd0d1e0 | ||
|
|
a438ba9fcb | ||
|
|
5332e4765f | ||
|
|
c0ad643d42 | ||
|
|
140fc0c5e1 | ||
|
|
7c6c330b02 | ||
|
|
e8de73cfbc | ||
|
|
d0b3b240e6 | ||
|
|
05bea21cc8 | ||
|
|
0f72030d5e | ||
|
|
dfaa73803e | ||
|
|
64244228ea | ||
|
|
da5556e3dc | ||
|
|
b95efca29d | ||
|
|
e9d3b44e97 | ||
|
|
7848481d8f | ||
|
|
3e3dd83243 | ||
|
|
3450de774f | ||
|
|
f4a78a0e78 | ||
|
|
5536e5e77d | ||
|
|
dbc2f9e2dd | ||
|
|
677cea329c | ||
|
|
209865d23f | ||
|
|
fe2c9bf49d | ||
|
|
21ef5054bf | ||
|
|
3dedf1e3e1 | ||
|
|
43314c2283 | ||
|
|
dd31cbfd70 | ||
|
|
f9bbc425d8 | ||
|
|
d4f768e3b6 | ||
|
|
aa3559e634 | ||
|
|
8dddd6e25e | ||
|
|
3aff874479 | ||
|
|
fa75c1b08d | ||
|
|
ea0edc41e2 | ||
|
|
89533cf76f | ||
|
|
007bb30826 | ||
|
|
9e71f1a683 | ||
|
|
0464ad4bcf | ||
|
|
bcf68aa074 | ||
|
|
b2e0edb919 | ||
|
|
ffee91939e | ||
|
|
9e3fad610c | ||
|
|
b67b025dc2 | ||
|
|
2c3b02a682 | ||
|
|
6d67fbde84 | ||
|
|
347ab1e220 | ||
|
|
21e985202d | ||
|
|
6c1d28a9ac | ||
|
|
8146939f0f | ||
|
|
508b5c0f4e | ||
|
|
84f0ebaba6 | ||
|
|
04cc1338c0 | ||
|
|
630e3fa863 | ||
|
|
5d2c0d2e0a | ||
|
|
55852bcf62 | ||
|
|
91815072d5 | ||
|
|
07cfdd73aa | ||
|
|
059e4d079a | ||
|
|
14697a01cc | ||
|
|
4d4eaecb87 | ||
|
|
344413568d | ||
|
|
77cbb302ce | ||
|
|
c1c0521ab4 | ||
|
|
a11135f358 | ||
|
|
bafe2db094 | ||
|
|
98cc81c53d | ||
|
|
60b1dd15f4 | ||
|
|
88e5b03430 | ||
|
|
9234d23da2 | ||
|
|
04351e843d | ||
|
|
0041500e08 | ||
|
|
4e8908925c | ||
|
|
80fc3df76d | ||
|
|
147de195a9 | ||
|
|
5300fb1265 | ||
|
|
71c44d725a | ||
|
|
097e2ba0ea | ||
|
|
b1c8166936 | ||
|
|
2d02ec7092 | ||
|
|
dd0b67716f | ||
|
|
95bab64424 | ||
|
|
b00a9ee938 | ||
|
|
d2a14e9cb7 | ||
|
|
09b1a0d430 | ||
|
|
1b9900ccf8 | ||
|
|
cb51b71128 | ||
|
|
5411feee36 | ||
|
|
912a5dab27 | ||
|
|
63fb733dc2 | ||
|
|
648be481d7 | ||
|
|
fea79f2ff4 | ||
|
|
01b3407a9c | ||
|
|
58ec9444a5 | ||
|
|
bf1101ff66 | ||
|
|
e6fa274aca | ||
|
|
fa2a995de6 | ||
|
|
7443522ed8 | ||
|
|
dbe5f3bf06 | ||
|
|
1f22819e0c | ||
|
|
58a6bbd88b | ||
|
|
bb6272469d | ||
|
|
c1caaa37aa | ||
|
|
d1c786e2f6 | ||
|
|
67dbea3faf | ||
|
|
bc92fb669b | ||
|
|
51bed8e852 | ||
|
|
81d7072a95 | ||
|
|
36d952b503 | ||
|
|
266c347292 | ||
|
|
9e0097e7b6 | ||
|
|
599159ecf0 | ||
|
|
4c5ff7714e | ||
|
|
010aef1e90 | ||
|
|
d7a5ecc4aa | ||
|
|
2b4b0d22ab | ||
|
|
24e40b25fd | ||
|
|
0ded140c72 | ||
|
|
7f7c6ef6f8 | ||
|
|
81d05e23b8 | ||
|
|
d2673281f9 | ||
|
|
807b335534 | ||
|
|
6ded0f54c4 | ||
|
|
067540980e | ||
|
|
fa88bd7057 | ||
|
|
adcec33fb9 | ||
|
|
6a4184a413 | ||
|
|
7f71781916 | ||
|
|
24182a6fb3 | ||
|
|
959e2b55cb | ||
|
|
0f11642418 | ||
|
|
c7a09ffbfc | ||
|
|
9980414969 | ||
|
|
9d8c1184ab | ||
|
|
490223b40a | ||
|
|
73d0900343 | ||
|
|
23f5e229c9 | ||
|
|
b6b9001406 | ||
|
|
86c0a5ec7b | ||
|
|
251b8cdd8d | ||
|
|
0f611ecf8a | ||
|
|
6b3e84255a | ||
|
|
a4c49d42af | ||
|
|
942e1a7d68 | ||
|
|
a1b931851a | ||
|
|
62d2fc8113 | ||
|
|
85d0deb239 | ||
|
|
e5c4642af4 | ||
|
|
6a2d6d6291 | ||
|
|
362a3554f9 | ||
|
|
7bbfbdb188 | ||
|
|
9fa95ccbe8 | ||
|
|
5e16c592cb | ||
|
|
eac605f181 | ||
|
|
caf5ae944a | ||
|
|
b34648389c | ||
|
|
3b380fa4cc | ||
|
|
8695bb9bb1 | ||
|
|
222b7b9dd6 | ||
|
|
621ff8fed5 | ||
|
|
83d821d80d | ||
|
|
ee6108ccec | ||
|
|
cca69556d0 | ||
|
|
c893608a41 | ||
|
|
fbb8185c82 | ||
|
|
cfb60c0bad | ||
|
|
4e6ba18f19 | ||
|
|
5198c3a5af | ||
|
|
66c565a3d7 | ||
|
|
3df36cd3b8 | ||
|
|
425f4152b6 | ||
|
|
d22e4a03e6 | ||
|
|
3ff1957f0c | ||
|
|
74e9eca0d9 | ||
|
|
ad3c295fd6 | ||
|
|
d3d3fd0db1 | ||
|
|
c5759be3ee | ||
|
|
87e56e2975 | ||
|
|
40afa7a420 | ||
|
|
40f7eaf7ba | ||
|
|
e4a65bd19a | ||
|
|
3333b76c98 | ||
|
|
3008f99668 | ||
|
|
ca0cf23d66 | ||
|
|
b7376fbd8d | ||
|
|
87abfc38cb | ||
|
|
432fb9cd66 | ||
|
|
db2e293ce5 | ||
|
|
60e1b9a41e | ||
|
|
8c23eae5fe | ||
|
|
4ff9663812 | ||
|
|
c9c892b4fe | ||
|
|
4aca806b70 | ||
|
|
50ccdc424d | ||
|
|
926842d949 | ||
|
|
ae4eb22db9 | ||
|
|
c21dad88bf | ||
|
|
2ebdb27dcb | ||
|
|
f5260b72e5 | ||
|
|
2aca447f9e | ||
|
|
f1a1039dea | ||
|
|
953eea7321 | ||
|
|
c4d4d931f1 | ||
|
|
c20ba429b1 | ||
|
|
0a264a7078 | ||
|
|
7e9ba6b983 | ||
|
|
c9cc660e54 | ||
|
|
0609ce91d0 | ||
|
|
57029405a7 | ||
|
|
ae9f57322b | ||
|
|
22813a09e8 | ||
|
|
279b79b9b1 | ||
|
|
b92eef8198 | ||
|
|
6688421e39 | ||
|
|
21e2ce6a85 | ||
|
|
91c40da8c9 | ||
|
|
d513bd7ea8 | ||
|
|
cc48e0be2c | ||
|
|
6eca311147 | ||
|
|
56c6612e2e | ||
|
|
07b543d6bf | ||
|
|
43dac03ec0 | ||
|
|
36e46249b5 | ||
|
|
ea708de9fb | ||
|
|
0177224eba | ||
|
|
912d45165b | ||
|
|
8b6a681614 | ||
|
|
f0b0fc3f4b | ||
|
|
9bf38da470 | ||
|
|
cec6bf59f2 | ||
|
|
7ade4c114b | ||
|
|
4bef9f9b79 | ||
|
|
6662bc4646 | ||
|
|
e917d40909 | ||
|
|
3cf839e1da | ||
|
|
55835b3741 | ||
|
|
da4ce0855c | ||
|
|
5e5f048071 | ||
|
|
969367c8bb | ||
|
|
235c9b0bdd | ||
|
|
2c24c2de83 | ||
|
|
9ef5c420d9 | ||
|
|
3deedada07 | ||
|
|
84e15b133a | ||
|
|
16603e4fc1 | ||
|
|
3b546b234f | ||
|
|
559a6dd19a | ||
|
|
8167debbe9 | ||
|
|
4d4b6f4831 | ||
|
|
ca9ee0e6ae | ||
|
|
b9d9875e98 | ||
|
|
9af0015c22 | ||
|
|
c732f2d423 | ||
|
|
68664968da | ||
|
|
5b80b43e2a | ||
|
|
4ece586709 | ||
|
|
01ac06b096 | ||
|
|
73fb18b929 | ||
|
|
5cbbf5a186 | ||
|
|
0b74e6cba8 | ||
|
|
ecbc3e7580 | ||
|
|
003361befb | ||
|
|
2c345426cb | ||
|
|
ba007a4b17 | ||
|
|
4d2fcf96c6 | ||
|
|
f4117df63d | ||
|
|
b32afac98c | ||
|
|
5e3c71e11c | ||
|
|
565aa499e5 | ||
|
|
58942ec88a | ||
|
|
7e07e2cd8a | ||
|
|
30d2c5de27 | ||
|
|
afb4f6e70b | ||
|
|
910696e41c | ||
|
|
422055046e | ||
|
|
ba3aba918a | ||
|
|
4d53e897b4 | ||
|
|
50f98807a3 | ||
|
|
db64abec4e | ||
|
|
91a45aae30 | ||
|
|
9b4e5194c1 | ||
|
|
756caf2c53 | ||
|
|
e4d54ebb14 | ||
|
|
d92492eba6 | ||
|
|
1d542c15e4 | ||
|
|
c08a4c8424 | ||
|
|
0753b11840 | ||
|
|
1feb985bec | ||
|
|
20c7ee98e7 | ||
|
|
d662292afb | ||
|
|
0d435c9e1a | ||
|
|
6a51162f99 | ||
|
|
44d19550e8 | ||
|
|
ac9e718ef1 | ||
|
|
11b1d9bbd3 | ||
|
|
b2d2fd225c | ||
|
|
7e7b536acb | ||
|
|
9025d4dd54 | ||
|
|
e02924e8dd | ||
|
|
590f1d2b04 | ||
|
|
e2671df4be | ||
|
|
c5796a8062 | ||
|
|
52e2d364dd | ||
|
|
7c58195d10 | ||
|
|
fc7a5c2e28 | ||
|
|
38d424f793 | ||
|
|
53999dbea5 | ||
|
|
498df53f25 | ||
|
|
eaaf7bedc2 | ||
|
|
01c1b22419 | ||
|
|
d7a7095b8d | ||
|
|
b082763438 | ||
|
|
c6df78815b | ||
|
|
03316f99d1 | ||
|
|
0b23e5bcd3 | ||
|
|
8d903c42b9 | ||
|
|
0bcfdc8028 | ||
|
|
9814927849 | ||
|
|
24c23f1ee8 | ||
|
|
2f83da7ce9 | ||
|
|
3f35671fb5 | ||
|
|
18989cf1e4 | ||
|
|
630967680f | ||
|
|
f7381a88e5 | ||
|
|
f21585782f | ||
|
|
fccfa202e1 | ||
|
|
f78c35bdcf | ||
|
|
1823212899 | ||
|
|
a0d6365c61 | ||
|
|
a266ed30b2 | ||
|
|
028bcbf53e | ||
|
|
1d0dc5c418 | ||
|
|
e3bf8d0039 | ||
|
|
cd0573e0d1 | ||
|
|
294e6d1667 | ||
|
|
af86956836 | ||
|
|
550a096749 | ||
|
|
12546e1096 | ||
|
|
6b96dce478 | ||
|
|
9a8740cbe0 | ||
|
|
05d4eb7696 | ||
|
|
628df1a972 | ||
|
|
fe216b12d9 | ||
|
|
9c532499e8 | ||
|
|
ffa97fedf3 | ||
|
|
a244458928 | ||
|
|
2628dc1271 | ||
|
|
93c5f0bd84 | ||
|
|
245af5fa8f | ||
|
|
bd641271a9 | ||
|
|
78e41fc3d3 | ||
|
|
69827843c9 | ||
|
|
4ae5576452 | ||
|
|
e84ec7dd86 | ||
|
|
72658c19f6 | ||
|
|
3e6f382c4d | ||
|
|
8a5887e890 | ||
|
|
abf74c1aaf | ||
|
|
dbf8d025e9 | ||
|
|
9b33baa4c1 | ||
|
|
7c2849b331 | ||
|
|
5b41ecf6e5 | ||
|
|
79f85ebf47 | ||
|
|
fd4d0123d1 | ||
|
|
70445ddfa2 | ||
|
|
95d15bde2f | ||
|
|
e8ba7b7f0c | ||
|
|
6c05a25303 | ||
|
|
e351bcea73 | ||
|
|
6af45c86f8 | ||
|
|
c8da732771 | ||
|
|
0bf337b7a6 | ||
|
|
2f94200a45 | ||
|
|
82ac10af93 | ||
|
|
f94007979b | ||
|
|
4d5580eabb | ||
|
|
c0aa9ff925 | ||
|
|
3c38aaaf33 | ||
|
|
0f41b0d933 | ||
|
|
71cd4e1279 | ||
|
|
4096d44688 | ||
|
|
7e1b99f726 | ||
|
|
701b8610e5 | ||
|
|
1c883cf150 | ||
|
|
33ffabcd28 | ||
|
|
7aeb4ef692 | ||
|
|
2296ad69b9 | ||
|
|
994c42440d | ||
|
|
677f99d03b | ||
|
|
4574d18ce3 | ||
|
|
a6809e99f1 | ||
|
|
5d4ce44627 | ||
|
|
b60d9cdfbc | ||
|
|
9851ae7169 | ||
|
|
7186426441 | ||
|
|
73e24195da | ||
|
|
f71ac67d24 | ||
|
|
c1a8863861 | ||
|
|
2fe7af0c22 | ||
|
|
2b9be35afa | ||
|
|
4ae6b982e0 | ||
|
|
669a5e429d | ||
|
|
d28730787a | ||
|
|
02373f366c | ||
|
|
2382aa44e1 | ||
|
|
4d2ebcede9 | ||
|
|
15e25b447f | ||
|
|
dc775b7ee5 | ||
|
|
90c05ccb51 | ||
|
|
c9b161423d | ||
|
|
943d96ee8c | ||
|
|
39394e1178 | ||
|
|
50b4e1523e | ||
|
|
ef862e2442 | ||
|
|
3d3cec2582 | ||
|
|
4879036216 | ||
|
|
23a61a37fd | ||
|
|
37166e230d | ||
|
|
f9c03f2a98 | ||
|
|
7fbc6f1461 | ||
|
|
ff92ae43a5 | ||
|
|
a122432c24 | ||
|
|
3709b652ae | ||
|
|
aa58e62440 | ||
|
|
8e52a2ba06 | ||
|
|
f744d83b65 | ||
|
|
c8666d4079 | ||
|
|
057bff69fc | ||
|
|
c5c068a8d4 | ||
|
|
32fdb32792 | ||
|
|
d690511a08 | ||
|
|
c8c1be594b | ||
|
|
3453e84889 | ||
|
|
5186f81d56 | ||
|
|
f3cfa038d3 | ||
|
|
34645908e9 | ||
|
|
acd658a0e7 | ||
|
|
ac95c09ea6 | ||
|
|
ca40fc7045 |
3
.gitattributes
vendored
Normal file
3
.gitattributes
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# ignore all differences in line endings
|
||||||
|
package.json eol=crlf -crlf
|
||||||
|
*/package.json eol=crlf -crlf
|
||||||
12
.github/FUNDING.yml
vendored
Normal file
12
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: ['mempool'] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||||
|
patreon: # Replace with a single Patreon username
|
||||||
|
open_collective: # Replace with a single Open Collective username
|
||||||
|
ko_fi: # Replace with a single Ko-fi username
|
||||||
|
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||||
|
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||||
|
liberapay: # Replace with a single Liberapay username
|
||||||
|
issuehunt: # Replace with a single IssueHunt username
|
||||||
|
otechie: # Replace with a single Otechie username
|
||||||
|
custom: ['https://mempool.space/sponsor'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||||
37
.github/ISSUE_TEMPLATE.md
vendored
Normal file
37
.github/ISSUE_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<!--
|
||||||
|
SUPPORT REQUESTS: This is for reporting bugs in Mempool.
|
||||||
|
If you have a support request, please join our Keybase group:
|
||||||
|
https://keybase.io/team/mempool
|
||||||
|
-->
|
||||||
|
|
||||||
|
### Description
|
||||||
|
|
||||||
|
<!-- brief description of the bug -->
|
||||||
|
|
||||||
|
#### Version
|
||||||
|
|
||||||
|
<!-- commit id or version number -->
|
||||||
|
|
||||||
|
### Steps to reproduce
|
||||||
|
|
||||||
|
<!--if you can reliably reproduce the bug, list the steps here -->
|
||||||
|
|
||||||
|
### Expected behaviour
|
||||||
|
|
||||||
|
<!--description of the expected behavior -->
|
||||||
|
|
||||||
|
### Actual behaviour
|
||||||
|
|
||||||
|
<!-- explain what happened instead of the expected behaviour -->
|
||||||
|
|
||||||
|
### Screenshots
|
||||||
|
|
||||||
|
<!--Screenshots if gui related, drag and drop to add to the issue -->
|
||||||
|
|
||||||
|
#### Device or machine
|
||||||
|
|
||||||
|
<!-- device/machine used, operating system -->
|
||||||
|
|
||||||
|
#### Additional info
|
||||||
|
|
||||||
|
<!-- Additional information useful for debugging (e.g. logs) -->
|
||||||
77
.github/workflows/cypress.yml
vendored
Normal file
77
.github/workflows/cypress.yml
vendored
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
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: ${{ 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
|
||||||
|
env: BASE_MODULE=mempool
|
||||||
|
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
|
||||||
|
env: BASE_MODULE=liquid
|
||||||
|
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
|
||||||
|
env: BASE_MODULE=bisq
|
||||||
|
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
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +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"
|
||||||
|
}
|
||||||
57
Dockerfile
57
Dockerfile
@@ -1,57 +0,0 @@
|
|||||||
FROM alpine:latest
|
|
||||||
|
|
||||||
RUN mkdir /mempool.space/
|
|
||||||
COPY ./backend /mempool.space/backend/
|
|
||||||
COPY ./frontend /mempool.space/frontend/
|
|
||||||
COPY ./mariadb-structure.sql /mempool.space/mariadb-structure.sql
|
|
||||||
|
|
||||||
RUN apk add mariadb mariadb-client git nginx npm rsync bash
|
|
||||||
|
|
||||||
RUN mysql_install_db --user=mysql --datadir=/var/lib/mysql/
|
|
||||||
RUN /usr/bin/mysqld_safe --datadir='/var/lib/mysql/'& \
|
|
||||||
sleep 60 && \
|
|
||||||
mysql -e "create database mempool" && \
|
|
||||||
mysql -e "grant all privileges on mempool.* to 'mempool'@'localhost' identified by 'mempool'" && \
|
|
||||||
mysql mempool < /mempool.space/mariadb-structure.sql
|
|
||||||
RUN sed -i "/^skip-networking/ c#skip-networking" /etc/my.cnf.d/mariadb-server.cnf
|
|
||||||
|
|
||||||
RUN export NG_CLI_ANALYTICS=ci && \
|
|
||||||
npm install -g typescript && \
|
|
||||||
cd /mempool.space/frontend && \
|
|
||||||
npm install && \
|
|
||||||
cd /mempool.space/backend && \
|
|
||||||
npm install && \
|
|
||||||
tsc
|
|
||||||
|
|
||||||
COPY ./nginx-nossl-docker.conf /etc/nginx/nginx.conf
|
|
||||||
|
|
||||||
ENV ENV dev
|
|
||||||
ENV DB_HOST localhost
|
|
||||||
ENV DB_PORT 3306
|
|
||||||
ENV DB_USER mempool
|
|
||||||
ENV DB_PASSWORD mempool
|
|
||||||
ENV DB_DATABASE mempool
|
|
||||||
ENV API_ENDPOINT /api/v1/
|
|
||||||
ENV CHAT_SSL_ENABLED false
|
|
||||||
ENV MEMPOOL_REFRESH_RATE_MS 500
|
|
||||||
ENV INITIAL_BLOCK_AMOUNT 8
|
|
||||||
ENV DEFAULT_PROJECTED_BLOCKS_AMOUNT 3
|
|
||||||
ENV KEEP_BLOCK_AMOUNT 24
|
|
||||||
ENV BITCOIN_NODE_HOST bitcoinhost
|
|
||||||
ENV BITCOIN_NODE_PORT 8332
|
|
||||||
ENV BITCOIN_NODE_USER bitcoinuser
|
|
||||||
ENV BITCOIN_NODE_PASS bitcoinpass
|
|
||||||
ENV TX_PER_SECOND_SPAN_SECONDS 150
|
|
||||||
ENV BACKEND_API bitcoind
|
|
||||||
ENV ELECTRS_API_URL https://www.blockstream.info/api
|
|
||||||
|
|
||||||
RUN cd /mempool.space/frontend/ && \
|
|
||||||
npm run build && \
|
|
||||||
rsync -av --delete dist/mempool/ /var/www/html/
|
|
||||||
|
|
||||||
EXPOSE 80
|
|
||||||
|
|
||||||
COPY ./entrypoint.sh /mempool.space/entrypoint.sh
|
|
||||||
RUN chmod +x /mempool.space/entrypoint.sh
|
|
||||||
WORKDIR /mempool.space
|
|
||||||
CMD ["/mempool.space/entrypoint.sh"]
|
|
||||||
48
GNUmakefile
Executable file
48
GNUmakefile
Executable file
@@ -0,0 +1,48 @@
|
|||||||
|
# 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/db-scripts $(DATA)/mysql/data
|
||||||
|
install -v mariadb-structure.sql $(DATA)/mysql/db-scripts
|
||||||
|
#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
|
||||||
42
LICENSE
42
LICENSE
@@ -1,21 +1,29 @@
|
|||||||
MIT License
|
The Mempool Open Source Project
|
||||||
|
Copyright (c) 2019-2021 The Mempool Open Source Project Developers
|
||||||
|
|
||||||
Copyright (c) 2019 Simon Lindh
|
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
|
1) the GNU Affero General Public License as published by the Free Software
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
Foundation, either version 3 of the License or any later version approved by a
|
||||||
in the Software without restriction, including without limitation the rights
|
proxy statement published on <https://mempool.space/about>; or
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
2) the GNU General Public License as published by the Free Software
|
||||||
copies or substantial portions of the 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
|
However, this copyright license does not include an implied right or license to
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
use our trademarks: The Mempool Open Source Project™, mempool.space™, the
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
mempool Logo™, the mempool.space Vertical Logo™, the mempool.space Horizontal
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
Logo™, the mempool Square Logo™, and the mempool Blocks logo™ are registered
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
trademarks or trademarks of Mempool Space K.K in Japan, the United States,
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
and/or other countries. See our full Trademark Policy and Guidelines for more
|
||||||
SOFTWARE.
|
details, published on <https://mempool.space/trademark-policy>.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. See the full license terms for more details.
|
||||||
|
|
||||||
|
You should have received a copy of both the GNU Affero General Public License
|
||||||
|
and the GNU General Public License along with this program. If not, see
|
||||||
|
<http://www.gnu.org/licenses/>.
|
||||||
|
|||||||
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>.
|
||||||
210
README.md
210
README.md
@@ -1,68 +1,53 @@
|
|||||||
# mempool.space
|
# The Mempool Open Source Project
|
||||||
🚨This is beta software, and may have issues!🚨
|
|
||||||
Please help us test and report bugs to our GitHub issue tracker.
|
|
||||||
|
|
||||||
Mempool visualizer for the Bitcoin blockchain. Live demo: https://mempool.space/
|
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 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)
|
||||||
|
|
||||||
|
# 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
|
## Dependencies
|
||||||
|
|
||||||
* Bitcoin (full node required, no pruning, txindex=1)
|
* Bitcoin Core (no pruning, txindex=1)
|
||||||
|
* Electrum Server (romanz/electrs)
|
||||||
* NodeJS (official stable LTS)
|
* NodeJS (official stable LTS)
|
||||||
* MySQL or MariaDB (default config)
|
* MariaDB (default config)
|
||||||
* Nginx (use supplied nginx.conf)
|
* Nginx (use supplied nginx.conf and nginx-mempool.conf)
|
||||||
|
|
||||||
|
## Mempool
|
||||||
|
|
||||||
|
Clone the mempool repo, and checkout the latest release tag:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/mempool/mempool
|
||||||
|
cd mempool
|
||||||
|
latestrelease=$(curl -s https://api.github.com/repos/mempool/mempool/releases/latest|grep tag_name|head -1|cut -d '"' -f4)
|
||||||
|
git checkout $latestrelease
|
||||||
|
```
|
||||||
|
|
||||||
## Bitcoin Core (bitcoind)
|
## Bitcoin Core (bitcoind)
|
||||||
|
|
||||||
Enable RPC and txindex in bitcoin.conf
|
Enable RPC and txindex in `bitcoin.conf`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rpcuser=mempool
|
rpcuser=mempool
|
||||||
rpcpassword=71b61986da5b03a5694d7c7d5165ece5
|
rpcpassword=71b61986da5b03a5694d7c7d5165ece5
|
||||||
txindex=1
|
txindex=1
|
||||||
```
|
```
|
||||||
|
|
||||||
## NodeJS
|
|
||||||
|
|
||||||
Install dependencies and build code:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd mempool.space
|
|
||||||
|
|
||||||
# Install TypeScript Globally
|
|
||||||
npm install -g typescript
|
|
||||||
|
|
||||||
# Frontend
|
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Backend
|
|
||||||
cd ../backend/
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Mempool Configuration
|
|
||||||
In the `backend` folder, make a copy of the sample config and modify it to fit your settings.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp mempool-config.sample.json mempool-config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Edit `mempool-config.json` to add your Bitcoin Core node RPC credentials:
|
|
||||||
```bash
|
|
||||||
"BITCOIN_NODE_HOST": "192.168.1.5",
|
|
||||||
"BITCOIN_NODE_PORT": 8332,
|
|
||||||
"BITCOIN_NODE_USER": "mempool",
|
|
||||||
"BITCOIN_NODE_PASS": "71b61986da5b03a5694d7c7d5165ece5",
|
|
||||||
```
|
|
||||||
|
|
||||||
## MySQL
|
## MySQL
|
||||||
|
|
||||||
Install MariaDB:
|
Install MariaDB from OS package manager:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Linux
|
# Linux
|
||||||
apt-get install mariadb-server mariadb-client
|
apt-get install mariadb-server mariadb-client
|
||||||
@@ -80,51 +65,72 @@ Create database and grant privileges:
|
|||||||
MariaDB [(none)]> create database mempool;
|
MariaDB [(none)]> create database mempool;
|
||||||
Query OK, 1 row affected (0.00 sec)
|
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)
|
Query OK, 0 rows affected (0.00 sec)
|
||||||
```
|
```
|
||||||
|
|
||||||
From the root folder, initialize database structure:
|
From the mempool repo's top-level folder, import the database structure:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mysql -u mempool -p mempool < mariadb-structure.sql
|
mysql -u mempool -p mempool < mariadb-structure.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running (Backend)
|
## Mempool Backend
|
||||||
|
Install mempool dependencies from npm and build the backend:
|
||||||
Create an initial empty cache and start the app:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
touch cache.json
|
# backend
|
||||||
npm run start # node dist/index.js
|
cd backend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
After starting you should see:
|
In the `backend` folder, make a copy of the sample config and modify it to fit your settings.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
Server started on port 8999 :)
|
cp mempool-config.sample.json mempool-config.json
|
||||||
New block found (#586498)! 0 of 1986 found in mempool. 1985 not found.
|
|
||||||
New block found (#586499)! 0 of 1094 found in mempool. 1093 not found.
|
|
||||||
New block found (#586500)! 0 of 2735 found in mempool. 2734 not found.
|
|
||||||
New block found (#586501)! 0 of 2675 found in mempool. 2674 not found.
|
|
||||||
New block found (#586502)! 0 of 975 found in mempool. 974 not found.
|
|
||||||
New block found (#586503)! 0 of 2130 found in mempool. 2129 not found.
|
|
||||||
New block found (#586504)! 0 of 2770 found in mempool. 2769 not found.
|
|
||||||
New block found (#586505)! 0 of 2759 found in mempool. 2758 not found.
|
|
||||||
Updating mempool
|
|
||||||
Calculated fee for transaction 1 / 3257
|
|
||||||
Calculated fee for transaction 2 / 3257
|
|
||||||
Calculated fee for transaction 3 / 3257
|
|
||||||
Calculated fee for transaction 4 / 3257
|
|
||||||
Calculated fee for transaction 5 / 3257
|
|
||||||
Calculated fee for transaction 6 / 3257
|
|
||||||
Calculated fee for transaction 7 / 3257
|
|
||||||
Calculated fee for transaction 8 / 3257
|
|
||||||
Calculated fee for transaction 9 / 3257
|
|
||||||
```
|
```
|
||||||
You need to wait for at least *8 blocks to be mined*, so please wait ~80 minutes.
|
|
||||||
The backend also needs to index transactions, calculate fees, etc.
|
Edit `mempool-config.json` to add your Bitcoin Core node RPC credentials:
|
||||||
When it's ready you will see output like this:
|
```bash
|
||||||
|
{
|
||||||
|
"MEMPOOL": {
|
||||||
|
"NETWORK": "mainnet",
|
||||||
|
"BACKEND": "electrum",
|
||||||
|
"HTTP_PORT": 8999,
|
||||||
|
"API_URL_PREFIX": "/api/v1/",
|
||||||
|
"POLL_RATE_MS": 2000
|
||||||
|
},
|
||||||
|
"CORE_RPC": {
|
||||||
|
"USERNAME": "mempool",
|
||||||
|
"PASSWORD": "71b61986da5b03a5694d7c7d5165ece5"
|
||||||
|
},
|
||||||
|
"ELECTRUM": {
|
||||||
|
"HOST": "127.0.0.1",
|
||||||
|
"PORT": 50002,
|
||||||
|
"TLS_ENABLED": true,
|
||||||
|
},
|
||||||
|
"DATABASE": {
|
||||||
|
"ENABLED": true,
|
||||||
|
"HOST": "127.0.0.1",
|
||||||
|
"PORT": 3306,
|
||||||
|
"USERNAME": "mempool",
|
||||||
|
"PASSWORD": "mempool",
|
||||||
|
"DATABASE": "mempool"
|
||||||
|
},
|
||||||
|
"STATISTICS": {
|
||||||
|
"ENABLED": true,
|
||||||
|
"TX_PER_SECOND_SAMPLE_PERIOD": 150
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the backend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
When it's running you should see output like this:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
Mempool updated in 0.189 seconds
|
Mempool updated in 0.189 seconds
|
||||||
@@ -147,36 +153,38 @@ When it's ready you will see output like this:
|
|||||||
Updating mempool
|
Updating mempool
|
||||||
```
|
```
|
||||||
|
|
||||||
## nginx + CertBot (LetsEncrypt)
|
## Mempool Frontend
|
||||||
Setup nginx using the supplied nginx.conf
|
|
||||||
|
Install mempool dependencies from npm and build the frontend static HTML/CSS/JS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# frontend
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Install the output into nginx webroot folder:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo rsync -av --delete dist/mempool /var/www/
|
||||||
|
```
|
||||||
|
|
||||||
|
## nginx + certbot
|
||||||
|
|
||||||
|
Install the supplied nginx.conf and nginx-mempool.conf in /etc/nginx
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# install nginx and certbot
|
# install nginx and certbot
|
||||||
apt-get install -y nginx python-certbot-nginx
|
apt-get install -y nginx python-certbot-nginx
|
||||||
|
|
||||||
|
# install the mempool configuration for nginx
|
||||||
|
cp nginx.conf nginx-mempool.conf /etc/nginx/
|
||||||
|
|
||||||
# replace example.com with your domain name
|
# replace example.com with your domain name
|
||||||
certbot --nginx -d example.com
|
certbot --nginx -d example.com
|
||||||
|
|
||||||
# install the mempool configuration for nginx
|
|
||||||
cp nginx.conf /etc/nginx/nginx.conf
|
|
||||||
|
|
||||||
# edit the installed nginx.conf, and replace all
|
|
||||||
# instances of example.com with your domain name
|
|
||||||
```
|
```
|
||||||
Make sure you can access https://<your-domain-name>/ in browser before proceeding
|
|
||||||
|
|
||||||
|
|
||||||
## Running (Frontend)
|
|
||||||
|
|
||||||
Build the frontend static HTML/CSS/JS, rsync the output into nginx folder:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend/
|
|
||||||
npm run build
|
|
||||||
sudo rsync -av --delete dist/mempool/ /var/www/html/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Try It Out
|
|
||||||
|
|
||||||
If everything went okay you should see the beautiful mempool :grin:
|
If everything went okay you should see the beautiful mempool :grin:
|
||||||
|
|
||||||
|
|||||||
2
backend/.gitignore
vendored
2
backend/.gitignore
vendored
@@ -41,5 +41,3 @@ testem.log
|
|||||||
#System Files
|
#System Files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
cache.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
|
||||||
@@ -1,25 +1,61 @@
|
|||||||
{
|
{
|
||||||
"ENV": "dev",
|
"MEMPOOL": {
|
||||||
"DB_HOST": "localhost",
|
"NETWORK": "mainnet",
|
||||||
"DB_PORT": 3306,
|
"BACKEND": "electrum",
|
||||||
"DB_USER": "mempool",
|
"HTTP_PORT": 8999,
|
||||||
"DB_PASSWORD": "mempool",
|
"SPAWN_CLUSTER_PROCS": 0,
|
||||||
"DB_DATABASE": "mempool",
|
"API_URL_PREFIX": "/api/v1/",
|
||||||
"HTTP_PORT": 3000,
|
"POLL_RATE_MS": 2000,
|
||||||
"API_ENDPOINT": "/api/v1/",
|
"CACHE_DIR": "./cache",
|
||||||
"CHAT_SSL_ENABLED": false,
|
"CLEAR_PROTECTION_MINUTES": 20,
|
||||||
"CHAT_SSL_PRIVKEY": "",
|
"RECOMMENDED_FEE_PERCENTILE": 50,
|
||||||
"CHAT_SSL_CERT": "",
|
"BLOCK_WEIGHT_UNITS": 4000000,
|
||||||
"CHAT_SSL_CHAIN": "",
|
"INITIAL_BLOCKS_AMOUNT": 8,
|
||||||
"MEMPOOL_REFRESH_RATE_MS": 500,
|
"MEMPOOL_BLOCKS_AMOUNT": 8,
|
||||||
"INITIAL_BLOCK_AMOUNT": 8,
|
"PRICE_FEED_UPDATE_INTERVAL": 3600,
|
||||||
"DEFAULT_PROJECTED_BLOCKS_AMOUNT": 3,
|
"USE_SECOND_NODE_FOR_MINFEE": false
|
||||||
"KEEP_BLOCK_AMOUNT": 24,
|
},
|
||||||
"BITCOIN_NODE_HOST": "localhost",
|
"CORE_RPC": {
|
||||||
"BITCOIN_NODE_PORT": 8332,
|
"HOST": "127.0.0.1",
|
||||||
"BITCOIN_NODE_USER": "",
|
"PORT": 8332,
|
||||||
"BITCOIN_NODE_PASS": "",
|
"USERNAME": "mempool",
|
||||||
"BACKEND_API": "bitcoind",
|
"PASSWORD": "mempool"
|
||||||
"ELECTRS_API_URL": "https://www.blockstream.info/api",
|
},
|
||||||
"TX_PER_SECOND_SPAN_SECONDS": 150
|
"ELECTRUM": {
|
||||||
|
"HOST": "127.0.0.1",
|
||||||
|
"PORT": 50002,
|
||||||
|
"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",
|
||||||
|
"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": {
|
||||||
|
"ENABLED": false,
|
||||||
|
"DATA_PATH": "/bisq/statsnode-data/btc_mainnet/db"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2833
backend/package-lock.json
generated
Normal file
2833
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,31 +1,50 @@
|
|||||||
{
|
{
|
||||||
"name": "mempool-backend",
|
"name": "mempool-backend",
|
||||||
"version": "1.0.0",
|
"version": "2.3.0-dev",
|
||||||
"description": "Bitcoin Mempool Visualizer",
|
"description": "Bitcoin mempool visualizer and blockchain explorer backend",
|
||||||
|
"license": "GNU Affero General Public License v3.0",
|
||||||
|
"homepage": "https://mempool.space",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/mempool/mempool"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/mempool/mempool/issues"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"bitcoin",
|
||||||
|
"mempool",
|
||||||
|
"blockchain",
|
||||||
|
"explorer",
|
||||||
|
"liquid"
|
||||||
|
],
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"ng": "./node_modules/@angular/cli/bin/ng",
|
||||||
"start": "npm run build && node dist/index.js"
|
"tsc": "./node_modules/typescript/bin/tsc",
|
||||||
|
"build": "npm run tsc",
|
||||||
|
"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"
|
||||||
},
|
},
|
||||||
"author": {
|
|
||||||
"name": "Simon Lindh",
|
|
||||||
"url": "https://github.com/mempool-space/mempool.space"
|
|
||||||
},
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bitcoin": "^3.0.1",
|
"@mempool/bitcoin": "^3.0.3",
|
||||||
"compression": "^1.7.3",
|
"@mempool/electrum-client": "^1.1.7",
|
||||||
"express": "^4.16.3",
|
"axios": "^0.21.1",
|
||||||
"mysql2": "^1.6.1",
|
"bitcoinjs-lib": "^5.2.0",
|
||||||
"request": "^2.88.0",
|
"crypto-js": "^4.0.0",
|
||||||
"ws": "^6.0.0"
|
"express": "^4.17.1",
|
||||||
|
"locutus": "^2.0.12",
|
||||||
|
"mysql2": "2.2.5",
|
||||||
|
"node-worker-threads-pool": "^1.4.3",
|
||||||
|
"ws": "^7.4.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.16.0",
|
"@types/compression": "^1.0.1",
|
||||||
"@types/mysql2": "github:types/mysql2",
|
"@types/express": "^4.17.2",
|
||||||
"@types/request": "^2.48.2",
|
"@types/locutus": "^0.0.6",
|
||||||
"@types/ws": "^6.0.1",
|
"@types/ws": "^7.4.4",
|
||||||
"tslint": "^5.11.0",
|
"tslint": "^6.1.0",
|
||||||
"typescript": "^3.1.1"
|
"typescript": "4.4.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
47
backend/src/api/backend-info.ts
Normal file
47
backend/src/api/backend-info.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import logger from '../logger';
|
||||||
|
import { IBackendInfo } from '../mempool.interfaces';
|
||||||
|
|
||||||
|
class BackendInfo {
|
||||||
|
private gitCommitHash = '';
|
||||||
|
private hostname = '';
|
||||||
|
private version = '';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.setLatestCommitHash();
|
||||||
|
this.setVersion();
|
||||||
|
this.hostname = os.hostname();
|
||||||
|
}
|
||||||
|
|
||||||
|
public getBackendInfo(): IBackendInfo {
|
||||||
|
return {
|
||||||
|
hostname: this.hostname,
|
||||||
|
gitCommit: this.gitCommitHash,
|
||||||
|
version: this.version,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public getShortCommitHash() {
|
||||||
|
return this.gitCommitHash.slice(0, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setLatestCommitHash(): void {
|
||||||
|
try {
|
||||||
|
this.gitCommitHash = fs.readFileSync('../.git/refs/heads/master').toString().trim();
|
||||||
|
} catch (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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new BackendInfo();
|
||||||
277
backend/src/api/bisq/bisq.ts
Normal file
277
backend/src/api/bisq/bisq.ts
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
import config from '../../config';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { BisqBlocks, BisqBlock, BisqTransaction, BisqStats, BisqTrade } from './interfaces';
|
||||||
|
import { Common } from '../common';
|
||||||
|
import { BlockExtended } from '../../mempool.interfaces';
|
||||||
|
import { StaticPool } from 'node-worker-threads-pool';
|
||||||
|
import logger from '../../logger';
|
||||||
|
|
||||||
|
class Bisq {
|
||||||
|
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 } = {};
|
||||||
|
private addressIndex: { [address: string]: BisqTransaction[] } = {};
|
||||||
|
private stats: BisqStats = {
|
||||||
|
minted: 0,
|
||||||
|
burnt: 0,
|
||||||
|
addresses: 0,
|
||||||
|
unspent_txos: 0,
|
||||||
|
spent_txos: 0,
|
||||||
|
};
|
||||||
|
private price: number = 0;
|
||||||
|
private priceUpdateCallbackFunction: ((price: number) => void) | undefined;
|
||||||
|
private topDirectoryWatcher: fs.FSWatcher | undefined;
|
||||||
|
private subdirectoryWatcher: fs.FSWatcher | undefined;
|
||||||
|
private jsonParsePool = new StaticPool({
|
||||||
|
size: 4,
|
||||||
|
task: (blob: string) => JSON.parse(blob),
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
startBisqService(): void {
|
||||||
|
this.checkForBisqDataFolder();
|
||||||
|
this.loadBisqDumpFile();
|
||||||
|
setInterval(this.updatePrice.bind(this), 1000 * 60 * 60);
|
||||||
|
this.updatePrice();
|
||||||
|
this.startTopDirectoryWatcher();
|
||||||
|
this.startSubDirectoryWatcher();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleNewBitcoinBlock(block: BlockExtended): void {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getTransaction(txId: string): BisqTransaction | undefined {
|
||||||
|
return this.transactionIndex[txId];
|
||||||
|
}
|
||||||
|
|
||||||
|
getTransactions(start: number, length: number, types: string[]): [BisqTransaction[], number] {
|
||||||
|
let transactions = this.transactions;
|
||||||
|
if (types.length) {
|
||||||
|
transactions = transactions.filter((tx) => types.indexOf(tx.txType) > -1);
|
||||||
|
}
|
||||||
|
return [transactions.slice(start, length + start), transactions.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
getBlock(hash: string): BisqBlock | undefined {
|
||||||
|
return this.blockIndex[hash];
|
||||||
|
}
|
||||||
|
|
||||||
|
getAddress(hash: string): BisqTransaction[] {
|
||||||
|
return this.addressIndex[hash];
|
||||||
|
}
|
||||||
|
|
||||||
|
getBlocks(start: number, length: number): [BisqBlock[], number] {
|
||||||
|
return [this.blocks.slice(start, length + start), this.blocks.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
getStats(): BisqStats {
|
||||||
|
return this.stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPriceCallbackFunction(fn: (price: number) => void) {
|
||||||
|
this.priceUpdateCallbackFunction = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLatestBlockHeight(): number {
|
||||||
|
return this.latestBlockHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkForBisqDataFolder() {
|
||||||
|
if (!fs.existsSync(Bisq.BLOCKS_JSON_FILE_PATH)) {
|
||||||
|
logger.warn(Bisq.BLOCKS_JSON_FILE_PATH + ` doesn't exist. Make sure Bisq is running and the config is correct before starting the server.`);
|
||||||
|
return process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startTopDirectoryWatcher() {
|
||||||
|
if (this.topDirectoryWatcher) {
|
||||||
|
this.topDirectoryWatcher.close();
|
||||||
|
}
|
||||||
|
let fsWait: NodeJS.Timeout | null = null;
|
||||||
|
this.topDirectoryWatcher = fs.watch(config.BISQ.DATA_PATH + '/json', () => {
|
||||||
|
if (fsWait) {
|
||||||
|
clearTimeout(fsWait);
|
||||||
|
}
|
||||||
|
if (this.subdirectoryWatcher) {
|
||||||
|
this.subdirectoryWatcher.close();
|
||||||
|
}
|
||||||
|
fsWait = setTimeout(() => {
|
||||||
|
logger.debug(`Bisq restart detected. Resetting both watchers in 3 minutes.`);
|
||||||
|
setTimeout(() => {
|
||||||
|
this.startTopDirectoryWatcher();
|
||||||
|
this.startSubDirectoryWatcher();
|
||||||
|
this.loadBisqDumpFile();
|
||||||
|
}, 180000);
|
||||||
|
}, 15000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private startSubDirectoryWatcher() {
|
||||||
|
if (this.subdirectoryWatcher) {
|
||||||
|
this.subdirectoryWatcher.close();
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(Bisq.BLOCKS_JSON_FILE_PATH)) {
|
||||||
|
logger.warn(Bisq.BLOCKS_JSON_FILE_PATH + ` doesn't exist. Trying to restart sub directory watcher again in 3 minutes.`);
|
||||||
|
setTimeout(() => this.startSubDirectoryWatcher(), 180000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let fsWait: NodeJS.Timeout | null = null;
|
||||||
|
this.subdirectoryWatcher = fs.watch(config.BISQ.DATA_PATH + '/json/all', () => {
|
||||||
|
if (fsWait) {
|
||||||
|
clearTimeout(fsWait);
|
||||||
|
}
|
||||||
|
fsWait = setTimeout(() => {
|
||||||
|
logger.debug(`Change detected in the Bisq data folder.`);
|
||||||
|
this.loadBisqDumpFile();
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private updatePrice() {
|
||||||
|
axios.get<BisqTrade[]>('https://bisq.markets/api/trades/?market=bsq_btc', { timeout: 10000 })
|
||||||
|
.then((response) => {
|
||||||
|
const prices: number[] = [];
|
||||||
|
response.data.forEach((trade) => {
|
||||||
|
prices.push(parseFloat(trade.price) * 100000000);
|
||||||
|
});
|
||||||
|
prices.sort((a, b) => a - b);
|
||||||
|
this.price = Common.median(prices);
|
||||||
|
if (this.priceUpdateCallbackFunction) {
|
||||||
|
this.priceUpdateCallbackFunction(this.price);
|
||||||
|
}
|
||||||
|
}).catch((err) => {
|
||||||
|
logger.err('Error updating Bisq market price: ' + err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadBisqDumpFile(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const data = await this.loadData();
|
||||||
|
await this.loadBisqBlocksDump(data);
|
||||||
|
this.buildIndex();
|
||||||
|
this.calculateStats();
|
||||||
|
} catch (e) {
|
||||||
|
logger.info('loadBisqDumpFile() error.' + (e instanceof Error ? e.message : e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildIndex() {
|
||||||
|
const start = new Date().getTime();
|
||||||
|
this.transactions = [];
|
||||||
|
this.transactionIndex = {};
|
||||||
|
this.addressIndex = {};
|
||||||
|
|
||||||
|
this.allBlocks.forEach((block) => {
|
||||||
|
/* Build block index */
|
||||||
|
if (!this.blockIndex[block.hash]) {
|
||||||
|
this.blockIndex[block.hash] = block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Build transactions index */
|
||||||
|
block.txs.forEach((tx) => {
|
||||||
|
this.transactions.push(tx);
|
||||||
|
this.transactionIndex[tx.id] = tx;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Build address index */
|
||||||
|
this.transactions.forEach((tx) => {
|
||||||
|
tx.inputs.forEach((input) => {
|
||||||
|
if (!this.addressIndex[input.address]) {
|
||||||
|
this.addressIndex[input.address] = [];
|
||||||
|
}
|
||||||
|
if (this.addressIndex[input.address].indexOf(tx) === -1) {
|
||||||
|
this.addressIndex[input.address].push(tx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tx.outputs.forEach((output) => {
|
||||||
|
if (!this.addressIndex[output.address]) {
|
||||||
|
this.addressIndex[output.address] = [];
|
||||||
|
}
|
||||||
|
if (this.addressIndex[output.address].indexOf(tx) === -1) {
|
||||||
|
this.addressIndex[output.address].push(tx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const time = new Date().getTime() - start;
|
||||||
|
logger.debug('Bisq data index rebuilt in ' + time + ' ms');
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateStats() {
|
||||||
|
let minted = 0;
|
||||||
|
let burned = 0;
|
||||||
|
let unspent = 0;
|
||||||
|
let spent = 0;
|
||||||
|
|
||||||
|
this.transactions.forEach((tx) => {
|
||||||
|
tx.outputs.forEach((output) => {
|
||||||
|
if (output.opReturn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (output.txOutputType === 'GENESIS_OUTPUT' || output.txOutputType === 'ISSUANCE_CANDIDATE_OUTPUT' && output.isVerified) {
|
||||||
|
minted += output.bsqAmount;
|
||||||
|
}
|
||||||
|
if (output.isUnspent) {
|
||||||
|
unspent++;
|
||||||
|
} else {
|
||||||
|
spent++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
burned += tx['burntFee'];
|
||||||
|
});
|
||||||
|
|
||||||
|
this.stats = {
|
||||||
|
addresses: Object.keys(this.addressIndex).length,
|
||||||
|
minted: minted / 100,
|
||||||
|
burnt: burned / 100,
|
||||||
|
spent_txos: spent,
|
||||||
|
unspent_txos: unspent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadBisqBlocksDump(cacheData: string): Promise<void> {
|
||||||
|
const start = new Date().getTime();
|
||||||
|
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.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)');
|
||||||
|
} else {
|
||||||
|
throw new Error(`Bisq dump didn't contain any blocks`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadData(): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!fs.existsSync(Bisq.BLOCKS_JSON_FILE_PATH)) {
|
||||||
|
return reject(Bisq.BLOCKS_JSON_FILE_PATH + ` doesn't exist`);
|
||||||
|
}
|
||||||
|
fs.readFile(Bisq.BLOCKS_JSON_FILE_PATH, 'utf8', (err, data) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
resolve(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new Bisq();
|
||||||
258
backend/src/api/bisq/interfaces.ts
Normal file
258
backend/src/api/bisq/interfaces.ts
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
|
||||||
|
export interface BisqBlocks {
|
||||||
|
chainHeight: number;
|
||||||
|
blocks: BisqBlock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BisqBlock {
|
||||||
|
height: number;
|
||||||
|
time: number;
|
||||||
|
hash: string;
|
||||||
|
previousBlockHash: string;
|
||||||
|
txs: BisqTransaction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BisqTransaction {
|
||||||
|
txVersion: string;
|
||||||
|
id: string;
|
||||||
|
blockHeight: number;
|
||||||
|
blockHash: string;
|
||||||
|
time: number;
|
||||||
|
inputs: BisqInput[];
|
||||||
|
outputs: BisqOutput[];
|
||||||
|
txType: string;
|
||||||
|
txTypeDisplayString: string;
|
||||||
|
burntFee: number;
|
||||||
|
invalidatedBsq: number;
|
||||||
|
unlockBlockHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BisqStats {
|
||||||
|
minted: number;
|
||||||
|
burnt: number;
|
||||||
|
addresses: number;
|
||||||
|
unspent_txos: number;
|
||||||
|
spent_txos: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BisqInput {
|
||||||
|
spendingTxOutputIndex: number;
|
||||||
|
spendingTxId: string;
|
||||||
|
bsqAmount: number;
|
||||||
|
isVerified: boolean;
|
||||||
|
address: string;
|
||||||
|
time: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BisqOutput {
|
||||||
|
txVersion: string;
|
||||||
|
txId: string;
|
||||||
|
index: number;
|
||||||
|
bsqAmount: number;
|
||||||
|
btcAmount: number;
|
||||||
|
height: number;
|
||||||
|
isVerified: boolean;
|
||||||
|
burntFee: number;
|
||||||
|
invalidatedBsq: number;
|
||||||
|
address: string;
|
||||||
|
scriptPubKey: BisqScriptPubKey;
|
||||||
|
time: any;
|
||||||
|
txType: string;
|
||||||
|
txTypeDisplayString: string;
|
||||||
|
txOutputType: string;
|
||||||
|
txOutputTypeDisplayString: string;
|
||||||
|
lockTime: number;
|
||||||
|
isUnspent: boolean;
|
||||||
|
spentInfo: SpentInfo;
|
||||||
|
opReturn?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BisqScriptPubKey {
|
||||||
|
addresses: string[];
|
||||||
|
asm: string;
|
||||||
|
hex: string;
|
||||||
|
reqSigs?: number;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpentInfo {
|
||||||
|
height: number;
|
||||||
|
inputIndex: number;
|
||||||
|
txId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BisqTrade {
|
||||||
|
direction: string;
|
||||||
|
price: string;
|
||||||
|
amount: string;
|
||||||
|
volume: string;
|
||||||
|
payment_method: string;
|
||||||
|
trade_id: string;
|
||||||
|
trade_date: number;
|
||||||
|
market?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Currencies { [txid: string]: Currency; }
|
||||||
|
|
||||||
|
export interface Currency {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
precision: number;
|
||||||
|
|
||||||
|
_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Depth { [market: string]: Market; }
|
||||||
|
|
||||||
|
interface Market {
|
||||||
|
'buys': string[];
|
||||||
|
'sells': string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HighLowOpenClose {
|
||||||
|
period_start: number | string;
|
||||||
|
open: string;
|
||||||
|
high: string;
|
||||||
|
low: string;
|
||||||
|
close: string;
|
||||||
|
volume_left: string;
|
||||||
|
volume_right: string;
|
||||||
|
avg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Markets { [txid: string]: Pair; }
|
||||||
|
|
||||||
|
interface Pair {
|
||||||
|
pair: string;
|
||||||
|
lname: string;
|
||||||
|
rname: string;
|
||||||
|
lsymbol: string;
|
||||||
|
rsymbol: string;
|
||||||
|
lprecision: number;
|
||||||
|
rprecision: number;
|
||||||
|
ltype: string;
|
||||||
|
rtype: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Offers { [market: string]: OffersMarket; }
|
||||||
|
|
||||||
|
interface OffersMarket {
|
||||||
|
buys: Offer[] | null;
|
||||||
|
sells: Offer[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OffersData {
|
||||||
|
direction: string;
|
||||||
|
currencyCode: string;
|
||||||
|
minAmount: number;
|
||||||
|
amount: number;
|
||||||
|
price: number;
|
||||||
|
date: number;
|
||||||
|
useMarketBasedPrice: boolean;
|
||||||
|
marketPriceMargin: number;
|
||||||
|
paymentMethod: string;
|
||||||
|
id: string;
|
||||||
|
currencyPair: string;
|
||||||
|
primaryMarketDirection: string;
|
||||||
|
priceDisplayString: string;
|
||||||
|
primaryMarketAmountDisplayString: string;
|
||||||
|
primaryMarketMinAmountDisplayString: string;
|
||||||
|
primaryMarketVolumeDisplayString: string;
|
||||||
|
primaryMarketMinVolumeDisplayString: string;
|
||||||
|
primaryMarketPrice: number;
|
||||||
|
primaryMarketAmount: number;
|
||||||
|
primaryMarketMinAmount: number;
|
||||||
|
primaryMarketVolume: number;
|
||||||
|
primaryMarketMinVolume: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Offer {
|
||||||
|
offer_id: string;
|
||||||
|
offer_date: number;
|
||||||
|
direction: string;
|
||||||
|
min_amount: string;
|
||||||
|
amount: string;
|
||||||
|
price: string;
|
||||||
|
volume: string;
|
||||||
|
payment_method: string;
|
||||||
|
offer_fee_txid: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tickers { [market: string]: Ticker | null; }
|
||||||
|
|
||||||
|
export interface Ticker {
|
||||||
|
last: string;
|
||||||
|
high: string;
|
||||||
|
low: string;
|
||||||
|
volume_left: string;
|
||||||
|
volume_right: string;
|
||||||
|
buy: string | null;
|
||||||
|
sell: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Trade {
|
||||||
|
direction: string;
|
||||||
|
price: string;
|
||||||
|
amount: string;
|
||||||
|
volume: string;
|
||||||
|
payment_method: string;
|
||||||
|
trade_id: string;
|
||||||
|
trade_date: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TradesData {
|
||||||
|
currency: string;
|
||||||
|
direction: string;
|
||||||
|
tradePrice: number;
|
||||||
|
tradeAmount: number;
|
||||||
|
tradeDate: number;
|
||||||
|
paymentMethod: string;
|
||||||
|
offerDate: number;
|
||||||
|
useMarketBasedPrice: boolean;
|
||||||
|
marketPriceMargin: number;
|
||||||
|
offerAmount: number;
|
||||||
|
offerMinAmount: number;
|
||||||
|
offerId: string;
|
||||||
|
depositTxId?: string;
|
||||||
|
currencyPair: string;
|
||||||
|
primaryMarketDirection: string;
|
||||||
|
primaryMarketTradePrice: number;
|
||||||
|
primaryMarketTradeAmount: number;
|
||||||
|
primaryMarketTradeVolume: number;
|
||||||
|
|
||||||
|
_market: string;
|
||||||
|
_tradePriceStr: string;
|
||||||
|
_tradeAmountStr: string;
|
||||||
|
_tradeVolumeStr: string;
|
||||||
|
_offerAmountStr: string;
|
||||||
|
_tradePrice: number;
|
||||||
|
_tradeAmount: number;
|
||||||
|
_tradeVolume: number;
|
||||||
|
_offerAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketVolume {
|
||||||
|
period_start: number;
|
||||||
|
num_trades: number;
|
||||||
|
volume: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketsApiError {
|
||||||
|
success: number;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Interval = 'minute' | 'half_hour' | 'hour' | 'half_day' | 'day' | 'week' | 'month' | 'year' | 'auto';
|
||||||
|
|
||||||
|
export interface SummarizedIntervals { [market: string]: SummarizedInterval; }
|
||||||
|
export interface SummarizedInterval {
|
||||||
|
'period_start': number;
|
||||||
|
'open': number;
|
||||||
|
'close': number;
|
||||||
|
'high': number;
|
||||||
|
'low': number;
|
||||||
|
'avg': number;
|
||||||
|
'volume_right': number;
|
||||||
|
'volume_left': number;
|
||||||
|
}
|
||||||
679
backend/src/api/bisq/markets-api.ts
Normal file
679
backend/src/api/bisq/markets-api.ts
Normal file
@@ -0,0 +1,679 @@
|
|||||||
|
import { Currencies, OffersData, TradesData, Depth, Currency, Interval, HighLowOpenClose,
|
||||||
|
Markets, Offers, Offer, BisqTrade, MarketVolume, Tickers, Ticker, SummarizedIntervals, SummarizedInterval } from './interfaces';
|
||||||
|
|
||||||
|
import * as datetime from 'locutus/php/datetime';
|
||||||
|
|
||||||
|
class BisqMarketsApi {
|
||||||
|
private cryptoCurrencyData: Currency[] = [];
|
||||||
|
private fiatCurrencyData: Currency[] = [];
|
||||||
|
private activeCryptoCurrencyData: Currency[] = [];
|
||||||
|
private activeFiatCurrencyData: Currency[] = [];
|
||||||
|
private offersData: OffersData[] = [];
|
||||||
|
private tradesData: TradesData[] = [];
|
||||||
|
private fiatCurrenciesIndexed: { [code: string]: true } = {};
|
||||||
|
private allCurrenciesIndexed: { [code: string]: Currency } = {};
|
||||||
|
private tradeDataByMarket: { [market: string]: TradesData[] } = {};
|
||||||
|
private tickersCache: Ticker | Tickers | null = null;
|
||||||
|
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
setOffersData(offers: OffersData[]) {
|
||||||
|
this.offersData = offers;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTradesData(trades: TradesData[]) {
|
||||||
|
this.tradesData = trades;
|
||||||
|
this.tradeDataByMarket = {};
|
||||||
|
|
||||||
|
this.tradesData.forEach((trade) => {
|
||||||
|
trade._market = trade.currencyPair.toLowerCase().replace('/', '_');
|
||||||
|
if (!this.tradeDataByMarket[trade._market]) {
|
||||||
|
this.tradeDataByMarket[trade._market] = [];
|
||||||
|
}
|
||||||
|
this.tradeDataByMarket[trade._market].push(trade);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrencyData(cryptoCurrency: Currency[], fiatCurrency: Currency[], activeCryptoCurrency: Currency[], activeFiatCurrency: Currency[]) {
|
||||||
|
this.cryptoCurrencyData = cryptoCurrency,
|
||||||
|
this.fiatCurrencyData = fiatCurrency,
|
||||||
|
this.activeCryptoCurrencyData = activeCryptoCurrency,
|
||||||
|
this.activeFiatCurrencyData = activeFiatCurrency;
|
||||||
|
|
||||||
|
this.fiatCurrenciesIndexed = {};
|
||||||
|
this.allCurrenciesIndexed = {};
|
||||||
|
|
||||||
|
this.fiatCurrencyData.forEach((currency) => {
|
||||||
|
currency._type = 'fiat';
|
||||||
|
this.fiatCurrenciesIndexed[currency.code] = true;
|
||||||
|
this.allCurrenciesIndexed[currency.code] = currency;
|
||||||
|
});
|
||||||
|
this.cryptoCurrencyData.forEach((currency) => {
|
||||||
|
currency._type = 'crypto';
|
||||||
|
this.allCurrenciesIndexed[currency.code] = currency;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCache() {
|
||||||
|
this.tickersCache = null;
|
||||||
|
this.tickersCache = this.getTicker();
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrencies(
|
||||||
|
type: 'crypto' | 'fiat' | 'active' | 'all' = 'all',
|
||||||
|
): Currencies {
|
||||||
|
let currencies: Currency[];
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'fiat':
|
||||||
|
currencies = this.fiatCurrencyData;
|
||||||
|
break;
|
||||||
|
case 'crypto':
|
||||||
|
currencies = this.cryptoCurrencyData;
|
||||||
|
break;
|
||||||
|
case 'active':
|
||||||
|
currencies = this.activeCryptoCurrencyData.concat(this.activeFiatCurrencyData);
|
||||||
|
break;
|
||||||
|
case 'all':
|
||||||
|
default:
|
||||||
|
currencies = this.cryptoCurrencyData.concat(this.fiatCurrencyData);
|
||||||
|
}
|
||||||
|
const result = {};
|
||||||
|
currencies.forEach((currency) => {
|
||||||
|
result[currency.code] = currency;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDepth(
|
||||||
|
market: string,
|
||||||
|
): Depth {
|
||||||
|
const currencyPair = market.replace('_', '/').toUpperCase();
|
||||||
|
|
||||||
|
const buys = this.offersData
|
||||||
|
.filter((offer) => offer.currencyPair === currencyPair && offer.primaryMarketDirection === 'BUY')
|
||||||
|
.map((offer) => offer.price)
|
||||||
|
.sort((a, b) => b - a)
|
||||||
|
.map((price) => this.intToBtc(price));
|
||||||
|
|
||||||
|
const sells = this.offersData
|
||||||
|
.filter((offer) => offer.currencyPair === currencyPair && offer.primaryMarketDirection === 'SELL')
|
||||||
|
.map((offer) => offer.price)
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.map((price) => this.intToBtc(price));
|
||||||
|
|
||||||
|
const result = {};
|
||||||
|
result[market] = {
|
||||||
|
'buys': buys,
|
||||||
|
'sells': sells,
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
getOffers(
|
||||||
|
market: string,
|
||||||
|
direction?: 'buy' | 'sell',
|
||||||
|
): Offers {
|
||||||
|
const currencyPair = market.replace('_', '/').toUpperCase();
|
||||||
|
|
||||||
|
let buys: Offer[] | null = null;
|
||||||
|
let sells: Offer[] | null = null;
|
||||||
|
|
||||||
|
if (!direction || direction === 'buy') {
|
||||||
|
buys = this.offersData
|
||||||
|
.filter((offer) => offer.currencyPair === currencyPair && offer.primaryMarketDirection === 'BUY')
|
||||||
|
.sort((a, b) => b.price - a.price)
|
||||||
|
.map((offer) => this.offerDataToOffer(offer, market));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!direction || direction === 'sell') {
|
||||||
|
sells = this.offersData
|
||||||
|
.filter((offer) => offer.currencyPair === currencyPair && offer.primaryMarketDirection === 'SELL')
|
||||||
|
.sort((a, b) => a.price - b.price)
|
||||||
|
.map((offer) => this.offerDataToOffer(offer, market));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Offers = {};
|
||||||
|
result[market] = {
|
||||||
|
'buys': buys,
|
||||||
|
'sells': sells,
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMarkets(): Markets {
|
||||||
|
const allCurrencies = this.getCurrencies();
|
||||||
|
const activeCurrencies = this.getCurrencies('active');
|
||||||
|
const markets = {};
|
||||||
|
|
||||||
|
for (const currency of Object.keys(activeCurrencies)) {
|
||||||
|
if (allCurrencies[currency].code === 'BTC') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFiat = allCurrencies[currency]._type === 'fiat';
|
||||||
|
const pmarketname = allCurrencies['BTC']['name'];
|
||||||
|
|
||||||
|
const lsymbol = isFiat ? 'BTC' : currency;
|
||||||
|
const rsymbol = isFiat ? currency : 'BTC';
|
||||||
|
const lname = isFiat ? pmarketname : allCurrencies[currency].name;
|
||||||
|
const rname = isFiat ? allCurrencies[currency].name : pmarketname;
|
||||||
|
const ltype = isFiat ? 'crypto' : allCurrencies[currency]._type;
|
||||||
|
const rtype = isFiat ? 'fiat' : 'crypto';
|
||||||
|
const lprecision = 8;
|
||||||
|
const rprecision = isFiat ? 2 : 8;
|
||||||
|
const pair = lsymbol.toLowerCase() + '_' + rsymbol.toLowerCase();
|
||||||
|
|
||||||
|
markets[pair] = {
|
||||||
|
'pair': pair,
|
||||||
|
'lname': lname,
|
||||||
|
'rname': rname,
|
||||||
|
'lsymbol': lsymbol,
|
||||||
|
'rsymbol': rsymbol,
|
||||||
|
'lprecision': lprecision,
|
||||||
|
'rprecision': rprecision,
|
||||||
|
'ltype': ltype,
|
||||||
|
'rtype': rtype,
|
||||||
|
'name': lname + '/' + rname,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return markets;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTrades(
|
||||||
|
market: string,
|
||||||
|
timestamp_from?: number,
|
||||||
|
timestamp_to?: number,
|
||||||
|
trade_id_from?: string,
|
||||||
|
trade_id_to?: string,
|
||||||
|
direction?: 'buy' | 'sell',
|
||||||
|
limit: number = 100,
|
||||||
|
sort: 'asc' | 'desc' = 'desc',
|
||||||
|
): BisqTrade[] {
|
||||||
|
limit = Math.min(limit, 2000);
|
||||||
|
const _market = market === 'all' ? undefined : market;
|
||||||
|
|
||||||
|
if (!timestamp_from) {
|
||||||
|
timestamp_from = new Date('2016-01-01').getTime() / 1000;
|
||||||
|
}
|
||||||
|
if (!timestamp_to) {
|
||||||
|
timestamp_to = new Date().getTime() / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = this.getTradesByCriteria(_market, timestamp_to, timestamp_from,
|
||||||
|
trade_id_to, trade_id_from, direction, sort, limit, false);
|
||||||
|
|
||||||
|
if (sort === 'asc') {
|
||||||
|
matches.sort((a, b) => a.tradeDate - b.tradeDate);
|
||||||
|
} else {
|
||||||
|
matches.sort((a, b) => b.tradeDate - a.tradeDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches.map((trade) => {
|
||||||
|
const bsqTrade: BisqTrade = {
|
||||||
|
direction: trade.primaryMarketDirection,
|
||||||
|
price: trade._tradePriceStr,
|
||||||
|
amount: trade._tradeAmountStr,
|
||||||
|
volume: trade._tradeVolumeStr,
|
||||||
|
payment_method: trade.paymentMethod,
|
||||||
|
trade_id: trade.offerId,
|
||||||
|
trade_date: trade.tradeDate,
|
||||||
|
};
|
||||||
|
if (market === 'all') {
|
||||||
|
bsqTrade.market = trade._market;
|
||||||
|
}
|
||||||
|
return bsqTrade;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getVolumes(
|
||||||
|
market?: string,
|
||||||
|
timestamp_from?: number,
|
||||||
|
timestamp_to?: number,
|
||||||
|
interval: Interval = 'auto',
|
||||||
|
milliseconds?: boolean,
|
||||||
|
timestamp: 'no' | 'yes' = 'yes',
|
||||||
|
): MarketVolume[] {
|
||||||
|
if (milliseconds) {
|
||||||
|
timestamp_from = timestamp_from ? timestamp_from / 1000 : timestamp_from;
|
||||||
|
timestamp_to = timestamp_to ? timestamp_to / 1000 : timestamp_to;
|
||||||
|
}
|
||||||
|
if (!timestamp_from) {
|
||||||
|
timestamp_from = new Date('2016-01-01').getTime() / 1000;
|
||||||
|
}
|
||||||
|
if (!timestamp_to) {
|
||||||
|
timestamp_to = new Date().getTime() / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trades = this.getTradesByCriteria(market, timestamp_to, timestamp_from,
|
||||||
|
undefined, undefined, undefined, 'asc', Number.MAX_SAFE_INTEGER);
|
||||||
|
|
||||||
|
if (interval === 'auto') {
|
||||||
|
const range = timestamp_to - timestamp_from;
|
||||||
|
interval = this.getIntervalFromRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervals: any = {};
|
||||||
|
const marketVolumes: MarketVolume[] = [];
|
||||||
|
|
||||||
|
for (const trade of trades) {
|
||||||
|
const traded_at = trade['tradeDate'] / 1000;
|
||||||
|
const interval_start = this.intervalStart(traded_at, interval);
|
||||||
|
|
||||||
|
if (!intervals[interval_start]) {
|
||||||
|
intervals[interval_start] = {
|
||||||
|
'volume': 0,
|
||||||
|
'num_trades': 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const period = intervals[interval_start];
|
||||||
|
period['period_start'] = interval_start;
|
||||||
|
period['volume'] += this.fiatCurrenciesIndexed[trade.currency] ? trade._tradeAmount : trade._tradeVolume;
|
||||||
|
period['num_trades']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p in intervals) {
|
||||||
|
if (intervals.hasOwnProperty(p)) {
|
||||||
|
const period = intervals[p];
|
||||||
|
marketVolumes.push({
|
||||||
|
period_start: timestamp === 'no' ? new Date(period['period_start'] * 1000).toISOString() : period['period_start'],
|
||||||
|
num_trades: period['num_trades'],
|
||||||
|
volume: this.intToBtc(period['volume']),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return marketVolumes;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTicker(
|
||||||
|
market?: string,
|
||||||
|
): Tickers | Ticker | null {
|
||||||
|
if (market) {
|
||||||
|
return this.getTickerFromMarket(market);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.tickersCache) {
|
||||||
|
return this.tickersCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allMarkets = this.getMarkets();
|
||||||
|
const tickers = {};
|
||||||
|
for (const m in allMarkets) {
|
||||||
|
if (allMarkets.hasOwnProperty(m)) {
|
||||||
|
tickers[allMarkets[m].pair] = this.getTickerFromMarket(allMarkets[m].pair);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tickers;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTickerFromMarket(market: string): Ticker | null {
|
||||||
|
let ticker: Ticker;
|
||||||
|
const timestamp_from = datetime.strtotime('-24 hour');
|
||||||
|
const timestamp_to = new Date().getTime() / 1000;
|
||||||
|
const trades = this.getTradesByCriteria(market, timestamp_to, timestamp_from,
|
||||||
|
undefined, undefined, undefined, 'asc', Number.MAX_SAFE_INTEGER);
|
||||||
|
|
||||||
|
const periods: SummarizedInterval[] = Object.values(this.getTradesSummarized(trades, timestamp_from));
|
||||||
|
|
||||||
|
const allCurrencies = this.getCurrencies();
|
||||||
|
const currencyRight = allCurrencies[market.split('_')[1].toUpperCase()];
|
||||||
|
|
||||||
|
if (periods[0]) {
|
||||||
|
ticker = {
|
||||||
|
'last': this.intToBtc(periods[0].close),
|
||||||
|
'high': this.intToBtc(periods[0].high),
|
||||||
|
'low': this.intToBtc(periods[0].low),
|
||||||
|
'volume_left': this.intToBtc(periods[0].volume_left),
|
||||||
|
'volume_right': this.intToBtc(periods[0].volume_right),
|
||||||
|
'buy': null,
|
||||||
|
'sell': null,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const lastTrade = this.tradeDataByMarket[market];
|
||||||
|
if (!lastTrade) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const tradePrice = lastTrade[0].primaryMarketTradePrice * Math.pow(10, 8 - currencyRight.precision);
|
||||||
|
|
||||||
|
const lastTradePrice = this.intToBtc(tradePrice);
|
||||||
|
ticker = {
|
||||||
|
'last': lastTradePrice,
|
||||||
|
'high': lastTradePrice,
|
||||||
|
'low': lastTradePrice,
|
||||||
|
'volume_left': '0',
|
||||||
|
'volume_right': '0',
|
||||||
|
'buy': null,
|
||||||
|
'sell': null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestampFromMilli = timestamp_from * 1000;
|
||||||
|
const timestampToMilli = timestamp_to * 1000;
|
||||||
|
|
||||||
|
const currencyPair = market.replace('_', '/').toUpperCase();
|
||||||
|
const offersData = this.offersData.slice().sort((a, b) => a.price - b.price);
|
||||||
|
|
||||||
|
const buy = offersData.find((offer) => offer.currencyPair === currencyPair
|
||||||
|
&& offer.primaryMarketDirection === 'BUY'
|
||||||
|
&& offer.date >= timestampFromMilli
|
||||||
|
&& offer.date <= timestampToMilli
|
||||||
|
);
|
||||||
|
const sell = offersData.find((offer) => offer.currencyPair === currencyPair
|
||||||
|
&& offer.primaryMarketDirection === 'SELL'
|
||||||
|
&& offer.date >= timestampFromMilli
|
||||||
|
&& offer.date <= timestampToMilli
|
||||||
|
);
|
||||||
|
|
||||||
|
if (buy) {
|
||||||
|
ticker.buy = this.intToBtc(buy.primaryMarketPrice * Math.pow(10, 8 - currencyRight.precision));
|
||||||
|
}
|
||||||
|
if (sell) {
|
||||||
|
ticker.sell = this.intToBtc(sell.primaryMarketPrice * Math.pow(10, 8 - currencyRight.precision));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ticker;
|
||||||
|
}
|
||||||
|
|
||||||
|
getHloc(
|
||||||
|
market: string,
|
||||||
|
interval: Interval = 'auto',
|
||||||
|
timestamp_from?: number,
|
||||||
|
timestamp_to?: number,
|
||||||
|
milliseconds?: boolean,
|
||||||
|
timestamp: 'no' | 'yes' = 'yes',
|
||||||
|
): HighLowOpenClose[] {
|
||||||
|
if (milliseconds) {
|
||||||
|
timestamp_from = timestamp_from ? timestamp_from / 1000 : timestamp_from;
|
||||||
|
timestamp_to = timestamp_to ? timestamp_to / 1000 : timestamp_to;
|
||||||
|
}
|
||||||
|
if (!timestamp_from) {
|
||||||
|
timestamp_from = new Date('2016-01-01').getTime() / 1000;
|
||||||
|
}
|
||||||
|
if (!timestamp_to) {
|
||||||
|
timestamp_to = new Date().getTime() / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trades = this.getTradesByCriteria(market, timestamp_to, timestamp_from,
|
||||||
|
undefined, undefined, undefined, 'asc', Number.MAX_SAFE_INTEGER);
|
||||||
|
|
||||||
|
if (interval === 'auto') {
|
||||||
|
const range = timestamp_to - timestamp_from;
|
||||||
|
interval = this.getIntervalFromRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervals = this.getTradesSummarized(trades, timestamp_from, interval);
|
||||||
|
|
||||||
|
const hloc: HighLowOpenClose[] = [];
|
||||||
|
|
||||||
|
for (const p in intervals) {
|
||||||
|
if (intervals.hasOwnProperty(p)) {
|
||||||
|
const period = intervals[p];
|
||||||
|
hloc.push({
|
||||||
|
period_start: timestamp === 'no' ? new Date(period['period_start'] * 1000).toISOString() : period['period_start'],
|
||||||
|
open: this.intToBtc(period['open']),
|
||||||
|
close: this.intToBtc(period['close']),
|
||||||
|
high: this.intToBtc(period['high']),
|
||||||
|
low: this.intToBtc(period['low']),
|
||||||
|
avg: this.intToBtc(period['avg']),
|
||||||
|
volume_right: this.intToBtc(period['volume_right']),
|
||||||
|
volume_left: this.intToBtc(period['volume_left']),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hloc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getIntervalFromRange(range: number): Interval {
|
||||||
|
// two days range loads minute data
|
||||||
|
if (range <= 3600) {
|
||||||
|
// up to one hour range loads minutely data
|
||||||
|
return 'minute';
|
||||||
|
} else if (range <= 1 * 24 * 3600) {
|
||||||
|
// up to one day range loads half-hourly data
|
||||||
|
return 'half_hour';
|
||||||
|
} else if (range <= 3 * 24 * 3600) {
|
||||||
|
// up to 3 day range loads hourly data
|
||||||
|
return 'hour';
|
||||||
|
} else if (range <= 7 * 24 * 3600) {
|
||||||
|
// up to 7 day range loads half-daily data
|
||||||
|
return 'half_day';
|
||||||
|
} else if (range <= 60 * 24 * 3600) {
|
||||||
|
// up to 2 month range loads daily data
|
||||||
|
return 'day';
|
||||||
|
} else if (range <= 12 * 31 * 24 * 3600) {
|
||||||
|
// up to one year range loads weekly data
|
||||||
|
return 'week';
|
||||||
|
} else if (range <= 12 * 31 * 24 * 3600) {
|
||||||
|
// up to 5 year range loads monthly data
|
||||||
|
return 'month';
|
||||||
|
} else {
|
||||||
|
// greater range loads yearly data
|
||||||
|
return 'year';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = {};
|
||||||
|
|
||||||
|
for (const trade of trades) {
|
||||||
|
const traded_at = trade.tradeDate / 1000;
|
||||||
|
const interval_start = !interval ? timestamp_from : this.intervalStart(traded_at, interval);
|
||||||
|
|
||||||
|
if (!intervals[interval_start]) {
|
||||||
|
intervals[interval_start] = {
|
||||||
|
'open': 0,
|
||||||
|
'close': 0,
|
||||||
|
'high': 0,
|
||||||
|
'low': 0,
|
||||||
|
'avg': 0,
|
||||||
|
'volume_right': 0,
|
||||||
|
'volume_left': 0,
|
||||||
|
};
|
||||||
|
intervals_prices[interval_start] = [];
|
||||||
|
}
|
||||||
|
const period = intervals[interval_start];
|
||||||
|
const price = trade._tradePrice;
|
||||||
|
|
||||||
|
if (!intervals_prices[interval_start]['leftvol']) {
|
||||||
|
intervals_prices[interval_start]['leftvol'] = [];
|
||||||
|
}
|
||||||
|
if (!intervals_prices[interval_start]['rightvol']) {
|
||||||
|
intervals_prices[interval_start]['rightvol'] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
intervals_prices[interval_start]['leftvol'].push(trade._tradeAmount);
|
||||||
|
intervals_prices[interval_start]['rightvol'].push(trade._tradeVolume);
|
||||||
|
|
||||||
|
if (price) {
|
||||||
|
const plow = period['low'];
|
||||||
|
period['period_start'] = interval_start;
|
||||||
|
period['open'] = period['open'] || price;
|
||||||
|
period['close'] = price;
|
||||||
|
period['high'] = price > period['high'] ? price : period['high'];
|
||||||
|
period['low'] = (plow && price > plow) ? period['low'] : price;
|
||||||
|
period['avg'] = intervals_prices[interval_start]['rightvol'].reduce((p: number, c: number) => c + p, 0)
|
||||||
|
/ intervals_prices[interval_start]['leftvol'].reduce((c: number, p: number) => c + p, 0) * 100000000;
|
||||||
|
period['volume_left'] += trade._tradeAmount;
|
||||||
|
period['volume_right'] += trade._tradeVolume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return intervals;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTradesByCriteria(
|
||||||
|
market: string | undefined,
|
||||||
|
timestamp_to: number,
|
||||||
|
timestamp_from: number,
|
||||||
|
trade_id_to: string | undefined,
|
||||||
|
trade_id_from: string | undefined,
|
||||||
|
direction: 'buy' | 'sell' | undefined,
|
||||||
|
sort: string,
|
||||||
|
limit: number,
|
||||||
|
integerAmounts: boolean = true,
|
||||||
|
): TradesData[] {
|
||||||
|
let trade_id_from_ts: number | null = null;
|
||||||
|
let trade_id_to_ts: number | null = null;
|
||||||
|
const allCurrencies = this.getCurrencies();
|
||||||
|
|
||||||
|
const timestampFromMilli = timestamp_from * 1000;
|
||||||
|
const timestampToMilli = timestamp_to * 1000;
|
||||||
|
|
||||||
|
// note: the offer_id_from/to depends on iterating over trades in
|
||||||
|
// descending chronological order.
|
||||||
|
const tradesDataSorted = this.tradesData.slice();
|
||||||
|
if (sort === 'asc') {
|
||||||
|
tradesDataSorted.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
let matches: TradesData[] = [];
|
||||||
|
for (const trade of tradesDataSorted) {
|
||||||
|
if (trade_id_from === trade.offerId) {
|
||||||
|
trade_id_from_ts = trade.tradeDate;
|
||||||
|
}
|
||||||
|
if (trade_id_to === trade.offerId) {
|
||||||
|
trade_id_to_ts = trade.tradeDate;
|
||||||
|
}
|
||||||
|
if (trade_id_to && trade_id_to_ts === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (trade_id_from && trade_id_from_ts != null && trade_id_from_ts !== trade.tradeDate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (market && market !== trade._market) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (timestampFromMilli && timestampFromMilli > trade.tradeDate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (timestampToMilli && timestampToMilli < trade.tradeDate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (direction && direction !== trade.direction.toLowerCase()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out bogus trades with BTC/BTC or XXX/XXX market.
|
||||||
|
// See github issue: https://github.com/bitsquare/bitsquare/issues/883
|
||||||
|
const currencyPairs = trade.currencyPair.split('/');
|
||||||
|
if (currencyPairs[0] === currencyPairs[1]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currencyLeft = allCurrencies[currencyPairs[0]];
|
||||||
|
const currencyRight = allCurrencies[currencyPairs[1]];
|
||||||
|
|
||||||
|
if (!currencyLeft || !currencyRight) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tradePrice = trade.primaryMarketTradePrice * Math.pow(10, 8 - currencyRight.precision);
|
||||||
|
const tradeAmount = trade.primaryMarketTradeAmount * Math.pow(10, 8 - currencyLeft.precision);
|
||||||
|
const tradeVolume = trade.primaryMarketTradeVolume * Math.pow(10, 8 - currencyRight.precision);
|
||||||
|
|
||||||
|
if (integerAmounts) {
|
||||||
|
trade._tradePrice = tradePrice;
|
||||||
|
trade._tradeAmount = tradeAmount;
|
||||||
|
trade._tradeVolume = tradeVolume;
|
||||||
|
trade._offerAmount = trade.offerAmount;
|
||||||
|
} else {
|
||||||
|
trade._tradePriceStr = this.intToBtc(tradePrice);
|
||||||
|
trade._tradeAmountStr = this.intToBtc(tradeAmount);
|
||||||
|
trade._tradeVolumeStr = this.intToBtc(tradeVolume);
|
||||||
|
trade._offerAmountStr = this.intToBtc(trade.offerAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
matches.push(trade);
|
||||||
|
|
||||||
|
if (matches.length >= limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((trade_id_from && !trade_id_from_ts) || (trade_id_to && !trade_id_to_ts)) {
|
||||||
|
matches = [];
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
private intervalStart(ts: number, interval: string): number {
|
||||||
|
switch (interval) {
|
||||||
|
case 'minute':
|
||||||
|
return (ts - (ts % 60));
|
||||||
|
case '10_minute':
|
||||||
|
return (ts - (ts % 600));
|
||||||
|
case 'half_hour':
|
||||||
|
return (ts - (ts % 1800));
|
||||||
|
case 'hour':
|
||||||
|
return (ts - (ts % 3600));
|
||||||
|
case 'half_day':
|
||||||
|
return (ts - (ts % (3600 * 12)));
|
||||||
|
case 'day':
|
||||||
|
return datetime.strtotime('midnight today', ts);
|
||||||
|
case 'week':
|
||||||
|
return datetime.strtotime('midnight sunday last week', ts);
|
||||||
|
case 'month':
|
||||||
|
return datetime.strtotime('midnight first day of this month', ts);
|
||||||
|
case 'year':
|
||||||
|
return datetime.strtotime('midnight first day of january', ts);
|
||||||
|
default:
|
||||||
|
throw new Error('Unsupported interval: ' + interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private offerDataToOffer(offer: OffersData, market: string): Offer {
|
||||||
|
const currencyPairs = market.split('_');
|
||||||
|
const currencyRight = this.allCurrenciesIndexed[currencyPairs[1].toUpperCase()];
|
||||||
|
const currencyLeft = this.allCurrenciesIndexed[currencyPairs[0].toUpperCase()];
|
||||||
|
const price = offer['primaryMarketPrice'] * Math.pow( 10, 8 - currencyRight['precision']);
|
||||||
|
const amount = offer['primaryMarketAmount'] * Math.pow( 10, 8 - currencyLeft['precision']);
|
||||||
|
const volume = offer['primaryMarketVolume'] * Math.pow( 10, 8 - currencyRight['precision']);
|
||||||
|
|
||||||
|
return {
|
||||||
|
offer_id: offer.id,
|
||||||
|
offer_date: offer.date,
|
||||||
|
direction: offer.primaryMarketDirection,
|
||||||
|
min_amount: this.intToBtc(offer.minAmount),
|
||||||
|
amount: this.intToBtc(amount),
|
||||||
|
price: this.intToBtc(price),
|
||||||
|
volume: this.intToBtc(volume),
|
||||||
|
payment_method: offer.paymentMethod,
|
||||||
|
offer_fee_txid: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private intToBtc(val: number): string {
|
||||||
|
return (val / 100000000).toFixed(8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new BisqMarketsApi();
|
||||||
131
backend/src/api/bisq/markets.ts
Normal file
131
backend/src/api/bisq/markets.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import config from '../../config';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import { OffersData as OffersData, TradesData, Currency } from './interfaces';
|
||||||
|
import bisqMarket from './markets-api';
|
||||||
|
import logger from '../../logger';
|
||||||
|
|
||||||
|
class Bisq {
|
||||||
|
private static FOLDER_WATCH_CHANGE_DETECTION_DEBOUNCE = 4000;
|
||||||
|
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',
|
||||||
|
cryptoCurrency: '/crypto_currency_list.json',
|
||||||
|
fiatCurrency: '/fiat_currency_list.json',
|
||||||
|
offers: '/offers_statistics.json',
|
||||||
|
trades: '/trade_statistics.json',
|
||||||
|
};
|
||||||
|
|
||||||
|
private cryptoCurrencyLastMtime = new Date('2016-01-01');
|
||||||
|
private fiatCurrencyLastMtime = new Date('2016-01-01');
|
||||||
|
private offersLastMtime = new Date('2016-01-01');
|
||||||
|
private tradesLastMtime = new Date('2016-01-01');
|
||||||
|
|
||||||
|
private subdirectoryWatcher: fs.FSWatcher | undefined;
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
startBisqService(): void {
|
||||||
|
this.checkForBisqDataFolder();
|
||||||
|
this.loadBisqDumpFile();
|
||||||
|
this.startBisqDirectoryWatcher();
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkForBisqDataFolder() {
|
||||||
|
if (!fs.existsSync(Bisq.MARKET_JSON_PATH + Bisq.MARKET_JSON_FILE_PATHS.cryptoCurrency)) {
|
||||||
|
logger.err(Bisq.MARKET_JSON_PATH + Bisq.MARKET_JSON_FILE_PATHS.cryptoCurrency + ` doesn't exist. Make sure Bisq is running and the config is correct before starting the server.`);
|
||||||
|
return process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startBisqDirectoryWatcher() {
|
||||||
|
if (this.subdirectoryWatcher) {
|
||||||
|
this.subdirectoryWatcher.close();
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(Bisq.MARKET_JSON_PATH + Bisq.MARKET_JSON_FILE_PATHS.cryptoCurrency)) {
|
||||||
|
logger.warn(Bisq.MARKET_JSON_PATH + Bisq.MARKET_JSON_FILE_PATHS.cryptoCurrency + ` doesn't exist. Trying to restart sub directory watcher again in 3 minutes.`);
|
||||||
|
setTimeout(() => this.startBisqDirectoryWatcher(), 180000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let fsWait: NodeJS.Timeout | null = null;
|
||||||
|
this.subdirectoryWatcher = fs.watch(Bisq.MARKET_JSON_PATH, () => {
|
||||||
|
if (fsWait) {
|
||||||
|
clearTimeout(fsWait);
|
||||||
|
}
|
||||||
|
fsWait = setTimeout(() => {
|
||||||
|
logger.debug(`Change detected in the Bisq market data folder.`);
|
||||||
|
this.loadBisqDumpFile();
|
||||||
|
}, Bisq.FOLDER_WATCH_CHANGE_DETECTION_DEBOUNCE);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadBisqDumpFile(): Promise<void> {
|
||||||
|
const start = new Date().getTime();
|
||||||
|
try {
|
||||||
|
let marketsDataUpdated = false;
|
||||||
|
const cryptoMtime = this.getFileMtime(Bisq.MARKET_JSON_FILE_PATHS.cryptoCurrency);
|
||||||
|
const fiatMtime = this.getFileMtime(Bisq.MARKET_JSON_FILE_PATHS.fiatCurrency);
|
||||||
|
if (cryptoMtime > this.cryptoCurrencyLastMtime || fiatMtime > this.fiatCurrencyLastMtime) {
|
||||||
|
const cryptoCurrencyData = await this.loadData<Currency[]>(Bisq.MARKET_JSON_FILE_PATHS.cryptoCurrency);
|
||||||
|
const fiatCurrencyData = await this.loadData<Currency[]>(Bisq.MARKET_JSON_FILE_PATHS.fiatCurrency);
|
||||||
|
const activeCryptoCurrencyData = await this.loadData<Currency[]>(Bisq.MARKET_JSON_FILE_PATHS.activeCryptoCurrency);
|
||||||
|
const activeFiatCurrencyData = await this.loadData<Currency[]>(Bisq.MARKET_JSON_FILE_PATHS.activeFiatCurrency);
|
||||||
|
logger.debug('Updating Bisq Market Currency Data');
|
||||||
|
bisqMarket.setCurrencyData(cryptoCurrencyData, fiatCurrencyData, activeCryptoCurrencyData, activeFiatCurrencyData);
|
||||||
|
if (cryptoMtime > this.cryptoCurrencyLastMtime) {
|
||||||
|
this.cryptoCurrencyLastMtime = cryptoMtime;
|
||||||
|
}
|
||||||
|
if (fiatMtime > this.fiatCurrencyLastMtime) {
|
||||||
|
this.fiatCurrencyLastMtime = fiatMtime;
|
||||||
|
}
|
||||||
|
marketsDataUpdated = true;
|
||||||
|
}
|
||||||
|
const offersMtime = this.getFileMtime(Bisq.MARKET_JSON_FILE_PATHS.offers);
|
||||||
|
if (offersMtime > this.offersLastMtime) {
|
||||||
|
const offersData = await this.loadData<OffersData[]>(Bisq.MARKET_JSON_FILE_PATHS.offers);
|
||||||
|
logger.debug('Updating Bisq Market Offers Data');
|
||||||
|
bisqMarket.setOffersData(offersData);
|
||||||
|
this.offersLastMtime = offersMtime;
|
||||||
|
marketsDataUpdated = true;
|
||||||
|
}
|
||||||
|
const tradesMtime = this.getFileMtime(Bisq.MARKET_JSON_FILE_PATHS.trades);
|
||||||
|
if (tradesMtime > this.tradesLastMtime) {
|
||||||
|
const tradesData = await this.loadData<TradesData[]>(Bisq.MARKET_JSON_FILE_PATHS.trades);
|
||||||
|
logger.debug('Updating Bisq Market Trades Data');
|
||||||
|
bisqMarket.setTradesData(tradesData);
|
||||||
|
this.tradesLastMtime = tradesMtime;
|
||||||
|
marketsDataUpdated = true;
|
||||||
|
}
|
||||||
|
if (marketsDataUpdated) {
|
||||||
|
bisqMarket.updateCache();
|
||||||
|
const time = new Date().getTime() - start;
|
||||||
|
logger.debug('Bisq market data updated in ' + time + ' ms');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('loadBisqMarketDataDumpFile() error.' + (e instanceof Error ? e.message : e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFileMtime(path: string): Date {
|
||||||
|
const stats = fs.statSync(Bisq.MARKET_JSON_PATH + path);
|
||||||
|
return stats.mtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadData<T>(path: string): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
fs.readFile(Bisq.MARKET_JSON_PATH + path, 'utf8', (err, data) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsedData = JSON.parse(data);
|
||||||
|
resolve(parsedData);
|
||||||
|
} catch (e) {
|
||||||
|
reject('JSON parse error (' + path + ')');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new Bisq();
|
||||||
@@ -1,19 +1,22 @@
|
|||||||
import { IMempoolInfo, ITransaction, IBlock } from '../../interfaces';
|
import { IEsploraApi } from './esplora-api.interface';
|
||||||
|
|
||||||
export interface AbstractBitcoinApi {
|
export interface AbstractBitcoinApi {
|
||||||
getMempoolInfo(): Promise<IMempoolInfo>;
|
$getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]>;
|
||||||
getRawMempool(): Promise<ITransaction['txid'][]>;
|
$getRawTransaction(txId: string, skipConversion?: boolean, addPrevout?: boolean): Promise<IEsploraApi.Transaction>;
|
||||||
getRawTransaction(txId: string): Promise<ITransaction>;
|
$getBlockHeightTip(): Promise<number>;
|
||||||
getBlockCount(): Promise<number>;
|
$getTxIdsForBlock(hash: string): Promise<string[]>;
|
||||||
getBlockAndTransactions(hash: string): Promise<IBlock>;
|
$getBlockHash(height: number): Promise<string>;
|
||||||
getBlockHash(height: number): Promise<string>;
|
$getBlockHeader(hash: string): Promise<string>;
|
||||||
|
$getBlock(hash: string): Promise<IEsploraApi.Block>;
|
||||||
getBlock(hash: string): Promise<IBlock>;
|
$getAddress(address: string): Promise<IEsploraApi.Address>;
|
||||||
getBlockTransactions(hash: string): Promise<IBlock>;
|
$getAddressTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]>;
|
||||||
getBlockTransactionsFromIndex(hash: string, index: number): Promise<IBlock>;
|
$getAddressPrefix(prefix: string): string[];
|
||||||
getBlocks(): Promise<string>;
|
$sendRawTransaction(rawTransaction: string): Promise<string>;
|
||||||
getBlocksFromHeight(height: number): Promise<string>;
|
}
|
||||||
getAddress(address: string): Promise<IBlock>;
|
export interface BitcoinRpcCredentials {
|
||||||
getAddressTransactions(address: string): Promise<IBlock>;
|
host: string;
|
||||||
getAddressTransactionsFromLastSeenTxid(address: string, lastSeenTxid: string): Promise<IBlock>;
|
port: number;
|
||||||
|
user: string;
|
||||||
|
pass: string;
|
||||||
|
timeout: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
const config = require('../../../mempool-config.json');
|
import config from '../../config';
|
||||||
import { AbstractBitcoinApi } from './bitcoin-api-abstract-factory';
|
import { AbstractBitcoinApi } from './bitcoin-api-abstract-factory';
|
||||||
import BitcoindApi from './bitcoind-api';
|
import EsploraApi from './esplora-api';
|
||||||
import ElectrsApi from './electrs-api';
|
import BitcoinApi from './bitcoin-api';
|
||||||
|
import ElectrumApi from './electrum-api';
|
||||||
|
import bitcoinClient from './bitcoin-client';
|
||||||
|
|
||||||
function factory(): AbstractBitcoinApi {
|
function bitcoinApiFactory(): AbstractBitcoinApi {
|
||||||
switch (config.BACKEND_API) {
|
switch (config.MEMPOOL.BACKEND) {
|
||||||
case 'electrs':
|
case 'esplora':
|
||||||
return new ElectrsApi();
|
return new EsploraApi();
|
||||||
case 'bitcoind':
|
case 'electrum':
|
||||||
|
return new ElectrumApi(bitcoinClient);
|
||||||
|
case 'none':
|
||||||
default:
|
default:
|
||||||
return new BitcoindApi();
|
return new BitcoinApi(bitcoinClient);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default factory();
|
export default bitcoinApiFactory();
|
||||||
|
|||||||
166
backend/src/api/bitcoin/bitcoin-api.interface.ts
Normal file
166
backend/src/api/bitcoin/bitcoin-api.interface.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
export namespace IBitcoinApi {
|
||||||
|
export interface MempoolInfo {
|
||||||
|
loaded: boolean; // (boolean) True if the mempool is fully loaded
|
||||||
|
size: number; // (numeric) Current tx count
|
||||||
|
bytes: number; // (numeric) Sum of all virtual transaction sizes as defined in BIP 141.
|
||||||
|
usage: number; // (numeric) Total memory usage for the mempool
|
||||||
|
maxmempool: number; // (numeric) Maximum memory usage for the mempool
|
||||||
|
mempoolminfee: number; // (numeric) Minimum fee rate in BTC/kB for tx to be accepted.
|
||||||
|
minrelaytxfee: number; // (numeric) Current minimum relay fee for transactions
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawMempool { [txId: string]: MempoolEntry; }
|
||||||
|
|
||||||
|
export interface MempoolEntry {
|
||||||
|
vsize: number; // (numeric) virtual transaction size as defined in BIP 141.
|
||||||
|
weight: number; // (numeric) transaction weight as defined in BIP 141.
|
||||||
|
time: number; // (numeric) local time transaction entered pool in seconds since 1 Jan 1970 GMT
|
||||||
|
height: number; // (numeric) block height when transaction entered pool
|
||||||
|
descendantcount: number; // (numeric) number of in-mempool descendant transactions (including this one)
|
||||||
|
descendantsize: number; // (numeric) virtual transaction size of in-mempool descendants (including this one)
|
||||||
|
ancestorcount: number; // (numeric) number of in-mempool ancestor transactions (including this one)
|
||||||
|
ancestorsize: number; // (numeric) virtual transaction size of in-mempool ancestors (including this one)
|
||||||
|
wtxid: string; // (string) hash of serialized transactionumber; including witness data
|
||||||
|
fees: {
|
||||||
|
base: number; // (numeric) transaction fee in BTC
|
||||||
|
modified: number; // (numeric) transaction fee with fee deltas used for mining priority in BTC
|
||||||
|
ancestor: number; // (numeric) modified fees (see above) of in-mempool ancestors (including this one) in BTC
|
||||||
|
descendant: number; // (numeric) modified fees (see above) of in-mempool descendants (including this one) in BTC
|
||||||
|
};
|
||||||
|
depends: string[]; // (string) parent transaction id
|
||||||
|
spentby: string[]; // (array) unconfirmed transactions spending outputs from this transaction
|
||||||
|
'bip125-replaceable': boolean; // (boolean) Whether this transaction could be replaced due to BIP125 (replace-by-fee)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Block {
|
||||||
|
hash: string; // (string) the block hash (same as provided)
|
||||||
|
confirmations: number; // (numeric) The number of confirmations, or -1 if the block is not on the main chain
|
||||||
|
size: number; // (numeric) The block size
|
||||||
|
strippedsize: number; // (numeric) The block size excluding witness data
|
||||||
|
weight: number; // (numeric) The block weight as defined in BIP 141
|
||||||
|
height: number; // (numeric) The block height or index
|
||||||
|
version: number; // (numeric) The block version
|
||||||
|
versionHex: string; // (string) The block version formatted in hexadecimal
|
||||||
|
merkleroot: string; // (string) The merkle root
|
||||||
|
tx: Transaction[];
|
||||||
|
time: number; // (numeric) The block time expressed in UNIX epoch time
|
||||||
|
mediantime: number; // (numeric) The median block time expressed in UNIX epoch time
|
||||||
|
nonce: number; // (numeric) The nonce
|
||||||
|
bits: string; // (string) The bits
|
||||||
|
difficulty: number; // (numeric) The difficulty
|
||||||
|
chainwork: string; // (string) Expected number of hashes required to produce the chain up to this block (in hex)
|
||||||
|
nTx: number; // (numeric) The number of transactions in the block
|
||||||
|
previousblockhash: string; // (string) The hash of the previous block
|
||||||
|
nextblockhash: string; // (string) The hash of the next block
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Transaction {
|
||||||
|
in_active_chain: boolean; // (boolean) Whether specified block is in the active chain or not
|
||||||
|
hex: string; // (string) The serialized, hex-encoded data for 'txid'
|
||||||
|
txid: string; // (string) The transaction id (same as provided)
|
||||||
|
hash: string; // (string) The transaction hash (differs from txid for witness transactions)
|
||||||
|
size: number; // (numeric) The serialized transaction size
|
||||||
|
vsize: number; // (numeric) The virtual transaction size (differs from size for witness transactions)
|
||||||
|
weight: number; // (numeric) The transaction's weight (between vsize*4-3 and vsize*4)
|
||||||
|
version: number; // (numeric) The version
|
||||||
|
locktime: number; // (numeric) The lock time
|
||||||
|
vin: Vin[];
|
||||||
|
vout: Vout[];
|
||||||
|
blockhash: string; // (string) the block hash
|
||||||
|
confirmations: number; // (numeric) The confirmations
|
||||||
|
blocktime: number; // (numeric) The block time expressed in UNIX epoch time
|
||||||
|
time: number; // (numeric) Same as blocktime
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Vin {
|
||||||
|
txid?: string; // (string) The transaction id
|
||||||
|
vout?: number; // (string)
|
||||||
|
scriptSig?: { // (json object) The script
|
||||||
|
asm: string; // (string) asm
|
||||||
|
hex: string; // (string) hex
|
||||||
|
};
|
||||||
|
sequence: number; // (numeric) The script sequence number
|
||||||
|
txinwitness?: string[]; // (string) hex-encoded witness data
|
||||||
|
coinbase?: string;
|
||||||
|
is_pegin?: boolean; // (boolean) Elements peg-in
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
type: string; // (string) The type, eg 'pubkeyhash'
|
||||||
|
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_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 {
|
||||||
|
height: number; // (numeric) height of the chain tip
|
||||||
|
hash: string; // (string) block hash of the tip
|
||||||
|
branchlen: number; // (numeric) zero for main chain, otherwise length of branch connecting the tip to the main chain
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
306
backend/src/api/bitcoin/bitcoin-api.ts
Normal file
306
backend/src/api/bitcoin/bitcoin-api.ts
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
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 mempool from '../mempool';
|
||||||
|
import { TransactionExtended } from '../../mempool.interfaces';
|
||||||
|
|
||||||
|
class BitcoinApi implements AbstractBitcoinApi {
|
||||||
|
private rawMempoolCache: IBitcoinApi.RawMempool | null = null;
|
||||||
|
protected bitcoindClient: any;
|
||||||
|
|
||||||
|
constructor(bitcoinClient: any) {
|
||||||
|
this.bitcoindClient = bitcoinClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
$getRawTransaction(txId: string, skipConversion = false, addPrevout = false): Promise<IEsploraApi.Transaction> {
|
||||||
|
// If the transaction is in the mempool we already converted and fetched the fee. Only prevouts are missing
|
||||||
|
const txInMempool = mempool.getMempool()[txId];
|
||||||
|
if (txInMempool && addPrevout) {
|
||||||
|
return this.$addPrevouts(txInMempool);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special case to fetch the Coinbase transaction
|
||||||
|
if (txId === '4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b') {
|
||||||
|
return this.$returnCoinbaseTransaction();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$getBlockHeightTip(): Promise<number> {
|
||||||
|
return this.bitcoindClient.getChainTips()
|
||||||
|
.then((result: IBitcoinApi.ChainTips[]) => result[0].height);
|
||||||
|
}
|
||||||
|
|
||||||
|
$getTxIdsForBlock(hash: string): Promise<string[]> {
|
||||||
|
return this.bitcoindClient.getBlock(hash, 1)
|
||||||
|
.then((rpcBlock: IBitcoinApi.Block) => rpcBlock.tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
$getRawBlock(hash: string): Promise<string> {
|
||||||
|
return this.bitcoindClient.getBlock(hash, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$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) {
|
||||||
|
return foundBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.bitcoindClient.getBlock(hash)
|
||||||
|
.then((block: IBitcoinApi.Block) => this.convertBlock(block));
|
||||||
|
}
|
||||||
|
|
||||||
|
$getAddress(address: string): Promise<IEsploraApi.Address> {
|
||||||
|
throw new Error('Method getAddress not supported by the Bitcoin RPC API.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$getAddressTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]> {
|
||||||
|
throw new Error('Method getAddressTransactions not supported by the Bitcoin RPC API.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]> {
|
||||||
|
return this.bitcoindClient.getRawMemPool();
|
||||||
|
}
|
||||||
|
|
||||||
|
$getAddressPrefix(prefix: string): string[] {
|
||||||
|
const found: string[] = [];
|
||||||
|
const mp = mempool.getMempool();
|
||||||
|
for (const tx in mp) {
|
||||||
|
for (const vout of mp[tx].vout) {
|
||||||
|
if (vout.scriptpubkey_address.indexOf(prefix) === 0) {
|
||||||
|
found.push(vout.scriptpubkey_address);
|
||||||
|
if (found.length >= 10) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sendRawTransaction(rawTransaction: string): Promise<string> {
|
||||||
|
return this.bitcoindClient.sendRawTransaction(rawTransaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async $convertTransaction(transaction: IBitcoinApi.Transaction, addPrevout: boolean): Promise<IEsploraApi.Transaction> {
|
||||||
|
let esploraTransaction: IEsploraApi.Transaction = {
|
||||||
|
txid: transaction.txid,
|
||||||
|
version: transaction.version,
|
||||||
|
locktime: transaction.locktime,
|
||||||
|
size: transaction.size,
|
||||||
|
weight: transaction.weight,
|
||||||
|
fee: 0,
|
||||||
|
vin: [],
|
||||||
|
vout: [],
|
||||||
|
status: { confirmed: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
esploraTransaction.vout = transaction.vout.map((vout) => {
|
||||||
|
return {
|
||||||
|
value: vout.value * 100000000,
|
||||||
|
scriptpubkey: vout.scriptPubKey.hex,
|
||||||
|
scriptpubkey_address: vout.scriptPubKey && vout.scriptPubKey.address ? vout.scriptPubKey.address
|
||||||
|
: vout.scriptPubKey.addresses ? vout.scriptPubKey.addresses[0] : '',
|
||||||
|
scriptpubkey_asm: vout.scriptPubKey.asm ? this.convertScriptSigAsm(vout.scriptPubKey.asm) : '',
|
||||||
|
scriptpubkey_type: this.translateScriptPubKeyType(vout.scriptPubKey.type),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
esploraTransaction.vin = transaction.vin.map((vin) => {
|
||||||
|
return {
|
||||||
|
is_coinbase: !!vin.coinbase,
|
||||||
|
prevout: null,
|
||||||
|
scriptsig: vin.scriptSig && vin.scriptSig.hex || vin.coinbase || '',
|
||||||
|
scriptsig_asm: vin.scriptSig && this.convertScriptSigAsm(vin.scriptSig.asm) || '',
|
||||||
|
sequence: vin.sequence,
|
||||||
|
txid: vin.txid || '',
|
||||||
|
vout: vin.vout || 0,
|
||||||
|
witness: vin.txinwitness,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (transaction.confirmations) {
|
||||||
|
esploraTransaction.status = {
|
||||||
|
confirmed: true,
|
||||||
|
block_height: blocks.getCurrentBlockHeight() - transaction.confirmations + 1,
|
||||||
|
block_hash: transaction.blockhash,
|
||||||
|
block_time: transaction.blocktime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transaction.confirmations) {
|
||||||
|
esploraTransaction = await this.$calculateFeeFromInputs(esploraTransaction, addPrevout);
|
||||||
|
} else {
|
||||||
|
esploraTransaction = await this.$appendMempoolFeeData(esploraTransaction);
|
||||||
|
if (addPrevout) {
|
||||||
|
esploraTransaction = await this.$calculateFeeFromInputs(esploraTransaction, addPrevout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return esploraTransaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
private convertBlock(block: IBitcoinApi.Block): IEsploraApi.Block {
|
||||||
|
return {
|
||||||
|
id: block.hash,
|
||||||
|
height: block.height,
|
||||||
|
version: block.version,
|
||||||
|
timestamp: block.time,
|
||||||
|
bits: parseInt(block.bits, 16),
|
||||||
|
nonce: block.nonce,
|
||||||
|
difficulty: block.difficulty,
|
||||||
|
merkle_root: block.merkleroot,
|
||||||
|
tx_count: block.nTx,
|
||||||
|
size: block.size,
|
||||||
|
weight: block.weight,
|
||||||
|
previousblockhash: block.previousblockhash,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private translateScriptPubKeyType(outputType: string): string {
|
||||||
|
const map = {
|
||||||
|
'pubkey': 'p2pk',
|
||||||
|
'pubkeyhash': 'p2pkh',
|
||||||
|
'scripthash': 'p2sh',
|
||||||
|
'witness_v0_keyhash': 'v0_p2wpkh',
|
||||||
|
'witness_v0_scripthash': 'v0_p2wsh',
|
||||||
|
'witness_v1_taproot': 'v1_p2tr',
|
||||||
|
'nonstandard': 'nonstandard',
|
||||||
|
'nulldata': 'op_return'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (map[outputType]) {
|
||||||
|
return map[outputType];
|
||||||
|
} else {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async $appendMempoolFeeData(transaction: IEsploraApi.Transaction): Promise<IEsploraApi.Transaction> {
|
||||||
|
if (transaction.fee) {
|
||||||
|
return transaction;
|
||||||
|
}
|
||||||
|
let mempoolEntry: IBitcoinApi.MempoolEntry;
|
||||||
|
if (!mempool.isInSync() && !this.rawMempoolCache) {
|
||||||
|
this.rawMempoolCache = await this.$getRawMempoolVerbose();
|
||||||
|
}
|
||||||
|
if (this.rawMempoolCache && this.rawMempoolCache[transaction.txid]) {
|
||||||
|
mempoolEntry = this.rawMempoolCache[transaction.txid];
|
||||||
|
} else {
|
||||||
|
mempoolEntry = await this.$getMempoolEntry(transaction.txid);
|
||||||
|
}
|
||||||
|
transaction.fee = mempoolEntry.fees.base * 100000000;
|
||||||
|
return transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async $addPrevouts(transaction: TransactionExtended): Promise<TransactionExtended> {
|
||||||
|
for (const vin of transaction.vin) {
|
||||||
|
if (vin.prevout) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const innerTx = await this.$getRawTransaction(vin.txid, false);
|
||||||
|
vin.prevout = innerTx.vout[vin.vout];
|
||||||
|
this.addInnerScriptsToVin(vin);
|
||||||
|
}
|
||||||
|
return transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected $returnCoinbaseTransaction(): Promise<IEsploraApi.Transaction> {
|
||||||
|
return this.bitcoindClient.getBlock('000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f', 2)
|
||||||
|
.then((block: IBitcoinApi.Block) => {
|
||||||
|
return this.$convertTransaction(Object.assign(block.tx[0], {
|
||||||
|
confirmations: blocks.getCurrentBlockHeight() + 1,
|
||||||
|
blocktime: 1231006505 }), false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
|
if (transaction.vin[0].is_coinbase) {
|
||||||
|
transaction.fee = 0;
|
||||||
|
return transaction;
|
||||||
|
}
|
||||||
|
let totalIn = 0;
|
||||||
|
for (const vin of transaction.vin) {
|
||||||
|
const innerTx = await this.$getRawTransaction(vin.txid, !addPrevout);
|
||||||
|
if (addPrevout) {
|
||||||
|
vin.prevout = innerTx.vout[vin.vout];
|
||||||
|
this.addInnerScriptsToVin(vin);
|
||||||
|
}
|
||||||
|
totalIn += innerTx.vout[vin.vout].value;
|
||||||
|
}
|
||||||
|
const totalOut = transaction.vout.reduce((p, output) => p + output.value, 0);
|
||||||
|
transaction.fee = parseFloat((totalIn - totalOut).toFixed(8));
|
||||||
|
return transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
private convertScriptSigAsm(str: string): string {
|
||||||
|
const a = str.split(' ');
|
||||||
|
const b: string[] = [];
|
||||||
|
a.forEach((chunk) => {
|
||||||
|
if (chunk.substr(0, 3) === 'OP_') {
|
||||||
|
chunk = chunk.replace(/^OP_(\d+)/, 'OP_PUSHNUM_$1');
|
||||||
|
chunk = chunk.replace('OP_CHECKSEQUENCEVERIFY', 'OP_CSV');
|
||||||
|
b.push(chunk);
|
||||||
|
} else {
|
||||||
|
chunk = chunk.replace('[ALL]', '01');
|
||||||
|
if (chunk === '0') {
|
||||||
|
b.push('OP_0');
|
||||||
|
} else {
|
||||||
|
b.push('OP_PUSHBYTES_' + Math.round(chunk.length / 2) + ' ' + chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return b.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
private addInnerScriptsToVin(vin: IEsploraApi.Vin): void {
|
||||||
|
if (!vin.prevout) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vin.prevout.scriptpubkey_type === 'p2sh') {
|
||||||
|
const redeemScript = vin.scriptsig_asm.split(' ').reverse()[0];
|
||||||
|
vin.inner_redeemscript_asm = this.convertScriptSigAsm(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')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BitcoinApi;
|
||||||
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,110 +0,0 @@
|
|||||||
const config = require('../../../mempool-config.json');
|
|
||||||
import * as bitcoin from 'bitcoin';
|
|
||||||
import { ITransaction, IMempoolInfo, IBlock } from '../../interfaces';
|
|
||||||
import { AbstractBitcoinApi } from './bitcoin-api-abstract-factory';
|
|
||||||
|
|
||||||
class BitcoindApi implements AbstractBitcoinApi {
|
|
||||||
client: any;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.client = new bitcoin.Client({
|
|
||||||
host: config.BITCOIN_NODE_HOST,
|
|
||||||
port: config.BITCOIN_NODE_PORT,
|
|
||||||
user: config.BITCOIN_NODE_USER,
|
|
||||||
pass: config.BITCOIN_NODE_PASS,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getMempoolInfo(): Promise<IMempoolInfo> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.client.getMempoolInfo((err: Error, mempoolInfo: any) => {
|
|
||||||
if (err) {
|
|
||||||
return reject(err);
|
|
||||||
}
|
|
||||||
resolve(mempoolInfo);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getRawMempool(): Promise<ITransaction['txid'][]> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.client.getRawMemPool((err: Error, transactions: ITransaction['txid'][]) => {
|
|
||||||
if (err) {
|
|
||||||
return reject(err);
|
|
||||||
}
|
|
||||||
resolve(transactions);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getRawTransaction(txId: string): Promise<ITransaction> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.client.getRawTransaction(txId, true, (err: Error, txData: ITransaction) => {
|
|
||||||
if (err) {
|
|
||||||
return reject(err);
|
|
||||||
}
|
|
||||||
resolve(txData);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getBlockCount(): Promise<number> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.client.getBlockCount((err: Error, response: number) => {
|
|
||||||
if (err) {
|
|
||||||
return reject(err);
|
|
||||||
}
|
|
||||||
resolve(response);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getBlockAndTransactions(hash: string, verbosity: 1 | 2 = 1): Promise<IBlock> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.client.getBlock(hash, verbosity, (err: Error, block: IBlock) => {
|
|
||||||
if (err) {
|
|
||||||
return reject(err);
|
|
||||||
}
|
|
||||||
resolve(block);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getBlockHash(height: number): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.client.getBlockHash(height, (err: Error, response: string) => {
|
|
||||||
if (err) {
|
|
||||||
return reject(err);
|
|
||||||
}
|
|
||||||
resolve(response);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getBlock(hash: string): Promise<IBlock> {
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
getBlocks(): Promise<string> {
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
getBlocksFromHeight(height: number): Promise<string> {
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
getBlockTransactions(hash: string): Promise<IBlock> {
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
getBlockTransactionsFromIndex(hash: string, index: number): Promise<IBlock> {
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
getAddress(address: string): Promise<IBlock> {
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
getAddressTransactions(address: string): Promise<IBlock> {
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
getAddressTransactionsFromLastSeenTxid(address: string, lastSeenTxid: string): Promise<IBlock> {
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default BitcoindApi;
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
const config = require('../../../mempool-config.json');
|
|
||||||
import { ITransaction, IMempoolInfo, IBlock } from '../../interfaces';
|
|
||||||
import { AbstractBitcoinApi } from './bitcoin-api-abstract-factory';
|
|
||||||
import * as request from 'request';
|
|
||||||
|
|
||||||
class ElectrsApi implements AbstractBitcoinApi {
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
}
|
|
||||||
|
|
||||||
getMempoolInfo(): Promise<IMempoolInfo> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
request(config.ELECTRS_API_URL + '/mempool', { json: true, timeout: 10000 }, (err, res, response) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
} else if (res.statusCode !== 200) {
|
|
||||||
reject(response);
|
|
||||||
} else {
|
|
||||||
resolve({
|
|
||||||
size: response.count,
|
|
||||||
bytes: response.vsize,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getRawMempool(): Promise<ITransaction['txid'][]> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
request(config.ELECTRS_API_URL + '/mempool/txids', { json: true, timeout: 10000 }, (err, res, response) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
} else if (res.statusCode !== 200) {
|
|
||||||
reject(response);
|
|
||||||
} else {
|
|
||||||
resolve(response);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getRawTransaction(txId: string): Promise<ITransaction> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
request(config.ELECTRS_API_URL + '/tx/' + txId, { json: true, timeout: 10000 }, (err, res, response) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
} else if (res.statusCode !== 200) {
|
|
||||||
reject(response);
|
|
||||||
} else {
|
|
||||||
response.vsize = Math.round(response.weight / 4);
|
|
||||||
response.fee = response.fee / 100000000;
|
|
||||||
response.blockhash = response.status.block_hash;
|
|
||||||
resolve(response);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getBlockCount(): Promise<number> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
request(config.ELECTRS_API_URL + '/blocks/tip/height', { json: true, timeout: 10000 }, (err, res, response) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
} else if (res.statusCode !== 200) {
|
|
||||||
reject(response);
|
|
||||||
} else {
|
|
||||||
resolve(response);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getBlockAndTransactions(hash: string): Promise<IBlock> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
request(config.ELECTRS_API_URL + '/block/' + hash, { json: true, timeout: 10000 }, (err, res, response) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
} else if (res.statusCode !== 200) {
|
|
||||||
reject(response);
|
|
||||||
} else {
|
|
||||||
request(config.ELECTRS_API_URL + '/block/' + hash + '/txids', { json: true, timeout: 10000 }, (err2, res2, response2) => {
|
|
||||||
if (err2) {
|
|
||||||
reject(err2);
|
|
||||||
} else if (res.statusCode !== 200) {
|
|
||||||
reject(response);
|
|
||||||
} else {
|
|
||||||
const block = response;
|
|
||||||
block.hash = hash;
|
|
||||||
block.nTx = block.tx_count;
|
|
||||||
block.time = block.timestamp;
|
|
||||||
block.tx = response2;
|
|
||||||
|
|
||||||
resolve(block);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getBlockHash(height: number): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
request(config.ELECTRS_API_URL + '/block-height/' + height, { json: true, timeout: 10000 }, (err, res, response) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
} else if (res.statusCode !== 200) {
|
|
||||||
reject(response);
|
|
||||||
} else {
|
|
||||||
resolve(response);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getBlocks(): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
request(config.ELECTRS_API_URL + '/blocks', { json: true, timeout: 10000 }, (err, res, response) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
} else if (res.statusCode !== 200) {
|
|
||||||
reject(response);
|
|
||||||
} else {
|
|
||||||
resolve(response);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getBlocksFromHeight(height: number): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
request(config.ELECTRS_API_URL + '/blocks/' + height, { json: true, timeout: 10000 }, (err, res, response) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
} else if (res.statusCode !== 200) {
|
|
||||||
reject(response);
|
|
||||||
} else {
|
|
||||||
resolve(response);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getBlock(hash: string): Promise<IBlock> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
request(config.ELECTRS_API_URL + '/block/' + hash, { json: true, timeout: 10000 }, (err, res, response) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
} else if (res.statusCode !== 200) {
|
|
||||||
reject(response);
|
|
||||||
} else {
|
|
||||||
resolve(response);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getBlockTransactions(hash: string): Promise<IBlock> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
request(config.ELECTRS_API_URL + '/block/' + hash + '/txs', { json: true, timeout: 10000 }, (err, res, response) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
} else if (res.statusCode !== 200) {
|
|
||||||
reject(response);
|
|
||||||
} else {
|
|
||||||
resolve(response);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getBlockTransactionsFromIndex(hash: string, index: number): Promise<IBlock> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
request(config.ELECTRS_API_URL + '/block/' + hash + '/txs/' + index, { json: true, timeout: 10000 }, (err, res, response) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
} else if (res.statusCode !== 200) {
|
|
||||||
reject(response);
|
|
||||||
} else {
|
|
||||||
resolve(response);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getAddress(address: string): Promise<IBlock> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
request(config.ELECTRS_API_URL + '/address/' + address, { json: true, timeout: 10000 }, (err, res, response) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
} else if (res.statusCode !== 200) {
|
|
||||||
reject(response);
|
|
||||||
} else {
|
|
||||||
resolve(response);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getAddressTransactions(address: string): Promise<IBlock> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
request(config.ELECTRS_API_URL + '/address/' + address + '/txs', { json: true, timeout: 10000 }, (err, res, response) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
} else if (res.statusCode !== 200) {
|
|
||||||
reject(response);
|
|
||||||
} else {
|
|
||||||
resolve(response);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getAddressTransactionsFromLastSeenTxid(address: string, lastSeenTxid: string): Promise<IBlock> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
request(config.ELECTRS_API_URL + '/address/' + address + '/txs/chain/' + lastSeenTxid,
|
|
||||||
{ json: true, timeout: 10000 }, (err, res, response) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
} else if (res.statusCode !== 200) {
|
|
||||||
reject(response);
|
|
||||||
} else {
|
|
||||||
resolve(response);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ElectrsApi;
|
|
||||||
12
backend/src/api/bitcoin/electrum-api.interface.ts
Normal file
12
backend/src/api/bitcoin/electrum-api.interface.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export namespace IElectrumApi {
|
||||||
|
export interface ScriptHashBalance {
|
||||||
|
confirmed: number;
|
||||||
|
unconfirmed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScriptHashHistory {
|
||||||
|
height: number;
|
||||||
|
tx_hash: string;
|
||||||
|
fee?: number;
|
||||||
|
}
|
||||||
|
}
|
||||||
159
backend/src/api/bitcoin/electrum-api.ts
Normal file
159
backend/src/api/bitcoin/electrum-api.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import config from '../../config';
|
||||||
|
import { AbstractBitcoinApi } from './bitcoin-api-abstract-factory';
|
||||||
|
import { IEsploraApi } from './esplora-api.interface';
|
||||||
|
import { IElectrumApi } from './electrum-api.interface';
|
||||||
|
import BitcoinApi from './bitcoin-api';
|
||||||
|
import logger from '../../logger';
|
||||||
|
import * as ElectrumClient from '@mempool/electrum-client';
|
||||||
|
import * as sha256 from 'crypto-js/sha256';
|
||||||
|
import * as hexEnc from 'crypto-js/enc-hex';
|
||||||
|
import loadingIndicators from '../loading-indicators';
|
||||||
|
import memoryCache from '../memory-cache';
|
||||||
|
|
||||||
|
class BitcoindElectrsApi extends BitcoinApi implements AbstractBitcoinApi {
|
||||||
|
private electrumClient: any;
|
||||||
|
|
||||||
|
constructor(bitcoinClient: any) {
|
||||||
|
super(bitcoinClient);
|
||||||
|
|
||||||
|
const electrumConfig = { client: 'mempool-v2', version: '1.4' };
|
||||||
|
const electrumPersistencePolicy = { retryPeriod: 10000, maxRetry: 1000, callback: null };
|
||||||
|
|
||||||
|
const electrumCallbacks = {
|
||||||
|
onConnect: (client, versionInfo) => { logger.info(`Connected to Electrum Server at ${config.ELECTRUM.HOST}:${config.ELECTRUM.PORT} (${JSON.stringify(versionInfo)})`); },
|
||||||
|
onClose: (client) => { logger.info(`Disconnected from Electrum Server at ${config.ELECTRUM.HOST}:${config.ELECTRUM.PORT}`); },
|
||||||
|
onError: (err) => { logger.err(`Electrum error: ${JSON.stringify(err)}`); },
|
||||||
|
onLog: (str) => { logger.debug(str); },
|
||||||
|
};
|
||||||
|
|
||||||
|
this.electrumClient = new ElectrumClient(
|
||||||
|
config.ELECTRUM.PORT,
|
||||||
|
config.ELECTRUM.HOST,
|
||||||
|
config.ELECTRUM.TLS_ENABLED ? 'tls' : 'tcp',
|
||||||
|
null,
|
||||||
|
electrumCallbacks
|
||||||
|
);
|
||||||
|
|
||||||
|
this.electrumClient.initElectrum(electrumConfig, electrumPersistencePolicy)
|
||||||
|
.then(() => {})
|
||||||
|
.catch((err) => {
|
||||||
|
logger.err(`Error connecting to Electrum Server at ${config.ELECTRUM.HOST}:${config.ELECTRUM.PORT}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async $getAddress(address: string): Promise<IEsploraApi.Address> {
|
||||||
|
const addressInfo = await this.bitcoindClient.validateAddress(address);
|
||||||
|
if (!addressInfo || !addressInfo.isvalid) {
|
||||||
|
return ({
|
||||||
|
'address': address,
|
||||||
|
'chain_stats': {
|
||||||
|
'funded_txo_count': 0,
|
||||||
|
'funded_txo_sum': 0,
|
||||||
|
'spent_txo_count': 0,
|
||||||
|
'spent_txo_sum': 0,
|
||||||
|
'tx_count': 0
|
||||||
|
},
|
||||||
|
'mempool_stats': {
|
||||||
|
'funded_txo_count': 0,
|
||||||
|
'funded_txo_sum': 0,
|
||||||
|
'spent_txo_count': 0,
|
||||||
|
'spent_txo_sum': 0,
|
||||||
|
'tx_count': 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const balance = await this.$getScriptHashBalance(addressInfo.scriptPubKey);
|
||||||
|
const history = await this.$getScriptHashHistory(addressInfo.scriptPubKey);
|
||||||
|
|
||||||
|
const unconfirmed = history.filter((h) => h.fee).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
'address': addressInfo.address,
|
||||||
|
'chain_stats': {
|
||||||
|
'funded_txo_count': 0,
|
||||||
|
'funded_txo_sum': balance.confirmed ? balance.confirmed : 0,
|
||||||
|
'spent_txo_count': 0,
|
||||||
|
'spent_txo_sum': balance.confirmed < 0 ? balance.confirmed : 0,
|
||||||
|
'tx_count': history.length - unconfirmed,
|
||||||
|
},
|
||||||
|
'mempool_stats': {
|
||||||
|
'funded_txo_count': 0,
|
||||||
|
'funded_txo_sum': balance.unconfirmed > 0 ? balance.unconfirmed : 0,
|
||||||
|
'spent_txo_count': 0,
|
||||||
|
'spent_txo_sum': balance.unconfirmed < 0 ? -balance.unconfirmed : 0,
|
||||||
|
'tx_count': unconfirmed,
|
||||||
|
},
|
||||||
|
'electrum': true,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
if (e === 'failed to get confirmed status') {
|
||||||
|
e = 'The number of transactions on this address exceeds the Electrum server limit';
|
||||||
|
}
|
||||||
|
throw new Error(typeof e === 'string' ? e : 'Error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async $getAddressTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]> {
|
||||||
|
const addressInfo = await this.bitcoindClient.validateAddress(address);
|
||||||
|
if (!addressInfo || !addressInfo.isvalid) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loadingIndicators.setProgress('address-' + address, 0);
|
||||||
|
|
||||||
|
const transactions: IEsploraApi.Transaction[] = [];
|
||||||
|
const history = await this.$getScriptHashHistory(addressInfo.scriptPubKey);
|
||||||
|
history.sort((a, b) => (b.height || 9999999) - (a.height || 9999999));
|
||||||
|
|
||||||
|
let startingIndex = 0;
|
||||||
|
if (lastSeenTxId) {
|
||||||
|
const pos = history.findIndex((historicalTx) => historicalTx.tx_hash === lastSeenTxId);
|
||||||
|
if (pos) {
|
||||||
|
startingIndex = pos + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const endIndex = Math.min(startingIndex + 10, history.length);
|
||||||
|
|
||||||
|
for (let i = startingIndex; i < endIndex; i++) {
|
||||||
|
const tx = await this.$getRawTransaction(history[i].tx_hash, false, true);
|
||||||
|
transactions.push(tx);
|
||||||
|
loadingIndicators.setProgress('address-' + address, (i + 1) / endIndex * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
return transactions;
|
||||||
|
} catch (e) {
|
||||||
|
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(typeof e === 'string' ? e : 'Error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private $getScriptHashBalance(scriptHash: string): Promise<IElectrumApi.ScriptHashBalance> {
|
||||||
|
return this.electrumClient.blockchainScripthash_getBalance(this.encodeScriptHash(scriptHash));
|
||||||
|
}
|
||||||
|
|
||||||
|
private $getScriptHashHistory(scriptHash: string): Promise<IElectrumApi.ScriptHashHistory[]> {
|
||||||
|
const fromCache = memoryCache.get<IElectrumApi.ScriptHashHistory[]>('Scripthash_getHistory', scriptHash);
|
||||||
|
if (fromCache) {
|
||||||
|
return Promise.resolve(fromCache);
|
||||||
|
}
|
||||||
|
return this.electrumClient.blockchainScripthash_getHistory(this.encodeScriptHash(scriptHash))
|
||||||
|
.then((history) => {
|
||||||
|
memoryCache.set('Scripthash_getHistory', scriptHash, history, 2);
|
||||||
|
return history;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private encodeScriptHash(scriptPubKey: string): string {
|
||||||
|
const addrScripthash = hexEnc.stringify(sha256(hexEnc.parse(scriptPubKey)));
|
||||||
|
return addrScripthash.match(/.{2}/g).reverse().join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BitcoindElectrsApi;
|
||||||
170
backend/src/api/bitcoin/esplora-api.interface.ts
Normal file
170
backend/src/api/bitcoin/esplora-api.interface.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
export namespace IEsploraApi {
|
||||||
|
export interface Transaction {
|
||||||
|
txid: string;
|
||||||
|
version: number;
|
||||||
|
locktime: number;
|
||||||
|
size: number;
|
||||||
|
weight: number;
|
||||||
|
fee: number;
|
||||||
|
vin: Vin[];
|
||||||
|
vout: Vout[];
|
||||||
|
status: Status;
|
||||||
|
hex?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Recent {
|
||||||
|
txid: string;
|
||||||
|
fee: number;
|
||||||
|
vsize: number;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Vin {
|
||||||
|
txid: string;
|
||||||
|
vout: number;
|
||||||
|
is_coinbase: boolean;
|
||||||
|
scriptsig: string;
|
||||||
|
scriptsig_asm: string;
|
||||||
|
inner_redeemscript_asm?: string;
|
||||||
|
inner_witnessscript_asm?: string;
|
||||||
|
sequence: any;
|
||||||
|
witness?: string[];
|
||||||
|
prevout: Vout | null;
|
||||||
|
// Elements
|
||||||
|
is_pegin?: boolean;
|
||||||
|
issuance?: Issuance;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Issuance {
|
||||||
|
asset_id: string;
|
||||||
|
is_reissuance: string;
|
||||||
|
asset_blinding_nonce: string;
|
||||||
|
asset_entropy: string;
|
||||||
|
contract_hash: string;
|
||||||
|
assetamount?: number;
|
||||||
|
assetamountcommitment?: string;
|
||||||
|
tokenamount?: number;
|
||||||
|
tokenamountcommitment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Vout {
|
||||||
|
scriptpubkey: string;
|
||||||
|
scriptpubkey_asm: string;
|
||||||
|
scriptpubkey_type: string;
|
||||||
|
scriptpubkey_address: string;
|
||||||
|
value: number;
|
||||||
|
// Elements
|
||||||
|
valuecommitment?: number;
|
||||||
|
asset?: string;
|
||||||
|
pegout?: Pegout;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Pegout {
|
||||||
|
genesis_hash: string;
|
||||||
|
scriptpubkey: string;
|
||||||
|
scriptpubkey_asm: string;
|
||||||
|
scriptpubkey_address: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Status {
|
||||||
|
confirmed: boolean;
|
||||||
|
block_height?: number;
|
||||||
|
block_hash?: string;
|
||||||
|
block_time?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Block {
|
||||||
|
id: string;
|
||||||
|
height: number;
|
||||||
|
version: number;
|
||||||
|
timestamp: number;
|
||||||
|
bits: number;
|
||||||
|
nonce: number;
|
||||||
|
difficulty: number;
|
||||||
|
merkle_root: string;
|
||||||
|
tx_count: number;
|
||||||
|
size: number;
|
||||||
|
weight: number;
|
||||||
|
previousblockhash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Address {
|
||||||
|
address: string;
|
||||||
|
chain_stats: ChainStats;
|
||||||
|
mempool_stats: MempoolStats;
|
||||||
|
electrum?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChainStats {
|
||||||
|
funded_txo_count: number;
|
||||||
|
funded_txo_sum: number;
|
||||||
|
spent_txo_count: number;
|
||||||
|
spent_txo_sum: number;
|
||||||
|
tx_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MempoolStats {
|
||||||
|
funded_txo_count: number;
|
||||||
|
funded_txo_sum: number;
|
||||||
|
spent_txo_count: number;
|
||||||
|
spent_txo_sum: number;
|
||||||
|
tx_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Outspend {
|
||||||
|
spent: boolean;
|
||||||
|
txid: string;
|
||||||
|
vin: number;
|
||||||
|
status: Status;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Asset {
|
||||||
|
asset_id: string;
|
||||||
|
issuance_txin: IssuanceTxin;
|
||||||
|
issuance_prevout: IssuancePrevout;
|
||||||
|
reissuance_token: string;
|
||||||
|
contract_hash: string;
|
||||||
|
status: Status;
|
||||||
|
chain_stats: AssetStats;
|
||||||
|
mempool_stats: AssetStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssetExtended extends Asset {
|
||||||
|
name: string;
|
||||||
|
ticker: string;
|
||||||
|
precision: number;
|
||||||
|
entity: Entity;
|
||||||
|
version: number;
|
||||||
|
issuer_pubkey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Entity {
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IssuanceTxin {
|
||||||
|
txid: string;
|
||||||
|
vin: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IssuancePrevout {
|
||||||
|
txid: string;
|
||||||
|
vout: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssetStats {
|
||||||
|
tx_count: number;
|
||||||
|
issuance_count: number;
|
||||||
|
issued_amount: number;
|
||||||
|
burned_amount: number;
|
||||||
|
has_blinded_issuances: boolean;
|
||||||
|
reissuance_tokens: number;
|
||||||
|
burned_reissuance_tokens: number;
|
||||||
|
peg_in_count: number;
|
||||||
|
peg_in_amount: number;
|
||||||
|
peg_out_count: number;
|
||||||
|
peg_out_amount: number;
|
||||||
|
burn_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
65
backend/src/api/bitcoin/esplora-api.ts
Normal file
65
backend/src/api/bitcoin/esplora-api.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import config from '../../config';
|
||||||
|
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', this.axiosConfig)
|
||||||
|
.then((response) => response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
$getRawTransaction(txId: string): Promise<IEsploraApi.Transaction> {
|
||||||
|
return axios.get<IEsploraApi.Transaction>(config.ESPLORA.REST_API_URL + '/tx/' + txId, this.axiosConfig)
|
||||||
|
.then((response) => response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
$getBlockHeightTip(): Promise<number> {
|
||||||
|
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', this.axiosConfig)
|
||||||
|
.then((response) => response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
$getBlockHash(height: number): Promise<string> {
|
||||||
|
return axios.get<string>(config.ESPLORA.REST_API_URL + '/block-height/' + height, this.axiosConfig)
|
||||||
|
.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, this.axiosConfig)
|
||||||
|
.then((response) => response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
$getAddress(address: string): Promise<IEsploraApi.Address> {
|
||||||
|
throw new Error('Method getAddress not implemented.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$getAddressTransactions(address: string, txId?: string): Promise<IEsploraApi.Transaction[]> {
|
||||||
|
throw new Error('Method getAddressTransactions not implemented.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$getAddressPrefix(prefix: string): string[] {
|
||||||
|
throw new Error('Method not implemented.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sendRawTransaction(rawTransaction: string): Promise<string> {
|
||||||
|
throw new Error('Method not implemented.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ElectrsApi;
|
||||||
@@ -1,215 +1,148 @@
|
|||||||
const config = require('../../mempool-config.json');
|
import config from '../config';
|
||||||
import bitcoinApi from './bitcoin/bitcoin-api-factory';
|
import bitcoinApi from './bitcoin/bitcoin-api-factory';
|
||||||
import { DB } from '../database';
|
import logger from '../logger';
|
||||||
import { IBlock, ITransaction } from '../interfaces';
|
|
||||||
import memPool from './mempool';
|
import memPool from './mempool';
|
||||||
|
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 {
|
class Blocks {
|
||||||
private blocks: IBlock[] = [];
|
private blocks: BlockExtended[] = [];
|
||||||
private newBlockCallback: Function | undefined;
|
|
||||||
private currentBlockHeight = 0;
|
private currentBlockHeight = 0;
|
||||||
|
private currentDifficulty = 0;
|
||||||
|
private lastDifficultyAdjustmentTime = 0;
|
||||||
|
private previousDifficultyRetarget = 0;
|
||||||
|
private newBlockCallbacks: ((block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void)[] = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() { }
|
||||||
setInterval(this.$clearOldTransactionsAndBlocksFromDatabase.bind(this), 86400000);
|
|
||||||
}
|
|
||||||
|
|
||||||
public setNewBlockCallback(fn: Function) {
|
public getBlocks(): BlockExtended[] {
|
||||||
this.newBlockCallback = fn;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getBlocks(): IBlock[] {
|
|
||||||
return this.blocks;
|
return this.blocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
public formatBlock(block: IBlock) {
|
public setBlocks(blocks: BlockExtended[]) {
|
||||||
return {
|
this.blocks = blocks;
|
||||||
hash: block.hash,
|
|
||||||
height: block.height,
|
|
||||||
nTx: block.nTx - 1,
|
|
||||||
size: block.size,
|
|
||||||
time: block.time,
|
|
||||||
weight: block.weight,
|
|
||||||
fees: block.fees,
|
|
||||||
minFee: block.minFee,
|
|
||||||
maxFee: block.maxFee,
|
|
||||||
medianFee: block.medianFee,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateBlocks() {
|
public setNewBlockCallback(fn: (block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) => void) {
|
||||||
try {
|
this.newBlockCallbacks.push(fn);
|
||||||
const blockCount = await bitcoinApi.getBlockCount();
|
}
|
||||||
|
|
||||||
if (this.blocks.length === 0) {
|
public async $updateBlocks() {
|
||||||
this.currentBlockHeight = blockCount - config.INITIAL_BLOCK_AMOUNT;
|
const blockHeightTip = await bitcoinApi.$getBlockHeightTip();
|
||||||
|
|
||||||
|
if (this.blocks.length === 0) {
|
||||||
|
this.currentBlockHeight = blockHeightTip - config.MEMPOOL.INITIAL_BLOCKS_AMOUNT;
|
||||||
|
} else {
|
||||||
|
this.currentBlockHeight = this.blocks[this.blocks.length - 1].height;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 {
|
} else {
|
||||||
this.currentBlockHeight = this.blocks[this.blocks.length - 1].height;
|
logger.debug(`Blockchain headers (${blockchainInfo.headers}) and blocks (${blockchainInfo.blocks}) not in sync. Waiting...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (this.currentBlockHeight < blockHeightTip) {
|
||||||
|
if (this.currentBlockHeight === 0) {
|
||||||
|
this.currentBlockHeight = blockHeightTip;
|
||||||
|
} else {
|
||||||
|
this.currentBlockHeight++;
|
||||||
|
logger.debug(`New block found (#${this.currentBlockHeight})!`);
|
||||||
}
|
}
|
||||||
|
|
||||||
while (this.currentBlockHeight < blockCount) {
|
const transactions: TransactionExtended[] = [];
|
||||||
this.currentBlockHeight++;
|
|
||||||
|
|
||||||
let block: IBlock | undefined;
|
const blockHash = await bitcoinApi.$getBlockHash(this.currentBlockHeight);
|
||||||
|
const block = await bitcoinApi.$getBlock(blockHash);
|
||||||
|
const txIds: string[] = await bitcoinApi.$getTxIdsForBlock(blockHash);
|
||||||
|
|
||||||
const storedBlock = await this.$getBlockFromDatabase(this.currentBlockHeight);
|
const mempool = memPool.getMempool();
|
||||||
if (storedBlock) {
|
let transactionsFound = 0;
|
||||||
block = storedBlock;
|
|
||||||
} else {
|
|
||||||
const blockHash = await bitcoinApi.getBlockHash(this.currentBlockHeight);
|
|
||||||
block = await bitcoinApi.getBlockAndTransactions(blockHash);
|
|
||||||
|
|
||||||
const coinbase = await memPool.getRawTransaction(block.tx[0], true);
|
for (let i = 0; i < txIds.length; i++) {
|
||||||
if (coinbase && coinbase.totalOut) {
|
if (mempool[txIds[i]]) {
|
||||||
block.fees = coinbase.totalOut;
|
transactions.push(mempool[txIds[i]]);
|
||||||
}
|
transactionsFound++;
|
||||||
|
} else if (config.MEMPOOL.BACKEND === 'esplora' || memPool.isInSync() || i === 0) {
|
||||||
const mempool = memPool.getMempool();
|
logger.debug(`Fetching block tx ${i} of ${txIds.length}`);
|
||||||
let found = 0;
|
try {
|
||||||
let notFound = 0;
|
const tx = await transactionUtils.$getTransactionExtended(txIds[i]);
|
||||||
|
transactions.push(tx);
|
||||||
let transactions: ITransaction[] = [];
|
} catch (e) {
|
||||||
|
logger.debug('Error fetching block tx: ' + (e instanceof Error ? e.message : e));
|
||||||
for (let i = 1; i < block.tx.length; i++) {
|
if (i === 0) {
|
||||||
if (mempool[block.tx[i]]) {
|
throw new Error('Failed to fetch Coinbase transaction: ' + txIds[i]);
|
||||||
transactions.push(mempool[block.tx[i]]);
|
|
||||||
found++;
|
|
||||||
} else {
|
|
||||||
console.log(`Fetching block tx ${i} of ${block.tx.length}`);
|
|
||||||
const tx = await memPool.getRawTransaction(block.tx[i]);
|
|
||||||
if (tx) {
|
|
||||||
transactions.push(tx);
|
|
||||||
}
|
|
||||||
notFound++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
transactions.sort((a, b) => b.feePerVsize - a.feePerVsize);
|
|
||||||
transactions = transactions.filter((tx: ITransaction) => tx.feePerVsize);
|
|
||||||
|
|
||||||
block.minFee = transactions[transactions.length - 1] ? transactions[transactions.length - 1].feePerVsize : 0;
|
|
||||||
block.maxFee = transactions[0] ? transactions[0].feePerVsize : 0;
|
|
||||||
block.medianFee = this.median(transactions.map((tx) => tx.feePerVsize));
|
|
||||||
|
|
||||||
console.log(`New block found (#${this.currentBlockHeight})! `
|
|
||||||
+ `${found} of ${block.tx.length} found in mempool. ${notFound} not found.`);
|
|
||||||
|
|
||||||
if (this.newBlockCallback) {
|
|
||||||
this.newBlockCallback(block);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$saveBlockToDatabase(block);
|
|
||||||
this.$saveTransactionsToDatabase(block.height, transactions);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.blocks.push(block);
|
transactions.forEach((tx) => {
|
||||||
if (this.blocks.length > config.KEEP_BLOCK_AMOUNT) {
|
if (!tx.cpfpChecked) {
|
||||||
this.blocks.shift();
|
Common.setRelativesAndGetCpfpInfo(tx, mempool);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
}
|
logger.debug(`${transactionsFound} of ${txIds.length} found in mempool. ${txIds.length - transactionsFound} not found.`);
|
||||||
} catch (err) {
|
|
||||||
console.log('Error getBlockCount', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async $getBlockFromDatabase(height: number): Promise<IBlock | undefined> {
|
const blockExtended: BlockExtended = Object.assign({}, block);
|
||||||
try {
|
blockExtended.reward = transactions[0].vout.reduce((acc, curr) => acc + curr.value, 0);
|
||||||
const connection = await DB.pool.getConnection();
|
blockExtended.coinbaseTx = transactionUtils.stripCoinbaseTransaction(transactions[0]);
|
||||||
const query = `
|
transactions.shift();
|
||||||
SELECT * FROM blocks WHERE height = ?
|
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];
|
||||||
|
|
||||||
const [rows] = await connection.query<any>(query, [height]);
|
if (block.height % 2016 === 0) {
|
||||||
connection.release();
|
this.previousDifficultyRetarget = (block.difficulty - this.currentDifficulty) / this.currentDifficulty * 100;
|
||||||
|
this.lastDifficultyAdjustmentTime = block.timestamp;
|
||||||
if (rows[0]) {
|
this.currentDifficulty = block.difficulty;
|
||||||
return rows[0];
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.log('$get() block error', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async $saveBlockToDatabase(block: IBlock) {
|
|
||||||
try {
|
|
||||||
const connection = await DB.pool.getConnection();
|
|
||||||
const query = `
|
|
||||||
INSERT IGNORE INTO blocks
|
|
||||||
(height, hash, size, weight, minFee, maxFee, time, fees, nTx, medianFee)
|
|
||||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`;
|
|
||||||
|
|
||||||
const params: (any)[] = [
|
|
||||||
block.height,
|
|
||||||
block.hash,
|
|
||||||
block.size,
|
|
||||||
block.weight,
|
|
||||||
block.minFee,
|
|
||||||
block.maxFee,
|
|
||||||
block.time,
|
|
||||||
block.fees,
|
|
||||||
block.nTx - 1,
|
|
||||||
block.medianFee,
|
|
||||||
];
|
|
||||||
|
|
||||||
await connection.query(query, params);
|
|
||||||
connection.release();
|
|
||||||
} catch (e) {
|
|
||||||
console.log('$create() block error', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async $saveTransactionsToDatabase(blockheight: number, transactions: ITransaction[]) {
|
|
||||||
try {
|
|
||||||
const connection = await DB.pool.getConnection();
|
|
||||||
|
|
||||||
for (let i = 0; i < transactions.length; i++) {
|
|
||||||
const query = `
|
|
||||||
INSERT IGNORE INTO transactions
|
|
||||||
(blockheight, txid, fee, feePerVsize)
|
|
||||||
VALUES(?, ?, ?, ?)
|
|
||||||
`;
|
|
||||||
|
|
||||||
const params: (any)[] = [
|
|
||||||
blockheight,
|
|
||||||
transactions[i].txid,
|
|
||||||
transactions[i].fee,
|
|
||||||
transactions[i].feePerVsize,
|
|
||||||
];
|
|
||||||
|
|
||||||
await connection.query(query, params);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
connection.release();
|
this.blocks.push(blockExtended);
|
||||||
} catch (e) {
|
if (this.blocks.length > config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4) {
|
||||||
console.log('$create() transaction error', e);
|
this.blocks = this.blocks.slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT * 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.newBlockCallbacks.length) {
|
||||||
|
this.newBlockCallbacks.forEach((cb) => cb(blockExtended, txIds, transactions));
|
||||||
|
}
|
||||||
|
if (memPool.isInSync()) {
|
||||||
|
diskCache.$saveCacheToDisk();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async $clearOldTransactionsAndBlocksFromDatabase() {
|
public getLastDifficultyAdjustmentTime(): number {
|
||||||
try {
|
return this.lastDifficultyAdjustmentTime;
|
||||||
const connection = await DB.pool.getConnection();
|
|
||||||
let query = `DELETE FROM blocks WHERE height < ?`;
|
|
||||||
await connection.query<any>(query, [this.currentBlockHeight - config.KEEP_BLOCK_AMOUNT]);
|
|
||||||
query = `DELETE FROM transactions WHERE blockheight < ?`;
|
|
||||||
await connection.query<any>(query, [this.currentBlockHeight - config.KEEP_BLOCK_AMOUNT]);
|
|
||||||
connection.release();
|
|
||||||
} catch (e) {
|
|
||||||
console.log('$clearOldTransactionsFromDatabase() error', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private median(numbers: number[]) {
|
public getPreviousDifficultyRetarget(): number {
|
||||||
if (!numbers.length) { return 0; }
|
return this.previousDifficultyRetarget;
|
||||||
let medianNr = 0;
|
}
|
||||||
const numsLen = numbers.length;
|
|
||||||
numbers.sort();
|
public getCurrentBlockHeight(): number {
|
||||||
if (numsLen % 2 === 0) {
|
return this.currentBlockHeight;
|
||||||
medianNr = (numbers[numsLen / 2 - 1] + numbers[numsLen / 2]) / 2;
|
|
||||||
} else {
|
|
||||||
medianNr = numbers[(numsLen - 1) / 2];
|
|
||||||
}
|
|
||||||
return medianNr;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
150
backend/src/api/common.ts
Normal file
150
backend/src/api/common.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { CpfpInfo, TransactionExtended, TransactionStripped } from '../mempool.interfaces';
|
||||||
|
import config from '../config';
|
||||||
|
export class Common {
|
||||||
|
static nativeAssetId = '6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d';
|
||||||
|
|
||||||
|
static median(numbers: number[]) {
|
||||||
|
let medianNr = 0;
|
||||||
|
const numsLen = numbers.length;
|
||||||
|
if (numsLen % 2 === 0) {
|
||||||
|
medianNr = (numbers[numsLen / 2 - 1] + numbers[numsLen / 2]) / 2;
|
||||||
|
} else {
|
||||||
|
medianNr = numbers[(numsLen - 1) / 2];
|
||||||
|
}
|
||||||
|
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].effectiveFeePerVsize];
|
||||||
|
const chunk = 1 / (rangeLength - 1);
|
||||||
|
let itemsToAdd = rangeLength - 2;
|
||||||
|
|
||||||
|
while (itemsToAdd > 0) {
|
||||||
|
arr.push(transactions[Math.floor(transactions.length * chunk * itemsToAdd)].effectiveFeePerVsize);
|
||||||
|
itemsToAdd--;
|
||||||
|
}
|
||||||
|
|
||||||
|
arr.push(transactions[0].effectiveFeePerVsize);
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
static findRbfTransactions(added: TransactionExtended[], deleted: TransactionExtended[]): { [txid: string]: TransactionExtended } {
|
||||||
|
const matches: { [txid: string]: TransactionExtended } = {};
|
||||||
|
deleted
|
||||||
|
// The replaced tx must have at least one input with nSequence < maxint-1 (That’s the opt-in)
|
||||||
|
.filter((tx) => tx.vin.some((vin) => vin.sequence < 0xfffffffe))
|
||||||
|
.forEach((deletedTx) => {
|
||||||
|
const foundMatches = added.find((addedTx) => {
|
||||||
|
// The new tx must, absolutely speaking, pay at least as much fee as the replaced tx.
|
||||||
|
return addedTx.fee > deletedTx.fee
|
||||||
|
// The new transaction must pay more fee per kB than the replaced tx.
|
||||||
|
&& addedTx.feePerVsize > deletedTx.feePerVsize
|
||||||
|
// Spends one or more of the same inputs
|
||||||
|
&& deletedTx.vin.some((deletedVin) =>
|
||||||
|
addedTx.vin.some((vin) => vin.txid === deletedVin.txid));
|
||||||
|
});
|
||||||
|
if (foundMatches) {
|
||||||
|
matches[deletedTx.txid] = foundMatches;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
static stripTransaction(tx: TransactionExtended): TransactionStripped {
|
||||||
|
return {
|
||||||
|
txid: tx.txid,
|
||||||
|
fee: tx.fee,
|
||||||
|
vsize: tx.weight / 4,
|
||||||
|
value: tx.vout.reduce((acc, vout) => acc + (vout.value ? vout.value : 0), 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve();
|
||||||
|
}, 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(config.MEMPOOL.NETWORK === 'liquid' ? 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,102 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
const fsPromises = fs.promises;
|
||||||
|
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 {
|
class DiskCache {
|
||||||
static FILE_NAME = './cache.json';
|
private static FILE_NAME = config.MEMPOOL.CACHE_DIR + '/cache.json';
|
||||||
|
private static FILE_NAMES = config.MEMPOOL.CACHE_DIR + '/cache{number}.json';
|
||||||
|
private static CHUNK_FILES = 25;
|
||||||
|
private isWritingCache = false;
|
||||||
|
|
||||||
constructor() { }
|
constructor() { }
|
||||||
|
|
||||||
saveData(dataBlob: string) {
|
async $saveCacheToDisk(): Promise<void> {
|
||||||
fs.writeFileSync(DiskCache.FILE_NAME, dataBlob, 'utf8');
|
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)...');
|
||||||
|
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: {},
|
||||||
|
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 instanceof Error ? e.message : e));
|
||||||
|
this.isWritingCache = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadData(): string {
|
loadMempoolCache() {
|
||||||
return fs.readFileSync(DiskCache.FILE_NAME, 'utf8');
|
if (!fs.existsSync(DiskCache.FILE_NAME)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('Failed to parse mempoool and blocks cache. Skipping.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,47 +1,50 @@
|
|||||||
import projectedBlocks from './projected-blocks';
|
import config from '../config';
|
||||||
import { DB } from '../database';
|
import { MempoolBlock } from '../mempool.interfaces';
|
||||||
|
import mempool from './mempool';
|
||||||
|
import projectedBlocks from './mempool-blocks';
|
||||||
|
|
||||||
class FeeApi {
|
class FeeApi {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
|
|
||||||
|
defaultFee = config.MEMPOOL.NETWORK === 'liquid' ? 0.1 : 1;
|
||||||
|
|
||||||
public getRecommendedFee() {
|
public getRecommendedFee() {
|
||||||
const pBlocks = projectedBlocks.getProjectedBlocks();
|
const pBlocks = projectedBlocks.getMempoolBlocks();
|
||||||
|
const mPool = mempool.getMempoolInfo();
|
||||||
|
const minimumFee = Math.ceil(mPool.mempoolminfee * 100000);
|
||||||
|
|
||||||
if (!pBlocks.length) {
|
if (!pBlocks.length) {
|
||||||
return {
|
return {
|
||||||
'fastestFee': 0,
|
'fastestFee': this.defaultFee,
|
||||||
'halfHourFee': 0,
|
'halfHourFee': this.defaultFee,
|
||||||
'hourFee': 0,
|
'hourFee': this.defaultFee,
|
||||||
|
'minimumFee': minimumFee,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
let firstMedianFee = Math.ceil(pBlocks[0].medianFee);
|
|
||||||
|
|
||||||
if (pBlocks.length === 1 && pBlocks[0].blockWeight <= 2000000) {
|
const firstMedianFee = this.optimizeMedianFee(pBlocks[0], pBlocks[1]);
|
||||||
firstMedianFee = 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;
|
||||||
|
|
||||||
const secondMedianFee = pBlocks[1] ? Math.ceil(pBlocks[1].medianFee) : firstMedianFee;
|
|
||||||
const thirdMedianFee = pBlocks[2] ? Math.ceil(pBlocks[2].medianFee) : secondMedianFee;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'fastestFee': firstMedianFee,
|
'fastestFee': firstMedianFee,
|
||||||
'halfHourFee': secondMedianFee,
|
'halfHourFee': secondMedianFee,
|
||||||
'hourFee': thirdMedianFee,
|
'hourFee': thirdMedianFee,
|
||||||
|
'minimumFee': minimumFee,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async $getTransactionsForBlock(blockHeight: number): Promise<any[]> {
|
private optimizeMedianFee(pBlock: MempoolBlock, nextBlock: MempoolBlock | undefined, previousFee?: number): number {
|
||||||
try {
|
const useFee = previousFee ? (pBlock.medianFee + previousFee) / 2 : pBlock.medianFee;
|
||||||
const connection = await DB.pool.getConnection();
|
if (pBlock.blockVSize <= 500000) {
|
||||||
const query = `SELECT feePerVsize AS fpv FROM transactions WHERE blockheight = ? ORDER BY feePerVsize ASC`;
|
return this.defaultFee;
|
||||||
const [rows] = await connection.query<any>(query, [blockHeight]);
|
|
||||||
connection.release();
|
|
||||||
return rows;
|
|
||||||
} catch (e) {
|
|
||||||
console.log('$getTransactionsForBlock() error', e);
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
if (pBlock.blockVSize <= 950000 && !nextBlock) {
|
||||||
|
const multiplier = (pBlock.blockVSize - 500000) / 500000;
|
||||||
|
return Math.max(Math.round(useFee * multiplier), this.defaultFee);
|
||||||
|
}
|
||||||
|
return Math.ceil(useFee);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new FeeApi();
|
export default new FeeApi();
|
||||||
|
|||||||
@@ -1,30 +1,43 @@
|
|||||||
import * as request from 'request';
|
import logger from '../logger';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { IConversionRates } from '../mempool.interfaces';
|
||||||
|
import config from '../config';
|
||||||
|
|
||||||
class FiatConversion {
|
class FiatConversion {
|
||||||
private tickers = {
|
private conversionRates: IConversionRates = {
|
||||||
'BTCUSD': {
|
'USD': 0
|
||||||
'USD': 4110.78
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
private ratesChangedCallback: ((rates: IConversionRates) => void) | undefined;
|
||||||
|
|
||||||
constructor() { }
|
constructor() { }
|
||||||
|
|
||||||
|
public setProgressChangedCallback(fn: (rates: IConversionRates) => void) {
|
||||||
|
this.ratesChangedCallback = fn;
|
||||||
|
}
|
||||||
|
|
||||||
public startService() {
|
public startService() {
|
||||||
setInterval(this.updateCurrency.bind(this), 1000 * 60 * 60);
|
logger.info('Starting currency rates service');
|
||||||
|
setInterval(this.updateCurrency.bind(this), 1000 * config.MEMPOOL.PRICE_FEED_UPDATE_INTERVAL);
|
||||||
this.updateCurrency();
|
this.updateCurrency();
|
||||||
}
|
}
|
||||||
|
|
||||||
public getTickers() {
|
public getConversionRates() {
|
||||||
return this.tickers;
|
return this.conversionRates;
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateCurrency() {
|
private async updateCurrency(): Promise<void> {
|
||||||
request('https://api.opennode.co/v1/rates', { json: true }, (err, res, body) => {
|
try {
|
||||||
if (err) { return console.log(err); }
|
const response = await axios.get('https://price.bisq.wiz.biz/getAllMarketPrices', { timeout: 10000 });
|
||||||
if (body && body.data) {
|
const usd = response.data.data.find((item: any) => item.currencyCode === 'USD');
|
||||||
this.tickers = body.data;
|
this.conversionRates = {
|
||||||
|
'USD': usd.price,
|
||||||
|
};
|
||||||
|
if (this.ratesChangedCallback) {
|
||||||
|
this.ratesChangedCallback(this.conversionRates);
|
||||||
}
|
}
|
||||||
});
|
} catch (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 latestBlock = await this.$getLatestBlockFromDatabase();
|
||||||
|
for (let height = latestBlock.block + 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, block.time, block.hash);
|
||||||
|
}
|
||||||
|
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 $getLatestBlockFromDatabase(): Promise<any> {
|
||||||
|
const connection = await DB.pool.getConnection();
|
||||||
|
const query = `SELECT block, datetime, block_hash FROM last_elements_block`;
|
||||||
|
const [rows] = await connection.query<any>(query);
|
||||||
|
connection.release();
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async $saveLatestBlockToDatabase(blockHeight: number, datetime: number, blockHash: string) {
|
||||||
|
const connection = await DB.pool.getConnection();
|
||||||
|
const query = `UPDATE last_elements_block SET block = ?, datetime = ?, block_hash = ?`;
|
||||||
|
await connection.query<any>(query, [blockHeight, datetime, blockHash]);
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new ElementsParser();
|
||||||
32
backend/src/api/loading-indicators.ts
Normal file
32
backend/src/api/loading-indicators.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { ILoadingIndicators } from '../mempool.interfaces';
|
||||||
|
|
||||||
|
class LoadingIndicators {
|
||||||
|
private loadingIndicators: ILoadingIndicators = {
|
||||||
|
'mempool': 0,
|
||||||
|
};
|
||||||
|
private progressChangedCallback: ((loadingIndicators: ILoadingIndicators) => void) | undefined;
|
||||||
|
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
public setProgressChangedCallback(fn: (loadingIndicators: ILoadingIndicators) => void) {
|
||||||
|
this.progressChangedCallback = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setProgress(name: string, progressPercent: number) {
|
||||||
|
const newProgress = Math.round(progressPercent);
|
||||||
|
if (newProgress >= 100) {
|
||||||
|
delete this.loadingIndicators[name];
|
||||||
|
} else {
|
||||||
|
this.loadingIndicators[name] = newProgress;
|
||||||
|
}
|
||||||
|
if (this.progressChangedCallback) {
|
||||||
|
this.progressChangedCallback(this.loadingIndicators);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getLoadingIndicators() {
|
||||||
|
return this.loadingIndicators;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new LoadingIndicators();
|
||||||
38
backend/src/api/memory-cache.ts
Normal file
38
backend/src/api/memory-cache.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
interface ICache {
|
||||||
|
type: string;
|
||||||
|
id: string;
|
||||||
|
expires: Date;
|
||||||
|
data: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemoryCache {
|
||||||
|
private cache: ICache[] = [];
|
||||||
|
constructor() {
|
||||||
|
setInterval(this.cleanup.bind(this), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
public set(type: string, id: string, data: any, secondsExpiry: number) {
|
||||||
|
const expiry = new Date();
|
||||||
|
expiry.setSeconds(expiry.getSeconds() + secondsExpiry);
|
||||||
|
this.cache.push({
|
||||||
|
type: type,
|
||||||
|
id: id,
|
||||||
|
data: data,
|
||||||
|
expires: expiry,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public get<T>(type: string, id: string): T | null {
|
||||||
|
const found = this.cache.find((cache) => cache.type === type && cache.id === id);
|
||||||
|
if (found) {
|
||||||
|
return found.data;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanup() {
|
||||||
|
this.cache = this.cache.filter((cache) => cache.expires < (new Date()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new MemoryCache();
|
||||||
119
backend/src/api/mempool-blocks.ts
Normal file
119
backend/src/api/mempool-blocks.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import logger from '../logger';
|
||||||
|
import { MempoolBlock, TransactionExtended, MempoolBlockWithTransactions } from '../mempool.interfaces';
|
||||||
|
import { Common } from './common';
|
||||||
|
import config from '../config';
|
||||||
|
|
||||||
|
class MempoolBlocks {
|
||||||
|
private mempoolBlocks: MempoolBlockWithTransactions[] = [];
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
public getMempoolBlocks(): MempoolBlock[] {
|
||||||
|
return this.mempoolBlocks.map((block) => {
|
||||||
|
return {
|
||||||
|
blockSize: block.blockSize,
|
||||||
|
blockVSize: block.blockVSize,
|
||||||
|
nTx: block.nTx,
|
||||||
|
totalFees: block.totalFees,
|
||||||
|
medianFee: block.medianFee,
|
||||||
|
feeRange: block.feeRange,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public getMempoolBlocksWithTransactions(): MempoolBlockWithTransactions[] {
|
||||||
|
return this.mempoolBlocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public updateMempoolBlocks(memPool: { [txid: string]: TransactionExtended }): void {
|
||||||
|
const latestMempool = memPool;
|
||||||
|
const memPoolArray: TransactionExtended[] = [];
|
||||||
|
for (const i in latestMempool) {
|
||||||
|
if (latestMempool.hasOwnProperty(i)) {
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 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 blockWeight = 0;
|
||||||
|
let blockSize = 0;
|
||||||
|
let transactions: TransactionExtended[] = [];
|
||||||
|
transactionsSorted.forEach((tx) => {
|
||||||
|
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, blockWeight, mempoolBlocks.length));
|
||||||
|
blockWeight = tx.weight;
|
||||||
|
blockSize = tx.size;
|
||||||
|
transactions = [tx];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (transactions.length) {
|
||||||
|
mempoolBlocks.push(this.dataToMempoolBlocks(transactions, blockSize, blockWeight, mempoolBlocks.length));
|
||||||
|
}
|
||||||
|
return mempoolBlocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
private dataToMempoolBlocks(transactions: TransactionExtended[],
|
||||||
|
blockSize: number, blockWeight: number, blocksIndex: number): MempoolBlockWithTransactions {
|
||||||
|
let rangeLength = 4;
|
||||||
|
if (blocksIndex === 0) {
|
||||||
|
rangeLength = 8;
|
||||||
|
}
|
||||||
|
if (transactions.length > 4000) {
|
||||||
|
rangeLength = 6;
|
||||||
|
} else if (transactions.length > 10000) {
|
||||||
|
rangeLength = 8;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
blockSize: blockSize,
|
||||||
|
blockVSize: blockWeight / 4,
|
||||||
|
nTx: transactions.length,
|
||||||
|
totalFees: transactions.reduce((acc, cur) => acc + cur.fee, 0),
|
||||||
|
medianFee: Common.percentile(transactions.map((tx) => tx.effectiveFeePerVsize), config.MEMPOOL.RECOMMENDED_FEE_PERCENTILE),
|
||||||
|
feeRange: Common.getFeesInRange(transactions, rangeLength),
|
||||||
|
transactionIds: transactions.map((tx) => tx.txid),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new MempoolBlocks();
|
||||||
@@ -1,35 +1,71 @@
|
|||||||
const config = require('../../mempool-config.json');
|
import config from '../config';
|
||||||
import bitcoinApi from './bitcoin/bitcoin-api-factory';
|
import bitcoinApi from './bitcoin/bitcoin-api-factory';
|
||||||
import { ITransaction, IMempoolInfo, IMempool } from '../interfaces';
|
import { TransactionExtended, VbytesPerSecond } from '../mempool.interfaces';
|
||||||
|
import logger from '../logger';
|
||||||
|
import { Common } from './common';
|
||||||
|
import transactionUtils from './transaction-utils';
|
||||||
|
import { IBitcoinApi } from './bitcoin/bitcoin-api.interface';
|
||||||
|
import loadingIndicators from './loading-indicators';
|
||||||
|
import bitcoinClient from './bitcoin/bitcoin-client';
|
||||||
|
import bitcoinSecondClient from './bitcoin/bitcoin-second-client';
|
||||||
|
|
||||||
class Mempool {
|
class Mempool {
|
||||||
private mempool: IMempool = {};
|
private static WEBSOCKET_REFRESH_RATE_MS = 10000;
|
||||||
private mempoolInfo: IMempoolInfo | undefined;
|
private static LAZY_DELETE_AFTER_SECONDS = 30;
|
||||||
private mempoolChangedCallback: Function | undefined;
|
private inSync: boolean = false;
|
||||||
|
private mempoolCache: { [txId: string]: TransactionExtended } = {};
|
||||||
|
private mempoolInfo: IBitcoinApi.MempoolInfo = { loaded: false, size: 0, bytes: 0, usage: 0,
|
||||||
|
maxmempool: 300000000, mempoolminfee: 0.00001000, minrelaytxfee: 0.00001000 };
|
||||||
|
private mempoolChangedCallback: ((newMempool: {[txId: string]: TransactionExtended; }, newTransactions: TransactionExtended[],
|
||||||
|
deletedTransactions: TransactionExtended[]) => void) | undefined;
|
||||||
|
|
||||||
private txPerSecondArray: number[] = [];
|
private txPerSecondArray: number[] = [];
|
||||||
private txPerSecond: number = 0;
|
private txPerSecond: number = 0;
|
||||||
|
|
||||||
private vBytesPerSecondArray: any[] = [];
|
private vBytesPerSecondArray: VbytesPerSecond[] = [];
|
||||||
private vBytesPerSecond: number = 0;
|
private vBytesPerSecond: number = 0;
|
||||||
|
private mempoolProtection = 0;
|
||||||
|
private latestTransactions: any[] = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
setInterval(this.updateTxPerSecond.bind(this), 1000);
|
setInterval(this.updateTxPerSecond.bind(this), 1000);
|
||||||
|
setInterval(this.deleteExpiredTransactions.bind(this), 20000);
|
||||||
}
|
}
|
||||||
|
|
||||||
public setMempoolChangedCallback(fn: Function) {
|
public isInSync(): boolean {
|
||||||
|
return this.inSync;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setOutOfSync(): void {
|
||||||
|
this.inSync = false;
|
||||||
|
loadingIndicators.setProgress('mempool', 99);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getLatestTransactions() {
|
||||||
|
return this.latestTransactions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setMempoolChangedCallback(fn: (newMempool: { [txId: string]: TransactionExtended; },
|
||||||
|
newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]) => void) {
|
||||||
this.mempoolChangedCallback = fn;
|
this.mempoolChangedCallback = fn;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getMempool(): { [txid: string]: ITransaction } {
|
public getMempool(): { [txid: string]: TransactionExtended } {
|
||||||
return this.mempool;
|
return this.mempoolCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
public setMempool(mempoolData: any) {
|
public setMempool(mempoolData: { [txId: string]: TransactionExtended }) {
|
||||||
this.mempool = mempoolData;
|
this.mempoolCache = mempoolData;
|
||||||
|
if (this.mempoolChangedCallback) {
|
||||||
|
this.mempoolChangedCallback(this.mempoolCache, [], []);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public getMempoolInfo(): IMempoolInfo | undefined {
|
public async $updateMemPoolInfo() {
|
||||||
|
this.mempoolInfo = await this.$getMempoolInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
public getMempoolInfo(): IBitcoinApi.MempoolInfo {
|
||||||
return this.mempoolInfo;
|
return this.mempoolInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,128 +77,150 @@ class Mempool {
|
|||||||
return this.vBytesPerSecond;
|
return this.vBytesPerSecond;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateMemPoolInfo() {
|
public getFirstSeenForTransactions(txIds: string[]): number[] {
|
||||||
try {
|
const txTimes: number[] = [];
|
||||||
this.mempoolInfo = await bitcoinApi.getMempoolInfo();
|
txIds.forEach((txId: string) => {
|
||||||
} catch (err) {
|
const tx = this.mempoolCache[txId];
|
||||||
console.log('Error getMempoolInfo', err);
|
if (tx && tx.firstSeen) {
|
||||||
}
|
txTimes.push(tx.firstSeen);
|
||||||
}
|
|
||||||
|
|
||||||
public async getRawTransaction(txId: string, isCoinbase = false): Promise<ITransaction | false> {
|
|
||||||
try {
|
|
||||||
const transaction = await bitcoinApi.getRawTransaction(txId);
|
|
||||||
|
|
||||||
let totalOut = 0;
|
|
||||||
transaction.vout.forEach((output) => totalOut += output.value);
|
|
||||||
|
|
||||||
if (config.BACKEND_API === 'electrs') {
|
|
||||||
transaction.feePerWeightUnit = (transaction.fee * 100000000) / transaction.weight || 0;
|
|
||||||
transaction.feePerVsize = (transaction.fee * 100000000) / (transaction.vsize) || 0;
|
|
||||||
transaction.totalOut = totalOut / 100000000;
|
|
||||||
} else {
|
} else {
|
||||||
let totalIn = 0;
|
txTimes.push(0);
|
||||||
if (!isCoinbase) {
|
|
||||||
for (let i = 0; i < transaction.vin.length; i++) {
|
|
||||||
try {
|
|
||||||
const result = await bitcoinApi.getRawTransaction(transaction.vin[i].txid);
|
|
||||||
transaction.vin[i]['value'] = result.vout[transaction.vin[i].vout].value;
|
|
||||||
totalIn += result.vout[transaction.vin[i].vout].value;
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Locating historical tx error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (totalIn > totalOut) {
|
|
||||||
transaction.fee = parseFloat((totalIn - totalOut).toFixed(8));
|
|
||||||
transaction.feePerWeightUnit = (transaction.fee * 100000000) / (transaction.vsize * 4) || 0;
|
|
||||||
transaction.feePerVsize = (transaction.fee * 100000000) / (transaction.vsize) || 0;
|
|
||||||
} else if (!isCoinbase) {
|
|
||||||
transaction.fee = 0;
|
|
||||||
transaction.feePerVsize = 0;
|
|
||||||
transaction.feePerWeightUnit = 0;
|
|
||||||
console.log('Minus fee error!');
|
|
||||||
}
|
|
||||||
transaction.totalOut = totalOut;
|
|
||||||
}
|
}
|
||||||
return transaction;
|
});
|
||||||
} catch (e) {
|
return txTimes;
|
||||||
console.log(txId + ' not found');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateMempool() {
|
public async $updateMempool() {
|
||||||
console.log('Updating mempool');
|
logger.debug('Updating mempool');
|
||||||
const start = new Date().getTime();
|
const start = new Date().getTime();
|
||||||
let hasChange: boolean = false;
|
let hasChange: boolean = false;
|
||||||
|
const currentMempoolSize = Object.keys(this.mempoolCache).length;
|
||||||
let txCount = 0;
|
let txCount = 0;
|
||||||
try {
|
const transactions = await bitcoinApi.$getRawMempool();
|
||||||
const transactions = await bitcoinApi.getRawMempool();
|
const diff = transactions.length - currentMempoolSize;
|
||||||
const diff = transactions.length - Object.keys(this.mempool).length;
|
const newTransactions: TransactionExtended[] = [];
|
||||||
for (const tx of transactions) {
|
|
||||||
if (!this.mempool[tx]) {
|
if (!this.inSync) {
|
||||||
const transaction = await this.getRawTransaction(tx);
|
loadingIndicators.setProgress('mempool', Object.keys(this.mempoolCache).length / transactions.length * 100);
|
||||||
if (transaction) {
|
}
|
||||||
this.mempool[tx] = transaction;
|
|
||||||
txCount++;
|
for (const txid of transactions) {
|
||||||
|
if (!this.mempoolCache[txid]) {
|
||||||
|
try {
|
||||||
|
const transaction = await transactionUtils.$getTransactionExtended(txid);
|
||||||
|
this.mempoolCache[txid] = transaction;
|
||||||
|
txCount++;
|
||||||
|
if (this.inSync) {
|
||||||
this.txPerSecondArray.push(new Date().getTime());
|
this.txPerSecondArray.push(new Date().getTime());
|
||||||
this.vBytesPerSecondArray.push({
|
this.vBytesPerSecondArray.push({
|
||||||
unixTime: new Date().getTime(),
|
unixTime: new Date().getTime(),
|
||||||
vSize: transaction.vsize,
|
vSize: transaction.vsize,
|
||||||
});
|
});
|
||||||
hasChange = true;
|
|
||||||
if (diff > 0) {
|
|
||||||
console.log('Calculated fee for transaction ' + txCount + ' / ' + diff);
|
|
||||||
} else {
|
|
||||||
console.log('Calculated fee for transaction ' + txCount);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log('Error finding transaction in mempool.');
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if ((new Date().getTime()) - start > config.MEMPOOL_REFRESH_RATE_MS * 10) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newMempool: IMempool = {};
|
|
||||||
transactions.forEach((tx) => {
|
|
||||||
if (this.mempool[tx]) {
|
|
||||||
newMempool[tx] = this.mempool[tx];
|
|
||||||
} else {
|
|
||||||
hasChange = true;
|
hasChange = true;
|
||||||
|
if (diff > 0) {
|
||||||
|
logger.debug('Fetched transaction ' + txCount + ' / ' + diff);
|
||||||
|
} else {
|
||||||
|
logger.debug('Fetched transaction ' + txCount);
|
||||||
|
}
|
||||||
|
newTransactions.push(transaction);
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug('Error finding transaction in mempool: ' + (e instanceof Error ? e.message : e));
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
this.mempool = newMempool;
|
|
||||||
|
|
||||||
if (hasChange && this.mempoolChangedCallback) {
|
|
||||||
this.mempoolChangedCallback(this.mempool);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const end = new Date().getTime();
|
if ((new Date().getTime()) - start > Mempool.WEBSOCKET_REFRESH_RATE_MS) {
|
||||||
const time = end - start;
|
break;
|
||||||
console.log('Mempool updated in ' + time / 1000 + ' seconds');
|
}
|
||||||
} catch (err) {
|
|
||||||
console.log('getRawMempool error.', err);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent mempool from clear on bitcoind restart by delaying the deletion
|
||||||
|
if (this.mempoolProtection === 0
|
||||||
|
&& currentMempoolSize > 20000
|
||||||
|
&& transactions.length / currentMempoolSize <= 0.80
|
||||||
|
) {
|
||||||
|
this.mempoolProtection = 1;
|
||||||
|
this.inSync = false;
|
||||||
|
logger.warn(`Mempool clear protection triggered because transactions.length: ${transactions.length} and currentMempoolSize: ${currentMempoolSize}.`);
|
||||||
|
setTimeout(() => {
|
||||||
|
this.mempoolProtection = 2;
|
||||||
|
logger.warn('Mempool clear protection resumed.');
|
||||||
|
}, 1000 * 60 * config.MEMPOOL.CLEAR_PROTECTION_MINUTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedTransactions: TransactionExtended[] = [];
|
||||||
|
|
||||||
|
if (this.mempoolProtection !== 1) {
|
||||||
|
this.mempoolProtection = 0;
|
||||||
|
// Index object for faster search
|
||||||
|
const transactionsObject = {};
|
||||||
|
transactions.forEach((txId) => transactionsObject[txId] = true);
|
||||||
|
|
||||||
|
// Flag transactions for lazy deletion
|
||||||
|
for (const tx in this.mempoolCache) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTransactionsStripped = newTransactions.map((tx) => Common.stripTransaction(tx));
|
||||||
|
this.latestTransactions = newTransactionsStripped.concat(this.latestTransactions).slice(0, 6);
|
||||||
|
|
||||||
|
if (!this.inSync && transactions.length === Object.keys(this.mempoolCache).length) {
|
||||||
|
this.inSync = true;
|
||||||
|
logger.notice('The mempool is now in sync!');
|
||||||
|
loadingIndicators.setProgress('mempool', 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.mempoolChangedCallback && (hasChange || deletedTransactions.length)) {
|
||||||
|
this.mempoolChangedCallback(this.mempoolCache, newTransactions, deletedTransactions);
|
||||||
|
}
|
||||||
|
|
||||||
|
const end = new Date().getTime();
|
||||||
|
const time = end - start;
|
||||||
|
logger.debug(`New mempool size: ${Object.keys(this.mempoolCache).length} Change: ${diff}`);
|
||||||
|
logger.debug('Mempool updated in ' + time / 1000 + ' seconds');
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateTxPerSecond() {
|
private updateTxPerSecond() {
|
||||||
const nowMinusTimeSpan = new Date().getTime() - (1000 * config.TX_PER_SECOND_SPAN_SECONDS);
|
const nowMinusTimeSpan = new Date().getTime() - (1000 * config.STATISTICS.TX_PER_SECOND_SAMPLE_PERIOD);
|
||||||
this.txPerSecondArray = this.txPerSecondArray.filter((unixTime) => unixTime > nowMinusTimeSpan);
|
this.txPerSecondArray = this.txPerSecondArray.filter((unixTime) => unixTime > nowMinusTimeSpan);
|
||||||
this.txPerSecond = this.txPerSecondArray.length / config.TX_PER_SECOND_SPAN_SECONDS || 0;
|
this.txPerSecond = this.txPerSecondArray.length / config.STATISTICS.TX_PER_SECOND_SAMPLE_PERIOD || 0;
|
||||||
|
|
||||||
this.vBytesPerSecondArray = this.vBytesPerSecondArray.filter((data) => data.unixTime > nowMinusTimeSpan);
|
this.vBytesPerSecondArray = this.vBytesPerSecondArray.filter((data) => data.unixTime > nowMinusTimeSpan);
|
||||||
if (this.vBytesPerSecondArray.length) {
|
if (this.vBytesPerSecondArray.length) {
|
||||||
this.vBytesPerSecond = Math.round(
|
this.vBytesPerSecond = Math.round(
|
||||||
this.vBytesPerSecondArray.map((data) => data.vSize).reduce((a, b) => a + b) / config.TX_PER_SECOND_SPAN_SECONDS
|
this.vBytesPerSecondArray.map((data) => data.vSize).reduce((a, b) => a + b) / config.STATISTICS.TX_PER_SECOND_SAMPLE_PERIOD
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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();
|
export default new Mempool();
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
const config = require('../../mempool-config.json');
|
|
||||||
import { ITransaction, IProjectedBlock, IMempool, IProjectedBlockInternal } from '../interfaces';
|
|
||||||
|
|
||||||
class ProjectedBlocks {
|
|
||||||
private transactionsSorted: ITransaction[] = [];
|
|
||||||
|
|
||||||
constructor() {}
|
|
||||||
|
|
||||||
public getProjectedBlockFeesForBlock(index: number) {
|
|
||||||
const projectedBlock = this.getProjectedBlocksInternal()[index];
|
|
||||||
|
|
||||||
if (!projectedBlock) {
|
|
||||||
throw new Error('No projected block for that index');
|
|
||||||
}
|
|
||||||
|
|
||||||
return projectedBlock.txFeePerVsizes.map((fpv) => {
|
|
||||||
return {'fpv': fpv};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public updateProjectedBlocks(memPool: IMempool): void {
|
|
||||||
const latestMempool = memPool;
|
|
||||||
const memPoolArray: ITransaction[] = [];
|
|
||||||
for (const i in latestMempool) {
|
|
||||||
if (latestMempool.hasOwnProperty(i)) {
|
|
||||||
memPoolArray.push(latestMempool[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
memPoolArray.sort((a, b) => b.feePerWeightUnit - a.feePerWeightUnit);
|
|
||||||
this.transactionsSorted = memPoolArray.filter((tx) => tx.feePerWeightUnit);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getProjectedBlocks(txId?: string, numberOfBlocks: number = config.DEFAULT_PROJECTED_BLOCKS_AMOUNT): IProjectedBlock[] {
|
|
||||||
return this.getProjectedBlocksInternal(numberOfBlocks).map((projectedBlock) => {
|
|
||||||
return {
|
|
||||||
blockSize: projectedBlock.blockSize,
|
|
||||||
blockWeight: projectedBlock.blockWeight,
|
|
||||||
nTx: projectedBlock.nTx,
|
|
||||||
minFee: projectedBlock.minFee,
|
|
||||||
maxFee: projectedBlock.maxFee,
|
|
||||||
minWeightFee: projectedBlock.minWeightFee,
|
|
||||||
maxWeightFee: projectedBlock.maxWeightFee,
|
|
||||||
medianFee: projectedBlock.medianFee,
|
|
||||||
fees: projectedBlock.fees,
|
|
||||||
hasMytx: txId ? projectedBlock.txIds.some((tx) => tx === txId) : false
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private getProjectedBlocksInternal(numberOfBlocks: number = config.DEFAULT_PROJECTED_BLOCKS_AMOUNT): IProjectedBlockInternal[] {
|
|
||||||
const projectedBlocks: IProjectedBlockInternal[] = [];
|
|
||||||
let blockWeight = 0;
|
|
||||||
let blockSize = 0;
|
|
||||||
let transactions: ITransaction[] = [];
|
|
||||||
this.transactionsSorted.forEach((tx) => {
|
|
||||||
if (blockWeight + tx.vsize * 4 < 4000000 || projectedBlocks.length === numberOfBlocks) {
|
|
||||||
blockWeight += tx.weight || tx.vsize * 4;
|
|
||||||
blockSize += tx.size;
|
|
||||||
transactions.push(tx);
|
|
||||||
} else {
|
|
||||||
projectedBlocks.push(this.dataToProjectedBlock(transactions, blockSize, blockWeight));
|
|
||||||
blockWeight = 0;
|
|
||||||
blockSize = 0;
|
|
||||||
transactions = [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (transactions.length) {
|
|
||||||
projectedBlocks.push(this.dataToProjectedBlock(transactions, blockSize, blockWeight));
|
|
||||||
}
|
|
||||||
return projectedBlocks;
|
|
||||||
}
|
|
||||||
|
|
||||||
private dataToProjectedBlock(transactions: ITransaction[], blockSize: number, blockWeight: number): IProjectedBlockInternal {
|
|
||||||
return {
|
|
||||||
blockSize: blockSize,
|
|
||||||
blockWeight: blockWeight,
|
|
||||||
nTx: transactions.length,
|
|
||||||
minFee: transactions[transactions.length - 1].feePerVsize,
|
|
||||||
maxFee: transactions[0].feePerVsize,
|
|
||||||
minWeightFee: transactions[transactions.length - 1].feePerWeightUnit,
|
|
||||||
maxWeightFee: transactions[0].feePerWeightUnit,
|
|
||||||
medianFee: this.median(transactions.map((tx) => tx.feePerVsize)),
|
|
||||||
txIds: transactions.map((tx) => tx.txid),
|
|
||||||
txFeePerVsizes: transactions.map((tx) => tx.feePerVsize).reverse(),
|
|
||||||
fees: transactions.map((tx) => tx.fee).reduce((acc, currValue) => acc + currValue),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private median(numbers: number[]) {
|
|
||||||
let medianNr = 0;
|
|
||||||
const numsLen = numbers.length;
|
|
||||||
numbers.sort();
|
|
||||||
if (numsLen % 2 === 0) {
|
|
||||||
medianNr = (numbers[numsLen / 2 - 1] + numbers[numsLen / 2]) / 2;
|
|
||||||
} else {
|
|
||||||
medianNr = numbers[(numsLen - 1) / 2];
|
|
||||||
}
|
|
||||||
return medianNr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new ProjectedBlocks();
|
|
||||||
@@ -1,20 +1,26 @@
|
|||||||
import memPool from './mempool';
|
import memPool from './mempool';
|
||||||
import { DB } from '../database';
|
import { DB } from '../database';
|
||||||
|
import logger from '../logger';
|
||||||
|
|
||||||
import { ITransaction, IMempoolStats } from '../interfaces';
|
import { Statistic, TransactionExtended, OptimizedStatistic } from '../mempool.interfaces';
|
||||||
|
|
||||||
class Statistics {
|
class Statistics {
|
||||||
protected intervalTimer: NodeJS.Timer | undefined;
|
protected intervalTimer: NodeJS.Timer | undefined;
|
||||||
protected newStatisticsEntryCallback: Function | undefined;
|
protected newStatisticsEntryCallback: ((stats: OptimizedStatistic) => void) | undefined;
|
||||||
|
protected queryTimeout = 120000;
|
||||||
|
protected cache: { [date: string]: OptimizedStatistic[] } = {
|
||||||
|
'24h': [], '1w': [], '1m': [], '3m': [], '6m': [], '1y': [],
|
||||||
|
};
|
||||||
|
|
||||||
public setNewStatisticsEntryCallback(fn: Function) {
|
public setNewStatisticsEntryCallback(fn: (stats: OptimizedStatistic) => void) {
|
||||||
this.newStatisticsEntryCallback = fn;
|
this.newStatisticsEntryCallback = fn;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() { }
|
||||||
}
|
|
||||||
|
|
||||||
public startStatistics(): void {
|
public startStatistics(): void {
|
||||||
|
logger.info('Starting statistics service');
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const nextInterval = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(),
|
const nextInterval = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(),
|
||||||
Math.floor(now.getMinutes() / 1) * 1 + 1, 0, 0);
|
Math.floor(now.getMinutes() / 1) * 1 + 1, 0, 0);
|
||||||
@@ -22,60 +28,64 @@ class Statistics {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.runStatistics();
|
this.runStatistics();
|
||||||
this.intervalTimer = setInterval(() => { this.runStatistics(); }, 1 * 60 * 1000);
|
this.intervalTimer = setInterval(() => {
|
||||||
|
this.runStatistics();
|
||||||
|
}, 1 * 60 * 1000);
|
||||||
}, difference);
|
}, difference);
|
||||||
|
|
||||||
|
this.createCache();
|
||||||
|
setInterval(this.createCache.bind(this), 600000);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCache() {
|
||||||
|
return this.cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createCache() {
|
||||||
|
this.cache['24h'] = await this.$list24H();
|
||||||
|
this.cache['1w'] = await this.$list1W();
|
||||||
|
this.cache['1m'] = await this.$list1M();
|
||||||
|
this.cache['3m'] = await this.$list3M();
|
||||||
|
this.cache['6m'] = await this.$list6M();
|
||||||
|
this.cache['1y'] = await this.$list1Y();
|
||||||
|
logger.debug('Statistics cache created');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runStatistics(): Promise<void> {
|
private async runStatistics(): Promise<void> {
|
||||||
|
if (!memPool.isInSync()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const currentMempool = memPool.getMempool();
|
const currentMempool = memPool.getMempool();
|
||||||
const txPerSecond = memPool.getTxPerSecond();
|
const txPerSecond = memPool.getTxPerSecond();
|
||||||
const vBytesPerSecond = memPool.getVBytesPerSecond();
|
const vBytesPerSecond = memPool.getVBytesPerSecond();
|
||||||
|
|
||||||
if (txPerSecond === 0) {
|
logger.debug('Running statistics');
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Running statistics');
|
let memPoolArray: TransactionExtended[] = [];
|
||||||
|
|
||||||
let memPoolArray: ITransaction[] = [];
|
|
||||||
for (const i in currentMempool) {
|
for (const i in currentMempool) {
|
||||||
if (currentMempool.hasOwnProperty(i)) {
|
if (currentMempool.hasOwnProperty(i)) {
|
||||||
memPoolArray.push(currentMempool[i]);
|
memPoolArray.push(currentMempool[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Remove 0 and undefined
|
// Remove 0 and undefined
|
||||||
memPoolArray = memPoolArray.filter((tx) => tx.feePerWeightUnit);
|
memPoolArray = memPoolArray.filter((tx) => tx.effectiveFeePerVsize);
|
||||||
|
|
||||||
if (!memPoolArray.length) {
|
if (!memPoolArray.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
memPoolArray.sort((a, b) => a.feePerWeightUnit - b.feePerWeightUnit);
|
memPoolArray.sort((a, b) => a.effectiveFeePerVsize - b.effectiveFeePerVsize);
|
||||||
const totalWeight = memPoolArray.map((tx) => tx.vsize).reduce((acc, curr) => acc + curr) * 4;
|
const totalWeight = memPoolArray.map((tx) => tx.vsize).reduce((acc, curr) => acc + curr) * 4;
|
||||||
const totalFee = memPoolArray.map((tx) => tx.fee).reduce((acc, curr) => acc + curr);
|
const totalFee = memPoolArray.map((tx) => tx.fee).reduce((acc, curr) => acc + curr);
|
||||||
|
|
||||||
const logFees = [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 175, 200,
|
const logFees = [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 175, 200,
|
||||||
250, 300, 350, 400, 500, 600, 700, 800, 900, 1000, 1200, 1400, 1600, 1800, 2000];
|
250, 300, 350, 400, 500, 600, 700, 800, 900, 1000, 1200, 1400, 1600, 1800, 2000];
|
||||||
|
|
||||||
const weightUnitFees: { [feePerWU: number]: number } = {};
|
|
||||||
const weightVsizeFees: { [feePerWU: number]: number } = {};
|
const weightVsizeFees: { [feePerWU: number]: number } = {};
|
||||||
|
|
||||||
memPoolArray.forEach((transaction) => {
|
memPoolArray.forEach((transaction) => {
|
||||||
for (let i = 0; i < logFees.length; i++) {
|
for (let i = 0; i < logFees.length; i++) {
|
||||||
if ((logFees[i] === 2000 && transaction.feePerWeightUnit >= 2000) || transaction.feePerWeightUnit <= logFees[i]) {
|
if ((logFees[i] === 2000 && transaction.effectiveFeePerVsize >= 2000) || transaction.effectiveFeePerVsize <= logFees[i]) {
|
||||||
if (weightUnitFees[logFees[i]]) {
|
|
||||||
weightUnitFees[logFees[i]] += transaction.vsize * 4;
|
|
||||||
} else {
|
|
||||||
weightUnitFees[logFees[i]] = transaction.vsize * 4;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
memPoolArray.forEach((transaction) => {
|
|
||||||
for (let i = 0; i < logFees.length; i++) {
|
|
||||||
if ((logFees[i] === 2000 && transaction.feePerVsize >= 2000) || transaction.feePerVsize <= logFees[i]) {
|
|
||||||
if (weightVsizeFees[logFees[i]]) {
|
if (weightVsizeFees[logFees[i]]) {
|
||||||
weightVsizeFees[logFees[i]] += transaction.vsize;
|
weightVsizeFees[logFees[i]] += transaction.vsize;
|
||||||
} else {
|
} else {
|
||||||
@@ -93,10 +103,7 @@ class Statistics {
|
|||||||
vbytes_per_second: Math.round(vBytesPerSecond),
|
vbytes_per_second: Math.round(vBytesPerSecond),
|
||||||
mempool_byte_weight: totalWeight,
|
mempool_byte_weight: totalWeight,
|
||||||
total_fee: totalFee,
|
total_fee: totalFee,
|
||||||
fee_data: JSON.stringify({
|
fee_data: '',
|
||||||
'wu': weightUnitFees,
|
|
||||||
'vsize': weightVsizeFees
|
|
||||||
}),
|
|
||||||
vsize_1: weightVsizeFees['1'] || 0,
|
vsize_1: weightVsizeFees['1'] || 0,
|
||||||
vsize_2: weightVsizeFees['2'] || 0,
|
vsize_2: weightVsizeFees['2'] || 0,
|
||||||
vsize_3: weightVsizeFees['3'] || 0,
|
vsize_3: weightVsizeFees['3'] || 0,
|
||||||
@@ -139,11 +146,13 @@ class Statistics {
|
|||||||
|
|
||||||
if (this.newStatisticsEntryCallback && insertId) {
|
if (this.newStatisticsEntryCallback && insertId) {
|
||||||
const newStats = await this.$get(insertId);
|
const newStats = await this.$get(insertId);
|
||||||
this.newStatisticsEntryCallback(newStats);
|
if (newStats) {
|
||||||
|
this.newStatisticsEntryCallback(newStats);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async $create(statistics: IMempoolStats): Promise<number | undefined> {
|
private async $create(statistics: Statistic): Promise<number | undefined> {
|
||||||
try {
|
try {
|
||||||
const connection = await DB.pool.getConnection();
|
const connection = await DB.pool.getConnection();
|
||||||
const query = `INSERT INTO statistics(
|
const query = `INSERT INTO statistics(
|
||||||
@@ -246,144 +255,212 @@ class Statistics {
|
|||||||
connection.release();
|
connection.release();
|
||||||
return result.insertId;
|
return result.insertId;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('$create() error', e);
|
logger.err('$create() error' + (e instanceof Error ? e.message : e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getQueryForDays(days: number, groupBy: number) {
|
private getQueryForDays(div: number) {
|
||||||
|
|
||||||
return `SELECT id, added, unconfirmed_transactions,
|
return `SELECT id, added, unconfirmed_transactions,
|
||||||
AVG(tx_per_second) AS tx_per_second,
|
tx_per_second,
|
||||||
AVG(vbytes_per_second) AS vbytes_per_second,
|
vbytes_per_second,
|
||||||
AVG(vsize_1) AS vsize_1,
|
vsize_1,
|
||||||
AVG(vsize_2) AS vsize_2,
|
vsize_2,
|
||||||
AVG(vsize_3) AS vsize_3,
|
vsize_3,
|
||||||
AVG(vsize_4) AS vsize_4,
|
vsize_4,
|
||||||
AVG(vsize_5) AS vsize_5,
|
vsize_5,
|
||||||
AVG(vsize_6) AS vsize_6,
|
vsize_6,
|
||||||
AVG(vsize_8) AS vsize_8,
|
vsize_8,
|
||||||
AVG(vsize_10) AS vsize_10,
|
vsize_10,
|
||||||
AVG(vsize_12) AS vsize_12,
|
vsize_12,
|
||||||
AVG(vsize_15) AS vsize_15,
|
vsize_15,
|
||||||
AVG(vsize_20) AS vsize_20,
|
vsize_20,
|
||||||
AVG(vsize_30) AS vsize_30,
|
vsize_30,
|
||||||
AVG(vsize_40) AS vsize_40,
|
vsize_40,
|
||||||
AVG(vsize_50) AS vsize_50,
|
vsize_50,
|
||||||
AVG(vsize_60) AS vsize_60,
|
vsize_60,
|
||||||
AVG(vsize_70) AS vsize_70,
|
vsize_70,
|
||||||
AVG(vsize_80) AS vsize_80,
|
vsize_80,
|
||||||
AVG(vsize_90) AS vsize_90,
|
vsize_90,
|
||||||
AVG(vsize_100) AS vsize_100,
|
vsize_100,
|
||||||
AVG(vsize_125) AS vsize_125,
|
vsize_125,
|
||||||
AVG(vsize_150) AS vsize_150,
|
vsize_150,
|
||||||
AVG(vsize_175) AS vsize_175,
|
vsize_175,
|
||||||
AVG(vsize_200) AS vsize_200,
|
vsize_200,
|
||||||
AVG(vsize_250) AS vsize_250,
|
vsize_250,
|
||||||
AVG(vsize_300) AS vsize_300,
|
vsize_300,
|
||||||
AVG(vsize_350) AS vsize_350,
|
vsize_350,
|
||||||
AVG(vsize_400) AS vsize_400,
|
vsize_400,
|
||||||
AVG(vsize_500) AS vsize_500,
|
vsize_500,
|
||||||
AVG(vsize_600) AS vsize_600,
|
vsize_600,
|
||||||
AVG(vsize_700) AS vsize_700,
|
vsize_700,
|
||||||
AVG(vsize_800) AS vsize_800,
|
vsize_800,
|
||||||
AVG(vsize_900) AS vsize_900,
|
vsize_900,
|
||||||
AVG(vsize_1000) AS vsize_1000,
|
vsize_1000,
|
||||||
AVG(vsize_1200) AS vsize_1200,
|
vsize_1200,
|
||||||
AVG(vsize_1400) AS vsize_1400,
|
vsize_1400,
|
||||||
AVG(vsize_1600) AS vsize_1600,
|
vsize_1600,
|
||||||
AVG(vsize_1800) AS vsize_1800,
|
vsize_1800,
|
||||||
AVG(vsize_2000) AS vsize_2000 FROM statistics GROUP BY UNIX_TIMESTAMP(added) DIV ${groupBy} ORDER BY id DESC LIMIT ${days}`;
|
vsize_2000 FROM statistics GROUP BY UNIX_TIMESTAMP(added) DIV ${div} ORDER BY id DESC LIMIT 480`;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async $get(id: number): Promise<IMempoolStats | undefined> {
|
public async $get(id: number): Promise<OptimizedStatistic | undefined> {
|
||||||
try {
|
try {
|
||||||
const connection = await DB.pool.getConnection();
|
const connection = await DB.pool.getConnection();
|
||||||
const query = `SELECT * FROM statistics WHERE id = ?`;
|
const query = `SELECT * FROM statistics WHERE id = ?`;
|
||||||
const [rows] = await connection.query<any>(query, [id]);
|
const [rows] = await connection.query<any>(query, [id]);
|
||||||
connection.release();
|
connection.release();
|
||||||
return rows[0];
|
if (rows[0]) {
|
||||||
|
return this.mapStatisticToOptimizedStatistic([rows[0]])[0];
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('$list2H() error', e);
|
logger.err('$list2H() error' + (e instanceof Error ? e.message : e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async $list2H(): Promise<IMempoolStats[]> {
|
public async $list2H(): Promise<OptimizedStatistic[]> {
|
||||||
try {
|
try {
|
||||||
const connection = await DB.pool.getConnection();
|
const connection = await DB.pool.getConnection();
|
||||||
const query = `SELECT * FROM statistics ORDER BY id DESC LIMIT 120`;
|
const query = `SELECT * FROM statistics ORDER BY id DESC LIMIT 120`;
|
||||||
const [rows] = await connection.query<any>(query);
|
const [rows] = await connection.query<any>({ sql: query, timeout: this.queryTimeout });
|
||||||
connection.release();
|
connection.release();
|
||||||
return rows;
|
return this.mapStatisticToOptimizedStatistic(rows);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('$list2H() error', e);
|
logger.err('$list2H() error' + (e instanceof Error ? e.message : e));
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async $list24H(): Promise<IMempoolStats[]> {
|
public async $list24H(): Promise<OptimizedStatistic[]> {
|
||||||
try {
|
try {
|
||||||
const connection = await DB.pool.getConnection();
|
const connection = await DB.pool.getConnection();
|
||||||
const query = this.getQueryForDays(120, 720);
|
const query = this.getQueryForDays(180);
|
||||||
const [rows] = await connection.query<any>(query);
|
const [rows] = await connection.query<any>({ sql: query, timeout: this.queryTimeout });
|
||||||
connection.release();
|
connection.release();
|
||||||
return rows;
|
return this.mapStatisticToOptimizedStatistic(rows);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
logger.err('$list24h() error' + (e instanceof Error ? e.message : e));
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async $list1W(): Promise<IMempoolStats[]> {
|
public async $list1W(): Promise<OptimizedStatistic[]> {
|
||||||
try {
|
try {
|
||||||
const connection = await DB.pool.getConnection();
|
const connection = await DB.pool.getConnection();
|
||||||
const query = this.getQueryForDays(120, 5040);
|
const query = this.getQueryForDays(1260);
|
||||||
const [rows] = await connection.query<any>(query);
|
const [rows] = await connection.query<any>({ sql: query, timeout: this.queryTimeout });
|
||||||
connection.release();
|
connection.release();
|
||||||
return rows;
|
return this.mapStatisticToOptimizedStatistic(rows);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('$list1W() error', e);
|
logger.err('$list1W() error' + (e instanceof Error ? e.message : e));
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async $list1M(): Promise<IMempoolStats[]> {
|
public async $list1M(): Promise<OptimizedStatistic[]> {
|
||||||
try {
|
try {
|
||||||
const connection = await DB.pool.getConnection();
|
const connection = await DB.pool.getConnection();
|
||||||
const query = this.getQueryForDays(120, 20160);
|
const query = this.getQueryForDays(5040);
|
||||||
const [rows] = await connection.query<any>(query);
|
const [rows] = await connection.query<any>({ sql: query, timeout: this.queryTimeout });
|
||||||
connection.release();
|
connection.release();
|
||||||
return rows;
|
return this.mapStatisticToOptimizedStatistic(rows);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('$list1M() error', e);
|
logger.err('$list1M() error' + (e instanceof Error ? e.message : e));
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async $list3M(): Promise<IMempoolStats[]> {
|
public async $list3M(): Promise<OptimizedStatistic[]> {
|
||||||
try {
|
try {
|
||||||
const connection = await DB.pool.getConnection();
|
const connection = await DB.pool.getConnection();
|
||||||
const query = this.getQueryForDays(120, 60480);
|
const query = this.getQueryForDays(15120);
|
||||||
const [rows] = await connection.query<any>(query);
|
const [rows] = await connection.query<any>({ sql: query, timeout: this.queryTimeout });
|
||||||
connection.release();
|
connection.release();
|
||||||
return rows;
|
return this.mapStatisticToOptimizedStatistic(rows);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('$list3M() error', e);
|
logger.err('$list3M() error' + (e instanceof Error ? e.message : e));
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async $list6M(): Promise<IMempoolStats[]> {
|
public async $list6M(): Promise<OptimizedStatistic[]> {
|
||||||
try {
|
try {
|
||||||
const connection = await DB.pool.getConnection();
|
const connection = await DB.pool.getConnection();
|
||||||
const query = this.getQueryForDays(120, 120960);
|
const query = this.getQueryForDays(30240);
|
||||||
const [rows] = await connection.query<any>(query);
|
const [rows] = await connection.query<any>({ sql: query, timeout: this.queryTimeout });
|
||||||
connection.release();
|
connection.release();
|
||||||
return rows;
|
return this.mapStatisticToOptimizedStatistic(rows);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('$list6M() error', e);
|
logger.err('$list6M() error' + (e instanceof Error ? e.message : e));
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async $list1Y(): Promise<OptimizedStatistic[]> {
|
||||||
|
try {
|
||||||
|
const connection = await DB.pool.getConnection();
|
||||||
|
const query = this.getQueryForDays(60480);
|
||||||
|
const [rows] = await connection.query<any>({ sql: query, timeout: this.queryTimeout });
|
||||||
|
connection.release();
|
||||||
|
return this.mapStatisticToOptimizedStatistic(rows);
|
||||||
|
} catch (e) {
|
||||||
|
logger.err('$list6M() 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,
|
||||||
|
vsizes: [
|
||||||
|
s.vsize_1,
|
||||||
|
s.vsize_2,
|
||||||
|
s.vsize_3,
|
||||||
|
s.vsize_4,
|
||||||
|
s.vsize_5,
|
||||||
|
s.vsize_6,
|
||||||
|
s.vsize_8,
|
||||||
|
s.vsize_10,
|
||||||
|
s.vsize_12,
|
||||||
|
s.vsize_15,
|
||||||
|
s.vsize_20,
|
||||||
|
s.vsize_30,
|
||||||
|
s.vsize_40,
|
||||||
|
s.vsize_50,
|
||||||
|
s.vsize_60,
|
||||||
|
s.vsize_70,
|
||||||
|
s.vsize_80,
|
||||||
|
s.vsize_90,
|
||||||
|
s.vsize_100,
|
||||||
|
s.vsize_125,
|
||||||
|
s.vsize_150,
|
||||||
|
s.vsize_175,
|
||||||
|
s.vsize_200,
|
||||||
|
s.vsize_250,
|
||||||
|
s.vsize_300,
|
||||||
|
s.vsize_350,
|
||||||
|
s.vsize_400,
|
||||||
|
s.vsize_500,
|
||||||
|
s.vsize_600,
|
||||||
|
s.vsize_700,
|
||||||
|
s.vsize_800,
|
||||||
|
s.vsize_900,
|
||||||
|
s.vsize_1000,
|
||||||
|
s.vsize_1200,
|
||||||
|
s.vsize_1400,
|
||||||
|
s.vsize_1600,
|
||||||
|
s.vsize_1800,
|
||||||
|
s.vsize_2000,
|
||||||
|
]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new Statistics();
|
export default new Statistics();
|
||||||
|
|||||||
47
backend/src/api/transaction-utils.ts
Normal file
47
backend/src/api/transaction-utils.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import bitcoinApi from './bitcoin/bitcoin-api-factory';
|
||||||
|
import { TransactionExtended, TransactionMinerInfo } from '../mempool.interfaces';
|
||||||
|
import { IEsploraApi } from './bitcoin/esplora-api.interface';
|
||||||
|
import config from '../config';
|
||||||
|
|
||||||
|
class TransactionUtils {
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
public stripCoinbaseTransaction(tx: TransactionExtended): TransactionMinerInfo {
|
||||||
|
return {
|
||||||
|
vin: [{
|
||||||
|
scriptsig: tx.vin[0].scriptsig || tx.vin[0]['coinbase']
|
||||||
|
}],
|
||||||
|
vout: tx.vout
|
||||||
|
.map((vout) => ({
|
||||||
|
scriptpubkey_address: vout.scriptpubkey_address,
|
||||||
|
value: vout.value
|
||||||
|
}))
|
||||||
|
.filter((vout) => vout.value)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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(config.MEMPOOL.NETWORK === 'liquid' ? 0.1 : 1, (transaction.fee || 0) / (transaction.weight / 4));
|
||||||
|
const transactionExtended: TransactionExtended = Object.assign({
|
||||||
|
vsize: Math.round(transaction.weight / 4),
|
||||||
|
feePerVsize: feePerVbytes,
|
||||||
|
effectiveFeePerVsize: feePerVbytes,
|
||||||
|
}, transaction);
|
||||||
|
if (!transaction.status.confirmed) {
|
||||||
|
transactionExtended.firstSeen = Math.round((new Date().getTime() / 1000));
|
||||||
|
}
|
||||||
|
return transactionExtended;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new TransactionUtils();
|
||||||
479
backend/src/api/websocket-handler.ts
Normal file
479
backend/src/api/websocket-handler.ts
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
import logger from '../logger';
|
||||||
|
import * as WebSocket from 'ws';
|
||||||
|
import { BlockExtended, TransactionExtended, WebsocketResponse, MempoolBlock,
|
||||||
|
OptimizedStatistic, ILoadingIndicators, IConversionRates } from '../mempool.interfaces';
|
||||||
|
import blocks from './blocks';
|
||||||
|
import memPool from './mempool';
|
||||||
|
import backendInfo from './backend-info';
|
||||||
|
import mempoolBlocks from './mempool-blocks';
|
||||||
|
import fiatConversion from './fiat-conversion';
|
||||||
|
import { Common } from './common';
|
||||||
|
import loadingIndicators from './loading-indicators';
|
||||||
|
import config from '../config';
|
||||||
|
import transactionUtils from './transaction-utils';
|
||||||
|
|
||||||
|
class WebsocketHandler {
|
||||||
|
private wss: WebSocket.Server | undefined;
|
||||||
|
private extraInitProperties = {};
|
||||||
|
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
setWebsocketServer(wss: WebSocket.Server) {
|
||||||
|
this.wss = wss;
|
||||||
|
}
|
||||||
|
|
||||||
|
setExtraInitProperties(property: string, value: any) {
|
||||||
|
this.extraInitProperties[property] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
setupConnectionHandling() {
|
||||||
|
if (!this.wss) {
|
||||||
|
throw new Error('WebSocket.Server is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.wss.on('connection', (client: WebSocket) => {
|
||||||
|
client.on('error', logger.info);
|
||||||
|
client.on('message', async (message: string) => {
|
||||||
|
try {
|
||||||
|
const parsedMessage: WebsocketResponse = JSON.parse(message);
|
||||||
|
const response = {};
|
||||||
|
|
||||||
|
if (parsedMessage.action === 'want') {
|
||||||
|
client['want-blocks'] = parsedMessage.data.indexOf('blocks') > -1;
|
||||||
|
client['want-mempool-blocks'] = parsedMessage.data.indexOf('mempool-blocks') > -1;
|
||||||
|
client['want-live-2h-chart'] = parsedMessage.data.indexOf('live-2h-chart') > -1;
|
||||||
|
client['want-stats'] = parsedMessage.data.indexOf('stats') > -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedMessage && parsedMessage['track-tx']) {
|
||||||
|
if (/^[a-fA-F0-9]{64}$/.test(parsedMessage['track-tx'])) {
|
||||||
|
client['track-tx'] = parsedMessage['track-tx'];
|
||||||
|
// Client is telling the transaction wasn't found but it might have appeared before we had the time to start watching for it
|
||||||
|
if (parsedMessage['watch-mempool']) {
|
||||||
|
const tx = memPool.getMempool()[client['track-tx']];
|
||||||
|
if (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 {
|
||||||
|
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 {
|
||||||
|
client['track-tx'] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,100}|[A-Z]{2,5}1[AC-HJ-NP-Z02-9]{8,100})$/
|
||||||
|
.test(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedMessage && parsedMessage['track-asset']) {
|
||||||
|
if (/^[a-fA-F0-9]{64}$/.test(parsedMessage['track-asset'])) {
|
||||||
|
client['track-asset'] = parsedMessage['track-asset'];
|
||||||
|
} else {
|
||||||
|
client['track-asset'] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedMessage.action === 'init') {
|
||||||
|
const _blocks = blocks.getBlocks().slice(-config.MEMPOOL.INITIAL_BLOCKS_AMOUNT);
|
||||||
|
if (!_blocks) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
client.send(JSON.stringify(this.getInitData(_blocks)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedMessage.action === 'ping') {
|
||||||
|
response['pong'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedMessage['track-donation'] && parsedMessage['track-donation'].length === 22) {
|
||||||
|
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 instanceof Error ? e.message : e));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleNewDonation(id: string) {
|
||||||
|
if (!this.wss) {
|
||||||
|
throw new Error('WebSocket.Server is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.wss.clients.forEach((client: WebSocket) => {
|
||||||
|
if (client.readyState !== WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (client['track-donation'] === id) {
|
||||||
|
client.send(JSON.stringify({ donationConfirmed: true }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleLoadingChanged(indicators: ILoadingIndicators) {
|
||||||
|
if (!this.wss) {
|
||||||
|
throw new Error('WebSocket.Server is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.wss.clients.forEach((client: WebSocket) => {
|
||||||
|
if (client.readyState !== WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
client.send(JSON.stringify({ loadingIndicators: indicators }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleNewConversionRates(conversionRates: IConversionRates) {
|
||||||
|
if (!this.wss) {
|
||||||
|
throw new Error('WebSocket.Server is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.wss.clients.forEach((client: WebSocket) => {
|
||||||
|
if (client.readyState !== WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
client.send(JSON.stringify({ conversions: conversionRates }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getInitData(_blocks?: BlockExtended[]) {
|
||||||
|
if (!_blocks) {
|
||||||
|
_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(),
|
||||||
|
'backendInfo': backendInfo.getBackendInfo(),
|
||||||
|
'loadingIndicators': loadingIndicators.getLoadingIndicators(),
|
||||||
|
...this.extraInitProperties
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
handleNewStatistic(stats: OptimizedStatistic) {
|
||||||
|
if (!this.wss) {
|
||||||
|
throw new Error('WebSocket.Server is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.wss.clients.forEach((client: WebSocket) => {
|
||||||
|
if (client.readyState !== WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client['want-live-2h-chart']) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.send(JSON.stringify({
|
||||||
|
'live-2h-chart': stats
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMempoolChange(newMempool: { [txid: string]: TransactionExtended },
|
||||||
|
newTransactions: TransactionExtended[], deletedTransactions: TransactionExtended[]) {
|
||||||
|
if (!this.wss) {
|
||||||
|
throw new Error('WebSocket.Server is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = {};
|
||||||
|
|
||||||
|
if (client['want-stats']) {
|
||||||
|
response['mempoolInfo'] = mempoolInfo;
|
||||||
|
response['vBytesPerSecond'] = vBytesPerSecond;
|
||||||
|
response['transactions'] = newTransactions.slice(0, 6).map((tx) => Common.stripTransaction(tx));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client['want-mempool-blocks']) {
|
||||||
|
response['mempool-blocks'] = mBlocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client['track-mempool-tx']) {
|
||||||
|
const tx = newTransactions.find((t) => t.txid === client['track-mempool-tx']);
|
||||||
|
if (tx) {
|
||||||
|
if (config.MEMPOOL.BACKEND !== 'esplora') {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
client['track-mempool-tx'] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client['track-address']) {
|
||||||
|
const foundTransactions: TransactionExtended[] = [];
|
||||||
|
|
||||||
|
for (const tx of newTransactions) {
|
||||||
|
const someVin = tx.vin.some((vin) => !!vin.prevout && vin.prevout.scriptpubkey_address === client['track-address']);
|
||||||
|
if (someVin) {
|
||||||
|
if (config.MEMPOOL.BACKEND !== 'esplora') {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const someVout = tx.vout.some((vout) => vout.scriptpubkey_address === client['track-address']);
|
||||||
|
if (someVout) {
|
||||||
|
if (config.MEMPOOL.BACKEND !== 'esplora') {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundTransactions.length) {
|
||||||
|
response['address-transactions'] = foundTransactions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client['track-asset']) {
|
||||||
|
const foundTransactions: TransactionExtended[] = [];
|
||||||
|
|
||||||
|
newTransactions.forEach((tx) => {
|
||||||
|
|
||||||
|
if (client['track-asset'] === Common.nativeAssetId) {
|
||||||
|
if (tx.vin.some((vin) => !!vin.is_pegin)) {
|
||||||
|
foundTransactions.push(tx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tx.vout.some((vout) => !!vout.pegout)) {
|
||||||
|
foundTransactions.push(tx);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (tx.vin.some((vin) => !!vin.issuance && vin.issuance.asset_id === client['track-asset'])) {
|
||||||
|
foundTransactions.push(tx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tx.vout.some((vout) => !!vout.asset && vout.asset === client['track-asset'])) {
|
||||||
|
foundTransactions.push(tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (foundTransactions.length) {
|
||||||
|
response['address-transactions'] = foundTransactions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client['track-tx'] && rbfTransactions[client['track-tx']]) {
|
||||||
|
for (const rbfTransaction in rbfTransactions) {
|
||||||
|
if (client['track-tx'] === rbfTransaction) {
|
||||||
|
const rbfTx = rbfTransactions[rbfTransaction];
|
||||||
|
if (config.MEMPOOL.BACKEND !== 'esplora') {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(response).length) {
|
||||||
|
client.send(JSON.stringify(response));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleNewBlock(block: BlockExtended, txIds: string[], transactions: TransactionExtended[]) {
|
||||||
|
if (!this.wss) {
|
||||||
|
throw new Error('WebSocket.Server is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
mempoolBlocks.updateMempoolBlocks(_memPool);
|
||||||
|
mBlocks = mempoolBlocks.getMempoolBlocks();
|
||||||
|
}
|
||||||
|
|
||||||
|
block.matchRate = matchRate;
|
||||||
|
|
||||||
|
this.wss.clients.forEach((client) => {
|
||||||
|
if (client.readyState !== WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client['want-blocks']) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
'block': block,
|
||||||
|
'mempoolInfo': memPool.getMempoolInfo(),
|
||||||
|
'lastDifficultyAdjustment': blocks.getLastDifficultyAdjustmentTime(),
|
||||||
|
'previousRetarget': blocks.getPreviousDifficultyRetarget(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mBlocks && client['want-mempool-blocks']) {
|
||||||
|
response['mempool-blocks'] = mBlocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client['track-tx'] && txIds.indexOf(client['track-tx']) > -1) {
|
||||||
|
client['track-tx'] = null;
|
||||||
|
response['txConfirmed'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client['track-address']) {
|
||||||
|
const foundTransactions: TransactionExtended[] = [];
|
||||||
|
|
||||||
|
transactions.forEach((tx) => {
|
||||||
|
if (tx.vin && tx.vin.some((vin) => !!vin.prevout && vin.prevout.scriptpubkey_address === client['track-address'])) {
|
||||||
|
foundTransactions.push(tx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tx.vout && tx.vout.some((vout) => vout.scriptpubkey_address === client['track-address'])) {
|
||||||
|
foundTransactions.push(tx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (foundTransactions.length) {
|
||||||
|
foundTransactions.forEach((tx) => {
|
||||||
|
tx.status = {
|
||||||
|
confirmed: true,
|
||||||
|
block_height: block.height,
|
||||||
|
block_hash: block.id,
|
||||||
|
block_time: block.timestamp,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
response['block-transactions'] = foundTransactions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client['track-asset']) {
|
||||||
|
const foundTransactions: TransactionExtended[] = [];
|
||||||
|
|
||||||
|
transactions.forEach((tx) => {
|
||||||
|
if (client['track-asset'] === Common.nativeAssetId) {
|
||||||
|
if (tx.vin && tx.vin.some((vin) => !!vin.is_pegin)) {
|
||||||
|
foundTransactions.push(tx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tx.vout && tx.vout.some((vout) => !!vout.pegout)) {
|
||||||
|
foundTransactions.push(tx);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (tx.vin && tx.vin.some((vin) => !!vin.issuance && vin.issuance.asset_id === client['track-asset'])) {
|
||||||
|
foundTransactions.push(tx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tx.vout && tx.vout.some((vout) => !!vout.asset && vout.asset === client['track-asset'])) {
|
||||||
|
foundTransactions.push(tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (foundTransactions.length) {
|
||||||
|
foundTransactions.forEach((tx) => {
|
||||||
|
tx.status = {
|
||||||
|
confirmed: true,
|
||||||
|
block_height: block.height,
|
||||||
|
block_hash: block.id,
|
||||||
|
block_time: block.timestamp,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
response['block-transactions'] = foundTransactions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client.send(JSON.stringify(response));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new WebsocketHandler();
|
||||||
162
backend/src/config.ts
Normal file
162
backend/src/config.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
const configFile = require('../mempool-config.json');
|
||||||
|
|
||||||
|
interface IConfig {
|
||||||
|
MEMPOOL: {
|
||||||
|
NETWORK: 'mainnet' | 'testnet' | 'signet' | 'liquid';
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
ESPLORA: {
|
||||||
|
REST_API_URL: string;
|
||||||
|
};
|
||||||
|
ELECTRUM: {
|
||||||
|
HOST: string;
|
||||||
|
PORT: number;
|
||||||
|
TLS_ENABLED: boolean;
|
||||||
|
};
|
||||||
|
CORE_RPC: {
|
||||||
|
HOST: string;
|
||||||
|
PORT: number;
|
||||||
|
USERNAME: string;
|
||||||
|
PASSWORD: string;
|
||||||
|
};
|
||||||
|
SECOND_CORE_RPC: {
|
||||||
|
HOST: string;
|
||||||
|
PORT: number;
|
||||||
|
USERNAME: string;
|
||||||
|
PASSWORD: string;
|
||||||
|
};
|
||||||
|
DATABASE: {
|
||||||
|
ENABLED: boolean;
|
||||||
|
HOST: string,
|
||||||
|
PORT: number;
|
||||||
|
DATABASE: string;
|
||||||
|
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: {
|
||||||
|
ENABLED: boolean;
|
||||||
|
DATA_PATH: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults: IConfig = {
|
||||||
|
'MEMPOOL': {
|
||||||
|
'NETWORK': 'mainnet',
|
||||||
|
'BACKEND': 'none',
|
||||||
|
'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,
|
||||||
|
},
|
||||||
|
'ESPLORA': {
|
||||||
|
'REST_API_URL': 'http://127.0.0.1:3000',
|
||||||
|
},
|
||||||
|
'ELECTRUM': {
|
||||||
|
'HOST': '127.0.0.1',
|
||||||
|
'PORT': 3306,
|
||||||
|
'TLS_ENABLED': true,
|
||||||
|
},
|
||||||
|
'CORE_RPC': {
|
||||||
|
'HOST': '127.0.0.1',
|
||||||
|
'PORT': 8332,
|
||||||
|
'USERNAME': 'mempool',
|
||||||
|
'PASSWORD': 'mempool'
|
||||||
|
},
|
||||||
|
'SECOND_CORE_RPC': {
|
||||||
|
'HOST': '127.0.0.1',
|
||||||
|
'PORT': 8332,
|
||||||
|
'USERNAME': 'mempool',
|
||||||
|
'PASSWORD': 'mempool'
|
||||||
|
},
|
||||||
|
'DATABASE': {
|
||||||
|
'ENABLED': true,
|
||||||
|
'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': {
|
||||||
|
'ENABLED': false,
|
||||||
|
'DATA_PATH': '/bisq/statsnode-data/btc_mainnet/db'
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
class Config implements IConfig {
|
||||||
|
MEMPOOL: IConfig['MEMPOOL'];
|
||||||
|
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: IConfig['BISQ'];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const configs = this.merge(configFile, defaults);
|
||||||
|
this.MEMPOOL = configs.MEMPOOL;
|
||||||
|
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 = configs.BISQ;
|
||||||
|
}
|
||||||
|
|
||||||
|
merge = (...objects: object[]): IConfig => {
|
||||||
|
// @ts-ignore
|
||||||
|
return objects.reduce((prev, next) => {
|
||||||
|
Object.keys(prev).forEach(key => {
|
||||||
|
next[key] = { ...next[key], ...prev[key] };
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new Config();
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
const config = require('../mempool-config.json');
|
import config from './config';
|
||||||
import { createPool } from 'mysql2/promise';
|
import { createPool } from 'mysql2/promise';
|
||||||
|
import logger from './logger';
|
||||||
|
|
||||||
export class DB {
|
export class DB {
|
||||||
static pool = createPool({
|
static pool = createPool({
|
||||||
host: config.DB_HOST,
|
host: config.DATABASE.HOST,
|
||||||
port: config.DB_PORT,
|
port: config.DATABASE.PORT,
|
||||||
database: config.DB_DATABASE,
|
database: config.DATABASE.DATABASE,
|
||||||
user: config.DB_USER,
|
user: config.DATABASE.USERNAME,
|
||||||
password: config.DB_PASSWORD,
|
password: config.DATABASE.PASSWORD,
|
||||||
connectionLimit: 10,
|
connectionLimit: 10,
|
||||||
supportBigNumbers: true,
|
supportBigNumbers: true,
|
||||||
});
|
});
|
||||||
@@ -16,11 +17,10 @@ export class DB {
|
|||||||
export async function checkDbConnection() {
|
export async function checkDbConnection() {
|
||||||
try {
|
try {
|
||||||
const connection = await DB.pool.getConnection();
|
const connection = await DB.pool.getConnection();
|
||||||
console.log('MySQL connection established.');
|
logger.info('Database connection established.');
|
||||||
connection.release();
|
connection.release();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Could not connect to MySQL.');
|
logger.err('Could not connect to database: ' + (e instanceof Error ? e.message : e));
|
||||||
console.log(e);
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,284 +1,277 @@
|
|||||||
const config = require('../mempool-config.json');
|
import { Express, Request, Response, NextFunction } from 'express';
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as express from 'express';
|
import * as express from 'express';
|
||||||
import * as compression from 'compression';
|
|
||||||
import * as http from 'http';
|
import * as http from 'http';
|
||||||
import * as https from 'https';
|
|
||||||
import * as WebSocket from 'ws';
|
import * as WebSocket from 'ws';
|
||||||
|
import * as cluster from 'cluster';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
import bitcoinApi from './api/bitcoin/bitcoin-api-factory';
|
import { checkDbConnection } from './database';
|
||||||
import diskCache from './api/disk-cache';
|
import config from './config';
|
||||||
import memPool from './api/mempool';
|
|
||||||
import blocks from './api/blocks';
|
|
||||||
import projectedBlocks from './api/projected-blocks';
|
|
||||||
import statistics from './api/statistics';
|
|
||||||
import { IBlock, IMempool, ITransaction, IMempoolStats } from './interfaces';
|
|
||||||
|
|
||||||
import routes from './routes';
|
import routes from './routes';
|
||||||
|
import blocks from './api/blocks';
|
||||||
|
import memPool from './api/mempool';
|
||||||
|
import diskCache from './api/disk-cache';
|
||||||
|
import statistics from './api/statistics';
|
||||||
|
import websocketHandler from './api/websocket-handler';
|
||||||
import fiatConversion from './api/fiat-conversion';
|
import fiatConversion from './api/fiat-conversion';
|
||||||
|
import bisq from './api/bisq/bisq';
|
||||||
|
import bisqMarkets from './api/bisq/markets';
|
||||||
|
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';
|
||||||
|
|
||||||
class MempoolSpace {
|
class Server {
|
||||||
private wss: WebSocket.Server;
|
private wss: WebSocket.Server | undefined;
|
||||||
private server: https.Server | http.Server;
|
private server: http.Server | undefined;
|
||||||
private app: any;
|
private app: Express;
|
||||||
|
private currentBackendRetryInterval = 5;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.app = express();
|
this.app = express();
|
||||||
|
|
||||||
|
if (!config.MEMPOOL.SPAWN_CLUSTER_PROCS) {
|
||||||
|
this.startServer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cluster.isMaster) {
|
||||||
|
logger.notice(`Mempool Server (Master) is running on port ${config.MEMPOOL.HTTP_PORT} (${backendInfo.getShortCommitHash()})`);
|
||||||
|
|
||||||
|
const numCPUs = config.MEMPOOL.SPAWN_CLUSTER_PROCS;
|
||||||
|
for (let i = 0; i < numCPUs; i++) {
|
||||||
|
const env = { workerId: i };
|
||||||
|
const worker = cluster.fork(env);
|
||||||
|
worker.process['env'] = env;
|
||||||
|
}
|
||||||
|
|
||||||
|
cluster.on('exit', (worker, code, signal) => {
|
||||||
|
const workerId = worker.process['env'].workerId;
|
||||||
|
logger.warn(`Mempool Worker PID #${worker.process.pid} workerId: ${workerId} died. Restarting in 10 seconds... ${signal || code}`);
|
||||||
|
setTimeout(() => {
|
||||||
|
const env = { workerId: workerId };
|
||||||
|
const newWorker = cluster.fork(env);
|
||||||
|
newWorker.process['env'] = env;
|
||||||
|
}, 10000);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.startServer(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async startServer(worker = false) {
|
||||||
|
logger.debug(`Starting Mempool Server${worker ? ' (worker)' : ''}... (${backendInfo.getShortCommitHash()})`);
|
||||||
|
|
||||||
this.app
|
this.app
|
||||||
.use((req, res, next) => {
|
.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
next();
|
next();
|
||||||
})
|
})
|
||||||
.use(compression());
|
.use(express.urlencoded({ extended: true }))
|
||||||
if (config.ENV === 'dev') {
|
.use(express.json());
|
||||||
this.server = http.createServer(this.app);
|
|
||||||
this.wss = new WebSocket.Server({ server: this.server });
|
this.server = http.createServer(this.app);
|
||||||
} else {
|
this.wss = new WebSocket.Server({ server: this.server });
|
||||||
const credentials = {
|
|
||||||
cert: fs.readFileSync('/etc/letsencrypt/live/mempool.space/fullchain.pem'),
|
this.setUpWebsocketHandling();
|
||||||
key: fs.readFileSync('/etc/letsencrypt/live/mempool.space/privkey.pem'),
|
|
||||||
};
|
diskCache.loadMempoolCache();
|
||||||
this.server = https.createServer(credentials, this.app);
|
|
||||||
this.wss = new WebSocket.Server({ server: this.server });
|
if (config.DATABASE.ENABLED) {
|
||||||
|
await checkDbConnection();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setUpRoutes();
|
if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED && cluster.isMaster) {
|
||||||
this.setUpWebsocketHandling();
|
statistics.startStatistics();
|
||||||
this.setUpMempoolCache();
|
}
|
||||||
this.runMempoolIntervalFunctions();
|
|
||||||
|
|
||||||
statistics.startStatistics();
|
|
||||||
fiatConversion.startService();
|
fiatConversion.startService();
|
||||||
|
|
||||||
const opts = {
|
this.setUpHttpApiRoutes();
|
||||||
host: '127.0.0.1',
|
this.runMainUpdateLoop();
|
||||||
port: 8999
|
|
||||||
};
|
|
||||||
this.server.listen(opts, () => {
|
|
||||||
console.log(`Server started on ${opts.host}:${opts.port}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async runMempoolIntervalFunctions() {
|
if (config.BISQ.ENABLED) {
|
||||||
await blocks.updateBlocks();
|
bisq.startBisqService();
|
||||||
await memPool.updateMemPoolInfo();
|
bisq.setPriceCallbackFunction((price) => websocketHandler.setExtraInitProperties('bsq-price', price));
|
||||||
await memPool.updateMempool();
|
blocks.setNewBlockCallback(bisq.handleNewBitcoinBlock.bind(bisq));
|
||||||
setTimeout(this.runMempoolIntervalFunctions.bind(this), config.MEMPOOL_REFRESH_RATE_MS);
|
bisqMarkets.startBisqService();
|
||||||
}
|
|
||||||
|
|
||||||
private setUpMempoolCache() {
|
|
||||||
const cacheData = diskCache.loadData();
|
|
||||||
if (cacheData) {
|
|
||||||
memPool.setMempool(JSON.parse(cacheData));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
process.on('SIGINT', (options) => {
|
this.server.listen(config.MEMPOOL.HTTP_PORT, () => {
|
||||||
console.log('SIGINT');
|
if (worker) {
|
||||||
diskCache.saveData(JSON.stringify(memPool.getMempool()));
|
logger.info(`Mempool Server worker #${process.pid} started`);
|
||||||
process.exit(2);
|
} else {
|
||||||
|
logger.notice(`Mempool Server is running on port ${config.MEMPOOL.HTTP_PORT}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private setUpWebsocketHandling() {
|
async runMainUpdateLoop() {
|
||||||
this.wss.on('connection', (client: WebSocket) => {
|
try {
|
||||||
let theBlocks = blocks.getBlocks();
|
try {
|
||||||
theBlocks = theBlocks.concat([]).splice(theBlocks.length - config.INITIAL_BLOCK_AMOUNT);
|
await memPool.$updateMemPoolInfo();
|
||||||
const formatedBlocks = theBlocks.map((b) => blocks.formatBlock(b));
|
} 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 instanceof Error ? e.message : e)}. Retrying in ${this.currentBackendRetryInterval} sec.`;
|
||||||
|
if (this.currentBackendRetryInterval > 5) {
|
||||||
|
logger.warn(loggerMsg);
|
||||||
|
mempool.setOutOfSync();
|
||||||
|
} else {
|
||||||
|
logger.debug(loggerMsg);
|
||||||
|
}
|
||||||
|
logger.debug(JSON.stringify(e));
|
||||||
|
setTimeout(this.runMainUpdateLoop.bind(this), 1000 * this.currentBackendRetryInterval);
|
||||||
|
this.currentBackendRetryInterval *= 2;
|
||||||
|
this.currentBackendRetryInterval = Math.min(this.currentBackendRetryInterval, 60);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
client.send(JSON.stringify({
|
setUpWebsocketHandling() {
|
||||||
'mempoolInfo': memPool.getMempoolInfo(),
|
if (this.wss) {
|
||||||
'blocks': formatedBlocks,
|
websocketHandler.setWebsocketServer(this.wss);
|
||||||
'projectedBlocks': projectedBlocks.getProjectedBlocks(),
|
}
|
||||||
'txPerSecond': memPool.getTxPerSecond(),
|
if (config.MEMPOOL.NETWORK === 'liquid') {
|
||||||
'vBytesPerSecond': memPool.getVBytesPerSecond(),
|
blocks.setNewBlockCallback(async () => {
|
||||||
'conversions': fiatConversion.getTickers()['BTCUSD'],
|
|
||||||
}));
|
|
||||||
|
|
||||||
client.on('message', async (message: any) => {
|
|
||||||
try {
|
try {
|
||||||
const parsedMessage = JSON.parse(message);
|
await elementsParser.$parse();
|
||||||
|
|
||||||
if (parsedMessage.action === 'want') {
|
|
||||||
client['want-stats'] = parsedMessage.data.indexOf('stats') > -1;
|
|
||||||
client['want-blocks'] = parsedMessage.data.indexOf('blocks') > -1;
|
|
||||||
client['want-projected-blocks'] = parsedMessage.data.indexOf('projected-blocks') > -1;
|
|
||||||
client['want-live-2h-chart'] = parsedMessage.data.indexOf('live-2h-chart') > -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsedMessage.action === 'track-tx' && parsedMessage.txId && /^[a-fA-F0-9]{64}$/.test(parsedMessage.txId)) {
|
|
||||||
const tx = await memPool.getRawTransaction(parsedMessage.txId);
|
|
||||||
if (tx) {
|
|
||||||
console.log('Now tracking: ' + parsedMessage.txId);
|
|
||||||
client['trackingTx'] = true;
|
|
||||||
client['txId'] = parsedMessage.txId;
|
|
||||||
client['tx'] = tx;
|
|
||||||
|
|
||||||
if (tx.blockhash) {
|
|
||||||
const currentBlocks = blocks.getBlocks();
|
|
||||||
const foundBlock = currentBlocks.find((block) => block.tx && block.tx.some((i: string) => i === parsedMessage.txId));
|
|
||||||
if (foundBlock) {
|
|
||||||
console.log('Found block by looking in local cache');
|
|
||||||
client['blockHeight'] = foundBlock.height;
|
|
||||||
} else {
|
|
||||||
const theBlock = await bitcoinApi.getBlockAndTransactions(tx.blockhash);
|
|
||||||
if (theBlock) {
|
|
||||||
client['blockHeight'] = theBlock.height;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
client['blockHeight'] = 0;
|
|
||||||
}
|
|
||||||
client.send(JSON.stringify({
|
|
||||||
'projectedBlocks': projectedBlocks.getProjectedBlocks(client['txId']),
|
|
||||||
'track-tx': {
|
|
||||||
tracking: true,
|
|
||||||
blockHeight: client['blockHeight'],
|
|
||||||
tx: client['tx'],
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
console.log('TX NOT FOUND, NOT TRACKING');
|
|
||||||
client['trackingTx'] = false;
|
|
||||||
client['blockHeight'] = 0;
|
|
||||||
client['tx'] = null;
|
|
||||||
client.send(JSON.stringify({
|
|
||||||
'track-tx': {
|
|
||||||
tracking: false,
|
|
||||||
blockHeight: 0,
|
|
||||||
message: 'not-found',
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (parsedMessage.action === 'stop-tracking-tx') {
|
|
||||||
console.log('STOP TRACKING');
|
|
||||||
client['trackingTx'] = false;
|
|
||||||
client.send(JSON.stringify({
|
|
||||||
'track-tx': {
|
|
||||||
tracking: false,
|
|
||||||
blockHeight: 0,
|
|
||||||
message: 'not-found',
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
logger.warn('Elements parsing error: ' + (e instanceof Error ? e.message : e));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
client.on('close', () => {
|
websocketHandler.setupConnectionHandling();
|
||||||
client['trackingTx'] = false;
|
statistics.setNewStatisticsEntryCallback(websocketHandler.handleNewStatistic.bind(websocketHandler));
|
||||||
});
|
blocks.setNewBlockCallback(websocketHandler.handleNewBlock.bind(websocketHandler));
|
||||||
});
|
memPool.setMempoolChangedCallback(websocketHandler.handleMempoolChange.bind(websocketHandler));
|
||||||
|
fiatConversion.setProgressChangedCallback(websocketHandler.handleNewConversionRates.bind(websocketHandler));
|
||||||
blocks.setNewBlockCallback((block: IBlock) => {
|
loadingIndicators.setProgressChangedCallback(websocketHandler.handleLoadingChanged.bind(websocketHandler));
|
||||||
const formattedBlocks = blocks.formatBlock(block);
|
|
||||||
|
|
||||||
this.wss.clients.forEach((client) => {
|
|
||||||
if (client.readyState !== WebSocket.OPEN) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = {};
|
|
||||||
|
|
||||||
if (client['trackingTx'] === true && client['blockHeight'] === 0) {
|
|
||||||
if (block.tx.some((tx: ITransaction) => tx === client['txId'])) {
|
|
||||||
client['blockHeight'] = block.height;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
response['track-tx'] = {
|
|
||||||
tracking: client['trackingTx'] || false,
|
|
||||||
blockHeight: client['blockHeight'],
|
|
||||||
};
|
|
||||||
|
|
||||||
response['block'] = formattedBlocks;
|
|
||||||
|
|
||||||
client.send(JSON.stringify(response));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
memPool.setMempoolChangedCallback((newMempool: IMempool) => {
|
|
||||||
projectedBlocks.updateProjectedBlocks(newMempool);
|
|
||||||
|
|
||||||
const pBlocks = projectedBlocks.getProjectedBlocks();
|
|
||||||
const mempoolInfo = memPool.getMempoolInfo();
|
|
||||||
const txPerSecond = memPool.getTxPerSecond();
|
|
||||||
const vBytesPerSecond = memPool.getVBytesPerSecond();
|
|
||||||
|
|
||||||
this.wss.clients.forEach((client: WebSocket) => {
|
|
||||||
if (client.readyState !== WebSocket.OPEN) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = {};
|
|
||||||
|
|
||||||
if (client['want-stats']) {
|
|
||||||
response['mempoolInfo'] = mempoolInfo;
|
|
||||||
response['txPerSecond'] = txPerSecond;
|
|
||||||
response['vBytesPerSecond'] = vBytesPerSecond;
|
|
||||||
response['track-tx'] = {
|
|
||||||
tracking: client['trackingTx'] || false,
|
|
||||||
blockHeight: client['blockHeight'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (client['want-projected-blocks'] && client['trackingTx'] && client['blockHeight'] === 0) {
|
|
||||||
response['projectedBlocks'] = projectedBlocks.getProjectedBlocks(client['txId']);
|
|
||||||
} else if (client['want-projected-blocks']) {
|
|
||||||
response['projectedBlocks'] = pBlocks;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(response).length) {
|
|
||||||
client.send(JSON.stringify(response));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
statistics.setNewStatisticsEntryCallback((stats: IMempoolStats) => {
|
|
||||||
this.wss.clients.forEach((client: WebSocket) => {
|
|
||||||
if (client.readyState !== WebSocket.OPEN) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (client['want-live-2h-chart']) {
|
|
||||||
client.send(JSON.stringify({
|
|
||||||
'live-2h-chart': stats
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private setUpRoutes() {
|
setUpHttpApiRoutes() {
|
||||||
this.app
|
this.app
|
||||||
.get(config.API_ENDPOINT + 'transactions/height/:id', routes.$getgetTransactionsForBlock)
|
.get(config.MEMPOOL.API_URL_PREFIX + 'transaction-times', routes.getTransactionTimes)
|
||||||
.get(config.API_ENDPOINT + 'transactions/projected/:id', routes.getgetTransactionsForProjectedBlock)
|
.get(config.MEMPOOL.API_URL_PREFIX + 'cpfp/:txId', routes.getCpfpInfo)
|
||||||
.get(config.API_ENDPOINT + 'fees/recommended', routes.getRecommendedFees)
|
.get(config.MEMPOOL.API_URL_PREFIX + 'difficulty-adjustment', routes.getDifficultyChange)
|
||||||
.get(config.API_ENDPOINT + 'fees/projected-blocks', routes.getProjectedBlocks)
|
.get(config.MEMPOOL.API_URL_PREFIX + 'fees/recommended', routes.getRecommendedFees)
|
||||||
.get(config.API_ENDPOINT + 'statistics/2h', routes.get2HStatistics)
|
.get(config.MEMPOOL.API_URL_PREFIX + 'fees/mempool-blocks', routes.getMempoolBlocks)
|
||||||
.get(config.API_ENDPOINT + 'statistics/24h', routes.get24HStatistics.bind(routes))
|
.get(config.MEMPOOL.API_URL_PREFIX + 'backend-info', routes.getBackendInfo)
|
||||||
.get(config.API_ENDPOINT + 'statistics/1w', routes.get1WHStatistics.bind(routes))
|
.get(config.MEMPOOL.API_URL_PREFIX + 'init-data', routes.getInitData)
|
||||||
.get(config.API_ENDPOINT + 'statistics/1m', routes.get1MStatistics.bind(routes))
|
.get(config.MEMPOOL.API_URL_PREFIX + 'validate-address/:address', routes.validateAddress)
|
||||||
.get(config.API_ENDPOINT + 'statistics/3m', routes.get3MStatistics.bind(routes))
|
.get(config.MEMPOOL.API_URL_PREFIX + 'donations', async (req, res) => {
|
||||||
.get(config.API_ENDPOINT + 'statistics/6m', routes.get6MStatistics.bind(routes))
|
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();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
;
|
||||||
|
|
||||||
if (config.BACKEND_API === 'electrs') {
|
if (config.STATISTICS.ENABLED && config.DATABASE.ENABLED) {
|
||||||
this.app
|
this.app
|
||||||
.get(config.API_ENDPOINT + 'explorer/blocks', routes.getBlocks)
|
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/2h', routes.get2HStatistics)
|
||||||
.get(config.API_ENDPOINT + 'explorer/blocks/:height', routes.getBlocks)
|
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/24h', routes.get24HStatistics.bind(routes))
|
||||||
.get(config.API_ENDPOINT + 'explorer/tx/:id', routes.getRawTransaction)
|
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/1w', routes.get1WHStatistics.bind(routes))
|
||||||
.get(config.API_ENDPOINT + 'explorer/block/:hash', routes.getBlock)
|
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/1m', routes.get1MStatistics.bind(routes))
|
||||||
.get(config.API_ENDPOINT + 'explorer/block/:hash/tx', routes.getBlockTransactions)
|
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/3m', routes.get3MStatistics.bind(routes))
|
||||||
.get(config.API_ENDPOINT + 'explorer/block/:hash/tx/:index', routes.getBlockTransactionsFromIndex)
|
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/6m', routes.get6MStatistics.bind(routes))
|
||||||
.get(config.API_ENDPOINT + 'explorer/address/:address', routes.getAddress)
|
.get(config.MEMPOOL.API_URL_PREFIX + 'statistics/1y', routes.get1YStatistics.bind(routes))
|
||||||
.get(config.API_ENDPOINT + 'explorer/address/:address/tx', routes.getAddressTransactions)
|
|
||||||
.get(config.API_ENDPOINT + 'explorer/address/:address/tx/chain/:txid', routes.getAddressTransactionsFromTxid)
|
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config.BISQ.ENABLED) {
|
||||||
|
this.app
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/stats', routes.getBisqStats)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/tx/:txId', routes.getBisqTransaction)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/block/:hash', routes.getBisqBlock)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/blocks/tip/height', routes.getBisqTip)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/blocks/:index/:length', routes.getBisqBlocks)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/address/:address', routes.getBisqAddress)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/txs/:index/:length', routes.getBisqTransactions)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/currencies', routes.getBisqMarketCurrencies.bind(routes))
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/depth', routes.getBisqMarketDepth.bind(routes))
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/hloc', routes.getBisqMarketHloc.bind(routes))
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/markets', routes.getBisqMarketMarkets.bind(routes))
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/offers', routes.getBisqMarketOffers.bind(routes))
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/ticker', routes.getBisqMarketTicker.bind(routes))
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/trades', routes.getBisqMarketTrades.bind(routes))
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/volumes', routes.getBisqMarketVolumes.bind(routes))
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'bisq/markets/volumes/7d', routes.getBisqMarketVolumes7d.bind(routes))
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 + '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)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'address/:address/txs/chain/:txId', routes.getAddressTransactions)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'address-prefix/:prefix', routes.getAddressPrefix)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.MEMPOOL.NETWORK === 'liquid') {
|
||||||
|
this.app
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'liquid/pegs/month', routes.$getElementsPegsByMonth)
|
||||||
|
;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const mempoolSpace = new MempoolSpace();
|
const server = new Server();
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
export interface IMempoolInfo {
|
|
||||||
size: number;
|
|
||||||
bytes: number;
|
|
||||||
usage?: number;
|
|
||||||
maxmempool?: number;
|
|
||||||
mempoolminfee?: number;
|
|
||||||
minrelaytxfee?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ITransaction {
|
|
||||||
txid: string;
|
|
||||||
hash: string;
|
|
||||||
version: number;
|
|
||||||
size: number;
|
|
||||||
vsize: number;
|
|
||||||
weight: number;
|
|
||||||
locktime: number;
|
|
||||||
vin: Vin[];
|
|
||||||
vout: Vout[];
|
|
||||||
hex: string;
|
|
||||||
fee: number;
|
|
||||||
feePerWeightUnit: number;
|
|
||||||
feePerVsize: number;
|
|
||||||
blockhash?: string;
|
|
||||||
confirmations?: number;
|
|
||||||
time?: number;
|
|
||||||
blocktime?: number;
|
|
||||||
totalOut?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IBlock {
|
|
||||||
hash: string;
|
|
||||||
confirmations: number;
|
|
||||||
strippedsize: number;
|
|
||||||
size: number;
|
|
||||||
weight: number;
|
|
||||||
height: number;
|
|
||||||
version: number;
|
|
||||||
versionHex: string;
|
|
||||||
merkleroot: string;
|
|
||||||
tx: any;
|
|
||||||
time: number;
|
|
||||||
mediantime: number;
|
|
||||||
nonce: number;
|
|
||||||
bits: string;
|
|
||||||
difficulty: number;
|
|
||||||
chainwork: string;
|
|
||||||
nTx: number;
|
|
||||||
previousblockhash: string;
|
|
||||||
fees: number;
|
|
||||||
|
|
||||||
minFee?: number;
|
|
||||||
maxFee?: number;
|
|
||||||
medianFee?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ScriptSig {
|
|
||||||
asm: string;
|
|
||||||
hex: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Vin {
|
|
||||||
txid: string;
|
|
||||||
vout: number;
|
|
||||||
scriptSig: ScriptSig;
|
|
||||||
sequence: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ScriptPubKey {
|
|
||||||
asm: string;
|
|
||||||
hex: string;
|
|
||||||
reqSigs: number;
|
|
||||||
type: string;
|
|
||||||
addresses: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Vout {
|
|
||||||
value: number;
|
|
||||||
n: number;
|
|
||||||
scriptPubKey: ScriptPubKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IMempoolStats {
|
|
||||||
id?: number;
|
|
||||||
added: string;
|
|
||||||
unconfirmed_transactions: number;
|
|
||||||
tx_per_second: number;
|
|
||||||
vbytes_per_second: number;
|
|
||||||
total_fee: number;
|
|
||||||
mempool_byte_weight: number;
|
|
||||||
fee_data: string;
|
|
||||||
|
|
||||||
vsize_1: number;
|
|
||||||
vsize_2: number;
|
|
||||||
vsize_3: number;
|
|
||||||
vsize_4: number;
|
|
||||||
vsize_5: number;
|
|
||||||
vsize_6: number;
|
|
||||||
vsize_8: number;
|
|
||||||
vsize_10: number;
|
|
||||||
vsize_12: number;
|
|
||||||
vsize_15: number;
|
|
||||||
vsize_20: number;
|
|
||||||
vsize_30: number;
|
|
||||||
vsize_40: number;
|
|
||||||
vsize_50: number;
|
|
||||||
vsize_60: number;
|
|
||||||
vsize_70: number;
|
|
||||||
vsize_80: number;
|
|
||||||
vsize_90: number;
|
|
||||||
vsize_100: number;
|
|
||||||
vsize_125: number;
|
|
||||||
vsize_150: number;
|
|
||||||
vsize_175: number;
|
|
||||||
vsize_200: number;
|
|
||||||
vsize_250: number;
|
|
||||||
vsize_300: number;
|
|
||||||
vsize_350: number;
|
|
||||||
vsize_400: number;
|
|
||||||
vsize_500: number;
|
|
||||||
vsize_600: number;
|
|
||||||
vsize_700: number;
|
|
||||||
vsize_800: number;
|
|
||||||
vsize_900: number;
|
|
||||||
vsize_1000: number;
|
|
||||||
vsize_1200: number;
|
|
||||||
vsize_1400: number;
|
|
||||||
vsize_1600: number;
|
|
||||||
vsize_1800: number;
|
|
||||||
vsize_2000: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IProjectedBlockInternal extends IProjectedBlock {
|
|
||||||
txIds: string[];
|
|
||||||
txFeePerVsizes: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IProjectedBlock {
|
|
||||||
blockSize: number;
|
|
||||||
blockWeight: number;
|
|
||||||
maxFee: number;
|
|
||||||
maxWeightFee: number;
|
|
||||||
medianFee: number;
|
|
||||||
minFee: number;
|
|
||||||
minWeightFee: number;
|
|
||||||
nTx: number;
|
|
||||||
fees: number;
|
|
||||||
hasMyTxId?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IMempool { [txid: string]: ITransaction; }
|
|
||||||
|
|
||||||
145
backend/src/logger.ts
Normal file
145
backend/src/logger.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import config from './config';
|
||||||
|
import * as dgram from 'dgram';
|
||||||
|
|
||||||
|
class Logger {
|
||||||
|
static priorities = {
|
||||||
|
emerg: 0,
|
||||||
|
alert: 1,
|
||||||
|
crit: 2,
|
||||||
|
err: 3,
|
||||||
|
warn: 4,
|
||||||
|
notice: 5,
|
||||||
|
info: 6,
|
||||||
|
debug: 7
|
||||||
|
};
|
||||||
|
static facilities = {
|
||||||
|
kern: 0,
|
||||||
|
user: 1,
|
||||||
|
mail: 2,
|
||||||
|
daemon: 3,
|
||||||
|
auth: 4,
|
||||||
|
syslog: 5,
|
||||||
|
lpr: 6,
|
||||||
|
news: 7,
|
||||||
|
uucp: 8,
|
||||||
|
local0: 16,
|
||||||
|
local1: 17,
|
||||||
|
local2: 18,
|
||||||
|
local3: 19,
|
||||||
|
local4: 20,
|
||||||
|
local5: 21,
|
||||||
|
local6: 22,
|
||||||
|
local7: 23
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
public emerg: ((msg: string) => void);
|
||||||
|
// @ts-ignore
|
||||||
|
public alert: ((msg: string) => void);
|
||||||
|
// @ts-ignore
|
||||||
|
public crit: ((msg: string) => void);
|
||||||
|
// @ts-ignore
|
||||||
|
public err: ((msg: string) => void);
|
||||||
|
// @ts-ignore
|
||||||
|
public warn: ((msg: string) => void);
|
||||||
|
// @ts-ignore
|
||||||
|
public notice: ((msg: string) => void);
|
||||||
|
// @ts-ignore
|
||||||
|
public info: ((msg: string) => void);
|
||||||
|
// @ts-ignore
|
||||||
|
public debug: ((msg: string) => void);
|
||||||
|
|
||||||
|
private name = 'mempool';
|
||||||
|
private client: dgram.Socket;
|
||||||
|
private network: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
let prio;
|
||||||
|
for (prio in Logger.priorities) {
|
||||||
|
if (true) {
|
||||||
|
this.addprio(prio);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.client = dgram.createSocket('udp4');
|
||||||
|
this.network = this.getNetwork();
|
||||||
|
}
|
||||||
|
|
||||||
|
private addprio(prio): void {
|
||||||
|
this[prio] = (function(_this) {
|
||||||
|
return function(msg) {
|
||||||
|
return _this.msg(prio, msg);
|
||||||
|
};
|
||||||
|
})(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getNetwork(): string {
|
||||||
|
if (config.BISQ.ENABLED) {
|
||||||
|
return 'bisq';
|
||||||
|
}
|
||||||
|
if (config.MEMPOOL.NETWORK && config.MEMPOOL.NETWORK !== 'mainnet') {
|
||||||
|
return config.MEMPOOL.NETWORK;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private msg(priority, msg) {
|
||||||
|
let consolemsg, prionum, syslogmsg;
|
||||||
|
if (typeof msg === 'string' && msg.length > 0) {
|
||||||
|
while (msg[msg.length - 1].charCodeAt(0) === 10) {
|
||||||
|
msg = msg.slice(0, msg.length - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const network = this.network ? ' <' + this.network + '>' : '';
|
||||||
|
prionum = Logger.priorities[priority] || Logger.priorities.info;
|
||||||
|
consolemsg = `${this.ts()} [${process.pid}] ${priority.toUpperCase()}:${network} ${msg}`;
|
||||||
|
|
||||||
|
if (config.SYSLOG.ENABLED && Logger.priorities[priority] <= Logger.priorities[config.SYSLOG.MIN_PRIORITY]) {
|
||||||
|
syslogmsg = `<${(Logger.facilities[config.SYSLOG.FACILITY] * 8 + prionum)}> ${this.name}[${process.pid}]: ${priority.toUpperCase()}${network} ${msg}`;
|
||||||
|
this.syslog(syslogmsg);
|
||||||
|
}
|
||||||
|
if (priority === 'warning') {
|
||||||
|
priority = 'warn';
|
||||||
|
}
|
||||||
|
if (priority === 'debug') {
|
||||||
|
priority = 'info';
|
||||||
|
}
|
||||||
|
if (priority === 'err') {
|
||||||
|
priority = 'error';
|
||||||
|
}
|
||||||
|
return (console[priority] || console.error)(consolemsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private syslog(msg) {
|
||||||
|
let msgbuf;
|
||||||
|
msgbuf = Buffer.from(msg);
|
||||||
|
this.client.send(msgbuf, 0, msgbuf.length, config.SYSLOG.PORT, config.SYSLOG.HOST, function(err, bytes) {
|
||||||
|
if (err) {
|
||||||
|
console.log(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private leadZero(n: number): number | string {
|
||||||
|
if (n < 10) {
|
||||||
|
return '0' + n;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ts() {
|
||||||
|
let day, dt, hours, minutes, month, months, seconds;
|
||||||
|
dt = new Date();
|
||||||
|
hours = this.leadZero(dt.getHours());
|
||||||
|
minutes = this.leadZero(dt.getMinutes());
|
||||||
|
seconds = this.leadZero(dt.getSeconds());
|
||||||
|
month = dt.getMonth();
|
||||||
|
day = dt.getDate();
|
||||||
|
if (day < 10) {
|
||||||
|
day = ' ' + day;
|
||||||
|
}
|
||||||
|
months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
return months[month] + ' ' + day + ' ' + hours + ':' + minutes + ':' + seconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new Logger();
|
||||||
169
backend/src/mempool.interfaces.ts
Normal file
169
backend/src/mempool.interfaces.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { IEsploraApi } from './api/bitcoin/esplora-api.interface';
|
||||||
|
|
||||||
|
export interface MempoolBlock {
|
||||||
|
blockSize: number;
|
||||||
|
blockVSize: number;
|
||||||
|
nTx: number;
|
||||||
|
medianFee: number;
|
||||||
|
totalFees: number;
|
||||||
|
feeRange: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MempoolBlockWithTransactions extends MempoolBlock {
|
||||||
|
transactionIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VinStrippedToScriptsig {
|
||||||
|
scriptsig: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VoutStrippedToScriptPubkey {
|
||||||
|
scriptpubkey_address: string | undefined;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
vsize: number;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
export interface BlockExtended extends IEsploraApi.Block {
|
||||||
|
medianFee?: number;
|
||||||
|
feeRange?: number[];
|
||||||
|
reward?: number;
|
||||||
|
coinbaseTx?: TransactionMinerInfo;
|
||||||
|
matchRate?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransactionMinerInfo {
|
||||||
|
vin: VinStrippedToScriptsig[];
|
||||||
|
vout: VoutStrippedToScriptPubkey[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MempoolStats {
|
||||||
|
funded_txo_count: number;
|
||||||
|
funded_txo_sum: number;
|
||||||
|
spent_txo_count: number;
|
||||||
|
spent_txo_sum: number;
|
||||||
|
tx_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Statistic {
|
||||||
|
id?: number;
|
||||||
|
added: string;
|
||||||
|
unconfirmed_transactions: number;
|
||||||
|
tx_per_second: number;
|
||||||
|
vbytes_per_second: number;
|
||||||
|
total_fee: number;
|
||||||
|
mempool_byte_weight: number;
|
||||||
|
fee_data: string;
|
||||||
|
|
||||||
|
vsize_1: number;
|
||||||
|
vsize_2: number;
|
||||||
|
vsize_3: number;
|
||||||
|
vsize_4: number;
|
||||||
|
vsize_5: number;
|
||||||
|
vsize_6: number;
|
||||||
|
vsize_8: number;
|
||||||
|
vsize_10: number;
|
||||||
|
vsize_12: number;
|
||||||
|
vsize_15: number;
|
||||||
|
vsize_20: number;
|
||||||
|
vsize_30: number;
|
||||||
|
vsize_40: number;
|
||||||
|
vsize_50: number;
|
||||||
|
vsize_60: number;
|
||||||
|
vsize_70: number;
|
||||||
|
vsize_80: number;
|
||||||
|
vsize_90: number;
|
||||||
|
vsize_100: number;
|
||||||
|
vsize_125: number;
|
||||||
|
vsize_150: number;
|
||||||
|
vsize_175: number;
|
||||||
|
vsize_200: number;
|
||||||
|
vsize_250: number;
|
||||||
|
vsize_300: number;
|
||||||
|
vsize_350: number;
|
||||||
|
vsize_400: number;
|
||||||
|
vsize_500: number;
|
||||||
|
vsize_600: number;
|
||||||
|
vsize_700: number;
|
||||||
|
vsize_800: number;
|
||||||
|
vsize_900: number;
|
||||||
|
vsize_1000: number;
|
||||||
|
vsize_1200: number;
|
||||||
|
vsize_1400: number;
|
||||||
|
vsize_1600: number;
|
||||||
|
vsize_1800: number;
|
||||||
|
vsize_2000: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
vsizes: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebsocketResponse {
|
||||||
|
action: string;
|
||||||
|
data: string[];
|
||||||
|
'track-tx': string;
|
||||||
|
'track-address': string;
|
||||||
|
'watch-mempool': boolean;
|
||||||
|
'track-bisq-market': string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VbytesPerSecond {
|
||||||
|
unixTime: number;
|
||||||
|
vSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequiredSpec { [name: string]: RequiredParams; }
|
||||||
|
|
||||||
|
interface RequiredParams {
|
||||||
|
required: boolean;
|
||||||
|
types: ('@string' | '@number' | '@boolean' | string)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ILoadingIndicators { [name: string]: number; }
|
||||||
|
export interface IConversionRates { [currency: string]: number; }
|
||||||
|
|
||||||
|
export interface IBackendInfo {
|
||||||
|
hostname: string;
|
||||||
|
gitCommit: string;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
@@ -1,196 +1,779 @@
|
|||||||
|
import config from './config';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
import statistics from './api/statistics';
|
import statistics from './api/statistics';
|
||||||
import feeApi from './api/fee-api';
|
import feeApi from './api/fee-api';
|
||||||
import projectedBlocks from './api/projected-blocks';
|
import backendInfo from './api/backend-info';
|
||||||
|
import mempoolBlocks from './api/mempool-blocks';
|
||||||
|
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 { RequiredSpec, TransactionExtended } from './mempool.interfaces';
|
||||||
|
import { MarketsApiError } from './api/bisq/interfaces';
|
||||||
|
import { IEsploraApi } from './api/bitcoin/esplora-api.interface';
|
||||||
|
import logger from './logger';
|
||||||
import bitcoinApi from './api/bitcoin/bitcoin-api-factory';
|
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';
|
||||||
|
|
||||||
class Routes {
|
class Routes {
|
||||||
private cache = {};
|
constructor() {}
|
||||||
|
|
||||||
constructor() {
|
public async get2HStatistics(req: Request, res: Response) {
|
||||||
this.createCache();
|
|
||||||
setInterval(this.createCache.bind(this), 600000);
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
console.log('Statistics cache created');
|
|
||||||
}
|
|
||||||
|
|
||||||
public async get2HStatistics(req, res) {
|
|
||||||
const result = await statistics.$list2H();
|
const result = await statistics.$list2H();
|
||||||
res.send(result);
|
res.json(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get24HStatistics(req, res) {
|
public get24HStatistics(req: Request, res: Response) {
|
||||||
res.send(this.cache['24h']);
|
res.json(statistics.getCache()['24h']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get1WHStatistics(req, res) {
|
public get1WHStatistics(req: Request, res: Response) {
|
||||||
res.send(this.cache['1w']);
|
res.json(statistics.getCache()['1w']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get1MStatistics(req, res) {
|
public get1MStatistics(req: Request, res: Response) {
|
||||||
res.send(this.cache['1m']);
|
res.json(statistics.getCache()['1m']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get3MStatistics(req, res) {
|
public get3MStatistics(req: Request, res: Response) {
|
||||||
res.send(this.cache['3m']);
|
res.json(statistics.getCache()['3m']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get6MStatistics(req, res) {
|
public get6MStatistics(req: Request, res: Response) {
|
||||||
res.send(this.cache['6m']);
|
res.json(statistics.getCache()['6m']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getRecommendedFees(req, res) {
|
public get1YStatistics(req: Request, res: Response) {
|
||||||
|
res.json(statistics.getCache()['1y']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getInitData(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const result = websocketHandler.getInitData();
|
||||||
|
res.json(result);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getRecommendedFees(req: Request, res: Response) {
|
||||||
|
if (!mempool.isInSync()) {
|
||||||
|
res.statusCode = 503;
|
||||||
|
res.send('Service Unavailable');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const result = feeApi.getRecommendedFee();
|
const result = feeApi.getRecommendedFee();
|
||||||
res.send(result);
|
res.json(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async $getgetTransactionsForBlock(req, res) {
|
public getMempoolBlocks(req: Request, res: Response) {
|
||||||
const result = await feeApi.$getTransactionsForBlock(req.params.id);
|
|
||||||
res.send(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getgetTransactionsForProjectedBlock(req, res) {
|
|
||||||
try {
|
try {
|
||||||
const result = await projectedBlocks.getProjectedBlockFeesForBlock(req.params.id);
|
const result = mempoolBlocks.getMempoolBlocks();
|
||||||
res.send(result);
|
res.json(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).send(e.message);
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getProjectedBlocks(req, res) {
|
public getTransactionTimes(req: Request, res: Response) {
|
||||||
try {
|
if (!Array.isArray(req.query.txId)) {
|
||||||
let txId: string | undefined;
|
res.status(500).send('Not an array');
|
||||||
if (req.query.txId && /^[a-fA-F0-9]{64}$/.test(req.query.txId)) {
|
return;
|
||||||
txId = req.query.txId;
|
|
||||||
}
|
|
||||||
const result = await projectedBlocks.getProjectedBlocks(txId, 6);
|
|
||||||
res.send(result);
|
|
||||||
} catch (e) {
|
|
||||||
res.status(500).send(e.message);
|
|
||||||
}
|
}
|
||||||
}
|
const txIds: string[] = [];
|
||||||
|
for (const _txId in req.query.txId) {
|
||||||
public async getBlocks(req, res) {
|
if (typeof req.query.txId[_txId] === 'string') {
|
||||||
try {
|
txIds.push(req.query.txId[_txId].toString());
|
||||||
let result: string;
|
|
||||||
if (req.params.height) {
|
|
||||||
result = await bitcoinApi.getBlocksFromHeight(req.params.height);
|
|
||||||
} else {
|
|
||||||
result = await bitcoinApi.getBlocks();
|
|
||||||
}
|
|
||||||
res.send(result);
|
|
||||||
} catch (e) {
|
|
||||||
res.status(500).send(e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getRawTransaction(req, res) {
|
|
||||||
try {
|
|
||||||
const result = await bitcoinApi.getRawTransaction(req.params.id);
|
|
||||||
res.send(result);
|
|
||||||
} catch (e) {
|
|
||||||
if (e.response) {
|
|
||||||
res.status(e.response.status).send(e.response.data);
|
|
||||||
} else {
|
|
||||||
res.status(500, e.message);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const times = mempool.getFirstSeenForTransactions(txIds);
|
||||||
|
res.json(times);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getBlock(req, res) {
|
public getCpfpInfo(req: Request, res: Response) {
|
||||||
try {
|
if (!/^[a-fA-F0-9]{64}$/.test(req.params.txId)) {
|
||||||
const result = await bitcoinApi.getBlock(req.params.hash);
|
res.status(501).send(`Invalid transaction ID.`);
|
||||||
res.send(result);
|
return;
|
||||||
} catch (e) {
|
}
|
||||||
if (e.response) {
|
|
||||||
res.status(e.response.status).send(e.response.data);
|
const tx = mempool.getMempool()[req.params.txId];
|
||||||
} else {
|
if (!tx) {
|
||||||
res.status(500, e.message);
|
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 getBisqStats(req: Request, res: Response) {
|
||||||
|
const result = bisq.getStats();
|
||||||
|
res.json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getBisqTip(req: Request, res: Response) {
|
||||||
|
const result = bisq.getLatestBlockHeight();
|
||||||
|
res.type('text/plain');
|
||||||
|
res.send(result.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public getBisqTransaction(req: Request, res: Response) {
|
||||||
|
const result = bisq.getTransaction(req.params.txId);
|
||||||
|
if (result) {
|
||||||
|
res.json(result);
|
||||||
|
} else {
|
||||||
|
res.status(404).send('Bisq transaction not found');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getBlockTransactions(req, res) {
|
public getBisqTransactions(req: Request, res: Response) {
|
||||||
try {
|
const types: string[] = [];
|
||||||
const result = await bitcoinApi.getBlockTransactions(req.params.hash);
|
req.query.types = req.query.types || [];
|
||||||
res.send(result);
|
if (!Array.isArray(req.query.types)) {
|
||||||
} catch (e) {
|
res.status(500).send('Types is not an array');
|
||||||
if (e.response) {
|
return;
|
||||||
res.status(e.response.status).send(e.response.data);
|
}
|
||||||
} else {
|
|
||||||
if (e.response) {
|
for (const _type in req.query.types) {
|
||||||
res.status(e.response.status).send(e.response.data);
|
if (typeof req.query.types[_type] === 'string') {
|
||||||
} else {
|
types.push(req.query.types[_type].toString());
|
||||||
res.status(500, e.message);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = parseInt(req.params.index, 10) || 0;
|
||||||
|
const length = parseInt(req.params.length, 10) > 100 ? 100 : parseInt(req.params.length, 10) || 25;
|
||||||
|
const [transactions, count] = bisq.getTransactions(index, length, types);
|
||||||
|
res.header('X-Total-Count', count.toString());
|
||||||
|
res.json(transactions);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getBisqBlock(req: Request, res: Response) {
|
||||||
|
const result = bisq.getBlock(req.params.hash);
|
||||||
|
if (result) {
|
||||||
|
res.json(result);
|
||||||
|
} else {
|
||||||
|
res.status(404).send('Bisq block not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getBisqBlocks(req: Request, res: Response) {
|
||||||
|
const index = parseInt(req.params.index, 10) || 0;
|
||||||
|
const length = parseInt(req.params.length, 10) > 100 ? 100 : parseInt(req.params.length, 10) || 25;
|
||||||
|
const [transactions, count] = bisq.getBlocks(index, length);
|
||||||
|
res.header('X-Total-Count', count.toString());
|
||||||
|
res.json(transactions);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getBisqAddress(req: Request, res: Response) {
|
||||||
|
const result = bisq.getAddress(req.params.address.substr(1));
|
||||||
|
if (result) {
|
||||||
|
res.json(result);
|
||||||
|
} else {
|
||||||
|
res.status(404).send('Bisq address not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getBisqMarketCurrencies(req: Request, res: Response) {
|
||||||
|
const constraints: RequiredSpec = {
|
||||||
|
'type': {
|
||||||
|
required: false,
|
||||||
|
types: ['crypto', 'fiat', 'all']
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const p = this.parseRequestParameters(req.query, constraints);
|
||||||
|
if (p.error) {
|
||||||
|
res.status(400).json(this.getBisqMarketErrorResponse(p.error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = bisqMarket.getCurrencies(p.type);
|
||||||
|
if (result) {
|
||||||
|
res.json(result);
|
||||||
|
} else {
|
||||||
|
res.status(500).json(this.getBisqMarketErrorResponse('getBisqMarketCurrencies error'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getBisqMarketDepth(req: Request, res: Response) {
|
||||||
|
const constraints: RequiredSpec = {
|
||||||
|
'market': {
|
||||||
|
required: true,
|
||||||
|
types: ['@string']
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const p = this.parseRequestParameters(req.query, constraints);
|
||||||
|
if (p.error) {
|
||||||
|
res.status(400).json(this.getBisqMarketErrorResponse(p.error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = bisqMarket.getDepth(p.market);
|
||||||
|
if (result) {
|
||||||
|
res.json(result);
|
||||||
|
} else {
|
||||||
|
res.status(500).json(this.getBisqMarketErrorResponse('getBisqMarketDepth error'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getBisqMarketMarkets(req: Request, res: Response) {
|
||||||
|
const result = bisqMarket.getMarkets();
|
||||||
|
if (result) {
|
||||||
|
res.json(result);
|
||||||
|
} else {
|
||||||
|
res.status(500).json(this.getBisqMarketErrorResponse('getBisqMarketMarkets error'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getBisqMarketTrades(req: Request, res: Response) {
|
||||||
|
const constraints: RequiredSpec = {
|
||||||
|
'market': {
|
||||||
|
required: true,
|
||||||
|
types: ['@string']
|
||||||
|
},
|
||||||
|
'timestamp_from': {
|
||||||
|
required: false,
|
||||||
|
types: ['@number']
|
||||||
|
},
|
||||||
|
'timestamp_to': {
|
||||||
|
required: false,
|
||||||
|
types: ['@number']
|
||||||
|
},
|
||||||
|
'trade_id_to': {
|
||||||
|
required: false,
|
||||||
|
types: ['@string']
|
||||||
|
},
|
||||||
|
'trade_id_from': {
|
||||||
|
required: false,
|
||||||
|
types: ['@string']
|
||||||
|
},
|
||||||
|
'direction': {
|
||||||
|
required: false,
|
||||||
|
types: ['buy', 'sell']
|
||||||
|
},
|
||||||
|
'limit': {
|
||||||
|
required: false,
|
||||||
|
types: ['@number']
|
||||||
|
},
|
||||||
|
'sort': {
|
||||||
|
required: false,
|
||||||
|
types: ['asc', 'desc']
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const p = this.parseRequestParameters(req.query, constraints);
|
||||||
|
if (p.error) {
|
||||||
|
res.status(400).json(this.getBisqMarketErrorResponse(p.error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = bisqMarket.getTrades(p.market, p.timestamp_from,
|
||||||
|
p.timestamp_to, p.trade_id_from, p.trade_id_to, p.direction, p.limit, p.sort);
|
||||||
|
if (result) {
|
||||||
|
res.json(result);
|
||||||
|
} else {
|
||||||
|
res.status(500).json(this.getBisqMarketErrorResponse('getBisqMarketTrades error'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getBisqMarketOffers(req: Request, res: Response) {
|
||||||
|
const constraints: RequiredSpec = {
|
||||||
|
'market': {
|
||||||
|
required: true,
|
||||||
|
types: ['@string']
|
||||||
|
},
|
||||||
|
'direction': {
|
||||||
|
required: false,
|
||||||
|
types: ['buy', 'sell']
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const p = this.parseRequestParameters(req.query, constraints);
|
||||||
|
if (p.error) {
|
||||||
|
res.status(400).json(this.getBisqMarketErrorResponse(p.error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = bisqMarket.getOffers(p.market, p.direction);
|
||||||
|
if (result) {
|
||||||
|
res.json(result);
|
||||||
|
} else {
|
||||||
|
res.status(500).json(this.getBisqMarketErrorResponse('getBisqMarketOffers error'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getBisqMarketVolumes(req: Request, res: Response) {
|
||||||
|
const constraints: RequiredSpec = {
|
||||||
|
'market': {
|
||||||
|
required: false,
|
||||||
|
types: ['@string']
|
||||||
|
},
|
||||||
|
'interval': {
|
||||||
|
required: false,
|
||||||
|
types: ['minute', 'half_hour', 'hour', 'half_day', 'day', 'week', 'month', 'year', 'auto']
|
||||||
|
},
|
||||||
|
'timestamp_from': {
|
||||||
|
required: false,
|
||||||
|
types: ['@number']
|
||||||
|
},
|
||||||
|
'timestamp_to': {
|
||||||
|
required: false,
|
||||||
|
types: ['@number']
|
||||||
|
},
|
||||||
|
'milliseconds': {
|
||||||
|
required: false,
|
||||||
|
types: ['@boolean']
|
||||||
|
},
|
||||||
|
'timestamp': {
|
||||||
|
required: false,
|
||||||
|
types: ['no', 'yes']
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const p = this.parseRequestParameters(req.query, constraints);
|
||||||
|
if (p.error) {
|
||||||
|
res.status(400).json(this.getBisqMarketErrorResponse(p.error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = bisqMarket.getVolumes(p.market, p.timestamp_from, p.timestamp_to, p.interval, p.milliseconds, p.timestamp);
|
||||||
|
if (result) {
|
||||||
|
res.json(result);
|
||||||
|
} else {
|
||||||
|
res.status(500).json(this.getBisqMarketErrorResponse('getBisqMarketVolumes error'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getBisqMarketHloc(req: Request, res: Response) {
|
||||||
|
const constraints: RequiredSpec = {
|
||||||
|
'market': {
|
||||||
|
required: true,
|
||||||
|
types: ['@string']
|
||||||
|
},
|
||||||
|
'interval': {
|
||||||
|
required: false,
|
||||||
|
types: ['minute', 'half_hour', 'hour', 'half_day', 'day', 'week', 'month', 'year', 'auto']
|
||||||
|
},
|
||||||
|
'timestamp_from': {
|
||||||
|
required: false,
|
||||||
|
types: ['@number']
|
||||||
|
},
|
||||||
|
'timestamp_to': {
|
||||||
|
required: false,
|
||||||
|
types: ['@number']
|
||||||
|
},
|
||||||
|
'milliseconds': {
|
||||||
|
required: false,
|
||||||
|
types: ['@boolean']
|
||||||
|
},
|
||||||
|
'timestamp': {
|
||||||
|
required: false,
|
||||||
|
types: ['no', 'yes']
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const p = this.parseRequestParameters(req.query, constraints);
|
||||||
|
if (p.error) {
|
||||||
|
res.status(400).json(this.getBisqMarketErrorResponse(p.error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = bisqMarket.getHloc(p.market, p.interval, p.timestamp_from, p.timestamp_to, p.milliseconds, p.timestamp);
|
||||||
|
if (result) {
|
||||||
|
res.json(result);
|
||||||
|
} else {
|
||||||
|
res.status(500).json(this.getBisqMarketErrorResponse('getBisqMarketHloc error'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getBisqMarketTicker(req: Request, res: Response) {
|
||||||
|
const constraints: RequiredSpec = {
|
||||||
|
'market': {
|
||||||
|
required: false,
|
||||||
|
types: ['@string']
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const p = this.parseRequestParameters(req.query, constraints);
|
||||||
|
if (p.error) {
|
||||||
|
res.status(400).json(this.getBisqMarketErrorResponse(p.error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = bisqMarket.getTicker(p.market);
|
||||||
|
if (result) {
|
||||||
|
res.json(result);
|
||||||
|
} else {
|
||||||
|
res.status(500).json(this.getBisqMarketErrorResponse('getBisqMarketTicker error'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if (params.hasOwnProperty(i)) {
|
||||||
|
if (params[i].required && requestParams[i] === undefined) {
|
||||||
|
return { error: i + ' parameter missing'};
|
||||||
|
}
|
||||||
|
if (typeof requestParams[i] === 'string') {
|
||||||
|
const str = (requestParams[i] || '').toString().toLowerCase();
|
||||||
|
if (params[i].types.indexOf('@number') > -1) {
|
||||||
|
const number = parseInt((str).toString(), 10);
|
||||||
|
final[i] = number;
|
||||||
|
} else if (params[i].types.indexOf('@string') > -1) {
|
||||||
|
final[i] = str;
|
||||||
|
} else if (params[i].types.indexOf('@boolean') > -1) {
|
||||||
|
final[i] = str === 'true' || str === 'yes';
|
||||||
|
} else if (params[i].types.indexOf(str) > -1) {
|
||||||
|
final[i] = str;
|
||||||
|
} else {
|
||||||
|
return { error: i + ' parameter invalid'};
|
||||||
|
}
|
||||||
|
} else if (typeof requestParams[i] === 'number') {
|
||||||
|
final[i] = requestParams[i];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return final;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getBlockTransactionsFromIndex(req, res) {
|
private getBisqMarketErrorResponse(message: string): MarketsApiError {
|
||||||
|
return {
|
||||||
|
'success': 0,
|
||||||
|
'error': message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getTransaction(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
const result = await bitcoinApi.getBlockTransactionsFromIndex(req.params.hash, req.params.index);
|
const transaction = await transactionUtils.$getTransactionExtended(req.params.txId, true);
|
||||||
res.send(result);
|
res.json(transaction);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.response) {
|
let statusCode = 500;
|
||||||
res.status(e.response.status).send(e.response.data);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getBlock(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const result = await bitcoinApi.$getBlock(req.params.hash);
|
||||||
|
res.json(result);
|
||||||
|
} catch (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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getBlocks(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
loadingIndicators.setProgress('blocks', 0);
|
||||||
|
|
||||||
|
const returnBlocks: IEsploraApi.Block[] = [];
|
||||||
|
const fromHeight = parseInt(req.params.height, 10) || blocks.getCurrentBlockHeight();
|
||||||
|
|
||||||
|
// Check if block height exist in local cache to skip the hash lookup
|
||||||
|
const blockByHeight = blocks.getBlocks().find((b) => b.height === fromHeight);
|
||||||
|
let startFromHash: string | null = null;
|
||||||
|
if (blockByHeight) {
|
||||||
|
startFromHash = blockByHeight.id;
|
||||||
} else {
|
} else {
|
||||||
if (e.response) {
|
startFromHash = await bitcoinApi.$getBlockHash(fromHeight);
|
||||||
res.status(e.response.status).send(e.response.data);
|
}
|
||||||
|
|
||||||
|
let nextHash = startFromHash;
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const localBlock = blocks.getBlocks().find((b) => b.id === nextHash);
|
||||||
|
if (localBlock) {
|
||||||
|
returnBlocks.push(localBlock);
|
||||||
|
nextHash = localBlock.previousblockhash;
|
||||||
} else {
|
} else {
|
||||||
res.status(500, e.message);
|
const block = await bitcoinApi.$getBlock(nextHash);
|
||||||
|
returnBlocks.push(block);
|
||||||
|
nextHash = block.previousblockhash;
|
||||||
|
}
|
||||||
|
loadingIndicators.setProgress('blocks', i / 10 * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(returnBlocks);
|
||||||
|
} catch (e) {
|
||||||
|
loadingIndicators.setProgress('blocks', 100);
|
||||||
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getBlockTransactions(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
loadingIndicators.setProgress('blocktxs-' + req.params.hash, 0);
|
||||||
|
|
||||||
|
const txIds = await bitcoinApi.$getTxIdsForBlock(req.params.hash);
|
||||||
|
const transactions: TransactionExtended[] = [];
|
||||||
|
const startingIndex = Math.max(0, parseInt(req.params.index || '0', 10));
|
||||||
|
|
||||||
|
const endIndex = Math.min(startingIndex + 10, txIds.length);
|
||||||
|
for (let i = startingIndex; i < endIndex; i++) {
|
||||||
|
try {
|
||||||
|
const transaction = await transactionUtils.$getTransactionExtended(txIds[i], true);
|
||||||
|
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 instanceof Error ? e.message : e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAddress(req, res) {
|
public async getBlockHeight(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
const result = await bitcoinApi.getAddress(req.params.address);
|
const blockHash = await bitcoinApi.$getBlockHash(parseInt(req.params.height, 10));
|
||||||
res.send(result);
|
res.send(blockHash);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.response) {
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
res.status(e.response.status).send(e.response.data);
|
|
||||||
} else {
|
|
||||||
if (e.response) {
|
|
||||||
res.status(e.response.status).send(e.response.data);
|
|
||||||
} else {
|
|
||||||
res.status(500, e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAddressTransactions(req, res) {
|
public async getAddress(req: Request, res: Response) {
|
||||||
|
if (config.MEMPOOL.BACKEND === 'none') {
|
||||||
|
res.status(405).send('Address lookups cannot be used with bitcoind as backend.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await bitcoinApi.getAddressTransactions(req.params.address);
|
const addressData = await bitcoinApi.$getAddress(req.params.address);
|
||||||
res.send(result);
|
res.json(addressData);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.response) {
|
if (e instanceof Error && e.message && e.message.indexOf('exceeds') > 0) {
|
||||||
res.status(e.response.status).send(e.response.data);
|
return res.status(413).send(e instanceof Error ? e.message : e);
|
||||||
} else {
|
|
||||||
res.status(500, e.message);
|
|
||||||
}
|
}
|
||||||
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAddressTransactionsFromTxid(req, res) {
|
public async getAddressTransactions(req: Request, res: Response) {
|
||||||
|
if (config.MEMPOOL.BACKEND === 'none') {
|
||||||
|
res.status(405).send('Address lookups cannot be used with bitcoind as backend.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await bitcoinApi.getAddressTransactionsFromLastSeenTxid(req.params.address, req.params.txid);
|
const transactions = await bitcoinApi.$getAddressTransactions(req.params.address, req.params.txId);
|
||||||
res.send(result);
|
res.json(transactions);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.response) {
|
if (e instanceof Error && e.message && e.message.indexOf('exceeds') > 0) {
|
||||||
res.status(e.response.status).send(e.response.data);
|
return res.status(413).send(e instanceof Error ? e.message : e);
|
||||||
} else {
|
|
||||||
res.status(500, e.message);
|
|
||||||
}
|
}
|
||||||
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAdressTxChain(req: Request, res: Response) {
|
||||||
|
res.status(501).send('Not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAddressPrefix(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const blockHash = await bitcoinApi.$getAddressPrefix(req.params.prefix);
|
||||||
|
res.send(blockHash);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).send(e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 getTransactionOutspends(req: Request, res: Response) {
|
||||||
|
res.status(501).send('Not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (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 {
|
||||||
|
const rawtx = Object.keys(req.body)[0];
|
||||||
|
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'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"target": "es2015",
|
"target": "esnext",
|
||||||
|
"lib": ["es2019"],
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noImplicitAny": false,
|
"noImplicitAny": false,
|
||||||
"sourceMap": false,
|
"sourceMap": false,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"severity": "warn"
|
"severity": "warn"
|
||||||
},
|
},
|
||||||
"eofline": true,
|
"eofline": true,
|
||||||
"forin": true,
|
"forin": false,
|
||||||
"import-blacklist": [
|
"import-blacklist": [
|
||||||
true,
|
true,
|
||||||
"rxjs",
|
"rxjs",
|
||||||
|
|||||||
1187
backend/yarn.lock
1187
backend/yarn.lock
File diff suppressed because it is too large
Load Diff
101
docker/README.md
Normal file
101
docker/README.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Docker
|
||||||
|
|
||||||
|
## Initialization
|
||||||
|
|
||||||
|
In an empty dir create 2 sub-dirs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p data mysql/data mysql/db-scripts
|
||||||
|
```
|
||||||
|
|
||||||
|
In the `mysql/db-scripts` sub-dir add the `mariadb-structure.sql` file from the mempool repo
|
||||||
|
|
||||||
|
Your dir should now look like that:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ls -R
|
||||||
|
.:
|
||||||
|
data mysql
|
||||||
|
|
||||||
|
./data:
|
||||||
|
|
||||||
|
./mysql:
|
||||||
|
data db-scripts
|
||||||
|
|
||||||
|
./mysql/data:
|
||||||
|
|
||||||
|
./mysql/db-scripts:
|
||||||
|
mariadb-structure.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
In the main dir add the following `docker-compose.yml`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
version: "3.7"
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
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
|
||||||
|
environment:
|
||||||
|
FRONTEND_HTTP_PORT: "8080"
|
||||||
|
BACKEND_MAINNET_HTTP_HOST: "api"
|
||||||
|
api:
|
||||||
|
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
|
||||||
|
environment:
|
||||||
|
RPC_HOST: "127.0.0.1"
|
||||||
|
RPC_PORT: "8332"
|
||||||
|
RPC_USER: "mempool"
|
||||||
|
RPC_PASS: "mempool"
|
||||||
|
ELECTRUM_HOST: "127.0.0.1"
|
||||||
|
ELECTRUM_PORT: "50002"
|
||||||
|
ELECTRUM_TLS: "false"
|
||||||
|
MYSQL_HOST: "db"
|
||||||
|
MYSQL_PORT: "3306"
|
||||||
|
MYSQL_DATABASE: "mempool"
|
||||||
|
MYSQL_USER: "mempool"
|
||||||
|
MYSQL_PASS: "mempool"
|
||||||
|
BACKEND_MAINNET_HTTP_PORT: "8999"
|
||||||
|
CACHE_DIR: "/backend/cache"
|
||||||
|
MEMPOOL_CLEAR_PROTECTION_MINUTES: "20"
|
||||||
|
db:
|
||||||
|
image: mariadb:10.5.8
|
||||||
|
user: "1000:1000"
|
||||||
|
restart: on-failure
|
||||||
|
stop_grace_period: 1m
|
||||||
|
volumes:
|
||||||
|
- ./mysql/data:/var/lib/mysql
|
||||||
|
- ./mysql/db-scripts:/docker-entrypoint-initdb.d
|
||||||
|
environment:
|
||||||
|
MYSQL_DATABASE: "mempool"
|
||||||
|
MYSQL_USER: "mempool"
|
||||||
|
MYSQL_PASSWORD: "mempool"
|
||||||
|
MYSQL_ROOT_PASSWORD: "admin"
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
You can update all the environment variables inside the API container, especially the RPC and ELECTRUM ones
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
To run our docker-compose use the following cmd:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
If everything went okay you should see the beautiful mempool :grin:
|
||||||
|
|
||||||
|
If you get stuck on "loading blocks", this means the websocket can't connect.
|
||||||
|
Check your nginx proxy setup, firewalls, etc. and open an issue if you need help.
|
||||||
26
docker/backend/Dockerfile
Normal file
26
docker/backend/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
FROM node:12-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:12-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"]
|
||||||
38
docker/backend/mempool-config.json
Normal file
38
docker/backend/mempool-config.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"MEMPOOL": {
|
||||||
|
"NETWORK": "mainnet",
|
||||||
|
"BACKEND": "electrum",
|
||||||
|
"HTTP_PORT": __MEMPOOL_BACKEND_MAINNET_HTTP_PORT__,
|
||||||
|
"SPAWN_CLUSTER_PROCS": 0,
|
||||||
|
"API_URL_PREFIX": "/api/v1/",
|
||||||
|
"POLL_RATE_MS": 2000,
|
||||||
|
"CACHE_DIR": "__MEMPOOL_BACKEND_MAINNET_CACHE_DIR__",
|
||||||
|
"CLEAR_PROTECTION_MINUTES": __MEMPOOL_BACKEND_CLEAR_PROTECTION_MINUTES__
|
||||||
|
},
|
||||||
|
"CORE_RPC": {
|
||||||
|
"HOST": "__BITCOIN_MAINNET_RPC_HOST__",
|
||||||
|
"PORT": __BITCOIN_MAINNET_RPC_PORT__,
|
||||||
|
"USERNAME": "__BITCOIN_MAINNET_RPC_USER__",
|
||||||
|
"PASSWORD": "__BITCOIN_MAINNET_RPC_PASS__"
|
||||||
|
},
|
||||||
|
"ELECTRUM": {
|
||||||
|
"HOST": "__ELECTRUM_MAINNET_HTTP_HOST__",
|
||||||
|
"PORT": __ELECTRUM_MAINNET_HTTP_PORT__,
|
||||||
|
"TLS_ENABLED": __ELECTRUM_MAINNET_TLS_ENABLED__
|
||||||
|
},
|
||||||
|
"ESPLORA": {
|
||||||
|
"REST_API_URL": "http://127.0.0.1:3000"
|
||||||
|
},
|
||||||
|
"DATABASE": {
|
||||||
|
"ENABLED": true,
|
||||||
|
"HOST": "__MYSQL_HOST__",
|
||||||
|
"PORT": __MYSQL_PORT__,
|
||||||
|
"DATABASE": "__MYSQL_DATABASE__",
|
||||||
|
"USERNAME": "__MYSQL_USERNAME__",
|
||||||
|
"PASSWORD": "__MYSQL_PASSWORD__"
|
||||||
|
},
|
||||||
|
"STATISTICS": {
|
||||||
|
"ENABLED": true,
|
||||||
|
"TX_PER_SECOND_SAMPLE_PERIOD": 150
|
||||||
|
}
|
||||||
|
}
|
||||||
41
docker/backend/start.sh
Normal file
41
docker/backend/start.sh
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#MEMPOOL
|
||||||
|
__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__=${BACKEND_MAINNET_HTTP_PORT:=8999}
|
||||||
|
__MEMPOOL_BACKEND_MAINNET_CACHE_DIR__=${CACHE_DIR:=./cache}
|
||||||
|
__MEMPOOL_BACKEND_CLEAR_PROTECTION_MINUTES__=${MEMPOOL_CLEAR_PROTECTION_MINUTES:=20}
|
||||||
|
# BITCOIN
|
||||||
|
__BITCOIN_MAINNET_RPC_HOST__=${RPC_HOST:=127.0.0.1}
|
||||||
|
__BITCOIN_MAINNET_RPC_PORT__=${RPC_PORT:=8332}
|
||||||
|
__BITCOIN_MAINNET_RPC_USER__=${RPC_USER:=mempool}
|
||||||
|
__BITCOIN_MAINNET_RPC_PASS__=${RPC_PASS:=mempool}
|
||||||
|
# ELECTRUM
|
||||||
|
__ELECTRUM_MAINNET_HTTP_HOST__=${ELECTRUM_HOST:=127.0.0.1}
|
||||||
|
__ELECTRUM_MAINNET_HTTP_PORT__=${ELECTRUM_PORT:=50002} # 50001?
|
||||||
|
__ELECTRUM_MAINNET_TLS_ENABLED__=${ELECTRUM_TLS:=false}
|
||||||
|
# MYSQL
|
||||||
|
__MYSQL_HOST__=${MYSQL_HOST:=127.0.0.1}
|
||||||
|
__MYSQL_PORT__=${MYSQL_PORT:=3306}
|
||||||
|
__MYSQL_DATABASE__=${MYSQL_DATABASE:=mempool}
|
||||||
|
__MYSQL_USERNAME__=${MYSQL_USER:=mempool}
|
||||||
|
__MYSQL_PASSWORD__=${MYSQL_PASS:=mempool}
|
||||||
|
|
||||||
|
mkdir -p "${__MEMPOOL_BACKEND_MAINNET_CACHE_DIR__}"
|
||||||
|
|
||||||
|
sed -i "s/__BITCOIN_MAINNET_RPC_HOST__/${__BITCOIN_MAINNET_RPC_HOST__}/g" mempool-config.json
|
||||||
|
sed -i "s/__BITCOIN_MAINNET_RPC_PORT__/${__BITCOIN_MAINNET_RPC_PORT__}/g" mempool-config.json
|
||||||
|
sed -i "s/__BITCOIN_MAINNET_RPC_USER__/${__BITCOIN_MAINNET_RPC_USER__}/g" mempool-config.json
|
||||||
|
sed -i "s/__BITCOIN_MAINNET_RPC_PASS__/${__BITCOIN_MAINNET_RPC_PASS__}/g" mempool-config.json
|
||||||
|
sed -i "s/__ELECTRUM_MAINNET_HTTP_HOST__/${__ELECTRUM_MAINNET_HTTP_HOST__}/g" mempool-config.json
|
||||||
|
sed -i "s/__ELECTRUM_MAINNET_HTTP_PORT__/${__ELECTRUM_MAINNET_HTTP_PORT__}/g" mempool-config.json
|
||||||
|
sed -i "s/__ELECTRUM_MAINNET_TLS_ENABLED__/${__ELECTRUM_MAINNET_TLS_ENABLED__}/g" mempool-config.json
|
||||||
|
sed -i "s/__MYSQL_HOST__/${__MYSQL_HOST__}/g" mempool-config.json
|
||||||
|
sed -i "s/__MYSQL_PORT__/${__MYSQL_PORT__}/g" mempool-config.json
|
||||||
|
sed -i "s/__MYSQL_DATABASE__/${__MYSQL_DATABASE__}/g" mempool-config.json
|
||||||
|
sed -i "s/__MYSQL_USERNAME__/${__MYSQL_USERNAME__}/g" mempool-config.json
|
||||||
|
sed -i "s/__MYSQL_PASSWORD__/${__MYSQL_PASSWORD__}/g" mempool-config.json
|
||||||
|
sed -i "s!__MEMPOOL_BACKEND_MAINNET_CACHE_DIR__!${__MEMPOOL_BACKEND_MAINNET_CACHE_DIR__}!g" mempool-config.json
|
||||||
|
sed -i "s/__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__/${__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__}/g" mempool-config.json
|
||||||
|
sed -i "s/__MEMPOOL_BACKEND_CLEAR_PROTECTION_MINUTES__/${__MEMPOOL_BACKEND_CLEAR_PROTECTION_MINUTES__}/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
|
||||||
67
docker/docker-compose.yml
Normal file
67
docker/docker-compose.yml
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
version: "3.7"
|
||||||
|
|
||||||
|
services:
|
||||||
|
|
||||||
|
electrum:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/electrum/Dockerfile
|
||||||
|
user: "1000:1000"
|
||||||
|
restart: on-failure
|
||||||
|
command: ""
|
||||||
|
ports:
|
||||||
|
- 50001:50001
|
||||||
|
- 50002:50002
|
||||||
|
- 4224:4224
|
||||||
|
- 8332:8332
|
||||||
|
environment:
|
||||||
|
ELECTRUM: "electrum"
|
||||||
|
# add electrs configs
|
||||||
|
web:
|
||||||
|
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
|
||||||
|
environment:
|
||||||
|
FRONTEND_HTTP_PORT: "8080"
|
||||||
|
BACKEND_MAINNET_HTTP_HOST: "api"
|
||||||
|
api:
|
||||||
|
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
|
||||||
|
environment:
|
||||||
|
RPC_HOST: "127.0.0.1"
|
||||||
|
RPC_PORT: "8332"
|
||||||
|
RPC_USER: "mempool"
|
||||||
|
RPC_PASS: "mempool"
|
||||||
|
ELECTRUM_HOST: "127.0.0.1"
|
||||||
|
ELECTRUM_PORT: "50002"
|
||||||
|
ELECTRUM_TLS: "false"
|
||||||
|
MYSQL_HOST: "db"
|
||||||
|
MYSQL_PORT: "3306"
|
||||||
|
MYSQL_DATABASE: "mempool"
|
||||||
|
MYSQL_USER: "mempool"
|
||||||
|
MYSQL_PASS: "mempool"
|
||||||
|
BACKEND_MAINNET_HTTP_PORT: "8999"
|
||||||
|
CACHE_DIR: "/backend/cache"
|
||||||
|
MEMPOOL_CLEAR_PROTECTION_MINUTES: "20"
|
||||||
|
db:
|
||||||
|
image: mariadb:10.5.8
|
||||||
|
user: "1000:1000"
|
||||||
|
restart: on-failure
|
||||||
|
stop_grace_period: 1m
|
||||||
|
volumes:
|
||||||
|
- ./mysql/data:/var/lib/mysql
|
||||||
|
- ./mysql/db-scripts:/docker-entrypoint-initdb.d
|
||||||
|
environment:
|
||||||
|
MYSQL_DATABASE: "mempool"
|
||||||
|
MYSQL_USER: "mempool"
|
||||||
|
MYSQL_PASSWORD: "mempool"
|
||||||
|
MYSQL_ROOT_PASSWORD: "admin"
|
||||||
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:12-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
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
## Start SQL
|
|
||||||
mysqld_safe&
|
|
||||||
sleep 5
|
|
||||||
## http server:
|
|
||||||
nginx
|
|
||||||
|
|
||||||
## Set up some files:
|
|
||||||
cd /mempool.space/backend
|
|
||||||
rm -f cache.json
|
|
||||||
touch cache.json
|
|
||||||
|
|
||||||
## Build mempool-config.json file ourseleves.
|
|
||||||
## We used to use jq for this but that produced output which caused bugs,
|
|
||||||
## specifically numbers were surrounded by quotes, which breaks things.
|
|
||||||
## Old command was jq -n env > mempool-config.json
|
|
||||||
## This way is more complex, but more compatible with the backend functions.
|
|
||||||
|
|
||||||
## Define a function to allow us to easily get indexes of the = string in from the env output:
|
|
||||||
strindex() {
|
|
||||||
x="${1%%$2*}"
|
|
||||||
[[ "$x" = "$1" ]] && echo -1 || echo "${#x}"
|
|
||||||
}
|
|
||||||
## Regex to check if we have a number or not:
|
|
||||||
NumberRegEx='^[0-9]+$'
|
|
||||||
## Delete the old file, and start a new one:
|
|
||||||
rm -f mempool-config.json
|
|
||||||
echo "{" >> mempool-config.json
|
|
||||||
## For each env we add into the mempool-config.json file in one of two ways.
|
|
||||||
## Either:
|
|
||||||
## "Variable": "Value",
|
|
||||||
## if a string, or
|
|
||||||
## "Variable": Value,
|
|
||||||
## if a integer
|
|
||||||
for e in `env`; do
|
|
||||||
if [[ ${e:`strindex "$e" "="`+1} =~ $NumberRegEx ]] ; then
|
|
||||||
## Integer add:
|
|
||||||
echo "\""${e:0:`strindex "$e" "="`}"\": "${e:`strindex "$e" "="`+1}"," >> mempool-config.json
|
|
||||||
else
|
|
||||||
## String add:
|
|
||||||
echo "\""${e:0:`strindex "$e" "="`}"\": \""${e:`strindex "$e" "="`+1}$"\"," >> mempool-config.json
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
## Take out the trailing , from the last entry.
|
|
||||||
## This means replacing the file with one that is missing the last character
|
|
||||||
echo `sed '$ s/.$//' mempool-config.json` > mempool-config.json
|
|
||||||
## And finally finish off:
|
|
||||||
echo "}" >> mempool-config.json
|
|
||||||
|
|
||||||
## Start mempoolspace:
|
|
||||||
node dist/index.js
|
|
||||||
12
frontend/.browserslistrc
Normal file
12
frontend/.browserslistrc
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
|
||||||
|
# For additional information regarding the format and rule options, please see:
|
||||||
|
# https://github.com/browserslist/browserslist#queries
|
||||||
|
|
||||||
|
# You can see what browsers were selected by your queries by running:
|
||||||
|
# npx browserslist
|
||||||
|
|
||||||
|
> 0.5%
|
||||||
|
last 2 versions
|
||||||
|
Firefox ESR
|
||||||
|
not dead
|
||||||
|
not IE 9-11 # For IE 9-11 support, remove 'not'.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Editor configuration, see http://editorconfig.org
|
# Editor configuration, see https://editorconfig.org
|
||||||
root = true
|
root = true
|
||||||
|
|
||||||
[*]
|
[*]
|
||||||
|
|||||||
25
frontend/.gitignore
vendored
25
frontend/.gitignore
vendored
@@ -4,10 +4,18 @@
|
|||||||
/dist
|
/dist
|
||||||
/tmp
|
/tmp
|
||||||
/out-tsc
|
/out-tsc
|
||||||
|
server.run.js
|
||||||
|
|
||||||
|
# Only exists if Bazel was run
|
||||||
|
/bazel-out
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
|
|
||||||
|
# profiling files
|
||||||
|
chrome-profiler-events.json
|
||||||
|
speed-measure-plugin.json
|
||||||
|
|
||||||
# IDEs and editors
|
# IDEs and editors
|
||||||
/.idea
|
/.idea
|
||||||
.project
|
.project
|
||||||
@@ -23,6 +31,7 @@
|
|||||||
!.vscode/tasks.json
|
!.vscode/tasks.json
|
||||||
!.vscode/launch.json
|
!.vscode/launch.json
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
.history/*
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
/.sass-cache
|
/.sass-cache
|
||||||
@@ -37,3 +46,19 @@ testem.log
|
|||||||
# System Files
|
# System Files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
src/resources/assets.json
|
||||||
|
src/resources/assets.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
|
||||||
|
|
||||||
|
|||||||
7
frontend/.tx/config
Normal file
7
frontend/.tx/config
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[main]
|
||||||
|
host = https://www.transifex.com
|
||||||
|
|
||||||
|
[mempool.frontend-src-locale-messages-xlf--master]
|
||||||
|
file_filter = frontend/src/locale/messages.<lang>.xlf
|
||||||
|
source_lang = en-US
|
||||||
|
type = XLIFF
|
||||||
36
frontend/README.md
Normal file
36
frontend/README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# mempool-frontend
|
||||||
|
|
||||||
|
## Transifex Project
|
||||||
|
|
||||||
|
The mempool frontend strings are localized into 20+ locales:
|
||||||
|
https://www.transifex.com/mempool/mempool/dashboard/
|
||||||
|
|
||||||
|
## Translators
|
||||||
|
|
||||||
|
* Arabic @baro0k
|
||||||
|
* Czech @pixelmade2
|
||||||
|
* German @Emzy
|
||||||
|
* English (default)
|
||||||
|
* Spanish @maxhodler @bisqes
|
||||||
|
* 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_
|
||||||
|
* Turkish @stackmore
|
||||||
|
* Ukrainian @volbil
|
||||||
|
* Vietnamese @bitcoin_vietnam
|
||||||
|
* Chinese @wdljt
|
||||||
|
* Russian @TonyCrusoe @Bitconan
|
||||||
|
* Romanian @mirceavesa
|
||||||
|
* Macedonian @SkechBoy
|
||||||
@@ -1,35 +1,168 @@
|
|||||||
{
|
{
|
||||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"cli": {
|
||||||
|
"analytics": false
|
||||||
|
},
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"newProjectRoot": "projects",
|
"newProjectRoot": "projects",
|
||||||
"projects": {
|
"projects": {
|
||||||
"mempool": {
|
"mempool": {
|
||||||
"root": "",
|
|
||||||
"sourceRoot": "src",
|
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
"prefix": "app",
|
|
||||||
"schematics": {
|
"schematics": {
|
||||||
"@schematics/angular:component": {
|
"@schematics/angular:component": {
|
||||||
"styleext": "scss"
|
"style": "scss"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"i18n": {
|
||||||
|
"sourceLocale": {
|
||||||
|
"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/"
|
||||||
|
},
|
||||||
|
"de": {
|
||||||
|
"translation": "src/locale/messages.de.xlf",
|
||||||
|
"baseHref": "/de/"
|
||||||
|
},
|
||||||
|
"es": {
|
||||||
|
"translation": "src/locale/messages.es.xlf",
|
||||||
|
"baseHref": "/es/"
|
||||||
|
},
|
||||||
|
"fa": {
|
||||||
|
"translation": "src/locale/messages.fa.xlf",
|
||||||
|
"baseHref": "/fa/"
|
||||||
|
},
|
||||||
|
"fr": {
|
||||||
|
"translation": "src/locale/messages.fr.xlf",
|
||||||
|
"baseHref": "/fr/"
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"translation": "src/locale/messages.ja.xlf",
|
||||||
|
"baseHref": "/ja/"
|
||||||
|
},
|
||||||
|
"ka": {
|
||||||
|
"translation": "src/locale/messages.ka.xlf",
|
||||||
|
"baseHref": "/ka/"
|
||||||
|
},
|
||||||
|
"ko": {
|
||||||
|
"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/"
|
||||||
|
},
|
||||||
|
"nb": {
|
||||||
|
"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/"
|
||||||
|
},
|
||||||
|
"sl": {
|
||||||
|
"translation": "src/locale/messages.sl.xlf",
|
||||||
|
"baseHref": "/sl/"
|
||||||
|
},
|
||||||
|
"sv": {
|
||||||
|
"translation": "src/locale/messages.sv.xlf",
|
||||||
|
"baseHref": "/sv/"
|
||||||
|
},
|
||||||
|
"tr": {
|
||||||
|
"translation": "src/locale/messages.tr.xlf",
|
||||||
|
"baseHref": "/tr/"
|
||||||
|
},
|
||||||
|
"uk": {
|
||||||
|
"translation": "src/locale/messages.uk.xlf",
|
||||||
|
"baseHref": "/uk/"
|
||||||
|
},
|
||||||
|
"fi": {
|
||||||
|
"translation": "src/locale/messages.fi.xlf",
|
||||||
|
"baseHref": "/fi/"
|
||||||
|
},
|
||||||
|
"vi": {
|
||||||
|
"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/"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"architect": {
|
"architect": {
|
||||||
"build": {
|
"build": {
|
||||||
"builder": "@angular-devkit/build-angular:browser",
|
"builder": "@angular-devkit/build-angular:browser",
|
||||||
"options": {
|
"options": {
|
||||||
"outputPath": "dist/mempool",
|
"outputPath": "dist/mempool/browser",
|
||||||
"index": "src/index.html",
|
"index": "src/index.html",
|
||||||
"main": "src/main.ts",
|
"main": "src/main.ts",
|
||||||
"polyfills": "src/polyfills.ts",
|
"polyfills": "src/polyfills.ts",
|
||||||
"tsConfig": "src/tsconfig.app.json",
|
"tsConfig": "tsconfig.app.json",
|
||||||
"assets": [
|
"assets": [
|
||||||
"src/favicon.ico",
|
"src/favicon.ico",
|
||||||
"src/assets"
|
"src/resources",
|
||||||
|
"src/robots.txt"
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
"src/styles.scss"
|
"src/styles.scss",
|
||||||
|
"node_modules/@fortawesome/fontawesome-svg-core/styles.css"
|
||||||
],
|
],
|
||||||
"scripts": []
|
"scripts": [
|
||||||
|
"generated-config.js"
|
||||||
|
],
|
||||||
|
"vendorChunk": true,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"buildOptimizer": false,
|
||||||
|
"sourceMap": true,
|
||||||
|
"optimization": false,
|
||||||
|
"namedChunks": true
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
@@ -39,34 +172,34 @@
|
|||||||
"with": "src/environments/environment.prod.ts"
|
"with": "src/environments/environment.prod.ts"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"optimization": true,
|
"optimization": {
|
||||||
|
"scripts": true,
|
||||||
|
"styles": {
|
||||||
|
"minify": true,
|
||||||
|
"inlineCritical": false
|
||||||
|
},
|
||||||
|
"fonts": true
|
||||||
|
},
|
||||||
"outputHashing": "all",
|
"outputHashing": "all",
|
||||||
"sourceMap": false,
|
"sourceMap": false,
|
||||||
"extractCss": true,
|
|
||||||
"namedChunks": false,
|
"namedChunks": false,
|
||||||
"aot": true,
|
|
||||||
"extractLicenses": true,
|
"extractLicenses": true,
|
||||||
"vendorChunk": false,
|
"vendorChunk": false,
|
||||||
"buildOptimizer": true
|
"buildOptimizer": true,
|
||||||
},
|
"budgets": [
|
||||||
"electrs": {
|
|
||||||
"fileReplacements": [
|
|
||||||
{
|
{
|
||||||
"replace": "src/environments/environment.ts",
|
"type": "initial",
|
||||||
"with": "src/environments/environment-electrs.prod.ts"
|
"maximumWarning": "2mb",
|
||||||
|
"maximumError": "5mb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "6kb"
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"optimization": true,
|
|
||||||
"outputHashing": "all",
|
|
||||||
"sourceMap": false,
|
|
||||||
"extractCss": true,
|
|
||||||
"namedChunks": false,
|
|
||||||
"aot": true,
|
|
||||||
"extractLicenses": true,
|
|
||||||
"vendorChunk": false,
|
|
||||||
"buildOptimizer": true
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"defaultConfiguration": ""
|
||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@angular-devkit/build-angular:dev-server",
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
@@ -76,6 +209,22 @@
|
|||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
"browserTarget": "mempool:build:production"
|
"browserTarget": "mempool:build:production"
|
||||||
|
},
|
||||||
|
"local": {
|
||||||
|
"proxyConfig": "proxy.conf.json",
|
||||||
|
"verbose": true
|
||||||
|
},
|
||||||
|
"staging": {
|
||||||
|
"proxyConfig": "proxy.stg.conf.json",
|
||||||
|
"disableHostCheck": true,
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"verbose": true
|
||||||
|
},
|
||||||
|
"local-prod": {
|
||||||
|
"proxyConfig": "proxy.conf.js",
|
||||||
|
"disableHostCheck": true,
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"verbose": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -90,50 +239,112 @@
|
|||||||
"options": {
|
"options": {
|
||||||
"main": "src/test.ts",
|
"main": "src/test.ts",
|
||||||
"polyfills": "src/polyfills.ts",
|
"polyfills": "src/polyfills.ts",
|
||||||
"tsConfig": "src/tsconfig.spec.json",
|
"tsConfig": "tsconfig.spec.json",
|
||||||
"karmaConfig": "src/karma.conf.js",
|
"karmaConfig": "karma.conf.js",
|
||||||
|
"assets": [
|
||||||
|
"src/favicon.ico",
|
||||||
|
"src/resources"
|
||||||
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
"src/styles.scss"
|
"src/styles.scss"
|
||||||
],
|
],
|
||||||
"scripts": [],
|
"scripts": []
|
||||||
"assets": [
|
|
||||||
"src/favicon.ico",
|
|
||||||
"src/assets"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"builder": "@angular-devkit/build-angular:tslint",
|
"builder": "@angular-devkit/build-angular:tslint",
|
||||||
"options": {
|
"options": {
|
||||||
"tsConfig": [
|
"tsConfig": [
|
||||||
"src/tsconfig.app.json",
|
"tsconfig.app.json",
|
||||||
"src/tsconfig.spec.json"
|
"tsconfig.spec.json",
|
||||||
|
"tsconfig.server.json",
|
||||||
|
"cypress/tsconfig.json"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"**/node_modules/**"
|
"**/node_modules/**"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
|
||||||
},
|
|
||||||
"mempool-e2e": {
|
|
||||||
"root": "e2e/",
|
|
||||||
"projectType": "application",
|
|
||||||
"architect": {
|
|
||||||
"e2e": {
|
"e2e": {
|
||||||
"builder": "@angular-devkit/build-angular:protractor",
|
"builder": "@cypress/schematic:cypress",
|
||||||
"options": {
|
"options": {
|
||||||
"protractorConfig": "e2e/protractor.conf.js",
|
"devServerTarget": "mempool:serve:local-prod",
|
||||||
"devServerTarget": "mempool:serve"
|
"watch": true,
|
||||||
|
"headless": false
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"devServerTarget": "mempool:serve:production"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lint": {
|
"server": {
|
||||||
"builder": "@angular-devkit/build-angular:tslint",
|
"builder": "@angular-devkit/build-angular:server",
|
||||||
"options": {
|
"options": {
|
||||||
"tsConfig": "e2e/tsconfig.e2e.json",
|
"outputPath": "dist/mempool/server",
|
||||||
"exclude": [
|
"main": "server.ts",
|
||||||
"**/node_modules/**"
|
"tsConfig": "tsconfig.server.json",
|
||||||
|
"sourceMap": true,
|
||||||
|
"optimization": false
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"outputHashing": "media",
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.prod.ts"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sourceMap": false,
|
||||||
|
"localize": true,
|
||||||
|
"optimization": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": ""
|
||||||
|
},
|
||||||
|
"serve-ssr": {
|
||||||
|
"builder": "@nguniversal/builders:ssr-dev-server",
|
||||||
|
"options": {
|
||||||
|
"browserTarget": "mempool:build",
|
||||||
|
"serverTarget": "mempool:server"
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"browserTarget": "mempool:build:production",
|
||||||
|
"serverTarget": "mempool:server:production"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"prerender": {
|
||||||
|
"builder": "@nguniversal/builders:prerender",
|
||||||
|
"options": {
|
||||||
|
"browserTarget": "mempool:build:production",
|
||||||
|
"serverTarget": "mempool:server:production",
|
||||||
|
"routes": [
|
||||||
|
"/"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
frontend/cypress.json
Normal file
15
frontend/cypress.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
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
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
88
frontend/cypress/integration/bisq/bisq.spec.ts
Normal file
88
frontend/cypress/integration/bisq/bisq.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
describe('Bisq', () => {
|
||||||
|
let baseModule;
|
||||||
|
beforeEach(() => {
|
||||||
|
baseModule = (Cypress.env('BASE_MODULE') && Cypress.env('BASE_MODULE') === 'bisq') ? '' : '/bisq';
|
||||||
|
|
||||||
|
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 (Cypress.env("BASE_MODULE") === '' || Cypress.env("BASE_MODULE") !== 'liquid') {
|
||||||
|
|
||||||
|
it('loads the dashboard', () => {
|
||||||
|
cy.visit(`${baseModule}`);
|
||||||
|
cy.waitForSkeletonGone();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the transactions screen', () => {
|
||||||
|
cy.visit(`${baseModule}`);
|
||||||
|
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(`${baseModule}`);
|
||||||
|
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(`${baseModule}`);
|
||||||
|
cy.waitForSkeletonGone();
|
||||||
|
cy.get('li:nth-of-type(4) > a').click().then(() => {
|
||||||
|
cy.wait('@stats');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the api screen', () => {
|
||||||
|
cy.visit(`${baseModule}`);
|
||||||
|
cy.waitForSkeletonGone();
|
||||||
|
cy.get('li:nth-of-type(5) > a').click().then(() => {
|
||||||
|
cy.get('.card').should('have.length.at.least', 1);
|
||||||
|
cy.get('.card').first().click();
|
||||||
|
cy.get('.card-body');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows blocks pagination with 5 pages (desktop)', () => {
|
||||||
|
cy.viewport(760, 800);
|
||||||
|
cy.visit(`${baseModule}/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(`${baseModule}/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");
|
||||||
|
}
|
||||||
|
});
|
||||||
147
frontend/cypress/integration/liquid/liquid.spec.ts
Normal file
147
frontend/cypress/integration/liquid/liquid.spec.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
describe('Liquid', () => {
|
||||||
|
let baseModule;
|
||||||
|
beforeEach(() => {
|
||||||
|
baseModule = (Cypress.env('BASE_MODULE') && Cypress.env('BASE_MODULE') === 'liquid') ? '' : '/liquid';
|
||||||
|
|
||||||
|
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 (Cypress.env("BASE_MODULE") === '' || Cypress.env("BASE_MODULE") !== 'bisq') {
|
||||||
|
|
||||||
|
it('loads the dashboard', () => {
|
||||||
|
cy.visit(`${baseModule}`);
|
||||||
|
cy.waitForSkeletonGone();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the blocks page', () => {
|
||||||
|
cy.visit(`${baseModule}/blocks`);
|
||||||
|
cy.waitForSkeletonGone();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads a specific block page', () => {
|
||||||
|
cy.visit(`${baseModule}/block/7e1369a23a5ab861e7bdede2aadcccae4ea873ffd9caf11c7c5541eb5bcdff54`);
|
||||||
|
cy.waitForSkeletonGone();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the graphs page', () => {
|
||||||
|
cy.visit(`${baseModule}/graphs`);
|
||||||
|
cy.waitForSkeletonGone();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the tv page - desktop', () => {
|
||||||
|
cy.visit(`${baseModule}`);
|
||||||
|
cy.waitForSkeletonGone();
|
||||||
|
cy.get('li:nth-of-type(3) > a').click().then(() => {
|
||||||
|
cy.wait(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the graphs page - mobile', () => {
|
||||||
|
cy.visit(`${baseModule}`)
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('assets', () => {
|
||||||
|
it('shows the assets screen', () => {
|
||||||
|
cy.visit(`${baseModule}/assets`);
|
||||||
|
cy.waitForSkeletonGone();
|
||||||
|
cy.get('table tr').should('have.length.at.least', 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows searching assets', () => {
|
||||||
|
cy.visit(`${baseModule}/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(`${baseModule}/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(`${baseModule}/tx/82a479043ec3841e0d3f829afc8df4f0e2bbd675a13f013ea611b2fde0027d45`);
|
||||||
|
cy.waitForSkeletonGone();
|
||||||
|
cy.get('.error-unblinded' ).should('not.exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('show unblinded TX', () => {
|
||||||
|
cy.visit(`${baseModule}/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(`${baseModule}/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(`${baseModule}/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(`${baseModule}/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(`${baseModule}/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(`${baseModule}/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(`${baseModule}/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(`${baseModule}/tx/393b890966f305e7c440fcfb12a13f51a7a9011cc59ff5f14f6f93214261bd82`);
|
||||||
|
cy.waitForSkeletonGone();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
it.skip("Tests cannot be run on the selected BASE_MODULE");
|
||||||
|
}
|
||||||
|
});
|
||||||
368
frontend/cypress/integration/mainnet/mainnet.spec.ts
Normal file
368
frontend/cypress/integration/mainnet/mainnet.spec.ts
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
import { emitMempoolInfo, dropWebSocket } from "../../support/websocket";
|
||||||
|
|
||||||
|
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 (Cypress.env("BASE_MODULE") === '' || Cypress.env("BASE_MODULE") === 'mempool') {
|
||||||
|
|
||||||
|
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': {
|
||||||
|
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 dashboard', () => {
|
||||||
|
cy.visit('/');
|
||||||
|
cy.waitForSkeletonGone();
|
||||||
|
});
|
||||||
|
|
||||||
|
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 bc1 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/bc1pqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqs3wf0qm');
|
||||||
|
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("liquid");
|
||||||
|
cy.changeNetwork("mainnet");
|
||||||
|
cy.changeNetwork("bisq");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('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': {
|
||||||
|
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('/');
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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.only('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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
it.skip("Tests cannot be run on the selected BASE_MODULE");
|
||||||
|
}
|
||||||
|
});
|
||||||
131
frontend/cypress/integration/signet/signet.spec.ts
Normal file
131
frontend/cypress/integration/signet/signet.spec.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { emitMempoolInfo } from "../../support/websocket";
|
||||||
|
|
||||||
|
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 (Cypress.env("BASE_MODULE") === '' || Cypress.env("BASE_MODULE") === 'mempool') {
|
||||||
|
it('loads the dashboard', () => {
|
||||||
|
cy.visit('/signet');
|
||||||
|
cy.waitForSkeletonGone();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('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");
|
||||||
|
}
|
||||||
|
});
|
||||||
128
frontend/cypress/integration/testnet/testnet.spec.ts
Normal file
128
frontend/cypress/integration/testnet/testnet.spec.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { confirmAddress, emitMempoolInfo, sendWsMock, showNewTx, startTrackingAddress } from "../../support/websocket";
|
||||||
|
|
||||||
|
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 (Cypress.env("BASE_MODULE") === '' || Cypress.env("BASE_MODULE") === 'mempool') {
|
||||||
|
|
||||||
|
it('loads the dashboard', () => {
|
||||||
|
cy.visit('/testnet');
|
||||||
|
cy.waitForSkeletonGone();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('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");
|
||||||
|
}
|
||||||
|
});
|
||||||
1
frontend/cypress/plugins/index.js
Normal file
1
frontend/cypress/plugins/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
module.exports = (on, 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();
|
||||||
92
frontend/cypress/support/websocket.ts
Normal file
92
frontend/cypress/support/websocket.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
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":
|
||||||
|
default:
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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,28 +0,0 @@
|
|||||||
// Protractor configuration file, see link for more information
|
|
||||||
// https://github.com/angular/protractor/blob/master/lib/config.ts
|
|
||||||
|
|
||||||
const { SpecReporter } = require('jasmine-spec-reporter');
|
|
||||||
|
|
||||||
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.e2e.json')
|
|
||||||
});
|
|
||||||
jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { AppPage } from './app.po';
|
|
||||||
|
|
||||||
describe('workspace-project App', () => {
|
|
||||||
let page: AppPage;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
page = new AppPage();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should display welcome message', () => {
|
|
||||||
page.navigateTo();
|
|
||||||
expect(page.getParagraphText()).toEqual('Welcome to app!');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user