Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7a6e86e7a | ||
|
|
232d50baaa | ||
|
|
6f9ad06449 | ||
|
|
2abf741f28 | ||
|
|
2782a92aaa | ||
|
|
9e652d98ea | ||
|
|
bfc95dc4dd | ||
|
|
7ee6d09585 | ||
|
|
d0ca0b18da | ||
|
|
1660b9dcf1 | ||
|
|
57fffa0c7f | ||
|
|
0930fca7ec | ||
|
|
e080e11bf5 | ||
|
|
d6e3eb5837 | ||
|
|
b8531f9e0e | ||
|
|
8c75ea600f | ||
|
|
15e9eeb682 | ||
|
|
fad040edf1 | ||
|
|
f3a16a28cc | ||
|
|
f30ca2cf43 | ||
|
|
4662a79c3b | ||
|
|
d757154125 | ||
|
|
54305c8f91 | ||
|
|
7d0b5ade3e | ||
|
|
9296e295ea | ||
|
|
2704552685 | ||
|
|
d1eb0b0c4d | ||
|
|
0177316075 | ||
|
|
d2f00435cc | ||
|
|
dc6098e1ca | ||
|
|
e0c048339c | ||
|
|
ddc3ec7e2f | ||
|
|
e5644ec4f6 | ||
|
|
d96ac5f3ef | ||
|
|
39800fd5fd | ||
|
|
83605a2524 | ||
|
|
7e90c47008 | ||
|
|
d95a3032e6 | ||
|
|
3080094273 | ||
|
|
c52ffe846d | ||
|
|
dda5784a47 | ||
|
|
7b2be9cd49 | ||
|
|
bcbdda657b | ||
|
|
ee76a58c27 | ||
|
|
609bdae1e4 | ||
|
|
fa4b89b9a2 | ||
|
|
aeab899e4d | ||
|
|
13dd3bbe43 | ||
|
|
01d0fdeac6 | ||
|
|
d5df15707e | ||
|
|
b3093cafdb | ||
|
|
36b2b82186 | ||
|
|
c114895219 | ||
|
|
ad60c1614e | ||
|
|
7a7413bb6f | ||
|
|
2ee10e28c5 | ||
|
|
78a98ca97d | ||
|
|
4e63662f42 | ||
|
|
81ed71a2e4 | ||
|
|
993511bcd4 | ||
|
|
a6cf19c29d | ||
|
|
5fecb02b8d | ||
|
|
88210129b7 | ||
|
|
7baec1de15 | ||
|
|
82904e509d | ||
|
|
fe83cad7a3 | ||
|
|
d4da94e10b | ||
|
|
56b4eb4a5e | ||
|
|
6d9d037c06 | ||
|
|
b7c7b28dfc | ||
|
|
51f9166744 | ||
|
|
1493e73724 | ||
|
|
8e41f7867e | ||
|
|
93483f76ab | ||
|
|
e715ff52d2 | ||
|
|
5dd6c0d2f4 | ||
|
|
d7696fdc0f | ||
|
|
a086db43ba | ||
|
|
d858a3c7cd | ||
|
|
33f9ae9f66 | ||
|
|
630cc296fb | ||
|
|
c35ae109a4 | ||
|
|
a8ac90da5c | ||
|
|
c0daf8e7b2 | ||
|
|
7ea3830fed | ||
|
|
7400d7f430 | ||
|
|
c8b725830b | ||
|
|
8832d6e4eb | ||
|
|
b987059386 | ||
|
|
59876fc17c | ||
|
|
7bfc0848ef | ||
|
|
88d38f7294 | ||
|
|
9e3cd3f00e | ||
|
|
17eeed48f5 | ||
|
|
25697a8b00 | ||
|
|
6e9fd28d28 | ||
|
|
6f5c35d4d3 | ||
|
|
8cabd7b5a4 | ||
|
|
cb5c11fae4 | ||
|
|
4f920fe563 | ||
|
|
977f9bb8c4 | ||
|
|
77ecc5a1d2 | ||
|
|
20dbd879b1 | ||
|
|
5ba624761c | ||
|
|
99595ba96b | ||
|
|
330a026379 | ||
|
|
c3870e65e3 | ||
|
|
4e8766ce08 | ||
|
|
59d6b654c5 | ||
|
|
c10f89e1d6 | ||
|
|
56c9234aff | ||
|
|
4e68c114fd | ||
|
|
baf993f185 | ||
|
|
ff8d32ec83 | ||
|
|
ccc76d8ed1 | ||
|
|
9bbcd28452 | ||
|
|
d08b7455e7 | ||
|
|
551c75c2cf | ||
|
|
1d55fe1d1a | ||
|
|
a3f910acf4 | ||
|
|
aff7e11089 | ||
|
|
218ccbc3a7 | ||
|
|
5c2045927a | ||
|
|
d9ddd746e0 | ||
|
|
a6f0acc030 | ||
|
|
f6a301668b | ||
|
|
38ef4f3a78 | ||
|
|
c4ffd45485 | ||
|
|
ec9005ffca | ||
|
|
b11ddf3d61 | ||
|
|
971d8f34e8 | ||
|
|
a8bc3ea870 | ||
|
|
f41c43cbf4 | ||
|
|
9dd7d30716 | ||
|
|
4242a82029 | ||
|
|
db83d803f8 | ||
|
|
6898412e6c | ||
|
|
302ccfd429 | ||
|
|
b80f2f8e4d | ||
|
|
7d56878f55 | ||
|
|
70deb7e702 | ||
|
|
739646f702 | ||
|
|
95e7633e19 | ||
|
|
5b59cdcd0d | ||
|
|
a28bedf430 | ||
|
|
43db91641a | ||
|
|
83f998fe37 | ||
|
|
713909292e | ||
|
|
fb7030c30f | ||
|
|
218ee77acd | ||
|
|
90938e89e1 | ||
|
|
d8d4d4d057 | ||
|
|
ae1a2ac383 | ||
|
|
e2c9673517 | ||
|
|
853536ea32 | ||
|
|
61d3ca8c0b | ||
|
|
e37fc0b898 | ||
|
|
faa9a017a4 | ||
|
|
fb60673f67 | ||
|
|
31a51f3cbc | ||
|
|
d649e6a5c0 | ||
|
|
24de8f6611 | ||
|
|
65f27afd76 | ||
|
|
790f0efda7 | ||
|
|
7bba742faf | ||
|
|
3dc00f67db | ||
|
|
ee4049ec1f | ||
|
|
88ec440ea1 | ||
|
|
f73f5a90c9 | ||
|
|
7c796f428a | ||
|
|
8956bdc954 | ||
|
|
6aef9bfd45 | ||
|
|
32bb59255d | ||
|
|
01d2db5a1b | ||
|
|
89130bd1fb | ||
|
|
4ac73f1a79 | ||
|
|
0e5f6ac586 | ||
|
|
befe96257d | ||
|
|
9bc99f796b | ||
|
|
0f760bf39e | ||
|
|
3df66334db | ||
|
|
c85be8dd22 | ||
|
|
fc427e7ccc | ||
|
|
293e5394a8 | ||
|
|
4c758f4665 | ||
|
|
f8d4c39b1e | ||
|
|
c04b0a1905 | ||
|
|
5b8d317441 | ||
|
|
5af49e3801 | ||
|
|
2f497ac0ee | ||
|
|
3cb62d1899 | ||
|
|
4fab2d3101 | ||
|
|
c32ff1de59 | ||
|
|
197ff7d054 | ||
|
|
d989441587 | ||
|
|
034813a1ab | ||
|
|
28661903b6 | ||
|
|
32a09f7359 | ||
|
|
e5d8c500d2 | ||
|
|
3457ff101e | ||
|
|
a32754610a | ||
|
|
19dfef6044 | ||
|
|
1c03b28b77 | ||
|
|
58ab68b876 | ||
|
|
e6c8865eca | ||
|
|
174cc28fe1 | ||
|
|
3f71df7829 | ||
|
|
d1e1cbed41 | ||
|
|
e20cfd3e6d | ||
|
|
6b57c8b2a7 | ||
|
|
e884e3b003 | ||
|
|
3d1ea76676 | ||
|
|
a6fba27554 | ||
|
|
f24149050d | ||
|
|
6d4116b049 | ||
|
|
8dfb4329d1 | ||
|
|
05edd908f0 | ||
|
|
4fc0a25d41 | ||
|
|
1f139884fe | ||
|
|
63849a0894 | ||
|
|
af8af54376 | ||
|
|
1f158dfd03 | ||
|
|
ac203046a6 | ||
|
|
b83fb1d5d8 | ||
|
|
7fc1361475 | ||
|
|
9b27c2da01 | ||
|
|
8075503566 | ||
|
|
e55044da5a | ||
|
|
337234cb0c | ||
|
|
40ca51af0e | ||
|
|
a0450cbdd9 | ||
|
|
798b8e5fdf | ||
|
|
48ab496cb4 | ||
|
|
4ab43e0743 | ||
|
|
48a57513f4 | ||
|
|
b46ec8f149 | ||
|
|
fa2a0ab1bd | ||
|
|
8535d70d36 | ||
|
|
05b758a784 | ||
|
|
2edaa0ee03 | ||
|
|
6c332ce0c5 | ||
|
|
828e4a78d1 | ||
|
|
f1976e624d | ||
|
|
f11a51a4cc | ||
|
|
d14fbd68aa | ||
|
|
d078b7b1f2 | ||
|
|
b83f19e5d1 | ||
|
|
04327a98cb | ||
|
|
cb137e7142 | ||
|
|
2ae4df70ea | ||
|
|
c407e054fd | ||
|
|
a030b20971 | ||
|
|
35ab1e7d9c | ||
|
|
f202d80241 | ||
|
|
2ac8ee95b0 | ||
|
|
73265a9951 | ||
|
|
ae479ee0d0 | ||
|
|
a50fc2d4ae | ||
|
|
6f19dab416 | ||
|
|
5697bd3b31 | ||
|
|
6150b767e4 | ||
|
|
4671954896 | ||
|
|
657ed7a37c | ||
|
|
51c8b58fdb | ||
|
|
68f45c471e | ||
|
|
6de8b0b06d | ||
|
|
5e20c4314b | ||
|
|
5414e3e772 | ||
|
|
1089be017b | ||
|
|
bf5e6330a8 | ||
|
|
8746045428 | ||
|
|
d541bebf38 | ||
|
|
3054248858 | ||
|
|
9374433153 | ||
|
|
87f6eda206 | ||
|
|
a27bbe628c | ||
|
|
b664500061 | ||
|
|
32486d62bf | ||
|
|
d5cc8d9187 | ||
|
|
5d1d2d634a | ||
|
|
7a3b675119 | ||
|
|
689cd09aef | ||
|
|
8dfc133460 | ||
|
|
5ee81f2e5a | ||
|
|
df7ebc6699 | ||
|
|
4e9cfc0f74 | ||
|
|
b0216bff75 | ||
|
|
59d960b5f9 | ||
|
|
24d162f3fc | ||
|
|
cec44f4a99 | ||
|
|
31729e41cf | ||
|
|
1bd74aba4d | ||
|
|
74328bfeb5 | ||
|
|
eedd0ee6a3 | ||
|
|
b576ef4f21 | ||
|
|
dc2ecfe9a2 | ||
|
|
86d3ebd5dc | ||
|
|
dc95143998 | ||
|
|
c76644c561 | ||
|
|
5638bca2d7 | ||
|
|
3765900be0 | ||
|
|
3e7ce185f6 | ||
|
|
be6dbb68c6 | ||
|
|
e98e3504ad | ||
|
|
799fb99661 | ||
|
|
e6b46301df | ||
|
|
d6b285feb5 | ||
|
|
ff29aacace | ||
|
|
8376f08e8b | ||
|
|
afbdfeff8c | ||
|
|
c5755ac587 | ||
|
|
b18a4d37e3 | ||
|
|
46dfd337b2 | ||
|
|
b96bdf25f6 | ||
|
|
f102928812 | ||
|
|
63424d1ca4 | ||
|
|
99813a48cd | ||
|
|
412dc8b579 | ||
|
|
559b4a663c | ||
|
|
9cbd01d514 | ||
|
|
5f7537f600 | ||
|
|
01c81c1a83 | ||
|
|
ded6df61b1 | ||
|
|
7f32139bcd | ||
|
|
9c343acf86 | ||
|
|
a0d56a7e06 | ||
|
|
b7c4a411b1 | ||
|
|
367faf437a | ||
|
|
6098b8308e | ||
|
|
0e87e6608f | ||
|
|
d80ae7de1b | ||
|
|
73adc4a7e8 | ||
|
|
3bf0bb1e63 | ||
|
|
7594474189 | ||
|
|
ae68c4dc6e | ||
|
|
41608b4b30 | ||
|
|
7b75cbf59c | ||
|
|
91fefdb83d | ||
|
|
989c6b51ba | ||
|
|
d38ff896f6 | ||
|
|
28af0a2100 | ||
|
|
716a30a0fc | ||
|
|
972a1d4ac9 | ||
|
|
a948f0f8d9 | ||
|
|
08fd967f8a | ||
|
|
3b6c2b0103 | ||
|
|
88ef3a38fc | ||
|
|
b3c866fd1d | ||
|
|
9154660479 | ||
|
|
c36da4172c | ||
|
|
1374aabf92 | ||
|
|
b8c1b2ad8f | ||
|
|
0b2c01e388 | ||
|
|
cb87b55e9d | ||
|
|
da57f55493 | ||
|
|
fed01b70bd | ||
|
|
d841690460 | ||
|
|
9c19b61e4a | ||
|
|
f1ec7e33f0 | ||
|
|
c88e6b0e62 | ||
|
|
a1919e0619 | ||
|
|
925a7735da | ||
|
|
28c50d93ce | ||
|
|
457829de0c | ||
|
|
cf85018f9d | ||
|
|
f7a675962a | ||
|
|
3e40d91466 | ||
|
|
84cb581207 | ||
|
|
0d4ccad6d7 | ||
|
|
17cd1dec47 | ||
|
|
723d7fffd2 | ||
|
|
c6b72f7f47 | ||
|
|
940ef26003 | ||
|
|
cac7e1e3c0 | ||
|
|
c9c3dd3f66 | ||
|
|
c075b28a96 | ||
|
|
f24d593f00 | ||
|
|
19fda31afb | ||
|
|
166618c452 | ||
|
|
1b1a7cc3ed | ||
|
|
9387d9cc48 | ||
|
|
096778eb43 | ||
|
|
909691bd9b | ||
|
|
f7209bbfd7 | ||
|
|
f4d47c3234 | ||
|
|
2e715d6eea | ||
|
|
e09a1bdf90 | ||
|
|
3b98a10c9e | ||
|
|
ffcff3f441 | ||
|
|
191124d8fc | ||
|
|
f22e1e16af | ||
|
|
66a3b24dd5 | ||
|
|
809d03d3db | ||
|
|
f9fd071315 | ||
|
|
739c108bdf | ||
|
|
c419afc1b1 | ||
|
|
46a7b54a00 | ||
|
|
0f204485c3 | ||
|
|
8c6b31ec12 | ||
|
|
d79d4e371e | ||
|
|
e4078504ba | ||
|
|
25f37b0e4f | ||
|
|
057de41678 | ||
|
|
31b6a9410c | ||
|
|
e960285c8f | ||
|
|
86f7ea64c4 | ||
|
|
ac3f95e6c2 | ||
|
|
4cb54ce549 | ||
|
|
5a3e1d7fbb | ||
|
|
4fa8b5a292 | ||
|
|
ccae7876d9 | ||
|
|
cce341fb39 | ||
|
|
5317d5afd5 | ||
|
|
6da62c648a | ||
|
|
40f5cf77f6 | ||
|
|
475fd566e6 | ||
|
|
a9506cee11 | ||
|
|
efbf34a51e | ||
|
|
0cd8ba956a | ||
|
|
05bc887ad5 | ||
|
|
80e1545920 | ||
|
|
728209da7d | ||
|
|
3bd4253292 | ||
|
|
912a7e1c4b | ||
|
|
d74b48b0b6 | ||
|
|
615520bcb3 | ||
|
|
1dd51a2752 | ||
|
|
e3d36e6a3a | ||
|
|
09a9a0f9ec | ||
|
|
74322232d0 | ||
|
|
91c6d32852 | ||
|
|
6c21bf359c | ||
|
|
b5e97f08a6 | ||
|
|
8e05a11978 | ||
|
|
50b2dcfcaa | ||
|
|
6cfa8e30bc | ||
|
|
5a005e830c | ||
|
|
d93b199735 | ||
|
|
dc501d3f40 | ||
|
|
6c63d3000f | ||
|
|
370a962528 | ||
|
|
885c690cc6 | ||
|
|
3d417bc40a | ||
|
|
87dc9136a4 | ||
|
|
c1f6845ad9 | ||
|
|
3f51117ea9 | ||
|
|
3d1b3b7173 | ||
|
|
cff9a59693 | ||
|
|
5bc2907f80 | ||
|
|
7bb936b09b | ||
|
|
f72421c62c | ||
|
|
cf75df366a | ||
|
|
23954b501f | ||
|
|
98366cfaf1 | ||
|
|
ee2342e1ba | ||
|
|
c1289c43a4 | ||
|
|
92e7dde500 | ||
|
|
085bcb8234 | ||
|
|
8e4e0bdf2d | ||
|
|
a2f48de583 | ||
|
|
dec8d4b6b3 | ||
|
|
2b5fc82110 | ||
|
|
2ef9548b05 | ||
|
|
184da9958c | ||
|
|
ad3e1728cb | ||
|
|
618717b529 | ||
|
|
261a156fa3 | ||
|
|
a57850f391 | ||
|
|
a7d27467f0 | ||
|
|
19b6acf932 | ||
|
|
eafefce709 | ||
|
|
a3a183b86c | ||
|
|
cf11b6c42d | ||
|
|
4ca701a442 | ||
|
|
56cac668ca | ||
|
|
a601e178ea | ||
|
|
04114c82f2 | ||
|
|
7f79b5ac7f | ||
|
|
4199f1bf98 | ||
|
|
51dac0275c | ||
|
|
730add5116 | ||
|
|
4c8641fe00 | ||
|
|
dab109614d | ||
|
|
9a9f3ae809 | ||
|
|
822373a763 | ||
|
|
4441837cb7 | ||
|
|
85db59c359 | ||
|
|
fa0649646c | ||
|
|
7f8ee461a2 | ||
|
|
d322e6d4dc | ||
|
|
c1278dbef9 | ||
|
|
3ec9cb6c89 | ||
|
|
556eef35ab | ||
|
|
783502ebf8 | ||
|
|
c340a55925 | ||
|
|
f5a3f5b503 | ||
|
|
254fa67859 | ||
|
|
bfb4006b02 | ||
|
|
b60ad6974c | ||
|
|
354fedc0b8 | ||
|
|
817472c036 | ||
|
|
f973de2f22 | ||
|
|
c3d3f843c1 | ||
|
|
64ea5db608 | ||
|
|
3a28ff1020 | ||
|
|
58659d2f47 | ||
|
|
bb002c6d40 | ||
|
|
22592d6fde | ||
|
|
1c77d165e2 | ||
|
|
41dc1b0512 | ||
|
|
8cac577f2f | ||
|
|
2b811ec1bb | ||
|
|
ee1cc28187 | ||
|
|
1389098477 | ||
|
|
6e344561e3 | ||
|
|
521c821f6b | ||
|
|
0c5cb8cb9e | ||
|
|
b1083a895e | ||
|
|
f6c38f2556 | ||
|
|
c72834e47e | ||
|
|
7da496dbb4 | ||
|
|
347baa076a | ||
|
|
36cb1eb47b | ||
|
|
7667c2f531 | ||
|
|
80dd330ec0 | ||
|
|
00dba54ac8 | ||
|
|
e63c4c9fa2 | ||
|
|
27ce17cd74 | ||
|
|
1c543e3a84 | ||
|
|
345cf86544 | ||
|
|
e4078910c4 | ||
|
|
5810a4993b | ||
|
|
b90b5ea517 | ||
|
|
ea76995467 | ||
|
|
2d46eda5f5 | ||
|
|
309a4dbd1a | ||
|
|
2834ce0b4f | ||
|
|
b708950b93 | ||
|
|
c7e5d66801 | ||
|
|
1da7c9b349 | ||
|
|
3ffcab2d4d | ||
|
|
462fffbeaa | ||
|
|
8d89518271 | ||
|
|
210edde08a | ||
|
|
d9849435a8 | ||
|
|
e67b2c0529 | ||
|
|
5779243d3e | ||
|
|
3d1abfa35a | ||
|
|
0bf4457c2f | ||
|
|
a7c10dde4a | ||
|
|
27b4db9877 | ||
|
|
9cecd42467 | ||
|
|
800eda2f6d | ||
|
|
df00a018f0 | ||
|
|
66477bc392 | ||
|
|
b865fad95e | ||
|
|
e939b9a5aa | ||
|
|
a3e4f175dd | ||
|
|
4dfa0873e5 | ||
|
|
0e393111de | ||
|
|
463fbb39bd | ||
|
|
88ee5fd6cc | ||
|
|
a0707f8a32 | ||
|
|
b9373f7283 | ||
|
|
a80fd5de7a | ||
|
|
9a17e4896b | ||
|
|
7f2c25336c | ||
|
|
f677777f07 | ||
|
|
25d19244be | ||
|
|
704b572349 | ||
|
|
56560fca02 | ||
|
|
7ad9ad799e | ||
|
|
8e9b3190e4 | ||
|
|
808bd36e23 | ||
|
|
895770e26a | ||
|
|
2891a421f1 | ||
|
|
5f3183c54c | ||
|
|
7fbea79e62 | ||
|
|
ff5b1189b5 | ||
|
|
4bb184e08b | ||
|
|
4e57b75fa1 | ||
|
|
20817df4de | ||
|
|
4b5e113c03 | ||
|
|
b17aed2d37 | ||
|
|
d93bd4bec1 | ||
|
|
dd7f622a67 | ||
|
|
923fad7382 | ||
|
|
52c889f1d8 | ||
|
|
244d6f23bf | ||
|
|
0e8da3c368 | ||
|
|
32a8736f32 | ||
|
|
70d270bd69 | ||
|
|
bf2a3e9df2 | ||
|
|
3b172630e3 | ||
|
|
c0c26fb98e | ||
|
|
96af5fc3a7 | ||
|
|
fd0d6bffce | ||
|
|
14344c2f03 | ||
|
|
e4df8c52e2 | ||
|
|
4feb45d745 | ||
|
|
76a5196dbf | ||
|
|
5aa30c843c | ||
|
|
76fdce4328 | ||
|
|
7f960d8802 | ||
|
|
04df2129b0 | ||
|
|
613c5b4667 | ||
|
|
0784ae02b1 | ||
|
|
f4e3a3980e | ||
|
|
06b1f71d39 | ||
|
|
2e0276bc60 | ||
|
|
c186e53cef | ||
|
|
5f6dc757dc | ||
|
|
627959e30b | ||
|
|
64502c355e | ||
|
|
56b4b0cf43 | ||
|
|
8dbf64a0d0 | ||
|
|
e96944a4fa | ||
|
|
68a195952a | ||
|
|
e1d1ce7edc | ||
|
|
045212a070 | ||
|
|
886c74f205 | ||
|
|
91c5a68ccc | ||
|
|
8901ff8084 | ||
|
|
45c356cf6c | ||
|
|
0f73501a88 | ||
|
|
45a13fd807 | ||
|
|
83a000d7c9 | ||
|
|
b8c99a15d1 | ||
|
|
fe2cbe8774 | ||
|
|
ed2c5cc586 | ||
|
|
694124b5d6 | ||
|
|
b2a0faf9de | ||
|
|
8d52b1b359 | ||
|
|
b6d01792e2 | ||
|
|
f4580955b9 | ||
|
|
c8ab2859fd | ||
|
|
2b827d37fa | ||
|
|
8079325ec9 | ||
|
|
6646fce549 | ||
|
|
d4c30a1091 | ||
|
|
2febb364ca | ||
|
|
ad2aad05e0 | ||
|
|
f4e7d62ca3 | ||
|
|
69516bb751 | ||
|
|
99b6b9d71c | ||
|
|
6a3a72b944 | ||
|
|
348033da1b | ||
|
|
37c7553c19 | ||
|
|
984f06239f | ||
|
|
1f13bdeb2f | ||
|
|
357860d849 | ||
|
|
41dda6fd01 | ||
|
|
46462b4f01 | ||
|
|
a57980469b | ||
|
|
6aa8330dc3 | ||
|
|
36e92765d3 | ||
|
|
49bd5ad26b | ||
|
|
50a5f47308 | ||
|
|
c4924bcf24 | ||
|
|
0825519361 | ||
|
|
b1d6c82466 | ||
|
|
63d4e60542 | ||
|
|
262bd32f44 | ||
|
|
089313f613 | ||
|
|
847fd18796 | ||
|
|
ed6ddd0f5e | ||
|
|
94e4495026 | ||
|
|
aa51d85d65 | ||
|
|
ebeb3d213b | ||
|
|
2cb87d96f2 | ||
|
|
1b064ceda4 | ||
|
|
92887ed169 | ||
|
|
d55f8dc36e | ||
|
|
42d342cd0d | ||
|
|
80f8d83548 | ||
|
|
e5b4c54348 | ||
|
|
f44bd55b4b | ||
|
|
17c79cd044 | ||
|
|
56c60587a1 | ||
|
|
c2399cf9aa | ||
|
|
f9b929ae7d | ||
|
|
52b8506d0a | ||
|
|
102dc56c4a | ||
|
|
b298e12cd3 | ||
|
|
d88ffeb237 | ||
|
|
d0ba5c6f30 | ||
|
|
dd9ed3bf78 | ||
|
|
82d7bfbba8 | ||
|
|
0ae1cf6e52 | ||
|
|
e4f16f172c | ||
|
|
6154454f15 | ||
|
|
c8a6caa4e3 | ||
|
|
e7dd3ec64f | ||
|
|
f6da260464 | ||
|
|
2f62d98299 | ||
|
|
2c68f53c91 | ||
|
|
6147bd23ec | ||
|
|
6867d65733 | ||
|
|
fea92354a8 | ||
|
|
a7efff858b | ||
|
|
267530f217 | ||
|
|
6b66ca1531 | ||
|
|
8a20b779df | ||
|
|
73b8bd682c | ||
|
|
06fbc4b0ae | ||
|
|
c90cf6ee1b | ||
|
|
6a4170f4cc | ||
|
|
6de20106ef | ||
|
|
d8445450d8 | ||
|
|
10e1bbcb66 | ||
|
|
b90eb0a3ba | ||
|
|
4e25da5ca0 | ||
|
|
f6d922e1b7 | ||
|
|
1a394d0a73 | ||
|
|
3e8d2a5ba1 | ||
|
|
1ebba9f846 | ||
|
|
626a93a46c | ||
|
|
02e0efe722 | ||
|
|
1dec874af7 | ||
|
|
8e9972ead1 | ||
|
|
399ff11b04 | ||
|
|
785c150bb2 | ||
|
|
c4f9206c10 | ||
|
|
2ff282ce4c | ||
|
|
5ceb8639f9 | ||
|
|
9279129f07 | ||
|
|
c5ddd1a2c6 | ||
|
|
6617450c21 | ||
|
|
b7008495b8 | ||
|
|
77fbea25af | ||
|
|
395b4379a4 | ||
|
|
906f0166bd | ||
|
|
b03da7b9c2 | ||
|
|
145b69b259 | ||
|
|
895d9d2e0a | ||
|
|
f8400512f7 | ||
|
|
7c4ce9bf96 | ||
|
|
b4a6b5a1d9 | ||
|
|
5574fa7263 | ||
|
|
11006ef7ed | ||
|
|
b4ad0e101d | ||
|
|
cbf067bba2 | ||
|
|
5e28183a24 | ||
|
|
b0db8d8f5a | ||
|
|
2a53ee00fc | ||
|
|
c23c8d9a37 | ||
|
|
1fa9ac2622 | ||
|
|
d951ac435f | ||
|
|
ca8f649fd6 | ||
|
|
bb5b04b307 | ||
|
|
f43d8ff0c3 | ||
|
|
313c40dc2b | ||
|
|
9d6382294a | ||
|
|
2337471e45 | ||
|
|
aeb8c27ab2 | ||
|
|
46feaab142 | ||
|
|
98891b7680 | ||
|
|
4391cd6fc0 | ||
|
|
c8aaf3377f | ||
|
|
caa23e6c9d | ||
|
|
a0a2ecd424 | ||
|
|
2d5f30acfd | ||
|
|
ebd6d6d58b | ||
|
|
f08fbc2487 | ||
|
|
c04e1de412 | ||
|
|
436c3dd51a | ||
|
|
0c089109b9 | ||
|
|
e0504a4e0c | ||
|
|
1695d0ce0e | ||
|
|
96df280d49 | ||
|
|
2cd6626ca1 | ||
|
|
96c7decae7 | ||
|
|
d77e304e12 | ||
|
|
8dbe1b3678 | ||
|
|
2fd12e1d19 | ||
|
|
a36ad05fe0 | ||
|
|
e6432f0e29 | ||
|
|
c539bcbbb1 | ||
|
|
8042dc51b3 | ||
|
|
ed48821ef9 | ||
|
|
1874d7fd54 | ||
|
|
165d663491 | ||
|
|
829e220096 | ||
|
|
b5703e0dfc | ||
|
|
c7322fd071 | ||
|
|
a6c84ceb61 | ||
|
|
d1d252210c | ||
|
|
9818ba4677 | ||
|
|
6fdd1fb24b | ||
|
|
953e07eeef | ||
|
|
c229fb9ab3 | ||
|
|
df9a088717 | ||
|
|
66529c10d0 | ||
|
|
13f490b586 | ||
|
|
2f381960a0 | ||
|
|
810b918619 | ||
|
|
81860bdc5d | ||
|
|
c5c76468cb | ||
|
|
950408ef64 | ||
|
|
799065e982 | ||
|
|
a94444ae2c | ||
|
|
54264315f0 | ||
|
|
946ebc350e | ||
|
|
b1c75813a0 | ||
|
|
a17108f108 | ||
|
|
c59d0a51ff | ||
|
|
1209526147 | ||
|
|
0f97f8f18b | ||
|
|
911fe92e03 | ||
|
|
eb8bbd3896 | ||
|
|
ff70efd1eb | ||
|
|
ce6620882f | ||
|
|
ffba97850b | ||
|
|
435b4056fd | ||
|
|
30a0d6336b | ||
|
|
52ef2da76d | ||
|
|
e4dd0972a5 | ||
|
|
c708cb1e2b | ||
|
|
43f9394014 | ||
|
|
9bbf78e026 | ||
|
|
6df951b873 | ||
|
|
d62ef0bb80 | ||
|
|
4a9e7f4264 | ||
|
|
b886e5b0ea | ||
|
|
72f2ec0be8 | ||
|
|
ef6d12fe10 | ||
|
|
1e759283ce | ||
|
|
e92447092d | ||
|
|
7988e2c303 | ||
|
|
a13bb9cfea | ||
|
|
7088a3240f | ||
|
|
00e52001b5 | ||
|
|
cd35966a56 | ||
|
|
c59a65cdb4 | ||
|
|
5f52658626 | ||
|
|
2399b05855 | ||
|
|
8fb70041d2 | ||
|
|
baf55afc28 | ||
|
|
251e6a8233 | ||
|
|
d73c3950b9 | ||
|
|
ac61cd5427 | ||
|
|
0c14b8fb82 | ||
|
|
6c0aa8fa8d | ||
|
|
9b95a7743d | ||
|
|
e96701cb59 | ||
|
|
c1fa5bbcbf | ||
|
|
241c81c790 | ||
|
|
42b444f54d | ||
|
|
f7ff9d27d2 | ||
|
|
4a588dda1b | ||
|
|
e82ce89e9a | ||
|
|
270cac37ac | ||
|
|
a374526faf | ||
|
|
8930152b0f | ||
|
|
71e11b7387 | ||
|
|
510a28f3d0 | ||
|
|
c58e2c5d74 | ||
|
|
eb7b369b6d | ||
|
|
a5a3331106 | ||
|
|
4f3e48e280 | ||
|
|
31bcb32e2d | ||
|
|
a132387fa8 | ||
|
|
27694ee23d | ||
|
|
36952981c2 | ||
|
|
fbafe2b8f1 | ||
|
|
4793ea0d18 | ||
|
|
756b121105 | ||
|
|
4585d97324 | ||
|
|
021cf64c1e |
@@ -0,0 +1,25 @@
|
||||
.git
|
||||
data
|
||||
docker
|
||||
docs
|
||||
tests
|
||||
venv
|
||||
tools
|
||||
|
||||
lnbits/static/css/*
|
||||
lnbits/static/bundle.js
|
||||
lnbits/static/bundle.css
|
||||
|
||||
*.md
|
||||
*.log
|
||||
|
||||
.env
|
||||
|
||||
.gitignore
|
||||
.prettierrc
|
||||
LICENSE
|
||||
Makefile
|
||||
mypy.ini
|
||||
package-lock.json
|
||||
package.json
|
||||
pytest.ini
|
||||
@@ -1,16 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.py]
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
+23
-19
@@ -1,16 +1,17 @@
|
||||
QUART_APP=lnbits.app:create_app()
|
||||
QUART_ENV=development
|
||||
QUART_DEBUG=true
|
||||
|
||||
HOST=127.0.0.1
|
||||
PORT=5000
|
||||
|
||||
DEBUG=false
|
||||
|
||||
LNBITS_ALLOWED_USERS=""
|
||||
LNBITS_ADMIN_USERS=""
|
||||
# Extensions only admin can access
|
||||
LNBITS_ADMIN_EXTENSIONS="ngrok"
|
||||
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
|
||||
|
||||
LNBITS_AD_SPACE="" # csv ad image filepaths or urls, extensions can choose to honor
|
||||
LNBITS_HIDE_API=false # Hides wallet api, extensions can choose to honor
|
||||
|
||||
# Disable extensions for all users, use "all" to disable all extensions
|
||||
LNBITS_DISABLED_EXTENSIONS="amilk"
|
||||
|
||||
@@ -24,40 +25,39 @@ LNBITS_DATA_FOLDER="./data"
|
||||
|
||||
LNBITS_FORCE_HTTPS=true
|
||||
LNBITS_SERVICE_FEE="0.0"
|
||||
LNBITS_RESERVE_FEE_MIN=2000 # value in millisats
|
||||
LNBITS_RESERVE_FEE_PERCENT=1.0 # value in percent
|
||||
|
||||
# Change theme
|
||||
LNBITS_SITE_TITLE="LNbits"
|
||||
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
|
||||
LNBITS_SITE_DESCRIPTION="Some description about your service, will display if title is not 'LNbits'"
|
||||
# Choose from mint, flamingo, salvador, autumn, monochrome, classic
|
||||
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, mint, autumn, monochrome, salvador"
|
||||
# Choose from mint, flamingo, freedom, salvador, autumn, monochrome, classic
|
||||
LNBITS_THEME_OPTIONS="classic, bitcoin, freedom, mint, autumn, monochrome, salvador"
|
||||
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
||||
|
||||
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, LndWallet (gRPC),
|
||||
# LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet, FakeWallet
|
||||
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, ClicheWallet
|
||||
# LndRestWallet, CoreLightningWallet, LNbitsWallet, SparkWallet, FakeWallet, EclairWallet
|
||||
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
||||
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
||||
# just so you can see the UI before dealing with this file.
|
||||
|
||||
# Set one of these blocks depending on the wallet kind you chose above:
|
||||
|
||||
# ClicheWallet
|
||||
CLICHE_ENDPOINT=ws://127.0.0.1:12000
|
||||
|
||||
# SparkWallet
|
||||
SPARK_URL=http://localhost:9737/rpc
|
||||
SPARK_TOKEN=myaccesstoken
|
||||
|
||||
# CLightningWallet
|
||||
CLIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
|
||||
# CoreLightningWallet
|
||||
CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
|
||||
|
||||
# LnbitsWallet
|
||||
LNBITS_ENDPOINT=https://legend.lnbits.com
|
||||
LNBITS_KEY=LNBITS_ADMIN_KEY
|
||||
|
||||
# LndWallet
|
||||
LND_GRPC_ENDPOINT=127.0.0.1
|
||||
LND_GRPC_PORT=11009
|
||||
LND_GRPC_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
|
||||
LND_GRPC_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/admin.macaroon or HEXSTRING"
|
||||
# To use an AES-encrypted macaroon, set
|
||||
# LND_GRPC_MACAROON_ENCRYPTED="eNcRyPtEdMaCaRoOn"
|
||||
|
||||
# LndRestWallet
|
||||
LND_REST_ENDPOINT=https://127.0.0.1:8080/
|
||||
LND_REST_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
|
||||
@@ -82,4 +82,8 @@ OPENNODE_KEY=OPENNODE_ADMIN_KEY
|
||||
|
||||
# FakeWallet
|
||||
FAKE_WALLET_SECRET="ToTheMoon1"
|
||||
LNBITS_DENOMINATION=sats
|
||||
LNBITS_DENOMINATION=sats
|
||||
|
||||
# EclairWallet
|
||||
ECLAIR_URL=http://127.0.0.1:8283
|
||||
ECLAIR_PASS=eclairpw
|
||||
+1
-1
@@ -1 +1 @@
|
||||
custom: https://lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK
|
||||
custom: https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK
|
||||
|
||||
@@ -2,9 +2,9 @@ name: codeql
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, ]
|
||||
branches: [main, ]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 12 * * 5'
|
||||
|
||||
@@ -19,10 +19,10 @@ jobs:
|
||||
- run: git checkout HEAD^2
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: javascript, python
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
uses: github/codeql-action/analyze@v2
|
||||
|
||||
@@ -2,22 +2,24 @@ name: formatting
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
black:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- run: sudo apt-get install python3-venv
|
||||
- run: python3 -m venv venv
|
||||
- run: ./venv/bin/pip install black
|
||||
- run: make checkblack
|
||||
prettier:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- run: npm install
|
||||
- run: make checkprettier
|
||||
- uses: abatilo/actions-poetry@v2.1.3
|
||||
- name: Install packages
|
||||
run: poetry install
|
||||
- name: Check black
|
||||
run: make checkblack
|
||||
- name: Check isort
|
||||
run: make checkisort
|
||||
- uses: actions/setup-node@v3
|
||||
- name: Check prettier
|
||||
run: |
|
||||
npm install prettier
|
||||
make checkprettier
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
name: migrations
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
sqlite-to-postgres:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:latest
|
||||
env:
|
||||
POSTGRES_USER: lnbits
|
||||
POSTGRES_PASSWORD: lnbits
|
||||
POSTGRES_DB: migration
|
||||
ports:
|
||||
# maps tcp port 5432 on service container to the host
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.9]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- uses: abatilo/actions-poetry@v2.1.3
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
sudo apt install unzip
|
||||
- name: Run migrations
|
||||
run: |
|
||||
make test-migration
|
||||
@@ -5,8 +5,18 @@ on: [push, pull_request]
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.9]
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: jpetrucciani/mypy-check@master
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
path: lnbits
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- uses: abatilo/actions-poetry@v2.1.3
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
- name: Run tests
|
||||
run: poetry run mypy
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
name: Docker build on push
|
||||
|
||||
env:
|
||||
DOCKER_CLI_EXPERIMENTAL: enabled
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-20.04
|
||||
name: Build and push lnbits image
|
||||
steps:
|
||||
- name: Login to Docker Hub
|
||||
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Checkout project
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- 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: Show available Docker buildx 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 against commit hash
|
||||
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_USERNAME }}/lnbits:${GITHUB_SHA:0:7} \
|
||||
--output "type=registry" ./
|
||||
|
||||
- name: Run Docker buildx against latest
|
||||
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_USERNAME }}/lnbits:latest \
|
||||
--output "type=registry" ./
|
||||
@@ -0,0 +1,68 @@
|
||||
name: Build and push Docker image on tag
|
||||
|
||||
env:
|
||||
DOCKER_CLI_EXPERIMENTAL: enabled
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "[0-9]+.[0-9]+.[0-9]+"
|
||||
- "[0-9]+.[0-9]+.[0-9]+-*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-20.04
|
||||
name: Build and push lnbits image
|
||||
steps:
|
||||
- name: Login to Docker Hub
|
||||
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Checkout project
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Import environment variables
|
||||
id: import-env
|
||||
shell: bash
|
||||
run: echo "TAG=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV
|
||||
|
||||
- name: Show set environment variables
|
||||
run: |
|
||||
printf " TAG: %s\n" "$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: Show available Docker buildx 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 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 \
|
||||
--tag ${{ secrets.DOCKER_USERNAME }}/lnbits-legend:${TAG} \
|
||||
--output "type=registry" ./
|
||||
|
||||
- name: Run Docker buildx against latest
|
||||
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 \
|
||||
--tag ${{ secrets.DOCKER_USERNAME }}/lnbits-legend:latest \
|
||||
--output "type=registry" ./
|
||||
@@ -0,0 +1,123 @@
|
||||
name: regtest
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
LndRestWallet:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.9]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- uses: abatilo/actions-poetry@v2.1.3
|
||||
- name: Setup Regtest
|
||||
run: |
|
||||
docker build -t lnbitsdocker/lnbits-legend .
|
||||
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
||||
cd docker
|
||||
chmod +x ./tests
|
||||
./tests
|
||||
sudo chmod -R a+rwx .
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
- name: Run tests
|
||||
env:
|
||||
PYTHONUNBUFFERED: 1
|
||||
PORT: 5123
|
||||
LNBITS_DATA_FOLDER: ./data
|
||||
LNBITS_BACKEND_WALLET_CLASS: LndRestWallet
|
||||
LND_REST_ENDPOINT: https://localhost:8081/
|
||||
LND_REST_CERT: ./docker/data/lnd-1/tls.cert
|
||||
LND_REST_MACAROON: ./docker/data/lnd-1/data/chain/bitcoin/regtest/admin.macaroon
|
||||
run: |
|
||||
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
|
||||
make test-real-wallet
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
LndWallet:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.8]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- uses: abatilo/actions-poetry@v2.1.3
|
||||
- name: Setup Regtest
|
||||
run: |
|
||||
docker build -t lnbitsdocker/lnbits-legend .
|
||||
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
||||
cd docker
|
||||
chmod +x ./tests
|
||||
./tests
|
||||
sudo chmod -R a+rwx .
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
poetry add grpcio protobuf
|
||||
- name: Run tests
|
||||
env:
|
||||
PYTHONUNBUFFERED: 1
|
||||
PORT: 5123
|
||||
LNBITS_DATA_FOLDER: ./data
|
||||
LNBITS_BACKEND_WALLET_CLASS: LndWallet
|
||||
LND_GRPC_ENDPOINT: localhost
|
||||
LND_GRPC_PORT: 10009
|
||||
LND_GRPC_CERT: docker/data/lnd-1/tls.cert
|
||||
LND_GRPC_MACAROON: docker/data/lnd-1/data/chain/bitcoin/regtest/admin.macaroon
|
||||
run: |
|
||||
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
|
||||
make test-real-wallet
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
CoreLightningWallet:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.9]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- uses: abatilo/actions-poetry@v2.1.3
|
||||
- name: Setup Regtest
|
||||
run: |
|
||||
docker build -t lnbitsdocker/lnbits-legend .
|
||||
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
||||
cd docker
|
||||
chmod +x ./tests
|
||||
./tests
|
||||
sudo chmod -R a+rwx .
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
poetry add pyln-client
|
||||
- name: Run tests
|
||||
env:
|
||||
PYTHONUNBUFFERED: 1
|
||||
PORT: 5123
|
||||
LNBITS_DATA_FOLDER: ./data
|
||||
LNBITS_BACKEND_WALLET_CLASS: CoreLightningWallet
|
||||
CORELIGHTNING_RPC: ./docker/data/clightning-1/regtest/lightning-rpc
|
||||
run: |
|
||||
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
|
||||
make test-real-wallet
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
+62
-35
@@ -3,15 +3,15 @@ name: tests
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
venv-sqlite:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.8]
|
||||
python-version: [3.9]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
@@ -22,37 +22,64 @@ jobs:
|
||||
python -m venv ${{ env.VIRTUAL_ENV }}
|
||||
./venv/bin/python -m pip install --upgrade pip
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
./venv/bin/pip install pytest pytest-asyncio requests trio mock
|
||||
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
|
||||
- name: Run tests
|
||||
run: make test-venv
|
||||
sqlite:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.9]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- uses: abatilo/actions-poetry@v2.1.3
|
||||
- name: Install dependencies
|
||||
env:
|
||||
VIRTUAL_ENV: ./venv
|
||||
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
|
||||
run: |
|
||||
poetry install
|
||||
- name: Run tests
|
||||
run: make test
|
||||
# build:
|
||||
# runs-on: ubuntu-latest
|
||||
# strategy:
|
||||
# matrix:
|
||||
# python-version: [3.7, 3.8]
|
||||
# steps:
|
||||
# - uses: actions/checkout@v2
|
||||
# - name: Set up Python ${{ matrix.python-version }}
|
||||
# uses: actions/setup-python@v1
|
||||
# with:
|
||||
# python-version: ${{ matrix.python-version }}
|
||||
# - name: Install dependencies
|
||||
# run: |
|
||||
# python -m pip install --upgrade pip
|
||||
# pip install -r requirements.txt
|
||||
# - name: Test with pytest
|
||||
# env:
|
||||
# LNBITS_BACKEND_WALLET_CLASS: LNPayWallet
|
||||
# LNBITS_FORCE_HTTPS: 0
|
||||
# LNPAY_API_ENDPOINT: https://api.lnpay.co/v1/
|
||||
# LNPAY_API_KEY: sak_gG5pSFZhFgOLHm26a8hcWvXKt98yd
|
||||
# LNPAY_ADMIN_KEY: waka_HqWfOoNE0TPqmQHSYErbF4n9
|
||||
# LNPAY_INVOICE_KEY: waki_ZqFEbhrTyopuPlOZButZUw
|
||||
# LNPAY_READ_KEY: wakr_6IyTaNrvSeu3jbojSWt4ou6h
|
||||
# run: |
|
||||
# pip install pytest pytest-cov
|
||||
# pytest --cov=lnbits --cov-report=xml
|
||||
# - name: Upload coverage to Codecov
|
||||
# uses: codecov/codecov-action@v1
|
||||
# with:
|
||||
# file: ./coverage.xml
|
||||
postgres:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:latest
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: postgres
|
||||
ports:
|
||||
# maps tcp port 5432 on service container to the host
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.9]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- uses: abatilo/actions-poetry@v2.1.3
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
- name: Run tests
|
||||
env:
|
||||
LNBITS_DATABASE_URL: postgres://postgres:postgres@0.0.0.0:5432/postgres
|
||||
run: make test
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
|
||||
+6
-1
@@ -15,7 +15,7 @@ __pycache__
|
||||
.webassets-cache
|
||||
htmlcov
|
||||
test-reports
|
||||
tests/data
|
||||
tests/data/*.sqlite3
|
||||
|
||||
*.swo
|
||||
*.swp
|
||||
@@ -31,5 +31,10 @@ venv
|
||||
|
||||
__bundle__
|
||||
|
||||
coverage.xml
|
||||
node_modules
|
||||
lnbits/static/bundle.*
|
||||
docker
|
||||
|
||||
# Nix
|
||||
*result*
|
||||
|
||||
+9
-38
@@ -1,41 +1,12 @@
|
||||
# Build image
|
||||
FROM python:3.7-slim as builder
|
||||
|
||||
# Setup virtualenv
|
||||
ENV VIRTUAL_ENV=/opt/venv
|
||||
RUN python -m venv $VIRTUAL_ENV
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
|
||||
# Install build deps
|
||||
FROM python:3.9-slim
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y --no-install-recommends build-essential
|
||||
RUN python -m pip install --upgrade pip
|
||||
|
||||
# Install runtime deps
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
RUN pip install -r /tmp/requirements.txt
|
||||
|
||||
# Install c-lightning specific deps
|
||||
RUN pip install pylightning
|
||||
|
||||
# Install LND specific deps
|
||||
RUN pip install lndgrpc
|
||||
|
||||
# Production image
|
||||
FROM python:3.7-slim as lnbits
|
||||
|
||||
# Run as non-root
|
||||
USER 1000:1000
|
||||
|
||||
# Copy over virtualenv
|
||||
ENV VIRTUAL_ENV="/opt/venv"
|
||||
COPY --from=builder --chown=1000:1000 $VIRTUAL_ENV $VIRTUAL_ENV
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
|
||||
# Copy in app source
|
||||
RUN apt-get install -y curl
|
||||
RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||
ENV PATH="/root/.local/bin:$PATH"
|
||||
WORKDIR /app
|
||||
COPY --chown=1000:1000 lnbits /app/lnbits
|
||||
|
||||
COPY . .
|
||||
RUN poetry config virtualenvs.create false
|
||||
RUN poetry install --no-dev --no-root
|
||||
RUN poetry run python build.py
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["uvicorn", "lnbits.__main__:app", "--port", "5000", "--host", "0.0.0.0"]
|
||||
CMD ["poetry", "run", "lnbits", "--port", "5000", "--host", "0.0.0.0"]
|
||||
|
||||
@@ -2,36 +2,75 @@
|
||||
|
||||
all: format check requirements.txt
|
||||
|
||||
format: prettier black
|
||||
format: prettier isort black
|
||||
|
||||
check: mypy checkprettier checkblack
|
||||
check: mypy checkprettier checkisort checkblack
|
||||
|
||||
prettier: $(shell find lnbits -name "*.js" -name ".html")
|
||||
./node_modules/.bin/prettier --write lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js
|
||||
./node_modules/.bin/prettier --write lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html
|
||||
|
||||
black: $(shell find lnbits -name "*.py")
|
||||
./venv/bin/black lnbits
|
||||
black:
|
||||
poetry run black .
|
||||
|
||||
mypy: $(shell find lnbits -name "*.py")
|
||||
./venv/bin/mypy lnbits
|
||||
./venv/bin/mypy lnbits/core
|
||||
./venv/bin/mypy lnbits/extensions/*
|
||||
mypy:
|
||||
poetry run mypy
|
||||
|
||||
isort:
|
||||
poetry run isort .
|
||||
|
||||
checkprettier: $(shell find lnbits -name "*.js" -name ".html")
|
||||
./node_modules/.bin/prettier --check lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js
|
||||
./node_modules/.bin/prettier --check lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html
|
||||
|
||||
checkblack: $(shell find lnbits -name "*.py")
|
||||
./venv/bin/black --check lnbits
|
||||
checkblack:
|
||||
poetry run black --check .
|
||||
|
||||
Pipfile.lock: Pipfile
|
||||
./venv/bin/pipenv lock
|
||||
|
||||
requirements.txt: Pipfile.lock
|
||||
cat Pipfile.lock | jq -r '.default | map_values(.version) | to_entries | map("\(.key)\(.value)") | join("\n")' > requirements.txt
|
||||
checkisort:
|
||||
poetry run isort --check-only .
|
||||
|
||||
test:
|
||||
rm -rf ./tests/data
|
||||
mkdir -p ./tests/data
|
||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||
FAKE_WALLET_SECRET="ToTheMoon1" \
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
./venv/bin/pytest -s
|
||||
DEBUG=true \
|
||||
poetry run pytest
|
||||
|
||||
test-real-wallet:
|
||||
BOLTZ_NETWORK="regtest" \
|
||||
BOLTZ_URL="http://127.0.0.1:9001" \
|
||||
BOLTZ_MEMPOOL_SPACE_URL="http://127.0.0.1:8080" \
|
||||
BOLTZ_MEMPOOL_SPACE_URL_WS="ws://127.0.0.1:8080" \
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
poetry run pytest
|
||||
|
||||
test-venv:
|
||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||
FAKE_WALLET_SECRET="ToTheMoon1" \
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
./venv/bin/pytest --durations=1 -s --cov=lnbits --cov-report=xml tests
|
||||
|
||||
test-migration:
|
||||
rm -rf ./migration-data
|
||||
mkdir -p ./migration-data
|
||||
unzip tests/data/mock_data.zip -d ./migration-data
|
||||
HOST=0.0.0.0 \
|
||||
PORT=5002 \
|
||||
LNBITS_DATA_FOLDER="./migration-data" \
|
||||
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
||||
HOST=0.0.0.0 \
|
||||
PORT=5002 \
|
||||
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
||||
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
||||
LNBITS_DATA_FOLDER="./migration-data" \
|
||||
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
||||
poetry run python tools/conv.py
|
||||
|
||||
migration:
|
||||
poetry run python tools/conv.py
|
||||
|
||||
bak:
|
||||
# LNBITS_DATABASE_URL=postgres://postgres:postgres@0.0.0.0:5432/postgres
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
[[source]]
|
||||
name = "pypi"
|
||||
url = "https://pypi.org/simple"
|
||||
verify_ssl = true
|
||||
|
||||
[requires]
|
||||
python_version = "3.7"
|
||||
|
||||
[packages]
|
||||
bitstring = "*"
|
||||
cerberus = "*"
|
||||
ecdsa = "*"
|
||||
environs = "*"
|
||||
lnurl = "==0.3.6"
|
||||
pyscss = "*"
|
||||
shortuuid = "*"
|
||||
typing-extensions = "*"
|
||||
httpx = "*"
|
||||
sqlalchemy-aio = "*"
|
||||
embit = "*"
|
||||
pyqrcode = "*"
|
||||
pypng = "*"
|
||||
sqlalchemy = "==1.3.23"
|
||||
psycopg2-binary = "*"
|
||||
aiofiles = "*"
|
||||
asyncio = "*"
|
||||
fastapi = "*"
|
||||
uvicorn = {extras = ["standard"], version = "*"}
|
||||
sse-starlette = "*"
|
||||
jinja2 = "3.0.1"
|
||||
pyngrok = "*"
|
||||
secp256k1 = "*"
|
||||
pycryptodomex = "*"
|
||||
|
||||
[dev-packages]
|
||||
black = "==20.8b1"
|
||||
pytest = "*"
|
||||
pytest-cov = "*"
|
||||
mypy = "latest"
|
||||
Generated
-1128
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
web: hypercorn -k trio --bind 0.0.0.0:5000 'lnbits.app:create_app()'
|
||||
@@ -7,11 +7,13 @@ LNbits
|
||||
|
||||

|
||||
|
||||
# LNbits v0.3 BETA, free and open-source lightning-network wallet/accounts system
|
||||
# LNbits v0.9 BETA, free and open-source lightning-network wallet/accounts system
|
||||
|
||||
(Join us on [https://t.me/lnbits](https://t.me/lnbits))
|
||||
|
||||
Use [lnbits.com](https://lnbits.com), or run your own LNbits server!
|
||||
(LNbits is beta, for responsible disclosure of any concerns please contact lnbits@pm.me)
|
||||
|
||||
Use [legend.lnbits.com](https://legend.lnbits.com), or run your own LNbits server!
|
||||
|
||||
LNbits is a very simple Python server that sits on top of any funding source, and can be used as:
|
||||
|
||||
@@ -23,7 +25,7 @@ LNbits is a very simple Python server that sits on top of any funding source, an
|
||||
|
||||
LNbits can run on top of any lightning-network funding source, currently there is support for LND, c-lightning, Spark, LNpay, OpenNode, lntxbot, with more being added regularly.
|
||||
|
||||
See [lnbits.org](https://lnbits.org) for more detailed documentation.
|
||||
See [legend.lnbits.org](https://legend.lnbits.org) for more detailed documentation.
|
||||
|
||||
Checkout the LNbits [YouTube](https://www.youtube.com/playlist?list=PLPj3KCksGbSYG0ciIQUWJru1dWstPHshe) video series.
|
||||
|
||||
@@ -31,7 +33,7 @@ LNbits is inspired by all the great work of [opennode.com](https://www.opennode.
|
||||
|
||||
## Running LNbits
|
||||
|
||||
See the [install guide](docs/devs/installation.md) for details on installation and setup.
|
||||
See the [install guide](docs/guide/installation.md) for details on installation and setup.
|
||||
|
||||
## LNbits as an account system
|
||||
|
||||
@@ -52,7 +54,7 @@ LNURL has a fallback scheme, so if scanned by a regular QR code reader it can de
|
||||

|
||||
|
||||
Using **lnbits.com/?lightning="LNURL-withdraw"** will trigger a withdraw that builds an LNbits wallet.
|
||||
Example use would be an ATM, which utilises LNURL, if the user scans the QR with a regular QR code scanner app, they will stilll be able to access the funds.
|
||||
Example use would be an ATM, which utilises LNURL, if the user scans the QR with a regular QR code scanner app, they will still be able to access the funds.
|
||||
|
||||

|
||||
|
||||
@@ -65,10 +67,10 @@ Wallets can be easily generated and given out to people at events (one click mul
|
||||
|
||||
## Tip us
|
||||
|
||||
If you like this project and might even use or extend it, why not [send some tip love](https://lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK)!
|
||||
If you like this project and might even use or extend it, why not [send some tip love](https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK)!
|
||||
|
||||
|
||||
[docs]: https://lnbits.org/
|
||||
[docs]: https://legend.lnbits.org/
|
||||
[docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg
|
||||
[github-mypy]: https://github.com/lnbits/lnbits/actions?query=workflow%3Amypy
|
||||
[github-mypy-badge]: https://github.com/lnbits/lnbits/workflows/mypy/badge.svg
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"scripts": {
|
||||
"dokku": {
|
||||
"predeploy": "quart migrate && quart assets"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import glob
|
||||
import os
|
||||
import subprocess
|
||||
import warnings
|
||||
from os import path
|
||||
from pathlib import Path
|
||||
from typing import Any, List, NamedTuple, Optional
|
||||
|
||||
LNBITS_PATH = path.dirname(path.realpath(__file__)) + "/lnbits"
|
||||
|
||||
|
||||
def get_js_vendored(prefer_minified: bool = False) -> List[str]:
|
||||
paths = get_vendored(".js", prefer_minified)
|
||||
|
||||
def sorter(key: str):
|
||||
if "moment@" in key:
|
||||
return 1
|
||||
if "vue@" in key:
|
||||
return 2
|
||||
if "vue-router@" in key:
|
||||
return 3
|
||||
if "polyfills" in key:
|
||||
return 4
|
||||
return 9
|
||||
|
||||
return sorted(paths, key=sorter)
|
||||
|
||||
|
||||
def get_css_vendored(prefer_minified: bool = False) -> List[str]:
|
||||
paths = get_vendored(".css", prefer_minified)
|
||||
|
||||
def sorter(key: str):
|
||||
if "quasar@" in key:
|
||||
return 1
|
||||
if "vue@" in key:
|
||||
return 2
|
||||
if "chart.js@" in key:
|
||||
return 100
|
||||
return 9
|
||||
|
||||
return sorted(paths, key=sorter)
|
||||
|
||||
|
||||
def get_vendored(ext: str, prefer_minified: bool = False) -> List[str]:
|
||||
paths: List[str] = []
|
||||
for path in glob.glob(
|
||||
os.path.join(LNBITS_PATH, "static/vendor/**"), recursive=True
|
||||
):
|
||||
if path.endswith(".min" + ext):
|
||||
# path is minified
|
||||
unminified = path.replace(".min" + ext, ext)
|
||||
if prefer_minified:
|
||||
paths.append(path)
|
||||
if unminified in paths:
|
||||
paths.remove(unminified)
|
||||
elif unminified not in paths:
|
||||
paths.append(path)
|
||||
|
||||
elif path.endswith(ext):
|
||||
# path is not minified
|
||||
minified = path.replace(ext, ".min" + ext)
|
||||
if not prefer_minified:
|
||||
paths.append(path)
|
||||
if minified in paths:
|
||||
paths.remove(minified)
|
||||
elif minified not in paths:
|
||||
paths.append(path)
|
||||
|
||||
return sorted(paths)
|
||||
|
||||
|
||||
def url_for_vendored(abspath: str) -> str:
|
||||
return "/" + os.path.relpath(abspath, LNBITS_PATH)
|
||||
|
||||
|
||||
def transpile_scss():
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
from scss.compiler import compile_string # type: ignore
|
||||
|
||||
with open(os.path.join(LNBITS_PATH, "static/scss/base.scss")) as scss:
|
||||
with open(os.path.join(LNBITS_PATH, "static/css/base.css"), "w") as css:
|
||||
css.write(compile_string(scss.read()))
|
||||
|
||||
|
||||
def bundle_vendored():
|
||||
for getfiles, outputpath in [
|
||||
(get_js_vendored, os.path.join(LNBITS_PATH, "static/bundle.js")),
|
||||
(get_css_vendored, os.path.join(LNBITS_PATH, "static/bundle.css")),
|
||||
]:
|
||||
output = ""
|
||||
for path in getfiles():
|
||||
with open(path) as f:
|
||||
output += "/* " + url_for_vendored(path) + " */\n" + f.read() + ";\n"
|
||||
with open(outputpath, "w") as f:
|
||||
f.write(output)
|
||||
|
||||
|
||||
def build():
|
||||
transpile_scss()
|
||||
bundle_vendored()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
build()
|
||||
@@ -1,657 +0,0 @@
|
||||
import psycopg2
|
||||
import sqlite3
|
||||
import os
|
||||
# Python script to migrate an LNbits SQLite DB to Postgres
|
||||
# All credits to @Fritz446 for the awesome work
|
||||
|
||||
|
||||
# pip install psycopg2 OR psycopg2-binary
|
||||
|
||||
|
||||
# Change these values as needed
|
||||
|
||||
sqfolder = "data/"
|
||||
pgdb = "lnbits"
|
||||
pguser = "postgres"
|
||||
pgpswd = "yourpassword"
|
||||
pghost = "localhost"
|
||||
pgport = "5432"
|
||||
pgschema = ""
|
||||
|
||||
|
||||
def get_sqlite_cursor(sqdb) -> sqlite3:
|
||||
consq = sqlite3.connect(sqdb)
|
||||
return consq.cursor()
|
||||
|
||||
|
||||
def get_postgres_cursor():
|
||||
conpg = psycopg2.connect(
|
||||
database=pgdb, user=pguser, password=pgpswd, host=pghost, port=pgport
|
||||
)
|
||||
return conpg.cursor()
|
||||
|
||||
|
||||
def check_db_versions(sqdb):
|
||||
sqlite = get_sqlite_cursor(sqdb)
|
||||
dblite = dict(sqlite.execute("SELECT * FROM dbversions;").fetchall())
|
||||
if "lnurlpos" in dblite:
|
||||
del dblite["lnurlpos"]
|
||||
sqlite.close()
|
||||
|
||||
postgres = get_postgres_cursor()
|
||||
postgres.execute("SELECT * FROM public.dbversions;")
|
||||
dbpost = dict(postgres.fetchall())
|
||||
|
||||
for key in dblite.keys():
|
||||
if key in dblite and key in dbpost and dblite[key] != dbpost[key]:
|
||||
raise Exception(
|
||||
f"sqlite database version ({dblite[key]}) of {key} doesn't match postgres database version {dbpost[key]}"
|
||||
)
|
||||
|
||||
connection = postgres.connection
|
||||
postgres.close()
|
||||
connection.close()
|
||||
|
||||
print("Database versions OK, converting")
|
||||
|
||||
|
||||
def fix_id(seq, values):
|
||||
if not values or len(values) == 0:
|
||||
return
|
||||
|
||||
postgres = get_postgres_cursor()
|
||||
|
||||
max_id = values[len(values) - 1][0]
|
||||
postgres.execute(f"SELECT setval('{seq}', {max_id});")
|
||||
|
||||
connection = postgres.connection
|
||||
postgres.close()
|
||||
connection.close()
|
||||
|
||||
|
||||
def insert_to_pg(query, data):
|
||||
if len(data) == 0:
|
||||
return
|
||||
|
||||
cursor = get_postgres_cursor()
|
||||
connection = cursor.connection
|
||||
|
||||
for d in data:
|
||||
try:
|
||||
cursor.execute(query, d)
|
||||
except:
|
||||
raise ValueError(f"Failed to insert {d}")
|
||||
connection.commit()
|
||||
|
||||
cursor.close()
|
||||
connection.close()
|
||||
|
||||
|
||||
def migrate_core(sqlite_db_file):
|
||||
sq = get_sqlite_cursor(sqlite_db_file)
|
||||
|
||||
# ACCOUNTS
|
||||
res = sq.execute("SELECT * FROM accounts;")
|
||||
q = f"INSERT INTO public.accounts (id, email, pass) VALUES (%s, %s, %s);"
|
||||
insert_to_pg(q, res.fetchall())
|
||||
|
||||
# WALLETS
|
||||
res = sq.execute("SELECT * FROM wallets;")
|
||||
q = f'INSERT INTO public.wallets (id, name, "user", adminkey, inkey) VALUES (%s, %s, %s, %s, %s);'
|
||||
insert_to_pg(q, res.fetchall())
|
||||
|
||||
# API PAYMENTS
|
||||
res = sq.execute("SELECT * FROM apipayments;")
|
||||
q = f"""
|
||||
INSERT INTO public.apipayments(
|
||||
checking_id, amount, fee, wallet, pending, memo, "time", hash, preimage, bolt11, extra, webhook, webhook_status)
|
||||
VALUES (%s, %s, %s, %s, %s::boolean, %s, to_timestamp(%s), %s, %s, %s, %s, %s, %s);
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
|
||||
# BALANCE CHECK
|
||||
res = sq.execute("SELECT * FROM balance_check;")
|
||||
q = f"INSERT INTO public.balance_check(wallet, service, url) VALUES (%s, %s, %s);"
|
||||
insert_to_pg(q, res.fetchall())
|
||||
|
||||
# BALANCE NOTIFY
|
||||
res = sq.execute("SELECT * FROM balance_notify;")
|
||||
q = f"INSERT INTO public.balance_notify(wallet, url) VALUES (%s, %s);"
|
||||
insert_to_pg(q, res.fetchall())
|
||||
|
||||
# EXTENSIONS
|
||||
res = sq.execute("SELECT * FROM extensions;")
|
||||
q = f'INSERT INTO public.extensions("user", extension, active) VALUES (%s, %s, %s::boolean);'
|
||||
insert_to_pg(q, res.fetchall())
|
||||
|
||||
print("Migrated: core")
|
||||
|
||||
|
||||
def migrate_ext(sqlite_db_file, schema):
|
||||
sq = get_sqlite_cursor(sqlite_db_file)
|
||||
|
||||
if schema == "bleskomat":
|
||||
# BLESKOMAT LNURLS
|
||||
res = sq.execute("SELECT * FROM bleskomat_lnurls;")
|
||||
q = f"""
|
||||
INSERT INTO bleskomat.bleskomat_lnurls(
|
||||
id, bleskomat, wallet, hash, tag, params, api_key_id, initial_uses, remaining_uses, created_time, updated_time)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s);
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
|
||||
# BLESKOMATS
|
||||
res = sq.execute("SELECT * FROM bleskomats;")
|
||||
q = f"""
|
||||
INSERT INTO bleskomat.bleskomats(
|
||||
id, wallet, api_key_id, api_key_secret, api_key_encoding, name, fiat_currency, exchange_rate_provider, fee)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s);
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
elif schema == "captcha":
|
||||
# CAPTCHA
|
||||
res = sq.execute("SELECT * FROM captchas;")
|
||||
q = f"""
|
||||
INSERT INTO captcha.captchas(
|
||||
id, wallet, url, memo, description, amount, "time", remembers, extras)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, to_timestamp(%s), %s, %s);
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
elif schema == "copilot":
|
||||
# OLD COPILOTS
|
||||
res = sq.execute("SELECT * FROM copilots;")
|
||||
q = f"""
|
||||
INSERT INTO copilot.copilots(
|
||||
id, "user", title, lnurl_toggle, wallet, animation1, animation2, animation3, animation1threshold, animation2threshold, animation3threshold, animation1webhook, animation2webhook, animation3webhook, lnurl_title, show_message, show_ack, show_price, amount_made, fullscreen_cam, iframe_url, "timestamp")
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, to_timestamp(%s));
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
|
||||
# NEW COPILOTS
|
||||
q = f"""
|
||||
INSERT INTO copilot.newer_copilots(
|
||||
id, "user", title, lnurl_toggle, wallet, animation1, animation2, animation3, animation1threshold, animation2threshold, animation3threshold, animation1webhook, animation2webhook, animation3webhook, lnurl_title, show_message, show_ack, show_price, amount_made, fullscreen_cam, iframe_url, "timestamp")
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, to_timestamp(%s));
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
elif schema == "events":
|
||||
# EVENTS
|
||||
res = sq.execute("SELECT * FROM events;")
|
||||
q = f"""
|
||||
INSERT INTO events.events(
|
||||
id, wallet, name, info, closing_date, event_start_date, event_end_date, amount_tickets, price_per_ticket, sold, "time")
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, to_timestamp(%s));
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
# EVENT TICKETS
|
||||
res = sq.execute("SELECT * FROM ticket;")
|
||||
q = f"""
|
||||
INSERT INTO events.ticket(
|
||||
id, wallet, event, name, email, registered, paid, "time")
|
||||
VALUES (%s, %s, %s, %s, %s, %s::boolean, %s::boolean, to_timestamp(%s));
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
elif schema == "example":
|
||||
# Example doesn't have a database at the moment
|
||||
pass
|
||||
elif schema == "hivemind":
|
||||
# Hivemind doesn't have a database at the moment
|
||||
pass
|
||||
elif schema == "jukebox":
|
||||
# JUKEBOXES
|
||||
res = sq.execute("SELECT * FROM jukebox;")
|
||||
q = f"""
|
||||
INSERT INTO jukebox.jukebox(
|
||||
id, "user", title, wallet, inkey, sp_user, sp_secret, sp_access_token, sp_refresh_token, sp_device, sp_playlists, price, profit)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s);
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
# JUKEBOX PAYMENTS
|
||||
res = sq.execute("SELECT * FROM jukebox_payment;")
|
||||
q = f"""
|
||||
INSERT INTO jukebox.jukebox_payment(
|
||||
payment_hash, juke_id, song_id, paid)
|
||||
VALUES (%s, %s, %s, %s::boolean);
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
elif schema == "withdraw":
|
||||
# WITHDRAW LINK
|
||||
res = sq.execute("SELECT * FROM withdraw_link;")
|
||||
q = f"""
|
||||
INSERT INTO withdraw.withdraw_link (
|
||||
id,
|
||||
wallet,
|
||||
title,
|
||||
min_withdrawable,
|
||||
max_withdrawable,
|
||||
uses,
|
||||
wait_time,
|
||||
is_unique,
|
||||
unique_hash,
|
||||
k1,
|
||||
open_time,
|
||||
used,
|
||||
usescsv
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s);
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
# WITHDRAW HASH CHECK
|
||||
res = sq.execute("SELECT * FROM hash_check;")
|
||||
q = f"""
|
||||
INSERT INTO withdraw.hash_check (id, lnurl_id)
|
||||
VALUES (%s, %s);
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
elif schema == "watchonly":
|
||||
# WALLETS
|
||||
res = sq.execute("SELECT * FROM wallets;")
|
||||
q = f"""
|
||||
INSERT INTO watchonly.wallets (
|
||||
id,
|
||||
"user",
|
||||
masterpub,
|
||||
title,
|
||||
address_no,
|
||||
balance
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s);
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
# ADDRESSES
|
||||
res = sq.execute("SELECT * FROM addresses;")
|
||||
q = f"""
|
||||
INSERT INTO watchonly.addresses (id, address, wallet, amount)
|
||||
VALUES (%s, %s, %s, %s);
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
# MEMPOOL
|
||||
res = sq.execute("SELECT * FROM mempool;")
|
||||
q = f"""
|
||||
INSERT INTO watchonly.mempool ("user", endpoint)
|
||||
VALUES (%s, %s);
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
elif schema == "usermanager":
|
||||
# USERS
|
||||
res = sq.execute("SELECT * FROM users;")
|
||||
q = f"""
|
||||
INSERT INTO usermanager.users (id, name, admin, email, password)
|
||||
VALUES (%s, %s, %s, %s, %s);
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
# WALLETS
|
||||
res = sq.execute("SELECT * FROM wallets;")
|
||||
q = f"""
|
||||
INSERT INTO usermanager.wallets (id, admin, name, "user", adminkey, inkey)
|
||||
VALUES (%s, %s, %s, %s, %s, %s);
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
elif schema == "tpos":
|
||||
# TPOSS
|
||||
res = sq.execute("SELECT * FROM tposs;")
|
||||
q = f"""
|
||||
INSERT INTO tpos.tposs (id, wallet, name, currency)
|
||||
VALUES (%s, %s, %s, %s);
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
elif schema == "tipjar":
|
||||
# TIPJARS
|
||||
res = sq.execute("SELECT * FROM TipJars;")
|
||||
q = f"""
|
||||
INSERT INTO tipjar.TipJars (id, name, wallet, onchain, webhook)
|
||||
VALUES (%s, %s, %s, %s, %s);
|
||||
"""
|
||||
pay_links = res.fetchall()
|
||||
insert_to_pg(q, pay_links)
|
||||
fix_id("tipjar.tipjars_id_seq", pay_links)
|
||||
# TIPS
|
||||
res = sq.execute("SELECT * FROM Tips;")
|
||||
q = f"""
|
||||
INSERT INTO tipjar.Tips (id, wallet, name, message, sats, tipjar)
|
||||
VALUES (%s, %s, %s, %s, %s, %s);
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
elif schema == "subdomains":
|
||||
# DOMAIN
|
||||
res = sq.execute("SELECT * FROM domain;")
|
||||
q = f"""
|
||||
INSERT INTO subdomains.domain (
|
||||
id,
|
||||
wallet,
|
||||
domain,
|
||||
webhook,
|
||||
cf_token,
|
||||
cf_zone_id,
|
||||
description,
|
||||
cost,
|
||||
amountmade,
|
||||
allowed_record_types,
|
||||
time
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, to_timestamp(%s));
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
# SUBDOMAIN
|
||||
res = sq.execute("SELECT * FROM subdomain;")
|
||||
q = f"""
|
||||
INSERT INTO subdomains.subdomain (
|
||||
id,
|
||||
domain,
|
||||
email,
|
||||
subdomain,
|
||||
ip,
|
||||
wallet,
|
||||
sats,
|
||||
duration,
|
||||
paid,
|
||||
record_type,
|
||||
time
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::boolean, %s, to_timestamp(%s));
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
elif schema == "streamalerts":
|
||||
# SERVICES
|
||||
res = sq.execute("SELECT * FROM Services;")
|
||||
q = f"""
|
||||
INSERT INTO streamalerts.Services (
|
||||
id,
|
||||
state,
|
||||
twitchuser,
|
||||
client_id,
|
||||
client_secret,
|
||||
wallet,
|
||||
onchain,
|
||||
servicename,
|
||||
authenticated,
|
||||
token
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::boolean, %s);
|
||||
"""
|
||||
services = res.fetchall()
|
||||
insert_to_pg(q, services)
|
||||
fix_id("streamalerts.services_id_seq", services)
|
||||
# DONATIONS
|
||||
res = sq.execute("SELECT * FROM Donations;")
|
||||
q = f"""
|
||||
INSERT INTO streamalerts.Donations (
|
||||
id,
|
||||
wallet,
|
||||
name,
|
||||
message,
|
||||
cur_code,
|
||||
sats,
|
||||
amount,
|
||||
service,
|
||||
posted,
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::boolean);
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
elif schema == "splitpayments":
|
||||
# TARGETS
|
||||
res = sq.execute("SELECT * FROM targets;")
|
||||
q = f"""
|
||||
INSERT INTO splitpayments.targets (wallet, source, percent, alias)
|
||||
VALUES (%s, %s, %s, %s);
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
elif schema == "satspay":
|
||||
# CHARGES
|
||||
res = sq.execute("SELECT * FROM charges;")
|
||||
q = f"""
|
||||
INSERT INTO satspay.charges (
|
||||
id,
|
||||
"user",
|
||||
description,
|
||||
onchainwallet,
|
||||
onchainaddress,
|
||||
lnbitswallet,
|
||||
payment_request,
|
||||
payment_hash,
|
||||
webhook,
|
||||
completelink,
|
||||
completelinktext,
|
||||
time,
|
||||
amount,
|
||||
balance,
|
||||
timestamp
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, to_timestamp(%s));
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
elif schema == "satsdice":
|
||||
# SATSDICE PAY
|
||||
res = sq.execute("SELECT * FROM satsdice_pay;")
|
||||
q = f"""
|
||||
INSERT INTO satsdice.satsdice_pay (
|
||||
id,
|
||||
wallet,
|
||||
title,
|
||||
min_bet,
|
||||
max_bet,
|
||||
amount,
|
||||
served_meta,
|
||||
served_pr,
|
||||
multiplier,
|
||||
haircut,
|
||||
chance,
|
||||
base_url,
|
||||
open_time
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s);
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
# SATSDICE WITHDRAW
|
||||
res = sq.execute("SELECT * FROM satsdice_withdraw;")
|
||||
q = f"""
|
||||
INSERT INTO satsdice.satsdice_withdraw (
|
||||
id,
|
||||
satsdice_pay,
|
||||
value,
|
||||
unique_hash,
|
||||
k1,
|
||||
open_time,
|
||||
used
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s);
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
# SATSDICE PAYMENT
|
||||
res = sq.execute("SELECT * FROM satsdice_payment;")
|
||||
q = f"""
|
||||
INSERT INTO satsdice.satsdice_payment (
|
||||
payment_hash,
|
||||
satsdice_pay,
|
||||
value,
|
||||
paid,
|
||||
lost
|
||||
)
|
||||
VALUES (%s, %s, %s, %s::boolean, %s::boolean);
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
# SATSDICE HASH CHECK
|
||||
res = sq.execute("SELECT * FROM hash_checkw;")
|
||||
q = f"""
|
||||
INSERT INTO satsdice.hash_checkw (id, lnurl_id)
|
||||
VALUES (%s, %s);
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
elif schema == "paywall":
|
||||
# PAYWALLS
|
||||
res = sq.execute("SELECT * FROM paywalls;")
|
||||
q = f"""
|
||||
INSERT INTO paywall.paywalls(
|
||||
id,
|
||||
wallet,
|
||||
url,
|
||||
memo,
|
||||
amount,
|
||||
time,
|
||||
remembers,
|
||||
extra
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, to_timestamp(%s), %s, %s);
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
elif schema == "offlineshop":
|
||||
# SHOPS
|
||||
res = sq.execute("SELECT * FROM shops;")
|
||||
q = f"""
|
||||
INSERT INTO offlineshop.shops (id, wallet, method, wordlist)
|
||||
VALUES (%s, %s, %s, %s);
|
||||
"""
|
||||
shops = res.fetchall()
|
||||
insert_to_pg(q, shops)
|
||||
fix_id("offlineshop.shops_id_seq", shops)
|
||||
# ITEMS
|
||||
res = sq.execute("SELECT * FROM items;")
|
||||
q = f"""
|
||||
INSERT INTO offlineshop.items (shop, id, name, description, image, enabled, price, unit)
|
||||
VALUES (%s, %s, %s, %s, %s, %s::boolean, %s, %s);
|
||||
"""
|
||||
items = res.fetchall()
|
||||
insert_to_pg(q, items)
|
||||
fix_id("offlineshop.items_id_seq", items)
|
||||
elif schema == "lnurlpos":
|
||||
# LNURLPOSS
|
||||
res = sq.execute("SELECT * FROM lnurlposs;")
|
||||
q = f"""
|
||||
INSERT INTO lnurlpos.lnurlposs (id, key, title, wallet, currency, timestamp)
|
||||
VALUES (%s, %s, %s, %s, %s, to_timestamp(%s));
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
# LNURLPOS PAYMENT
|
||||
res = sq.execute("SELECT * FROM lnurlpospayment;")
|
||||
q = f"""
|
||||
INSERT INTO lnurlpos.lnurlpospayment (id, posid, payhash, payload, pin, sats, timestamp)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, to_timestamp(%s));
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
elif schema == "lnurlp":
|
||||
# PAY LINKS
|
||||
res = sq.execute("SELECT * FROM pay_links;")
|
||||
q = f"""
|
||||
INSERT INTO lnurlp.pay_links (
|
||||
id,
|
||||
wallet,
|
||||
description,
|
||||
min,
|
||||
served_meta,
|
||||
served_pr,
|
||||
webhook_url,
|
||||
success_text,
|
||||
success_url,
|
||||
currency,
|
||||
comment_chars,
|
||||
max
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s);
|
||||
"""
|
||||
pay_links = res.fetchall()
|
||||
insert_to_pg(q, pay_links)
|
||||
fix_id("lnurlp.pay_links_id_seq", pay_links)
|
||||
elif schema == "lndhub":
|
||||
# LndHub doesn't have a database at the moment
|
||||
pass
|
||||
elif schema == "lnticket":
|
||||
# TICKET
|
||||
res = sq.execute("SELECT * FROM ticket;")
|
||||
q = f"""
|
||||
INSERT INTO lnticket.ticket (
|
||||
id,
|
||||
form,
|
||||
email,
|
||||
ltext,
|
||||
name,
|
||||
wallet,
|
||||
sats,
|
||||
paid,
|
||||
time
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::boolean, to_timestamp(%s));
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
# FORM
|
||||
res = sq.execute("SELECT * FROM form2;")
|
||||
q = f"""
|
||||
INSERT INTO lnticket.form2 (
|
||||
id,
|
||||
wallet,
|
||||
name,
|
||||
webhook,
|
||||
description,
|
||||
flatrate,
|
||||
amount,
|
||||
amountmade,
|
||||
time
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, to_timestamp(%s));
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
elif schema == "livestream":
|
||||
# LIVESTREAMS
|
||||
res = sq.execute("SELECT * FROM livestreams;")
|
||||
q = f"""
|
||||
INSERT INTO livestream.livestreams (
|
||||
id,
|
||||
wallet,
|
||||
fee_pct,
|
||||
current_track
|
||||
)
|
||||
VALUES (%s, %s, %s, %s);
|
||||
"""
|
||||
livestreams = res.fetchall()
|
||||
insert_to_pg(q, livestreams)
|
||||
fix_id("livestream.livestreams_id_seq", livestreams)
|
||||
# PRODUCERS
|
||||
res = sq.execute("SELECT * FROM producers;")
|
||||
q = f"""
|
||||
INSERT INTO livestream.producers (
|
||||
livestream,
|
||||
id,
|
||||
"user",
|
||||
wallet,
|
||||
name
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s);
|
||||
"""
|
||||
producers = res.fetchall()
|
||||
insert_to_pg(q, producers)
|
||||
fix_id("livestream.producers_id_seq", producers)
|
||||
# TRACKS
|
||||
res = sq.execute("SELECT * FROM tracks;")
|
||||
q = f"""
|
||||
INSERT INTO livestream.tracks (
|
||||
livestream,
|
||||
id,
|
||||
download_url,
|
||||
price_msat,
|
||||
name,
|
||||
producer
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s);
|
||||
"""
|
||||
tracks = res.fetchall()
|
||||
insert_to_pg(q, tracks)
|
||||
fix_id("livestream.tracks_id_seq", tracks)
|
||||
else:
|
||||
print(f"Not implemented: {schema}")
|
||||
sq.close()
|
||||
return
|
||||
|
||||
print(f"Migrated: {schema}")
|
||||
sq.close()
|
||||
|
||||
|
||||
check_db_versions("data/database.sqlite3")
|
||||
migrate_core("data/database.sqlite3")
|
||||
|
||||
files = os.listdir(sqfolder)
|
||||
for file in files:
|
||||
path = f"data/{file}"
|
||||
if file.startswith("ext_"):
|
||||
schema = file.replace("ext_", "").split(".")[0]
|
||||
print(f"Migrating: {schema}")
|
||||
migrate_ext(path, schema)
|
||||
+1
-1
@@ -1 +1 @@
|
||||
lnbits.org
|
||||
legend.lnbits.org
|
||||
+1
-1
@@ -3,7 +3,7 @@ title: "LNbits docs"
|
||||
remote_theme: pmarsceill/just-the-docs
|
||||
logo: "/logos/lnbits-full.png"
|
||||
search_enabled: true
|
||||
url: https://lnbits.org
|
||||
url: https://legend.lnbits.org
|
||||
aux_links:
|
||||
"LNbits on GitHub":
|
||||
- "//github.com/lnbits/lnbits"
|
||||
|
||||
+1
-1
@@ -9,4 +9,4 @@ nav_order: 3
|
||||
API reference
|
||||
=============
|
||||
|
||||
Coming soon...
|
||||
[Swagger Docs](https://legend.lnbits.org/devs/swagger.html)
|
||||
|
||||
@@ -17,10 +17,26 @@ Tests
|
||||
|
||||
This project has unit tests that help prevent regressions. Before you can run the tests, you must install a few dependencies:
|
||||
```bash
|
||||
./venv/bin/pip install pytest pytest-asyncio requests trio mock
|
||||
poetry install
|
||||
npm i
|
||||
```
|
||||
|
||||
Then to run the tests:
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
Run formatting:
|
||||
```bash
|
||||
make format
|
||||
```
|
||||
|
||||
Run mypy checks:
|
||||
```bash
|
||||
poetry run mypy
|
||||
```
|
||||
|
||||
Run everything:
|
||||
```bash
|
||||
make all
|
||||
```
|
||||
|
||||
+36
-7
@@ -15,6 +15,7 @@ cp lnbits/extensions/example lnbits/extensions/mysuperplugin -r # Let's not use
|
||||
cd lnbits/extensions/mysuperplugin
|
||||
find . -type f -print0 | xargs -0 sed -i 's/example/mysuperplugin/g' # Change all occurrences of 'example' to your plugin name 'mysuperplugin'.
|
||||
```
|
||||
- if you are on macOS and having difficulty with 'sed', consider `brew install gnu-sed` and use 'gsed', without -0 option after xargs.
|
||||
|
||||
Going over the example extension's structure:
|
||||
* views_api.py: This is where your public API would go. It will be exposed at "$DOMAIN/$PLUGIN/$ROUTE". For example: https://lnbits.com/mysuperplugin/api/v1/tools.
|
||||
@@ -27,17 +28,45 @@ Going over the example extension's structure:
|
||||
Adding new dependencies
|
||||
-----------------------
|
||||
|
||||
If for some reason your extensions needs a new python package to work, you can add a new package using Pipenv:
|
||||
If for some reason your extensions needs a new python package to work, you can add a new package using `venv`, or `poerty`:
|
||||
|
||||
```sh
|
||||
$ pipenv install new_package_name
|
||||
$ poetry add <package>
|
||||
# or
|
||||
$ ./venv/bin/pip install <package>
|
||||
```
|
||||
|
||||
This will create a new entry in the `Pipenv` file.
|
||||
**But we need an extra step to make sure LNbits doesn't break in production.**
|
||||
All tests and deployments should run against the `requirements.txt` file so every time a new package is added
|
||||
it is necessary to run the Pipenv `lock` command and manually update the requirements file:
|
||||
Dependencies need to be added to `pyproject.toml` and `requirements.txt`, then tested by running on `venv` and `poetry`.
|
||||
`nix` compatability can be tested with `nix build .#checks.x86_64-linux.vmTest`.
|
||||
|
||||
```sh
|
||||
$ pipenv lock -r
|
||||
|
||||
SQLite to PostgreSQL migration
|
||||
-----------------------
|
||||
|
||||
LNbits currently supports SQLite and PostgreSQL databases. There is a migration script `tools/conv.py` that helps users migrate from SQLite to PostgreSQL. This script also copies all extension databases to the new backend.
|
||||
|
||||
### Adding mock data to `mock_data.zip`
|
||||
|
||||
`mock_data.zip` contains a few lines of sample SQLite data and is used in automated GitHub test to see whether your migration in `conv.py` works. Run your extension and save a few lines of data into a SQLite `your_extension.sqlite3` file. Unzip `tests/data/mock_data.zip`, add `your_extension.sqlite3`, updated `database.sqlite3` and zip it again. Add the updated `mock_data.zip` to your PR.
|
||||
|
||||
### running migration locally
|
||||
you will need a running postgres database
|
||||
|
||||
#### create lnbits user for migration database
|
||||
```console
|
||||
sudo su - postgres -c "psql -c 'CREATE ROLE lnbits LOGIN PASSWORD 'lnbits';'"
|
||||
```
|
||||
#### create migration database
|
||||
```console
|
||||
sudo su - postgres -c "psql -c 'CREATE DATABASE migration;'"
|
||||
```
|
||||
#### run the migration
|
||||
```console
|
||||
make test-migration
|
||||
```
|
||||
sudo su - postgres -c "psql -c 'CREATE ROLE lnbits LOGIN PASSWORD 'lnbits';'"
|
||||
#### clean migration database afterwards, fails if you try again
|
||||
```console
|
||||
sudo su - postgres -c "psql -c 'DROP DATABASE IF EXISTS migration;'"
|
||||
```
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
---
|
||||
layout: default
|
||||
parent: For developers
|
||||
title: Installation
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
# Installation
|
||||
|
||||
LNbits uses [Pipenv][pipenv] to manage Python packages.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits-legend.git
|
||||
cd lnbits-legend/
|
||||
pipenv shell
|
||||
# pipenv --python 3.9 shell (if you wish to use a version of Python higher than 3.7)
|
||||
pipenv install --dev
|
||||
# pipenv --python 3.9 install --dev (if you wish to use a version of Python higher than 3.7)
|
||||
|
||||
# If any of the modules fails to install, try checking and upgrading your setupTool module
|
||||
# pip install -U setuptools
|
||||
```
|
||||
## Running the server
|
||||
|
||||
Create the data folder and edit the .env file:
|
||||
|
||||
mkdir data
|
||||
cp .env.example .env
|
||||
sudo nano .env
|
||||
|
||||
To then run the server for development purposes (includes hot-reload), use:
|
||||
|
||||
pipenv run python -m uvicorn lnbits.__main__:app --host 0.0.0.0 --reload
|
||||
|
||||
For production, use:
|
||||
|
||||
pipenv run python -m uvicorn lnbits.__main__:app --host 0.0.0.0
|
||||
|
||||
You might also need to install additional packages, depending on the [backend wallet](../guide/wallets.md) you use.
|
||||
E.g. when you want to use LND you have to `pipenv run pip install lndgrpc` and `pipenv run pip install purerpc`.
|
||||
|
||||
Take a look at [Polar][polar] for an excellent way of spinning up a Lightning Network dev environment.
|
||||
|
||||
**Notes**:
|
||||
|
||||
* We reccomend using <a href="https://caddyserver.com/docs/install#debian-ubuntu-raspbian">Caddy</a> for a reverse-proxy if you want to serve your install through a domain, alternatively you can use [ngrok](https://ngrok.com/).
|
||||
* <a href="https://linuxize.com/post/how-to-use-linux-screen/#starting-linux-screen">Screen</a> works well if you want LNbits to continue running when you close your terminal session.
|
||||
@@ -0,0 +1,29 @@
|
||||
<html>
|
||||
<head>
|
||||
<!-- Load the latest Swagger UI code and style from npm using unpkg.com -->
|
||||
<script src="https://unpkg.com/swagger-ui-dist@3/swagger-ui-bundle.js"></script>
|
||||
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@3/swagger-ui.css"/>
|
||||
<title>My New API</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div> <!-- Div to hold the UI component -->
|
||||
<script>
|
||||
window.onload = function () {
|
||||
// Begin Swagger UI call region
|
||||
const ui = SwaggerUIBundle({
|
||||
url: "https://legend.lnbits.com/openapi.json", //Location of Open API spec in the repo
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIBundle.SwaggerUIStandalonePreset
|
||||
],
|
||||
plugins: [
|
||||
SwaggerUIBundle.plugins.DownloadUrl
|
||||
],
|
||||
})
|
||||
window.ui = ui
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+306
-49
@@ -5,7 +5,189 @@ nav_order: 2
|
||||
---
|
||||
|
||||
# Basic installation
|
||||
Install Postgres and setup a database for LNbits:
|
||||
|
||||
You can choose between four package managers, `poetry`, `nix` and `venv`.
|
||||
|
||||
By default, LNbits will use SQLite as its database. You can also use PostgreSQL which is recommended for applications with a high load (see guide below).
|
||||
|
||||
## Option 1 (recommended): poetry
|
||||
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits-legend.git
|
||||
cd lnbits-legend/
|
||||
|
||||
# for making sure python 3.9 is installed, skip if installed
|
||||
sudo apt update
|
||||
sudo apt install software-properties-common
|
||||
sudo add-apt-repository ppa:deadsnakes/ppa
|
||||
sudo apt install python3.9 python3.9-distutils
|
||||
|
||||
curl -sSL https://install.python-poetry.org | python3 -
|
||||
export PATH="/home/ubuntu/.local/bin:$PATH" # or whatever is suggested in the poetry install notes printed to terminal
|
||||
poetry env use python3.9
|
||||
poetry install --no-dev
|
||||
|
||||
mkdir data
|
||||
cp .env.example .env
|
||||
sudo nano .env # set funding source
|
||||
|
||||
|
||||
```
|
||||
|
||||
#### Running the server
|
||||
|
||||
```sh
|
||||
poetry run lnbits
|
||||
# To change port/host pass 'poetry run lnbits --port 9000 --host 0.0.0.0'
|
||||
```
|
||||
|
||||
## Option 2: Nix
|
||||
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits-legend.git
|
||||
cd lnbits-legend/
|
||||
# Modern debian distros usually include Nix, however you can install with:
|
||||
# 'sh <(curl -L https://nixos.org/nix/install) --daemon', or use setup here https://nixos.org/download.html#nix-verify-installation
|
||||
|
||||
nix build .#lnbits
|
||||
mkdir data
|
||||
|
||||
```
|
||||
|
||||
#### Running the server
|
||||
|
||||
```sh
|
||||
# .env variables are currently passed when running
|
||||
LNBITS_DATA_FOLDER=data LNBITS_BACKEND_WALLET_CLASS=LNbitsWallet LNBITS_ENDPOINT=https://legend.lnbits.com LNBITS_KEY=7b1a78d6c78f48b09a202f2dcb2d22eb ./result/bin/lnbits --port 9000
|
||||
```
|
||||
|
||||
## Option 3: venv
|
||||
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits-legend.git
|
||||
cd lnbits-legend/
|
||||
# ensure you have virtualenv installed, on debian/ubuntu 'apt install python3-venv'
|
||||
python3 -m venv venv
|
||||
# If you have problems here, try `sudo apt install -y pkg-config libpq-dev`
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
# create the data folder and the .env file
|
||||
mkdir data && cp .env.example .env
|
||||
# build the static files
|
||||
./venv/bin/python build.py
|
||||
```
|
||||
|
||||
#### Running the server
|
||||
|
||||
```sh
|
||||
./venv/bin/uvicorn lnbits.__main__:app --port 5000
|
||||
```
|
||||
|
||||
If you want to host LNbits on the internet, run with the option `--host 0.0.0.0`.
|
||||
|
||||
## Option 4: Docker
|
||||
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits-legend.git
|
||||
cd lnbits-legend
|
||||
docker build -t lnbits-legend .
|
||||
cp .env.example .env
|
||||
mkdir data
|
||||
docker run --detach --publish 5000:5000 --name lnbits-legend --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits-legend
|
||||
```
|
||||
|
||||
## Option 5: Fly.io
|
||||
|
||||
Fly.io is a docker container hosting platform that has a generous free tier. You can host LNBits for free on Fly.io for personal use.
|
||||
|
||||
First, sign up for an account at [Fly.io](https://fly.io) (no credit card required).
|
||||
|
||||
Then, install the Fly.io CLI onto your device [here](https://fly.io/docs/getting-started/installing-flyctl/).
|
||||
|
||||
After install is complete, the command will output a command you should copy/paste/run to get `fly` into your `$PATH`. Something like:
|
||||
|
||||
```
|
||||
flyctl was installed successfully to /home/ubuntu/.fly/bin/flyctl
|
||||
Manually add the directory to your $HOME/.bash_profile (or similar)
|
||||
export FLYCTL_INSTALL="/home/ubuntu/.fly"
|
||||
export PATH="$FLYCTL_INSTALL/bin:$PATH"
|
||||
```
|
||||
|
||||
You can either run those commands, then `source ~/.bash_profile` or, if you don't, you'll have to call Fly from `~/.fly/bin/flyctl`.
|
||||
|
||||
Once installed, run the following commands.
|
||||
|
||||
```
|
||||
git clone https://github.com/lnbits/lnbits-legend.git
|
||||
cd lnbits-legend
|
||||
fly auth login
|
||||
[complete login process]
|
||||
fly launch
|
||||
```
|
||||
|
||||
You'll be prompted to enter an app name, region, postgres (choose no), deploy now (choose no).
|
||||
|
||||
You'll now find a file in the directory called `fly.toml`. Open that file and modify/add the following settings.
|
||||
|
||||
Note: Be sure to replace `${PUT_YOUR_LNBITS_ENV_VARS_HERE}` with all relevant environment variables in `.env` or `.env.example`. Environment variable strings should be quoted here, so if in `.env` you have `LNBITS_ENDPOINT=https://legend.lnbits.com` in `fly.toml` you should have `LNBITS_ENDPOINT="https://legend.lnbits.com"`.
|
||||
|
||||
Note: Don't enter secret environment variables here. Fly.io offers secrets (via the `fly secrets` command) that are exposed as environment variables in your runtime. So, for example, if using the LND_REST funding source, you can run `fly secrets set LND_REST_MACAROON=<hex_macaroon_data>`.
|
||||
|
||||
```
|
||||
...
|
||||
kill_timeout = 30
|
||||
...
|
||||
|
||||
...
|
||||
[mounts]
|
||||
source="lnbits_data"
|
||||
destination="/data"
|
||||
...
|
||||
|
||||
...
|
||||
[env]
|
||||
HOST="127.0.0.1"
|
||||
PORT=5000
|
||||
LNBITS_FORCE_HTTPS=true
|
||||
LNBITS_DATA_FOLDER="/data"
|
||||
|
||||
${PUT_YOUR_LNBITS_ENV_VARS_HERE}
|
||||
...
|
||||
|
||||
...
|
||||
[[services]]
|
||||
internal_port = 5000
|
||||
...
|
||||
```
|
||||
|
||||
Next, create a volume to store the sqlite database for LNBits. Be sure to choose the same region for the volume that you chose earlier.
|
||||
|
||||
```
|
||||
fly volumes create lnbits_data --size 1
|
||||
```
|
||||
|
||||
You're ready to deploy! Run `fly deploy` and follow the steps to finish deployment. You'll select a `region` (up to you, choose the same as you did for the storage volume previously created), `postgres` (choose no), `deploy` (choose yes).
|
||||
|
||||
You can use `fly logs` to view the application logs, or `fly ssh console` to get a ssh shell in the running container.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
Problems installing? These commands have helped us install LNbits.
|
||||
|
||||
```sh
|
||||
sudo apt install pkg-config libffi-dev libpq-dev
|
||||
|
||||
# if the secp256k1 build fails:
|
||||
# if you used venv
|
||||
./venv/bin/pip install setuptools wheel
|
||||
# if you used poetry
|
||||
poetry add setuptools wheel
|
||||
# build essentials for debian/ubuntu
|
||||
sudo apt install python3-dev gcc build-essential
|
||||
```
|
||||
|
||||
### Optional: PostgreSQL database
|
||||
|
||||
If you want to use LNbits at scale, we recommend using PostgreSQL as the backend database. Install Postgres and setup a database for LNbits:
|
||||
|
||||
```sh
|
||||
# on debian/ubuntu 'sudo apt-get -y install postgresql'
|
||||
@@ -22,23 +204,18 @@ createdb lnbits
|
||||
exit
|
||||
```
|
||||
|
||||
Download this repo and install the dependencies:
|
||||
You need to edit the `.env` file.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits-legend.git
|
||||
cd lnbits-legend/
|
||||
# ensure you have virtualenv installed, on debian/ubuntu 'apt install python3-venv' should work
|
||||
python3 -m venv venv
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
# add the database connection string to .env 'nano .env' LNBITS_DATABASE_URL=
|
||||
# postgres://<user>:<myPassword>@<host>/<lnbits> - alter line bellow with your user, password and db name
|
||||
LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits"
|
||||
# save and exit
|
||||
./venv/bin/uvicorn lnbits.__main__:app --port 5000
|
||||
```
|
||||
|
||||
Now you can visit your LNbits at http://localhost:5000/.
|
||||
# Using LNbits
|
||||
|
||||
Now you can visit your LNbits at http://localhost:5000/.
|
||||
|
||||
Now modify the `.env` file with any settings you prefer and add a proper [funding source](./wallets.md) by modifying the value of `LNBITS_BACKEND_WALLET_CLASS` and providing the extra information and credentials related to the chosen funding source.
|
||||
|
||||
@@ -46,29 +223,36 @@ Then you can restart it and it will be using the new settings.
|
||||
|
||||
You might also need to install additional packages or perform additional setup steps, depending on the chosen backend. See [the short guide](./wallets.md) on each different funding source.
|
||||
|
||||
## Important note
|
||||
If you already have LNbits installed and running, on an SQLite database, we **HIGHLY** recommend you migrate to postgres!
|
||||
|
||||
There's a script included that can do the migration easy. You should have Postgres already installed and there should be a password for the user, check the guide above.
|
||||
|
||||
```sh
|
||||
# STOP LNbits
|
||||
# on the LNBits folder, locate and edit 'conv.py' with the relevant credentials
|
||||
python3 conv.py
|
||||
|
||||
# add the database connection string to .env 'nano .env' LNBITS_DATABASE_URL=
|
||||
# postgres://<user>:<password>@<host>/<database> - alter line bellow with your user, password and db name
|
||||
LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits"
|
||||
# save and exit
|
||||
```
|
||||
|
||||
Hopefully, everything works and get migrated... Launch LNbits again and check if everything is working properly.
|
||||
Take a look at [Polar](https://lightningpolar.com/) for an excellent way of spinning up a Lightning Network dev environment.
|
||||
|
||||
|
||||
|
||||
# Additional guides
|
||||
|
||||
### LNbits as a systemd service
|
||||
## SQLite to PostgreSQL migration
|
||||
If you already have LNbits installed and running, on an SQLite database, we **highly** recommend you migrate to postgres if you are planning to run LNbits on scale.
|
||||
|
||||
There's a script included that can do the migration easy. You should have Postgres already installed and there should be a password for the user (see Postgres install guide above). Additionally, your LNbits instance should run once on postgres to implement the database schema before the migration works:
|
||||
|
||||
```sh
|
||||
# STOP LNbits
|
||||
|
||||
# add the database connection string to .env 'nano .env' LNBITS_DATABASE_URL=
|
||||
# postgres://<user>:<password>@<host>/<database> - alter line bellow with your user, password and db name
|
||||
LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits"
|
||||
# save and exit
|
||||
|
||||
# START LNbits
|
||||
# STOP LNbits
|
||||
poetry run python tools/conv.py
|
||||
# or
|
||||
make migration
|
||||
```
|
||||
|
||||
Hopefully, everything works and get migrated... Launch LNbits again and check if everything is working properly.
|
||||
|
||||
|
||||
## LNbits as a systemd service
|
||||
|
||||
Systemd is great for taking care of your LNbits instance. It will start it on boot and restart it in case it crashes. If you want to run LNbits as a systemd service on your Debian/Ubuntu/Raspbian server, create a file at `/etc/systemd/system/lnbits.service` with the following content:
|
||||
|
||||
@@ -78,17 +262,22 @@ Systemd is great for taking care of your LNbits instance. It will start it on bo
|
||||
|
||||
[Unit]
|
||||
Description=LNbits
|
||||
#Wants=lnd.service # you can uncomment these lines if you know what you're doing
|
||||
#After=lnd.service # it will make sure that lnbits starts after lnd (replace with your own backend service)
|
||||
# you can uncomment these lines if you know what you're doing
|
||||
# it will make sure that lnbits starts after lnd (replace with your own backend service)
|
||||
#Wants=lnd.service
|
||||
#After=lnd.service
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=/home/bitcoin/lnbits # replace with the absolute path of your lnbits installation
|
||||
ExecStart=/home/bitcoin/lnbits/venv/bin/uvicorn lnbits.__main__:app --port 5000 # same here
|
||||
User=bitcoin # replace with the user that you're running lnbits on
|
||||
# replace with the absolute path of your lnbits installation
|
||||
WorkingDirectory=/home/lnbits/lnbits-legend
|
||||
# same here. run `which poetry` if you can't find the poetry binary
|
||||
ExecStart=/home/lnbits/.local/bin/poetry run lnbits
|
||||
# replace with the user that you're running lnbits on
|
||||
User=lnbits
|
||||
Restart=always
|
||||
TimeoutSec=120
|
||||
RestartSec=30
|
||||
Environment=PYTHONUNBUFFERED=1 # this makes sure that you receive logs in real time
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -101,18 +290,94 @@ sudo systemctl enable lnbits.service
|
||||
sudo systemctl start lnbits.service
|
||||
```
|
||||
|
||||
### LNbits running on Umbrel behind Tor
|
||||
## Running behind an apache2 reverse proxy over https
|
||||
Install apache2 and enable apache2 mods
|
||||
```sh
|
||||
apt-get install apache2 certbot
|
||||
a2enmod headers ssl proxy proxy-http
|
||||
```
|
||||
create a ssl certificate with letsencrypt
|
||||
```sh
|
||||
certbot certonly --webroot --agree-tos --text --non-interactive --webroot-path /var/www/html -d lnbits.org
|
||||
```
|
||||
create a apache2 vhost at: /etc/apache2/sites-enabled/lnbits.conf
|
||||
```sh
|
||||
cat <<EOF > /etc/apache2/sites-enabled/lnbits.conf
|
||||
<VirtualHost *:443>
|
||||
ServerName lnbits.org
|
||||
SSLEngine On
|
||||
SSLProxyEngine On
|
||||
SSLCertificateFile /etc/letsencrypt/live/lnbits.org/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/lnbits.org/privkey.pem
|
||||
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||
LogLevel info
|
||||
ErrorLog /var/log/apache2/lnbits.log
|
||||
CustomLog /var/log/apache2/lnbits-access.log combined
|
||||
RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME}
|
||||
RequestHeader set "X-Forwarded-SSL" expr=%{HTTPS}
|
||||
ProxyPreserveHost On
|
||||
ProxyPass / http://localhost:5000/
|
||||
ProxyPassReverse / http://localhost:5000/
|
||||
<Proxy *>
|
||||
Order deny,allow
|
||||
Allow from all
|
||||
</Proxy>
|
||||
</VirtualHost>
|
||||
EOF
|
||||
```
|
||||
restart apache2
|
||||
```sh
|
||||
service restart apache2
|
||||
```
|
||||
|
||||
|
||||
## Using https without reverse proxy
|
||||
The most common way of using LNbits via https is to use a reverse proxy such as Caddy, nginx, or ngriok. However, you can also run LNbits via https without additional software. This is useful for development purposes or if you want to use LNbits in your local network.
|
||||
|
||||
We have to create a self-signed certificate using `mkcert`. Note that this certiciate is not "trusted" by most browsers but that's fine (since you know that you have created it) and encryption is always better than clear text.
|
||||
|
||||
#### Install mkcert
|
||||
You can find the install instructions for `mkcert` [here](https://github.com/FiloSottile/mkcert).
|
||||
|
||||
Install mkcert on Ubuntu:
|
||||
```sh
|
||||
sudo apt install libnss3-tools
|
||||
curl -JLO "https://dl.filippo.io/mkcert/latest?for=linux/amd64"
|
||||
chmod +x mkcert-v*-linux-amd64
|
||||
sudo cp mkcert-v*-linux-amd64 /usr/local/bin/mkcert
|
||||
```
|
||||
#### Create certificate
|
||||
To create a certificate, first `cd` into your LNbits folder and execute the following command on Linux:
|
||||
```sh
|
||||
openssl req -new -newkey rsa:4096 -x509 -sha256 -days 3650 -nodes -out cert.pem -keyout key.pem
|
||||
```
|
||||
This will create two new files (`key.pem` and `cert.pem `).
|
||||
|
||||
Alternatively, you can use mkcert ([more info](https://kifarunix.com/how-to-create-self-signed-ssl-certificate-with-mkcert-on-ubuntu-18-04/)):
|
||||
```sh
|
||||
# add your local IP (192.x.x.x) as well if you want to use it in your local network
|
||||
mkcert localhost 127.0.0.1 ::1
|
||||
```
|
||||
|
||||
You can then pass the certificate files to uvicorn when you start LNbits:
|
||||
|
||||
```sh
|
||||
./venv/bin/uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5000 --ssl-keyfile ./key.pem --ssl-certfile ./cert.pem
|
||||
```
|
||||
|
||||
|
||||
## LNbits running on Umbrel behind Tor
|
||||
|
||||
If you want to run LNbits on your Umbrel but want it to be reached through clearnet, _Uxellodunum_ made an extensive [guide](https://community.getumbrel.com/t/guide-lnbits-without-tor/604) on how to do it.
|
||||
|
||||
### Docker installation
|
||||
## Docker installation
|
||||
|
||||
To install using docker you first need to build the docker image as:
|
||||
|
||||
```
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
cd lnbits/ # ${PWD} referred as <lnbits_repo>
|
||||
docker build -t lnbits .
|
||||
git clone https://github.com/lnbits/lnbits-legend.git
|
||||
cd lnbits-legend
|
||||
docker build -t lnbits-legend .
|
||||
```
|
||||
|
||||
You can launch the docker in a different directory, but make sure to copy `.env.example` from lnbits there
|
||||
@@ -123,23 +388,15 @@ cp <lnbits_repo>/.env.example .env
|
||||
|
||||
and change the configuration in `.env` as required.
|
||||
|
||||
Then create the data directory for the user ID 1000, which is the user that runs the lnbits within the docker container.
|
||||
|
||||
Then create the data directory
|
||||
```
|
||||
mkdir data
|
||||
sudo chown 1000:1000 ./data/
|
||||
```
|
||||
|
||||
Then the image can be run as:
|
||||
|
||||
```
|
||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits
|
||||
docker run --detach --publish 5000:5000 --name lnbits-legend -e "LNBITS_BACKEND_WALLET_CLASS='FakeWallet'" --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits-legend
|
||||
```
|
||||
|
||||
Finally you can access your lnbits on your machine at port 5000.
|
||||
|
||||
# Additional guides
|
||||
|
||||
## LNbits running on Umbrel behind Tor
|
||||
|
||||
If you want to run LNbits on your Umbrel but want it to be reached through clearnet, _Uxellodunum_ made an extensive [guide](https://community.getumbrel.com/t/guide-lnbits-without-tor/604) on how to do it.
|
||||
|
||||
+15
-17
@@ -8,19 +8,17 @@ nav_order: 3
|
||||
Backend wallets
|
||||
===============
|
||||
|
||||
LNbits can run on top of many lightning-network funding sources. Currently there is support for
|
||||
CLightning, LND, LNbits, LNPay, lntxbot and OpenNode, with more being added regularily.
|
||||
LNbits can run on top of many lightning-network funding sources. Currently there is support for CoreLightning, LND, LNbits, LNPay, lntxbot and OpenNode, with more being added regularly.
|
||||
|
||||
A backend wallet can be configured using the following LNbits environment variables:
|
||||
|
||||
|
||||
### CLightning
|
||||
### CoreLightning
|
||||
|
||||
Using this wallet requires the installation of the `pylightning` Python package.
|
||||
If you want to use LNURLp you should use SparkWallet because of an issue with description_hash and CLightning.
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **CLightningWallet**
|
||||
- `CLIGHTNING_RPC`: /file/path/lightning-rpc
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
|
||||
- `CORELIGHTNING_RPC`: /file/path/lightning-rpc
|
||||
|
||||
### Spark (c-lightning)
|
||||
|
||||
@@ -28,6 +26,17 @@ If you want to use LNURLp you should use SparkWallet because of an issue with de
|
||||
- `SPARK_URL`: http://10.147.17.230:9737/rpc
|
||||
- `SPARK_TOKEN`: secret_access_key
|
||||
|
||||
### LND (REST)
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
|
||||
- `LND_REST_ENDPOINT`: http://10.147.17.230:8080/
|
||||
- `LND_REST_CERT`: /file/path/tls.cert
|
||||
- `LND_REST_MACAROON`: /file/path/admin.macaroon or Bech64/Hex
|
||||
|
||||
or
|
||||
|
||||
- `LND_REST_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
|
||||
|
||||
### LND (gRPC)
|
||||
|
||||
Using this wallet requires the installation of the `grpcio` and `protobuf` Python packages.
|
||||
@@ -44,17 +53,6 @@ You can also use an AES-encrypted macaroon (more info) instead by using
|
||||
|
||||
To encrypt your macaroon, run `./venv/bin/python lnbits/wallets/macaroon/macaroon.py`.
|
||||
|
||||
### LND (REST)
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
|
||||
- `LND_REST_ENDPOINT`: ip_address
|
||||
- `LND_REST_CERT`: /file/path/tls.cert
|
||||
- `LND_REST_MACAROON`: /file/path/admin.macaroon or Bech64/Hex
|
||||
|
||||
or
|
||||
|
||||
- `LND_REST_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
|
||||
|
||||
### LNbits
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LNbitsWallet**
|
||||
|
||||
Generated
+77
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"locked": {
|
||||
"lastModified": 1656928814,
|
||||
"narHash": "sha256-RIFfgBuKz6Hp89yRr7+NR5tzIAbn52h8vT6vXkYjZoM=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "7e2a3b3dfd9af950a856d66b0a7d01e3c18aa249",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1657114324,
|
||||
"narHash": "sha256-fWuaUNXrHcz/ciHRHlcSO92dvV3EVS0GJQUSBO5JIB4=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "a5c867d9fe9e4380452628e8f171c26b69fa9d3d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1657261001,
|
||||
"narHash": "sha256-sUZeuRYfhG59uD6xafM07bc7bAIkpcGq84Vj4B+cyms=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0be91cefefde5701f8fa957904618a13e3bb51d8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"poetry2nix": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1657149754,
|
||||
"narHash": "sha256-iSnZoqwNDDVoO175whSuvl4sS9lAb/2zZ3Sa4ywo970=",
|
||||
"owner": "nix-community",
|
||||
"repo": "poetry2nix",
|
||||
"rev": "fc1930e011dea149db81863aac22fe701f36f1b5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "poetry2nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs",
|
||||
"poetry2nix": "poetry2nix"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
poetry2nix.url = "github:nix-community/poetry2nix";
|
||||
};
|
||||
outputs = { self, nixpkgs, poetry2nix }@inputs:
|
||||
let
|
||||
supportedSystems = [ "x86_64-linux" "aarch64-linux" ];
|
||||
forSystems = systems: f:
|
||||
nixpkgs.lib.genAttrs systems
|
||||
(system: f system (import nixpkgs { inherit system; overlays = [ poetry2nix.overlay self.overlays.default ]; }));
|
||||
forAllSystems = forSystems supportedSystems;
|
||||
projectName = "lnbits";
|
||||
in
|
||||
{
|
||||
devShells = forAllSystems (system: pkgs: {
|
||||
default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
nodePackages.prettier
|
||||
];
|
||||
};
|
||||
});
|
||||
overlays = {
|
||||
default = final: prev: {
|
||||
${projectName} = self.packages.${final.hostPlatform.system}.${projectName};
|
||||
};
|
||||
};
|
||||
packages = forAllSystems (system: pkgs: {
|
||||
default = self.packages.${system}.${projectName};
|
||||
${projectName} = pkgs.poetry2nix.mkPoetryApplication {
|
||||
projectDir = ./.;
|
||||
python = pkgs.python39;
|
||||
};
|
||||
});
|
||||
nixosModules = {
|
||||
default = { pkgs, lib, config, ... }: {
|
||||
imports = [ "${./nix/modules/${projectName}-service.nix}" ];
|
||||
nixpkgs.overlays = [ self.overlays.default ];
|
||||
};
|
||||
};
|
||||
checks = forAllSystems (system: pkgs:
|
||||
let
|
||||
vmTests = import ./nix/tests {
|
||||
makeTest = (import (nixpkgs + "/nixos/lib/testing-python.nix") { inherit system; }).makeTest;
|
||||
inherit inputs pkgs;
|
||||
};
|
||||
in
|
||||
pkgs.lib.optionalAttrs pkgs.stdenv.isLinux vmTests # vmTests can only be ran on Linux, so append them only if on Linux.
|
||||
//
|
||||
{
|
||||
# Other checks here...
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
+15
-13
@@ -1,36 +1,38 @@
|
||||
import asyncio
|
||||
|
||||
import uvloop
|
||||
from loguru import logger
|
||||
from starlette.requests import Request
|
||||
|
||||
from .commands import bundle_vendored, migrate_databases, transpile_scss
|
||||
from .commands import migrate_databases
|
||||
from .settings import (
|
||||
DEBUG,
|
||||
HOST,
|
||||
LNBITS_COMMIT,
|
||||
LNBITS_DATA_FOLDER,
|
||||
LNBITS_DATABASE_URL,
|
||||
LNBITS_SITE_TITLE,
|
||||
PORT,
|
||||
SERVICE_FEE,
|
||||
WALLET,
|
||||
)
|
||||
|
||||
uvloop.install()
|
||||
|
||||
asyncio.create_task(migrate_databases())
|
||||
transpile_scss()
|
||||
bundle_vendored()
|
||||
|
||||
from .app import create_app
|
||||
|
||||
app = create_app()
|
||||
|
||||
print(
|
||||
f"""Starting LNbits with
|
||||
- git version: {LNBITS_COMMIT}
|
||||
- site title: {LNBITS_SITE_TITLE}
|
||||
- debug: {DEBUG}
|
||||
- data folder: {LNBITS_DATA_FOLDER}
|
||||
- funding source: {WALLET.__class__.__name__}
|
||||
- service fee: {SERVICE_FEE}
|
||||
"""
|
||||
logger.info("Starting LNbits")
|
||||
logger.info(f"Host: {HOST}")
|
||||
logger.info(f"Port: {PORT}")
|
||||
logger.info(f"Debug: {DEBUG}")
|
||||
logger.info(f"Site title: {LNBITS_SITE_TITLE}")
|
||||
logger.info(f"Funding source: {WALLET.__class__.__name__}")
|
||||
logger.info(
|
||||
f"Database: {'PostgreSQL' if LNBITS_DATABASE_URL and LNBITS_DATABASE_URL.startswith('postgres://') else 'CockroachDB' if LNBITS_DATABASE_URL and LNBITS_DATABASE_URL.startswith('cockroachdb://') else 'SQLite'}"
|
||||
)
|
||||
logger.info(f"Data folder: {LNBITS_DATA_FOLDER}")
|
||||
logger.info(f"Git version: {LNBITS_COMMIT}")
|
||||
# logger.info(f"Service fee: {SERVICE_FEE}")
|
||||
|
||||
+110
-34
@@ -1,19 +1,23 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
import traceback
|
||||
import warnings
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from loguru import logger
|
||||
|
||||
import lnbits.settings
|
||||
from lnbits.core.tasks import register_task_listeners
|
||||
|
||||
from .commands import db_migrate, handle_assets
|
||||
from .core import core_app
|
||||
from .core.views.generic import core_html_routes
|
||||
from .helpers import (
|
||||
@@ -39,10 +43,21 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
||||
"""Create application factory.
|
||||
:param config_object: The configuration object to use.
|
||||
"""
|
||||
app = FastAPI()
|
||||
app.mount("/static", StaticFiles(directory="lnbits/static"), name="static")
|
||||
configure_logger()
|
||||
|
||||
app = FastAPI(
|
||||
title="LNbits API",
|
||||
description="API for LNbits, the free and open source bitcoin wallet and accounts system with plugins.",
|
||||
license_info={
|
||||
"name": "MIT License",
|
||||
"url": "https://raw.githubusercontent.com/lnbits/lnbits-legend/main/LICENSE",
|
||||
},
|
||||
)
|
||||
app.mount("/static", StaticFiles(packages=[("lnbits", "static")]), name="static")
|
||||
app.mount(
|
||||
"/core/static", StaticFiles(directory="lnbits/core/static"), name="core_static"
|
||||
"/core/static",
|
||||
StaticFiles(packages=[("lnbits.core", "static")]),
|
||||
name="core_static",
|
||||
)
|
||||
|
||||
origins = ["*"]
|
||||
@@ -58,15 +73,23 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
||||
async def validation_exception_handler(
|
||||
request: Request, exc: RequestValidationError
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html",
|
||||
{"request": request, "err": f"`{exc.errors()}` is not a valid UUID."},
|
||||
)
|
||||
# Only the browser sends "text/html" request
|
||||
# not fail proof, but everything else get's a JSON response
|
||||
|
||||
# return HTMLResponse(
|
||||
# status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
# content=jsonable_encoder({"detail": exc.errors(), "body": exc.body}),
|
||||
# )
|
||||
if (
|
||||
request.headers
|
||||
and "accept" in request.headers
|
||||
and "text/html" in request.headers["accept"]
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html",
|
||||
{"request": request, "err": f"{exc.errors()} is not a valid UUID."},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=HTTPStatus.NO_CONTENT,
|
||||
content={"detail": exc.errors()},
|
||||
)
|
||||
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
# app.add_middleware(ASGIProxyFix)
|
||||
@@ -74,7 +97,6 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
||||
check_funding_source(app)
|
||||
register_assets(app)
|
||||
register_routes(app)
|
||||
# register_commands(app)
|
||||
register_async_tasks(app)
|
||||
register_exception_handlers(app)
|
||||
|
||||
@@ -84,18 +106,30 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
||||
def check_funding_source(app: FastAPI) -> None:
|
||||
@app.on_event("startup")
|
||||
async def check_wallet_status():
|
||||
error_message, balance = await WALLET.status()
|
||||
if error_message:
|
||||
warnings.warn(
|
||||
f" × The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
|
||||
RuntimeWarning,
|
||||
)
|
||||
original_sigint_handler = signal.getsignal(signal.SIGINT)
|
||||
|
||||
sys.exit(4)
|
||||
else:
|
||||
print(
|
||||
f" ✔️ {WALLET.__class__.__name__} seems to be connected and with a balance of {balance} msat."
|
||||
)
|
||||
def signal_handler(signal, frame):
|
||||
logger.debug(f"SIGINT received, terminating LNbits.")
|
||||
sys.exit(1)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
while True:
|
||||
try:
|
||||
error_message, balance = await WALLET.status()
|
||||
if not error_message:
|
||||
break
|
||||
logger.error(
|
||||
f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
|
||||
RuntimeWarning,
|
||||
)
|
||||
except:
|
||||
pass
|
||||
logger.info("Retrying connection to backend in 5 seconds...")
|
||||
await asyncio.sleep(5)
|
||||
signal.signal(signal.SIGINT, original_sigint_handler)
|
||||
logger.info(
|
||||
f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat."
|
||||
)
|
||||
|
||||
|
||||
def register_routes(app: FastAPI) -> None:
|
||||
@@ -117,20 +151,15 @@ def register_routes(app: FastAPI) -> None:
|
||||
for s in ext_statics:
|
||||
app.mount(s["path"], s["app"], s["name"])
|
||||
|
||||
logger.trace(f"adding route for extension {ext_module}")
|
||||
app.include_router(ext_route)
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
logger.error(str(e))
|
||||
raise ImportError(
|
||||
f"Please make sure that the extension `{ext.code}` follows conventions."
|
||||
)
|
||||
|
||||
|
||||
def register_commands(app: FastAPI):
|
||||
"""Register Click commands."""
|
||||
app.cli.add_command(db_migrate)
|
||||
app.cli.add_command(handle_assets)
|
||||
|
||||
|
||||
def register_assets(app: FastAPI):
|
||||
"""Serve each vendored asset separately or a bundle."""
|
||||
|
||||
@@ -166,10 +195,57 @@ def register_async_tasks(app):
|
||||
def register_exception_handlers(app: FastAPI):
|
||||
@app.exception_handler(Exception)
|
||||
async def basic_error(request: Request, err):
|
||||
print("handled error", traceback.format_exc())
|
||||
logger.error("handled error", traceback.format_exc())
|
||||
logger.error("ERROR:", err)
|
||||
etype, _, tb = sys.exc_info()
|
||||
traceback.print_exception(etype, err, tb)
|
||||
exc = traceback.format_exc()
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": err}
|
||||
|
||||
if (
|
||||
request.headers
|
||||
and "accept" in request.headers
|
||||
and "text/html" in request.headers["accept"]
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": err}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=HTTPStatus.NO_CONTENT,
|
||||
content={"detail": err},
|
||||
)
|
||||
|
||||
|
||||
def configure_logger() -> None:
|
||||
logger.remove()
|
||||
log_level: str = "DEBUG" if lnbits.settings.DEBUG else "INFO"
|
||||
formatter = Formatter()
|
||||
logger.add(sys.stderr, level=log_level, format=formatter.format)
|
||||
|
||||
logging.getLogger("uvicorn").handlers = [InterceptHandler()]
|
||||
logging.getLogger("uvicorn.access").handlers = [InterceptHandler()]
|
||||
|
||||
|
||||
class Formatter:
|
||||
def __init__(self):
|
||||
self.padding = 0
|
||||
self.minimal_fmt: str = "<green>{time:YYYY-MM-DD HH:mm:ss.SS}</green> | <level>{level}</level> | <level>{message}</level>\n"
|
||||
if lnbits.settings.DEBUG:
|
||||
self.fmt: str = "<green>{time:YYYY-MM-DD HH:mm:ss.SS}</green> | <level>{level: <4}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | <level>{message}</level>\n"
|
||||
else:
|
||||
self.fmt: str = self.minimal_fmt
|
||||
|
||||
def format(self, record):
|
||||
function = "{function}".format(**record)
|
||||
if function == "emit": # uvicorn logs
|
||||
return self.minimal_fmt
|
||||
return self.fmt
|
||||
|
||||
|
||||
class InterceptHandler(logging.Handler):
|
||||
def emit(self, record):
|
||||
try:
|
||||
level = logger.level(record.levelname).name
|
||||
except ValueError:
|
||||
level = record.levelno
|
||||
logger.log(level, record.getMessage())
|
||||
|
||||
+17
-16
@@ -1,15 +1,16 @@
|
||||
import bitstring # type: ignore
|
||||
import re
|
||||
import hashlib
|
||||
from typing import List, NamedTuple, Optional
|
||||
from bech32 import bech32_encode, bech32_decode, CHARSET
|
||||
from ecdsa import SECP256k1, VerifyingKey # type: ignore
|
||||
from ecdsa.util import sigdecode_string # type: ignore
|
||||
from binascii import unhexlify
|
||||
import re
|
||||
import time
|
||||
from binascii import unhexlify
|
||||
from decimal import Decimal
|
||||
from typing import List, NamedTuple, Optional
|
||||
|
||||
import bitstring # type: ignore
|
||||
import embit
|
||||
import secp256k1
|
||||
from bech32 import CHARSET, bech32_decode, bech32_encode
|
||||
from ecdsa import SECP256k1, VerifyingKey # type: ignore
|
||||
from ecdsa.util import sigdecode_string # type: ignore
|
||||
|
||||
|
||||
class Route(NamedTuple):
|
||||
@@ -60,7 +61,7 @@ def decode(pr: str) -> Invoice:
|
||||
invoice = Invoice()
|
||||
|
||||
# decode the amount from the hrp
|
||||
m = re.search("[^\d]+", hrp[2:])
|
||||
m = re.search(r"[^\d]+", hrp[2:])
|
||||
if m:
|
||||
amountstr = hrp[2 + m.end() :]
|
||||
if amountstr != "":
|
||||
@@ -165,7 +166,7 @@ def lnencode(addr, privkey):
|
||||
if addr.amount:
|
||||
amount = Decimal(str(addr.amount))
|
||||
# We can only send down to millisatoshi.
|
||||
if amount * 10 ** 12 % 10:
|
||||
if amount * 10**12 % 10:
|
||||
raise ValueError(
|
||||
"Cannot encode {}: too many decimal places".format(addr.amount)
|
||||
)
|
||||
@@ -215,7 +216,7 @@ def lnencode(addr, privkey):
|
||||
expirybits = expirybits[5:]
|
||||
data += tagged("x", expirybits)
|
||||
elif k == "h":
|
||||
data += tagged_bytes("h", hashlib.sha256(v.encode("utf-8")).digest())
|
||||
data += tagged_bytes("h", v)
|
||||
elif k == "n":
|
||||
data += tagged_bytes("n", v)
|
||||
else:
|
||||
@@ -270,7 +271,7 @@ class LnAddr(object):
|
||||
def shorten_amount(amount):
|
||||
"""Given an amount in bitcoin, shorten it"""
|
||||
# Convert to pico initially
|
||||
amount = int(amount * 10 ** 12)
|
||||
amount = int(amount * 10**12)
|
||||
units = ["p", "n", "u", "m", ""]
|
||||
for unit in units:
|
||||
if amount % 1000 == 0:
|
||||
@@ -289,13 +290,13 @@ def _unshorten_amount(amount: str) -> int:
|
||||
# * `u` (micro): multiply by 0.000001
|
||||
# * `n` (nano): multiply by 0.000000001
|
||||
# * `p` (pico): multiply by 0.000000000001
|
||||
units = {"p": 10 ** 12, "n": 10 ** 9, "u": 10 ** 6, "m": 10 ** 3}
|
||||
units = {"p": 10**12, "n": 10**9, "u": 10**6, "m": 10**3}
|
||||
unit = str(amount)[-1]
|
||||
|
||||
# BOLT #11:
|
||||
# A reader SHOULD fail if `amount` contains a non-digit, or is followed by
|
||||
# anything except a `multiplier` in the table above.
|
||||
if not re.fullmatch("\d+[pnum]?", str(amount)):
|
||||
if not re.fullmatch(r"\d+[pnum]?", str(amount)):
|
||||
raise ValueError("Invalid amount '{}'".format(amount))
|
||||
|
||||
if unit in units:
|
||||
@@ -348,9 +349,9 @@ def _trim_to_bytes(barr):
|
||||
|
||||
def _readable_scid(short_channel_id: int) -> str:
|
||||
return "{blockheight}x{transactionindex}x{outputindex}".format(
|
||||
blockheight=((short_channel_id >> 40) & 0xffffff),
|
||||
transactionindex=((short_channel_id >> 16) & 0xffffff),
|
||||
outputindex=(short_channel_id & 0xffff),
|
||||
blockheight=((short_channel_id >> 40) & 0xFFFFFF),
|
||||
transactionindex=((short_channel_id >> 16) & 0xFFFFFF),
|
||||
outputindex=(short_channel_id & 0xFFFF),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+11
-8
@@ -1,16 +1,19 @@
|
||||
import asyncio
|
||||
import warnings
|
||||
import click
|
||||
import importlib
|
||||
import re
|
||||
import os
|
||||
import re
|
||||
import warnings
|
||||
|
||||
from .db import SQLITE, POSTGRES, COCKROACH
|
||||
from .core import db as core_db, migrations as core_migrations
|
||||
import click
|
||||
from loguru import logger
|
||||
|
||||
from .core import db as core_db
|
||||
from .core import migrations as core_migrations
|
||||
from .db import COCKROACH, POSTGRES, SQLITE
|
||||
from .helpers import (
|
||||
get_valid_extensions,
|
||||
get_css_vendored,
|
||||
get_js_vendored,
|
||||
get_valid_extensions,
|
||||
url_for_vendored,
|
||||
)
|
||||
from .settings import LNBITS_PATH
|
||||
@@ -69,7 +72,7 @@ async def migrate_databases():
|
||||
if match:
|
||||
version = int(match.group(1))
|
||||
if version > current_versions.get(db_name, 0):
|
||||
print(f"running migration {db_name}.{version}")
|
||||
logger.debug(f"running migration {db_name}.{version}")
|
||||
await migrate(db)
|
||||
|
||||
if db.schema == None:
|
||||
@@ -110,4 +113,4 @@ async def migrate_databases():
|
||||
async with ext_db.connect() as ext_conn:
|
||||
await run_migration(ext_conn, ext_migrations)
|
||||
|
||||
print(" ✔️ All migrations done.")
|
||||
logger.info("✔️ All migrations done.")
|
||||
|
||||
+76
-11
@@ -1,15 +1,17 @@
|
||||
import json
|
||||
import datetime
|
||||
from uuid import uuid4
|
||||
from typing import List, Optional, Dict, Any
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
from uuid import uuid4
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.db import Connection, POSTGRES, COCKROACH
|
||||
from lnbits.db import COCKROACH, POSTGRES, Connection
|
||||
from lnbits.settings import DEFAULT_WALLET_NAME, LNBITS_ADMIN_USERS
|
||||
|
||||
from . import db
|
||||
from .models import User, Wallet, Payment, BalanceCheck
|
||||
from .models import BalanceCheck, Payment, User, Wallet
|
||||
|
||||
# accounts
|
||||
# --------
|
||||
@@ -113,7 +115,7 @@ async def create_wallet(
|
||||
async def update_wallet(
|
||||
wallet_id: str, new_name: str, conn: Optional[Connection] = None
|
||||
) -> Optional[Wallet]:
|
||||
await (conn or db).execute(
|
||||
return await (conn or db).execute(
|
||||
"""
|
||||
UPDATE wallets SET
|
||||
name = ?
|
||||
@@ -180,16 +182,28 @@ async def get_wallet_for_key(
|
||||
|
||||
|
||||
async def get_standalone_payment(
|
||||
checking_id_or_hash: str, conn: Optional[Connection] = None
|
||||
checking_id_or_hash: str,
|
||||
conn: Optional[Connection] = None,
|
||||
incoming: Optional[bool] = False,
|
||||
wallet_id: Optional[str] = None,
|
||||
) -> Optional[Payment]:
|
||||
clause: str = "checking_id = ? OR hash = ?"
|
||||
values = [checking_id_or_hash, checking_id_or_hash]
|
||||
if incoming:
|
||||
clause = f"({clause}) AND amount > 0"
|
||||
|
||||
if wallet_id:
|
||||
clause = f"({clause}) AND wallet = ?"
|
||||
values.append(wallet_id)
|
||||
|
||||
row = await (conn or db).fetchone(
|
||||
"""
|
||||
f"""
|
||||
SELECT *
|
||||
FROM apipayments
|
||||
WHERE checking_id = ? OR hash = ?
|
||||
WHERE {clause}
|
||||
LIMIT 1
|
||||
""",
|
||||
(checking_id_or_hash, checking_id_or_hash),
|
||||
tuple(values),
|
||||
)
|
||||
|
||||
return Payment.from_row(row) if row else None
|
||||
@@ -313,6 +327,7 @@ async def delete_expired_invoices(
|
||||
AND amount > 0 AND time < {db.timestamp_now} - {db.interval_seconds(86400)}
|
||||
"""
|
||||
)
|
||||
logger.debug(f"Checking expiry of {len(rows)} invoices")
|
||||
for (payment_request,) in rows:
|
||||
try:
|
||||
invoice = bolt11.decode(payment_request)
|
||||
@@ -322,7 +337,9 @@ async def delete_expired_invoices(
|
||||
expiration_date = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
|
||||
if expiration_date > datetime.datetime.utcnow():
|
||||
continue
|
||||
|
||||
logger.debug(
|
||||
f"Deleting expired invoice: {invoice.payment_hash} (expired: {expiration_date})"
|
||||
)
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
DELETE FROM apipayments
|
||||
@@ -351,6 +368,11 @@ async def create_payment(
|
||||
webhook: Optional[str] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Payment:
|
||||
|
||||
# todo: add this when tests are fixed
|
||||
# previous_payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
|
||||
# assert previous_payment is None, "Payment already exists"
|
||||
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
INSERT INTO apipayments
|
||||
@@ -390,12 +412,55 @@ async def update_payment_status(
|
||||
)
|
||||
|
||||
|
||||
async def update_payment_details(
|
||||
checking_id: str,
|
||||
pending: Optional[bool] = None,
|
||||
fee: Optional[int] = None,
|
||||
preimage: Optional[str] = None,
|
||||
new_checking_id: Optional[str] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
|
||||
set_clause: List[str] = []
|
||||
set_variables: List[Any] = []
|
||||
|
||||
if new_checking_id is not None:
|
||||
set_clause.append("checking_id = ?")
|
||||
set_variables.append(new_checking_id)
|
||||
if pending is not None:
|
||||
set_clause.append("pending = ?")
|
||||
set_variables.append(pending)
|
||||
if fee is not None:
|
||||
set_clause.append("fee = ?")
|
||||
set_variables.append(fee)
|
||||
if preimage is not None:
|
||||
set_clause.append("preimage = ?")
|
||||
set_variables.append(preimage)
|
||||
|
||||
set_variables.append(checking_id)
|
||||
|
||||
await (conn or db).execute(
|
||||
f"UPDATE apipayments SET {', '.join(set_clause)} WHERE checking_id = ?",
|
||||
tuple(set_variables),
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
async def delete_payment(checking_id: str, conn: Optional[Connection] = None) -> None:
|
||||
await (conn or db).execute(
|
||||
"DELETE FROM apipayments WHERE checking_id = ?", (checking_id,)
|
||||
)
|
||||
|
||||
|
||||
async def delete_wallet_payment(
|
||||
checking_id: str, wallet_id: str, conn: Optional[Connection] = None
|
||||
) -> None:
|
||||
await (conn or db).execute(
|
||||
"DELETE FROM apipayments WHERE checking_id = ? AND wallet = ?",
|
||||
(checking_id, wallet_id),
|
||||
)
|
||||
|
||||
|
||||
async def check_internal(
|
||||
payment_hash: str, conn: Optional[Connection] = None
|
||||
) -> Optional[str]:
|
||||
|
||||
+51
-19
@@ -1,13 +1,18 @@
|
||||
import json
|
||||
import hmac
|
||||
import hashlib
|
||||
from lnbits.helpers import url_for
|
||||
import hmac
|
||||
import json
|
||||
from sqlite3 import Row
|
||||
from typing import Dict, List, NamedTuple, Optional
|
||||
|
||||
from ecdsa import SECP256k1, SigningKey # type: ignore
|
||||
from lnurl import encode as lnurl_encode # type: ignore
|
||||
from typing import List, NamedTuple, Optional, Dict
|
||||
from sqlite3 import Row
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from lnbits.db import Connection
|
||||
from lnbits.helpers import url_for
|
||||
from lnbits.settings import WALLET
|
||||
from lnbits.wallets.base import PaymentStatus
|
||||
|
||||
|
||||
class Wallet(BaseModel):
|
||||
@@ -103,6 +108,8 @@ class Payment(BaseModel):
|
||||
|
||||
@property
|
||||
def tag(self) -> Optional[str]:
|
||||
if self.extra is None:
|
||||
return ""
|
||||
return self.extra.get("tag")
|
||||
|
||||
@property
|
||||
@@ -123,8 +130,21 @@ class Payment(BaseModel):
|
||||
|
||||
@property
|
||||
def is_uncheckable(self) -> bool:
|
||||
return self.checking_id.startswith("temp_") or self.checking_id.startswith(
|
||||
"internal_"
|
||||
return self.checking_id.startswith("internal_")
|
||||
|
||||
async def update_status(
|
||||
self,
|
||||
status: PaymentStatus,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
from .crud import update_payment_details
|
||||
|
||||
await update_payment_details(
|
||||
checking_id=self.checking_id,
|
||||
pending=status.pending,
|
||||
fee=status.fee_msat,
|
||||
preimage=status.preimage,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
async def set_pending(self, pending: bool) -> None:
|
||||
@@ -132,28 +152,40 @@ class Payment(BaseModel):
|
||||
|
||||
await update_payment_status(self.checking_id, pending)
|
||||
|
||||
async def check_pending(self) -> None:
|
||||
async def check_status(
|
||||
self,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> PaymentStatus:
|
||||
if self.is_uncheckable:
|
||||
return
|
||||
return PaymentStatus(None)
|
||||
|
||||
logger.debug(
|
||||
f"Checking {'outgoing' if self.is_out else 'incoming'} pending payment {self.checking_id}"
|
||||
)
|
||||
|
||||
if self.is_out:
|
||||
status = await WALLET.get_payment_status(self.checking_id)
|
||||
else:
|
||||
status = await WALLET.get_invoice_status(self.checking_id)
|
||||
|
||||
if self.is_out and status.failed:
|
||||
print(f" - deleting outgoing failed payment {self.checking_id}: {status}")
|
||||
await self.delete()
|
||||
elif not status.pending:
|
||||
print(
|
||||
f" - marking '{'in' if self.is_in else 'out'}' {self.checking_id} as not pending anymore: {status}"
|
||||
)
|
||||
await self.set_pending(status.pending)
|
||||
logger.debug(f"Status: {status}")
|
||||
|
||||
async def delete(self) -> None:
|
||||
if self.is_out and status.failed:
|
||||
logger.warning(
|
||||
f"Deleting outgoing failed payment {self.checking_id}: {status}"
|
||||
)
|
||||
await self.delete(conn)
|
||||
elif not status.pending:
|
||||
logger.info(
|
||||
f"Marking '{'in' if self.is_in else 'out'}' {self.checking_id} as not pending anymore: {status}"
|
||||
)
|
||||
await self.update_status(status, conn=conn)
|
||||
return status
|
||||
|
||||
async def delete(self, conn: Optional[Connection] = None) -> None:
|
||||
from .crud import delete_payment
|
||||
|
||||
await delete_payment(self.checking_id)
|
||||
await delete_payment(self.checking_id, conn=conn)
|
||||
|
||||
|
||||
class BalanceCheck(BaseModel):
|
||||
|
||||
+107
-67
@@ -6,25 +6,35 @@ from typing import Dict, Optional, Tuple
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends
|
||||
from lnurl import LnurlErrorResponse
|
||||
from lnurl import decode as decode_lnurl # type: ignore
|
||||
from loguru import logger
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.db import Connection
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
get_key_type,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import url_for, urlsafe_short_hash
|
||||
from lnbits.requestvars import g
|
||||
from lnbits.settings import WALLET
|
||||
from lnbits.settings import FAKE_WALLET, RESERVE_FEE_MIN, RESERVE_FEE_PERCENT, WALLET
|
||||
from lnbits.wallets.base import PaymentResponse, PaymentStatus
|
||||
|
||||
from . import db
|
||||
from .crud import (
|
||||
check_internal,
|
||||
create_payment,
|
||||
delete_payment,
|
||||
delete_wallet_payment,
|
||||
get_wallet,
|
||||
get_wallet_payment,
|
||||
update_payment_details,
|
||||
update_payment_status,
|
||||
)
|
||||
from .models import Payment
|
||||
|
||||
try:
|
||||
from typing import TypedDict # type: ignore
|
||||
@@ -46,17 +56,25 @@ async def create_invoice(
|
||||
amount: int, # in satoshis
|
||||
memo: str,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
extra: Optional[Dict] = None,
|
||||
webhook: Optional[str] = None,
|
||||
internal: Optional[bool] = False,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Tuple[str, str]:
|
||||
invoice_memo = None if description_hash else memo
|
||||
|
||||
ok, checking_id, payment_request, error_message = await WALLET.create_invoice(
|
||||
amount=amount, memo=invoice_memo, description_hash=description_hash
|
||||
# use the fake wallet if the invoice is for internal use only
|
||||
wallet = FAKE_WALLET if internal else WALLET
|
||||
|
||||
ok, checking_id, payment_request, error_message = await wallet.create_invoice(
|
||||
amount=amount,
|
||||
memo=invoice_memo,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
)
|
||||
if not ok:
|
||||
raise InvoiceFailure(error_message or "Unexpected backend error.")
|
||||
raise InvoiceFailure(error_message or "unexpected backend error.")
|
||||
|
||||
invoice = bolt11.decode(payment_request)
|
||||
|
||||
@@ -85,31 +103,36 @@ async def pay_invoice(
|
||||
description: str = "",
|
||||
conn: Optional[Connection] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Pay a Lightning invoice.
|
||||
First, we create a temporary payment in the database with fees set to the reserve fee.
|
||||
We then check whether the balance of the payer would go negative.
|
||||
We then attempt to pay the invoice through the backend.
|
||||
If the payment is successful, we update the payment in the database with the payment details.
|
||||
If the payment is unsuccessful, we delete the temporary payment.
|
||||
If the payment is still in flight, we hope that some other process will regularly check for the payment.
|
||||
"""
|
||||
invoice = bolt11.decode(payment_request)
|
||||
fee_reserve_msat = fee_reserve(invoice.amount_msat)
|
||||
async with (db.reuse_conn(conn) if conn else db.connect()) as conn:
|
||||
temp_id = f"temp_{urlsafe_short_hash()}"
|
||||
internal_id = f"internal_{urlsafe_short_hash()}"
|
||||
temp_id = invoice.payment_hash
|
||||
internal_id = f"internal_{invoice.payment_hash}"
|
||||
|
||||
invoice = bolt11.decode(payment_request)
|
||||
if invoice.amount_msat == 0:
|
||||
raise ValueError("Amountless invoices not supported.")
|
||||
if max_sat and invoice.amount_msat > max_sat * 1000:
|
||||
raise ValueError("Amount in invoice is too high.")
|
||||
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
|
||||
# put all parameters that don't change here
|
||||
PaymentKwargs = TypedDict(
|
||||
"PaymentKwargs",
|
||||
{
|
||||
"wallet_id": str,
|
||||
"payment_request": str,
|
||||
"payment_hash": str,
|
||||
"amount": int,
|
||||
"memo": str,
|
||||
"extra": Optional[Dict],
|
||||
},
|
||||
)
|
||||
payment_kwargs: PaymentKwargs = dict(
|
||||
class PaymentKwargs(TypedDict):
|
||||
wallet_id: str
|
||||
payment_request: str
|
||||
payment_hash: str
|
||||
amount: int
|
||||
memo: str
|
||||
extra: Optional[Dict]
|
||||
|
||||
payment_kwargs: PaymentKwargs = PaymentKwargs(
|
||||
wallet_id=wallet_id,
|
||||
payment_request=payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
@@ -121,6 +144,7 @@ async def pay_invoice(
|
||||
# check_internal() returns the checking_id of the invoice we're waiting for
|
||||
internal_checking_id = await check_internal(invoice.payment_hash, conn=conn)
|
||||
if internal_checking_id:
|
||||
logger.debug(f"creating temporary internal payment with id {internal_id}")
|
||||
# create a new payment from this wallet
|
||||
await create_payment(
|
||||
checking_id=internal_id,
|
||||
@@ -130,32 +154,29 @@ async def pay_invoice(
|
||||
**payment_kwargs,
|
||||
)
|
||||
else:
|
||||
logger.debug(f"creating temporary payment with id {temp_id}")
|
||||
# create a temporary payment here so we can check if
|
||||
# the balance is enough in the next step
|
||||
await create_payment(
|
||||
checking_id=temp_id,
|
||||
fee=-fee_reserve(invoice.amount_msat),
|
||||
fee=-fee_reserve_msat,
|
||||
conn=conn,
|
||||
**payment_kwargs,
|
||||
)
|
||||
|
||||
# do the balance check if internal payment
|
||||
if internal_checking_id:
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
assert wallet
|
||||
if wallet.balance_msat < 0:
|
||||
raise PermissionError("Insufficient balance.")
|
||||
|
||||
# do the balance check if external payment
|
||||
else:
|
||||
if invoice.amount_msat > wallet.balance_msat - (
|
||||
wallet.balance_msat / 100 * 2
|
||||
):
|
||||
raise PermissionError(
|
||||
"LNbits requires you keep at least 2% reserve to cover potential routing fees."
|
||||
# do the balance check
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
assert wallet
|
||||
if wallet.balance_msat < 0:
|
||||
logger.debug("balance is too low, deleting temporary payment")
|
||||
if not internal_checking_id and wallet.balance_msat > -fee_reserve_msat:
|
||||
raise PaymentFailure(
|
||||
f"You must reserve at least ({round(fee_reserve_msat/1000)} sat) to cover potential routing fees."
|
||||
)
|
||||
raise PermissionError("Insufficient balance.")
|
||||
|
||||
if internal_checking_id:
|
||||
logger.debug(f"marking temporary payment as not pending {internal_checking_id}")
|
||||
# mark the invoice from the other side as not pending anymore
|
||||
# so the other side only has access to his new money when we are sure
|
||||
# the payer has enough to deduct from
|
||||
@@ -170,25 +191,44 @@ async def pay_invoice(
|
||||
|
||||
await internal_invoice_queue.put(internal_checking_id)
|
||||
else:
|
||||
logger.debug(f"backend: sending payment {temp_id}")
|
||||
# actually pay the external invoice
|
||||
payment: PaymentResponse = await WALLET.pay_invoice(payment_request)
|
||||
if payment.checking_id:
|
||||
payment: PaymentResponse = await WALLET.pay_invoice(
|
||||
payment_request, fee_reserve_msat
|
||||
)
|
||||
|
||||
if payment.checking_id and payment.checking_id != temp_id:
|
||||
logger.warning(
|
||||
f"backend sent unexpected checking_id (expected: {temp_id} got: {payment.checking_id})"
|
||||
)
|
||||
|
||||
logger.debug(f"backend: pay_invoice finished {temp_id}")
|
||||
if payment.checking_id and payment.ok != False:
|
||||
# payment.ok can be True (paid) or None (pending)!
|
||||
logger.debug(f"updating payment {temp_id}")
|
||||
async with db.connect() as conn:
|
||||
await create_payment(
|
||||
checking_id=payment.checking_id,
|
||||
await update_payment_details(
|
||||
checking_id=temp_id,
|
||||
pending=payment.ok != True,
|
||||
fee=payment.fee_msat,
|
||||
preimage=payment.preimage,
|
||||
pending=payment.ok == None,
|
||||
new_checking_id=payment.checking_id,
|
||||
conn=conn,
|
||||
**payment_kwargs,
|
||||
)
|
||||
await delete_payment(temp_id, conn=conn)
|
||||
else:
|
||||
logger.debug(f"payment successful {payment.checking_id}")
|
||||
elif payment.checking_id is None and payment.ok == False:
|
||||
# payment failed
|
||||
logger.warning(f"backend sent payment failure")
|
||||
async with db.connect() as conn:
|
||||
await delete_payment(temp_id, conn=conn)
|
||||
logger.debug(f"deleting temporary payment {temp_id}")
|
||||
await delete_wallet_payment(temp_id, wallet_id, conn=conn)
|
||||
raise PaymentFailure(
|
||||
payment.error_message
|
||||
or "Payment failed, but backend didn't give us an error message."
|
||||
f"payment failed: {payment.error_message}"
|
||||
or "payment failed, but backend didn't give us an error message"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"didn't receive checking_id from backend, payment may be stuck in database: {temp_id}"
|
||||
)
|
||||
|
||||
return invoice.payment_hash
|
||||
@@ -221,7 +261,7 @@ async def redeem_lnurl_withdraw(
|
||||
conn=conn,
|
||||
)
|
||||
except:
|
||||
print(
|
||||
logger.warning(
|
||||
f"failed to create invoice on redeem_lnurl_withdraw from {lnurl}. params: {res}"
|
||||
)
|
||||
return None
|
||||
@@ -248,12 +288,15 @@ async def redeem_lnurl_withdraw(
|
||||
|
||||
|
||||
async def perform_lnurlauth(
|
||||
callback: str, conn: Optional[Connection] = None
|
||||
callback: str,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Optional[LnurlErrorResponse]:
|
||||
cb = urlparse(callback)
|
||||
|
||||
k1 = unhexlify(parse_qs(cb.query)["k1"][0])
|
||||
key = g().wallet.lnurlauth_key(cb.netloc)
|
||||
|
||||
key = wallet.wallet.lnurlauth_key(cb.netloc)
|
||||
|
||||
def int_to_bytes_suitable_der(x: int) -> bytes:
|
||||
"""for strict DER we need to encode the integer with some quirks"""
|
||||
@@ -286,12 +329,12 @@ async def perform_lnurlauth(
|
||||
sign_len = 6 + r_len + s_len
|
||||
|
||||
signature = BytesIO()
|
||||
signature.write(0x30 .to_bytes(1, "big", signed=False))
|
||||
signature.write(0x30.to_bytes(1, "big", signed=False))
|
||||
signature.write((sign_len - 2).to_bytes(1, "big", signed=False))
|
||||
signature.write(0x02 .to_bytes(1, "big", signed=False))
|
||||
signature.write(0x02.to_bytes(1, "big", signed=False))
|
||||
signature.write(r_len.to_bytes(1, "big", signed=False))
|
||||
signature.write(r)
|
||||
signature.write(0x02 .to_bytes(1, "big", signed=False))
|
||||
signature.write(0x02.to_bytes(1, "big", signed=False))
|
||||
signature.write(s_len.to_bytes(1, "big", signed=False))
|
||||
signature.write(s)
|
||||
|
||||
@@ -320,25 +363,22 @@ async def perform_lnurlauth(
|
||||
)
|
||||
|
||||
|
||||
async def check_invoice_status(
|
||||
async def check_transaction_status(
|
||||
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
|
||||
) -> PaymentStatus:
|
||||
payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
|
||||
payment: Optional[Payment] = await get_wallet_payment(
|
||||
wallet_id, payment_hash, conn=conn
|
||||
)
|
||||
if not payment:
|
||||
return PaymentStatus(None)
|
||||
status = await WALLET.get_invoice_status(payment.checking_id)
|
||||
if not payment.pending:
|
||||
return status
|
||||
if payment.is_out and status.failed:
|
||||
print(f" - deleting outgoing failed payment {payment.checking_id}: {status}")
|
||||
await payment.delete()
|
||||
elif not status.pending:
|
||||
print(
|
||||
f" - marking '{'in' if payment.is_in else 'out'}' {payment.checking_id} as not pending anymore: {status}"
|
||||
)
|
||||
await payment.set_pending(status.pending)
|
||||
# note: before, we still checked the status of the payment again
|
||||
return PaymentStatus(True)
|
||||
|
||||
status: PaymentStatus = await payment.check_status()
|
||||
return status
|
||||
|
||||
|
||||
# WARN: this same value must be used for balance check and passed to WALLET.pay_invoice(), it may cause a vulnerability if the values differ
|
||||
def fee_reserve(amount_msat: int) -> int:
|
||||
return max(1000, int(amount_msat * 0.01))
|
||||
return max(int(RESERVE_FEE_MIN), int(amount_msat * RESERVE_FEE_PERCENT / 100.0))
|
||||
|
||||
@@ -1,4 +1,36 @@
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
data: function () {
|
||||
return {
|
||||
searchTerm: '',
|
||||
filteredExtensions: null
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.filteredExtensions = this.g.extensions
|
||||
},
|
||||
watch: {
|
||||
searchTerm(term) {
|
||||
// Reset the filter
|
||||
this.filteredExtensions = this.g.extensions
|
||||
if (term !== '') {
|
||||
// Filter the extensions list
|
||||
function extensionNameContains(searchTerm) {
|
||||
return function (extension) {
|
||||
return (
|
||||
extension.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
extension.shortDescription
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
this.filteredExtensions = this.filteredExtensions.filter(
|
||||
extensionNameContains(term)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
mixins: [windowMixin]
|
||||
})
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// the cache version gets updated every time there is a new deployment
|
||||
const CACHE_VERSION = 1
|
||||
const CURRENT_CACHE = `lnbits-${CACHE_VERSION}-`
|
||||
|
||||
const getApiKey = request => {
|
||||
return request.headers.get('X-Api-Key') || 'none'
|
||||
}
|
||||
|
||||
// on activation we clean up the previously registered service workers
|
||||
self.addEventListener('activate', evt =>
|
||||
evt.waitUntil(
|
||||
caches.keys().then(cacheNames => {
|
||||
return Promise.all(
|
||||
cacheNames.map(cacheName => {
|
||||
const currentCacheVersion = cacheName.split('-').slice(-2, 2)
|
||||
if (currentCacheVersion !== CACHE_VERSION) {
|
||||
return caches.delete(cacheName)
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// The fetch handler serves responses for same-origin resources from a cache.
|
||||
// If no response is found, it populates the runtime cache with the response
|
||||
// from the network before returning it to the page.
|
||||
self.addEventListener('fetch', event => {
|
||||
// Skip cross-origin requests, like those for Google Analytics.
|
||||
if (
|
||||
event.request.url.startsWith(self.location.origin) &&
|
||||
event.request.method == 'GET'
|
||||
) {
|
||||
// Open the cache
|
||||
event.respondWith(
|
||||
caches.open(CURRENT_CACHE + getApiKey(event.request)).then(cache => {
|
||||
// Go to the network first
|
||||
return fetch(event.request)
|
||||
.then(fetchedResponse => {
|
||||
cache.put(event.request, fetchedResponse.clone())
|
||||
|
||||
return fetchedResponse
|
||||
})
|
||||
.catch(() => {
|
||||
// If the network is unavailable, get
|
||||
return cache.match(event.request.url)
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -232,6 +232,9 @@ new Vue({
|
||||
generateChart(this.$refs.canvas, this.payments)
|
||||
})
|
||||
},
|
||||
focusInput(el) {
|
||||
this.$nextTick(() => this.$refs[el].focus())
|
||||
},
|
||||
showReceiveDialog: function () {
|
||||
this.receive.show = true
|
||||
this.receive.status = 'pending'
|
||||
@@ -243,6 +246,7 @@ new Vue({
|
||||
this.receive.paymentChecker = null
|
||||
this.receive.minMax = [0, 2100000000000000]
|
||||
this.receive.lnurl = null
|
||||
this.focusInput('setAmount')
|
||||
},
|
||||
showParseDialog: function () {
|
||||
this.parse.show = true
|
||||
@@ -364,12 +368,12 @@ new Vue({
|
||||
},
|
||||
decodeRequest: function () {
|
||||
this.parse.show = true
|
||||
|
||||
if (this.parse.data.request.startsWith('lightning:')) {
|
||||
let req = this.parse.data.request.toLowerCase()
|
||||
if (this.parse.data.request.toLowerCase().startsWith('lightning:')) {
|
||||
this.parse.data.request = this.parse.data.request.slice(10)
|
||||
} else if (this.parse.data.request.startsWith('lnurl:')) {
|
||||
} else if (this.parse.data.request.toLowerCase().startsWith('lnurl:')) {
|
||||
this.parse.data.request = this.parse.data.request.slice(6)
|
||||
} else if (this.parse.data.request.indexOf('lightning=lnurl1') !== -1) {
|
||||
} else if (req.indexOf('lightning=lnurl1') !== -1) {
|
||||
this.parse.data.request = this.parse.data.request
|
||||
.split('lightning=')[1]
|
||||
.split('&')[0]
|
||||
@@ -618,10 +622,10 @@ new Vue({
|
||||
},
|
||||
updateWalletName: function () {
|
||||
let newName = this.newName
|
||||
let adminkey = this.g.wallet.adminkey
|
||||
if (!newName || !newName.length) return
|
||||
// let data = {name: newName}
|
||||
LNbits.api
|
||||
.request('PUT', '/api/v1/wallet/' + newName, this.g.wallet.inkey, {})
|
||||
.request('PUT', '/api/v1/wallet/' + newName, adminkey, {})
|
||||
.then(res => {
|
||||
this.newName = ''
|
||||
this.$q.notify({
|
||||
@@ -668,7 +672,17 @@ new Vue({
|
||||
})
|
||||
},
|
||||
exportCSV: function () {
|
||||
LNbits.utils.exportCSV(this.paymentsTable.columns, this.payments)
|
||||
// status is important for export but it is not in paymentsTable
|
||||
// because it is manually added with payment detail link and icons
|
||||
// and would cause duplication in the list
|
||||
let columns = this.paymentsTable.columns
|
||||
columns.unshift({
|
||||
name: 'pending',
|
||||
align: 'left',
|
||||
label: 'Pending',
|
||||
field: 'pending'
|
||||
})
|
||||
LNbits.utils.exportCSV(columns, this.payments)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -691,10 +705,7 @@ new Vue({
|
||||
},
|
||||
mounted: function () {
|
||||
// show disclaimer
|
||||
if (
|
||||
this.$refs.disclaimer &&
|
||||
!this.$q.localStorage.getItem('lnbits.disclaimerShown')
|
||||
) {
|
||||
if (!this.$q.localStorage.getItem('lnbits.disclaimerShown')) {
|
||||
this.disclaimerDialog.show = true
|
||||
this.$q.localStorage.set('lnbits.disclaimerShown', true)
|
||||
}
|
||||
@@ -705,3 +716,11 @@ new Vue({
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
if (navigator.serviceWorker != null) {
|
||||
navigator.serviceWorker
|
||||
.register('/service-worker.js')
|
||||
.then(function (registration) {
|
||||
console.log('Registered events at scope: ', registration.scope)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import asyncio
|
||||
import httpx
|
||||
from typing import List
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
from . import db
|
||||
@@ -20,7 +22,7 @@ async def register_task_listeners():
|
||||
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
|
||||
while True:
|
||||
payment = await invoice_paid_queue.get()
|
||||
|
||||
logger.debug("received invoice paid event")
|
||||
# send information to sse channel
|
||||
await dispatch_invoice_listener(payment)
|
||||
|
||||
@@ -44,7 +46,7 @@ async def dispatch_invoice_listener(payment: Payment):
|
||||
try:
|
||||
send_channel.put_nowait(payment)
|
||||
except asyncio.QueueFull:
|
||||
print("removing sse listener", send_channel)
|
||||
logger.debug("removing sse listener", send_channel)
|
||||
api_invoice_listeners.remove(send_channel)
|
||||
|
||||
|
||||
@@ -52,7 +54,8 @@ async def dispatch_webhook(payment: Payment):
|
||||
async with httpx.AsyncClient() as client:
|
||||
data = payment.dict()
|
||||
try:
|
||||
r = await client.post(payment.webhook, json=data, timeout=40)
|
||||
logger.debug("sending webhook", payment.webhook)
|
||||
r = await client.post(payment.webhook, json=data, timeout=40) # type: ignore
|
||||
await mark_webhook_sent(payment, r.status_code)
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
await mark_webhook_sent(payment, -1)
|
||||
|
||||
@@ -48,7 +48,9 @@
|
||||
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code
|
||||
>{"out": false, "amount": <int>, "memo": <string>}</code
|
||||
>{"out": false, "amount": <int>, "memo": <string>, "unit":
|
||||
<string>, "webhook": <url:string>, "internal":
|
||||
<bool>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
@@ -61,8 +63,8 @@
|
||||
<code
|
||||
>curl -X POST {{ request.base_url }}api/v1/payments -d '{"out": false,
|
||||
"amount": <int>, "memo": <string>, "webhook":
|
||||
<url:string>, "unit": <string>}' -H "X-Api-Key: <i>{{ wallet.inkey }}</i>" -H
|
||||
"Content-type: application/json"</code
|
||||
<url:string>, "unit": <string>}' -H "X-Api-Key:
|
||||
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
@@ -2,10 +2,23 @@
|
||||
%} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="/core/static/js/extensions.js"></script>
|
||||
{% endblock %} {% block page %}
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-sm-3 col-xs-8 q-ml-auto">
|
||||
<q-input v-model="searchTerm" label="Search extensions">
|
||||
<q-icon
|
||||
v-if="searchTerm !== ''"
|
||||
name="close"
|
||||
@click="searchTerm = ''"
|
||||
class="cursor-pointer q-mt-lg"
|
||||
/>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-md">
|
||||
<div
|
||||
class="col-6 col-md-4 col-lg-3"
|
||||
v-for="extension in g.extensions"
|
||||
v-for="extension in filteredExtensions"
|
||||
:key="extension.code"
|
||||
>
|
||||
<q-card>
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
outline
|
||||
color="grey"
|
||||
type="a"
|
||||
href="https://github.com/lnbits/lnbits"
|
||||
href="https://github.com/lnbits/lnbits-legend"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>View project in GitHub</q-btn
|
||||
@@ -75,7 +75,7 @@
|
||||
outline
|
||||
color="grey"
|
||||
type="a"
|
||||
href="https://lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK"
|
||||
href="https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>Donate</q-btn
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<!---->
|
||||
{% block scripts %} {{ window_vars(user, wallet) }}
|
||||
<script src="/core/static/js/wallet.js"></script>
|
||||
<link rel="manifest" href="/manifest/{{ user.id }}.webmanifest" />
|
||||
{% endblock %}
|
||||
<!---->
|
||||
{% block title %} {{ wallet.name }} - {{ SITE_TITLE }} {% endblock %}
|
||||
@@ -273,444 +272,470 @@
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
|
||||
{{ SITE_TITLE }} Wallet: <strong><em>{{ wallet.name }}</em></strong>
|
||||
</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
|
||||
<q-list>
|
||||
{% include "core/_api_docs.html" %}
|
||||
{% if HIDE_API %}
|
||||
<div class="col-12 col-md-4 q-gutter-y-md">
|
||||
{% else %}
|
||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
|
||||
{{ SITE_TITLE }} Wallet: <strong><em>{{ wallet.name }}</em></strong>
|
||||
</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
|
||||
{% if wallet.lnurlwithdraw_full %}
|
||||
<q-expansion-item group="extras" icon="crop_free" label="Drain Funds">
|
||||
<q-card>
|
||||
<q-card-section class="text-center">
|
||||
<p>
|
||||
This is an LNURL-withdraw QR code for slurping everything from
|
||||
this wallet. Do not share with anyone.
|
||||
</p>
|
||||
<a href="lightning:{{wallet.lnurlwithdraw_full}}">
|
||||
<q-list>
|
||||
{% include "core/_api_docs.html" %}
|
||||
<q-separator></q-separator>
|
||||
|
||||
{% if wallet.lnurlwithdraw_full %}
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="crop_free"
|
||||
label="Drain Funds"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section class="text-center">
|
||||
<p>
|
||||
This is an LNURL-withdraw QR code for slurping everything
|
||||
from this wallet. Do not share with anyone.
|
||||
</p>
|
||||
<a href="lightning:{{wallet.lnurlwithdraw_full}}">
|
||||
<qrcode
|
||||
value="{{wallet.lnurlwithdraw_full}}"
|
||||
:options="{width:240}"
|
||||
></qrcode>
|
||||
</a>
|
||||
<p>
|
||||
It is compatible with <code>balanceCheck</code> and
|
||||
<code>balanceNotify</code> so your wallet may keep pulling
|
||||
the funds continuously from here after the first withdraw.
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-separator></q-separator>
|
||||
{% endif %}
|
||||
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="settings_cell"
|
||||
label="Export to Phone with QR Code"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section class="text-center">
|
||||
<p>
|
||||
This QR code contains your wallet URL with full access. You
|
||||
can scan it from your phone to open your wallet from there.
|
||||
</p>
|
||||
<qrcode
|
||||
value="{{wallet.lnurlwithdraw_full}}"
|
||||
:value="'{{request.base_url}}' +'wallet?usr={{user.id}}&wal={{wallet.id}}'"
|
||||
:options="{width:240}"
|
||||
></qrcode>
|
||||
</a>
|
||||
<p>
|
||||
It is compatible with <code>balanceCheck</code> and
|
||||
<code>balanceNotify</code> so your wallet may keep pulling the
|
||||
funds continuously from here after the first withdraw.
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-separator></q-separator>
|
||||
{% endif %}
|
||||
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="settings_cell"
|
||||
label="Export to Phone with QR Code"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section class="text-center">
|
||||
<p>
|
||||
This QR code contains your wallet URL with full access. You
|
||||
can scan it from your phone to open your wallet from there.
|
||||
</p>
|
||||
<qrcode
|
||||
:value="'{{request.base_url}}' +'wallet?usr={{user.id}}&wal={{wallet.id}}'"
|
||||
:options="{width:240}"
|
||||
></qrcode>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-separator></q-separator>
|
||||
<q-expansion-item group="extras" icon="edit" label="Rename wallet">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="" style="max-width: 320px">
|
||||
<q-input
|
||||
filled
|
||||
v-model.trim="newName"
|
||||
label="Label"
|
||||
dense="dense"
|
||||
@update:model-value="(e) => console.log(e)"
|
||||
/>
|
||||
</div>
|
||||
<q-btn
|
||||
:disable="!newName.length"
|
||||
unelevated
|
||||
class="q-mt-sm"
|
||||
color="primary"
|
||||
@click="updateWalletName()"
|
||||
>Update name</q-btn
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-separator></q-separator>
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="remove_circle"
|
||||
label="Delete wallet"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
This whole wallet will be deleted, the funds will be
|
||||
<strong>UNRECOVERABLE</strong>.
|
||||
</p>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="red-10"
|
||||
@click="deleteWallet('{{ wallet.id }}', '{{ user.id }}')"
|
||||
>Delete wallet</q-btn
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-separator></q-separator>
|
||||
<q-expansion-item group="extras" icon="edit" label="Rename wallet">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="" style="max-width: 320px">
|
||||
<q-input
|
||||
filled
|
||||
v-model.trim="newName"
|
||||
label="Label"
|
||||
dense="dense"
|
||||
@update:model-value="(e) => console.log(e)"
|
||||
/>
|
||||
</div>
|
||||
<q-btn
|
||||
:disable="!newName.length"
|
||||
unelevated
|
||||
class="q-mt-sm"
|
||||
color="primary"
|
||||
@click="updateWalletName()"
|
||||
>Update name</q-btn
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-separator></q-separator>
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="remove_circle"
|
||||
label="Delete wallet"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
This whole wallet will be deleted, the funds will be
|
||||
<strong>UNRECOVERABLE</strong>.
|
||||
</p>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="red-10"
|
||||
@click="deleteWallet('{{ wallet.id }}', '{{ user.id }}')"
|
||||
>Delete wallet</q-btn
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
{% endif %} {% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD =
|
||||
ADS.split(';') %}
|
||||
<q-card>
|
||||
<a href="{{ AD[0] }}"
|
||||
><img width="100%" src="{{ AD[1] }}"
|
||||
/></a> </q-card
|
||||
>{% endfor %} {% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="receive.show" @hide="closeReceiveDialog">
|
||||
{% raw %}
|
||||
<q-card
|
||||
v-if="!receive.paymentReq"
|
||||
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
||||
>
|
||||
<q-form @submit="createInvoice" class="q-gutter-md">
|
||||
<p v-if="receive.lnurl" class="text-h6 text-center q-my-none">
|
||||
<b>{{receive.lnurl.domain}}</b> is requesting an invoice:
|
||||
</p>
|
||||
{% endraw %} {% if LNBITS_DENOMINATION != 'sats' %}
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="receive.data.amount"
|
||||
label="Amount ({{LNBITS_DENOMINATION}}) *"
|
||||
mask="#.##"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
:min="receive.minMax[0]"
|
||||
:max="receive.minMax[1]"
|
||||
:readonly="receive.lnurl && receive.lnurl.fixed"
|
||||
></q-input>
|
||||
{% else %}
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="receive.unit"
|
||||
type="text"
|
||||
label="Unit"
|
||||
:options="receive.units"
|
||||
></q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="receive.data.amount"
|
||||
:label="'Amount (' + receive.unit + ') *'"
|
||||
:mask="receive.unit != 'sat' ? '#.##' : '#'"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
:step="receive.unit != 'sat' ? '0.01' : '1'"
|
||||
:min="receive.minMax[0]"
|
||||
:max="receive.minMax[1]"
|
||||
:readonly="receive.lnurl && receive.lnurl.fixed"
|
||||
></q-input>
|
||||
{% endif %}
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="receive.data.memo"
|
||||
label="Memo"
|
||||
></q-input>
|
||||
{% raw %}
|
||||
<div v-if="receive.status == 'pending'" class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="receive.data.amount == null || receive.data.amount <= 0"
|
||||
type="submit"
|
||||
>
|
||||
<span v-if="receive.lnurl">
|
||||
Withdraw from {{receive.lnurl.domain}}
|
||||
</span>
|
||||
<span v-else> Create invoice </span>
|
||||
</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||
</div>
|
||||
<q-spinner
|
||||
v-if="receive.status == 'loading'"
|
||||
color="primary"
|
||||
size="2.55em"
|
||||
></q-spinner>
|
||||
</q-form>
|
||||
</q-card>
|
||||
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<div class="text-center q-mb-lg">
|
||||
<a :href="'lightning:' + receive.paymentReq">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<qrcode
|
||||
:value="receive.paymentReq"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn outline color="grey" @click="copyText(receive.paymentReq)"
|
||||
>Copy invoice</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
{% endraw %}
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="parse.show" @hide="closeParseDialog">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<div v-if="parse.invoice">
|
||||
<h6 v-if="'{{LNBITS_DENOMINATION}}' != 'sats'" class="q-my-none">
|
||||
{% raw %} {{ parseFloat(String(parse.invoice.fsat).replaceAll(",", ""))
|
||||
/ 100 }} {% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
|
||||
</h6>
|
||||
<h6 v-else class="q-my-none">
|
||||
{{ parse.invoice.fsat }}{% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
|
||||
</h6>
|
||||
<q-separator class="q-my-sm"></q-separator>
|
||||
<p class="text-wrap">
|
||||
<strong>Description:</strong> {{ parse.invoice.description }}<br />
|
||||
<strong>Expire date:</strong> {{ parse.invoice.expireDate }}<br />
|
||||
<strong>Hash:</strong> {{ parse.invoice.hash }}
|
||||
</p>
|
||||
{% endraw %}
|
||||
<div v-if="canPay" class="row q-mt-lg">
|
||||
<q-btn unelevated color="primary" @click="payInvoice">Pay</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||
</div>
|
||||
<div v-else class="row q-mt-lg">
|
||||
<q-btn unelevated disabled color="yellow" text-color="black"
|
||||
>Not enough funds!</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="parse.lnurlauth">
|
||||
{% raw %}
|
||||
<q-form @submit="authLnurl" class="q-gutter-md">
|
||||
<p class="q-my-none text-h6">
|
||||
Authenticate with <b>{{ parse.lnurlauth.domain }}</b>?
|
||||
<q-dialog v-model="receive.show" @hide="closeReceiveDialog">
|
||||
{% raw %}
|
||||
<q-card
|
||||
v-if="!receive.paymentReq"
|
||||
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
||||
>
|
||||
<q-form @submit="createInvoice" class="q-gutter-md">
|
||||
<p v-if="receive.lnurl" class="text-h6 text-center q-my-none">
|
||||
<b>{{receive.lnurl.domain}}</b> is requesting an invoice:
|
||||
</p>
|
||||
<q-separator class="q-my-sm"></q-separator>
|
||||
<p>
|
||||
For every website and for every LNbits wallet, a new keypair will be
|
||||
deterministically generated so your identity can't be tied to your
|
||||
LNbits wallet or linked across websites. No other data will be shared
|
||||
with {{ parse.lnurlauth.domain }}.
|
||||
</p>
|
||||
<p>Your public key for <b>{{ parse.lnurlauth.domain }}</b> is:</p>
|
||||
<p class="q-mx-xl">
|
||||
<code class="text-wrap"> {{ parse.lnurlauth.pubkey }} </code>
|
||||
</p>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn unelevated color="primary" type="submit">Login</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
{% endraw %}
|
||||
</div>
|
||||
<div v-else-if="parse.lnurlpay">
|
||||
{% raw %}
|
||||
<q-form @submit="payLnurl" class="q-gutter-md">
|
||||
<p v-if="parse.lnurlpay.fixed" class="q-my-none text-h6">
|
||||
<b>{{ parse.lnurlpay.domain }}</b> is requesting {{
|
||||
parse.lnurlpay.maxSendable | msatoshiFormat }} {{LNBITS_DENOMINATION}}
|
||||
<span v-if="parse.lnurlpay.commentAllowed > 0">
|
||||
<br />
|
||||
and a {{parse.lnurlpay.commentAllowed}}-char comment
|
||||
</span>
|
||||
</p>
|
||||
<p v-else class="q-my-none text-h6 text-center">
|
||||
<b>{{ parse.lnurlpay.targetUser || parse.lnurlpay.domain }}</b> is
|
||||
requesting <br />
|
||||
between <b>{{ parse.lnurlpay.minSendable | msatoshiFormat }}</b> and
|
||||
<b>{{ parse.lnurlpay.maxSendable | msatoshiFormat }}</b>
|
||||
{% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
|
||||
<span v-if="parse.lnurlpay.commentAllowed > 0">
|
||||
<br />
|
||||
and a {{parse.lnurlpay.commentAllowed}}-char comment
|
||||
</span>
|
||||
</p>
|
||||
<q-separator class="q-my-sm"></q-separator>
|
||||
<div class="row">
|
||||
<p class="col text-justify text-italic">
|
||||
{{ parse.lnurlpay.description }}
|
||||
</p>
|
||||
<p class="col-4 q-pl-md" v-if="parse.lnurlpay.image">
|
||||
<q-img :src="parse.lnurlpay.image" />
|
||||
</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
{% endraw %}
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="parse.data.amount"
|
||||
type="number"
|
||||
label="Amount ({{LNBITS_DENOMINATION}}) *"
|
||||
:min="parse.lnurlpay.minSendable / 1000"
|
||||
:max="parse.lnurlpay.maxSendable / 1000"
|
||||
:readonly="parse.lnurlpay.fixed"
|
||||
></q-input>
|
||||
{% raw %}
|
||||
</div>
|
||||
<div class="col-8 q-pl-md" v-if="parse.lnurlpay.commentAllowed > 0">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="parse.data.comment"
|
||||
:type="parse.lnurlpay.commentAllowed > 64 ? 'textarea' : 'text'"
|
||||
label="Comment (optional)"
|
||||
:maxlength="parse.lnurlpay.commentAllowed"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn unelevated color="primary" type="submit"
|
||||
>Send {{LNBITS_DENOMINATION}}</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
{% endraw %}
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-form
|
||||
v-if="!parse.camera.show"
|
||||
@submit="decodeRequest"
|
||||
class="q-gutter-md"
|
||||
>
|
||||
{% endraw %} {% if LNBITS_DENOMINATION != 'sats' %}
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="parse.data.request"
|
||||
type="textarea"
|
||||
label="Paste an invoice, payment request or lnurl code *"
|
||||
>
|
||||
</q-input>
|
||||
<div class="row q-mt-lg">
|
||||
v-model.number="receive.data.amount"
|
||||
label="Amount ({{LNBITS_DENOMINATION}}) *"
|
||||
mask="#.##"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
:min="receive.minMax[0]"
|
||||
:max="receive.minMax[1]"
|
||||
:readonly="receive.lnurl && receive.lnurl.fixed"
|
||||
></q-input>
|
||||
{% else %}
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="receive.unit"
|
||||
type="text"
|
||||
label="Unit"
|
||||
:options="receive.units"
|
||||
></q-select>
|
||||
<q-input
|
||||
ref="setAmount"
|
||||
filled
|
||||
dense
|
||||
v-model.number="receive.data.amount"
|
||||
:label="'Amount (' + receive.unit + ') *'"
|
||||
:mask="receive.unit != 'sat' ? '#.##' : '#'"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
:step="receive.unit != 'sat' ? '0.01' : '1'"
|
||||
:min="receive.minMax[0]"
|
||||
:max="receive.minMax[1]"
|
||||
:readonly="receive.lnurl && receive.lnurl.fixed"
|
||||
></q-input>
|
||||
{% endif %}
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="receive.data.memo"
|
||||
label="Memo"
|
||||
></q-input>
|
||||
{% raw %}
|
||||
<div v-if="receive.status == 'pending'" class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="parse.data.request == ''"
|
||||
:disable="receive.data.amount == null || receive.data.amount <= 0"
|
||||
type="submit"
|
||||
>Read</q-btn
|
||||
>
|
||||
<span v-if="receive.lnurl">
|
||||
Withdraw from {{receive.lnurl.domain}}
|
||||
</span>
|
||||
<span v-else> Create invoice </span>
|
||||
</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
<q-spinner
|
||||
v-if="receive.status == 'loading'"
|
||||
color="primary"
|
||||
size="2.55em"
|
||||
></q-spinner>
|
||||
</q-form>
|
||||
</q-card>
|
||||
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<div class="text-center q-mb-lg">
|
||||
<a :href="'lightning:' + receive.paymentReq">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<qrcode
|
||||
:value="receive.paymentReq"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn outline color="grey" @click="copyText(receive.paymentReq)"
|
||||
>Copy invoice</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
{% endraw %}
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="parse.show" @hide="closeParseDialog">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<div v-if="parse.invoice">
|
||||
<h6 v-if="'{{LNBITS_DENOMINATION}}' != 'sats'" class="q-my-none">
|
||||
{% raw %} {{ parseFloat(String(parse.invoice.fsat).replaceAll(",",
|
||||
"")) / 100 }} {% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
|
||||
</h6>
|
||||
<h6 v-else class="q-my-none">
|
||||
{{ parse.invoice.fsat }}{% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
|
||||
</h6>
|
||||
<q-separator class="q-my-sm"></q-separator>
|
||||
<p class="text-wrap">
|
||||
<strong>Description:</strong> {{ parse.invoice.description }}<br />
|
||||
<strong>Expire date:</strong> {{ parse.invoice.expireDate }}<br />
|
||||
<strong>Hash:</strong> {{ parse.invoice.hash }}
|
||||
</p>
|
||||
{% endraw %}
|
||||
<div v-if="canPay" class="row q-mt-lg">
|
||||
<q-btn unelevated color="primary" @click="payInvoice">Pay</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
<div v-else class="row q-mt-lg">
|
||||
<q-btn unelevated disabled color="yellow" text-color="black"
|
||||
>Not enough funds!</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</div>
|
||||
<div v-else-if="parse.lnurlauth">
|
||||
{% raw %}
|
||||
<q-form @submit="authLnurl" class="q-gutter-md">
|
||||
<p class="q-my-none text-h6">
|
||||
Authenticate with <b>{{ parse.lnurlauth.domain }}</b>?
|
||||
</p>
|
||||
<q-separator class="q-my-sm"></q-separator>
|
||||
<p>
|
||||
For every website and for every LNbits wallet, a new keypair will be
|
||||
deterministically generated so your identity can't be tied to your
|
||||
LNbits wallet or linked across websites. No other data will be
|
||||
shared with {{ parse.lnurlauth.domain }}.
|
||||
</p>
|
||||
<p>Your public key for <b>{{ parse.lnurlauth.domain }}</b> is:</p>
|
||||
<p class="q-mx-xl">
|
||||
<code class="text-wrap"> {{ parse.lnurlauth.pubkey }} </code>
|
||||
</p>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn unelevated color="primary" type="submit">Login</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
{% endraw %}
|
||||
</div>
|
||||
<div v-else-if="parse.lnurlpay">
|
||||
{% raw %}
|
||||
<q-form @submit="payLnurl" class="q-gutter-md">
|
||||
<p v-if="parse.lnurlpay.fixed" class="q-my-none text-h6">
|
||||
<b>{{ parse.lnurlpay.domain }}</b> is requesting {{
|
||||
parse.lnurlpay.maxSendable | msatoshiFormat }}
|
||||
{{LNBITS_DENOMINATION}}
|
||||
<span v-if="parse.lnurlpay.commentAllowed > 0">
|
||||
<br />
|
||||
and a {{parse.lnurlpay.commentAllowed}}-char comment
|
||||
</span>
|
||||
</p>
|
||||
<p v-else class="q-my-none text-h6 text-center">
|
||||
<b>{{ parse.lnurlpay.targetUser || parse.lnurlpay.domain }}</b> is
|
||||
requesting <br />
|
||||
between <b>{{ parse.lnurlpay.minSendable | msatoshiFormat }}</b> and
|
||||
<b>{{ parse.lnurlpay.maxSendable | msatoshiFormat }}</b>
|
||||
{% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
|
||||
<span v-if="parse.lnurlpay.commentAllowed > 0">
|
||||
<br />
|
||||
and a {{parse.lnurlpay.commentAllowed}}-char comment
|
||||
</span>
|
||||
</p>
|
||||
<q-separator class="q-my-sm"></q-separator>
|
||||
<div class="row">
|
||||
<p class="col text-justify text-italic">
|
||||
{{ parse.lnurlpay.description }}
|
||||
</p>
|
||||
<p class="col-4 q-pl-md" v-if="parse.lnurlpay.image">
|
||||
<q-img :src="parse.lnurlpay.image" />
|
||||
</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
{% endraw %}
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="parse.data.amount"
|
||||
type="number"
|
||||
label="Amount ({{LNBITS_DENOMINATION}}) *"
|
||||
:min="parse.lnurlpay.minSendable / 1000"
|
||||
:max="parse.lnurlpay.maxSendable / 1000"
|
||||
:readonly="parse.lnurlpay.fixed"
|
||||
></q-input>
|
||||
{% raw %}
|
||||
</div>
|
||||
<div class="col-8 q-pl-md" v-if="parse.lnurlpay.commentAllowed > 0">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="parse.data.comment"
|
||||
:type="parse.lnurlpay.commentAllowed > 64 ? 'textarea' : 'text'"
|
||||
label="Comment (optional)"
|
||||
:maxlength="parse.lnurlpay.commentAllowed"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn unelevated color="primary" type="submit"
|
||||
>Send {{LNBITS_DENOMINATION}}</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
{% endraw %}
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-responsive :ratio="1">
|
||||
<qrcode-stream
|
||||
@decode="decodeQR"
|
||||
class="rounded-borders"
|
||||
></qrcode-stream>
|
||||
</q-responsive>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto">
|
||||
Cancel
|
||||
</q-btn>
|
||||
<q-form
|
||||
v-if="!parse.camera.show"
|
||||
@submit="decodeRequest"
|
||||
class="q-gutter-md"
|
||||
>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="parse.data.request"
|
||||
type="textarea"
|
||||
label="Paste an invoice, payment request or lnurl code *"
|
||||
>
|
||||
</q-input>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="parse.data.request == ''"
|
||||
type="submit"
|
||||
>Read</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
<div v-else>
|
||||
<q-responsive :ratio="1">
|
||||
<qrcode-stream
|
||||
@decode="decodeQR"
|
||||
class="rounded-borders"
|
||||
></qrcode-stream>
|
||||
</q-responsive>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto">
|
||||
Cancel
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="parse.camera.show">
|
||||
<q-card class="q-pa-lg q-pt-xl">
|
||||
<div class="text-center q-mb-lg">
|
||||
<qrcode-stream @decode="decodeQR" class="rounded-borders"></qrcode-stream>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<q-dialog v-model="parse.camera.show">
|
||||
<q-card class="q-pa-lg q-pt-xl">
|
||||
<div class="text-center q-mb-lg">
|
||||
<qrcode-stream
|
||||
@decode="decodeQR"
|
||||
class="rounded-borders"
|
||||
></qrcode-stream>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="paymentsChart.show">
|
||||
<q-card class="q-pa-sm" style="width: 800px; max-width: unset">
|
||||
<q-card-section>
|
||||
<canvas ref="canvas" width="600" height="400"></canvas>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<q-tabs
|
||||
class="lt-md fixed-bottom left-0 right-0 bg-primary text-white shadow-2 z-max"
|
||||
active-class="px-0"
|
||||
indicator-color="transparent"
|
||||
>
|
||||
<q-tab
|
||||
icon="account_balance_wallet"
|
||||
label="Wallets"
|
||||
@click="g.visibleDrawer = !g.visibleDrawer"
|
||||
<q-dialog v-model="paymentsChart.show">
|
||||
<q-card class="q-pa-sm" style="width: 800px; max-width: unset">
|
||||
<q-card-section>
|
||||
<canvas ref="canvas" width="600" height="400"></canvas>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<q-tabs
|
||||
class="lt-md fixed-bottom left-0 right-0 bg-primary text-white shadow-2 z-top"
|
||||
active-class="px-0"
|
||||
indicator-color="transparent"
|
||||
>
|
||||
</q-tab>
|
||||
<q-tab icon="content_paste" label="Paste" @click="showParseDialog"> </q-tab>
|
||||
<q-tab icon="file_download" label="Receive" @click="showReceiveDialog">
|
||||
</q-tab>
|
||||
<q-tab
|
||||
icon="account_balance_wallet"
|
||||
label="Wallets"
|
||||
@click="g.visibleDrawer = !g.visibleDrawer"
|
||||
>
|
||||
</q-tab>
|
||||
<q-tab icon="content_paste" label="Paste" @click="showParseDialog"> </q-tab>
|
||||
<q-tab icon="file_download" label="Receive" @click="showReceiveDialog">
|
||||
</q-tab>
|
||||
|
||||
<q-tab icon="photo_camera" label="Scan" @click="showCamera"> </q-tab>
|
||||
</q-tabs>
|
||||
{% if service_fee > 0 %}
|
||||
<div ref="disclaimer"></div>
|
||||
<q-dialog v-model="disclaimerDialog.show">
|
||||
<q-card class="q-pa-lg">
|
||||
<h6 class="q-my-md text-deep-purple">Warning</h6>
|
||||
<p>
|
||||
Login functionality to be released in v0.2, for now,
|
||||
<strong
|
||||
>make sure you bookmark this page for future access to your
|
||||
wallet</strong
|
||||
>!
|
||||
</p>
|
||||
<p>
|
||||
This service is in BETA, and we hold no responsibility for people losing
|
||||
access to funds. To encourage you to run your own LNbits installation, any
|
||||
balance on {% raw %}{{ disclaimerDialog.location.host }}{% endraw %} will
|
||||
incur a charge of <strong>{{ service_fee }}% service fee</strong> per
|
||||
week.
|
||||
</p>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(disclaimerDialog.location.href)"
|
||||
>Copy wallet URL</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>I understand</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
{% endif %} {% endblock %}
|
||||
<q-tab icon="photo_camera" label="Scan" @click="showCamera"> </q-tab>
|
||||
</q-tabs>
|
||||
|
||||
<q-dialog v-model="disclaimerDialog.show">
|
||||
<q-card class="q-pa-lg">
|
||||
<h6 class="q-my-md text-primary">Warning</h6>
|
||||
<p>
|
||||
Login functionality to be released in a future update, for now,
|
||||
<strong
|
||||
>make sure you bookmark this page for future access to your
|
||||
wallet</strong
|
||||
>!
|
||||
</p>
|
||||
<p>
|
||||
This service is in BETA, and we hold no responsibility for people losing
|
||||
access to funds. {% if service_fee > 0 %} To encourage you to run your
|
||||
own LNbits installation, any balance on {% raw %}{{
|
||||
disclaimerDialog.location.host }}{% endraw %} will incur a charge of
|
||||
<strong>{{ service_fee }}% service fee</strong> per week. {% endif %}
|
||||
</p>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(disclaimerDialog.location.href)"
|
||||
>Copy wallet URL</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>I understand</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
+170
-76
@@ -1,59 +1,59 @@
|
||||
import asyncio
|
||||
import binascii
|
||||
import hashlib
|
||||
import json
|
||||
from binascii import unhexlify
|
||||
from http import HTTPStatus
|
||||
from typing import Dict, List, Optional, Union
|
||||
from io import BytesIO
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
import httpx
|
||||
from fastapi import Query, Request
|
||||
import pyqrcode
|
||||
from fastapi import Depends, Header, Query, Request
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.param_functions import Depends
|
||||
from fastapi.params import Body
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
from pydantic.fields import Field
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
from starlette.responses import HTMLResponse, StreamingResponse
|
||||
|
||||
from lnbits import bolt11, lnurl
|
||||
from lnbits.bolt11 import Invoice
|
||||
from lnbits.core.models import Payment, Wallet
|
||||
from lnbits.decorators import (
|
||||
WalletAdminKeyChecker,
|
||||
WalletInvoiceKeyChecker,
|
||||
WalletTypeInfo,
|
||||
get_key_type,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import url_for
|
||||
from lnbits.requestvars import g
|
||||
from lnbits.helpers import url_for, urlsafe_short_hash
|
||||
from lnbits.settings import LNBITS_ADMIN_USERS, LNBITS_SITE_TITLE
|
||||
from lnbits.utils.exchange_rates import (
|
||||
currencies,
|
||||
fiat_amount_as_satoshis,
|
||||
satoshis_amount_as_fiat,
|
||||
)
|
||||
from lnbits.settings import LNBITS_SITE_TITLE
|
||||
|
||||
from .. import core_app, db
|
||||
from ..crud import (
|
||||
create_payment,
|
||||
get_payments,
|
||||
get_standalone_payment,
|
||||
save_balance_check,
|
||||
update_wallet,
|
||||
create_payment,
|
||||
get_wallet,
|
||||
get_wallet_for_key,
|
||||
save_balance_check,
|
||||
update_payment_status,
|
||||
update_wallet,
|
||||
)
|
||||
from ..services import (
|
||||
InvoiceFailure,
|
||||
PaymentFailure,
|
||||
check_invoice_status,
|
||||
check_transaction_status,
|
||||
create_invoice,
|
||||
pay_invoice,
|
||||
perform_lnurlauth,
|
||||
)
|
||||
from ..tasks import api_invoice_listeners
|
||||
from lnbits.settings import LNBITS_ADMIN_USERS
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
|
||||
@core_app.get("/api/v1/wallet")
|
||||
@@ -99,7 +99,7 @@ async def api_update_balance(
|
||||
|
||||
@core_app.put("/api/v1/wallet/{new_name}")
|
||||
async def api_update_wallet(
|
||||
new_name: str, wallet: WalletTypeInfo = Depends(WalletAdminKeyChecker())
|
||||
new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
await update_wallet(wallet.wallet.id, new_name)
|
||||
return {
|
||||
@@ -110,41 +110,75 @@ async def api_update_wallet(
|
||||
|
||||
|
||||
@core_app.get("/api/v1/payments")
|
||||
async def api_payments(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
await get_payments(wallet_id=wallet.wallet.id, pending=True, complete=True)
|
||||
async def api_payments(
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = None,
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
):
|
||||
pendingPayments = await get_payments(
|
||||
wallet_id=wallet.wallet.id, pending=True, exclude_uncheckable=True
|
||||
wallet_id=wallet.wallet.id,
|
||||
pending=True,
|
||||
exclude_uncheckable=True,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
for payment in pendingPayments:
|
||||
await check_invoice_status(
|
||||
await check_transaction_status(
|
||||
wallet_id=payment.wallet_id, payment_hash=payment.payment_hash
|
||||
)
|
||||
return await get_payments(wallet_id=wallet.wallet.id, pending=True, complete=True)
|
||||
return await get_payments(
|
||||
wallet_id=wallet.wallet.id,
|
||||
pending=True,
|
||||
complete=True,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
class CreateInvoiceData(BaseModel):
|
||||
out: Optional[bool] = True
|
||||
amount: float = Query(None, ge=0)
|
||||
memo: str = None
|
||||
memo: Optional[str] = None
|
||||
unit: Optional[str] = "sat"
|
||||
description_hash: Optional[str] = None
|
||||
unhashed_description: Optional[str] = None
|
||||
lnurl_callback: Optional[str] = None
|
||||
lnurl_balance_check: Optional[str] = None
|
||||
extra: Optional[dict] = None
|
||||
webhook: Optional[str] = None
|
||||
internal: Optional[bool] = False
|
||||
bolt11: Optional[str] = None
|
||||
|
||||
|
||||
async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||
if data.description_hash:
|
||||
description_hash = unhexlify(data.description_hash)
|
||||
try:
|
||||
description_hash = binascii.unhexlify(data.description_hash)
|
||||
except binascii.Error:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="'description_hash' must be a valid hex string",
|
||||
)
|
||||
unhashed_description = b""
|
||||
memo = ""
|
||||
elif data.unhashed_description:
|
||||
try:
|
||||
unhashed_description = binascii.unhexlify(data.unhashed_description)
|
||||
except binascii.Error:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="'unhashed_description' must be a valid hex string",
|
||||
)
|
||||
description_hash = b""
|
||||
memo = ""
|
||||
else:
|
||||
description_hash = b""
|
||||
unhashed_description = b""
|
||||
memo = data.memo or LNBITS_SITE_TITLE
|
||||
if data.unit == "sat":
|
||||
amount = int(data.amount)
|
||||
else:
|
||||
assert data.unit is not None, "unit not set"
|
||||
price_in_sats = await fiat_amount_as_satoshis(data.amount, data.unit)
|
||||
amount = price_in_sats
|
||||
|
||||
@@ -155,8 +189,10 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||
amount=amount,
|
||||
memo=memo,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
extra=data.extra,
|
||||
webhook=data.webhook,
|
||||
internal=data.internal,
|
||||
conn=conn,
|
||||
)
|
||||
except InvoiceFailure as e:
|
||||
@@ -168,8 +204,8 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||
|
||||
lnurl_response: Union[None, bool, str] = None
|
||||
if data.lnurl_callback:
|
||||
if "lnurl_balance_check" in data:
|
||||
save_balance_check(wallet.id, data.lnurl_balance_check)
|
||||
if data.lnurl_balance_check is not None:
|
||||
await save_balance_check(wallet.id, data.lnurl_balance_check)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
@@ -226,17 +262,12 @@ async def api_payments_pay_invoice(bolt11: str, wallet: Wallet):
|
||||
|
||||
@core_app.post(
|
||||
"/api/v1/payments",
|
||||
# deprecated=True,
|
||||
# description="DEPRECATED. Use /api/v2/TBD and /api/v2/TBD instead",
|
||||
status_code=HTTPStatus.CREATED,
|
||||
)
|
||||
async def api_payments_create(
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
invoiceData: CreateInvoiceData = Body(...),
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
invoiceData: CreateInvoiceData = Body(...), # type: ignore
|
||||
):
|
||||
if wallet.wallet_type < 0 or wallet.wallet_type > 2:
|
||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Key is invalid")
|
||||
|
||||
if invoiceData.out is True and wallet.wallet_type == 0:
|
||||
if not invoiceData.bolt11:
|
||||
raise HTTPException(
|
||||
@@ -246,8 +277,14 @@ async def api_payments_create(
|
||||
return await api_payments_pay_invoice(
|
||||
invoiceData.bolt11, wallet.wallet
|
||||
) # admin key
|
||||
# invoice key
|
||||
return await api_payments_create_invoice(invoiceData, wallet.wallet)
|
||||
elif not invoiceData.out:
|
||||
# invoice key
|
||||
return await api_payments_create_invoice(invoiceData, wallet.wallet)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Invoice (or Admin) key required.",
|
||||
)
|
||||
|
||||
|
||||
class CreateLNURLData(BaseModel):
|
||||
@@ -260,7 +297,7 @@ class CreateLNURLData(BaseModel):
|
||||
|
||||
@core_app.post("/api/v1/payments/lnurl")
|
||||
async def api_payments_pay_lnurl(
|
||||
data: CreateLNURLData, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
data: CreateLNURLData, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
domain = urlparse(data.callback).netloc
|
||||
|
||||
@@ -272,7 +309,7 @@ async def api_payments_pay_lnurl(
|
||||
timeout=40,
|
||||
)
|
||||
if r.is_error:
|
||||
raise httpx.ConnectError
|
||||
raise httpx.ConnectError("LNURL callback connection error")
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
@@ -286,6 +323,12 @@ async def api_payments_pay_lnurl(
|
||||
detail=f"{domain} said: '{params.get('reason', '')}'",
|
||||
)
|
||||
|
||||
if not params.get("pr"):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} did not return a payment request.",
|
||||
)
|
||||
|
||||
invoice = bolt11.decode(params["pr"])
|
||||
if invoice.amount_msat != data.amount:
|
||||
raise HTTPException(
|
||||
@@ -293,11 +336,11 @@ async def api_payments_pay_lnurl(
|
||||
detail=f"{domain} returned an invalid invoice. Expected {data.amount} msat, got {invoice.amount_msat}.",
|
||||
)
|
||||
|
||||
# if invoice.description_hash != data.description_hash:
|
||||
# raise HTTPException(
|
||||
# status_code=HTTPStatus.BAD_REQUEST,
|
||||
# detail=f"{domain} returned an invalid invoice. Expected description_hash == {data.description_hash}, got {invoice.description_hash}.",
|
||||
# )
|
||||
if invoice.description_hash != data.description_hash:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} returned an invalid invoice. Expected description_hash == {data.description_hash}, got {invoice.description_hash}.",
|
||||
)
|
||||
|
||||
extra = {}
|
||||
|
||||
@@ -305,7 +348,7 @@ async def api_payments_pay_lnurl(
|
||||
extra["success_action"] = params["successAction"]
|
||||
if data.comment:
|
||||
extra["comment"] = data.comment
|
||||
|
||||
assert data.description is not None, "description is required"
|
||||
payment_hash = await pay_invoice(
|
||||
wallet_id=wallet.wallet.id,
|
||||
payment_request=params["pr"],
|
||||
@@ -322,19 +365,20 @@ async def api_payments_pay_lnurl(
|
||||
|
||||
|
||||
async def subscribe(request: Request, wallet: Wallet):
|
||||
this_wallet_id = wallet.wallet.id
|
||||
this_wallet_id = wallet.id
|
||||
|
||||
payment_queue = asyncio.Queue(0)
|
||||
payment_queue: asyncio.Queue[Payment] = asyncio.Queue(0)
|
||||
|
||||
print("adding sse listener", payment_queue)
|
||||
logger.debug("adding sse listener", payment_queue)
|
||||
api_invoice_listeners.append(payment_queue)
|
||||
|
||||
send_queue = asyncio.Queue(0)
|
||||
send_queue: asyncio.Queue[Tuple[str, Payment]] = asyncio.Queue(0)
|
||||
|
||||
async def payment_received() -> None:
|
||||
while True:
|
||||
payment: Payment = await payment_queue.get()
|
||||
if payment.wallet_id == this_wallet_id:
|
||||
logger.debug("payment received", payment)
|
||||
await send_queue.put(("payment-received", payment))
|
||||
|
||||
asyncio.create_task(payment_received())
|
||||
@@ -359,34 +403,56 @@ async def api_payments_sse(
|
||||
request: Request, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
return EventSourceResponse(
|
||||
subscribe(request, wallet), ping=20, media_type="text/event-stream"
|
||||
subscribe(request, wallet.wallet), ping=20, media_type="text/event-stream"
|
||||
)
|
||||
|
||||
|
||||
@core_app.get("/api/v1/payments/{payment_hash}")
|
||||
async def api_payment(payment_hash):
|
||||
payment = await get_standalone_payment(payment_hash)
|
||||
await check_invoice_status(payment.wallet_id, payment_hash)
|
||||
payment = await get_standalone_payment(payment_hash)
|
||||
async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
|
||||
# We use X_Api_Key here because we want this call to work with and without keys
|
||||
# If a valid key is given, we also return the field "details", otherwise not
|
||||
wallet = await get_wallet_for_key(X_Api_Key) if type(X_Api_Key) == str else None
|
||||
|
||||
# we have to specify the wallet id here, because postgres and sqlite return internal payments in different order
|
||||
# and get_standalone_payment otherwise just fetches the first one, causing unpredictable results
|
||||
payment = await get_standalone_payment(
|
||||
payment_hash, wallet_id=wallet.id if wallet else None
|
||||
)
|
||||
if payment is None:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
|
||||
)
|
||||
await check_transaction_status(payment.wallet_id, payment_hash)
|
||||
payment = await get_standalone_payment(
|
||||
payment_hash, wallet_id=wallet.id if wallet else None
|
||||
)
|
||||
if not payment:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
|
||||
)
|
||||
elif not payment.pending:
|
||||
if wallet and wallet.id == payment.wallet_id:
|
||||
return {"paid": True, "preimage": payment.preimage, "details": payment}
|
||||
return {"paid": True, "preimage": payment.preimage}
|
||||
|
||||
try:
|
||||
await payment.check_pending()
|
||||
await payment.check_status()
|
||||
except Exception:
|
||||
if wallet and wallet.id == payment.wallet_id:
|
||||
return {"paid": False, "details": payment}
|
||||
return {"paid": False}
|
||||
|
||||
if wallet and wallet.id == payment.wallet_id:
|
||||
return {
|
||||
"paid": not payment.pending,
|
||||
"preimage": payment.preimage,
|
||||
"details": payment,
|
||||
}
|
||||
return {"paid": not payment.pending, "preimage": payment.preimage}
|
||||
|
||||
|
||||
@core_app.get(
|
||||
"/api/v1/lnurlscan/{code}", dependencies=[Depends(WalletInvoiceKeyChecker())]
|
||||
)
|
||||
async def api_lnurlscan(code: str):
|
||||
@core_app.get("/api/v1/lnurlscan/{code}")
|
||||
async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
try:
|
||||
url = lnurl.decode(code)
|
||||
domain = urlparse(url).netloc
|
||||
@@ -414,7 +480,7 @@ async def api_lnurlscan(code: str):
|
||||
params.update(kind="auth")
|
||||
params.update(callback=url) # with k1 already in it
|
||||
|
||||
lnurlauth_key = g().wallet.lnurlauth_key(domain)
|
||||
lnurlauth_key = wallet.wallet.lnurlauth_key(domain)
|
||||
params.update(pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex())
|
||||
else:
|
||||
async with httpx.AsyncClient() as client:
|
||||
@@ -437,7 +503,8 @@ async def api_lnurlscan(code: str):
|
||||
)
|
||||
|
||||
try:
|
||||
tag = data["tag"]
|
||||
tag: str = data.get("tag")
|
||||
params.update(**data)
|
||||
if tag == "channelRequest":
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
@@ -447,10 +514,7 @@ async def api_lnurlscan(code: str):
|
||||
"message": "unsupported",
|
||||
},
|
||||
)
|
||||
|
||||
params.update(**data)
|
||||
|
||||
if tag == "withdrawRequest":
|
||||
elif tag == "withdrawRequest":
|
||||
params.update(kind="withdraw")
|
||||
params.update(fixed=data["minWithdrawable"] == data["maxWithdrawable"])
|
||||
|
||||
@@ -468,8 +532,7 @@ async def api_lnurlscan(code: str):
|
||||
query=urlencode(qs, doseq=True)
|
||||
)
|
||||
params.update(callback=urlunparse(parsed_callback))
|
||||
|
||||
if tag == "payRequest":
|
||||
elif tag == "payRequest":
|
||||
params.update(kind="pay")
|
||||
params.update(fixed=data["minSendable"] == data["maxSendable"])
|
||||
|
||||
@@ -487,8 +550,8 @@ async def api_lnurlscan(code: str):
|
||||
params.update(image=data_uri)
|
||||
if k == "text/email" or k == "text/identifier":
|
||||
params.update(targetUser=v)
|
||||
|
||||
params.update(commentAllowed=data.get("commentAllowed", 0))
|
||||
|
||||
except KeyError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
@@ -501,14 +564,19 @@ async def api_lnurlscan(code: str):
|
||||
return params
|
||||
|
||||
|
||||
class DecodePayment(BaseModel):
|
||||
data: str
|
||||
|
||||
|
||||
@core_app.post("/api/v1/payments/decode")
|
||||
async def api_payments_decode(data: str = Query(None)):
|
||||
async def api_payments_decode(data: DecodePayment):
|
||||
payment_str = data.data
|
||||
try:
|
||||
if data["data"][:5] == "LNURL":
|
||||
url = lnurl.decode(data["data"])
|
||||
if payment_str[:5] == "LNURL":
|
||||
url = lnurl.decode(payment_str)
|
||||
return {"domain": url}
|
||||
else:
|
||||
invoice = bolt11.decode(data["data"])
|
||||
invoice = bolt11.decode(payment_str)
|
||||
return {
|
||||
"payment_hash": invoice.payment_hash,
|
||||
"amount_msat": invoice.amount_msat,
|
||||
@@ -525,14 +593,19 @@ async def api_payments_decode(data: str = Query(None)):
|
||||
return {"message": "Failed to decode"}
|
||||
|
||||
|
||||
@core_app.post("/api/v1/lnurlauth", dependencies=[Depends(WalletAdminKeyChecker())])
|
||||
async def api_perform_lnurlauth(callback: str):
|
||||
err = await perform_lnurlauth(callback)
|
||||
class Callback(BaseModel):
|
||||
callback: str = Query(...)
|
||||
|
||||
|
||||
@core_app.post("/api/v1/lnurlauth")
|
||||
async def api_perform_lnurlauth(
|
||||
callback: Callback, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
err = await perform_lnurlauth(callback.callback, wallet=wallet)
|
||||
if err:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=err.reason
|
||||
)
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
@@ -551,8 +624,8 @@ class ConversionData(BaseModel):
|
||||
async def api_fiat_as_sats(data: ConversionData):
|
||||
output = {}
|
||||
if data.from_ == "sat":
|
||||
output["sats"] = int(data.amount)
|
||||
output["BTC"] = data.amount / 100000000
|
||||
output["sats"] = int(data.amount)
|
||||
for currency in data.to.split(","):
|
||||
output[currency.strip().upper()] = await satoshis_amount_as_fiat(
|
||||
data.amount, currency.strip()
|
||||
@@ -560,6 +633,27 @@ async def api_fiat_as_sats(data: ConversionData):
|
||||
return output
|
||||
else:
|
||||
output[data.from_.upper()] = data.amount
|
||||
output["sats"] = await fiat_amount_as_satoshis(data.amount, data.to)
|
||||
output["sats"] = await fiat_amount_as_satoshis(data.amount, data.from_)
|
||||
output["BTC"] = output["sats"] / 100000000
|
||||
return output
|
||||
|
||||
|
||||
@core_app.get("/api/v1/qrcode/{data}", response_class=StreamingResponse)
|
||||
async def img(request: Request, data):
|
||||
qr = pyqrcode.create(data)
|
||||
stream = BytesIO()
|
||||
qr.svg(stream, scale=3)
|
||||
stream.seek(0)
|
||||
|
||||
async def _generator(stream: BytesIO):
|
||||
yield stream.getvalue()
|
||||
|
||||
return StreamingResponse(
|
||||
_generator(stream),
|
||||
headers={
|
||||
"Content-Type": "image/svg+xml",
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi.exceptions import HTTPException
|
||||
from fastapi.params import Depends, Query
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from fastapi.routing import APIRouter
|
||||
from loguru import logger
|
||||
from pydantic.types import UUID4
|
||||
from starlette.responses import HTMLResponse, JSONResponse
|
||||
|
||||
@@ -17,10 +18,12 @@ from lnbits.helpers import template_renderer, url_for
|
||||
from lnbits.settings import (
|
||||
LNBITS_ADMIN_USERS,
|
||||
LNBITS_ALLOWED_USERS,
|
||||
LNBITS_CUSTOM_LOGO,
|
||||
LNBITS_SITE_TITLE,
|
||||
SERVICE_FEE,
|
||||
)
|
||||
|
||||
from ...helpers import get_valid_extensions
|
||||
from ..crud import (
|
||||
create_account,
|
||||
create_wallet,
|
||||
@@ -52,9 +55,9 @@ async def home(request: Request, lightning: str = None):
|
||||
)
|
||||
async def extensions(
|
||||
request: Request,
|
||||
user: User = Depends(check_user_exists),
|
||||
enable: str = Query(None),
|
||||
disable: str = Query(None),
|
||||
user: User = Depends(check_user_exists), # type: ignore
|
||||
enable: str = Query(None), # type: ignore
|
||||
disable: str = Query(None), # type: ignore
|
||||
):
|
||||
extension_to_enable = enable
|
||||
extension_to_disable = disable
|
||||
@@ -64,18 +67,28 @@ async def extensions(
|
||||
HTTPStatus.BAD_REQUEST, "You can either `enable` or `disable` an extension."
|
||||
)
|
||||
|
||||
# check if extension exists
|
||||
if extension_to_enable or extension_to_disable:
|
||||
ext = extension_to_enable or extension_to_disable
|
||||
if ext not in [e.code for e in get_valid_extensions()]:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST, f"Extension '{ext}' doesn't exist."
|
||||
)
|
||||
|
||||
if extension_to_enable:
|
||||
logger.info(f"Enabling extension: {extension_to_enable} for user {user.id}")
|
||||
await update_user_extension(
|
||||
user_id=user.id, extension=extension_to_enable, active=True
|
||||
)
|
||||
elif extension_to_disable:
|
||||
logger.info(f"Disabling extension: {extension_to_disable} for user {user.id}")
|
||||
await update_user_extension(
|
||||
user_id=user.id, extension=extension_to_disable, active=False
|
||||
)
|
||||
|
||||
# Update user as his extensions have been updated
|
||||
if extension_to_enable or extension_to_disable:
|
||||
user = await get_user(user.id)
|
||||
user = await get_user(user.id) # type: ignore
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
"core/extensions.html", {"request": request, "user": user.dict()}
|
||||
@@ -96,10 +109,10 @@ nothing: create everything<br>
|
||||
""",
|
||||
)
|
||||
async def wallet(
|
||||
request: Request = Query(None),
|
||||
nme: Optional[str] = Query(None),
|
||||
usr: Optional[UUID4] = Query(None),
|
||||
wal: Optional[UUID4] = Query(None),
|
||||
request: Request = Query(None), # type: ignore
|
||||
nme: Optional[str] = Query(None), # type: ignore
|
||||
usr: Optional[UUID4] = Query(None), # type: ignore
|
||||
wal: Optional[UUID4] = Query(None), # type: ignore
|
||||
):
|
||||
user_id = usr.hex if usr else None
|
||||
wallet_id = wal.hex if wal else None
|
||||
@@ -108,6 +121,7 @@ async def wallet(
|
||||
|
||||
if not user_id:
|
||||
user = await get_user((await create_account()).id)
|
||||
logger.info(f"Create user {user.id}") # type: ignore
|
||||
else:
|
||||
user = await get_user(user_id)
|
||||
if not user:
|
||||
@@ -121,18 +135,24 @@ async def wallet(
|
||||
if LNBITS_ADMIN_USERS and user_id in LNBITS_ADMIN_USERS:
|
||||
user.admin = True
|
||||
if not wallet_id:
|
||||
if user.wallets and not wallet_name:
|
||||
wallet = user.wallets[0]
|
||||
if user.wallets and not wallet_name: # type: ignore
|
||||
wallet = user.wallets[0] # type: ignore
|
||||
else:
|
||||
wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name)
|
||||
wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name) # type: ignore
|
||||
logger.info(
|
||||
f"Created new wallet {wallet_name if wallet_name else '(no name)'} for user {user.id}" # type: ignore
|
||||
)
|
||||
|
||||
return RedirectResponse(
|
||||
f"/wallet?usr={user.id}&wal={wallet.id}",
|
||||
f"/wallet?usr={user.id}&wal={wallet.id}", # type: ignore
|
||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||
)
|
||||
|
||||
wallet = user.get_wallet(wallet_id)
|
||||
if not wallet:
|
||||
logger.debug(
|
||||
f"Access {'user '+ user.id + ' ' if user else ''} {'wallet ' + wallet_name if wallet_name else ''}"
|
||||
)
|
||||
userwallet = user.get_wallet(wallet_id) # type: ignore
|
||||
if not userwallet:
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": "Wallet not found"}
|
||||
)
|
||||
@@ -141,9 +161,10 @@ async def wallet(
|
||||
"core/wallet.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user.dict(),
|
||||
"wallet": wallet.dict(),
|
||||
"user": user.dict(), # type: ignore
|
||||
"wallet": userwallet.dict(),
|
||||
"service_fee": service_fee,
|
||||
"web_manifest": f"/manifest/{user.id}.webmanifest", # type: ignore
|
||||
},
|
||||
)
|
||||
|
||||
@@ -197,20 +218,20 @@ async def lnurl_full_withdraw_callback(request: Request):
|
||||
|
||||
|
||||
@core_html_routes.get("/deletewallet", response_class=RedirectResponse)
|
||||
async def deletewallet(request: Request, wal: str = Query(...), usr: str = Query(...)):
|
||||
async def deletewallet(request: Request, wal: str = Query(...), usr: str = Query(...)): # type: ignore
|
||||
user = await get_user(usr)
|
||||
user_wallet_ids = [u.id for u in user.wallets]
|
||||
print("USR", user_wallet_ids)
|
||||
user_wallet_ids = [u.id for u in user.wallets] # type: ignore
|
||||
|
||||
if wal not in user_wallet_ids:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
|
||||
else:
|
||||
await delete_wallet(user_id=user.id, wallet_id=wal)
|
||||
await delete_wallet(user_id=user.id, wallet_id=wal) # type: ignore
|
||||
user_wallet_ids.remove(wal)
|
||||
logger.debug("Deleted wallet {wal} of user {user.id}")
|
||||
|
||||
if user_wallet_ids:
|
||||
return RedirectResponse(
|
||||
url_for("/wallet", usr=user.id, wal=user_wallet_ids[0]),
|
||||
url_for("/wallet", usr=user.id, wal=user_wallet_ids[0]), # type: ignore
|
||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||
)
|
||||
|
||||
@@ -223,15 +244,17 @@ async def deletewallet(request: Request, wal: str = Query(...), usr: str = Query
|
||||
async def lnurl_balance_notify(request: Request, service: str):
|
||||
bc = await get_balance_check(request.query_params.get("wal"), service)
|
||||
if bc:
|
||||
redeem_lnurl_withdraw(bc.wallet, bc.url)
|
||||
await redeem_lnurl_withdraw(bc.wallet, bc.url)
|
||||
|
||||
|
||||
@core_html_routes.get("/lnurlwallet", response_class=RedirectResponse, name="core.lnurlwallet")
|
||||
@core_html_routes.get(
|
||||
"/lnurlwallet", response_class=RedirectResponse, name="core.lnurlwallet"
|
||||
)
|
||||
async def lnurlwallet(request: Request):
|
||||
async with db.connect() as conn:
|
||||
account = await create_account(conn=conn)
|
||||
user = await get_user(account.id, conn=conn)
|
||||
wallet = await create_wallet(user_id=user.id, conn=conn)
|
||||
wallet = await create_wallet(user_id=user.id, conn=conn) # type: ignore
|
||||
|
||||
asyncio.create_task(
|
||||
redeem_lnurl_withdraw(
|
||||
@@ -244,11 +267,16 @@ async def lnurlwallet(request: Request):
|
||||
)
|
||||
|
||||
return RedirectResponse(
|
||||
f"/wallet?usr={user.id}&wal={wallet.id}",
|
||||
f"/wallet?usr={user.id}&wal={wallet.id}", # type: ignore
|
||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||
)
|
||||
|
||||
|
||||
@core_html_routes.get("/service-worker.js", response_class=FileResponse)
|
||||
async def service_worker():
|
||||
return FileResponse("lnbits/core/static/js/service-worker.js")
|
||||
|
||||
|
||||
@core_html_routes.get("/manifest/{usr}.webmanifest")
|
||||
async def manifest(usr: str):
|
||||
user = await get_user(usr)
|
||||
@@ -256,21 +284,23 @@ async def manifest(usr: str):
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
||||
|
||||
return {
|
||||
"short_name": "LNbits",
|
||||
"name": "LNbits Wallet",
|
||||
"short_name": LNBITS_SITE_TITLE,
|
||||
"name": LNBITS_SITE_TITLE + " Wallet",
|
||||
"icons": [
|
||||
{
|
||||
"src": "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png",
|
||||
"src": LNBITS_CUSTOM_LOGO
|
||||
if LNBITS_CUSTOM_LOGO
|
||||
else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png",
|
||||
"type": "image/png",
|
||||
"sizes": "900x900",
|
||||
}
|
||||
],
|
||||
"start_url": "/wallet?usr=" + usr,
|
||||
"background_color": "#3367D6",
|
||||
"description": "Weather forecast information",
|
||||
"start_url": "/wallet?usr=" + usr + "&wal=" + user.wallets[0].id,
|
||||
"background_color": "#1F2234",
|
||||
"description": "Bitcoin Lightning Wallet",
|
||||
"display": "standalone",
|
||||
"scope": "/",
|
||||
"theme_color": "#3367D6",
|
||||
"theme_color": "#1F2234",
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": wallet.name,
|
||||
|
||||
@@ -4,6 +4,7 @@ from http import HTTPStatus
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import HTTPException
|
||||
from loguru import logger
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
@@ -45,7 +46,7 @@ async def api_public_payment_longpolling(payment_hash):
|
||||
|
||||
payment_queue = asyncio.Queue(0)
|
||||
|
||||
print("adding standalone invoice listener", payment_hash, payment_queue)
|
||||
logger.debug("adding standalone invoice listener", payment_hash, payment_queue)
|
||||
api_invoice_listeners.append(payment_queue)
|
||||
|
||||
response = None
|
||||
|
||||
+11
-4
@@ -5,6 +5,7 @@ import time
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy_aio.base import AsyncConnection
|
||||
from sqlalchemy_aio.strategy import ASYNCIO_STRATEGY # type: ignore
|
||||
@@ -130,10 +131,16 @@ class Database(Compat):
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.path = os.path.join(LNBITS_DATA_FOLDER, f"{self.name}.sqlite3")
|
||||
database_uri = f"sqlite:///{self.path}"
|
||||
self.type = SQLITE
|
||||
|
||||
if os.path.isdir(LNBITS_DATA_FOLDER):
|
||||
self.path = os.path.join(LNBITS_DATA_FOLDER, f"{self.name}.sqlite3")
|
||||
database_uri = f"sqlite:///{self.path}"
|
||||
self.type = SQLITE
|
||||
else:
|
||||
raise NotADirectoryError(
|
||||
f"LNBITS_DATA_FOLDER named {LNBITS_DATA_FOLDER} was not created"
|
||||
f" - please 'mkdir {LNBITS_DATA_FOLDER}' and try again"
|
||||
)
|
||||
logger.trace(f"database {self.type} added for {self.name}")
|
||||
self.schema = self.name
|
||||
if self.name.startswith("ext_"):
|
||||
self.schema = self.name[4:]
|
||||
|
||||
+68
-46
@@ -1,4 +1,5 @@
|
||||
from http import HTTPStatus
|
||||
from typing import Union
|
||||
|
||||
from cerberus import Validator # type: ignore
|
||||
from fastapi import status
|
||||
@@ -13,7 +14,11 @@ from starlette.requests import Request
|
||||
from lnbits.core.crud import get_user, get_wallet_for_key
|
||||
from lnbits.core.models import User, Wallet
|
||||
from lnbits.requestvars import g
|
||||
from lnbits.settings import LNBITS_ALLOWED_USERS, LNBITS_ADMIN_USERS, LNBITS_ADMIN_EXTENSIONS
|
||||
from lnbits.settings import (
|
||||
LNBITS_ADMIN_EXTENSIONS,
|
||||
LNBITS_ADMIN_USERS,
|
||||
LNBITS_ALLOWED_USERS,
|
||||
)
|
||||
|
||||
|
||||
class KeyChecker(SecurityBase):
|
||||
@@ -25,20 +30,21 @@ class KeyChecker(SecurityBase):
|
||||
self._key_type = "invoice"
|
||||
self._api_key = api_key
|
||||
if api_key:
|
||||
self.model: APIKey = APIKey(
|
||||
key = APIKey(
|
||||
**{"in": APIKeyIn.query},
|
||||
name="X-API-KEY",
|
||||
description="Wallet API Key - QUERY",
|
||||
)
|
||||
else:
|
||||
self.model: APIKey = APIKey(
|
||||
key = APIKey(
|
||||
**{"in": APIKeyIn.header},
|
||||
name="X-API-KEY",
|
||||
description="Wallet API Key - HEADER",
|
||||
)
|
||||
self.wallet = None
|
||||
self.wallet = None # type: ignore
|
||||
self.model: APIKey = key
|
||||
|
||||
async def __call__(self, request: Request) -> Wallet:
|
||||
async def __call__(self, request: Request):
|
||||
try:
|
||||
key_value = (
|
||||
self._api_key
|
||||
@@ -48,7 +54,7 @@ class KeyChecker(SecurityBase):
|
||||
# FIXME: Find another way to validate the key. A fetch from DB should be avoided here.
|
||||
# Also, we should not return the wallet here - thats silly.
|
||||
# Possibly store it in a Redis DB
|
||||
self.wallet = await get_wallet_for_key(key_value, self._key_type)
|
||||
self.wallet = await get_wallet_for_key(key_value, self._key_type) # type: ignore
|
||||
if not self.wallet:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
@@ -116,56 +122,65 @@ api_key_query = APIKeyQuery(
|
||||
|
||||
async def get_key_type(
|
||||
r: Request,
|
||||
api_key_header: str = Security(api_key_header),
|
||||
api_key_query: str = Security(api_key_query),
|
||||
api_key_header: str = Security(api_key_header), # type: ignore
|
||||
api_key_query: str = Security(api_key_query), # type: ignore
|
||||
) -> WalletTypeInfo:
|
||||
# 0: admin
|
||||
# 1: invoice
|
||||
# 2: invalid
|
||||
pathname = r['path'].split('/')[1]
|
||||
pathname = r["path"].split("/")[1]
|
||||
|
||||
if not api_key_header and not api_key_query:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
|
||||
token = api_key_header or api_key_query
|
||||
|
||||
token = api_key_header if api_key_header else api_key_query
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Invoice (or Admin) key required.",
|
||||
)
|
||||
|
||||
try:
|
||||
checker = WalletAdminKeyChecker(api_key=token)
|
||||
await checker.__call__(r)
|
||||
wallet = WalletTypeInfo(0, checker.wallet)
|
||||
if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS):
|
||||
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized.")
|
||||
return wallet
|
||||
except HTTPException as e:
|
||||
if e.status_code == HTTPStatus.BAD_REQUEST:
|
||||
for typenr, WalletChecker in zip(
|
||||
[0, 1], [WalletAdminKeyChecker, WalletInvoiceKeyChecker]
|
||||
):
|
||||
try:
|
||||
checker = WalletChecker(api_key=token)
|
||||
await checker.__call__(r)
|
||||
wallet = WalletTypeInfo(typenr, checker.wallet) # type: ignore
|
||||
if wallet is None or wallet.wallet is None:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
|
||||
)
|
||||
if (
|
||||
LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS
|
||||
) and (LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized."
|
||||
)
|
||||
return wallet
|
||||
except HTTPException as e:
|
||||
if e.status_code == HTTPStatus.BAD_REQUEST:
|
||||
raise
|
||||
if e.status_code == HTTPStatus.UNAUTHORIZED:
|
||||
pass
|
||||
except:
|
||||
raise
|
||||
if e.status_code == HTTPStatus.UNAUTHORIZED:
|
||||
pass
|
||||
except:
|
||||
raise
|
||||
|
||||
try:
|
||||
checker = WalletInvoiceKeyChecker(api_key=token)
|
||||
await checker.__call__(r)
|
||||
wallet = WalletTypeInfo(0, checker.wallet)
|
||||
if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS):
|
||||
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized.")
|
||||
return wallet
|
||||
except HTTPException as e:
|
||||
if e.status_code == HTTPStatus.BAD_REQUEST:
|
||||
raise
|
||||
if e.status_code == HTTPStatus.UNAUTHORIZED:
|
||||
return WalletTypeInfo(2, None)
|
||||
except:
|
||||
raise
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
|
||||
)
|
||||
|
||||
|
||||
async def require_admin_key(
|
||||
r: Request,
|
||||
api_key_header: str = Security(api_key_header),
|
||||
api_key_query: str = Security(api_key_query),
|
||||
api_key_header: str = Security(api_key_header), # type: ignore
|
||||
api_key_query: str = Security(api_key_query), # type: ignore
|
||||
):
|
||||
token = api_key_header if api_key_header else api_key_query
|
||||
|
||||
token = api_key_header or api_key_query
|
||||
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Admin key required.",
|
||||
)
|
||||
|
||||
wallet = await get_key_type(r, token)
|
||||
|
||||
@@ -181,10 +196,17 @@ async def require_admin_key(
|
||||
|
||||
async def require_invoice_key(
|
||||
r: Request,
|
||||
api_key_header: str = Security(api_key_header),
|
||||
api_key_query: str = Security(api_key_query),
|
||||
api_key_header: str = Security(api_key_header), # type: ignore
|
||||
api_key_query: str = Security(api_key_query), # type: ignore
|
||||
):
|
||||
token = api_key_header if api_key_header else api_key_query
|
||||
|
||||
token = api_key_header or api_key_query
|
||||
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Invoice (or Admin) key required.",
|
||||
)
|
||||
|
||||
wallet = await get_key_type(r, token)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ db = Database("ext_bleskomat")
|
||||
bleskomat_static_files = [
|
||||
{
|
||||
"path": "/bleskomat/static",
|
||||
"app": StaticFiles(directory="lnbits/extensions/bleskomat/static"),
|
||||
"app": StaticFiles(packages=[("lnbits", "extensions/bleskomat/static")]),
|
||||
"name": "bleskomat_static",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import httpx
|
||||
import json
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
fiat_currencies = json.load(
|
||||
open(
|
||||
os.path.join(
|
||||
|
||||
@@ -3,6 +3,7 @@ import math
|
||||
import traceback
|
||||
from http import HTTPStatus
|
||||
|
||||
from loguru import logger
|
||||
from starlette.requests import Request
|
||||
|
||||
from . import bleskomat_ext
|
||||
@@ -122,7 +123,7 @@ async def api_bleskomat_lnurl(req: Request):
|
||||
except LnurlHttpError as e:
|
||||
return {"status": "ERROR", "reason": str(e)}
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
logger.error(str(e))
|
||||
return {"status": "ERROR", "reason": "Unexpected error"}
|
||||
|
||||
return {"status": "OK"}
|
||||
|
||||
@@ -3,11 +3,12 @@ import time
|
||||
from typing import Dict
|
||||
|
||||
from fastapi.params import Query
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, validator
|
||||
from starlette.requests import Request
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.services import pay_invoice, PaymentFailure
|
||||
from lnbits.core.services import PaymentFailure, pay_invoice
|
||||
|
||||
from . import db
|
||||
from .exchange_rates import exchange_rate_providers, fiat_currencies
|
||||
@@ -125,7 +126,7 @@ class BleskomatLnurl(BaseModel):
|
||||
except (ValueError, PermissionError, PaymentFailure) as e:
|
||||
raise LnurlValidationError("Failed to pay invoice: " + str(e))
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
logger.error(str(e))
|
||||
raise LnurlValidationError("Unexpected error")
|
||||
|
||||
async def use(self, conn) -> bool:
|
||||
|
||||
@@ -62,4 +62,5 @@
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-btn flat label="Swagger API" type="a" href="../docs#/Bleskomat"></q-btn>
|
||||
</q-expansion-item>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Depends, Query
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
@@ -60,7 +61,7 @@ async def api_bleskomat_create_or_update(
|
||||
currency=fiat_currency, provider=exchange_rate_provider
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
logger.error(e)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f'Failed to fetch BTC/{fiat_currency} currency pair from "{exchange_rate_provider}"',
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Bolt cards (NXP NTAG) Extension
|
||||
|
||||
This extension allows you to link your Bolt Card (or other compatible NXP NTAG device) with a LNbits instance and use it in a more secure way than a static LNURLw. A technology called [Secure Unique NFC](https://mishka-scan.com/blog/secure-unique-nfc) is utilized in this workflow.
|
||||
|
||||
<a href="https://www.youtube.com/watch?v=wJ7QLFTRjK0">Tutorial</a>
|
||||
|
||||
**Disclaimer:** ***Use this only if you either know what you are doing or are a reckless lightning pioneer. Only you are responsible for all your sats, cards and other devices. Always backup all your card keys!***
|
||||
|
||||
***In order to use this extension you need to be able to setup your own card.*** That means writing a URL template pointing to your LNBits instance, configuring some SUN (SDM) settings and optionally changing the card's keys. There's a [guide](https://www.whitewolftech.com/articles/payment-card/) to set it up with a card reader connected to your computer. It can be done (without setting the keys) with [TagWriter app by NXP](https://play.google.com/store/apps/details?id=com.nxp.nfc.tagwriter) Android app. Last but not least, an OSS android app by name [bolt-nfc-android-app](https://github.com/boltcard/bolt-nfc-android-app) is being developed for these purposes. It's available from Google Play [here](https://play.google.com/store/apps/details?id=com.lightningnfcapp).
|
||||
|
||||
## About the keys
|
||||
|
||||
Up to five 16-byte keys can be stored on the card, numbered from 00 to 04. In the empty state they all should be set to zeros (00000000000000000000000000000000). For this extension only two keys need to be set:
|
||||
|
||||
One for encrypting the card UID and the counter (p parameter), let's called it meta key, key #01 or K1.
|
||||
|
||||
One for calculating CMAC (c parameter), let's called it file key, key #02 or K2.
|
||||
|
||||
The key #00, K0 (also know as auth key) is skipped to be use as authentification key. Is not needed by this extension, but can be filled in order to write the keys in cooperation with bolt-nfc-android-app.
|
||||
|
||||
***Always backup all keys that you're trying to write on the card. Without them you may not be able to change them in the future!***
|
||||
|
||||
## Setting the card - bolt-nfc-android-app (easy way)
|
||||
So far, regarding the keys, the app can only write a new key set on an empty card (with zero keys). **When you write non zero (and 'non debug') keys, they can't be rewrite with this app.** You have to do it on your computer.
|
||||
|
||||
- Read the card with the app. Note UID so you can fill it in the extension later.
|
||||
- Write the link on the card. It shoud be like `YOUR_LNBITS_DOMAIN/boltcards/api/v1/scan/{external_id}`
|
||||
- `{external_id}` should be replaced with the External ID found in the LNBits dialog.
|
||||
|
||||
- Add new card in the extension.
|
||||
- Set a max sats per transaction. Any transaction greater than this amount will be rejected.
|
||||
- Set a max sats per day. After the card spends this amount of sats in a day, additional transactions will be rejected.
|
||||
- Set a card name. This is just for your reference inside LNBits.
|
||||
- Set the card UID. This is the unique identifier on your NFC card and is 7 bytes.
|
||||
- If on an Android device with a newish version of Chrome, you can click the icon next to the input and tap your card to autofill this field.
|
||||
- Advanced Options
|
||||
- Card Keys (k0, k1, k2) will be automatically generated if not explicitly set.
|
||||
- Set to 16 bytes of 0s (00000000000000000000000000000000) to leave the keys in debug mode.
|
||||
- GENERATE KEY button fill the keys randomly. If there is "debug" in the card name, a debug set of keys is filled instead.
|
||||
- Click CREATE CARD button
|
||||
- Click the QR code button next to a card to view its details. You can scan the QR code with the Android app to import the keys.
|
||||
- Click the "KEYS / AUTH LINK" button to copy the auth URL to the clipboard. You can then paste this into the Android app to import the keys.
|
||||
- Tap the NFC card to write the keys to the card.
|
||||
|
||||
## Setting the card - computer (hard way)
|
||||
|
||||
Follow the guide.
|
||||
|
||||
The URI should be `lnurlw://YOUR-DOMAIN.COM/boltcards/api/v1/scan/{YOUR_card_external_id}?p=00000000000000000000000000000000&c=0000000000000000`
|
||||
|
||||
Then fill up the card parameters in the extension. Card Auth key (K0) can be omitted. Initical counter can be 0.
|
||||
|
||||
## Setting the card - android NXP app (hard way)
|
||||
- If you don't know the card ID, use NXP TagInfo app to find it out.
|
||||
- In the TagWriter app tap Write tags
|
||||
- New Data Set > Link
|
||||
- Set URI type to Custom URL
|
||||
- URL should look like lnurlw://YOUR_LNBITS_DOMAIN/boltcards/api/v1/scan/{YOUR_card_external_id}?p=00000000000000000000000000000000&c=0000000000000000
|
||||
- click Configure mirroring options
|
||||
- Select Card Type NTAG 424 DNA
|
||||
- Check Enable SDM Mirroring
|
||||
- Select SDM Meta Read Access Right to 01
|
||||
- Check Enable UID Mirroring
|
||||
- Check Enable Counter Mirroring
|
||||
- Set SDM Counter Retrieval Key to 0E
|
||||
- Set PICC Data Offset to immediately after e=
|
||||
- Set Derivation Key for CMAC Calculation to 00
|
||||
- Set SDM MAC Input Offset to immediately after c=
|
||||
- Set SDM MAC Offset to immediately after c=
|
||||
- Save & Write
|
||||
- Scan with compatible Wallet
|
||||
|
||||
This app afaik cannot change the keys. If you cannot change them any other way, leave them empty in the extension dialog and remember you're not secure. Card Auth key (K0) can be omitted anyway. Initical counter can be 0.
|
||||
@@ -0,0 +1,37 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import template_renderer
|
||||
from lnbits.tasks import catch_everything_and_restart
|
||||
|
||||
db = Database("ext_boltcards")
|
||||
|
||||
boltcards_static_files = [
|
||||
{
|
||||
"path": "/boltcards/static",
|
||||
"app": StaticFiles(packages=[("lnbits", "extensions/boltcards/static")]),
|
||||
"name": "boltcards_static",
|
||||
}
|
||||
]
|
||||
|
||||
boltcards_ext: APIRouter = APIRouter(prefix="/boltcards", tags=["boltcards"])
|
||||
|
||||
|
||||
def boltcards_renderer():
|
||||
return template_renderer(["lnbits/extensions/boltcards/templates"])
|
||||
|
||||
|
||||
from .lnurl import * # noqa
|
||||
from .tasks import * # noqa
|
||||
|
||||
|
||||
def boltcards_start():
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
||||
|
||||
|
||||
from .views import * # noqa
|
||||
from .views_api import * # noqa
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Bolt Cards",
|
||||
"short_description": "Self custody Bolt Cards with one time LNURLw",
|
||||
"icon": "payment",
|
||||
"contributors": ["iwarpbtc", "arcbtc", "leesalminen"]
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import secrets
|
||||
from datetime import date, datetime
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from . import db
|
||||
from .models import Card, CreateCardData, Hit, Refund
|
||||
|
||||
|
||||
async def create_card(data: CreateCardData, wallet_id: str) -> Card:
|
||||
card_id = urlsafe_short_hash().upper()
|
||||
extenal_id = urlsafe_short_hash().lower()
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO boltcards.cards (
|
||||
id,
|
||||
uid,
|
||||
external_id,
|
||||
wallet,
|
||||
card_name,
|
||||
counter,
|
||||
tx_limit,
|
||||
daily_limit,
|
||||
enable,
|
||||
k0,
|
||||
k1,
|
||||
k2,
|
||||
otp
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
card_id,
|
||||
data.uid.upper(),
|
||||
extenal_id,
|
||||
wallet_id,
|
||||
data.card_name,
|
||||
data.counter,
|
||||
data.tx_limit,
|
||||
data.daily_limit,
|
||||
True,
|
||||
data.k0,
|
||||
data.k1,
|
||||
data.k2,
|
||||
secrets.token_hex(16),
|
||||
),
|
||||
)
|
||||
card = await get_card(card_id)
|
||||
assert card, "Newly created card couldn't be retrieved"
|
||||
return card
|
||||
|
||||
|
||||
async def update_card(card_id: str, **kwargs) -> Optional[Card]:
|
||||
if "is_unique" in kwargs:
|
||||
kwargs["is_unique"] = int(kwargs["is_unique"])
|
||||
if "uid" in kwargs:
|
||||
kwargs["uid"] = kwargs["uid"].upper()
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
await db.execute(
|
||||
f"UPDATE boltcards.cards SET {q} WHERE id = ?",
|
||||
(*kwargs.values(), card_id),
|
||||
)
|
||||
row = await db.fetchone("SELECT * FROM boltcards.cards WHERE id = ?", (card_id,))
|
||||
return Card(**row) if row else None
|
||||
|
||||
|
||||
async def get_cards(wallet_ids: Union[str, List[str]]) -> List[Card]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltcards.cards WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
|
||||
return [Card(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_card(card_id: str) -> Optional[Card]:
|
||||
row = await db.fetchone("SELECT * FROM boltcards.cards WHERE id = ?", (card_id,))
|
||||
if not row:
|
||||
return None
|
||||
|
||||
card = dict(**row)
|
||||
|
||||
return Card.parse_obj(card)
|
||||
|
||||
|
||||
async def get_card_by_uid(card_uid: str) -> Optional[Card]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM boltcards.cards WHERE uid = ?", (card_uid.upper(),)
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
|
||||
card = dict(**row)
|
||||
|
||||
return Card.parse_obj(card)
|
||||
|
||||
|
||||
async def get_card_by_external_id(external_id: str) -> Optional[Card]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM boltcards.cards WHERE external_id = ?", (external_id.lower(),)
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
|
||||
card = dict(**row)
|
||||
|
||||
return Card.parse_obj(card)
|
||||
|
||||
|
||||
async def get_card_by_otp(otp: str) -> Optional[Card]:
|
||||
row = await db.fetchone("SELECT * FROM boltcards.cards WHERE otp = ?", (otp,))
|
||||
if not row:
|
||||
return None
|
||||
|
||||
card = dict(**row)
|
||||
|
||||
return Card.parse_obj(card)
|
||||
|
||||
|
||||
async def delete_card(card_id: str) -> None:
|
||||
# Delete cards
|
||||
card = await get_card(card_id)
|
||||
await db.execute("DELETE FROM boltcards.cards WHERE id = ?", (card_id,))
|
||||
# Delete hits
|
||||
hits = await get_hits([card_id])
|
||||
for hit in hits:
|
||||
await db.execute("DELETE FROM boltcards.hits WHERE id = ?", (hit.id,))
|
||||
# Delete refunds
|
||||
refunds = await get_refunds([hit])
|
||||
for refund in refunds:
|
||||
await db.execute(
|
||||
"DELETE FROM boltcards.refunds WHERE id = ?", (refund.hit_id,)
|
||||
)
|
||||
|
||||
|
||||
async def update_card_counter(counter: int, id: str):
|
||||
await db.execute(
|
||||
"UPDATE boltcards.cards SET counter = ? WHERE id = ?",
|
||||
(counter, id),
|
||||
)
|
||||
|
||||
|
||||
async def enable_disable_card(enable: bool, id: str) -> Optional[Card]:
|
||||
row = await db.execute(
|
||||
"UPDATE boltcards.cards SET enable = ? WHERE id = ?",
|
||||
(enable, id),
|
||||
)
|
||||
return await get_card(id)
|
||||
|
||||
|
||||
async def update_card_otp(otp: str, id: str):
|
||||
await db.execute(
|
||||
"UPDATE boltcards.cards SET otp = ? WHERE id = ?",
|
||||
(otp, id),
|
||||
)
|
||||
|
||||
|
||||
async def get_hit(hit_id: str) -> Optional[Hit]:
|
||||
row = await db.fetchone(f"SELECT * FROM boltcards.hits WHERE id = ?", (hit_id))
|
||||
if not row:
|
||||
return None
|
||||
|
||||
hit = dict(**row)
|
||||
|
||||
return Hit.parse_obj(hit)
|
||||
|
||||
|
||||
async def get_hits(cards_ids: Union[str, List[str]]) -> List[Hit]:
|
||||
q = ",".join(["?"] * len(cards_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltcards.hits WHERE card_id IN ({q})", (*cards_ids,)
|
||||
)
|
||||
|
||||
return [Hit(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_hits_today(card_id: str) -> Optional[Hit]:
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltcards.hits WHERE card_id = ?",
|
||||
(card_id,),
|
||||
)
|
||||
updatedrow = []
|
||||
for row in rows:
|
||||
if datetime.now().date() == datetime.fromtimestamp(row.time).date():
|
||||
updatedrow.append(row)
|
||||
|
||||
return [Hit(**row) for row in updatedrow]
|
||||
|
||||
|
||||
async def spend_hit(id: str, amount: int):
|
||||
await db.execute(
|
||||
"UPDATE boltcards.hits SET spent = ?, amount = ? WHERE id = ?",
|
||||
(True, amount, id),
|
||||
)
|
||||
return await get_hit(id)
|
||||
|
||||
|
||||
async def create_hit(card_id, ip, useragent, old_ctr, new_ctr) -> Hit:
|
||||
hit_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO boltcards.hits (
|
||||
id,
|
||||
card_id,
|
||||
ip,
|
||||
spent,
|
||||
useragent,
|
||||
old_ctr,
|
||||
new_ctr,
|
||||
amount
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
hit_id,
|
||||
card_id,
|
||||
ip,
|
||||
False,
|
||||
useragent,
|
||||
old_ctr,
|
||||
new_ctr,
|
||||
0,
|
||||
),
|
||||
)
|
||||
hit = await get_hit(hit_id)
|
||||
assert hit, "Newly recorded hit couldn't be retrieved"
|
||||
return hit
|
||||
|
||||
|
||||
async def create_refund(hit_id, refund_amount) -> Refund:
|
||||
refund_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO boltcards.refunds (
|
||||
id,
|
||||
hit_id,
|
||||
refund_amount
|
||||
)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(
|
||||
refund_id,
|
||||
hit_id,
|
||||
refund_amount,
|
||||
),
|
||||
)
|
||||
refund = await get_refund(refund_id)
|
||||
assert refund, "Newly recorded hit couldn't be retrieved"
|
||||
return refund
|
||||
|
||||
|
||||
async def get_refund(refund_id: str) -> Optional[Refund]:
|
||||
row = await db.fetchone(
|
||||
f"SELECT * FROM boltcards.refunds WHERE id = ?", (refund_id)
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
refund = dict(**row)
|
||||
return Refund.parse_obj(refund)
|
||||
|
||||
|
||||
async def get_refunds(hits_ids: Union[str, List[str]]) -> List[Refund]:
|
||||
q = ",".join(["?"] * len(hits_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltcards.refunds WHERE hit_id IN ({q})", (*hits_ids,)
|
||||
)
|
||||
|
||||
return [Refund(**row) for row in rows]
|
||||
@@ -0,0 +1,219 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from embit import bech32, compact
|
||||
from fastapi import Request
|
||||
from fastapi.param_functions import Query
|
||||
from fastapi.params import Depends, Query
|
||||
from lnurl import Lnurl, LnurlWithdrawResponse
|
||||
from lnurl import encode as lnurl_encode # type: ignore
|
||||
from lnurl.types import LnurlPayMetadata # type: ignore
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.services import create_invoice
|
||||
from lnbits.core.views.api import pay_invoice
|
||||
|
||||
from . import boltcards_ext
|
||||
from .crud import (
|
||||
create_hit,
|
||||
get_card,
|
||||
get_card_by_external_id,
|
||||
get_card_by_otp,
|
||||
get_hit,
|
||||
get_hits_today,
|
||||
spend_hit,
|
||||
update_card,
|
||||
update_card_counter,
|
||||
update_card_otp,
|
||||
)
|
||||
from .models import CreateCardData
|
||||
from .nxp424 import decryptSUN, getSunMAC
|
||||
|
||||
###############LNURLWITHDRAW#################
|
||||
|
||||
# /boltcards/api/v1/scan?p=00000000000000000000000000000000&c=0000000000000000
|
||||
@boltcards_ext.get("/api/v1/scan/{external_id}")
|
||||
async def api_scan(p, c, request: Request, external_id: str = None):
|
||||
# some wallets send everything as lower case, no bueno
|
||||
p = p.upper()
|
||||
c = c.upper()
|
||||
card = None
|
||||
counter = b""
|
||||
card = await get_card_by_external_id(external_id)
|
||||
if not card:
|
||||
return {"status": "ERROR", "reason": "No card."}
|
||||
if not card.enable:
|
||||
return {"status": "ERROR", "reason": "Card is disabled."}
|
||||
try:
|
||||
card_uid, counter = decryptSUN(bytes.fromhex(p), bytes.fromhex(card.k1))
|
||||
if card.uid.upper() != card_uid.hex().upper():
|
||||
return {"status": "ERROR", "reason": "Card UID mis-match."}
|
||||
if c != getSunMAC(card_uid, counter, bytes.fromhex(card.k2)).hex().upper():
|
||||
return {"status": "ERROR", "reason": "CMAC does not check."}
|
||||
except:
|
||||
return {"status": "ERROR", "reason": "Error decrypting card."}
|
||||
|
||||
ctr_int = int.from_bytes(counter, "little")
|
||||
|
||||
if ctr_int <= card.counter:
|
||||
return {"status": "ERROR", "reason": "This link is already used."}
|
||||
|
||||
await update_card_counter(ctr_int, card.id)
|
||||
|
||||
# gathering some info for hit record
|
||||
ip = request.client.host
|
||||
if "x-real-ip" in request.headers:
|
||||
ip = request.headers["x-real-ip"]
|
||||
elif "x-forwarded-for" in request.headers:
|
||||
ip = request.headers["x-forwarded-for"]
|
||||
|
||||
agent = request.headers["user-agent"] if "user-agent" in request.headers else ""
|
||||
todays_hits = await get_hits_today(card.id)
|
||||
|
||||
hits_amount = 0
|
||||
for hit in todays_hits:
|
||||
hits_amount = hits_amount + hit.amount
|
||||
if hits_amount > card.daily_limit:
|
||||
return {"status": "ERROR", "reason": "Max daily limit spent."}
|
||||
hit = await create_hit(card.id, ip, agent, card.counter, ctr_int)
|
||||
lnurlpay = lnurl_encode(request.url_for("boltcards.lnurlp_response", hit_id=hit.id))
|
||||
return {
|
||||
"tag": "withdrawRequest",
|
||||
"callback": request.url_for("boltcards.lnurl_callback", hitid=hit.id),
|
||||
"k1": hit.id,
|
||||
"minWithdrawable": 1 * 1000,
|
||||
"maxWithdrawable": card.tx_limit * 1000,
|
||||
"defaultDescription": f"Boltcard (refund address lnurl://{lnurlpay})",
|
||||
}
|
||||
|
||||
|
||||
@boltcards_ext.get(
|
||||
"/api/v1/lnurl/cb/{hitid}",
|
||||
status_code=HTTPStatus.OK,
|
||||
name="boltcards.lnurl_callback",
|
||||
)
|
||||
async def lnurl_callback(
|
||||
request: Request,
|
||||
pr: str = Query(None),
|
||||
k1: str = Query(None),
|
||||
):
|
||||
hit = await get_hit(k1)
|
||||
card = await get_card(hit.card_id)
|
||||
if not hit:
|
||||
return {"status": "ERROR", "reason": f"LNURL-pay record not found."}
|
||||
if hit.id != k1:
|
||||
return {"status": "ERROR", "reason": "Bad K1"}
|
||||
if hit.spent:
|
||||
return {"status": "ERROR", "reason": f"Payment already claimed"}
|
||||
invoice = bolt11.decode(pr)
|
||||
hit = await spend_hit(id=hit.id, amount=int(invoice.amount_msat / 1000))
|
||||
try:
|
||||
await pay_invoice(
|
||||
wallet_id=card.wallet,
|
||||
payment_request=pr,
|
||||
max_sat=card.tx_limit,
|
||||
extra={"tag": "boltcard", "tag": hit.id},
|
||||
)
|
||||
return {"status": "OK"}
|
||||
except:
|
||||
return {"status": "ERROR", "reason": f"Payment failed"}
|
||||
|
||||
|
||||
# /boltcards/api/v1/auth?a=00000000000000000000000000000000
|
||||
@boltcards_ext.get("/api/v1/auth")
|
||||
async def api_auth(a, request: Request):
|
||||
if a == "00000000000000000000000000000000":
|
||||
response = {"k0": "0" * 32, "k1": "1" * 32, "k2": "2" * 32}
|
||||
return response
|
||||
|
||||
card = await get_card_by_otp(a)
|
||||
if not card:
|
||||
raise HTTPException(
|
||||
detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND
|
||||
)
|
||||
|
||||
new_otp = secrets.token_hex(16)
|
||||
await update_card_otp(new_otp, card.id)
|
||||
|
||||
lnurlw_base = (
|
||||
f"{urlparse(str(request.url)).netloc}/boltcards/api/v1/scan/{card.external_id}"
|
||||
)
|
||||
|
||||
response = {
|
||||
"card_name": card.card_name,
|
||||
"id": 1,
|
||||
"k0": card.k0,
|
||||
"k1": card.k1,
|
||||
"k2": card.k2,
|
||||
"k3": card.k1,
|
||||
"k4": card.k2,
|
||||
"lnurlw_base": "lnurlw://" + lnurlw_base,
|
||||
"protocol_name": "new_bolt_card_response",
|
||||
"protocol_version": 1,
|
||||
}
|
||||
|
||||
return response
|
||||
|
||||
|
||||
###############LNURLPAY REFUNDS#################
|
||||
|
||||
|
||||
@boltcards_ext.get(
|
||||
"/api/v1/lnurlp/{hit_id}",
|
||||
response_class=HTMLResponse,
|
||||
name="boltcards.lnurlp_response",
|
||||
)
|
||||
async def lnurlp_response(req: Request, hit_id: str = Query(None)):
|
||||
hit = await get_hit(hit_id)
|
||||
card = await get_card(hit.card_id)
|
||||
if not hit:
|
||||
return {"status": "ERROR", "reason": f"LNURL-pay record not found."}
|
||||
if not card.enable:
|
||||
return {"status": "ERROR", "reason": "Card is disabled."}
|
||||
payResponse = {
|
||||
"tag": "payRequest",
|
||||
"callback": req.url_for("boltcards.lnurlp_callback", hit_id=hit_id),
|
||||
"metadata": LnurlPayMetadata(json.dumps([["text/plain", "Refund"]])),
|
||||
"minSendable": 1 * 1000,
|
||||
"maxSendable": card.tx_limit * 1000,
|
||||
}
|
||||
return json.dumps(payResponse)
|
||||
|
||||
|
||||
@boltcards_ext.get(
|
||||
"/api/v1/lnurlp/cb/{hit_id}",
|
||||
response_class=HTMLResponse,
|
||||
name="boltcards.lnurlp_callback",
|
||||
)
|
||||
async def lnurlp_callback(
|
||||
req: Request, hit_id: str = Query(None), amount: str = Query(None)
|
||||
):
|
||||
hit = await get_hit(hit_id)
|
||||
card = await get_card(hit.card_id)
|
||||
if not hit:
|
||||
return {"status": "ERROR", "reason": f"LNURL-pay record not found."}
|
||||
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=card.wallet,
|
||||
amount=int(amount) / 1000,
|
||||
memo=f"Refund {hit_id}",
|
||||
unhashed_description=LnurlPayMetadata(
|
||||
json.dumps([["text/plain", "Refund"]])
|
||||
).encode("utf-8"),
|
||||
extra={"refund": hit_id},
|
||||
)
|
||||
|
||||
payResponse = {"pr": payment_request, "routes": []}
|
||||
|
||||
return json.dumps(payResponse)
|
||||
@@ -0,0 +1,60 @@
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
|
||||
async def m001_initial(db):
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE boltcards.cards (
|
||||
id TEXT PRIMARY KEY UNIQUE,
|
||||
wallet TEXT NOT NULL,
|
||||
card_name TEXT NOT NULL,
|
||||
uid TEXT NOT NULL UNIQUE,
|
||||
external_id TEXT NOT NULL UNIQUE,
|
||||
counter INT NOT NULL DEFAULT 0,
|
||||
tx_limit TEXT NOT NULL,
|
||||
daily_limit TEXT NOT NULL,
|
||||
enable BOOL NOT NULL,
|
||||
k0 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
|
||||
k1 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
|
||||
k2 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
|
||||
prev_k0 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
|
||||
prev_k1 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
|
||||
prev_k2 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
|
||||
otp TEXT NOT NULL DEFAULT '',
|
||||
time TIMESTAMP NOT NULL DEFAULT """
|
||||
+ db.timestamp_now
|
||||
+ """
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE boltcards.hits (
|
||||
id TEXT PRIMARY KEY UNIQUE,
|
||||
card_id TEXT NOT NULL,
|
||||
ip TEXT NOT NULL,
|
||||
spent BOOL NOT NULL DEFAULT True,
|
||||
useragent TEXT,
|
||||
old_ctr INT NOT NULL DEFAULT 0,
|
||||
new_ctr INT NOT NULL DEFAULT 0,
|
||||
amount INT NOT NULL,
|
||||
time TIMESTAMP NOT NULL DEFAULT """
|
||||
+ db.timestamp_now
|
||||
+ """
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE boltcards.refunds (
|
||||
id TEXT PRIMARY KEY UNIQUE,
|
||||
hit_id TEXT NOT NULL,
|
||||
refund_amount INT NOT NULL,
|
||||
time TIMESTAMP NOT NULL DEFAULT """
|
||||
+ db.timestamp_now
|
||||
+ """
|
||||
);
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
from sqlite3 import Row
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.params import Query
|
||||
from lnurl import Lnurl
|
||||
from lnurl import encode as lnurl_encode # type: ignore
|
||||
from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore
|
||||
from lnurl.types import LnurlPayMetadata # type: ignore
|
||||
from pydantic import BaseModel
|
||||
from pydantic.main import BaseModel
|
||||
|
||||
ZERO_KEY = "00000000000000000000000000000000"
|
||||
|
||||
|
||||
class Card(BaseModel):
|
||||
id: str
|
||||
wallet: str
|
||||
card_name: str
|
||||
uid: str
|
||||
external_id: str
|
||||
counter: int
|
||||
tx_limit: int
|
||||
daily_limit: int
|
||||
enable: bool
|
||||
k0: str
|
||||
k1: str
|
||||
k2: str
|
||||
prev_k0: str
|
||||
prev_k1: str
|
||||
prev_k2: str
|
||||
otp: str
|
||||
time: int
|
||||
|
||||
def from_row(cls, row: Row) -> "Card":
|
||||
return cls(**dict(row))
|
||||
|
||||
def lnurl(self, req: Request) -> Lnurl:
|
||||
url = req.url_for("boltcard.lnurl_response", device_id=self.id, _external=True)
|
||||
return lnurl_encode(url)
|
||||
|
||||
async def lnurlpay_metadata(self) -> LnurlPayMetadata:
|
||||
return LnurlPayMetadata(json.dumps([["text/plain", self.title]]))
|
||||
|
||||
|
||||
class CreateCardData(BaseModel):
|
||||
card_name: str = Query(...)
|
||||
uid: str = Query(...)
|
||||
counter: int = Query(0)
|
||||
tx_limit: int = Query(0)
|
||||
daily_limit: int = Query(0)
|
||||
enable: bool = Query(True)
|
||||
k0: str = Query(ZERO_KEY)
|
||||
k1: str = Query(ZERO_KEY)
|
||||
k2: str = Query(ZERO_KEY)
|
||||
prev_k0: str = Query(ZERO_KEY)
|
||||
prev_k1: str = Query(ZERO_KEY)
|
||||
prev_k2: str = Query(ZERO_KEY)
|
||||
|
||||
|
||||
class Hit(BaseModel):
|
||||
id: str
|
||||
card_id: str
|
||||
ip: str
|
||||
spent: bool
|
||||
useragent: str
|
||||
old_ctr: int
|
||||
new_ctr: int
|
||||
amount: int
|
||||
time: int
|
||||
|
||||
def from_row(cls, row: Row) -> "Hit":
|
||||
return cls(**dict(row))
|
||||
|
||||
|
||||
class Refund(BaseModel):
|
||||
id: str
|
||||
hit_id: str
|
||||
refund_amount: int
|
||||
time: int
|
||||
|
||||
def from_row(cls, row: Row) -> "Refund":
|
||||
return cls(**dict(row))
|
||||
@@ -0,0 +1,36 @@
|
||||
# https://www.nxp.com/docs/en/application-note/AN12196.pdf
|
||||
from typing import Tuple
|
||||
|
||||
from Cryptodome.Cipher import AES
|
||||
from Cryptodome.Hash import CMAC
|
||||
|
||||
SV2 = "3CC300010080"
|
||||
|
||||
|
||||
def myCMAC(key: bytes, msg: bytes = b"") -> bytes:
|
||||
cobj = CMAC.new(key, ciphermod=AES)
|
||||
if msg != b"":
|
||||
cobj.update(msg)
|
||||
return cobj.digest()
|
||||
|
||||
|
||||
def decryptSUN(sun: bytes, key: bytes) -> Tuple[bytes, bytes]:
|
||||
IVbytes = b"\x00" * 16
|
||||
|
||||
cipher = AES.new(key, AES.MODE_CBC, IVbytes)
|
||||
sun_plain = cipher.decrypt(sun)
|
||||
|
||||
UID = sun_plain[1:8]
|
||||
counter = sun_plain[8:11]
|
||||
|
||||
return UID, counter
|
||||
|
||||
|
||||
def getSunMAC(UID: bytes, counter: bytes, key: bytes) -> bytes:
|
||||
sv2prefix = bytes.fromhex(SV2)
|
||||
sv2bytes = sv2prefix + UID + counter
|
||||
|
||||
mac1 = myCMAC(key, sv2bytes)
|
||||
mac2 = myCMAC(mac1)
|
||||
|
||||
return mac2[1::2]
|
||||
@@ -0,0 +1,435 @@
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
const mapCards = obj => {
|
||||
obj.date = Quasar.utils.date.formatDate(
|
||||
new Date(obj.time * 1000),
|
||||
'YYYY-MM-DD HH:mm'
|
||||
)
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
toggleAdvanced: false,
|
||||
nfcTagReading: false,
|
||||
lnurlLink: `${window.location.host}/boltcards/api/v1/scan/`,
|
||||
cards: [],
|
||||
hits: [],
|
||||
refunds: [],
|
||||
cardDialog: {
|
||||
show: false,
|
||||
data: {
|
||||
counter: 1,
|
||||
k0: '',
|
||||
k1: '',
|
||||
k2: '',
|
||||
uid: '',
|
||||
card_name: ''
|
||||
},
|
||||
temp: {}
|
||||
},
|
||||
cardsTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'card_name',
|
||||
align: 'left',
|
||||
label: 'Card name',
|
||||
field: 'card_name'
|
||||
},
|
||||
{
|
||||
name: 'counter',
|
||||
align: 'left',
|
||||
label: 'Counter',
|
||||
field: 'counter'
|
||||
},
|
||||
{
|
||||
name: 'wallet',
|
||||
align: 'left',
|
||||
label: 'Wallet',
|
||||
field: 'wallet'
|
||||
},
|
||||
{
|
||||
name: 'tx_limit',
|
||||
align: 'left',
|
||||
label: 'Max tx',
|
||||
field: 'tx_limit'
|
||||
},
|
||||
{
|
||||
name: 'daily_limit',
|
||||
align: 'left',
|
||||
label: 'Daily tx limit',
|
||||
field: 'daily_limit'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
refundsTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'hit_id',
|
||||
align: 'left',
|
||||
label: 'Hit ID',
|
||||
field: 'hit_id'
|
||||
},
|
||||
{
|
||||
name: 'refund_amount',
|
||||
align: 'left',
|
||||
label: 'Refund Amount',
|
||||
field: 'refund_amount'
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
align: 'left',
|
||||
label: 'Time',
|
||||
field: 'date'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10,
|
||||
sortBy: 'date',
|
||||
descending: true
|
||||
}
|
||||
},
|
||||
hitsTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'card_name',
|
||||
align: 'left',
|
||||
label: 'Card name',
|
||||
field: 'card_name'
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
align: 'left',
|
||||
label: 'Amount',
|
||||
field: 'amount'
|
||||
},
|
||||
{
|
||||
name: 'old_ctr',
|
||||
align: 'left',
|
||||
label: 'Old counter',
|
||||
field: 'old_ctr'
|
||||
},
|
||||
{
|
||||
name: 'new_ctr',
|
||||
align: 'left',
|
||||
label: 'New counter',
|
||||
field: 'new_ctr'
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
align: 'left',
|
||||
label: 'Time',
|
||||
field: 'date'
|
||||
},
|
||||
{
|
||||
name: 'ip',
|
||||
align: 'left',
|
||||
label: 'IP',
|
||||
field: 'ip'
|
||||
},
|
||||
{
|
||||
name: 'useragent',
|
||||
align: 'left',
|
||||
label: 'User agent',
|
||||
field: 'useragent'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10,
|
||||
sortBy: 'date',
|
||||
descending: true
|
||||
}
|
||||
},
|
||||
qrCodeDialog: {
|
||||
show: false,
|
||||
data: null
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
readNfcTag: function () {
|
||||
try {
|
||||
const self = this
|
||||
|
||||
if (typeof NDEFReader == 'undefined') {
|
||||
throw {
|
||||
toString: function () {
|
||||
return 'NFC not supported on this device or browser.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ndef = new NDEFReader()
|
||||
|
||||
const readerAbortController = new AbortController()
|
||||
readerAbortController.signal.onabort = event => {
|
||||
console.log('All NFC Read operations have been aborted.')
|
||||
}
|
||||
|
||||
this.nfcTagReading = true
|
||||
this.$q.notify({
|
||||
message: 'Tap your NFC tag to copy its UID here.'
|
||||
})
|
||||
|
||||
return ndef.scan({signal: readerAbortController.signal}).then(() => {
|
||||
ndef.onreadingerror = () => {
|
||||
self.nfcTagReading = false
|
||||
|
||||
this.$q.notify({
|
||||
type: 'negative',
|
||||
message: 'There was an error reading this NFC tag.'
|
||||
})
|
||||
|
||||
readerAbortController.abort()
|
||||
}
|
||||
|
||||
ndef.onreading = ({message, serialNumber}) => {
|
||||
//Decode NDEF data from tag
|
||||
var self = this
|
||||
self.cardDialog.data.uid = serialNumber
|
||||
.toUpperCase()
|
||||
.replaceAll(':', '')
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'NFC tag read successfully.'
|
||||
})
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
this.nfcTagReading = false
|
||||
this.$q.notify({
|
||||
type: 'negative',
|
||||
message: error
|
||||
? error.toString()
|
||||
: 'An unexpected error has occurred.'
|
||||
})
|
||||
}
|
||||
},
|
||||
getCards: function () {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/boltcards/api/v1/cards?all_wallets=true',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.cards = response.data.map(function (obj) {
|
||||
return mapCards(obj)
|
||||
})
|
||||
})
|
||||
.then(function () {
|
||||
self.getHits()
|
||||
})
|
||||
},
|
||||
getHits: function () {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/boltcards/api/v1/hits?all_wallets=true',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.hits = response.data.map(function (obj) {
|
||||
obj.card_name = self.cards.find(d => d.id == obj.card_id).card_name
|
||||
return mapCards(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
getRefunds: function () {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/boltcards/api/v1/refunds?all_wallets=true',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.refunds = response.data.map(function (obj) {
|
||||
return mapCards(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
openQrCodeDialog(cardId) {
|
||||
var card = _.findWhere(this.cards, {id: cardId})
|
||||
this.qrCodeDialog.data = {
|
||||
link: window.location.origin + '/boltcards/api/v1/auth?a=' + card.otp,
|
||||
name: card.card_name,
|
||||
uid: card.uid,
|
||||
external_id: card.external_id,
|
||||
k0: card.k0,
|
||||
k1: card.k1,
|
||||
k2: card.k2,
|
||||
k3: card.k1,
|
||||
k4: card.k2
|
||||
}
|
||||
this.qrCodeDialog.show = true
|
||||
},
|
||||
addCardOpen: function () {
|
||||
this.cardDialog.show = true
|
||||
this.generateKeys()
|
||||
},
|
||||
generateKeys: function () {
|
||||
var self = this
|
||||
const genRanHex = size =>
|
||||
[...Array(size)]
|
||||
.map(() => Math.floor(Math.random() * 16).toString(16))
|
||||
.join('')
|
||||
|
||||
debugcard =
|
||||
typeof this.cardDialog.data.card_name === 'string' &&
|
||||
this.cardDialog.data.card_name.search('debug') > -1
|
||||
|
||||
self.cardDialog.data.k0 = debugcard
|
||||
? '11111111111111111111111111111111'
|
||||
: genRanHex(32)
|
||||
|
||||
self.cardDialog.data.k1 = debugcard
|
||||
? '22222222222222222222222222222222'
|
||||
: genRanHex(32)
|
||||
|
||||
self.cardDialog.data.k2 = debugcard
|
||||
? '33333333333333333333333333333333'
|
||||
: genRanHex(32)
|
||||
},
|
||||
closeFormDialog: function () {
|
||||
this.cardDialog.data = {}
|
||||
},
|
||||
sendFormData: function () {
|
||||
let wallet = _.findWhere(this.g.user.wallets, {
|
||||
id: this.cardDialog.data.wallet
|
||||
})
|
||||
let data = this.cardDialog.data
|
||||
if (data.id) {
|
||||
this.updateCard(wallet, data)
|
||||
} else {
|
||||
this.createCard(wallet, data)
|
||||
}
|
||||
},
|
||||
createCard: function (wallet, data) {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request('POST', '/boltcards/api/v1/cards', wallet.adminkey, data)
|
||||
.then(function (response) {
|
||||
self.cards.push(mapCards(response.data))
|
||||
self.cardDialog.show = false
|
||||
self.cardDialog.data = {}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
updateCardDialog: function (formId) {
|
||||
var card = _.findWhere(this.cards, {id: formId})
|
||||
this.cardDialog.data = _.clone(card)
|
||||
|
||||
this.cardDialog.temp.k0 = this.cardDialog.data.k0
|
||||
this.cardDialog.temp.k1 = this.cardDialog.data.k1
|
||||
this.cardDialog.temp.k2 = this.cardDialog.data.k2
|
||||
|
||||
this.cardDialog.show = true
|
||||
},
|
||||
updateCard: function (wallet, data) {
|
||||
var self = this
|
||||
|
||||
if (
|
||||
this.cardDialog.temp.k0 != data.k0 ||
|
||||
this.cardDialog.temp.k1 != data.k1 ||
|
||||
this.cardDialog.temp.k2 != data.k2
|
||||
) {
|
||||
data.prev_k0 = this.cardDialog.temp.k0
|
||||
data.prev_k1 = this.cardDialog.temp.k1
|
||||
data.prev_k2 = this.cardDialog.temp.k2
|
||||
}
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/boltcards/api/v1/cards/' + data.id,
|
||||
wallet.adminkey,
|
||||
data
|
||||
)
|
||||
.then(function (response) {
|
||||
self.cards = _.reject(self.cards, function (obj) {
|
||||
return obj.id == data.id
|
||||
})
|
||||
self.cards.push(mapCards(response.data))
|
||||
self.cardDialog.show = false
|
||||
self.cardDialog.data = {}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
enableCard: function (wallet, card_id, enable) {
|
||||
var self = this
|
||||
let fullWallet = _.findWhere(self.g.user.wallets, {
|
||||
id: wallet
|
||||
})
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/boltcards/api/v1/cards/enable/' + card_id + '/' + enable,
|
||||
fullWallet.adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
console.log(response.data)
|
||||
self.cards = _.reject(self.cards, function (obj) {
|
||||
return obj.id == response.data.id
|
||||
})
|
||||
self.cards.push(mapCards(response.data))
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteCard: function (cardId) {
|
||||
let self = this
|
||||
let cards = _.findWhere(this.cards, {id: cardId})
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this card')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/boltcards/api/v1/cards/' + cardId,
|
||||
_.findWhere(self.g.user.wallets, {id: cards.wallet}).adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.cards = _.reject(self.cards, function (obj) {
|
||||
return obj.id == cardId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
exportCardsCSV: function () {
|
||||
LNbits.utils.exportCSV(this.cardsTable.columns, this.cards)
|
||||
},
|
||||
exportHitsCSV: function () {
|
||||
LNbits.utils.exportCSV(this.hitsTable.columns, this.hits)
|
||||
},
|
||||
exportRefundsCSV: function () {
|
||||
LNbits.utils.exportCSV(this.refundsTable.columns, this.refunds)
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
if (this.g.user.wallets.length) {
|
||||
this.getCards()
|
||||
this.getRefunds()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
from lnbits.core import db as core_db
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
from .crud import create_refund, get_hit
|
||||
|
||||
|
||||
async def wait_for_paid_invoices():
|
||||
invoice_queue = asyncio.Queue()
|
||||
register_invoice_listener(invoice_queue)
|
||||
|
||||
while True:
|
||||
payment = await invoice_queue.get()
|
||||
await on_invoice_paid(payment)
|
||||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
if not payment.extra.get("refund"):
|
||||
return
|
||||
|
||||
if payment.extra.get("wh_status"):
|
||||
# this webhook has already been sent
|
||||
return
|
||||
hit = await get_hit(payment.extra.get("refund"))
|
||||
|
||||
if hit:
|
||||
refund = await create_refund(
|
||||
hit_id=hit.id, refund_amount=(payment.amount / 1000)
|
||||
)
|
||||
await mark_webhook_sent(payment, 1)
|
||||
|
||||
|
||||
async def mark_webhook_sent(payment: Payment, status: int) -> None:
|
||||
payment.extra["wh_status"] = status
|
||||
|
||||
await core_db.execute(
|
||||
"""
|
||||
UPDATE apipayments SET extra = ?
|
||||
WHERE hash = ?
|
||||
""",
|
||||
(json.dumps(payment.extra), payment.payment_hash),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="About Bolt Cards"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h5 class="text-subtitle1 q-my-none">Be your own card association</h5>
|
||||
<p>
|
||||
Manage your Bolt Cards self custodian way<br />
|
||||
|
||||
<a
|
||||
href="https://github.com/lnbits/lnbits/tree/main/lnbits/extensions/boltcards"
|
||||
>More details</a
|
||||
>
|
||||
<br />
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
@@ -0,0 +1,405 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block page %}
|
||||
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<div class="row justify-start" style="width: 150px">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Cards</h5>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn
|
||||
round
|
||||
size="sm"
|
||||
icon="add"
|
||||
unelevated
|
||||
color="primary"
|
||||
@click="addCardOpen"
|
||||
>
|
||||
<q-tooltip>Add card</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportCardsCSV"
|
||||
>Export to CSV</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="cards"
|
||||
row-key="id"
|
||||
:columns="cardsTable.columns"
|
||||
:pagination.sync="cardsTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
icon="qr_code"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
@click="openQrCodeDialog(props.row.id)"
|
||||
>
|
||||
<q-tooltip>Card key credentials</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
v-if="props.row.enable"
|
||||
dense
|
||||
@click="enableCard(props.row.wallet, props.row.id, false)"
|
||||
color="pink"
|
||||
>DISABLE</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-else
|
||||
dense
|
||||
@click="enableCard(props.row.wallet, props.row.id, true)"
|
||||
color="green"
|
||||
>ENABLE
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="updateCardDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
>
|
||||
<q-tooltip>Edit card</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteCard(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
>
|
||||
<q-tooltip
|
||||
>Deleting card will also delete all records</q-tooltip
|
||||
>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Hits</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportCardsCSV"
|
||||
>Export to CSV</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="hits"
|
||||
row-key="id"
|
||||
:columns="hitsTable.columns"
|
||||
:pagination.sync="hitsTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Refunds</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportRefundsCSV"
|
||||
>Export to CSV</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="refunds"
|
||||
row-key="id"
|
||||
:columns="refundsTable.columns"
|
||||
:pagination.sync="refundsTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-my-none">
|
||||
{{SITE_TITLE}} Bolt Cards extension
|
||||
</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "boltcards/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<q-dialog v-model="cardDialog.show" position="top" @hide="closeFormDialog">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="sendFormData" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="cardDialog.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
>
|
||||
</q-select>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model.trim="cardDialog.data.tx_limit"
|
||||
type="number"
|
||||
label="Max transaction (sats)"
|
||||
class="q-pr-sm"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model.trim="cardDialog.data.daily_limit"
|
||||
type="number"
|
||||
label="Daily limit (sats)"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model.trim="cardDialog.data.card_name"
|
||||
type="text"
|
||||
label="Card name "
|
||||
>
|
||||
</q-input>
|
||||
<div class="row">
|
||||
<div class="col-10">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model.trim="cardDialog.data.uid"
|
||||
type="text"
|
||||
label="Card UID "
|
||||
>
|
||||
<q-tooltip
|
||||
>Get from the card you'll use, using an NFC app</q-tooltip
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-2 q-pl-sm">
|
||||
<q-btn
|
||||
outline
|
||||
disable
|
||||
color="grey"
|
||||
icon="nfc"
|
||||
:disable="nfcTagReading"
|
||||
@click="readNfcTag()"
|
||||
>
|
||||
<q-tooltip>Tap card to scan UID</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-toggle
|
||||
v-model="toggleAdvanced"
|
||||
label="Show advanced options"
|
||||
></q-toggle>
|
||||
<div v-show="toggleAdvanced">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="cardDialog.data.k0"
|
||||
type="text"
|
||||
label="Card Auth key (K0)"
|
||||
hint="Used to authentificate with the card (16 bytes in HEX). "
|
||||
@randomkey
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="cardDialog.data.k1"
|
||||
type="text"
|
||||
label="Card Meta key (K1)"
|
||||
hint="Used for encypting of the message (16 bytes in HEX)."
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="cardDialog.data.k2"
|
||||
type="text"
|
||||
label="Card File key (K2)"
|
||||
hint="Used for CMAC of the message (16 bytes in HEX)."
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="cardDialog.data.counter"
|
||||
type="number"
|
||||
label="Initial counter"
|
||||
>
|
||||
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left"
|
||||
>Zero if you don't know.</q-tooltip
|
||||
>
|
||||
</q-input>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
class="q-ml-auto"
|
||||
v-on:click="generateKeys"
|
||||
v-on:click.right="debugKeys"
|
||||
>Generate keys</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="cardDialog.data.id"
|
||||
unelevated
|
||||
color="primary"
|
||||
type="submit"
|
||||
>Update Card</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="cardDialog.data.uid == null"
|
||||
type="submit"
|
||||
>Create Card
|
||||
</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="qrCodeDialog.show" position="top">
|
||||
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
|
||||
{% raw %}
|
||||
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
|
||||
<qrcode
|
||||
:value="qrCodeDialog.data.link"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
<p style="word-break: break-all" class="text-center">
|
||||
(Keys for
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=com.lightningnfcapp"
|
||||
target="_blank"
|
||||
>bolt-nfc-android-app</a
|
||||
>)
|
||||
</p>
|
||||
<p style="word-break: break-all">
|
||||
<strong>Name:</strong> {{ qrCodeDialog.data.name }}<br />
|
||||
<strong>UID:</strong> {{ qrCodeDialog.data.uid }}<br />
|
||||
<strong>External ID:</strong> {{ qrCodeDialog.data.external_id }}<br />
|
||||
<strong>Lock key:</strong> {{ qrCodeDialog.data.k0 }}<br />
|
||||
<strong>Meta key:</strong> {{ qrCodeDialog.data.k1 }}<br />
|
||||
<strong>File key:</strong> {{ qrCodeDialog.data.k2 }}<br />
|
||||
</p>
|
||||
<br />
|
||||
<q-btn
|
||||
unelevated
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(qrCodeDialog.data.link)"
|
||||
label="Keys/Auth link"
|
||||
>
|
||||
</q-btn>
|
||||
<q-tooltip>Click to copy, then add to NFC card</q-tooltip>
|
||||
|
||||
{% endraw %}
|
||||
<div class="row q-mt-lg q-gutter-sm">
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="/boltcards/static/js/index.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,18 @@
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.params import Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_user_exists
|
||||
|
||||
from . import boltcards_ext, boltcards_renderer
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@boltcards_ext.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||
return boltcards_renderer().TemplateResponse(
|
||||
"boltcards/index.html", {"request": request, "user": user.dict()}
|
||||
)
|
||||
@@ -0,0 +1,170 @@
|
||||
import secrets
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi.params import Depends, Query
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||
|
||||
from . import boltcards_ext
|
||||
from .crud import (
|
||||
create_card,
|
||||
create_hit,
|
||||
delete_card,
|
||||
enable_disable_card,
|
||||
get_card,
|
||||
get_card_by_otp,
|
||||
get_card_by_uid,
|
||||
get_cards,
|
||||
get_hits,
|
||||
get_refunds,
|
||||
update_card,
|
||||
update_card_counter,
|
||||
update_card_otp,
|
||||
)
|
||||
from .models import CreateCardData
|
||||
from .nxp424 import decryptSUN, getSunMAC
|
||||
|
||||
|
||||
@boltcards_ext.get("/api/v1/cards")
|
||||
async def api_cards(
|
||||
g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)
|
||||
):
|
||||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if all_wallets:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
|
||||
return [card.dict() for card in await get_cards(wallet_ids)]
|
||||
|
||||
|
||||
@boltcards_ext.post("/api/v1/cards", status_code=HTTPStatus.CREATED)
|
||||
@boltcards_ext.put("/api/v1/cards/{card_id}", status_code=HTTPStatus.OK)
|
||||
async def api_card_create_or_update(
|
||||
# req: Request,
|
||||
data: CreateCardData,
|
||||
card_id: str = None,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
try:
|
||||
if len(bytes.fromhex(data.uid)) != 7:
|
||||
raise HTTPException(
|
||||
detail="Invalid bytes for card uid.", status_code=HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
|
||||
if len(bytes.fromhex(data.k0)) != 16:
|
||||
raise HTTPException(
|
||||
detail="Invalid bytes for k0.", status_code=HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
|
||||
if len(bytes.fromhex(data.k1)) != 16:
|
||||
raise HTTPException(
|
||||
detail="Invalid bytes for k1.", status_code=HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
|
||||
if len(bytes.fromhex(data.k2)) != 16:
|
||||
raise HTTPException(
|
||||
detail="Invalid bytes for k2.", status_code=HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
except:
|
||||
raise HTTPException(
|
||||
detail="Invalid byte data provided.", status_code=HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
if card_id:
|
||||
card = await get_card(card_id)
|
||||
if not card:
|
||||
raise HTTPException(
|
||||
detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND
|
||||
)
|
||||
if card.wallet != wallet.wallet.id:
|
||||
raise HTTPException(
|
||||
detail="Not your card.", status_code=HTTPStatus.FORBIDDEN
|
||||
)
|
||||
checkUid = await get_card_by_uid(data.uid)
|
||||
if checkUid and checkUid.id != card_id:
|
||||
raise HTTPException(
|
||||
detail="UID already registered. Delete registered card and try again.",
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
card = await update_card(card_id, **data.dict())
|
||||
else:
|
||||
checkUid = await get_card_by_uid(data.uid)
|
||||
if checkUid:
|
||||
raise HTTPException(
|
||||
detail="UID already registered. Delete registered card and try again.",
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
card = await create_card(wallet_id=wallet.wallet.id, data=data)
|
||||
return card.dict()
|
||||
|
||||
|
||||
@boltcards_ext.get("/api/v1/cards/enable/{card_id}/{enable}", status_code=HTTPStatus.OK)
|
||||
async def enable_card(
|
||||
card_id,
|
||||
enable,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
card = await get_card(card_id)
|
||||
if not card:
|
||||
raise HTTPException(detail="No card found.", status_code=HTTPStatus.NOT_FOUND)
|
||||
if card.wallet != wallet.wallet.id:
|
||||
raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN)
|
||||
card = await enable_disable_card(enable=enable, id=card_id)
|
||||
return card.dict()
|
||||
|
||||
|
||||
@boltcards_ext.delete("/api/v1/cards/{card_id}")
|
||||
async def api_card_delete(card_id, wallet: WalletTypeInfo = Depends(require_admin_key)):
|
||||
card = await get_card(card_id)
|
||||
|
||||
if not card:
|
||||
raise HTTPException(
|
||||
detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND
|
||||
)
|
||||
|
||||
if card.wallet != wallet.wallet.id:
|
||||
raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN)
|
||||
|
||||
await delete_card(card_id)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
|
||||
|
||||
@boltcards_ext.get("/api/v1/hits")
|
||||
async def api_hits(
|
||||
g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)
|
||||
):
|
||||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if all_wallets:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
|
||||
cards = await get_cards(wallet_ids)
|
||||
cards_ids = []
|
||||
for card in cards:
|
||||
cards_ids.append(card.id)
|
||||
|
||||
return [hit.dict() for hit in await get_hits(cards_ids)]
|
||||
|
||||
|
||||
@boltcards_ext.get("/api/v1/refunds")
|
||||
async def api_hits(
|
||||
g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)
|
||||
):
|
||||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if all_wallets:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
|
||||
cards = await get_cards(wallet_ids)
|
||||
cards_ids = []
|
||||
for card in cards:
|
||||
cards_ids.append(card.id)
|
||||
hits = await get_hits(cards_ids)
|
||||
hits_ids = []
|
||||
for hit in hits:
|
||||
hits_ids.append(hit.id)
|
||||
|
||||
return [refund.dict() for refund in await get_refunds(hits_ids)]
|
||||
@@ -0,0 +1,40 @@
|
||||
# Swap on [Boltz](https://boltz.exchange)
|
||||
providing **trustless** and **account-free** swap services since **2018.**
|
||||
move **IN** and **OUT** of the **lightning network** and remain in control of your bitcoin, at all times.
|
||||
* [Lightning Node](https://amboss.space/node/026165850492521f4ac8abd9bd8088123446d126f648ca35e60f88177dc149ceb2)
|
||||
* [Documentation](https://docs.boltz.exchange/en/latest/)
|
||||
* [Discord](https://discord.gg/d6EK85KK)
|
||||
* [Twitter](https://twitter.com/Boltzhq)
|
||||
|
||||
# usage
|
||||
This extension lets you create swaps, reverse swaps and in the case of failure refund your onchain funds.
|
||||
|
||||
## create normal swap
|
||||
1. click on "Swap (IN)" button to open following dialog, select a wallet, choose a proper amount in the min-max range and choose a onchain address to do your refund to if the swap fails after you already commited onchain funds.
|
||||
---
|
||||

|
||||
---
|
||||
2. after you confirm your inputs, following dialog with the QR code for the onchain transaction, onchain- address and amount, will pop up.
|
||||
---
|
||||

|
||||
---
|
||||
3. after you pay this onchain address with the correct amount, boltz will see it and will pay your invoice and the sats will appear on your wallet.
|
||||
|
||||
if anything goes wrong when boltz is trying to pay your invoice, the swap will fail and you will need to refund your onchain funds after the timeout block height hit. (if boltz can pay the invoice, it wont be able to redeem your onchain funds either).
|
||||
|
||||
## create reverse swap
|
||||
1. click on "Swap (OUT)" button to open following dialog, select a wallet, choose a proper amount in the min-max range and choose a onchain address to receive your funds to. Instant settlement: means that LNbits will create the onchain claim transaction if it sees the boltz lockup transaction in the mempool, but it is not confirmed yet. it is advised to leave this checked because it is faster and the longer is takes to settle, the higher the chances are that the lightning invoice expires and the swap fails.
|
||||
---
|
||||

|
||||
---
|
||||
if this swap fails, boltz is doing the onchain refunding, because they have to commit onchain funds.
|
||||
|
||||
# refund locked onchain funds from a normal swap
|
||||
if for some reason the normal swap fails and you already paid onchain, you can easily refund your btc.
|
||||
this can happen if boltz is not able to pay your lightning invoice after you locked up your funds.
|
||||
in case that happens, there is a info icon in the Swap (In) List which opens following dialog.
|
||||
---
|
||||

|
||||
----
|
||||
if the timeout block height is exceeded you can either press refund and lnbits will do the refunding to the address you specified when creating the swap. Or download the refundfile so you can manually refund your onchain directly on the boltz.exchange website.
|
||||
if you think there is something wrong and/or you are unsure, you can ask for help either in LNbits telegram or in Boltz [Discord](https://discord.gg/d6EK85KK)
|
||||
@@ -0,0 +1,26 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import template_renderer
|
||||
from lnbits.tasks import catch_everything_and_restart
|
||||
|
||||
db = Database("ext_boltz")
|
||||
|
||||
boltz_ext: APIRouter = APIRouter(prefix="/boltz", tags=["boltz"])
|
||||
|
||||
|
||||
def boltz_renderer():
|
||||
return template_renderer(["lnbits/extensions/boltz/templates"])
|
||||
|
||||
|
||||
from .tasks import check_for_pending_swaps, wait_for_paid_invoices
|
||||
from .views import * # noqa
|
||||
from .views_api import * # noqa
|
||||
|
||||
|
||||
def boltz_start():
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.create_task(check_for_pending_swaps())
|
||||
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
||||
@@ -0,0 +1,424 @@
|
||||
import asyncio
|
||||
import os
|
||||
from binascii import hexlify, unhexlify
|
||||
from hashlib import sha256
|
||||
from typing import Awaitable, Union
|
||||
|
||||
import httpx
|
||||
from embit import ec, script
|
||||
from embit.networks import NETWORKS
|
||||
from embit.transaction import SIGHASH, Transaction, TransactionInput, TransactionOutput
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.services import create_invoice, pay_invoice
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
from lnbits.settings import BOLTZ_NETWORK, BOLTZ_URL
|
||||
|
||||
from .crud import update_swap_status
|
||||
from .mempool import (
|
||||
get_fee_estimation,
|
||||
get_mempool_blockheight,
|
||||
get_mempool_fees,
|
||||
get_mempool_tx,
|
||||
get_mempool_tx_from_txs,
|
||||
send_onchain_tx,
|
||||
wait_for_websocket_message,
|
||||
)
|
||||
from .models import (
|
||||
CreateReverseSubmarineSwap,
|
||||
CreateSubmarineSwap,
|
||||
ReverseSubmarineSwap,
|
||||
SubmarineSwap,
|
||||
SwapStatus,
|
||||
)
|
||||
from .utils import check_balance, get_timestamp, req_wrap
|
||||
|
||||
net = NETWORKS[BOLTZ_NETWORK]
|
||||
logger.debug(f"BOLTZ_URL: {BOLTZ_URL}")
|
||||
logger.debug(f"Bitcoin Network: {net['name']}")
|
||||
|
||||
|
||||
async def create_swap(data: CreateSubmarineSwap) -> SubmarineSwap:
|
||||
if not check_boltz_limits(data.amount):
|
||||
msg = f"Boltz - swap not in boltz limits"
|
||||
logger.warning(msg)
|
||||
raise Exception(msg)
|
||||
|
||||
swap_id = urlsafe_short_hash()
|
||||
try:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=data.wallet,
|
||||
amount=data.amount,
|
||||
memo=f"swap of {data.amount} sats on boltz.exchange",
|
||||
extra={"tag": "boltz", "swap_id": swap_id},
|
||||
)
|
||||
except Exception as exc:
|
||||
msg = f"Boltz - create_invoice failed {str(exc)}"
|
||||
logger.error(msg)
|
||||
raise
|
||||
|
||||
refund_privkey = ec.PrivateKey(os.urandom(32), True, net)
|
||||
refund_pubkey_hex = hexlify(refund_privkey.sec()).decode("UTF-8")
|
||||
|
||||
res = req_wrap(
|
||||
"post",
|
||||
f"{BOLTZ_URL}/createswap",
|
||||
json={
|
||||
"type": "submarine",
|
||||
"pairId": "BTC/BTC",
|
||||
"orderSide": "sell",
|
||||
"refundPublicKey": refund_pubkey_hex,
|
||||
"invoice": payment_request,
|
||||
"referralId": "lnbits",
|
||||
},
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
res = res.json()
|
||||
logger.info(
|
||||
f"Boltz - created normal swap, boltz_id: {res['id']}. wallet: {data.wallet}"
|
||||
)
|
||||
return SubmarineSwap(
|
||||
id=swap_id,
|
||||
time=get_timestamp(),
|
||||
wallet=data.wallet,
|
||||
amount=data.amount,
|
||||
payment_hash=payment_hash,
|
||||
refund_privkey=refund_privkey.wif(net),
|
||||
refund_address=data.refund_address,
|
||||
boltz_id=res["id"],
|
||||
status="pending",
|
||||
address=res["address"],
|
||||
expected_amount=res["expectedAmount"],
|
||||
timeout_block_height=res["timeoutBlockHeight"],
|
||||
bip21=res["bip21"],
|
||||
redeem_script=res["redeemScript"],
|
||||
)
|
||||
|
||||
|
||||
"""
|
||||
explanation taken from electrum
|
||||
send on Lightning, receive on-chain
|
||||
- User generates preimage, RHASH. Sends RHASH to server.
|
||||
- Server creates an LN invoice for RHASH.
|
||||
- User pays LN invoice - except server needs to hold the HTLC as preimage is unknown.
|
||||
- Server creates on-chain output locked to RHASH.
|
||||
- User spends on-chain output, revealing preimage.
|
||||
- Server fulfills HTLC using preimage.
|
||||
Note: expected_onchain_amount_sat is BEFORE deducting the on-chain claim tx fee.
|
||||
"""
|
||||
|
||||
|
||||
async def create_reverse_swap(
|
||||
data: CreateReverseSubmarineSwap,
|
||||
) -> [ReverseSubmarineSwap, asyncio.Task]:
|
||||
if not check_boltz_limits(data.amount):
|
||||
msg = f"Boltz - reverse swap not in boltz limits"
|
||||
logger.warning(msg)
|
||||
raise Exception(msg)
|
||||
|
||||
swap_id = urlsafe_short_hash()
|
||||
|
||||
if not await check_balance(data):
|
||||
logger.error(f"Boltz - reverse swap, insufficient balance.")
|
||||
return False
|
||||
|
||||
claim_privkey = ec.PrivateKey(os.urandom(32), True, net)
|
||||
claim_pubkey_hex = hexlify(claim_privkey.sec()).decode("UTF-8")
|
||||
preimage = os.urandom(32)
|
||||
preimage_hash = sha256(preimage).hexdigest()
|
||||
|
||||
res = req_wrap(
|
||||
"post",
|
||||
f"{BOLTZ_URL}/createswap",
|
||||
json={
|
||||
"type": "reversesubmarine",
|
||||
"pairId": "BTC/BTC",
|
||||
"orderSide": "buy",
|
||||
"invoiceAmount": data.amount,
|
||||
"preimageHash": preimage_hash,
|
||||
"claimPublicKey": claim_pubkey_hex,
|
||||
"referralId": "lnbits",
|
||||
},
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
res = res.json()
|
||||
|
||||
logger.info(
|
||||
f"Boltz - created reverse swap, boltz_id: {res['id']}. wallet: {data.wallet}"
|
||||
)
|
||||
|
||||
swap = ReverseSubmarineSwap(
|
||||
id=swap_id,
|
||||
amount=data.amount,
|
||||
wallet=data.wallet,
|
||||
onchain_address=data.onchain_address,
|
||||
instant_settlement=data.instant_settlement,
|
||||
claim_privkey=claim_privkey.wif(net),
|
||||
preimage=preimage.hex(),
|
||||
status="pending",
|
||||
boltz_id=res["id"],
|
||||
timeout_block_height=res["timeoutBlockHeight"],
|
||||
lockup_address=res["lockupAddress"],
|
||||
onchain_amount=res["onchainAmount"],
|
||||
redeem_script=res["redeemScript"],
|
||||
invoice=res["invoice"],
|
||||
time=get_timestamp(),
|
||||
)
|
||||
logger.debug(f"Boltz - waiting for onchain tx, reverse swap_id: {swap.id}")
|
||||
task = create_task_log_exception(
|
||||
swap.id, wait_for_onchain_tx(swap, swap_websocket_callback_initial)
|
||||
)
|
||||
return swap, task
|
||||
|
||||
|
||||
def start_onchain_listener(swap: ReverseSubmarineSwap) -> asyncio.Task:
|
||||
return create_task_log_exception(
|
||||
swap.id, wait_for_onchain_tx(swap, swap_websocket_callback_restart)
|
||||
)
|
||||
|
||||
|
||||
async def start_confirmation_listener(
|
||||
swap: ReverseSubmarineSwap, mempool_lockup_tx
|
||||
) -> asyncio.Task:
|
||||
logger.debug(f"Boltz - reverse swap, waiting for confirmation...")
|
||||
|
||||
tx, txid, *_ = mempool_lockup_tx
|
||||
|
||||
confirmed = await wait_for_websocket_message({"track-tx": txid}, "txConfirmed")
|
||||
if confirmed:
|
||||
logger.debug(f"Boltz - reverse swap lockup transaction confirmed! claiming...")
|
||||
await create_claim_tx(swap, mempool_lockup_tx)
|
||||
else:
|
||||
logger.debug(f"Boltz - reverse swap lockup transaction still not confirmed.")
|
||||
|
||||
|
||||
def create_task_log_exception(swap_id: str, awaitable: Awaitable) -> asyncio.Task:
|
||||
async def _log_exception(awaitable):
|
||||
try:
|
||||
return await awaitable
|
||||
except Exception as e:
|
||||
logger.error(f"Boltz - reverse swap failed!: {swap_id} - {e}")
|
||||
await update_swap_status(swap_id, "failed")
|
||||
|
||||
return asyncio.create_task(_log_exception(awaitable))
|
||||
|
||||
|
||||
async def swap_websocket_callback_initial(swap):
|
||||
wstask = asyncio.create_task(
|
||||
wait_for_websocket_message(
|
||||
{"track-address": swap.lockup_address}, "address-transactions"
|
||||
)
|
||||
)
|
||||
logger.debug(
|
||||
f"Boltz - created task, waiting on mempool websocket for address: {swap.lockup_address}"
|
||||
)
|
||||
|
||||
# create_task is used because pay_invoice is stuck as long as boltz does not
|
||||
# see the onchain claim tx and it ends up in deadlock
|
||||
task: asyncio.Task = create_task_log_exception(
|
||||
swap.id,
|
||||
pay_invoice(
|
||||
wallet_id=swap.wallet,
|
||||
payment_request=swap.invoice,
|
||||
description=f"reverse swap for {swap.amount} sats on boltz.exchange",
|
||||
extra={"tag": "boltz", "swap_id": swap.id, "reverse": True},
|
||||
),
|
||||
)
|
||||
logger.debug(f"Boltz - task pay_invoice created, reverse swap_id: {swap.id}")
|
||||
|
||||
done, pending = await asyncio.wait(
|
||||
[task, wstask], return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
message = done.pop().result()
|
||||
|
||||
# pay_invoice already failed, do not wait for onchain tx anymore
|
||||
if message is None:
|
||||
logger.debug(f"Boltz - pay_invoice already failed cancel websocket task.")
|
||||
wstask.cancel()
|
||||
raise
|
||||
|
||||
return task, message
|
||||
|
||||
|
||||
async def swap_websocket_callback_restart(swap):
|
||||
logger.debug(f"Boltz - swap_websocket_callback_restart called...")
|
||||
message = await wait_for_websocket_message(
|
||||
{"track-address": swap.lockup_address}, "address-transactions"
|
||||
)
|
||||
return None, message
|
||||
|
||||
|
||||
async def wait_for_onchain_tx(swap: ReverseSubmarineSwap, callback):
|
||||
task, txs = await callback(swap)
|
||||
mempool_lockup_tx = get_mempool_tx_from_txs(txs, swap.lockup_address)
|
||||
if mempool_lockup_tx:
|
||||
tx, txid, *_ = mempool_lockup_tx
|
||||
if swap.instant_settlement or tx["status"]["confirmed"]:
|
||||
logger.debug(
|
||||
f"Boltz - reverse swap instant settlement, claiming immediatly..."
|
||||
)
|
||||
await create_claim_tx(swap, mempool_lockup_tx)
|
||||
else:
|
||||
await start_confirmation_listener(swap, mempool_lockup_tx)
|
||||
try:
|
||||
if task:
|
||||
await task
|
||||
except:
|
||||
logger.error(
|
||||
f"Boltz - could not await pay_invoice task, but sent onchain. should never happen!"
|
||||
)
|
||||
else:
|
||||
logger.error(f"Boltz - mempool lockup tx not found.")
|
||||
|
||||
|
||||
async def create_claim_tx(swap: ReverseSubmarineSwap, mempool_lockup_tx):
|
||||
tx = await create_onchain_tx(swap, mempool_lockup_tx)
|
||||
await send_onchain_tx(tx)
|
||||
logger.debug(f"Boltz - onchain tx sent, reverse swap completed")
|
||||
await update_swap_status(swap.id, "complete")
|
||||
|
||||
|
||||
async def create_refund_tx(swap: SubmarineSwap):
|
||||
mempool_lockup_tx = get_mempool_tx(swap.address)
|
||||
tx = await create_onchain_tx(swap, mempool_lockup_tx)
|
||||
await send_onchain_tx(tx)
|
||||
|
||||
|
||||
def check_block_height(block_height: int):
|
||||
current_block_height = get_mempool_blockheight()
|
||||
if current_block_height <= block_height:
|
||||
msg = f"refund not possible, timeout_block_height ({block_height}) is not yet exceeded ({current_block_height})"
|
||||
logger.debug(msg)
|
||||
raise Exception(msg)
|
||||
|
||||
|
||||
"""
|
||||
a submarine swap consists of 2 onchain tx's a lockup and a redeem tx.
|
||||
we create a tx to redeem the funds locked by the onchain lockup tx.
|
||||
claim tx for reverse swaps, refund tx for normal swaps they are the same
|
||||
onchain redeem tx, the difference between them is the private key, onchain_address,
|
||||
input sequence and input script_sig
|
||||
"""
|
||||
|
||||
|
||||
async def create_onchain_tx(
|
||||
swap: Union[ReverseSubmarineSwap, SubmarineSwap], mempool_lockup_tx
|
||||
) -> Transaction:
|
||||
is_refund_tx = type(swap) == SubmarineSwap
|
||||
if is_refund_tx:
|
||||
check_block_height(swap.timeout_block_height)
|
||||
privkey = ec.PrivateKey.from_wif(swap.refund_privkey)
|
||||
onchain_address = swap.refund_address
|
||||
preimage = b""
|
||||
sequence = 0xFFFFFFFE
|
||||
else:
|
||||
privkey = ec.PrivateKey.from_wif(swap.claim_privkey)
|
||||
preimage = unhexlify(swap.preimage)
|
||||
onchain_address = swap.onchain_address
|
||||
sequence = 0xFFFFFFFF
|
||||
|
||||
locktime = swap.timeout_block_height
|
||||
redeem_script = unhexlify(swap.redeem_script)
|
||||
|
||||
fees = get_fee_estimation()
|
||||
|
||||
tx, txid, vout_cnt, vout_amount = mempool_lockup_tx
|
||||
|
||||
script_pubkey = script.address_to_scriptpubkey(onchain_address)
|
||||
|
||||
vin = [TransactionInput(unhexlify(txid), vout_cnt, sequence=sequence)]
|
||||
vout = [TransactionOutput(vout_amount - fees, script_pubkey)]
|
||||
tx = Transaction(vin=vin, vout=vout)
|
||||
|
||||
if is_refund_tx:
|
||||
tx.locktime = locktime
|
||||
|
||||
# TODO: 2 rounds for fee calculation, look at vbytes after signing and do another TX
|
||||
s = script.Script(data=redeem_script)
|
||||
for i, inp in enumerate(vin):
|
||||
if is_refund_tx:
|
||||
rs = bytes([34]) + bytes([0]) + bytes([32]) + sha256(redeem_script).digest()
|
||||
tx.vin[i].script_sig = script.Script(data=rs)
|
||||
h = tx.sighash_segwit(i, s, vout_amount)
|
||||
sig = privkey.sign(h).serialize() + bytes([SIGHASH.ALL])
|
||||
witness_items = [sig, preimage, redeem_script]
|
||||
tx.vin[i].witness = script.Witness(items=witness_items)
|
||||
|
||||
return tx
|
||||
|
||||
|
||||
def get_swap_status(swap: Union[SubmarineSwap, ReverseSubmarineSwap]) -> SwapStatus:
|
||||
swap_status = SwapStatus(
|
||||
wallet=swap.wallet,
|
||||
swap_id=swap.id,
|
||||
)
|
||||
|
||||
try:
|
||||
boltz_request = get_boltz_status(swap.boltz_id)
|
||||
swap_status.boltz = boltz_request["status"]
|
||||
except httpx.HTTPStatusError as exc:
|
||||
json = exc.response.json()
|
||||
swap_status.boltz = json["error"]
|
||||
if "could not find" in swap_status.boltz:
|
||||
swap_status.exists = False
|
||||
|
||||
if type(swap) == SubmarineSwap:
|
||||
swap_status.reverse = False
|
||||
swap_status.address = swap.address
|
||||
else:
|
||||
swap_status.reverse = True
|
||||
swap_status.address = swap.lockup_address
|
||||
|
||||
swap_status.block_height = get_mempool_blockheight()
|
||||
swap_status.timeout_block_height = (
|
||||
f"{str(swap.timeout_block_height)} -> current: {str(swap_status.block_height)}"
|
||||
)
|
||||
|
||||
if swap_status.block_height >= swap.timeout_block_height:
|
||||
swap_status.hit_timeout = True
|
||||
|
||||
mempool_tx = get_mempool_tx(swap_status.address)
|
||||
swap_status.lockup = mempool_tx
|
||||
if mempool_tx == None:
|
||||
swap_status.has_lockup = False
|
||||
swap_status.confirmed = False
|
||||
swap_status.mempool = "transaction.unknown"
|
||||
swap_status.message = "lockup tx not in mempool"
|
||||
else:
|
||||
swap_status.has_lockup = True
|
||||
tx, *_ = mempool_tx
|
||||
if tx["status"]["confirmed"] == True:
|
||||
swap_status.mempool = "transaction.confirmed"
|
||||
swap_status.confirmed = True
|
||||
else:
|
||||
swap_status.confirmed = False
|
||||
swap_status.mempool = "transaction.unconfirmed"
|
||||
|
||||
return swap_status
|
||||
|
||||
|
||||
def check_boltz_limits(amount):
|
||||
try:
|
||||
pairs = get_boltz_pairs()
|
||||
limits = pairs["pairs"]["BTC/BTC"]["limits"]
|
||||
return amount >= limits["minimal"] and amount <= limits["maximal"]
|
||||
except:
|
||||
return False
|
||||
|
||||
|
||||
def get_boltz_pairs():
|
||||
res = req_wrap(
|
||||
"get",
|
||||
f"{BOLTZ_URL}/getpairs",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
return res.json()
|
||||
|
||||
|
||||
def get_boltz_status(boltzid):
|
||||
res = req_wrap(
|
||||
"post",
|
||||
f"{BOLTZ_URL}/swapstatus",
|
||||
json={"id": boltzid},
|
||||
)
|
||||
return res.json()
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Boltz",
|
||||
"short_description": "Perform onchain/offchain swaps",
|
||||
"icon": "swap_horiz",
|
||||
"contributors": ["dni"]
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
from http import HTTPStatus
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from . import db
|
||||
from .models import (
|
||||
CreateReverseSubmarineSwap,
|
||||
CreateSubmarineSwap,
|
||||
ReverseSubmarineSwap,
|
||||
SubmarineSwap,
|
||||
)
|
||||
|
||||
"""
|
||||
Submarine Swaps
|
||||
"""
|
||||
|
||||
|
||||
async def get_submarine_swaps(wallet_ids: Union[str, List[str]]) -> List[SubmarineSwap]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltz.submarineswap WHERE wallet IN ({q}) order by time DESC",
|
||||
(*wallet_ids,),
|
||||
)
|
||||
|
||||
return [SubmarineSwap(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_pending_submarine_swaps(
|
||||
wallet_ids: Union[str, List[str]]
|
||||
) -> List[SubmarineSwap]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltz.submarineswap WHERE wallet IN ({q}) and status='pending' order by time DESC",
|
||||
(*wallet_ids,),
|
||||
)
|
||||
return [SubmarineSwap(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_all_pending_submarine_swaps() -> List[SubmarineSwap]:
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltz.submarineswap WHERE status='pending' order by time DESC",
|
||||
)
|
||||
return [SubmarineSwap(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_submarine_swap(swap_id) -> SubmarineSwap:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM boltz.submarineswap WHERE id = ?", (swap_id,)
|
||||
)
|
||||
return SubmarineSwap(**row) if row else None
|
||||
|
||||
|
||||
async def create_submarine_swap(swap: SubmarineSwap) -> Optional[SubmarineSwap]:
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO boltz.submarineswap (
|
||||
id,
|
||||
wallet,
|
||||
payment_hash,
|
||||
status,
|
||||
boltz_id,
|
||||
refund_privkey,
|
||||
refund_address,
|
||||
expected_amount,
|
||||
timeout_block_height,
|
||||
address,
|
||||
bip21,
|
||||
redeem_script,
|
||||
amount
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
swap.id,
|
||||
swap.wallet,
|
||||
swap.payment_hash,
|
||||
swap.status,
|
||||
swap.boltz_id,
|
||||
swap.refund_privkey,
|
||||
swap.refund_address,
|
||||
swap.expected_amount,
|
||||
swap.timeout_block_height,
|
||||
swap.address,
|
||||
swap.bip21,
|
||||
swap.redeem_script,
|
||||
swap.amount,
|
||||
),
|
||||
)
|
||||
return await get_submarine_swap(swap.id)
|
||||
|
||||
|
||||
async def delete_submarine_swap(swap_id):
|
||||
await db.execute("DELETE FROM boltz.submarineswap WHERE id = ?", (swap_id,))
|
||||
|
||||
|
||||
async def get_reverse_submarine_swaps(
|
||||
wallet_ids: Union[str, List[str]]
|
||||
) -> List[ReverseSubmarineSwap]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltz.reverse_submarineswap WHERE wallet IN ({q}) order by time DESC",
|
||||
(*wallet_ids,),
|
||||
)
|
||||
|
||||
return [ReverseSubmarineSwap(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_pending_reverse_submarine_swaps(
|
||||
wallet_ids: Union[str, List[str]]
|
||||
) -> List[ReverseSubmarineSwap]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltz.reverse_submarineswap WHERE wallet IN ({q}) and status='pending' order by time DESC",
|
||||
(*wallet_ids,),
|
||||
)
|
||||
|
||||
return [ReverseSubmarineSwap(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_all_pending_reverse_submarine_swaps() -> List[ReverseSubmarineSwap]:
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltz.reverse_submarineswap WHERE status='pending' order by time DESC"
|
||||
)
|
||||
|
||||
return [ReverseSubmarineSwap(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_reverse_submarine_swap(swap_id) -> SubmarineSwap:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM boltz.reverse_submarineswap WHERE id = ?", (swap_id,)
|
||||
)
|
||||
return ReverseSubmarineSwap(**row) if row else None
|
||||
|
||||
|
||||
async def create_reverse_submarine_swap(
|
||||
swap: ReverseSubmarineSwap,
|
||||
) -> Optional[ReverseSubmarineSwap]:
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO boltz.reverse_submarineswap (
|
||||
id,
|
||||
wallet,
|
||||
status,
|
||||
boltz_id,
|
||||
instant_settlement,
|
||||
preimage,
|
||||
claim_privkey,
|
||||
lockup_address,
|
||||
invoice,
|
||||
onchain_amount,
|
||||
onchain_address,
|
||||
timeout_block_height,
|
||||
redeem_script,
|
||||
amount
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
swap.id,
|
||||
swap.wallet,
|
||||
swap.status,
|
||||
swap.boltz_id,
|
||||
swap.instant_settlement,
|
||||
swap.preimage,
|
||||
swap.claim_privkey,
|
||||
swap.lockup_address,
|
||||
swap.invoice,
|
||||
swap.onchain_amount,
|
||||
swap.onchain_address,
|
||||
swap.timeout_block_height,
|
||||
swap.redeem_script,
|
||||
swap.amount,
|
||||
),
|
||||
)
|
||||
return await get_reverse_submarine_swap(swap.id)
|
||||
|
||||
|
||||
async def update_swap_status(swap_id: str, status: str):
|
||||
|
||||
reverse = ""
|
||||
swap = await get_submarine_swap(swap_id)
|
||||
if swap is None:
|
||||
swap = await get_reverse_submarine_swap(swap_id)
|
||||
|
||||
if swap is None:
|
||||
return None
|
||||
|
||||
if type(swap) == SubmarineSwap:
|
||||
await db.execute(
|
||||
"UPDATE boltz.submarineswap SET status='"
|
||||
+ status
|
||||
+ "' WHERE id='"
|
||||
+ swap.id
|
||||
+ "'"
|
||||
)
|
||||
if type(swap) == ReverseSubmarineSwap:
|
||||
reverse = "reverse"
|
||||
await db.execute(
|
||||
"UPDATE boltz.reverse_submarineswap SET status='"
|
||||
+ status
|
||||
+ "' WHERE id='"
|
||||
+ swap.id
|
||||
+ "'"
|
||||
)
|
||||
|
||||
message = f"Boltz - {reverse} swap status change: {status}. boltz_id: {swap.boltz_id}, wallet: {swap.wallet}"
|
||||
logger.info(message)
|
||||
|
||||
return swap
|
||||
@@ -0,0 +1,97 @@
|
||||
import asyncio
|
||||
import json
|
||||
from binascii import hexlify
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
from embit.transaction import Transaction
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.settings import BOLTZ_MEMPOOL_SPACE_URL, BOLTZ_MEMPOOL_SPACE_URL_WS
|
||||
|
||||
from .utils import req_wrap
|
||||
|
||||
logger.debug(f"BOLTZ_MEMPOOL_SPACE_URL: {BOLTZ_MEMPOOL_SPACE_URL}")
|
||||
logger.debug(f"BOLTZ_MEMPOOL_SPACE_URL_WS: {BOLTZ_MEMPOOL_SPACE_URL_WS}")
|
||||
|
||||
websocket_url = f"{BOLTZ_MEMPOOL_SPACE_URL_WS}/api/v1/ws"
|
||||
|
||||
|
||||
async def wait_for_websocket_message(send, message_string):
|
||||
async for websocket in websockets.connect(websocket_url):
|
||||
try:
|
||||
await websocket.send(json.dumps({"action": "want", "data": ["blocks"]}))
|
||||
await websocket.send(json.dumps(send))
|
||||
async for raw in websocket:
|
||||
message = json.loads(raw)
|
||||
if message_string in message:
|
||||
return message.get(message_string)
|
||||
except websockets.ConnectionClosed:
|
||||
continue
|
||||
|
||||
|
||||
def get_mempool_tx(address):
|
||||
res = req_wrap(
|
||||
"get",
|
||||
f"{BOLTZ_MEMPOOL_SPACE_URL}/api/address/{address}/txs",
|
||||
headers={"Content-Type": "text/plain"},
|
||||
)
|
||||
txs = res.json()
|
||||
return get_mempool_tx_from_txs(txs, address)
|
||||
|
||||
|
||||
def get_mempool_tx_from_txs(txs, address):
|
||||
if len(txs) == 0:
|
||||
return None
|
||||
tx = txid = vout_cnt = vout_amount = None
|
||||
for a_tx in txs:
|
||||
for i, vout in enumerate(a_tx["vout"]):
|
||||
if vout["scriptpubkey_address"] == address:
|
||||
tx = a_tx
|
||||
txid = a_tx["txid"]
|
||||
vout_cnt = i
|
||||
vout_amount = vout["value"]
|
||||
# should never happen
|
||||
if tx == None:
|
||||
raise Exception("mempool tx not found")
|
||||
if txid == None:
|
||||
raise Exception("mempool txid not found")
|
||||
return tx, txid, vout_cnt, vout_amount
|
||||
|
||||
|
||||
def get_fee_estimation() -> int:
|
||||
# TODO: hardcoded maximum tx size, in the future we try to get the size of the tx via embit
|
||||
# we need a function like Transaction.vsize()
|
||||
tx_size_vbyte = 200
|
||||
mempool_fees = get_mempool_fees()
|
||||
return mempool_fees * tx_size_vbyte
|
||||
|
||||
|
||||
def get_mempool_fees() -> int:
|
||||
res = req_wrap(
|
||||
"get",
|
||||
f"{BOLTZ_MEMPOOL_SPACE_URL}/api/v1/fees/recommended",
|
||||
headers={"Content-Type": "text/plain"},
|
||||
)
|
||||
fees = res.json()
|
||||
return int(fees["economyFee"])
|
||||
|
||||
|
||||
def get_mempool_blockheight() -> int:
|
||||
res = req_wrap(
|
||||
"get",
|
||||
f"{BOLTZ_MEMPOOL_SPACE_URL}/api/blocks/tip/height",
|
||||
headers={"Content-Type": "text/plain"},
|
||||
)
|
||||
return int(res.text)
|
||||
|
||||
|
||||
async def send_onchain_tx(tx: Transaction):
|
||||
raw = hexlify(tx.serialize())
|
||||
logger.debug(f"Boltz - mempool sending onchain tx...")
|
||||
req_wrap(
|
||||
"post",
|
||||
f"{BOLTZ_MEMPOOL_SPACE_URL}/api/tx",
|
||||
headers={"Content-Type": "text/plain"},
|
||||
content=raw,
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
async def m001_initial(db):
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE boltz.submarineswap (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
payment_hash TEXT NOT NULL,
|
||||
amount INT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
boltz_id TEXT NOT NULL,
|
||||
refund_address TEXT NOT NULL,
|
||||
refund_privkey TEXT NOT NULL,
|
||||
expected_amount INT NOT NULL,
|
||||
timeout_block_height INT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
bip21 TEXT NOT NULL,
|
||||
redeem_script TEXT NOT NULL,
|
||||
time TIMESTAMP NOT NULL DEFAULT """
|
||||
+ db.timestamp_now
|
||||
+ """
|
||||
);
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE boltz.reverse_submarineswap (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
onchain_address TEXT NOT NULL,
|
||||
amount INT NOT NULL,
|
||||
instant_settlement BOOLEAN NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
boltz_id TEXT NOT NULL,
|
||||
timeout_block_height INT NOT NULL,
|
||||
redeem_script TEXT NOT NULL,
|
||||
preimage TEXT NOT NULL,
|
||||
claim_privkey TEXT NOT NULL,
|
||||
lockup_address TEXT NOT NULL,
|
||||
invoice TEXT NOT NULL,
|
||||
onchain_amount INT NOT NULL,
|
||||
time TIMESTAMP NOT NULL DEFAULT """
|
||||
+ db.timestamp_now
|
||||
+ """
|
||||
);
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
import json
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from fastapi.params import Query
|
||||
from pydantic.main import BaseModel
|
||||
from sqlalchemy.engine import base # type: ignore
|
||||
|
||||
|
||||
class SubmarineSwap(BaseModel):
|
||||
id: str
|
||||
wallet: str
|
||||
amount: int
|
||||
payment_hash: str
|
||||
time: int
|
||||
status: str
|
||||
refund_privkey: str
|
||||
refund_address: str
|
||||
boltz_id: str
|
||||
expected_amount: int
|
||||
timeout_block_height: int
|
||||
address: str
|
||||
bip21: str
|
||||
redeem_script: str
|
||||
|
||||
|
||||
class CreateSubmarineSwap(BaseModel):
|
||||
wallet: str = Query(...) # type: ignore
|
||||
refund_address: str = Query(...) # type: ignore
|
||||
amount: int = Query(...) # type: ignore
|
||||
|
||||
|
||||
class ReverseSubmarineSwap(BaseModel):
|
||||
id: str
|
||||
wallet: str
|
||||
amount: int
|
||||
onchain_address: str
|
||||
instant_settlement: bool
|
||||
time: int
|
||||
status: str
|
||||
boltz_id: str
|
||||
preimage: str
|
||||
claim_privkey: str
|
||||
lockup_address: str
|
||||
invoice: str
|
||||
onchain_amount: int
|
||||
timeout_block_height: int
|
||||
redeem_script: str
|
||||
|
||||
|
||||
class CreateReverseSubmarineSwap(BaseModel):
|
||||
wallet: str = Query(...) # type: ignore
|
||||
amount: int = Query(...) # type: ignore
|
||||
instant_settlement: bool = Query(...) # type: ignore
|
||||
# validate on-address, bcrt1 for regtest addresses
|
||||
onchain_address: str = Query(
|
||||
..., regex="^(bcrt1|bc1|[13])[a-zA-HJ-NP-Z0-9]{25,39}$"
|
||||
) # type: ignore
|
||||
|
||||
|
||||
class SwapStatus(BaseModel):
|
||||
swap_id: str
|
||||
wallet: str
|
||||
status: str = ""
|
||||
message: str = ""
|
||||
boltz: str = ""
|
||||
mempool: str = ""
|
||||
address: str = ""
|
||||
block_height: int = 0
|
||||
timeout_block_height: str = ""
|
||||
lockup: Optional[dict] = {}
|
||||
has_lockup: bool = False
|
||||
hit_timeout: bool = False
|
||||
confirmed: bool = True
|
||||
exists: bool = True
|
||||
reverse: bool = False
|
||||
@@ -0,0 +1,153 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.services import check_transaction_status
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
from .boltz import (
|
||||
create_claim_tx,
|
||||
create_refund_tx,
|
||||
get_swap_status,
|
||||
start_confirmation_listener,
|
||||
start_onchain_listener,
|
||||
)
|
||||
from .crud import (
|
||||
get_all_pending_reverse_submarine_swaps,
|
||||
get_all_pending_submarine_swaps,
|
||||
get_reverse_submarine_swap,
|
||||
get_submarine_swap,
|
||||
update_swap_status,
|
||||
)
|
||||
|
||||
"""
|
||||
testcases for boltz startup
|
||||
A. normal swaps
|
||||
1. test: create -> kill -> start -> startup invoice listeners -> pay onchain funds -> should complete
|
||||
2. test: create -> kill -> pay onchain funds -> start -> startup check -> should complete
|
||||
3. test: create -> kill -> mine blocks and hit timeout -> start -> should go timeout/failed
|
||||
4. test: create -> kill -> pay to less onchain funds -> mine blocks hit timeout -> start lnbits -> should be refunded
|
||||
|
||||
B. reverse swaps
|
||||
1. test: create instant -> kill -> boltz does lockup -> not confirmed -> start lnbits -> should claim/complete
|
||||
2. test: create instant -> kill -> no lockup -> start lnbits -> should start onchain listener -> boltz does lockup -> should claim/complete (difficult to test)
|
||||
3. test: create -> kill -> boltz does lockup -> not confirmed -> start lnbits -> should start tx listener -> after confirmation -> should claim/complete
|
||||
4. test: create -> kill -> boltz does lockup -> confirmed -> start lnbits -> should claim/complete
|
||||
5. test: create -> kill -> boltz does lockup -> hit timeout -> boltz refunds -> start -> should timeout
|
||||
"""
|
||||
|
||||
|
||||
async def check_for_pending_swaps():
|
||||
try:
|
||||
swaps = await get_all_pending_submarine_swaps()
|
||||
reverse_swaps = await get_all_pending_reverse_submarine_swaps()
|
||||
if len(swaps) > 0 or len(reverse_swaps) > 0:
|
||||
logger.debug(f"Boltz - startup swap check")
|
||||
except:
|
||||
# database is not created yet, do nothing
|
||||
return
|
||||
|
||||
if len(swaps) > 0:
|
||||
logger.debug(f"Boltz - {len(swaps)} pending swaps")
|
||||
for swap in swaps:
|
||||
try:
|
||||
swap_status = get_swap_status(swap)
|
||||
# should only happen while development when regtest is reset
|
||||
if swap_status.exists is False:
|
||||
logger.warning(f"Boltz - swap: {swap.boltz_id} does not exist.")
|
||||
await update_swap_status(swap.id, "failed")
|
||||
continue
|
||||
|
||||
payment_status = await check_transaction_status(
|
||||
swap.wallet, swap.payment_hash
|
||||
)
|
||||
|
||||
if payment_status.paid:
|
||||
logger.debug(
|
||||
f"Boltz - swap: {swap.boltz_id} got paid while offline."
|
||||
)
|
||||
await update_swap_status(swap.id, "complete")
|
||||
else:
|
||||
if swap_status.hit_timeout:
|
||||
if not swap_status.has_lockup:
|
||||
logger.warning(
|
||||
f"Boltz - swap: {swap.id} hit timeout, but no lockup tx..."
|
||||
)
|
||||
await update_swap_status(swap.id, "timeout")
|
||||
else:
|
||||
logger.debug(f"Boltz - refunding swap: {swap.id}...")
|
||||
await create_refund_tx(swap)
|
||||
await update_swap_status(swap.id, "refunded")
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Boltz - swap: {swap.id} - {str(exc)}")
|
||||
|
||||
if len(reverse_swaps) > 0:
|
||||
logger.debug(f"Boltz - {len(reverse_swaps)} pending reverse swaps")
|
||||
for reverse_swap in reverse_swaps:
|
||||
try:
|
||||
swap_status = get_swap_status(reverse_swap)
|
||||
|
||||
if swap_status.exists is False:
|
||||
logger.debug(
|
||||
f"Boltz - reverse_swap: {reverse_swap.boltz_id} does not exist."
|
||||
)
|
||||
await update_swap_status(reverse_swap.id, "failed")
|
||||
continue
|
||||
|
||||
# if timeout hit, boltz would have already refunded
|
||||
if swap_status.hit_timeout:
|
||||
logger.debug(
|
||||
f"Boltz - reverse_swap: {reverse_swap.boltz_id} timeout."
|
||||
)
|
||||
await update_swap_status(reverse_swap.id, "timeout")
|
||||
continue
|
||||
|
||||
if not swap_status.has_lockup:
|
||||
# start listener for onchain address
|
||||
logger.debug(
|
||||
f"Boltz - reverse_swap: {reverse_swap.boltz_id} restarted onchain address listener."
|
||||
)
|
||||
await start_onchain_listener(reverse_swap)
|
||||
continue
|
||||
|
||||
if reverse_swap.instant_settlement or swap_status.confirmed:
|
||||
await create_claim_tx(reverse_swap, swap_status.lockup)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Boltz - reverse_swap: {reverse_swap.boltz_id} restarted confirmation listener."
|
||||
)
|
||||
await start_confirmation_listener(reverse_swap, swap_status.lockup)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Boltz - reverse swap: {reverse_swap.id} - {str(exc)}")
|
||||
|
||||
|
||||
async def wait_for_paid_invoices():
|
||||
invoice_queue = asyncio.Queue()
|
||||
register_invoice_listener(invoice_queue)
|
||||
|
||||
while True:
|
||||
payment = await invoice_queue.get()
|
||||
await on_invoice_paid(payment)
|
||||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
if "boltz" != payment.extra.get("tag"):
|
||||
# not a boltz invoice
|
||||
return
|
||||
|
||||
await payment.set_pending(False)
|
||||
swap_id = payment.extra.get("swap_id")
|
||||
swap = await get_submarine_swap(swap_id)
|
||||
|
||||
if not swap:
|
||||
logger.error(f"swap_id: {swap_id} not found.")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Boltz - lightning invoice is paid, normal swap completed. swap_id: {swap_id}"
|
||||
)
|
||||
await update_swap_status(swap_id, "complete")
|
||||
@@ -0,0 +1,236 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="About Boltz"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<img
|
||||
src="https://boltz.exchange/static/media/Shape.6c1a92b3.svg"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
src="https://boltz.exchange/static/media/Boltz.02fb7acb.svg"
|
||||
style="padding: 5px 9px"
|
||||
alt=""
|
||||
/>
|
||||
<h5 class="text-subtitle1 q-my-none">
|
||||
Boltz.exchange: Do onchain to offchain and vice-versa swaps
|
||||
</h5>
|
||||
<p>
|
||||
Submarine and Reverse Submarine Swaps on LNbits via boltz.exchange
|
||||
API<br />
|
||||
</p>
|
||||
<p>
|
||||
Link :
|
||||
<a target="_blank" href="https://boltz.exchange"
|
||||
>https://boltz.exchange
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://github.com/lnbits/lnbits-legend/tree/main/lnbits/extensions/boltz"
|
||||
>More details</a
|
||||
>
|
||||
</p>
|
||||
<p>
|
||||
<small
|
||||
>Created by,
|
||||
<a target="_blank" href="https://github.com/dni">dni</a></small
|
||||
>
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-expansion-item group="api" dense expand-separator label="GET swap/reverse">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">GET</span>
|
||||
/boltz/api/v1/swap/reverse</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>JSON list of reverse submarine swaps</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ root_url }}/boltz/api/v1/swap/reverse -H "X-Api-Key:
|
||||
{{ user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="POST swap/reverse"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">POST</span>
|
||||
/boltz/api/v1/swap/reverse</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code
|
||||
>{"wallet": <string>, "onchain_address": <string>,
|
||||
"amount": <integer>, "instant_settlement":
|
||||
<boolean>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>JSON create a reverse-submarine swaps</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ root_url }}/boltz/api/v1/swap/reverse -H "X-Api-Key:
|
||||
{{ user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="GET swap">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-light-blue">GET</span> /boltz/api/v1/swap</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>JSON list of submarine swaps</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ root_url }}/boltz/api/v1/swap -H "X-Api-Key: {{
|
||||
user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="POST swap">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">POST</span> /boltz/api/v1/swap</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code
|
||||
>{"wallet": <string>, "refund_address": <string>,
|
||||
"amount": <integer>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>JSON create a submarine swaps</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ root_url }}/boltz/api/v1/swap -H "X-Api-Key: {{
|
||||
user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="GET swap/refund">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">POST</span>
|
||||
/boltz/api/v1/swap/refund/{swap_id}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>JSON submarine swap</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ root_url }}/boltz/api/v1/swap/refund/{swap_id} -H
|
||||
"X-Api-Key: {{ user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="GET swap/status">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">POST</span>
|
||||
/boltz/api/v1/swap/status/{swap_id}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (text/plain)
|
||||
</h5>
|
||||
<code>swap status</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ root_url }}/boltz/api/v1/swap/status/{swap_id} -H
|
||||
"X-Api-Key: {{ user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="GET swap/check">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">GET</span>
|
||||
/boltz/api/v1/swap/check</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>JSON pending swaps</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ root_url }}/boltz/api/v1/swap/check -H "X-Api-Key: {{
|
||||
user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="GET boltz-config">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">GET</span>
|
||||
/boltz/api/v1/swap/boltz</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (text/plain)
|
||||
</h5>
|
||||
<code>JSON boltz config</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ root_url }}/boltz/api/v1/swap/boltz -H "X-Api-Key: {{
|
||||
user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="GET mempool-url">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">GET</span>
|
||||
/boltz/api/v1/swap/mempool</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (text/plain)
|
||||
</h5>
|
||||
<code>mempool url</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ root_url }}/boltz/api/v1/swap/mempool -H "X-Api-Key:
|
||||
{{ user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
import calendar
|
||||
import datetime
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.services import fee_reserve, get_wallet
|
||||
|
||||
|
||||
async def check_balance(data) -> bool:
|
||||
# check if we can pay the invoice before we create the actual swap on boltz
|
||||
amount_msat = data.amount * 1000
|
||||
fee_reserve_msat = fee_reserve(amount_msat)
|
||||
wallet = await get_wallet(data.wallet)
|
||||
assert wallet
|
||||
if wallet.balance_msat - fee_reserve_msat < amount_msat:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_timestamp():
|
||||
date = datetime.datetime.utcnow()
|
||||
return calendar.timegm(date.utctimetuple())
|
||||
|
||||
|
||||
def req_wrap(funcname, *args, **kwargs):
|
||||
try:
|
||||
try:
|
||||
func = getattr(httpx, funcname)
|
||||
except AttributeError:
|
||||
logger.error('httpx function not found "%s"' % funcname)
|
||||
else:
|
||||
res = func(*args, timeout=30, **kwargs)
|
||||
res.raise_for_status()
|
||||
return res
|
||||
except httpx.RequestError as exc:
|
||||
msg = f"Unreachable: {exc.request.url!r}."
|
||||
logger.error(msg)
|
||||
raise
|
||||
except httpx.HTTPStatusError as exc:
|
||||
msg = f"HTTP Status Error: {exc.response.status_code} while requesting {exc.request.url!r}."
|
||||
logger.error(msg)
|
||||
logger.error(exc.response.json()["error"])
|
||||
raise
|
||||
@@ -0,0 +1,23 @@
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.params import Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from lnbits.core.models import Payment, User
|
||||
from lnbits.decorators import check_user_exists
|
||||
|
||||
from . import boltz_ext, boltz_renderer
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@boltz_ext.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||
root_url = urlparse(str(request.url)).netloc
|
||||
wallet_ids = [wallet.id for wallet in user.wallets]
|
||||
return boltz_renderer().TemplateResponse(
|
||||
"boltz/index.html",
|
||||
{"request": request, "user": user.dict(), "root_url": root_url},
|
||||
)
|
||||
@@ -0,0 +1,338 @@
|
||||
from datetime import datetime
|
||||
from http import HTTPStatus
|
||||
from typing import List
|
||||
|
||||
import httpx
|
||||
from fastapi import status
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.param_functions import Body
|
||||
from fastapi.params import Depends, Query
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||
from lnbits.settings import BOLTZ_MEMPOOL_SPACE_URL
|
||||
|
||||
from . import boltz_ext
|
||||
from .boltz import (
|
||||
create_refund_tx,
|
||||
create_reverse_swap,
|
||||
create_swap,
|
||||
get_boltz_pairs,
|
||||
get_swap_status,
|
||||
)
|
||||
from .crud import (
|
||||
create_reverse_submarine_swap,
|
||||
create_submarine_swap,
|
||||
get_pending_reverse_submarine_swaps,
|
||||
get_pending_submarine_swaps,
|
||||
get_reverse_submarine_swap,
|
||||
get_reverse_submarine_swaps,
|
||||
get_submarine_swap,
|
||||
get_submarine_swaps,
|
||||
update_swap_status,
|
||||
)
|
||||
from .models import (
|
||||
CreateReverseSubmarineSwap,
|
||||
CreateSubmarineSwap,
|
||||
ReverseSubmarineSwap,
|
||||
SubmarineSwap,
|
||||
)
|
||||
from .utils import check_balance
|
||||
|
||||
|
||||
@boltz_ext.get(
|
||||
"/api/v1/swap/mempool",
|
||||
name=f"boltz.get /swap/mempool",
|
||||
summary="get a the mempool url",
|
||||
description="""
|
||||
This endpoint gets the URL from mempool.space
|
||||
""",
|
||||
response_description="mempool.space url",
|
||||
response_model=str,
|
||||
)
|
||||
async def api_mempool_url():
|
||||
return BOLTZ_MEMPOOL_SPACE_URL
|
||||
|
||||
|
||||
# NORMAL SWAP
|
||||
@boltz_ext.get(
|
||||
"/api/v1/swap",
|
||||
name=f"boltz.get /swap",
|
||||
summary="get a list of swaps a swap",
|
||||
description="""
|
||||
This endpoint gets a list of normal swaps.
|
||||
""",
|
||||
response_description="list of normal swaps",
|
||||
dependencies=[Depends(get_key_type)],
|
||||
response_model=List[SubmarineSwap],
|
||||
)
|
||||
async def api_submarineswap(
|
||||
g: WalletTypeInfo = Depends(get_key_type),
|
||||
all_wallets: bool = Query(False),
|
||||
):
|
||||
wallet_ids = [g.wallet.id]
|
||||
if all_wallets:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
|
||||
for swap in await get_pending_submarine_swaps(wallet_ids):
|
||||
swap_status = get_swap_status(swap)
|
||||
if swap_status.hit_timeout:
|
||||
if not swap_status.has_lockup:
|
||||
logger.warning(
|
||||
f"Boltz - swap: {swap.id} hit timeout, but no lockup tx..."
|
||||
)
|
||||
await update_swap_status(swap.id, "timeout")
|
||||
|
||||
return [swap.dict() for swap in await get_submarine_swaps(wallet_ids)]
|
||||
|
||||
|
||||
@boltz_ext.post(
|
||||
"/api/v1/swap/refund",
|
||||
name=f"boltz.swap_refund",
|
||||
summary="refund of a swap",
|
||||
description="""
|
||||
This endpoint attempts to refund a normal swaps, creates onchain tx and sets swap status ro refunded.
|
||||
""",
|
||||
response_description="refunded swap with status set to refunded",
|
||||
dependencies=[Depends(require_admin_key)],
|
||||
response_model=SubmarineSwap,
|
||||
responses={
|
||||
400: {"description": "when swap_id is missing"},
|
||||
404: {"description": "when swap is not found"},
|
||||
405: {"description": "when swap is not pending"},
|
||||
500: {
|
||||
"description": "when something goes wrong creating the refund onchain tx"
|
||||
},
|
||||
},
|
||||
)
|
||||
async def api_submarineswap_refund(
|
||||
swap_id: str,
|
||||
g: WalletTypeInfo = Depends(require_admin_key), # type: ignore
|
||||
):
|
||||
if swap_id == None:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="swap_id missing"
|
||||
)
|
||||
|
||||
swap = await get_submarine_swap(swap_id)
|
||||
if swap == None:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="swap does not exist."
|
||||
)
|
||||
|
||||
if swap.status != "pending":
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.METHOD_NOT_ALLOWED, detail="swap is not pending."
|
||||
)
|
||||
|
||||
try:
|
||||
await create_refund_tx(swap)
|
||||
except httpx.RequestError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f"Unreachable: {exc.request.url!r}.",
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=HTTPStatus.METHOD_NOT_ALLOWED, detail=str(exc))
|
||||
|
||||
await update_swap_status(swap.id, "refunded")
|
||||
return swap
|
||||
|
||||
|
||||
@boltz_ext.post(
|
||||
"/api/v1/swap",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
name=f"boltz.post /swap",
|
||||
summary="create a submarine swap",
|
||||
description="""
|
||||
This endpoint creates a submarine swap
|
||||
""",
|
||||
response_description="create swap",
|
||||
response_model=SubmarineSwap,
|
||||
responses={
|
||||
405: {"description": "not allowed method, insufficient balance"},
|
||||
500: {"description": "boltz error"},
|
||||
},
|
||||
)
|
||||
async def api_submarineswap_create(
|
||||
data: CreateSubmarineSwap,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key), # type: ignore
|
||||
):
|
||||
try:
|
||||
swap_data = await create_swap(data)
|
||||
except httpx.RequestError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f"Unreachable: {exc.request.url!r}.",
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=HTTPStatus.METHOD_NOT_ALLOWED, detail=str(exc))
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(
|
||||
status_code=exc.response.status_code, detail=exc.response.json()["error"]
|
||||
)
|
||||
swap = await create_submarine_swap(swap_data)
|
||||
return swap.dict()
|
||||
|
||||
|
||||
# REVERSE SWAP
|
||||
@boltz_ext.get(
|
||||
"/api/v1/swap/reverse",
|
||||
name=f"boltz.get /swap/reverse",
|
||||
summary="get a list of reverse swaps a swap",
|
||||
description="""
|
||||
This endpoint gets a list of reverse swaps.
|
||||
""",
|
||||
response_description="list of reverse swaps",
|
||||
dependencies=[Depends(get_key_type)],
|
||||
response_model=List[ReverseSubmarineSwap],
|
||||
)
|
||||
async def api_reverse_submarineswap(
|
||||
g: WalletTypeInfo = Depends(get_key_type), # type:ignore
|
||||
all_wallets: bool = Query(False),
|
||||
):
|
||||
wallet_ids = [g.wallet.id]
|
||||
if all_wallets:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
return [swap.dict() for swap in await get_reverse_submarine_swaps(wallet_ids)]
|
||||
|
||||
|
||||
@boltz_ext.post(
|
||||
"/api/v1/swap/reverse",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
name=f"boltz.post /swap/reverse",
|
||||
summary="create a reverse submarine swap",
|
||||
description="""
|
||||
This endpoint creates a reverse submarine swap
|
||||
""",
|
||||
response_description="create reverse swap",
|
||||
response_model=ReverseSubmarineSwap,
|
||||
responses={
|
||||
405: {"description": "not allowed method, insufficient balance"},
|
||||
500: {"description": "boltz error"},
|
||||
},
|
||||
)
|
||||
async def api_reverse_submarineswap_create(
|
||||
data: CreateReverseSubmarineSwap,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
|
||||
if not await check_balance(data):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.METHOD_NOT_ALLOWED, detail="Insufficient balance."
|
||||
)
|
||||
|
||||
try:
|
||||
swap_data, task = await create_reverse_swap(data)
|
||||
except httpx.RequestError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f"Unreachable: {exc.request.url!r}.",
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(
|
||||
status_code=exc.response.status_code, detail=exc.response.json()["error"]
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=HTTPStatus.METHOD_NOT_ALLOWED, detail=str(exc))
|
||||
|
||||
swap = await create_reverse_submarine_swap(swap_data)
|
||||
return swap.dict()
|
||||
|
||||
|
||||
@boltz_ext.post(
|
||||
"/api/v1/swap/status",
|
||||
name=f"boltz.swap_status",
|
||||
summary="shows the status of a swap",
|
||||
description="""
|
||||
This endpoint attempts to get the status of the swap.
|
||||
""",
|
||||
response_description="status of swap json",
|
||||
responses={
|
||||
404: {"description": "when swap_id is not found"},
|
||||
},
|
||||
)
|
||||
async def api_swap_status(
|
||||
swap_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) # type: ignore
|
||||
):
|
||||
swap = await get_submarine_swap(swap_id) or await get_reverse_submarine_swap(
|
||||
swap_id
|
||||
)
|
||||
if swap == None:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="swap does not exist."
|
||||
)
|
||||
try:
|
||||
status = get_swap_status(swap)
|
||||
except httpx.RequestError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f"Unreachable: {exc.request.url!r}.",
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
|
||||
)
|
||||
return status
|
||||
|
||||
|
||||
@boltz_ext.post(
|
||||
"/api/v1/swap/check",
|
||||
name=f"boltz.swap_check",
|
||||
summary="list all pending swaps",
|
||||
description="""
|
||||
This endpoint gives you 2 lists of pending swaps and reverse swaps.
|
||||
""",
|
||||
response_description="list of pending swaps",
|
||||
)
|
||||
async def api_check_swaps(
|
||||
g: WalletTypeInfo = Depends(require_admin_key), # type: ignore
|
||||
all_wallets: bool = Query(False),
|
||||
):
|
||||
wallet_ids = [g.wallet.id]
|
||||
if all_wallets:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
status = []
|
||||
try:
|
||||
for swap in await get_pending_submarine_swaps(wallet_ids):
|
||||
status.append(get_swap_status(swap))
|
||||
for reverseswap in await get_pending_reverse_submarine_swaps(wallet_ids):
|
||||
status.append(get_swap_status(reverseswap))
|
||||
except httpx.RequestError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f"Unreachable: {exc.request.url!r}.",
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
|
||||
)
|
||||
return status
|
||||
|
||||
|
||||
@boltz_ext.get(
|
||||
"/api/v1/swap/boltz",
|
||||
name=f"boltz.get /swap/boltz",
|
||||
summary="get a boltz configuration",
|
||||
description="""
|
||||
This endpoint gets configuration for boltz. (limits, fees...)
|
||||
""",
|
||||
response_description="dict of boltz config",
|
||||
response_model=dict,
|
||||
)
|
||||
async def api_boltz_config():
|
||||
try:
|
||||
res = get_boltz_pairs()
|
||||
except httpx.RequestError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f"Unreachable: {exc.request.url!r}.",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
return res["pairs"]["BTC/BTC"]
|
||||
@@ -12,7 +12,7 @@ db = Database("ext_copilot")
|
||||
copilot_static_files = [
|
||||
{
|
||||
"path": "/copilot/static",
|
||||
"app": StaticFiles(directory="lnbits/extensions/copilot/static"),
|
||||
"app": StaticFiles(packages=[("lnbits", "extensions/copilot/static")]),
|
||||
"name": "copilot_static",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -88,7 +88,7 @@ async def get_copilot(copilot_id: str) -> Copilots:
|
||||
|
||||
async def get_copilots(user: str) -> List[Copilots]:
|
||||
rows = await db.fetchall(
|
||||
"SELECT * FROM copilot.newer_copilots WHERE user = ?", (user,)
|
||||
'SELECT * FROM copilot.newer_copilots WHERE "user" = ?', (user,)
|
||||
)
|
||||
return [Copilots(**row) for row in rows]
|
||||
|
||||
|
||||
@@ -73,11 +73,9 @@ async def lnurl_callback(
|
||||
wallet_id=cp.wallet,
|
||||
amount=int(amount_received / 1000),
|
||||
memo=cp.lnurl_title,
|
||||
description_hash=hashlib.sha256(
|
||||
(
|
||||
LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]]))
|
||||
).encode("utf-8")
|
||||
).digest(),
|
||||
unhashed_description=(
|
||||
LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]]))
|
||||
).encode("utf-8"),
|
||||
extra={"tag": "copilot", "copilotid": cp.id, "comment": comment},
|
||||
)
|
||||
payResponse = {"pr": payment_request, "routes": []}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode, ParseResult
|
||||
from starlette.requests import Request
|
||||
from fastapi.param_functions import Query
|
||||
from typing import Optional, Dict
|
||||
from lnbits.lnurl import encode as lnurl_encode # type: ignore
|
||||
from lnurl.types import LnurlPayMetadata # type: ignore
|
||||
from pydantic import BaseModel
|
||||
import json
|
||||
from sqlite3 import Row
|
||||
from typing import Dict, Optional
|
||||
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
from fastapi.param_functions import Query
|
||||
from lnurl.types import LnurlPayMetadata # type: ignore
|
||||
from pydantic import BaseModel
|
||||
from starlette.requests import Request
|
||||
|
||||
from lnbits.lnurl import encode as lnurl_encode # type: ignore
|
||||
|
||||
|
||||
class CreateCopilotData(BaseModel):
|
||||
|
||||
@@ -25,7 +25,7 @@ async def wait_for_paid_invoices():
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
webhook = None
|
||||
data = None
|
||||
if "copilot" != payment.extra.get("tag"):
|
||||
if payment.extra.get("tag") != "copilot":
|
||||
# not an copilot invoice
|
||||
return
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-btn flat label="Swagger API" type="a" href="../docs#/copilot"></q-btn>
|
||||
<q-expansion-item group="api" dense expand-separator label="Create copilot">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
@@ -31,8 +32,8 @@
|
||||
<code>[<copilot_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url }}api/v1/copilot -d '{"title":
|
||||
<string>, "animation": <string>,
|
||||
>curl -X POST {{ request.base_url }}copilot/api/v1/copilot -d
|
||||
'{"title": <string>, "animation": <string>,
|
||||
"show_message":<string>, "amount": <integer>,
|
||||
"lnurl_title": <string>}' -H "Content-type: application/json"
|
||||
-H "X-Api-Key: {{user.wallets[0].adminkey }}"
|
||||
@@ -59,11 +60,11 @@
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url
|
||||
}}api/v1/copilot/<copilot_id> -d '{"title": <string>,
|
||||
"animation": <string>, "show_message":<string>,
|
||||
"amount": <integer>, "lnurl_title": <string>}' -H
|
||||
"Content-type: application/json" -H "X-Api-Key:
|
||||
{{user.wallets[0].adminkey }}"
|
||||
}}copilot/api/v1/copilot/<copilot_id> -d '{"title":
|
||||
<string>, "animation": <string>,
|
||||
"show_message":<string>, "amount": <integer>,
|
||||
"lnurl_title": <string>}' -H "Content-type: application/json"
|
||||
-H "X-Api-Key: {{user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -87,8 +88,9 @@
|
||||
<code>[<copilot_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url }}api/v1/copilot/<copilot_id>
|
||||
-H "X-Api-Key: {{ user.wallets[0].inkey }}"
|
||||
>curl -X GET {{ request.base_url
|
||||
}}copilot/api/v1/copilot/<copilot_id> -H "X-Api-Key: {{
|
||||
user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -110,8 +112,8 @@
|
||||
<code>[<copilot_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url }}api/v1/copilots -H "X-Api-Key: {{
|
||||
user.wallets[0].inkey }}"
|
||||
>curl -X GET {{ request.base_url }}copilot/api/v1/copilots -H
|
||||
"X-Api-Key: {{ user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -136,7 +138,7 @@
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X DELETE {{ request.base_url
|
||||
}}api/v1/copilot/<copilot_id> -H "X-Api-Key: {{
|
||||
}}copilot/api/v1/copilot/<copilot_id> -H "X-Api-Key: {{
|
||||
user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
@@ -161,9 +163,10 @@
|
||||
<code></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url }}/api/v1/copilot/ws/<string,
|
||||
copilot_id>/<string, comment>/<string, gif name> -H
|
||||
"X-Api-Key: {{ user.wallets[0].adminkey }}"
|
||||
>curl -X GET {{ request.base_url
|
||||
}}copilot/api/v1/copilot/ws/<string, copilot_id>/<string,
|
||||
comment>/<string, gif name> -H "X-Api-Key: {{
|
||||
user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
@@ -63,7 +63,7 @@ class ConnectionManager:
|
||||
manager = ConnectionManager()
|
||||
|
||||
|
||||
@copilot_ext.websocket("/copilot/ws/{copilot_id}", name="copilot.websocket_by_id")
|
||||
@copilot_ext.websocket("/ws/{copilot_id}", name="copilot.websocket_by_id")
|
||||
async def websocket_endpoint(websocket: WebSocket, copilot_id: str):
|
||||
await manager.connect(websocket, copilot_id)
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
[[source]]
|
||||
url = "https://pypi.python.org/simple"
|
||||
verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
|
||||
[dev-packages]
|
||||
|
||||
[requires]
|
||||
python_version = "3.9"
|
||||
@@ -0,0 +1,34 @@
|
||||
# Discord Bot
|
||||
|
||||
## Provide LNbits wallets for all your Discord users
|
||||
|
||||
_This extension is a modifed version of LNbits [User Manager](../usermanager/README.md)_
|
||||
|
||||
The intended usage of this extension is to connect it to a specifically designed [Discord Bot](https://github.com/chrislennon/lnbits-discord-bot) leveraging LNbits as a community based lightning node.
|
||||
|
||||
## Setup
|
||||
This bot can target [lnbits.com](https://lnbits.com) or a self hosted instance.
|
||||
|
||||
To setup and run the bot instructions are located [here](https://github.com/chrislennon/lnbits-discord-bot#installation)
|
||||
|
||||
## Usage
|
||||
This bot will allow users to interact with it in the following ways [full command list](https://github.com/chrislennon/lnbits-discord-bot#commands):
|
||||
|
||||
`/create` Will create a wallet for the Discord user
|
||||
- (currently limiting 1 Discord user == 1 LNbits user == 1 user wallet)
|
||||
|
||||

|
||||
|
||||
`/balance` Will show the balance of the users wallet.
|
||||
|
||||

|
||||
|
||||
`/tip @user [amount]` Will sent money from one user to another
|
||||
- If the recieving user does not have a wallet, one will be created for them
|
||||
- The receiving user will receive a direct message from the bot with a link to their wallet
|
||||
|
||||

|
||||
|
||||
`/payme [amount] [description]` Will open an invoice that can be paid by any user
|
||||
|
||||

|
||||
@@ -0,0 +1,25 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import template_renderer
|
||||
|
||||
db = Database("ext_discordbot")
|
||||
|
||||
discordbot_static_files = [
|
||||
{
|
||||
"path": "/discordbot/static",
|
||||
"app": StaticFiles(packages=[("lnbits", "extensions/discordbot/static")]),
|
||||
"name": "discordbot_static",
|
||||
}
|
||||
]
|
||||
|
||||
discordbot_ext: APIRouter = APIRouter(prefix="/discordbot", tags=["discordbot"])
|
||||
|
||||
|
||||
def discordbot_renderer():
|
||||
return template_renderer(["lnbits/extensions/discordbot/templates"])
|
||||
|
||||
|
||||
from .views import * # noqa
|
||||
from .views_api import * # noqa
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Discord Bot",
|
||||
"short_description": "Generate users and wallets",
|
||||
"icon": "person_add",
|
||||
"contributors": ["bitcoingamer21"]
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from lnbits.core.crud import (
|
||||
create_account,
|
||||
create_wallet,
|
||||
delete_wallet,
|
||||
get_payments,
|
||||
get_user,
|
||||
)
|
||||
from lnbits.core.models import Payment
|
||||
|
||||
from . import db
|
||||
from .models import CreateUserData, Users, Wallets
|
||||
|
||||
### Users
|
||||
|
||||
|
||||
async def create_discordbot_user(data: CreateUserData) -> Users:
|
||||
account = await create_account()
|
||||
user = await get_user(account.id)
|
||||
assert user, "Newly created user couldn't be retrieved"
|
||||
|
||||
wallet = await create_wallet(user_id=user.id, wallet_name=data.wallet_name)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO discordbot.users (id, name, admin, discord_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(user.id, data.user_name, data.admin_id, data.discord_id),
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO discordbot.wallets (id, admin, name, "user", adminkey, inkey)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
wallet.id,
|
||||
data.admin_id,
|
||||
data.wallet_name,
|
||||
user.id,
|
||||
wallet.adminkey,
|
||||
wallet.inkey,
|
||||
),
|
||||
)
|
||||
|
||||
user_created = await get_discordbot_user(user.id)
|
||||
assert user_created, "Newly created user couldn't be retrieved"
|
||||
return user_created
|
||||
|
||||
|
||||
async def get_discordbot_user(user_id: str) -> Optional[Users]:
|
||||
row = await db.fetchone("SELECT * FROM discordbot.users WHERE id = ?", (user_id,))
|
||||
return Users(**row) if row else None
|
||||
|
||||
|
||||
async def get_discordbot_users(user_id: str) -> List[Users]:
|
||||
rows = await db.fetchall(
|
||||
"SELECT * FROM discordbot.users WHERE admin = ?", (user_id,)
|
||||
)
|
||||
|
||||
return [Users(**row) for row in rows]
|
||||
|
||||
|
||||
async def delete_discordbot_user(user_id: str) -> None:
|
||||
wallets = await get_discordbot_wallets(user_id)
|
||||
for wallet in wallets:
|
||||
await delete_wallet(user_id=user_id, wallet_id=wallet.id)
|
||||
|
||||
await db.execute("DELETE FROM discordbot.users WHERE id = ?", (user_id,))
|
||||
await db.execute("""DELETE FROM discordbot.wallets WHERE "user" = ?""", (user_id,))
|
||||
|
||||
|
||||
### Wallets
|
||||
|
||||
|
||||
async def create_discordbot_wallet(
|
||||
user_id: str, wallet_name: str, admin_id: str
|
||||
) -> Wallets:
|
||||
wallet = await create_wallet(user_id=user_id, wallet_name=wallet_name)
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO discordbot.wallets (id, admin, name, "user", adminkey, inkey)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(wallet.id, admin_id, wallet_name, user_id, wallet.adminkey, wallet.inkey),
|
||||
)
|
||||
wallet_created = await get_discordbot_wallet(wallet.id)
|
||||
assert wallet_created, "Newly created wallet couldn't be retrieved"
|
||||
return wallet_created
|
||||
|
||||
|
||||
async def get_discordbot_wallet(wallet_id: str) -> Optional[Wallets]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM discordbot.wallets WHERE id = ?", (wallet_id,)
|
||||
)
|
||||
return Wallets(**row) if row else None
|
||||
|
||||
|
||||
async def get_discordbot_wallets(admin_id: str) -> Optional[Wallets]:
|
||||
rows = await db.fetchall(
|
||||
"SELECT * FROM discordbot.wallets WHERE admin = ?", (admin_id,)
|
||||
)
|
||||
return [Wallets(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_discordbot_users_wallets(user_id: str) -> Optional[Wallets]:
|
||||
rows = await db.fetchall(
|
||||
"""SELECT * FROM discordbot.wallets WHERE "user" = ?""", (user_id,)
|
||||
)
|
||||
return [Wallets(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_discordbot_wallet_transactions(wallet_id: str) -> Optional[Payment]:
|
||||
return await get_payments(
|
||||
wallet_id=wallet_id, complete=True, pending=False, outgoing=True, incoming=True
|
||||
)
|
||||
|
||||
|
||||
async def delete_discordbot_wallet(wallet_id: str, user_id: str) -> None:
|
||||
await delete_wallet(user_id=user_id, wallet_id=wallet_id)
|
||||
await db.execute("DELETE FROM discordbot.wallets WHERE id = ?", (wallet_id,))
|
||||
@@ -0,0 +1,30 @@
|
||||
async def m001_initial(db):
|
||||
"""
|
||||
Initial users table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE discordbot.users (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
admin TEXT NOT NULL,
|
||||
discord_id TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
"""
|
||||
Initial wallets table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE discordbot.wallets (
|
||||
id TEXT PRIMARY KEY,
|
||||
admin TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
"user" TEXT NOT NULL,
|
||||
adminkey TEXT NOT NULL,
|
||||
inkey TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user