Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcecf6d431 | ||
|
|
7134821fcd | ||
|
|
b55cc82f1c | ||
|
|
2aa4a93da1 | ||
|
|
232de067a4 | ||
|
|
daff019920 | ||
|
|
6ae3690797 | ||
|
|
33e2bdb7fb | ||
|
|
d908f912ee | ||
|
|
e7bb78b0c7 | ||
|
|
ffe39de391 | ||
|
|
3d489bf2ee | ||
|
|
42b3359d12 | ||
|
|
cbeb888761 | ||
|
|
a413685160 | ||
|
|
943237e7ff | ||
|
|
efd9c6917f | ||
|
|
f08d86c6df | ||
|
|
27e170d304 | ||
|
|
a3bd36d44f | ||
|
|
1af3964039 | ||
|
|
808ded509d | ||
|
|
0f998419e7 | ||
|
|
05a0861601 | ||
|
|
aa2e09bbeb | ||
|
|
9f7a97f58e | ||
|
|
3d54de7976 | ||
|
|
86c03fa5d9 | ||
|
|
237a6b369f | ||
|
|
9fa05e43f9 | ||
|
|
5eba071acc | ||
|
|
94acbd84e0 | ||
|
|
49827a64da | ||
|
|
63da1b0962 | ||
|
|
492d710bb8 | ||
|
|
b9b8e6a162 | ||
|
|
74074d148e | ||
|
|
f131e18969 | ||
|
|
da9723735c | ||
|
|
2a198d3186 | ||
|
|
c6f4372d23 | ||
|
|
529717a0f6 | ||
|
|
69f867f692 | ||
|
|
52e7a28fc2 | ||
|
|
42cf70e158 | ||
|
|
a3fbcd46cd | ||
|
|
e17b9422ad | ||
|
|
76199c66a1 | ||
|
|
10671e5732 | ||
|
|
95a32b0d7b | ||
|
|
980bb69151 | ||
|
|
e38b945d5c | ||
|
|
fdf4f6c1ae | ||
|
|
61f6e2a4f2 | ||
|
|
d872f0d138 | ||
|
|
6a18766a82 | ||
|
|
f84e965e13 | ||
|
|
cbdf97f949 | ||
|
|
4219513a8a | ||
|
|
64bb1b5444 | ||
|
|
70a0ec8a1e | ||
|
|
b48a44bcd0 | ||
|
|
c26f6fcbdf | ||
|
|
6a7c28eb0e | ||
|
|
8d3d828e49 | ||
|
|
b7090d963b | ||
|
|
2d8f85f77c | ||
|
|
a27a3885f3 | ||
|
|
90319bfd8c | ||
|
|
82731dc901 | ||
|
|
59aaea47fd | ||
|
|
f5d06653a6 | ||
|
|
744662fb63 | ||
|
|
55748b427b | ||
|
|
96082978d1 | ||
|
|
d7c3c4e51c | ||
|
|
ed5aeccb8e | ||
|
|
b2841ee909 | ||
|
|
69ef4d7e63 | ||
|
|
ae5d45a73e | ||
|
|
6f42413107 | ||
|
|
946279674a | ||
|
|
11679a4fa7 | ||
|
|
045e069312 | ||
|
|
0db516b6e0 | ||
|
|
b05b8c0115 | ||
|
|
569f43fe81 | ||
|
|
5c894ff707 | ||
|
|
d95159d73b | ||
|
|
198cff8449 | ||
|
|
81326b2368 | ||
|
|
da20588c84 | ||
|
|
be6ee64b4e | ||
|
|
6e4f8431b7 | ||
|
|
a93903a183 | ||
|
|
385406a78a | ||
|
|
ecfcc167f0 | ||
|
|
9724f33111 | ||
|
|
4425c43726 | ||
|
|
9c8836096c | ||
|
|
ca60893701 | ||
|
|
7d44fa1c2d | ||
|
|
4c7c792390 | ||
|
|
54c0c9f3a8 | ||
|
|
257c9c1c64 | ||
|
|
2db3325727 | ||
|
|
c62678b3aa | ||
|
|
48cf23346c | ||
|
|
913f2a37c1 | ||
|
|
93417e5bdf | ||
|
|
a838706090 |
@@ -43,7 +43,7 @@ LND_REST_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bit
|
||||
LND_REST_MACAROON="HEXSTRING"
|
||||
|
||||
# LNPayWallet
|
||||
LNPAY_API_ENDPOINT=https://lnpay.co/v1/
|
||||
LNPAY_API_ENDPOINT=https://api.lnpay.co/v1/
|
||||
LNPAY_API_KEY=LNPAY_API_KEY
|
||||
LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
env:
|
||||
LNBITS_BACKEND_WALLET_CLASS: LNPayWallet
|
||||
LNBITS_FORCE_HTTPS: 0
|
||||
LNPAY_API_ENDPOINT: https://lnpay.co/v1/
|
||||
LNPAY_API_ENDPOINT: https://api.lnpay.co/v1/
|
||||
LNPAY_API_KEY: sak_gG5pSFZhFgOLHm26a8hcWvXKt98yd
|
||||
LNPAY_ADMIN_KEY: waka_HqWfOoNE0TPqmQHSYErbF4n9
|
||||
LNPAY_INVOICE_KEY: waki_ZqFEbhrTyopuPlOZButZUw
|
||||
|
||||
@@ -11,7 +11,7 @@ bitstring = "*"
|
||||
cerberus = "*"
|
||||
ecdsa = "*"
|
||||
environs = "*"
|
||||
lnurl = "*"
|
||||
lnurl = "==0.3.6"
|
||||
pyscss = "*"
|
||||
shortuuid = "*"
|
||||
quart = "*"
|
||||
@@ -24,8 +24,10 @@ quart-trio = "*"
|
||||
trio = "==0.16.0"
|
||||
hypercorn = {extras = ["trio"], version = "*"}
|
||||
sqlalchemy-aio = "*"
|
||||
embit = "*"
|
||||
pyqrcode = "*"
|
||||
pypng = "*"
|
||||
sqlalchemy = "==1.3.23"
|
||||
|
||||
[dev-packages]
|
||||
black = "==20.8b1"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "f98f5cc03179f57291aeeca8e0e117ef4f38806176c9d2c0f984f501a5806338"
|
||||
"sha256": "e12af74353e8bea3f97bf2aea16a1ba0a6e4c3a08042ce7368187a06e7791e2c"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
@@ -99,10 +99,11 @@
|
||||
},
|
||||
"cerberus": {
|
||||
"hashes": [
|
||||
"sha256:302e6694f206dd85cb63f13fd5025b31ab6d38c99c50c6d769f8fa0b0f299589"
|
||||
"sha256:7aff49bc793e58a88ac14bffc3eca0f67e077881d3c62c621679a621294dd174",
|
||||
"sha256:eec10585c33044fb7c69650bc5b68018dac0443753337e2b07684ee0f3c83329"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.3.2"
|
||||
"version": "==1.3.3"
|
||||
},
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
@@ -127,62 +128,20 @@
|
||||
"index": "pypi",
|
||||
"version": "==0.16.1"
|
||||
},
|
||||
"environs": {
|
||||
"embit": {
|
||||
"hashes": [
|
||||
"sha256:2da44b7c30114415aa858577fa6396ee326fc76a0a60f0f15e8260ba554f19dc",
|
||||
"sha256:3f6def554abb5455141b540e6e0b72fda3853404f2b0d31658aab1bf95410db3"
|
||||
"sha256:7c4264d7ede8e2c114db10585270874c9df809c68d2e21db918872e3245b5f2b"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==9.3.1"
|
||||
"version": "==0.2.1"
|
||||
},
|
||||
"greenlet": {
|
||||
"environs": {
|
||||
"hashes": [
|
||||
"sha256:0a77691f0080c9da8dfc81e23f4e3cffa5accf0f5b56478951016d7cfead9196",
|
||||
"sha256:0ddd77586553e3daf439aa88b6642c5f252f7ef79a39271c25b1d4bf1b7cbb85",
|
||||
"sha256:111cfd92d78f2af0bc7317452bd93a477128af6327332ebf3c2be7df99566683",
|
||||
"sha256:122c63ba795fdba4fc19c744df6277d9cfd913ed53d1a286f12189a0265316dd",
|
||||
"sha256:181300f826625b7fd1182205b830642926f52bd8cdb08b34574c9d5b2b1813f7",
|
||||
"sha256:1a1ada42a1fd2607d232ae11a7b3195735edaa49ea787a6d9e6a53afaf6f3476",
|
||||
"sha256:1bb80c71de788b36cefb0c3bb6bfab306ba75073dbde2829c858dc3ad70f867c",
|
||||
"sha256:1d1d4473ecb1c1d31ce8fd8d91e4da1b1f64d425c1dc965edc4ed2a63cfa67b2",
|
||||
"sha256:292e801fcb3a0b3a12d8c603c7cf340659ea27fd73c98683e75800d9fd8f704c",
|
||||
"sha256:2c65320774a8cd5fdb6e117c13afa91c4707548282464a18cf80243cf976b3e6",
|
||||
"sha256:4365eccd68e72564c776418c53ce3c5af402bc526fe0653722bc89efd85bf12d",
|
||||
"sha256:5352c15c1d91d22902582e891f27728d8dac3bd5e0ee565b6a9f575355e6d92f",
|
||||
"sha256:58ca0f078d1c135ecf1879d50711f925ee238fe773dfe44e206d7d126f5bc664",
|
||||
"sha256:5d4030b04061fdf4cbc446008e238e44936d77a04b2b32f804688ad64197953c",
|
||||
"sha256:5d69bbd9547d3bc49f8a545db7a0bd69f407badd2ff0f6e1a163680b5841d2b0",
|
||||
"sha256:5f297cb343114b33a13755032ecf7109b07b9a0020e841d1c3cedff6602cc139",
|
||||
"sha256:62afad6e5fd70f34d773ffcbb7c22657e1d46d7fd7c95a43361de979f0a45aef",
|
||||
"sha256:647ba1df86d025f5a34043451d7c4a9f05f240bee06277a524daad11f997d1e7",
|
||||
"sha256:719e169c79255816cdcf6dccd9ed2d089a72a9f6c42273aae12d55e8d35bdcf8",
|
||||
"sha256:7cd5a237f241f2764324396e06298b5dee0df580cf06ef4ada0ff9bff851286c",
|
||||
"sha256:875d4c60a6299f55df1c3bb870ebe6dcb7db28c165ab9ea6cdc5d5af36bb33ce",
|
||||
"sha256:90b6a25841488cf2cb1c8623a53e6879573010a669455046df5f029d93db51b7",
|
||||
"sha256:94620ed996a7632723a424bccb84b07e7b861ab7bb06a5aeb041c111dd723d36",
|
||||
"sha256:b5f1b333015d53d4b381745f5de842f19fe59728b65f0fbb662dafbe2018c3a5",
|
||||
"sha256:c5b22b31c947ad8b6964d4ed66776bcae986f73669ba50620162ba7c832a6b6a",
|
||||
"sha256:c93d1a71c3fe222308939b2e516c07f35a849c5047f0197442a4d6fbcb4128ee",
|
||||
"sha256:cdb90267650c1edb54459cdb51dab865f6c6594c3a47ebd441bc493360c7af70",
|
||||
"sha256:cfd06e0f0cc8db2a854137bd79154b61ecd940dce96fad0cba23fe31de0b793c",
|
||||
"sha256:d3789c1c394944084b5e57c192889985a9f23bd985f6d15728c745d380318128",
|
||||
"sha256:da7d09ad0f24270b20f77d56934e196e982af0d0a2446120cb772be4e060e1a2",
|
||||
"sha256:df3e83323268594fa9755480a442cabfe8d82b21aba815a71acf1bb6c1776218",
|
||||
"sha256:df8053867c831b2643b2c489fe1d62049a98566b1646b194cc815f13e27b90df",
|
||||
"sha256:e1128e022d8dce375362e063754e129750323b67454cac5600008aad9f54139e",
|
||||
"sha256:e6e9fdaf6c90d02b95e6b0709aeb1aba5affbbb9ccaea5502f8638e4323206be",
|
||||
"sha256:eac8803c9ad1817ce3d8d15d1bb82c2da3feda6bee1153eec5c58fa6e5d3f770",
|
||||
"sha256:eb333b90036358a0e2c57373f72e7648d7207b76ef0bd00a4f7daad1f79f5203",
|
||||
"sha256:ed1d1351f05e795a527abc04a0d82e9aecd3bdf9f46662c36ff47b0b00ecaf06",
|
||||
"sha256:f3dc68272990849132d6698f7dc6df2ab62a88b0d36e54702a8fd16c0490e44f",
|
||||
"sha256:f59eded163d9752fd49978e0bab7a1ff21b1b8d25c05f0995d140cc08ac83379",
|
||||
"sha256:f5e2d36c86c7b03c94b8459c3bd2c9fe2c7dab4b258b8885617d44a22e453fb7",
|
||||
"sha256:f6f65bf54215e4ebf6b01e4bb94c49180a589573df643735107056f7a910275b",
|
||||
"sha256:f8450d5ef759dbe59f84f2c9f77491bb3d3c44bc1a573746daf086e70b14c243",
|
||||
"sha256:f97d83049715fd9dec7911860ecf0e17b48d8725de01e45de07d8ac0bd5bc378"
|
||||
"sha256:2eb671afd37e6e9820131b918bbbcaa6658d0fb420ebf35bdfb750ae39c51a66",
|
||||
"sha256:6bef733b88cc901e787cf24fb2eaa72621b0656226ea4e332ab24ed0cba36fcf"
|
||||
],
|
||||
"markers": "python_version >= '3'",
|
||||
"version": "==1.0.0"
|
||||
"index": "pypi",
|
||||
"version": "==9.3.2"
|
||||
},
|
||||
"h11": {
|
||||
"hashes": [
|
||||
@@ -268,10 +227,11 @@
|
||||
},
|
||||
"lnurl": {
|
||||
"hashes": [
|
||||
"sha256:aaff8552cd4c02f8ae30c82dd3ebe6c6c3a8e06cbc59991281534ba0bbe12890"
|
||||
"sha256:579982fd8c4d25bc84c61c74ec45cb7999fa1fa2426f5d5aeb0160ba333b9c92",
|
||||
"sha256:8af07460115a48f3122a5a9c9a6062bee3897d5f6ab4c9a60f6561a83a8234f6"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.3.5"
|
||||
"version": "==0.3.6"
|
||||
},
|
||||
"markupsafe": {
|
||||
"hashes": [
|
||||
@@ -333,11 +293,11 @@
|
||||
},
|
||||
"marshmallow": {
|
||||
"hashes": [
|
||||
"sha256:4ab2fdb7f36eb61c3665da67a7ce281c8900db08d72ba6bf0e695828253581f7",
|
||||
"sha256:eca81d53aa4aafbc0e20566973d0d2e50ce8bf0ee15165bb799bec0df1e50177"
|
||||
"sha256:0dd42891a5ef288217ed6410917f3c6048f585f8692075a0052c24f9bfff9dfd",
|
||||
"sha256:16e99cb7f630c0ef4d7d364ed0109ac194268dde123966076ab3dafb9ae3906b"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==3.10.0"
|
||||
"version": "==3.11.1"
|
||||
},
|
||||
"outcome": {
|
||||
"hashes": [
|
||||
@@ -406,10 +366,10 @@
|
||||
},
|
||||
"python-dotenv": {
|
||||
"hashes": [
|
||||
"sha256:31d752f5b748f4e292448c9a0cac6a08ed5e6f4cefab85044462dcad56905cec",
|
||||
"sha256:9fa413c37d4652d3fa02fea0ff465c384f5db75eab259c4fc5d0c5b8bf20edd4"
|
||||
"sha256:471b782da0af10da1a80341e8438fca5fadeba2881c54360d5fd8d03d03a4f4a",
|
||||
"sha256:49782a97c9d641e8a09ae1d9af0856cc587c8d2474919342d5104d85be9890b2"
|
||||
],
|
||||
"version": "==0.16.0"
|
||||
"version": "==0.17.0"
|
||||
},
|
||||
"quart": {
|
||||
"hashes": [
|
||||
@@ -502,43 +462,47 @@
|
||||
},
|
||||
"sqlalchemy": {
|
||||
"hashes": [
|
||||
"sha256:0096305b3e0912c59d8308f55d17544b3e5c1787f5ad8ef9cd75084136bcba9c",
|
||||
"sha256:106fd3da313390dffe6ca156e5b7244293d6f4bfd389bcb315771c7addb5f3b3",
|
||||
"sha256:1b30b71ea7c0f854d1b31549816694d8c435c9b5cce44da140b473544bb48a6b",
|
||||
"sha256:228fe0cc700748ccc7a9a430896a77dfaa8a1035874e540961589e31f31cabe1",
|
||||
"sha256:22faab9884c46ea2c00d5457a6a23375e0b4ab5257b72a27c8b979d4f677d4cf",
|
||||
"sha256:3280c283e85e5c7b95c7be75b2df765d4bb13a01be36552826557bb4177d2bdf",
|
||||
"sha256:33a2b756bd8f7022c24a16228071dea39cf6f21f62732a5307b6ebcef084bf16",
|
||||
"sha256:409f3cd35f99592d0ceb1ee2e13c24b3083109e0f80096aae36000e5988aa24a",
|
||||
"sha256:41a6dc66714c7dddf210dc8652d19bb2a55364c21038ca77312500014271aa67",
|
||||
"sha256:4aa4de9bd3ae5e46f7395aa769303722e7174795ae83dd78302d849fcbc7513d",
|
||||
"sha256:52c2512914bdeb3ce7957e2597b6a9d4a3dd3b3177c32ccf481908d6e59384ae",
|
||||
"sha256:5da7f97893631d060c4590e531784f5eaf64bb3e6002804ee8a96d9c91cd1885",
|
||||
"sha256:655b35725f1478bb6f797336acc803ddbb4c693816b3663a6fd94ad454eb056a",
|
||||
"sha256:755aed46915e20c0b317a4124251f31682dcc7a43984d771352f6863ea11cd9c",
|
||||
"sha256:81920161039cca14dac30378713c472a0ac5e783b2077984d6f8ec6f2d824356",
|
||||
"sha256:83e65d8826bc649f8af556588555b744d4b9cfc0fcda8f3ddd08fe43c656e459",
|
||||
"sha256:8761759028eb7754b76ca153e613bdea0fb6f8107557e57c60616a7212e2a297",
|
||||
"sha256:8cf28097524b7fff3526df9154abcdbed0c4e434d4c4e6787e3d4fc33e7deb6a",
|
||||
"sha256:92cca0c8757ac9a8a53cc800ea0f04a4f6c346376bc4cb878e4a6aed6f19d18d",
|
||||
"sha256:96e68231f7115f5acb1bb51ccec26351bc155fedf835d7625fa203a43c8a3762",
|
||||
"sha256:9852a7b4feee4c7de4b7541fa8a72ab36a5dad7942c58006e76ffe59c0f8efec",
|
||||
"sha256:a719b80b41a900bbcec3cc248616394ebd134043ce5e62185270d785d8a184d3",
|
||||
"sha256:a91fa4189f66af9644fde50740c5134689dc01c6c5edf04af6eafa3225ae110c",
|
||||
"sha256:aa529647a3770293f392dff40466344a5d142fe66a2bbec465247a05d695eced",
|
||||
"sha256:ad2d9fc0ffba476cf069cea558527bc23e1ced24ec6c8badab8aa63cbde56b07",
|
||||
"sha256:b445fe8f043288178bc7d4adda49a505de86641864d50493d3fad10e0711cbff",
|
||||
"sha256:b529f285b04094d458e811147a320019397909265eef1d1aa9dc6ecac0ad240c",
|
||||
"sha256:bce476fd66aeaeb1a155f97838233d95fccd2c611da4d6b1cb4b6205435e5326",
|
||||
"sha256:be1df71f9a06730b2a7213b68d6c465130a82305789462e375cf87037b181af3",
|
||||
"sha256:c063277efd89c7f755480ff80f87828c9a68afb0fdc6d79462b9e474301fded3",
|
||||
"sha256:d157a87dcd861eae04cd9b19cac535451719397fcae7b6f870688d8fb69d84f9",
|
||||
"sha256:d34afc46b9fe3025b8db7ade6876bf80668918c5cdcdae067aaec348b5daa821",
|
||||
"sha256:e337983564e09857a7a687dfa7adfaf85f59ed9e885d30081e13aea792d6abf7",
|
||||
"sha256:e8b24bb68e981b6a2a8845d6d0f85891564d38562fc338170338ef90a221241e"
|
||||
"sha256:040bdfc1d76a9074717a3f43455685f781c581f94472b010cd6c4754754e1862",
|
||||
"sha256:1fe5d8d39118c2b018c215c37b73fd6893c3e1d4895be745ca8ff6eb83333ed3",
|
||||
"sha256:23927c3981d1ec6b4ea71eb99d28424b874d9c696a21e5fbd9fa322718be3708",
|
||||
"sha256:24f9569e82a009a09ce2d263559acb3466eba2617203170e4a0af91e75b4f075",
|
||||
"sha256:2578dbdbe4dbb0e5126fb37ffcd9793a25dcad769a95f171a2161030bea850ff",
|
||||
"sha256:269990b3ab53cb035d662dcde51df0943c1417bdab707dc4a7e4114a710504b4",
|
||||
"sha256:29cccc9606750fe10c5d0e8bd847f17a97f3850b8682aef1f56f5d5e1a5a64b1",
|
||||
"sha256:37b83bf81b4b85dda273aaaed5f35ea20ad80606f672d94d2218afc565fb0173",
|
||||
"sha256:63677d0c08524af4c5893c18dbe42141de7178001360b3de0b86217502ed3601",
|
||||
"sha256:639940bbe1108ac667dcffc79925db2966826c270112e9159439ab6bb14f8d80",
|
||||
"sha256:6a939a868fdaa4b504e8b9d4a61f21aac11e3fecc8a8214455e144939e3d2aea",
|
||||
"sha256:6b8b8c80c7f384f06825612dd078e4a31f0185e8f1f6b8c19e188ff246334205",
|
||||
"sha256:6c9e6cc9237de5660bcddea63f332428bb83c8e2015c26777281f7ffbd2efb84",
|
||||
"sha256:6ec1044908414013ebfe363450c22f14698803ce97fbb47e53284d55c5165848",
|
||||
"sha256:6fca33672578666f657c131552c4ef8979c1606e494f78cd5199742dfb26918b",
|
||||
"sha256:751934967f5336a3e26fc5993ccad1e4fee982029f9317eb6153bc0bc3d2d2da",
|
||||
"sha256:8be835aac18ec85351385e17b8665bd4d63083a7160a017bef3d640e8e65cadb",
|
||||
"sha256:927ce09e49bff3104459e1451ce82983b0a3062437a07d883a4c66f0b344c9b5",
|
||||
"sha256:94208867f34e60f54a33a37f1c117251be91a47e3bfdb9ab8a7847f20886ad06",
|
||||
"sha256:94f667d86be82dd4cb17d08de0c3622e77ca865320e0b95eae6153faa7b4ecaf",
|
||||
"sha256:9e9c25522933e569e8b53ccc644dc993cab87e922fb7e142894653880fdd419d",
|
||||
"sha256:a0e306e9bb76fd93b29ae3a5155298e4c1b504c7cbc620c09c20858d32d16234",
|
||||
"sha256:a8bfc1e1afe523e94974132d7230b82ca7fa2511aedde1f537ec54db0399541a",
|
||||
"sha256:ac2244e64485c3778f012951fdc869969a736cd61375fde6096d08850d8be729",
|
||||
"sha256:b4b0e44d586cd64b65b507fa116a3814a1a53d55dce4836d7c1a6eb2823ff8d1",
|
||||
"sha256:baeb451ee23e264de3f577fee5283c73d9bbaa8cb921d0305c0bbf700094b65b",
|
||||
"sha256:c7dc052432cd5d060d7437e217dd33c97025287f99a69a50e2dc1478dd610d64",
|
||||
"sha256:d1a85dfc5dee741bf49cb9b6b6b8d2725a268e4992507cf151cba26b17d97c37",
|
||||
"sha256:d90010304abb4102123d10cbad2cdf2c25a9f2e66a50974199b24b468509bad5",
|
||||
"sha256:ddfb511e76d016c3a160910642d57f4587dc542ce5ee823b0d415134790eeeb9",
|
||||
"sha256:e273367f4076bd7b9a8dc2e771978ef2bfd6b82526e80775a7db52bff8ca01dd",
|
||||
"sha256:e5bb3463df697279e5459a7316ad5a60b04b0107f9392e88674d0ece70e9cf70",
|
||||
"sha256:e8a1750b44ad6422ace82bf3466638f1aa0862dbb9689690d5f2f48cce3476c8",
|
||||
"sha256:eab063a70cca4a587c28824e18be41d8ecc4457f8f15b2933584c6c6cccd30f0",
|
||||
"sha256:ecce8c021894a77d89808222b1ff9687ad84db54d18e4bd0500ca766737faaf6",
|
||||
"sha256:f4d972139d5000105fcda9539a76452039434013570d6059993120dc2a65e447",
|
||||
"sha256:fd3b96f8c705af8e938eaa99cbd8fd1450f632d38cad55e7367c33b263bf98ec",
|
||||
"sha256:fdd2ed7395df8ac2dbb10cefc44737b66c6a5cd7755c92524733d7a443e5b7e2"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
|
||||
"version": "==1.4.3"
|
||||
"index": "pypi",
|
||||
"version": "==1.3.23"
|
||||
},
|
||||
"sqlalchemy-aio": {
|
||||
"hashes": [
|
||||
@@ -785,11 +749,11 @@
|
||||
},
|
||||
"pytest": {
|
||||
"hashes": [
|
||||
"sha256:9d1edf9e7d0b84d72ea3dbcdfd22b35fb543a5e8f2a60092dd578936bf63d7f9",
|
||||
"sha256:b574b57423e818210672e07ca1fa90aaf194a4f63f3ab909a2c67ebb22913839"
|
||||
"sha256:671238a46e4df0f3498d1c3270e5deb9b32d25134c99b7d75370a68cfbe9b634",
|
||||
"sha256:6ad9c7bdf517a808242b998ac20063c41532a570d088d77eec1ee12b0b5574bc"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==6.2.2"
|
||||
"version": "==6.2.3"
|
||||
},
|
||||
"pytest-cov": {
|
||||
"hashes": [
|
||||
@@ -808,49 +772,49 @@
|
||||
},
|
||||
"regex": {
|
||||
"hashes": [
|
||||
"sha256:07ef35301b4484bce843831e7039a84e19d8d33b3f8b2f9aab86c376813d0139",
|
||||
"sha256:13f50969028e81765ed2a1c5fcfdc246c245cf8d47986d5172e82ab1a0c42ee5",
|
||||
"sha256:14de88eda0976020528efc92d0a1f8830e2fb0de2ae6005a6fc4e062553031fa",
|
||||
"sha256:159fac1a4731409c830d32913f13f68346d6b8e39650ed5d704a9ce2f9ef9cb3",
|
||||
"sha256:18e25e0afe1cf0f62781a150c1454b2113785401ba285c745acf10c8ca8917df",
|
||||
"sha256:201e2619a77b21a7780580ab7b5ce43835e242d3e20fef50f66a8df0542e437f",
|
||||
"sha256:360a01b5fa2ad35b3113ae0c07fb544ad180603fa3b1f074f52d98c1096fa15e",
|
||||
"sha256:39c44532d0e4f1639a89e52355b949573e1e2c5116106a395642cbbae0ff9bcd",
|
||||
"sha256:3d9356add82cff75413bec360c1eca3e58db4a9f5dafa1f19650958a81e3249d",
|
||||
"sha256:3d9a7e215e02bd7646a91fb8bcba30bc55fd42a719d6b35cf80e5bae31d9134e",
|
||||
"sha256:4651f839dbde0816798e698626af6a2469eee6d9964824bb5386091255a1694f",
|
||||
"sha256:486a5f8e11e1f5bbfcad87f7c7745eb14796642323e7e1829a331f87a713daaa",
|
||||
"sha256:4b8a1fb724904139149a43e172850f35aa6ea97fb0545244dc0b805e0154ed68",
|
||||
"sha256:4c0788010a93ace8a174d73e7c6c9d3e6e3b7ad99a453c8ee8c975ddd9965643",
|
||||
"sha256:4c2e364491406b7888c2ad4428245fc56c327e34a5dfe58fd40df272b3c3dab3",
|
||||
"sha256:575a832e09d237ae5fedb825a7a5bc6a116090dd57d6417d4f3b75121c73e3be",
|
||||
"sha256:5770a51180d85ea468234bc7987f5597803a4c3d7463e7323322fe4a1b181578",
|
||||
"sha256:633497504e2a485a70a3268d4fc403fe3063a50a50eed1039083e9471ad0101c",
|
||||
"sha256:63f3ca8451e5ff7133ffbec9eda641aeab2001be1a01878990f6c87e3c44b9d5",
|
||||
"sha256:709f65bb2fa9825f09892617d01246002097f8f9b6dde8d1bb4083cf554701ba",
|
||||
"sha256:808404898e9a765e4058bf3d7607d0629000e0a14a6782ccbb089296b76fa8fe",
|
||||
"sha256:882f53afe31ef0425b405a3f601c0009b44206ea7f55ee1c606aad3cc213a52c",
|
||||
"sha256:8bd4f91f3fb1c9b1380d6894bd5b4a519409135bec14c0c80151e58394a4e88a",
|
||||
"sha256:8e65e3e4c6feadf6770e2ad89ad3deb524bcb03d8dc679f381d0568c024e0deb",
|
||||
"sha256:976a54d44fd043d958a69b18705a910a8376196c6b6ee5f2596ffc11bff4420d",
|
||||
"sha256:a0d04128e005142260de3733591ddf476e4902c0c23c1af237d9acf3c96e1b38",
|
||||
"sha256:a0df9a0ad2aad49ea3c7f65edd2ffb3d5c59589b85992a6006354f6fb109bb18",
|
||||
"sha256:a2ee026f4156789df8644d23ef423e6194fad0bc53575534101bb1de5d67e8ce",
|
||||
"sha256:a59a2ee329b3de764b21495d78c92ab00b4ea79acef0f7ae8c1067f773570afa",
|
||||
"sha256:b97ec5d299c10d96617cc851b2e0f81ba5d9d6248413cd374ef7f3a8871ee4a6",
|
||||
"sha256:b98bc9db003f1079caf07b610377ed1ac2e2c11acc2bea4892e28cc5b509d8d5",
|
||||
"sha256:b9d8d286c53fe0cbc6d20bf3d583cabcd1499d89034524e3b94c93a5ab85ca90",
|
||||
"sha256:bcd945175c29a672f13fce13a11893556cd440e37c1b643d6eeab1988c8b209c",
|
||||
"sha256:c66221e947d7207457f8b6f42b12f613b09efa9669f65a587a2a71f6a0e4d106",
|
||||
"sha256:c782da0e45aff131f0bed6e66fbcfa589ff2862fc719b83a88640daa01a5aff7",
|
||||
"sha256:cb4ee827857a5ad9b8ae34d3c8cc51151cb4a3fe082c12ec20ec73e63cc7c6f0",
|
||||
"sha256:d47d359545b0ccad29d572ecd52c9da945de7cd6cf9c0cfcb0269f76d3555689",
|
||||
"sha256:dc9963aacb7da5177e40874585d7407c0f93fb9d7518ec58b86e562f633f36cd",
|
||||
"sha256:ea2f41445852c660ba7c3ebf7d70b3779b20d9ca8ba54485a17740db49f46932",
|
||||
"sha256:f5d0c921c99297354cecc5a416ee4280bd3f20fd81b9fb671ca6be71499c3fdf",
|
||||
"sha256:f85d6f41e34f6a2d1607e312820971872944f1661a73d33e1e82d35ea3305e14"
|
||||
"sha256:01afaf2ec48e196ba91b37451aa353cb7eda77efe518e481707e0515025f0cd5",
|
||||
"sha256:11d773d75fa650cd36f68d7ca936e3c7afaae41b863b8c387a22aaa78d3c5c79",
|
||||
"sha256:18c071c3eb09c30a264879f0d310d37fe5d3a3111662438889ae2eb6fc570c31",
|
||||
"sha256:1e1c20e29358165242928c2de1482fb2cf4ea54a6a6dea2bd7a0e0d8ee321500",
|
||||
"sha256:281d2fd05555079448537fe108d79eb031b403dac622621c78944c235f3fcf11",
|
||||
"sha256:314d66636c494ed9c148a42731b3834496cc9a2c4251b1661e40936814542b14",
|
||||
"sha256:32e65442138b7b76dd8173ffa2cf67356b7bc1768851dded39a7a13bf9223da3",
|
||||
"sha256:339456e7d8c06dd36a22e451d58ef72cef293112b559010db3d054d5560ef439",
|
||||
"sha256:3916d08be28a1149fb97f7728fca1f7c15d309a9f9682d89d79db75d5e52091c",
|
||||
"sha256:3a9cd17e6e5c7eb328517969e0cb0c3d31fd329298dd0c04af99ebf42e904f82",
|
||||
"sha256:47bf5bf60cf04d72bf6055ae5927a0bd9016096bf3d742fa50d9bf9f45aa0711",
|
||||
"sha256:4c46e22a0933dd783467cf32b3516299fb98cfebd895817d685130cc50cd1093",
|
||||
"sha256:4c557a7b470908b1712fe27fb1ef20772b78079808c87d20a90d051660b1d69a",
|
||||
"sha256:52ba3d3f9b942c49d7e4bc105bb28551c44065f139a65062ab7912bef10c9afb",
|
||||
"sha256:563085e55b0d4fb8f746f6a335893bda5c2cef43b2f0258fe1020ab1dd874df8",
|
||||
"sha256:598585c9f0af8374c28edd609eb291b5726d7cbce16be6a8b95aa074d252ee17",
|
||||
"sha256:619d71c59a78b84d7f18891fe914446d07edd48dc8328c8e149cbe0929b4e000",
|
||||
"sha256:67bdb9702427ceddc6ef3dc382455e90f785af4c13d495f9626861763ee13f9d",
|
||||
"sha256:6d1b01031dedf2503631d0903cb563743f397ccaf6607a5e3b19a3d76fc10480",
|
||||
"sha256:741a9647fcf2e45f3a1cf0e24f5e17febf3efe8d4ba1281dcc3aa0459ef424dc",
|
||||
"sha256:7c2a1af393fcc09e898beba5dd59196edaa3116191cc7257f9224beaed3e1aa0",
|
||||
"sha256:7d9884d86dd4dd489e981d94a65cd30d6f07203d90e98f6f657f05170f6324c9",
|
||||
"sha256:90f11ff637fe8798933fb29f5ae1148c978cccb0452005bf4c69e13db951e765",
|
||||
"sha256:919859aa909429fb5aa9cf8807f6045592c85ef56fdd30a9a3747e513db2536e",
|
||||
"sha256:96fcd1888ab4d03adfc9303a7b3c0bd78c5412b2bfbe76db5b56d9eae004907a",
|
||||
"sha256:97f29f57d5b84e73fbaf99ab3e26134e6687348e95ef6b48cfd2c06807005a07",
|
||||
"sha256:980d7be47c84979d9136328d882f67ec5e50008681d94ecc8afa8a65ed1f4a6f",
|
||||
"sha256:a91aa8619b23b79bcbeb37abe286f2f408d2f2d6f29a17237afda55bb54e7aac",
|
||||
"sha256:ade17eb5d643b7fead300a1641e9f45401c98eee23763e9ed66a43f92f20b4a7",
|
||||
"sha256:b9c3db21af35e3b3c05764461b262d6f05bbca08a71a7849fd79d47ba7bc33ed",
|
||||
"sha256:bd28bc2e3a772acbb07787c6308e00d9626ff89e3bfcdebe87fa5afbfdedf968",
|
||||
"sha256:bf5824bfac591ddb2c1f0a5f4ab72da28994548c708d2191e3b87dd207eb3ad7",
|
||||
"sha256:c0502c0fadef0d23b128605d69b58edb2c681c25d44574fc673b0e52dce71ee2",
|
||||
"sha256:c38c71df845e2aabb7fb0b920d11a1b5ac8526005e533a8920aea97efb8ec6a4",
|
||||
"sha256:ce15b6d103daff8e9fee13cf7f0add05245a05d866e73926c358e871221eae87",
|
||||
"sha256:d3029c340cfbb3ac0a71798100ccc13b97dddf373a4ae56b6a72cf70dfd53bc8",
|
||||
"sha256:e512d8ef5ad7b898cdb2d8ee1cb09a8339e4f8be706d27eaa180c2f177248a10",
|
||||
"sha256:e8e5b509d5c2ff12f8418006d5a90e9436766133b564db0abaec92fd27fcee29",
|
||||
"sha256:ee54ff27bf0afaf4c3b3a62bcd016c12c3fdb4ec4f413391a90bd38bc3624605",
|
||||
"sha256:fa4537fb4a98fe8fde99626e4681cc644bdcf2a795038533f9f711513a862ae6",
|
||||
"sha256:fd45ff9293d9274c5008a2054ecef86a9bfe819a67c7be1afb65e69b405b3042"
|
||||
],
|
||||
"version": "==2021.3.17"
|
||||
"version": "==2021.4.4"
|
||||
},
|
||||
"sniffio": {
|
||||
"hashes": [
|
||||
@@ -885,38 +849,38 @@
|
||||
},
|
||||
"typed-ast": {
|
||||
"hashes": [
|
||||
"sha256:07d49388d5bf7e863f7fa2f124b1b1d89d8aa0e2f7812faff0a5658c01c59aa1",
|
||||
"sha256:14bf1522cdee369e8f5581238edac09150c765ec1cb33615855889cf33dcb92d",
|
||||
"sha256:240296b27397e4e37874abb1df2a608a92df85cf3e2a04d0d4d61055c8305ba6",
|
||||
"sha256:36d829b31ab67d6fcb30e185ec996e1f72b892255a745d3a82138c97d21ed1cd",
|
||||
"sha256:37f48d46d733d57cc70fd5f30572d11ab8ed92da6e6b28e024e4a3edfb456e37",
|
||||
"sha256:4c790331247081ea7c632a76d5b2a265e6d325ecd3179d06e9cf8d46d90dd151",
|
||||
"sha256:5dcfc2e264bd8a1db8b11a892bd1647154ce03eeba94b461effe68790d8b8e07",
|
||||
"sha256:7147e2a76c75f0f64c4319886e7639e490fee87c9d25cb1d4faef1d8cf83a440",
|
||||
"sha256:7703620125e4fb79b64aa52427ec192822e9f45d37d4b6625ab37ef403e1df70",
|
||||
"sha256:8368f83e93c7156ccd40e49a783a6a6850ca25b556c0fa0240ed0f659d2fe496",
|
||||
"sha256:84aa6223d71012c68d577c83f4e7db50d11d6b1399a9c779046d75e24bed74ea",
|
||||
"sha256:85f95aa97a35bdb2f2f7d10ec5bbdac0aeb9dafdaf88e17492da0504de2e6400",
|
||||
"sha256:8db0e856712f79c45956da0c9a40ca4246abc3485ae0d7ecc86a20f5e4c09abc",
|
||||
"sha256:9044ef2df88d7f33692ae3f18d3be63dec69c4fb1b5a4a9ac950f9b4ba571606",
|
||||
"sha256:963c80b583b0661918718b095e02303d8078950b26cc00b5e5ea9ababe0de1fc",
|
||||
"sha256:987f15737aba2ab5f3928c617ccf1ce412e2e321c77ab16ca5a293e7bbffd581",
|
||||
"sha256:9ec45db0c766f196ae629e509f059ff05fc3148f9ffd28f3cfe75d4afb485412",
|
||||
"sha256:9fc0b3cb5d1720e7141d103cf4819aea239f7d136acf9ee4a69b047b7986175a",
|
||||
"sha256:a2c927c49f2029291fbabd673d51a2180038f8cd5a5b2f290f78c4516be48be2",
|
||||
"sha256:a38878a223bdd37c9709d07cd357bb79f4c760b29210e14ad0fb395294583787",
|
||||
"sha256:b4fcdcfa302538f70929eb7b392f536a237cbe2ed9cba88e3bf5027b39f5f77f",
|
||||
"sha256:c0c74e5579af4b977c8b932f40a5464764b2f86681327410aa028a22d2f54937",
|
||||
"sha256:c1c876fd795b36126f773db9cbb393f19808edd2637e00fd6caba0e25f2c7b64",
|
||||
"sha256:c9aadc4924d4b5799112837b226160428524a9a45f830e0d0f184b19e4090487",
|
||||
"sha256:cc7b98bf58167b7f2db91a4327da24fb93368838eb84a44c472283778fc2446b",
|
||||
"sha256:cf54cfa843f297991b7388c281cb3855d911137223c6b6d2dd82a47ae5125a41",
|
||||
"sha256:d003156bb6a59cda9050e983441b7fa2487f7800d76bdc065566b7d728b4581a",
|
||||
"sha256:d175297e9533d8d37437abc14e8a83cbc68af93cc9c1c59c2c292ec59a0697a3",
|
||||
"sha256:d746a437cdbca200622385305aedd9aef68e8a645e385cc483bdc5e488f07166",
|
||||
"sha256:e683e409e5c45d5c9082dc1daf13f6374300806240719f95dc783d1fc942af10"
|
||||
"sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace",
|
||||
"sha256:067a74454df670dcaa4e59349a2e5c81e567d8d65458d480a5b3dfecec08c5ff",
|
||||
"sha256:0fb71b8c643187d7492c1f8352f2c15b4c4af3f6338f21681d3681b3dc31a266",
|
||||
"sha256:1b3ead4a96c9101bef08f9f7d1217c096f31667617b58de957f690c92378b528",
|
||||
"sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6",
|
||||
"sha256:209596a4ec71d990d71d5e0d312ac935d86930e6eecff6ccc7007fe54d703808",
|
||||
"sha256:2c726c276d09fc5c414693a2de063f521052d9ea7c240ce553316f70656c84d4",
|
||||
"sha256:398e44cd480f4d2b7ee8d98385ca104e35c81525dd98c519acff1b79bdaac363",
|
||||
"sha256:52b1eb8c83f178ab787f3a4283f68258525f8d70f778a2f6dd54d3b5e5fb4341",
|
||||
"sha256:5feca99c17af94057417d744607b82dd0a664fd5e4ca98061480fd8b14b18d04",
|
||||
"sha256:7538e495704e2ccda9b234b82423a4038f324f3a10c43bc088a1636180f11a41",
|
||||
"sha256:760ad187b1041a154f0e4d0f6aae3e40fdb51d6de16e5c99aedadd9246450e9e",
|
||||
"sha256:777a26c84bea6cd934422ac2e3b78863a37017618b6e5c08f92ef69853e765d3",
|
||||
"sha256:95431a26309a21874005845c21118c83991c63ea800dd44843e42a916aec5899",
|
||||
"sha256:9ad2c92ec681e02baf81fdfa056fe0d818645efa9af1f1cd5fd6f1bd2bdfd805",
|
||||
"sha256:9c6d1a54552b5330bc657b7ef0eae25d00ba7ffe85d9ea8ae6540d2197a3788c",
|
||||
"sha256:aee0c1256be6c07bd3e1263ff920c325b59849dc95392a05f258bb9b259cf39c",
|
||||
"sha256:af3d4a73793725138d6b334d9d247ce7e5f084d96284ed23f22ee626a7b88e39",
|
||||
"sha256:b36b4f3920103a25e1d5d024d155c504080959582b928e91cb608a65c3a49e1a",
|
||||
"sha256:b9574c6f03f685070d859e75c7f9eeca02d6933273b5e69572e5ff9d5e3931c3",
|
||||
"sha256:bff6ad71c81b3bba8fa35f0f1921fb24ff4476235a6e94a26ada2e54370e6da7",
|
||||
"sha256:c190f0899e9f9f8b6b7863debfb739abcb21a5c054f911ca3596d12b8a4c4c7f",
|
||||
"sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075",
|
||||
"sha256:cae53c389825d3b46fb37538441f75d6aecc4174f615d048321b716df2757fb0",
|
||||
"sha256:dd4a21253f42b8d2b48410cb31fe501d32f8b9fbeb1f55063ad102fe9c425e40",
|
||||
"sha256:dde816ca9dac1d9c01dd504ea5967821606f02e510438120091b84e852367428",
|
||||
"sha256:f2362f3cb0f3172c42938946dbc5b7843c2a28aec307c49100c8b38764eb6927",
|
||||
"sha256:f328adcfebed9f11301eaedfa48e15bdece9b519fb27e6a8c01aa52a17ec31b3",
|
||||
"sha256:f8afcf15cc511ada719a88e013cec87c11aff7b91f019295eb4530f96fe5ef2f",
|
||||
"sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"
|
||||
],
|
||||
"version": "==1.4.2"
|
||||
"version": "==1.4.3"
|
||||
},
|
||||
"typing-extensions": {
|
||||
"hashes": [
|
||||
|
||||
@@ -10,7 +10,7 @@ LNbits
|
||||
|
||||

|
||||
|
||||
# LNbits v0.1 BETA, free and open-source lightning-network wallet/accounts system
|
||||
# LNbits v0.3 BETA, free and open-source lightning-network wallet/accounts system
|
||||
|
||||
Use [lnbits.com](https://lnbits.com), or run your own LNbits server!
|
||||
|
||||
|
||||
@@ -54,10 +54,10 @@ Using this wallet requires the installation of the `lndgrpc` and `purerpc` Pytho
|
||||
|
||||
### LNPay
|
||||
|
||||
For the invoice listener to work you have a publicly accessible URL in your LNbits and must set up [LNPay webhooks](https://lnpay.co/webhook/) pointing to `<your LNbits host>/wallet/webhook` with the "Wallet Receive" event and no secret.
|
||||
For the invoice listener to work you have a publicly accessible URL in your LNbits and must set up [LNPay webhooks](https://dashboard.lnpay.co/webhook/) pointing to `<your LNbits host>/wallet/webhook` with the "Wallet Receive" event and no secret.
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LNPayWallet**
|
||||
- `LNPAY_API_ENDPOINT`: https://lnpay.co/v1/
|
||||
- `LNPAY_API_ENDPOINT`: https://api.lnpay.co/v1/
|
||||
- `LNPAY_API_KEY`: sak_apiKey
|
||||
- `LNPAY_WALLET_KEY`: waka_apiKey
|
||||
|
||||
|
||||
@@ -2,13 +2,14 @@ import json
|
||||
import datetime
|
||||
from uuid import uuid4
|
||||
from typing import List, Optional, Dict, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.db import Connection
|
||||
from lnbits.settings import DEFAULT_WALLET_NAME
|
||||
|
||||
from . import db
|
||||
from .models import User, Wallet, Payment
|
||||
from .models import User, Wallet, Payment, BalanceCheck
|
||||
|
||||
|
||||
# accounts
|
||||
@@ -370,12 +371,86 @@ async def check_internal(
|
||||
) -> Optional[str]:
|
||||
row = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT checking_id FROM apipayments
|
||||
WHERE hash = ? AND pending AND amount > 0
|
||||
""",
|
||||
SELECT checking_id FROM apipayments
|
||||
WHERE hash = ? AND pending AND amount > 0
|
||||
""",
|
||||
(payment_hash,),
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
else:
|
||||
return row["checking_id"]
|
||||
|
||||
|
||||
# balance_check
|
||||
# -------------
|
||||
|
||||
|
||||
async def save_balance_check(
|
||||
wallet_id: str,
|
||||
url: str,
|
||||
conn: Optional[Connection] = None,
|
||||
):
|
||||
domain = urlparse(url).netloc
|
||||
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO balance_check (wallet, service, url)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(wallet_id, domain, url),
|
||||
)
|
||||
|
||||
|
||||
async def get_balance_check(
|
||||
wallet_id: str,
|
||||
domain: str,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Optional[BalanceCheck]:
|
||||
row = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT wallet, service, url
|
||||
FROM balance_check
|
||||
WHERE wallet = ? AND service = ?
|
||||
""",
|
||||
(wallet_id, domain),
|
||||
)
|
||||
return BalanceCheck.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_balance_checks(conn: Optional[Connection] = None) -> List[BalanceCheck]:
|
||||
rows = await (conn or db).fetchall("SELECT wallet, service, url FROM balance_check")
|
||||
return [BalanceCheck.from_row(row) for row in rows]
|
||||
|
||||
|
||||
# balance_notify
|
||||
# --------------
|
||||
|
||||
|
||||
async def save_balance_notify(
|
||||
wallet_id: str,
|
||||
url: str,
|
||||
conn: Optional[Connection] = None,
|
||||
):
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO balance_notify (wallet, url)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(wallet_id, url),
|
||||
)
|
||||
|
||||
|
||||
async def get_balance_notify(
|
||||
wallet_id: str,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Optional[str]:
|
||||
row = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT url
|
||||
FROM balance_notify
|
||||
WHERE wallet = ?
|
||||
""",
|
||||
(wallet_id,),
|
||||
)
|
||||
return row[0] if row else None
|
||||
|
||||
@@ -135,3 +135,58 @@ async def m003_add_invoice_webhook(db):
|
||||
|
||||
await db.execute("ALTER TABLE apipayments ADD COLUMN webhook TEXT")
|
||||
await db.execute("ALTER TABLE apipayments ADD COLUMN webhook_status TEXT")
|
||||
|
||||
|
||||
async def m004_ensure_fees_are_always_negative(db):
|
||||
"""
|
||||
Use abs() so wallet backends don't have to care about the sign of the fees.
|
||||
"""
|
||||
|
||||
await db.execute("DROP VIEW balances")
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE VIEW IF NOT EXISTS balances AS
|
||||
SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM (
|
||||
SELECT wallet, SUM(amount) AS s -- incoming
|
||||
FROM apipayments
|
||||
WHERE amount > 0 AND pending = 0 -- don't sum pending
|
||||
GROUP BY wallet
|
||||
UNION ALL
|
||||
SELECT wallet, SUM(amount - abs(fee)) AS s -- outgoing, sum fees
|
||||
FROM apipayments
|
||||
WHERE amount < 0 -- do sum pending
|
||||
GROUP BY wallet
|
||||
)
|
||||
GROUP BY wallet;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m005_balance_check_balance_notify(db):
|
||||
"""
|
||||
Keep track of balanceCheck-enabled lnurl-withdrawals to be consumed by an LNbits wallet and of balanceNotify URLs supplied by users to empty their wallets.
|
||||
"""
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE balance_check (
|
||||
wallet INTEGER NOT NULL REFERENCES wallets (id),
|
||||
service TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
|
||||
UNIQUE(wallet, service)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE balance_notify (
|
||||
wallet INTEGER NOT NULL REFERENCES wallets (id),
|
||||
url TEXT NOT NULL,
|
||||
|
||||
UNIQUE(wallet, url)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import json
|
||||
import hmac
|
||||
import hashlib
|
||||
from quart import url_for
|
||||
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
|
||||
|
||||
@@ -36,6 +38,25 @@ class Wallet(NamedTuple):
|
||||
def balance(self) -> int:
|
||||
return self.balance_msat // 1000
|
||||
|
||||
@property
|
||||
def withdrawable_balance(self) -> int:
|
||||
from .services import fee_reserve
|
||||
|
||||
return self.balance_msat - fee_reserve(self.balance_msat)
|
||||
|
||||
@property
|
||||
def lnurlwithdraw_full(self) -> str:
|
||||
url = url_for(
|
||||
"core.lnurl_full_withdraw",
|
||||
usr=self.user,
|
||||
wal=self.id,
|
||||
_external=True,
|
||||
)
|
||||
try:
|
||||
return lnurl_encode(url)
|
||||
except:
|
||||
return ""
|
||||
|
||||
def lnurlauth_key(self, domain: str) -> SigningKey:
|
||||
hashing_key = hashlib.sha256(self.id.encode("utf-8")).digest()
|
||||
linking_key = hmac.digest(hashing_key, domain.encode("utf-8"), "sha256")
|
||||
@@ -145,16 +166,26 @@ class Payment(NamedTuple):
|
||||
else:
|
||||
status = await WALLET.get_invoice_status(self.checking_id)
|
||||
|
||||
print(
|
||||
f" - checking '{'in' if self.is_in else 'out'}' {self.checking_id}: {status}"
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
async def delete(self) -> None:
|
||||
from .crud import delete_payment
|
||||
|
||||
await delete_payment(self.checking_id)
|
||||
|
||||
|
||||
class BalanceCheck(NamedTuple):
|
||||
wallet: str
|
||||
service: str
|
||||
url: str
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row):
|
||||
return cls(wallet=row["wallet"], service=row["service"], url=row["url"])
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import trio # type: ignore
|
||||
import json
|
||||
import httpx
|
||||
from io import BytesIO
|
||||
from binascii import unhexlify
|
||||
from typing import Optional, Tuple, Dict
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from quart import g
|
||||
from lnurl import LnurlErrorResponse, LnurlWithdrawResponse # type: ignore
|
||||
from quart import g, url_for
|
||||
from lnurl import LnurlErrorResponse, decode as decode_lnurl # type: ignore
|
||||
|
||||
try:
|
||||
from typing import TypedDict # type: ignore
|
||||
@@ -29,6 +30,14 @@ from .crud import (
|
||||
)
|
||||
|
||||
|
||||
class PaymentFailure(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvoiceFailure(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def create_invoice(
|
||||
*,
|
||||
wallet_id: str,
|
||||
@@ -46,7 +55,7 @@ async def create_invoice(
|
||||
amount=amount, memo=invoice_memo, description_hash=description_hash
|
||||
)
|
||||
if not ok:
|
||||
raise Exception(error_message or "Unexpected backend error.")
|
||||
raise InvoiceFailure(error_message or "Unexpected backend error.")
|
||||
|
||||
invoice = bolt11.decode(payment_request)
|
||||
|
||||
@@ -120,10 +129,9 @@ async def pay_invoice(
|
||||
else:
|
||||
# create a temporary payment here so we can check if
|
||||
# the balance is enough in the next step
|
||||
fee_reserve = max(1000, int(invoice.amount_msat * 0.01))
|
||||
await create_payment(
|
||||
checking_id=temp_id,
|
||||
fee=-fee_reserve,
|
||||
fee=-fee_reserve(invoice.amount_msat),
|
||||
conn=conn,
|
||||
**payment_kwargs,
|
||||
)
|
||||
@@ -162,8 +170,9 @@ async def pay_invoice(
|
||||
)
|
||||
await delete_payment(temp_id, conn=conn)
|
||||
else:
|
||||
raise Exception(
|
||||
payment.error_message or "Failed to pay_invoice on backend."
|
||||
raise PaymentFailure(
|
||||
payment.error_message
|
||||
or "Payment failed, but backend didn't give us an error message."
|
||||
)
|
||||
|
||||
return invoice.payment_hash
|
||||
@@ -171,25 +180,58 @@ async def pay_invoice(
|
||||
|
||||
async def redeem_lnurl_withdraw(
|
||||
wallet_id: str,
|
||||
res: LnurlWithdrawResponse,
|
||||
lnurl_request: str,
|
||||
memo: Optional[str] = None,
|
||||
extra: Optional[Dict] = None,
|
||||
wait_seconds: int = 0,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
_, payment_request = await create_invoice(
|
||||
wallet_id=wallet_id,
|
||||
amount=res.max_sats,
|
||||
memo=memo or res.default_description or "",
|
||||
extra={"tag": "lnurlwallet"},
|
||||
conn=conn,
|
||||
)
|
||||
if not lnurl_request:
|
||||
return None
|
||||
|
||||
res = {}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
lnurl = decode_lnurl(lnurl_request)
|
||||
r = await client.get(str(lnurl))
|
||||
res = r.json()
|
||||
|
||||
try:
|
||||
_, payment_request = await create_invoice(
|
||||
wallet_id=wallet_id,
|
||||
amount=int(res["maxWithdrawable"] / 1000),
|
||||
memo=memo or res["defaultDescription"] or "",
|
||||
extra=extra,
|
||||
conn=conn,
|
||||
)
|
||||
except:
|
||||
print(
|
||||
f"failed to create invoice on redeem_lnurl_withdraw from {lnurl}. params: {res}"
|
||||
)
|
||||
return None
|
||||
|
||||
if wait_seconds:
|
||||
await trio.sleep(wait_seconds)
|
||||
|
||||
params = {
|
||||
"k1": res["k1"],
|
||||
"pr": payment_request,
|
||||
}
|
||||
|
||||
try:
|
||||
params["balanceNotify"] = url_for(
|
||||
"core.lnurl_balance_notify",
|
||||
service=urlparse(lnurl_request).netloc,
|
||||
wal=wallet_id,
|
||||
_external=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
await client.get(
|
||||
res.callback.base,
|
||||
params={
|
||||
**res.callback.query_params,
|
||||
**{"k1": res.k1, "pr": payment_request},
|
||||
},
|
||||
res["callback"],
|
||||
params=params,
|
||||
)
|
||||
|
||||
|
||||
@@ -277,3 +319,7 @@ async def check_invoice_status(
|
||||
return PaymentStatus(None)
|
||||
|
||||
return await WALLET.get_invoice_status(payment.checking_id)
|
||||
|
||||
|
||||
def fee_reserve(amount_msat: int) -> int:
|
||||
return max(1000, int(amount_msat * 0.01))
|
||||
|
||||
@@ -211,6 +211,9 @@ new Vue({
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
paymentTableRowKey: function (row) {
|
||||
return row.payment_hash + row.amount
|
||||
},
|
||||
closeCamera: function () {
|
||||
this.parse.camera.show = false
|
||||
},
|
||||
|
||||
@@ -3,7 +3,9 @@ import httpx
|
||||
from typing import List
|
||||
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
from . import db
|
||||
from .crud import get_balance_notify
|
||||
from .models import Payment
|
||||
|
||||
sse_listeners: List[trio.MemorySendChannel] = []
|
||||
@@ -24,6 +26,19 @@ async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
|
||||
if payment.webhook and not payment.webhook_status:
|
||||
await dispatch_webhook(payment)
|
||||
|
||||
# dispatch balance_notify
|
||||
url = await get_balance_notify(payment.wallet_id)
|
||||
if url:
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
r = await client.post(
|
||||
url,
|
||||
timeout=4,
|
||||
)
|
||||
await mark_webhook_sent(payment, r.status_code)
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
pass
|
||||
|
||||
|
||||
async def dispatch_sse(payment: Payment):
|
||||
for send_channel in sse_listeners:
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card-section>
|
||||
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br />
|
||||
<strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br />
|
||||
<strong>Invoice/read key: </strong><em>{{ wallet.inkey }}</em>
|
||||
</q-card-section>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
|
||||
@@ -88,21 +88,81 @@
|
||||
<div class="col-12 col-md-3 col-lg-3">
|
||||
<div class="row q-col-gutter-lg justify-center">
|
||||
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
|
||||
<q-btn
|
||||
flat
|
||||
color="purple"
|
||||
label="Supporters"
|
||||
type="a"
|
||||
href="mailto:lnbits@pm.me"
|
||||
class="full-width"
|
||||
></q-btn>
|
||||
<a href="https://github.com/rootzoll/raspiblitz">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/raspiblitz-negative.svg' : '/static/images/raspiblitz.svg'"
|
||||
style="height: 90px"
|
||||
></q-img>
|
||||
</a>
|
||||
<q-btn flat color="purple" label="Runs on" class="full-width"></q-btn>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<a href="https://github.com/ElementsProject/lightning">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/cln.png' : '/static/images/clnl.png'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col q-pl-md">
|
||||
<a href="https://github.com/lightningnetwork/lnd">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/lnd.png' : '/static/images/lnd.png'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<a href="https://opennode.com">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/opennode.png' : '/static/images/opennodel.png'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col q-pl-md">
|
||||
<a href="https://lnpay.co/">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/lnpay.png' : '/static/images/lnpayl.png'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<a href="https://github.com/shesek/spark-wallet">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/spark.png' : '/static/images/sparkl.png'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col q-pl-md">
|
||||
<a href="https://t.me/lntxbot">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/lntxbot.png' : '/static/images/lntxbotl.png'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<a href="https://github.com/rootzoll/raspiblitz">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/blitz.png' : '/static/images/blitzl.png'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col q-pl-md">
|
||||
<a href="https://getumbrel.com/">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/umbrel.png' : '/static/images/umbrell.png'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<!---->
|
||||
{% 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 %}
|
||||
@@ -87,9 +88,10 @@
|
||||
dense
|
||||
flat
|
||||
:data="filteredPayments"
|
||||
row-key="payment_hash"
|
||||
:row-key="paymentTableRowKey"
|
||||
:columns="paymentsTable.columns"
|
||||
:pagination.sync="paymentsTable.pagination"
|
||||
no-data-label="No transactions made yet"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
@@ -219,25 +221,53 @@
|
||||
<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">LNbits wallet</h6>
|
||||
<strong>Wallet name: </strong><em>{{ wallet.name }}</em><br />
|
||||
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br />
|
||||
<strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br />
|
||||
<strong>Invoice/read key: </strong><em>{{ wallet.inkey }}</em>
|
||||
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
|
||||
LNbits 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" %}
|
||||
<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>
|
||||
<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>
|
||||
<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.url_root}}'+'wallet?usr={{user.id}}&wal={{wallet.id}}'"
|
||||
:options="{width:240}"
|
||||
@@ -304,7 +334,7 @@
|
||||
<q-btn
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
:disable="receive.data.amount == null || receive.data.amount <= 0"
|
||||
:disable="receive.data.memo == null || receive.data.amount == null || receive.data.amount <= 0"
|
||||
type="submit"
|
||||
>
|
||||
<span v-if="receive.lnurl">
|
||||
|
||||
@@ -3,7 +3,7 @@ import json
|
||||
import lnurl # type: ignore
|
||||
import httpx
|
||||
from urllib.parse import urlparse, urlunparse, urlencode, parse_qs, ParseResult
|
||||
from quart import g, jsonify, make_response
|
||||
from quart import g, jsonify, make_response, url_for
|
||||
from http import HTTPStatus
|
||||
from binascii import unhexlify
|
||||
from typing import Dict, Union
|
||||
@@ -12,7 +12,14 @@ from lnbits import bolt11
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
|
||||
from .. import core_app, db
|
||||
from ..services import create_invoice, pay_invoice, perform_lnurlauth
|
||||
from ..crud import save_balance_check
|
||||
from ..services import (
|
||||
PaymentFailure,
|
||||
InvoiceFailure,
|
||||
create_invoice,
|
||||
pay_invoice,
|
||||
perform_lnurlauth,
|
||||
)
|
||||
from ..tasks import sse_listeners
|
||||
|
||||
|
||||
@@ -54,6 +61,7 @@ async def api_payments():
|
||||
"excludes": "memo",
|
||||
},
|
||||
"lnurl_callback": {"type": "string", "nullable": True, "required": False},
|
||||
"lnurl_balance_check": {"type": "string", "required": False},
|
||||
"extra": {"type": "dict", "nullable": True, "required": False},
|
||||
"webhook": {"type": "string", "empty": False, "required": False},
|
||||
}
|
||||
@@ -67,25 +75,41 @@ async def api_payments_create_invoice():
|
||||
memo = g.data["memo"]
|
||||
|
||||
async with db.connect() as conn:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=g.wallet.id,
|
||||
amount=g.data["amount"],
|
||||
memo=memo,
|
||||
description_hash=description_hash,
|
||||
extra=g.data.get("extra"),
|
||||
webhook=g.data.get("webhook"),
|
||||
conn=conn,
|
||||
)
|
||||
try:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=g.wallet.id,
|
||||
amount=g.data["amount"],
|
||||
memo=memo,
|
||||
description_hash=description_hash,
|
||||
extra=g.data.get("extra"),
|
||||
webhook=g.data.get("webhook"),
|
||||
conn=conn,
|
||||
)
|
||||
except InvoiceFailure as e:
|
||||
return jsonify({"message": str(e)}), 520
|
||||
except Exception as exc:
|
||||
raise exc
|
||||
|
||||
invoice = bolt11.decode(payment_request)
|
||||
|
||||
lnurl_response: Union[None, bool, str] = None
|
||||
if g.data.get("lnurl_callback"):
|
||||
if "lnurl_balance_check" in g.data:
|
||||
save_balance_check(g.wallet.id, g.data["lnurl_balance_check"])
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
r = await client.get(
|
||||
g.data["lnurl_callback"],
|
||||
params={"pr": payment_request},
|
||||
params={
|
||||
"pr": payment_request,
|
||||
"balanceNotify": url_for(
|
||||
"core.lnurl_balance_notify",
|
||||
service=urlparse(g.data["lnurl_callback"]).netloc,
|
||||
wal=g.wallet.id,
|
||||
_external=True,
|
||||
),
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
if r.is_error:
|
||||
@@ -127,6 +151,8 @@ async def api_payments_pay_invoice():
|
||||
return jsonify({"message": str(e)}), HTTPStatus.BAD_REQUEST
|
||||
except PermissionError as e:
|
||||
return jsonify({"message": str(e)}), HTTPStatus.FORBIDDEN
|
||||
except PaymentFailure as e:
|
||||
return jsonify({"message": str(e)}), 520
|
||||
except Exception as exc:
|
||||
raise exc
|
||||
|
||||
@@ -374,6 +400,12 @@ async def api_lnurlscan(code: str):
|
||||
parsed_callback: ParseResult = urlparse(data.callback)
|
||||
qs: Dict = parse_qs(parsed_callback.query)
|
||||
qs["k1"] = data.k1
|
||||
|
||||
# balanceCheck/balanceNotify
|
||||
if "balanceCheck" in jdata:
|
||||
params.update(balanceCheck=jdata["balanceCheck"])
|
||||
|
||||
# format callback url and send to client
|
||||
parsed_callback = parsed_callback._replace(query=urlencode(qs, doseq=True))
|
||||
params.update(callback=urlunparse(parsed_callback))
|
||||
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import trio # type: ignore
|
||||
import httpx
|
||||
from os import path
|
||||
from http import HTTPStatus
|
||||
from quart import (
|
||||
g,
|
||||
abort,
|
||||
redirect,
|
||||
jsonify,
|
||||
request,
|
||||
redirect,
|
||||
render_template,
|
||||
send_from_directory,
|
||||
url_for,
|
||||
)
|
||||
from lnurl import LnurlResponse, LnurlWithdrawResponse, decode as decode_lnurl # type: ignore
|
||||
|
||||
from lnbits.core import core_app, db
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
from lnbits.settings import LNBITS_ALLOWED_USERS, SERVICE_FEE
|
||||
from lnbits.settings import LNBITS_ALLOWED_USERS, SERVICE_FEE, LNBITS_SITE_TITLE
|
||||
|
||||
from ..crud import (
|
||||
create_account,
|
||||
@@ -23,8 +21,10 @@ from ..crud import (
|
||||
update_user_extension,
|
||||
create_wallet,
|
||||
delete_wallet,
|
||||
get_balance_check,
|
||||
save_balance_notify,
|
||||
)
|
||||
from ..services import redeem_lnurl_withdraw
|
||||
from ..services import redeem_lnurl_withdraw, pay_invoice
|
||||
|
||||
|
||||
@core_app.route("/favicon.ico")
|
||||
@@ -107,6 +107,62 @@ async def wallet():
|
||||
)
|
||||
|
||||
|
||||
@core_app.route("/withdraw")
|
||||
@validate_uuids(["usr", "wal"], required=True)
|
||||
async def lnurl_full_withdraw():
|
||||
user = await get_user(request.args.get("usr"))
|
||||
if not user:
|
||||
return jsonify({"status": "ERROR", "reason": "User does not exist."})
|
||||
|
||||
wallet = user.get_wallet(request.args.get("wal"))
|
||||
if not wallet:
|
||||
return jsonify({"status": "ERROR", "reason": "Wallet does not exist."})
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"tag": "withdrawRequest",
|
||||
"callback": url_for(
|
||||
"core.lnurl_full_withdraw_callback",
|
||||
usr=user.id,
|
||||
wal=wallet.id,
|
||||
_external=True,
|
||||
),
|
||||
"k1": "0",
|
||||
"minWithdrawable": 1 if wallet.withdrawable_balance else 0,
|
||||
"maxWithdrawable": wallet.withdrawable_balance,
|
||||
"defaultDescription": f"{LNBITS_SITE_TITLE} balance withdraw from {wallet.id[0:5]}",
|
||||
"balanceCheck": url_for(
|
||||
"core.lnurl_full_withdraw", usr=user.id, wal=wallet.id, _external=True
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@core_app.route("/withdraw/cb")
|
||||
@validate_uuids(["usr", "wal"], required=True)
|
||||
async def lnurl_full_withdraw_callback():
|
||||
user = await get_user(request.args.get("usr"))
|
||||
if not user:
|
||||
return jsonify({"status": "ERROR", "reason": "User does not exist."})
|
||||
|
||||
wallet = user.get_wallet(request.args.get("wal"))
|
||||
if not wallet:
|
||||
return jsonify({"status": "ERROR", "reason": "Wallet does not exist."})
|
||||
|
||||
pr = request.args.get("pr")
|
||||
|
||||
async def pay():
|
||||
await pay_invoice(wallet_id=wallet.id, payment_request=pr)
|
||||
|
||||
g.nursery.start_soon(pay)
|
||||
|
||||
balance_notify = request.args.get("balanceNotify")
|
||||
if balance_notify:
|
||||
await save_balance_notify(wallet.id, balance_notify)
|
||||
|
||||
return jsonify({"status": "OK"})
|
||||
|
||||
|
||||
@core_app.route("/deletewallet")
|
||||
@validate_uuids(["usr", "wal"], required=True)
|
||||
@check_user_exists()
|
||||
@@ -126,31 +182,16 @@ async def deletewallet():
|
||||
return redirect(url_for("core.home"))
|
||||
|
||||
|
||||
@core_app.route("/withdraw/notify/<service>")
|
||||
@validate_uuids(["wal"], required=True)
|
||||
async def lnurl_balance_notify(service: str):
|
||||
bc = await get_balance_check(request.args.get("wal"), service)
|
||||
if bc:
|
||||
redeem_lnurl_withdraw(bc.wallet, bc.url)
|
||||
|
||||
|
||||
@core_app.route("/lnurlwallet")
|
||||
async def lnurlwallet():
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
lnurl = decode_lnurl(request.args.get("lightning"))
|
||||
r = await client.get(str(lnurl))
|
||||
withdraw_res = LnurlResponse.from_dict(r.json())
|
||||
|
||||
if not withdraw_res.ok:
|
||||
return (
|
||||
f"Could not process lnurl-withdraw: {withdraw_res.error_msg}",
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
if not isinstance(withdraw_res, LnurlWithdrawResponse):
|
||||
return (
|
||||
f"Expected an lnurl-withdraw code, got {withdraw_res.tag}",
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
except Exception as exc:
|
||||
return (
|
||||
f"Could not process lnurl-withdraw: {exc}",
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
async with db.connect() as conn:
|
||||
account = await create_account(conn=conn)
|
||||
user = await get_user(account.id, conn=conn)
|
||||
@@ -159,9 +200,46 @@ async def lnurlwallet():
|
||||
g.nursery.start_soon(
|
||||
redeem_lnurl_withdraw,
|
||||
wallet.id,
|
||||
withdraw_res,
|
||||
request.args.get("lightning"),
|
||||
"LNbits initial funding: voucher redeem.",
|
||||
{"tag": "lnurlwallet"},
|
||||
5, # wait 5 seconds before sending the invoice to the service
|
||||
)
|
||||
await trio.sleep(3)
|
||||
|
||||
return redirect(url_for("core.wallet", usr=user.id, wal=wallet.id))
|
||||
|
||||
|
||||
@core_app.route("/manifest/<usr>.webmanifest")
|
||||
async def manifest(usr: str):
|
||||
user = await get_user(usr)
|
||||
if not user:
|
||||
return "", HTTPStatus.NOT_FOUND
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"short_name": "LNbits",
|
||||
"name": "LNbits Wallet",
|
||||
"icons": [
|
||||
{
|
||||
"src": "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",
|
||||
"display": "standalone",
|
||||
"scope": "/",
|
||||
"theme_color": "#3367D6",
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": wallet.name,
|
||||
"short_name": wallet.name,
|
||||
"description": wallet.name,
|
||||
"url": "/wallet?usr=" + usr + "&wal=" + wallet.id,
|
||||
}
|
||||
for wallet in user.wallets
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -37,12 +37,15 @@ async def api_amilkit(amilk_id):
|
||||
except LnurlException:
|
||||
abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
|
||||
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=milk.wallet,
|
||||
amount=withdraw_res.max_sats,
|
||||
memo=memo,
|
||||
extra={"tag": "amilk"},
|
||||
)
|
||||
try:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=milk.wallet,
|
||||
amount=withdraw_res.max_sats,
|
||||
memo=memo,
|
||||
extra={"tag": "amilk"},
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
r = httpx.get(
|
||||
withdraw_res.callback.base,
|
||||
|
||||
@@ -106,7 +106,7 @@ async def create_bleskomat_lnurl(
|
||||
return bleskomat_lnurl
|
||||
|
||||
|
||||
async def get_bleskomat_lnurl(secret: str) -> BleskomatLnurl:
|
||||
async def get_bleskomat_lnurl(secret: str) -> Optional[BleskomatLnurl]:
|
||||
hash = generate_bleskomat_lnurl_hash(secret)
|
||||
row = await db.fetchone("SELECT * FROM bleskomat_lnurls WHERE hash = ?", (hash,))
|
||||
return BleskomatLnurl(**row) if row else None
|
||||
|
||||
@@ -86,10 +86,13 @@ class BleskomatLnurl(NamedTuple):
|
||||
raise LnurlValidationError("Maximum number of uses already reached")
|
||||
tag = self.tag
|
||||
if tag == "withdrawRequest":
|
||||
payment_hash = await pay_invoice(
|
||||
wallet_id=self.wallet,
|
||||
payment_request=query["pr"],
|
||||
)
|
||||
try:
|
||||
payment_hash = await pay_invoice(
|
||||
wallet_id=self.wallet,
|
||||
payment_request=query["pr"],
|
||||
)
|
||||
except Exception:
|
||||
raise LnurlValidationError("Failed to pay invoice")
|
||||
if not payment_hash:
|
||||
raise LnurlValidationError("Failed to pay invoice")
|
||||
|
||||
|
||||
@@ -13,12 +13,14 @@ function ccreateIframeElement(t = {}) {
|
||||
var captchaid = document
|
||||
.getElementById('captchascript')
|
||||
.getAttribute('data-captchaid')
|
||||
return (e.src = 'http://localhost:5000/captcha/' + captchaid), e
|
||||
var lnbhostsrc = document.getElementById('captchascript').getAttribute('src')
|
||||
var lnbhost = lnbhostsrc.split('/captcha/static/js/captcha.js')[0]
|
||||
return (e.src = lnbhost + '/captcha/' + captchaid), e
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (captchaStyleAdded) console.log('Captcha stuff already added!')
|
||||
if (captchaStyleAdded) console.log('Captcha already added!')
|
||||
else {
|
||||
console.log('Adding captcha stuff'), (captchaStyleAdded = !0)
|
||||
console.log('Adding captcha'), (captchaStyleAdded = !0)
|
||||
var t = document.createElement('style')
|
||||
t.innerHTML =
|
||||
"\t/*Button*/\t\t.button-captcha-filled\t\t\t{\t\t\tdisplay: flex;\t\t\talign-items: center;\t\t\tjustify-content: center;\t\t\twidth: 120px;\t\t\tmin-width: 30px;\t\t\theight: 40px;\t\t\tline-height: 2.5;\t\t\ttext-align: center;\t\t\tcursor: pointer;\t\t\t/* Rectangle 2: */\t\t\tbackground: #FF7979;\t\t\tbox-shadow: 0 2px 4px 0 rgba(0,0,0,0.20);\t\t\tborder-radius: 20px;\t\t\t/* Sign up: */\t\t\tfont-family: 'Avenir-Heavy', Futura, Helvetica, Arial;\t\t\tfont-size: 16px;\t\t\tcolor: #FFFFFF;\t\t}\t\t.button-captcha-filled:hover\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #FF7979;\t\t\tbox-shadow: 0 0 4px 0 rgba(0,0,0,0.20);\t\t}\t\t.button-captcha-filled:active\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #FF7979;\t\t\t/*Move it down a little bit*/\t\t\tposition: relative;\t\t\ttop: 1px;\t\t}\t\t.button-captcha-filled-dark\t\t\t{\t\t\tdisplay: flex;\t\t\talign-items: center;\t\t\tjustify-content: center;\t\t\twidth: 120px;\t\t\tmin-width: 30px;\t\t\theight: 40px;\t\t\tline-height: 2.5;\t\t\ttext-align: center;\t\t\tcursor: pointer;\t\t\t/* Rectangle 2: */\t\t\tbackground: #161C38;\t\t\tbox-shadow: 0 0px 4px 0 rgba(0,0,0,0.20);\t\t\tborder-radius: 20px;\t\t\t/* Sign up: */\t\t\tfont-family: 'Avenir-Heavy', Futura, Helvetica, Arial;\t\t\tfont-size: 16px;\t\t\tcolor: #FFFFFF;\t\t}\t\t.button-captcha-filled-dark:hover\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #161C38;\t\t\tbox-shadow: 0 0px 4px 0 rgba(0,0,0,0.20);\t\t}\t\t.button-captcha-filled-dark:active\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #161C38;\t\t\t/*Move it down a little bit*/\t\t\tposition: relative;\t\t\ttop: 1px;\t\t}\t\t.modal-captcha-container {\t\t position: fixed;\t\t z-index: 1000;\t\t text-align: left;/*Si no añado esto, a veces hereda el text-align:center del body, y entonces el popup queda movido a la derecha, por center + margin left que aplico*/\t\t left: 0;\t\t top: 0;\t\t width: 100%;\t\t height: 100%;\t\t background-color: rgba(0, 0, 0, 0.5);\t\t opacity: 0;\t\t visibility: hidden;\t\t transform: scale(1.1);\t\t transition: visibility 0s linear 0.25s, opacity 0.25s 0s, transform 0.25s;\t\t}\t\t.modal-captcha-content {\t\t position: absolute;\t\t top: 50%;\t\t left: 50%;\t\t transform: translate(-50%, -50%);\t\t background-color: white;\t\t width: 100%;\t\t height: 100%;\t\t border-radius: 0.5rem;\t\t /*Rounded shadowed borders*/\t\t\tbox-shadow: 2px 2px 4px 0 rgba(0,0,0,0.15);\t\t\tborder-radius: 5px;\t\t}\t\t.close-button-captcha {\t\t float: right;\t\t width: 1.5rem;\t\t line-height: 1.5rem;\t\t text-align: center;\t\t cursor: pointer;\t\t margin-right:20px;\t\t margin-top:10px;\t\t border-radius: 0.25rem;\t\t background-color: lightgray;\t\t}\t\t.close-button-captcha:hover {\t\t background-color: darkgray;\t\t}\t\t.show-modal-captcha {\t\t opacity: 1;\t\t visibility: visible;\t\t transform: scale(1.0);\t\t transition: visibility 0s linear 0s, opacity 0.25s 0s, transform 0.25s;\t\t}\t\t/* Mobile */\t\t@media screen and (min-device-width: 160px) and ( max-width: 1077px ) /*No tendria ni por que poner un min-device, porq abarca todo lo humano...*/\t\t{\t\t}"
|
||||
|
||||
@@ -41,10 +41,14 @@
|
||||
|
||||
<q-card v-show="ticketLink.show" class="q-pa-lg">
|
||||
<div class="text-center q-mb-lg">
|
||||
<a :href="ticketLink.data.link" target="_blank">
|
||||
<q-btn unelevated size="xl" color="deep-purple"
|
||||
>Link to your ticket!</q-btn
|
||||
></a
|
||||
<q-btn
|
||||
unelevated
|
||||
size="xl"
|
||||
:href="ticketLink.data.link"
|
||||
target="_blank"
|
||||
color="deep-purple"
|
||||
type="a"
|
||||
>Link to your ticket!</q-btn
|
||||
>
|
||||
<br /><br />
|
||||
<p>You'll be redirected in a few moments...</p>
|
||||
|
||||
@@ -176,22 +176,29 @@
|
||||
<q-dialog v-model="formDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="sendEventData" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialog.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
>
|
||||
</q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.name"
|
||||
type="name"
|
||||
label="Name of event "
|
||||
></q-input>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.name"
|
||||
type="name"
|
||||
label="Title of event "
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col q-pl-sm">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialog.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
>
|
||||
</q-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
@@ -199,41 +206,63 @@
|
||||
type="textarea"
|
||||
label="Info about the event "
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.closing_date"
|
||||
type="date"
|
||||
label="Closing for tickets "
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.event_start_date"
|
||||
type="date"
|
||||
label="Event begins "
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.event_end_date"
|
||||
type="date"
|
||||
label="Event ends "
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="formDialog.data.amount_tickets"
|
||||
type="number"
|
||||
label="Amount of tickets "
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="formDialog.data.price_per_ticket"
|
||||
type="number"
|
||||
label="Price per ticket "
|
||||
></q-input>
|
||||
<div class="row">
|
||||
<div class="col-4">Ticket closing date</div>
|
||||
<div class="col-8">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.closing_date"
|
||||
type="date"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-4">Event begins</div>
|
||||
<div class="col-8">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.event_start_date"
|
||||
type="date"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-4">Event ends</div>
|
||||
<div class="col-8">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.event_end_date"
|
||||
type="date"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="formDialog.data.amount_tickets"
|
||||
type="number"
|
||||
label="Amount of tickets "
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col q-pl-sm">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="formDialog.data.price_per_ticket"
|
||||
type="number"
|
||||
label="Price per ticket "
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="formDialog.data.id"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# DJ Livestream
|
||||
|
||||
An extension to help DJs to conduct music livestreams.
|
||||
|
||||
It produces a single static QR code that can be shown on screen. Once someone scans that QR code with an lnurl-pay capable wallet they will see the name of the track being played at that time and the name of the producer.
|
||||
|
||||
They will then be given the opportunity to send a tip and a message related to that specific track and if they pay an amount over a specific threshold they will be given a link to download it (optional).
|
||||
|
||||
The revenue will be sent to a wallet created specifically for that producer, with optional revenue splitting between the DJ and the producer.
|
||||
|
||||
## Sponsored by
|
||||
|
||||
[](https://cryptograffiti.com/)
|
||||
@@ -0,0 +1,19 @@
|
||||
from quart import Blueprint
|
||||
|
||||
from lnbits.db import Database
|
||||
|
||||
db = Database("ext_livestream")
|
||||
|
||||
livestream_ext: Blueprint = Blueprint(
|
||||
"livestream", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
from .views import * # noqa
|
||||
from .lnurl import * # noqa
|
||||
from .tasks import register_listeners
|
||||
|
||||
from lnbits.tasks import record_async
|
||||
|
||||
livestream_ext.record(record_async(register_listeners))
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "DJ Livestream",
|
||||
"short_description": "Sell tracks and split revenue (lnurl-pay)",
|
||||
"icon": "speaker",
|
||||
"contributors": [
|
||||
"fiatjaf",
|
||||
"cryptograffiti"
|
||||
],
|
||||
"hidden": false
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from lnbits.core.crud import create_account, create_wallet
|
||||
|
||||
from . import db
|
||||
from .models import Livestream, Track, Producer
|
||||
|
||||
|
||||
async def create_livestream(*, wallet_id: str) -> int:
|
||||
result = await db.execute(
|
||||
"""
|
||||
INSERT INTO livestreams (wallet)
|
||||
VALUES (?)
|
||||
""",
|
||||
(wallet_id,),
|
||||
)
|
||||
return result._result_proxy.lastrowid
|
||||
|
||||
|
||||
async def get_livestream(ls_id: int) -> Optional[Livestream]:
|
||||
row = await db.fetchone("SELECT * FROM livestreams WHERE id = ?", (ls_id,))
|
||||
return Livestream(**dict(row)) if row else None
|
||||
|
||||
|
||||
async def get_livestream_by_track(track_id: int) -> Optional[Livestream]:
|
||||
row = await db.fetchone(
|
||||
"""
|
||||
SELECT livestreams.* FROM livestreams
|
||||
INNER JOIN tracks ON tracks.livestream = livestreams.id
|
||||
WHERE tracks.id = ?
|
||||
""",
|
||||
(track_id,),
|
||||
)
|
||||
return Livestream(**dict(row)) if row else None
|
||||
|
||||
|
||||
async def get_or_create_livestream_by_wallet(wallet: str) -> Optional[Livestream]:
|
||||
row = await db.fetchone("SELECT * FROM livestreams WHERE wallet = ?", (wallet,))
|
||||
|
||||
if not row:
|
||||
# create on the fly
|
||||
ls_id = await create_livestream(wallet_id=wallet)
|
||||
return await get_livestream(ls_id)
|
||||
|
||||
return Livestream(**dict(row)) if row else None
|
||||
|
||||
|
||||
async def update_current_track(ls_id: int, track_id: Optional[int]):
|
||||
await db.execute(
|
||||
"UPDATE livestreams SET current_track = ? WHERE id = ?",
|
||||
(track_id, ls_id),
|
||||
)
|
||||
|
||||
|
||||
async def update_livestream_fee(ls_id: int, fee_pct: int):
|
||||
await db.execute(
|
||||
"UPDATE livestreams SET fee_pct = ? WHERE id = ?",
|
||||
(fee_pct, ls_id),
|
||||
)
|
||||
|
||||
|
||||
async def add_track(
|
||||
livestream: int,
|
||||
name: str,
|
||||
download_url: Optional[str],
|
||||
price_msat: int,
|
||||
producer: Optional[int],
|
||||
) -> int:
|
||||
result = await db.execute(
|
||||
"""
|
||||
INSERT INTO tracks (livestream, name, download_url, price_msat, producer)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(livestream, name, download_url, price_msat, producer),
|
||||
)
|
||||
return result._result_proxy.lastrowid
|
||||
|
||||
|
||||
async def update_track(
|
||||
livestream: int,
|
||||
track_id: int,
|
||||
name: str,
|
||||
download_url: Optional[str],
|
||||
price_msat: int,
|
||||
producer: int,
|
||||
) -> int:
|
||||
result = await db.execute(
|
||||
"""
|
||||
UPDATE tracks SET
|
||||
name = ?,
|
||||
download_url = ?,
|
||||
price_msat = ?,
|
||||
producer = ?
|
||||
WHERE livestream = ? AND id = ?
|
||||
""",
|
||||
(name, download_url, price_msat, producer, livestream, track_id),
|
||||
)
|
||||
return result._result_proxy.lastrowid
|
||||
|
||||
|
||||
async def get_track(track_id: Optional[int]) -> Optional[Track]:
|
||||
if not track_id:
|
||||
return None
|
||||
|
||||
row = await db.fetchone(
|
||||
"""
|
||||
SELECT id, download_url, price_msat, name, producer
|
||||
FROM tracks WHERE id = ?
|
||||
""",
|
||||
(track_id,),
|
||||
)
|
||||
return Track(**dict(row)) if row else None
|
||||
|
||||
|
||||
async def get_tracks(livestream: int) -> List[Track]:
|
||||
rows = await db.fetchall(
|
||||
"""
|
||||
SELECT id, download_url, price_msat, name, producer
|
||||
FROM tracks WHERE livestream = ?
|
||||
""",
|
||||
(livestream,),
|
||||
)
|
||||
return [Track(**dict(row)) for row in rows]
|
||||
|
||||
|
||||
async def delete_track_from_livestream(livestream: int, track_id: int):
|
||||
await db.execute(
|
||||
"""
|
||||
DELETE FROM tracks WHERE livestream = ? AND id = ?
|
||||
""",
|
||||
(livestream, track_id),
|
||||
)
|
||||
|
||||
|
||||
async def add_producer(livestream: int, name: str) -> int:
|
||||
name = name.strip()
|
||||
|
||||
existing = await db.fetchall(
|
||||
"""
|
||||
SELECT id FROM producers
|
||||
WHERE livestream = ? AND lower(name) = ?
|
||||
""",
|
||||
(livestream, name.lower()),
|
||||
)
|
||||
if existing:
|
||||
return existing[0].id
|
||||
|
||||
user = await create_account()
|
||||
wallet = await create_wallet(user_id=user.id, wallet_name="livestream: " + name)
|
||||
|
||||
result = await db.execute(
|
||||
"""
|
||||
INSERT INTO producers (livestream, name, user, wallet)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(livestream, name, user.id, wallet.id),
|
||||
)
|
||||
return result._result_proxy.lastrowid
|
||||
|
||||
|
||||
async def get_producer(producer_id: int) -> Optional[Producer]:
|
||||
row = await db.fetchone(
|
||||
"""
|
||||
SELECT id, user, wallet, name
|
||||
FROM producers WHERE id = ?
|
||||
""",
|
||||
(producer_id,),
|
||||
)
|
||||
return Producer(**dict(row)) if row else None
|
||||
|
||||
|
||||
async def get_producers(livestream: int) -> List[Producer]:
|
||||
rows = await db.fetchall(
|
||||
"""
|
||||
SELECT id, user, wallet, name
|
||||
FROM producers WHERE livestream = ?
|
||||
""",
|
||||
(livestream,),
|
||||
)
|
||||
return [Producer(**dict(row)) for row in rows]
|
||||
@@ -0,0 +1,114 @@
|
||||
import hashlib
|
||||
import math
|
||||
from quart import jsonify, url_for, request
|
||||
from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore
|
||||
|
||||
from lnbits.core.services import create_invoice
|
||||
|
||||
from . import livestream_ext
|
||||
from .crud import get_livestream, get_livestream_by_track, get_track
|
||||
|
||||
|
||||
@livestream_ext.route("/lnurl/<ls_id>", methods=["GET"])
|
||||
async def lnurl_livestream(ls_id):
|
||||
ls = await get_livestream(ls_id)
|
||||
if not ls:
|
||||
return jsonify({"status": "ERROR", "reason": "Livestream not found."})
|
||||
|
||||
track = await get_track(ls.current_track)
|
||||
if not track:
|
||||
return jsonify({"status": "ERROR", "reason": "This livestream is offline."})
|
||||
|
||||
resp = LnurlPayResponse(
|
||||
callback=url_for(
|
||||
"livestream.lnurl_callback", track_id=track.id, _external=True
|
||||
),
|
||||
min_sendable=track.min_sendable,
|
||||
max_sendable=track.max_sendable,
|
||||
metadata=await track.lnurlpay_metadata(),
|
||||
)
|
||||
|
||||
params = resp.dict()
|
||||
params["commentAllowed"] = 300
|
||||
|
||||
return jsonify(params)
|
||||
|
||||
|
||||
@livestream_ext.route("/lnurl/t/<track_id>", methods=["GET"])
|
||||
async def lnurl_track(track_id):
|
||||
track = await get_track(track_id)
|
||||
if not track:
|
||||
return jsonify({"status": "ERROR", "reason": "Track not found."})
|
||||
|
||||
resp = LnurlPayResponse(
|
||||
callback=url_for(
|
||||
"livestream.lnurl_callback", track_id=track.id, _external=True
|
||||
),
|
||||
min_sendable=track.min_sendable,
|
||||
max_sendable=track.max_sendable,
|
||||
metadata=await track.lnurlpay_metadata(),
|
||||
)
|
||||
|
||||
params = resp.dict()
|
||||
params["commentAllowed"] = 300
|
||||
|
||||
return jsonify(params)
|
||||
|
||||
|
||||
@livestream_ext.route("/lnurl/cb/<track_id>", methods=["GET"])
|
||||
async def lnurl_callback(track_id):
|
||||
track = await get_track(track_id)
|
||||
if not track:
|
||||
return jsonify({"status": "ERROR", "reason": "Couldn't find track."})
|
||||
|
||||
amount_received = int(request.args.get("amount"))
|
||||
|
||||
if amount_received < track.min_sendable:
|
||||
return (
|
||||
jsonify(
|
||||
LnurlErrorResponse(
|
||||
reason=f"Amount {round(amount_received / 1000)} is smaller than minimum {math.floor(track.min_sendable)}."
|
||||
).dict()
|
||||
),
|
||||
)
|
||||
elif track.max_sendable < amount_received:
|
||||
return (
|
||||
jsonify(
|
||||
LnurlErrorResponse(
|
||||
reason=f"Amount {round(amount_received / 1000)} is greater than maximum {math.floor(track.max_sendable)}."
|
||||
).dict()
|
||||
),
|
||||
)
|
||||
|
||||
comment = request.args.get("comment")
|
||||
if len(comment or "") > 300:
|
||||
return jsonify(
|
||||
LnurlErrorResponse(
|
||||
reason=f"Got a comment with {len(comment)} characters, but can only accept 300"
|
||||
).dict()
|
||||
)
|
||||
|
||||
ls = await get_livestream_by_track(track_id)
|
||||
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=ls.wallet,
|
||||
amount=int(amount_received / 1000),
|
||||
memo=await track.fullname(),
|
||||
description_hash=hashlib.sha256(
|
||||
(await track.lnurlpay_metadata()).encode("utf-8")
|
||||
).digest(),
|
||||
extra={"tag": "livestream", "track": track.id, "comment": comment},
|
||||
)
|
||||
|
||||
if amount_received < track.price_msat:
|
||||
success_action = None
|
||||
else:
|
||||
success_action = track.success_action(payment_hash)
|
||||
|
||||
resp = LnurlPayActionResponse(
|
||||
pr=payment_request,
|
||||
success_action=success_action,
|
||||
routes=[],
|
||||
)
|
||||
|
||||
return jsonify(resp.dict())
|
||||
@@ -0,0 +1,39 @@
|
||||
async def m001_initial(db):
|
||||
"""
|
||||
Initial livestream tables.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE livestreams (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
wallet TEXT NOT NULL,
|
||||
fee_pct INTEGER NOT NULL DEFAULT 10,
|
||||
current_track INTEGER
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE producers (
|
||||
livestream INTEGER NOT NULL REFERENCES livestreams (id),
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user TEXT NOT NULL,
|
||||
wallet TEXT NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE tracks (
|
||||
livestream INTEGER NOT NULL REFERENCES livestreams (id),
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
download_url TEXT,
|
||||
price_msat INTEGER NOT NULL DEFAULT 0,
|
||||
name TEXT,
|
||||
producer INTEGER REFERENCES producers (id) NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,81 @@
|
||||
import json
|
||||
from quart import url_for
|
||||
from typing import NamedTuple, Optional
|
||||
from lnurl import Lnurl, encode as lnurl_encode # type: ignore
|
||||
from lnurl.types import LnurlPayMetadata # type: ignore
|
||||
from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore
|
||||
|
||||
|
||||
class Livestream(NamedTuple):
|
||||
id: int
|
||||
wallet: str
|
||||
fee_pct: int
|
||||
current_track: Optional[int]
|
||||
|
||||
@property
|
||||
def lnurl(self) -> Lnurl:
|
||||
url = url_for("livestream.lnurl_livestream", ls_id=self.id, _external=True)
|
||||
return lnurl_encode(url)
|
||||
|
||||
|
||||
class Track(NamedTuple):
|
||||
id: int
|
||||
download_url: str
|
||||
price_msat: int
|
||||
name: str
|
||||
producer: int
|
||||
|
||||
@property
|
||||
def min_sendable(self) -> int:
|
||||
return min(100_000, self.price_msat or 100_000)
|
||||
|
||||
@property
|
||||
def max_sendable(self) -> int:
|
||||
return max(50_000_000, self.price_msat * 5)
|
||||
|
||||
@property
|
||||
def lnurl(self) -> Lnurl:
|
||||
url = url_for("livestream.lnurl_track", track_id=self.id, _external=True)
|
||||
return lnurl_encode(url)
|
||||
|
||||
async def fullname(self) -> str:
|
||||
from .crud import get_producer
|
||||
|
||||
producer = await get_producer(self.producer)
|
||||
if producer:
|
||||
producer_name = producer.name
|
||||
else:
|
||||
producer_name = "unknown author"
|
||||
|
||||
return f"'{self.name}', from {producer_name}."
|
||||
|
||||
async def lnurlpay_metadata(self) -> LnurlPayMetadata:
|
||||
description = (
|
||||
await self.fullname()
|
||||
) + " Like this track? Send some sats in appreciation."
|
||||
|
||||
if self.download_url:
|
||||
description += f" Send {round(self.price_msat/1000)} sats or more and you can download it."
|
||||
|
||||
return LnurlPayMetadata(json.dumps([["text/plain", description]]))
|
||||
|
||||
def success_action(self, payment_hash: str) -> Optional[LnurlPaySuccessAction]:
|
||||
if not self.download_url:
|
||||
return None
|
||||
|
||||
return UrlAction(
|
||||
url=url_for(
|
||||
"livestream.track_redirect_download",
|
||||
track_id=self.id,
|
||||
p=payment_hash,
|
||||
_external=True,
|
||||
),
|
||||
description=f"Download the track {self.name}!",
|
||||
)
|
||||
|
||||
|
||||
class Producer(NamedTuple):
|
||||
id: int
|
||||
user: str
|
||||
wallet: str
|
||||
name: str
|
||||
@@ -0,0 +1,213 @@
|
||||
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
|
||||
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data() {
|
||||
return {
|
||||
cancelListener: () => {},
|
||||
selectedWallet: null,
|
||||
nextCurrentTrack: null,
|
||||
livestream: {
|
||||
tracks: [],
|
||||
producers: []
|
||||
},
|
||||
trackDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
sortedTracks() {
|
||||
return this.livestream.tracks.sort((a, b) => a.name - b.name)
|
||||
},
|
||||
tracksMap() {
|
||||
return Object.fromEntries(
|
||||
this.livestream.tracks.map(track => [track.id, track])
|
||||
)
|
||||
},
|
||||
producersMap() {
|
||||
return Object.fromEntries(
|
||||
this.livestream.producers.map(prod => [prod.id, prod])
|
||||
)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getTrackLabel(trackId) {
|
||||
let track = this.tracksMap[trackId]
|
||||
return `${track.name}, ${this.producersMap[track.producer].name}`
|
||||
},
|
||||
disabledAddTrackButton() {
|
||||
return (
|
||||
!this.trackDialog.data.name ||
|
||||
this.trackDialog.data.name.length === 0 ||
|
||||
!this.trackDialog.data.producer ||
|
||||
this.trackDialog.data.producer.length === 0
|
||||
)
|
||||
},
|
||||
changedWallet(wallet) {
|
||||
this.selectedWallet = wallet
|
||||
this.loadLivestream()
|
||||
this.startPaymentNotifier()
|
||||
},
|
||||
loadLivestream() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/livestream/api/v1/livestream',
|
||||
this.selectedWallet.inkey
|
||||
)
|
||||
.then(response => {
|
||||
this.livestream = response.data
|
||||
this.nextCurrentTrack = this.livestream.current_track
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
startPaymentNotifier() {
|
||||
this.cancelListener()
|
||||
|
||||
this.cancelListener = LNbits.events.onInvoicePaid(
|
||||
this.selectedWallet,
|
||||
payment => {
|
||||
let satoshiAmount = Math.round(payment.amount / 1000)
|
||||
let trackName = (
|
||||
this.tracksMap[payment.extra.track] || {name: '[unknown]'}
|
||||
).name
|
||||
|
||||
this.$q.notify({
|
||||
message: `Someone paid <b>${satoshiAmount} sat</b> for the track <em>${trackName}</em>.`,
|
||||
caption: payment.extra.comment
|
||||
? `<em>"${payment.extra.comment}"</em>`
|
||||
: undefined,
|
||||
color: 'secondary',
|
||||
html: true,
|
||||
timeout: 0,
|
||||
actions: [{label: 'Dismiss', color: 'white', handler: () => {}}]
|
||||
})
|
||||
}
|
||||
)
|
||||
},
|
||||
addTrack() {
|
||||
let {id, name, producer, price_sat, download_url} = this.trackDialog.data
|
||||
|
||||
const [method, path] = id
|
||||
? ['PUT', `/livestream/api/v1/livestream/tracks/${id}`]
|
||||
: ['POST', '/livestream/api/v1/livestream/tracks']
|
||||
|
||||
LNbits.api
|
||||
.request(method, path, this.selectedWallet.inkey, {
|
||||
download_url:
|
||||
download_url && download_url.length > 0 ? download_url : undefined,
|
||||
name,
|
||||
price_msat: price_sat * 1000 || 0,
|
||||
producer_name: typeof producer === 'string' ? producer : undefined,
|
||||
producer_id: typeof producer === 'object' ? producer.id : undefined
|
||||
})
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
message: `Track '${this.trackDialog.data.name}' added.`,
|
||||
timeout: 700
|
||||
})
|
||||
this.loadLivestream()
|
||||
this.trackDialog.show = false
|
||||
this.trackDialog.data = {}
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
openAddTrackDialog() {
|
||||
this.trackDialog.show = true
|
||||
this.trackDialog.data = {}
|
||||
},
|
||||
openUpdateDialog(itemId) {
|
||||
this.trackDialog.show = true
|
||||
let item = this.livestream.tracks.find(item => item.id === itemId)
|
||||
this.trackDialog.data = {
|
||||
...item,
|
||||
producer: this.livestream.producers.find(
|
||||
prod => prod.id === item.producer
|
||||
),
|
||||
price_sat: Math.round(item.price_msat / 1000)
|
||||
}
|
||||
},
|
||||
deleteTrack(trackId) {
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this track?')
|
||||
.onOk(() => {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/livestream/api/v1/livestream/tracks/' + trackId,
|
||||
this.selectedWallet.inkey
|
||||
)
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
message: `Track deleted`,
|
||||
timeout: 700
|
||||
})
|
||||
this.livestream.tracks.splice(
|
||||
this.livestream.tracks.findIndex(track => track.id === trackId),
|
||||
1
|
||||
)
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
})
|
||||
},
|
||||
updateCurrentTrack(track) {
|
||||
if (this.livestream.current_track === track) {
|
||||
// if clicking the same, stop it
|
||||
track = 0
|
||||
}
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/livestream/api/v1/livestream/track/' + track,
|
||||
this.selectedWallet.inkey
|
||||
)
|
||||
.then(() => {
|
||||
this.livestream.current_track = track
|
||||
this.$q.notify({
|
||||
message: `Current track updated.`,
|
||||
timeout: 700
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
updateFeePct() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/livestream/api/v1/livestream/fee/' + this.livestream.fee_pct,
|
||||
this.selectedWallet.inkey
|
||||
)
|
||||
.then(() => {
|
||||
this.$q.notify({
|
||||
message: `Percentage updated.`,
|
||||
timeout: 700
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
producerAdded(added, cb) {
|
||||
cb(added)
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.selectedWallet = this.g.user.wallets[0]
|
||||
this.loadLivestream()
|
||||
this.startPaymentNotifier()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import json
|
||||
import trio # type: ignore
|
||||
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.crud import create_payment
|
||||
from lnbits.core import db as core_db
|
||||
from lnbits.tasks import register_invoice_listener, internal_invoice_paid
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from .crud import get_track, get_producer, get_livestream_by_track
|
||||
|
||||
|
||||
async def register_listeners():
|
||||
invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(2)
|
||||
register_invoice_listener(invoice_paid_chan_send)
|
||||
await wait_for_paid_invoices(invoice_paid_chan_recv)
|
||||
|
||||
|
||||
async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
|
||||
async for payment in invoice_paid_chan:
|
||||
await on_invoice_paid(payment)
|
||||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
if "livestream" != payment.extra.get("tag"):
|
||||
# not a livestream invoice
|
||||
return
|
||||
|
||||
track = await get_track(payment.extra.get("track", -1))
|
||||
if not track:
|
||||
print("this should never happen", payment)
|
||||
return
|
||||
|
||||
if payment.extra.get("shared_with"):
|
||||
print("payment was shared already", payment)
|
||||
return
|
||||
|
||||
producer = await get_producer(track.producer)
|
||||
assert producer, f"track {track.id} is not associated with a producer"
|
||||
|
||||
ls = await get_livestream_by_track(track.id)
|
||||
assert ls, f"track {track.id} is not associated with a livestream"
|
||||
|
||||
# now we make a special kind of internal transfer
|
||||
amount = int(payment.amount * (100 - ls.fee_pct) / 100)
|
||||
|
||||
# mark the original payment with two extra keys, "shared_with" and "received"
|
||||
# (this prevents us from doing this process again and it's informative)
|
||||
# and reduce it by the amount we're going to send to the producer
|
||||
await core_db.execute(
|
||||
"""
|
||||
UPDATE apipayments
|
||||
SET extra = ?, amount = ?
|
||||
WHERE hash = ?
|
||||
AND checking_id NOT LIKE 'internal_%'
|
||||
""",
|
||||
(
|
||||
json.dumps(
|
||||
dict(
|
||||
**payment.extra,
|
||||
shared_with=[producer.name, producer.id],
|
||||
received=payment.amount,
|
||||
)
|
||||
),
|
||||
payment.amount - amount,
|
||||
payment.payment_hash,
|
||||
),
|
||||
)
|
||||
|
||||
# perform an internal transfer using the same payment_hash to the producer wallet
|
||||
internal_checking_id = f"internal_{urlsafe_short_hash()}"
|
||||
await create_payment(
|
||||
wallet_id=producer.wallet,
|
||||
checking_id=internal_checking_id,
|
||||
payment_request="",
|
||||
payment_hash=payment.payment_hash,
|
||||
amount=amount,
|
||||
memo=f"Revenue from '{track.name}'.",
|
||||
pending=False,
|
||||
)
|
||||
|
||||
# manually send this for now
|
||||
await internal_invoice_paid.send(internal_checking_id)
|
||||
|
||||
# so the flow is the following:
|
||||
# - we receive, say, 1000 satoshis
|
||||
# - if the fee_pct is, say, 30%, the amount we will send is 700
|
||||
# - we change the amount of receiving payment on the database from 1000 to 300
|
||||
# - we create a new payment on the producer's wallet with amount 700
|
||||
@@ -0,0 +1,146 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="How to use"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>Add tracks, profit.</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="List livestream links"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/livestream/api/v1/livestream</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<livestream_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root }}api/v1/livestream -H "X-Api-Key: {{
|
||||
g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="Update track">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">PUT</span>
|
||||
/livestream/api/v1/livestream/track/<track_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X PUT {{ request.url_root
|
||||
}}api/v1/livestream/track/<track_id> -H "X-Api-Key: {{
|
||||
g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="Update fee">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">PUT</span>
|
||||
/livestream/api/v1/livestream/fee/<fee_pct></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X PUT {{ request.url_root
|
||||
}}api/v1/livestream/fee/<fee_pct> -H "X-Api-Key: {{
|
||||
g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item group="api" dense expand-separator label="Add track">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-green">POST</span>
|
||||
/livestream/api/v1/livestream/tracks</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code
|
||||
>{"name": <string>, "download_url": <string>,
|
||||
"price_msat": <integer>, "producer_id": <integer>,
|
||||
"producer_name": <string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.url_root }}api/v1/livestream/tracks -d
|
||||
'{"name": <string>, "download_url": <string>,
|
||||
"price_msat": <integer>, "producer_id": <integer>,
|
||||
"producer_name": <string>}' -H "Content-type: application/json"
|
||||
-H "X-Api-Key: {{ g.user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Delete a withdraw link"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-pink">DELETE</span>
|
||||
/livestream/api/v1/livestream/tracks/<track_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
|
||||
<code></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X DELETE {{ request.url_root
|
||||
}}api/v1/livestream/tracks/<track_id> -H "X-Api-Key: {{
|
||||
g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
||||
@@ -0,0 +1,320 @@
|
||||
{% 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-7 q-gutter-y-md">
|
||||
<q-card class="q-pa-lg q-pt-xl">
|
||||
<q-form
|
||||
@submit="updateCurrentTrack(nextCurrentTrack)"
|
||||
class="q-gutter-md"
|
||||
>
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col">
|
||||
<q-select
|
||||
dense
|
||||
filled
|
||||
v-model="nextCurrentTrack"
|
||||
use-input
|
||||
hide-selected
|
||||
fill-input
|
||||
input-debounce="0"
|
||||
:options="sortedTracks.map(track => track.id)"
|
||||
option-value="id"
|
||||
:option-label="getTrackLabel"
|
||||
options-dense
|
||||
label="Current track"
|
||||
/>
|
||||
</div>
|
||||
<div class="col">
|
||||
{% raw %}
|
||||
<q-btn unelevated color="deep-purple" type="submit">
|
||||
{{ nextCurrentTrack === livestream.current_track ? 'Stop' : 'Set'
|
||||
}} current track
|
||||
</q-btn>
|
||||
{% endraw %}
|
||||
</div>
|
||||
</div>
|
||||
</q-form>
|
||||
<q-form @submit="updateFeePct" class="q-gutter-md">
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="livestream.fee_pct"
|
||||
type="number"
|
||||
label="Revenue to keep (%)"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn unelevated color="deep-purple" type="submit"
|
||||
>Set percent rate</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</q-form>
|
||||
</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">Tracks</h5>
|
||||
</div>
|
||||
<div class="col q-ml-lg">
|
||||
<q-btn unelevated color="deep-purple" @click="openAddTrackDialog"
|
||||
>Add new track</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="sortedTracks"
|
||||
row-key="id"
|
||||
no-data-label="No tracks added yet"
|
||||
:pagination="{rowsPerPage: 0}"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th auto-width>Name</q-th>
|
||||
<q-th auto-width>Producer</q-th>
|
||||
<q-th auto-width>Price</q-th>
|
||||
<q-th auto-width>Download URL</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
|
||||
size="xs"
|
||||
:icon="livestream.current_track !== props.row.id ? 'play_circle_outline' : 'play_arrow'"
|
||||
:color="livestream.current_track !== props.row.id ? ($q.dark.isActive ? 'grey-7' : 'grey-5') : 'green'"
|
||||
type="a"
|
||||
@click="updateCurrentTrack(props.row.id)"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width>{{ props.row.name }}</q-td>
|
||||
<q-td auto-width>
|
||||
{{ producersMap[props.row.producer].name }}
|
||||
</q-td>
|
||||
<q-td class="text-right" auto-width
|
||||
>{{ Math.round(props.row.price_msat / 1000) }}</q-td
|
||||
>
|
||||
<q-td class="text-center" auto-width
|
||||
>{{ props.row.download_url }}</q-td
|
||||
>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="openUpdateDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="delete"
|
||||
color="negative"
|
||||
type="a"
|
||||
@click="deleteTrack(props.row.id)"
|
||||
target="_blank"
|
||||
></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">Producers</h5>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="livestream.producers"
|
||||
row-key="id"
|
||||
no-data-label="To include a producer, add a track"
|
||||
:pagination="{rowsPerPage: 0}"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width>Name</q-th>
|
||||
<q-th auto-width>Wallet</q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>{{ props.row.name }}</q-td>
|
||||
<q-td class="text-center" auto-width>
|
||||
<a
|
||||
target="_blank"
|
||||
:href="'/wallet?usr=' + props.row.user + '&wal=' + props.row.wallet"
|
||||
>
|
||||
{{ props.row.wallet }}
|
||||
</a>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card class="q-pa-sm col-5">
|
||||
<q-card-section class="q-pa-none text-center">
|
||||
<q-form class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
:options="g.user.wallets"
|
||||
:value="selectedWallet"
|
||||
label="Using wallet:"
|
||||
option-label="name"
|
||||
@input="changedWallet"
|
||||
>
|
||||
</q-select>
|
||||
</q-form>
|
||||
|
||||
<a :href="'lightning:' + livestream.lnurl">
|
||||
<q-responsive :ratio="1" class="q-mx-sm">
|
||||
<qrcode
|
||||
:value="livestream.lnurl"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(livestream.lnurl)"
|
||||
class="text-center q-mb-md"
|
||||
>Copy LNURL-pay code</q-btn
|
||||
>
|
||||
</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-my-none">LNbits Livestream extension</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "livestream/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="trackDialog.show">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-card-section
|
||||
v-if="trackDialog.data.lnurl"
|
||||
class="q-pa-none text-center"
|
||||
>
|
||||
<p class="text-subtitle1 q-my-none">
|
||||
Standalone QR Code for this track
|
||||
</p>
|
||||
<a :href="'lightning:' + trackDialog.data.lnurl">
|
||||
<q-responsive :ratio="1" class="q-mx-sm">
|
||||
<qrcode
|
||||
:value="trackDialog.data.lnurl"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(trackDialog.data.lnurl)"
|
||||
class="text-center q-mb-md"
|
||||
>Copy LNURL-pay code</q-btn
|
||||
>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-form @submit="addTrack" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="trackDialog.data.producer"
|
||||
use-input
|
||||
hide-selected
|
||||
fill-input
|
||||
option-label="name"
|
||||
input-debounce="0"
|
||||
@new-value="producerAdded"
|
||||
:options="livestream.producers"
|
||||
options-dense
|
||||
label="Producer"
|
||||
hint="Select an existing producer or add a new one by name (press Enter to add)."
|
||||
></q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="trackDialog.data.name"
|
||||
type="text"
|
||||
label="Track name"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="trackDialog.data.price_sat"
|
||||
type="number"
|
||||
min="1"
|
||||
label="Track price (sat)"
|
||||
hint="This is the minimum price for buying the track download link. It does nothing for tracks without a download URL."
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="trackDialog.data.download_url"
|
||||
type="text"
|
||||
label="Download URL"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<div class="col q-ml-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
:disable="disabledAddTrackButton()"
|
||||
type="submit"
|
||||
>
|
||||
<span v-if="trackDialog.data.id">Update track</span>
|
||||
<span v-else>Add track</span>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div class="col q-ml-lg">
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="/livestream/static/js/index.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,38 @@
|
||||
from quart import g, render_template, request, redirect
|
||||
from http import HTTPStatus
|
||||
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.crud import get_wallet_payment
|
||||
|
||||
from . import livestream_ext
|
||||
from .crud import get_track, get_livestream_by_track
|
||||
|
||||
|
||||
@livestream_ext.route("/")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
async def index():
|
||||
return await render_template("livestream/index.html", user=g.user)
|
||||
|
||||
|
||||
@livestream_ext.route("/track/<track_id>")
|
||||
async def track_redirect_download(track_id):
|
||||
payment_hash = request.args.get("p")
|
||||
track = await get_track(track_id)
|
||||
ls = await get_livestream_by_track(track_id)
|
||||
payment: Payment = await get_wallet_payment(ls.wallet, payment_hash)
|
||||
|
||||
if not payment:
|
||||
return (
|
||||
f"Couldn't find the payment {payment_hash} or track {track.id}.",
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
if payment.pending:
|
||||
return (
|
||||
f"Payment {payment_hash} wasn't received yet. Please try again in a minute.",
|
||||
HTTPStatus.PAYMENT_REQUIRED,
|
||||
)
|
||||
|
||||
return redirect(track.download_url)
|
||||
@@ -0,0 +1,135 @@
|
||||
from quart import g, jsonify
|
||||
from http import HTTPStatus
|
||||
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore
|
||||
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
|
||||
from . import livestream_ext
|
||||
from .crud import (
|
||||
get_or_create_livestream_by_wallet,
|
||||
add_track,
|
||||
get_tracks,
|
||||
update_track,
|
||||
add_producer,
|
||||
get_producers,
|
||||
update_current_track,
|
||||
update_livestream_fee,
|
||||
delete_track_from_livestream,
|
||||
)
|
||||
|
||||
|
||||
@livestream_ext.route("/api/v1/livestream", methods=["GET"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_livestream_from_wallet():
|
||||
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
|
||||
tracks = await get_tracks(ls.id)
|
||||
producers = await get_producers(ls.id)
|
||||
|
||||
try:
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
**ls._asdict(),
|
||||
**{
|
||||
"lnurl": ls.lnurl,
|
||||
"tracks": [
|
||||
dict(lnurl=track.lnurl, **track._asdict())
|
||||
for track in tracks
|
||||
],
|
||||
"producers": [producer._asdict() for producer in producers],
|
||||
},
|
||||
}
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
except LnurlInvalidUrl:
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor."
|
||||
}
|
||||
),
|
||||
HTTPStatus.UPGRADE_REQUIRED,
|
||||
)
|
||||
|
||||
|
||||
@livestream_ext.route("/api/v1/livestream/track/<track_id>", methods=["PUT"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_update_track(track_id):
|
||||
try:
|
||||
id = int(track_id)
|
||||
except ValueError:
|
||||
id = 0
|
||||
if id <= 0:
|
||||
id = None
|
||||
|
||||
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
|
||||
await update_current_track(ls.id, id)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
@livestream_ext.route("/api/v1/livestream/fee/<fee_pct>", methods=["PUT"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_update_fee(fee_pct):
|
||||
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
|
||||
await update_livestream_fee(ls.id, int(fee_pct))
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
@livestream_ext.route("/api/v1/livestream/tracks", methods=["POST"])
|
||||
@livestream_ext.route("/api/v1/livestream/tracks/<id>", methods=["PUT"])
|
||||
@api_check_wallet_key("invoice")
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"name": {"type": "string", "empty": False, "required": True},
|
||||
"download_url": {"type": "string", "empty": False, "required": False},
|
||||
"price_msat": {"type": "number", "min": 0, "required": False},
|
||||
"producer_id": {
|
||||
"type": "number",
|
||||
"required": True,
|
||||
"excludes": "producer_name",
|
||||
},
|
||||
"producer_name": {
|
||||
"type": "string",
|
||||
"required": True,
|
||||
"excludes": "producer_id",
|
||||
},
|
||||
}
|
||||
)
|
||||
async def api_add_track(id=None):
|
||||
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
|
||||
|
||||
if "producer_id" in g.data:
|
||||
p_id = g.data["producer_id"]
|
||||
elif "producer_name" in g.data:
|
||||
p_id = await add_producer(ls.id, g.data["producer_name"])
|
||||
else:
|
||||
raise TypeError("need either producer_id or producer_name arguments")
|
||||
|
||||
if id:
|
||||
await update_track(
|
||||
ls.id,
|
||||
id,
|
||||
g.data["name"],
|
||||
g.data.get("download_url"),
|
||||
g.data.get("price_msat", 0),
|
||||
p_id,
|
||||
)
|
||||
return "", HTTPStatus.OK
|
||||
else:
|
||||
await add_track(
|
||||
ls.id,
|
||||
g.data["name"],
|
||||
g.data.get("download_url"),
|
||||
g.data.get("price_msat", 0),
|
||||
p_id,
|
||||
)
|
||||
return "", HTTPStatus.CREATED
|
||||
|
||||
|
||||
@livestream_ext.route("/api/v1/livestream/tracks/<track_id>", methods=["DELETE"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_delete_track(track_id):
|
||||
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
|
||||
await delete_track_from_livestream(ls.id, track_id)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
@@ -55,24 +55,25 @@ async def set_ticket_paid(payment_hash: str) -> Tickets:
|
||||
)
|
||||
|
||||
ticket = await get_ticket(payment_hash)
|
||||
assert ticket, "Newly paid ticket could not be retrieved"
|
||||
|
||||
if formdata.webhook:
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
r = await client.post(
|
||||
formdata.webhook,
|
||||
json={
|
||||
"form": ticket.form,
|
||||
"name": ticket.name,
|
||||
"email": ticket.email,
|
||||
"content": ticket.ltext,
|
||||
},
|
||||
timeout=40,
|
||||
)
|
||||
except AssertionError:
|
||||
webhook = None
|
||||
await client.post(
|
||||
formdata.webhook,
|
||||
json={
|
||||
"form": ticket.form,
|
||||
"name": ticket.name,
|
||||
"email": ticket.email,
|
||||
"content": ticket.ltext,
|
||||
},
|
||||
timeout=40,
|
||||
)
|
||||
return ticket
|
||||
|
||||
ticket = await get_ticket(payment_hash)
|
||||
return
|
||||
assert ticket, "Newly paid ticket could not be retrieved"
|
||||
return ticket
|
||||
|
||||
|
||||
async def get_ticket(ticket_id: str) -> Optional[Tickets]:
|
||||
|
||||
@@ -116,12 +116,16 @@ async def api_ticket_make_ticket(form_id):
|
||||
|
||||
nwords = len(re.split(r"\s+", g.data["ltext"]))
|
||||
sats = g.data["sats"]
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=form.wallet,
|
||||
amount=sats,
|
||||
memo=f"ticket with {nwords} words on {form_id}",
|
||||
extra={"tag": "lnticket"},
|
||||
)
|
||||
|
||||
try:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=form.wallet,
|
||||
amount=sats,
|
||||
memo=f"ticket with {nwords} words on {form_id}",
|
||||
extra={"tag": "lnticket"},
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
ticket = await create_ticket(
|
||||
payment_hash=payment_hash, wallet=form.wallet, **g.data
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<code>[<pay_link_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root }}api/v0/links -H "X-Api-Key: {{
|
||||
>curl -X GET {{ request.url_root }}api/v1/links -H "X-Api-Key: {{
|
||||
g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
|
||||
@@ -64,15 +64,19 @@ async def lnurl_callback(item_id):
|
||||
)
|
||||
|
||||
shop = await get_shop(item.shop)
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=shop.wallet,
|
||||
amount=int(amount_received / 1000),
|
||||
memo=item.name,
|
||||
description_hash=hashlib.sha256(
|
||||
(await item.lnurlpay_metadata()).encode("utf-8")
|
||||
).digest(),
|
||||
extra={"tag": "offlineshop", "item": item.id},
|
||||
)
|
||||
|
||||
try:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=shop.wallet,
|
||||
amount=int(amount_received / 1000),
|
||||
memo=item.name,
|
||||
description_hash=hashlib.sha256(
|
||||
(await item.lnurlpay_metadata()).encode("utf-8")
|
||||
).digest(),
|
||||
extra={"tag": "offlineshop", "item": item.id},
|
||||
)
|
||||
except Exception as exc:
|
||||
return jsonify(LnurlErrorResponse(reason=exc.message).dict())
|
||||
|
||||
resp = LnurlPayActionResponse(
|
||||
pr=payment_request,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# SatsPay Server
|
||||
|
||||
Create onchain and LN charges. Includes webhooks!
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
from quart import Blueprint
|
||||
from lnbits.db import Database
|
||||
|
||||
db = Database("ext_satspay")
|
||||
|
||||
|
||||
satspay_ext: Blueprint = Blueprint(
|
||||
"satspay", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
from .views import * # noqa
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "SatsPayServer",
|
||||
"short_description": "Create onchain and LN charges",
|
||||
"icon": "payment",
|
||||
"contributors": [
|
||||
"arcbtc"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
from typing import List, Optional, Union
|
||||
|
||||
# from lnbits.db import open_ext_db
|
||||
from . import db
|
||||
from .models import Charges
|
||||
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from quart import jsonify
|
||||
import httpx
|
||||
from lnbits.core.services import create_invoice, check_invoice_status
|
||||
from ..watchonly.crud import get_watch_wallet, get_fresh_address, get_mempool
|
||||
|
||||
|
||||
###############CHARGES##########################
|
||||
|
||||
|
||||
async def create_charge(
|
||||
user: str,
|
||||
description: str = None,
|
||||
onchainwallet: Optional[str] = None,
|
||||
lnbitswallet: Optional[str] = None,
|
||||
webhook: Optional[str] = None,
|
||||
completelink: Optional[str] = None,
|
||||
completelinktext: Optional[str] = None,
|
||||
time: Optional[int] = None,
|
||||
amount: Optional[int] = None,
|
||||
) -> Charges:
|
||||
charge_id = urlsafe_short_hash()
|
||||
if onchainwallet:
|
||||
wallet = await get_watch_wallet(onchainwallet)
|
||||
onchain = await get_fresh_address(onchainwallet)
|
||||
onchainaddress = onchain.address
|
||||
else:
|
||||
onchainaddress = None
|
||||
if lnbitswallet:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=lnbitswallet, amount=amount, memo=charge_id
|
||||
)
|
||||
else:
|
||||
payment_hash = None
|
||||
payment_request = None
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO charges (
|
||||
id,
|
||||
user,
|
||||
description,
|
||||
onchainwallet,
|
||||
onchainaddress,
|
||||
lnbitswallet,
|
||||
payment_request,
|
||||
payment_hash,
|
||||
webhook,
|
||||
completelink,
|
||||
completelinktext,
|
||||
time,
|
||||
amount,
|
||||
balance
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
charge_id,
|
||||
user,
|
||||
description,
|
||||
onchainwallet,
|
||||
onchainaddress,
|
||||
lnbitswallet,
|
||||
payment_request,
|
||||
payment_hash,
|
||||
webhook,
|
||||
completelink,
|
||||
completelinktext,
|
||||
time,
|
||||
amount,
|
||||
0,
|
||||
),
|
||||
)
|
||||
return await get_charge(charge_id)
|
||||
|
||||
|
||||
async def update_charge(charge_id: str, **kwargs) -> Optional[Charges]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
await db.execute(
|
||||
f"UPDATE charges SET {q} WHERE id = ?", (*kwargs.values(), charge_id)
|
||||
)
|
||||
row = await db.fetchone("SELECT * FROM charges WHERE id = ?", (charge_id,))
|
||||
return Charges.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_charge(charge_id: str) -> Charges:
|
||||
row = await db.fetchone("SELECT * FROM charges WHERE id = ?", (charge_id,))
|
||||
return Charges.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_charges(user: str) -> List[Charges]:
|
||||
rows = await db.fetchall("SELECT * FROM charges WHERE user = ?", (user,))
|
||||
return [Charges.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def delete_charge(charge_id: str) -> None:
|
||||
await db.execute("DELETE FROM charges WHERE id = ?", (charge_id,))
|
||||
|
||||
|
||||
async def check_address_balance(charge_id: str) -> List[Charges]:
|
||||
charge = await get_charge(charge_id)
|
||||
if not charge.paid:
|
||||
if charge.onchainaddress:
|
||||
mempool = await get_mempool(charge.user)
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(
|
||||
mempool.endpoint + "/api/address/" + charge.onchainaddress
|
||||
)
|
||||
respAmount = r.json()["chain_stats"]["funded_txo_sum"]
|
||||
if respAmount >= charge.balance:
|
||||
await update_charge(charge_id=charge_id, balance=respAmount)
|
||||
except Exception:
|
||||
pass
|
||||
if charge.lnbitswallet:
|
||||
invoice_status = await check_invoice_status(
|
||||
charge.lnbitswallet, charge.payment_hash
|
||||
)
|
||||
if invoice_status.paid:
|
||||
return await update_charge(charge_id=charge_id, balance=charge.amount)
|
||||
row = await db.fetchone("SELECT * FROM charges WHERE id = ?", (charge_id,))
|
||||
return Charges.from_row(row) if row else None
|
||||
@@ -0,0 +1,26 @@
|
||||
async def m001_initial(db):
|
||||
"""
|
||||
Initial wallet table.
|
||||
"""
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS charges (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
user TEXT,
|
||||
description TEXT,
|
||||
onchainwallet TEXT,
|
||||
onchainaddress TEXT,
|
||||
lnbitswallet TEXT,
|
||||
payment_request TEXT,
|
||||
payment_hash TEXT,
|
||||
webhook TEXT,
|
||||
completelink TEXT,
|
||||
completelinktext TEXT,
|
||||
time INTEGER,
|
||||
amount INTEGER,
|
||||
balance INTEGER DEFAULT 0,
|
||||
timestamp TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
from sqlite3 import Row
|
||||
from typing import NamedTuple
|
||||
import time
|
||||
|
||||
|
||||
class Charges(NamedTuple):
|
||||
id: str
|
||||
user: str
|
||||
description: str
|
||||
onchainwallet: str
|
||||
onchainaddress: str
|
||||
lnbitswallet: str
|
||||
payment_request: str
|
||||
payment_hash: str
|
||||
webhook: str
|
||||
completelink: str
|
||||
completelinktext: str
|
||||
time: int
|
||||
amount: int
|
||||
balance: int
|
||||
timestamp: int
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Charges":
|
||||
return cls(**dict(row))
|
||||
|
||||
@property
|
||||
def time_elapsed(self):
|
||||
if (self.timestamp + (self.time * 60)) >= time.time():
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
@property
|
||||
def paid(self):
|
||||
if self.balance >= self.amount:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
@@ -0,0 +1,171 @@
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
SatsPayServer, create Onchain/LN charges.<br />WARNING: If using with the
|
||||
WatchOnly extension, we highly reccomend using a fresh extended public Key
|
||||
specifically for SatsPayServer!<br />
|
||||
<small>
|
||||
Created by, <a href="https://github.com/benarc">Ben Arc</a></small
|
||||
>
|
||||
</p>
|
||||
</q-card-section>
|
||||
<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="Create charge">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">POST</span> /satspay/api/v1/charge</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<charge_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.url_root }}api/v1/charge -d
|
||||
'{"onchainwallet": <string, watchonly_wallet_id>,
|
||||
"description": <string>, "webhook":<string>, "time":
|
||||
<integer>, "amount": <integer>, "lnbitswallet":
|
||||
<string, lnbits_wallet_id>}' -H "Content-type:
|
||||
application/json" -H "X-Api-Key: {{g.user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="Update charge">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">PUT</span>
|
||||
/satspay/api/v1/charge/<charge_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<charge_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.url_root }}api/v1/charge/<charge_id>
|
||||
-d '{"onchainwallet": <string, watchonly_wallet_id>,
|
||||
"description": <string>, "webhook":<string>, "time":
|
||||
<integer>, "amount": <integer>, "lnbitswallet":
|
||||
<string, lnbits_wallet_id>}' -H "Content-type:
|
||||
application/json" -H "X-Api-Key: {{g.user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item group="api" dense expand-separator label="Get charge">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/satspay/api/v1/charge/<charge_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<charge_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root }}api/v1/charge/<charge_id>
|
||||
-H "X-Api-Key: {{ g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="Get charges">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span> /satspay/api/v1/charges</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<charge_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root }}api/v1/charges -H "X-Api-Key: {{
|
||||
g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Delete a pay link"
|
||||
class="q-pb-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-pink">DELETE</span>
|
||||
/satspay/api/v1/charge/<charge_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
|
||||
<code></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X DELETE {{ request.url_root
|
||||
}}api/v1/charge/<charge_id> -H "X-Api-Key: {{
|
||||
g.user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="Get balances">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/satspay/api/v1/charges/balance/<charge_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<charge_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root
|
||||
}}api/v1/charges/balance/<charge_id> -H "X-Api-Key: {{
|
||||
g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
||||
</q-card>
|
||||
@@ -0,0 +1,318 @@
|
||||
{% extends "public.html" %} {% block page %}
|
||||
<div class="q-pa-sm theCard">
|
||||
<q-card class="my-card">
|
||||
<div class="column">
|
||||
<center>
|
||||
<div class="col theHeading">{{ charge.description }}</div>
|
||||
</center>
|
||||
<div class="col">
|
||||
<div
|
||||
class="col"
|
||||
color="white"
|
||||
style="background-color: grey; height: 30px; padding: 5px"
|
||||
v-if="timetoComplete < 1"
|
||||
>
|
||||
<center>Time elapsed</center>
|
||||
</div>
|
||||
<div
|
||||
class="col"
|
||||
color="white"
|
||||
style="background-color: grey; height: 30px; padding: 5px"
|
||||
v-else-if="charge_paid == 'True'"
|
||||
>
|
||||
<center>Charge paid</center>
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-linear-progress size="30px" :value="newProgress" color="grey">
|
||||
<q-item-section>
|
||||
<q-item style="padding: 3px">
|
||||
<q-spinner color="white" size="0.8em"></q-spinner
|
||||
><span style="font-size: 15px; color: white"
|
||||
><span class="q-pr-xl q-pl-md"> Awaiting payment...</span>
|
||||
<span class="q-pl-xl" style="color: white">
|
||||
{% raw %} {{ newTimeLeft }} {% endraw %}</span
|
||||
></span
|
||||
>
|
||||
</q-item>
|
||||
</q-item-section>
|
||||
</q-linear-progress>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col" style="margin: 2px 15px; max-height: 100px">
|
||||
<center>
|
||||
<q-btn flat dense outline @click="copyText('{{ charge.id }}')"
|
||||
>Charge ID: {{ charge.id }}</q-btn
|
||||
>
|
||||
</center>
|
||||
<span
|
||||
><small
|
||||
>{% raw %} Total to pay: {{ charge_amount }}sats<br />
|
||||
Amount paid: {{ charge_balance }}</small
|
||||
><br />
|
||||
Amount due: {{ charge_amount - charge_balance }}sats {% endraw %}
|
||||
</span>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
<div class="col">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
flat
|
||||
disable
|
||||
v-if="'{{ charge.lnbitswallet }}' == 'None' || charge_time_elapsed == 'True'"
|
||||
style="color: primary; width: 100%"
|
||||
label="lightning⚡"
|
||||
>
|
||||
<q-tooltip>
|
||||
bitcoin onchain payment method not available
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
v-else
|
||||
@click="payLN"
|
||||
style="color: primary; width: 100%"
|
||||
label="lightning⚡"
|
||||
>
|
||||
<q-tooltip> pay with lightning </q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn
|
||||
flat
|
||||
disable
|
||||
v-if="'{{ charge.onchainwallet }}' == 'None' || charge_time_elapsed == 'True'"
|
||||
style="color: primary; width: 100%"
|
||||
label="onchain⛓️"
|
||||
>
|
||||
<q-tooltip>
|
||||
bitcoin lightning payment method not available
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
v-else
|
||||
@click="payON"
|
||||
style="color: primary; width: 100%"
|
||||
label="onchain⛓️"
|
||||
>
|
||||
<q-tooltip> pay onchain </q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
</div>
|
||||
</div>
|
||||
<q-card class="q-pa-lg" v-if="lnbtc">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div class="text-center q-pt-md">
|
||||
<div v-if="timetoComplete < 1 && charge_paid == 'False'">
|
||||
<q-icon
|
||||
name="block"
|
||||
style="color: #ccc; font-size: 21.4em"
|
||||
></q-icon>
|
||||
</div>
|
||||
<div v-else-if="charge_paid == 'True'">
|
||||
<q-icon
|
||||
name="check"
|
||||
style="color: green; font-size: 21.4em"
|
||||
></q-icon>
|
||||
<q-btn
|
||||
outline
|
||||
v-if="'{{ charge.webhook }}' != 'None'"
|
||||
type="a"
|
||||
href="{{ charge.completelink }}"
|
||||
label="{{ charge.completelinktext }}"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div v-else>
|
||||
<center>
|
||||
<span class="text-subtitle2"
|
||||
>Pay this <br />
|
||||
lightning-network invoice</span
|
||||
>
|
||||
</center>
|
||||
<a href="lightning://{{ charge.payment_request }}">
|
||||
<q-responsive :ratio="1" class="q-mx-md">
|
||||
<qrcode
|
||||
:value="'{{ charge.payment_request }}'"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText('{{ charge.payment_request }}')"
|
||||
>Copy invoice</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card class="q-pa-lg" v-if="onbtc">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div class="text-center q-pt-md">
|
||||
<div v-if="timetoComplete < 1 && charge_paid == 'False'">
|
||||
<q-icon
|
||||
name="block"
|
||||
style="color: #ccc; font-size: 21.4em"
|
||||
></q-icon>
|
||||
</div>
|
||||
<div v-else-if="charge_paid == 'True'">
|
||||
<q-icon
|
||||
name="check"
|
||||
style="color: green; font-size: 21.4em"
|
||||
></q-icon>
|
||||
<q-btn
|
||||
outline
|
||||
v-if="'{{ charge.webhook }}' != 'None'"
|
||||
type="a"
|
||||
href="{{ charge.completelink }}"
|
||||
label="{{ charge.completelinktext }}"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div v-else>
|
||||
<center>
|
||||
<span class="text-subtitle2"
|
||||
>Send {{ charge.amount }}sats<br />
|
||||
to this onchain address</span
|
||||
>
|
||||
</center>
|
||||
<a href="bitcoin://{{ charge.onchainaddress }}">
|
||||
<q-responsive :ratio="1" class="q-mx-md">
|
||||
<qrcode
|
||||
:value="'{{ charge.onchainaddress }}'"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText('{{ charge.onchainaddress }}')"
|
||||
>Copy address</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
|
||||
<style>
|
||||
.theCard {
|
||||
width: 360px;
|
||||
margin: 10px auto;
|
||||
}
|
||||
.theHeading {
|
||||
margin: 15px;
|
||||
font-size: 25px;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data() {
|
||||
return {
|
||||
newProgress: 0.4,
|
||||
counter: 1,
|
||||
newTimeLeft: '',
|
||||
timetoComplete: 100,
|
||||
lnbtc: true,
|
||||
onbtc: false,
|
||||
charge_time_elapsed: '{{charge.time_elapsed}}',
|
||||
charge_amount: '{{charge.amount}}',
|
||||
charge_balance: '{{charge.balance}}',
|
||||
charge_paid: '{{charge.paid}}'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
checkBalance: function () {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/satspay/api/v1/charges/balance/{{ charge.id }}',
|
||||
'filla'
|
||||
)
|
||||
.then(function (response) {
|
||||
self.charge_time_elapsed = response.data.time_elapsed
|
||||
self.charge_amount = response.data.amount
|
||||
self.charge_balance = response.data.balance
|
||||
if (self.charge_balance >= self.charge_amount) {
|
||||
self.charge_paid = 'True'
|
||||
}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
payLN: function () {
|
||||
this.lnbtc = true
|
||||
this.onbtc = false
|
||||
},
|
||||
payON: function () {
|
||||
this.lnbtc = false
|
||||
this.onbtc = true
|
||||
},
|
||||
getTheTime: function () {
|
||||
var timeToComplete =
|
||||
parseInt('{{ charge.time }}') * 60 -
|
||||
(Date.now() / 1000 - parseInt('{{ charge.timestamp }}'))
|
||||
this.timetoComplete = timeToComplete
|
||||
var timeLeft = Quasar.utils.date.formatDate(
|
||||
new Date((timeToComplete - 3600) * 1000),
|
||||
'HH:mm:ss'
|
||||
)
|
||||
this.newTimeLeft = timeLeft
|
||||
},
|
||||
getThePercentage: function () {
|
||||
var timeToComplete =
|
||||
parseInt('{{ charge.time }}') * 60 -
|
||||
(Date.now() / 1000 - parseInt('{{ charge.timestamp }}'))
|
||||
this.newProgress =
|
||||
1 - timeToComplete / (parseInt('{{ charge.time }}') * 60)
|
||||
},
|
||||
|
||||
timerCount: function () {
|
||||
self = this
|
||||
var refreshIntervalId = setInterval(function () {
|
||||
if (self.charge_paid == 'True' || self.timetoComplete < 1) {
|
||||
clearInterval(refreshIntervalId)
|
||||
}
|
||||
self.getTheTime()
|
||||
self.getThePercentage()
|
||||
self.counter++
|
||||
if (self.counter % 10 === 0) {
|
||||
self.checkBalance()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
if ('{{ charge.lnbitswallet }}' == 'None') {
|
||||
this.lnbtc = false
|
||||
this.onbtc = true
|
||||
}
|
||||
this.getTheTime()
|
||||
this.getThePercentage()
|
||||
var timerCount = this.timerCount
|
||||
if ('{{ charge.paid }}' == 'False') {
|
||||
timerCount()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,556 @@
|
||||
{% 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-7 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
{% raw %}
|
||||
<q-btn
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
@click="formDialogCharge.show = true"
|
||||
>New charge
|
||||
</q-btn>
|
||||
</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">Charges</h5>
|
||||
</div>
|
||||
|
||||
<div class="col-auto">
|
||||
<q-input
|
||||
borderless
|
||||
dense
|
||||
debounce="300"
|
||||
v-model="filter"
|
||||
placeholder="Search"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon name="search"></q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
<q-btn flat color="grey" @click="exportchargeCSV"
|
||||
>Export to CSV</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
flat
|
||||
dense
|
||||
:data="ChargeLinks"
|
||||
row-key="id"
|
||||
:columns="ChargesTable.columns"
|
||||
:pagination.sync="ChargesTable.pagination"
|
||||
:filter="filter"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th auto-width></q-th>
|
||||
|
||||
<q-th
|
||||
v-for="col in props.cols"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
auto-width
|
||||
>
|
||||
<div v-if="col.name == 'id'"></div>
|
||||
<div v-else>{{ col.label }}</div>
|
||||
</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
|
||||
size="xs"
|
||||
icon="link"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
type="a"
|
||||
:href="props.row.displayUrl"
|
||||
target="_blank"
|
||||
>
|
||||
<q-tooltip> Payment link </q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
v-if="props.row.time_elapsed && props.row.balance < props.row.amount"
|
||||
unelevated
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
icon="error"
|
||||
:color="($q.dark.isActive) ? 'red' : 'red'"
|
||||
>
|
||||
<q-tooltip> Time elapsed </q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<q-btn
|
||||
v-else-if="props.row.balance >= props.row.amount"
|
||||
unelevated
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
icon="check"
|
||||
:color="($q.dark.isActive) ? 'green' : 'green'"
|
||||
>
|
||||
<q-tooltip> PAID! </q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="cached"
|
||||
flat
|
||||
:color="($q.dark.isActive) ? 'blue' : 'blue'"
|
||||
>
|
||||
<q-tooltip> Processing </q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteChargeLink(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
>
|
||||
<q-tooltip> Delete charge </q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td
|
||||
v-for="col in props.cols"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
auto-width
|
||||
>
|
||||
<div v-if="col.name == 'id'"></div>
|
||||
<div v-else>{{ col.value }}</div>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</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-my-none">LNbits satspay Extension</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "satspay/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<q-dialog
|
||||
v-model="formDialogCharge.show"
|
||||
position="top"
|
||||
@hide="closeFormDialog"
|
||||
>
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="sendFormDataCharge" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialogCharge.data.description"
|
||||
type="text"
|
||||
label="*Description"
|
||||
></q-input>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialogCharge.data.amount"
|
||||
type="number"
|
||||
label="*Amount (sats)"
|
||||
></q-input>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialogCharge.data.time"
|
||||
type="number"
|
||||
max="1440"
|
||||
label="*Mins valid for (max 1440)"
|
||||
>
|
||||
</q-input>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialogCharge.data.webhook"
|
||||
type="url"
|
||||
label="Webhook (URL to send transaction data to once paid)"
|
||||
>
|
||||
</q-input>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialogCharge.data.completelink"
|
||||
type="url"
|
||||
label="Completed button URL"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialogCharge.data.completelinktext"
|
||||
type="text"
|
||||
label="Completed button text (ie 'Back to merchant')"
|
||||
>
|
||||
</q-input>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div v-if="walletLinks.length > 0">
|
||||
<q-checkbox
|
||||
v-model="formDialogCharge.data.onchain"
|
||||
label="Onchain"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-checkbox :value="false" label="Onchain" disabled>
|
||||
<q-tooltip>
|
||||
Watch-Only extension MUST be activated and have a wallet
|
||||
</q-tooltip>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div>
|
||||
<q-checkbox
|
||||
v-model="formDialogCharge.data.lnbits"
|
||||
label="LNbits wallet"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="formDialogCharge.data.onchain">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialogCharge.data.onchainwallet"
|
||||
:options="walletLinks"
|
||||
label="Onchain Wallet"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<q-select
|
||||
v-if="formDialogCharge.data.lnbits"
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialogCharge.data.lnbitswallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
>
|
||||
</q-select>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
:disable="
|
||||
formDialogCharge.data.time == null ||
|
||||
formDialogCharge.data.amount == null"
|
||||
type="submit"
|
||||
>Create Charge</q-btn
|
||||
>
|
||||
<q-btn @click="cancelCharge" flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
|
||||
<style></style>
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
var mapCharge = obj => {
|
||||
obj._data = _.clone(obj)
|
||||
obj.theTime = obj.time * 60 - (Date.now() / 1000 - obj.timestamp)
|
||||
obj.time = obj.time + 'mins'
|
||||
|
||||
if (obj.time_elapsed) {
|
||||
obj.date = 'Time elapsed'
|
||||
} else {
|
||||
obj.date = Quasar.utils.date.formatDate(
|
||||
new Date((obj.theTime - 3600) * 1000),
|
||||
'HH:mm:ss'
|
||||
)
|
||||
}
|
||||
obj.displayUrl = ['/satspay/', obj.id].join('')
|
||||
return obj
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
filter: '',
|
||||
watchonlyactive: false,
|
||||
balance: null,
|
||||
checker: null,
|
||||
walletLinks: [],
|
||||
ChargeLinks: [],
|
||||
ChargeLinksObj: [],
|
||||
onchainwallet: '',
|
||||
currentaddress: '',
|
||||
Addresses: {
|
||||
show: false,
|
||||
data: null
|
||||
},
|
||||
mempool: {
|
||||
endpoint: ''
|
||||
},
|
||||
|
||||
ChargesTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'theId',
|
||||
align: 'left',
|
||||
label: 'ID',
|
||||
field: 'id'
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
align: 'left',
|
||||
label: 'Title',
|
||||
field: 'description'
|
||||
},
|
||||
{
|
||||
name: 'timeleft',
|
||||
align: 'left',
|
||||
label: 'Time left',
|
||||
field: 'date'
|
||||
},
|
||||
{
|
||||
name: 'time to pay',
|
||||
align: 'left',
|
||||
label: 'Time to Pay',
|
||||
field: 'time'
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
align: 'left',
|
||||
label: 'Amount to pay',
|
||||
field: 'amount'
|
||||
},
|
||||
{
|
||||
name: 'balance',
|
||||
align: 'left',
|
||||
label: 'Balance',
|
||||
field: 'balance'
|
||||
},
|
||||
{
|
||||
name: 'onchain address',
|
||||
align: 'left',
|
||||
label: 'Onchain Address',
|
||||
field: 'onchainaddress'
|
||||
},
|
||||
{
|
||||
name: 'LNbits wallet',
|
||||
align: 'left',
|
||||
label: 'LNbits wallet',
|
||||
field: 'lnbitswallet'
|
||||
},
|
||||
{
|
||||
name: 'Webhook link',
|
||||
align: 'left',
|
||||
label: 'Webhook link',
|
||||
field: 'webhook'
|
||||
},
|
||||
{
|
||||
name: 'Paid link',
|
||||
align: 'left',
|
||||
label: 'Paid link',
|
||||
field: 'completelink'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
formDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
formDialogCharge: {
|
||||
show: false,
|
||||
data: {
|
||||
onchain: false,
|
||||
lnbits: false,
|
||||
description: '',
|
||||
time: null,
|
||||
amount: null
|
||||
}
|
||||
},
|
||||
qrCodeDialog: {
|
||||
show: false,
|
||||
data: null
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
cancelCharge: function (data) {
|
||||
var self = this
|
||||
self.formDialogCharge.data.description = ''
|
||||
self.formDialogCharge.data.onchainwallet = ''
|
||||
self.formDialogCharge.data.lnbitswallet = ''
|
||||
self.formDialogCharge.data.time = null
|
||||
self.formDialogCharge.data.amount = null
|
||||
self.formDialogCharge.data.webhook = ''
|
||||
self.formDialogCharge.data.completelink = ''
|
||||
self.formDialogCharge.show = false
|
||||
},
|
||||
|
||||
getWalletLinks: function () {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/watchonly/api/v1/wallet',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
for (i = 0; i < response.data.length; i++) {
|
||||
self.walletLinks.push(response.data[i].id)
|
||||
}
|
||||
return
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
closeFormDialog: function () {
|
||||
this.formDialog.data = {
|
||||
is_unique: false
|
||||
}
|
||||
},
|
||||
openQrCodeDialog: function (linkId) {
|
||||
var self = this
|
||||
var getAddresses = this.getAddresses
|
||||
getAddresses(linkId)
|
||||
self.current = linkId
|
||||
self.Addresses.show = true
|
||||
},
|
||||
getCharges: function () {
|
||||
var self = this
|
||||
var getAddressBalance = this.getAddressBalance
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/satspay/api/v1/charges',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.ChargeLinks = response.data.map(mapCharge)
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
sendFormDataCharge: function () {
|
||||
var self = this
|
||||
var wallet = this.g.user.wallets[0].adminkey
|
||||
var data = this.formDialogCharge.data
|
||||
data.amount = parseInt(data.amount)
|
||||
data.time = parseInt(data.time)
|
||||
this.createCharge(wallet, data)
|
||||
},
|
||||
timerCount: function () {
|
||||
self = this
|
||||
var refreshIntervalId = setInterval(function () {
|
||||
for (i = 0; i < self.ChargeLinks.length - 1; i++) {
|
||||
if (self.ChargeLinks[i]['paid'] == 'True') {
|
||||
setTimeout(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/satspay/api/v1/charges/balance/' +
|
||||
self.ChargeLinks[i]['id'],
|
||||
'filla'
|
||||
)
|
||||
.then(function (response) {})
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
self.getCharges()
|
||||
}, 20000)
|
||||
},
|
||||
createCharge: function (wallet, data) {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request('POST', '/satspay/api/v1/charge', wallet, data)
|
||||
.then(function (response) {
|
||||
self.ChargeLinks.push(mapCharge(response.data))
|
||||
self.formDialogCharge.show = false
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
|
||||
deleteChargeLink: function (chargeId) {
|
||||
var self = this
|
||||
var link = _.findWhere(this.ChargeLinks, {id: chargeId})
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this pay link?')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/satspay/api/v1/charge/' + chargeId,
|
||||
self.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.ChargeLinks = _.reject(self.ChargeLinks, function (obj) {
|
||||
return obj.id === chargeId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
exportchargeCSV: function () {
|
||||
var self = this
|
||||
LNbits.utils.exportCSV(self.ChargesTable.columns, this.ChargeLinks)
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
var self = this
|
||||
var getCharges = this.getCharges
|
||||
getCharges()
|
||||
var getWalletLinks = this.getWalletLinks
|
||||
getWalletLinks()
|
||||
var timerCount = this.timerCount
|
||||
timerCount()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
from quart import g, abort, render_template, jsonify
|
||||
from http import HTTPStatus
|
||||
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
|
||||
from . import satspay_ext
|
||||
from .crud import get_charge
|
||||
|
||||
|
||||
@satspay_ext.route("/")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
async def index():
|
||||
return await render_template("satspay/index.html", user=g.user)
|
||||
|
||||
|
||||
@satspay_ext.route("/<charge_id>")
|
||||
async def display(charge_id):
|
||||
charge = await get_charge(charge_id) or abort(
|
||||
HTTPStatus.NOT_FOUND, "Charge link does not exist."
|
||||
)
|
||||
return await render_template("satspay/display.html", charge=charge)
|
||||
@@ -0,0 +1,157 @@
|
||||
import hashlib
|
||||
from quart import g, jsonify, url_for
|
||||
from http import HTTPStatus
|
||||
import httpx
|
||||
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
|
||||
from lnbits.extensions.satspay import satspay_ext
|
||||
from .crud import (
|
||||
create_charge,
|
||||
update_charge,
|
||||
get_charge,
|
||||
get_charges,
|
||||
delete_charge,
|
||||
check_address_balance,
|
||||
)
|
||||
|
||||
#############################CHARGES##########################
|
||||
|
||||
|
||||
@satspay_ext.route("/api/v1/charge", methods=["POST"])
|
||||
@satspay_ext.route("/api/v1/charge/<charge_id>", methods=["PUT"])
|
||||
@api_check_wallet_key("admin")
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"onchainwallet": {"type": "string"},
|
||||
"lnbitswallet": {"type": "string"},
|
||||
"description": {"type": "string", "empty": False, "required": True},
|
||||
"webhook": {"type": "string"},
|
||||
"completelink": {"type": "string"},
|
||||
"completelinktext": {"type": "string"},
|
||||
"time": {"type": "integer", "min": 1, "required": True},
|
||||
"amount": {"type": "integer", "min": 1, "required": True},
|
||||
}
|
||||
)
|
||||
async def api_charge_create_or_update(charge_id=None):
|
||||
if not charge_id:
|
||||
charge = await create_charge(user=g.wallet.user, **g.data)
|
||||
return jsonify(charge._asdict()), HTTPStatus.CREATED
|
||||
else:
|
||||
charge = await update_charge(charge_id=charge_id, **g.data)
|
||||
return jsonify(charge._asdict()), HTTPStatus.OK
|
||||
|
||||
|
||||
@satspay_ext.route("/api/v1/charges", methods=["GET"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_charges_retrieve():
|
||||
try:
|
||||
return (
|
||||
jsonify(
|
||||
[
|
||||
{
|
||||
**charge._asdict(),
|
||||
**{"time_elapsed": charge.time_elapsed},
|
||||
**{"paid": charge.paid},
|
||||
}
|
||||
for charge in await get_charges(g.wallet.user)
|
||||
]
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
except:
|
||||
return ""
|
||||
|
||||
|
||||
@satspay_ext.route("/api/v1/charge/<charge_id>", methods=["GET"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_charge_retrieve(charge_id):
|
||||
charge = await get_charge(charge_id)
|
||||
|
||||
if not charge:
|
||||
return jsonify({"message": "charge does not exist"}), HTTPStatus.NOT_FOUND
|
||||
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
**charge._asdict(),
|
||||
**{"time_elapsed": charge.time_elapsed},
|
||||
**{"paid": charge.paid},
|
||||
}
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@satspay_ext.route("/api/v1/charge/<charge_id>", methods=["DELETE"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_charge_delete(charge_id):
|
||||
charge = await get_charge(charge_id)
|
||||
|
||||
if not charge:
|
||||
return jsonify({"message": "Wallet link does not exist."}), HTTPStatus.NOT_FOUND
|
||||
|
||||
await delete_charge(charge_id)
|
||||
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
#############################BALANCE##########################
|
||||
|
||||
|
||||
@satspay_ext.route("/api/v1/charges/balance/<charge_id>", methods=["GET"])
|
||||
async def api_charges_balance(charge_id):
|
||||
|
||||
charge = await check_address_balance(charge_id)
|
||||
|
||||
if not charge:
|
||||
return jsonify({"message": "charge does not exist"}), HTTPStatus.NOT_FOUND
|
||||
if charge.paid and charge.webhook:
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
r = await client.post(
|
||||
charge.webhook,
|
||||
json={
|
||||
"id": charge.id,
|
||||
"description": charge.description,
|
||||
"onchainaddress": charge.onchainaddress,
|
||||
"payment_request": charge.payment_request,
|
||||
"payment_hash": charge.payment_hash,
|
||||
"time": charge.time,
|
||||
"amount": charge.amount,
|
||||
"balance": charge.balance,
|
||||
"paid": charge.paid,
|
||||
"timestamp": charge.timestamp,
|
||||
"completelink": charge.completelink,
|
||||
},
|
||||
timeout=40,
|
||||
)
|
||||
except AssertionError:
|
||||
charge.webhook = None
|
||||
return jsonify(charge._asdict()), HTTPStatus.OK
|
||||
|
||||
|
||||
#############################MEMPOOL##########################
|
||||
|
||||
|
||||
@satspay_ext.route("/api/v1/mempool", methods=["PUT"])
|
||||
@api_check_wallet_key("invoice")
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"endpoint": {"type": "string", "empty": False, "required": True},
|
||||
}
|
||||
)
|
||||
async def api_update_mempool():
|
||||
mempool = await update_mempool(user=g.wallet.user, **g.data)
|
||||
return jsonify(mempool._asdict()), HTTPStatus.OK
|
||||
|
||||
|
||||
@satspay_ext.route("/api/v1/mempool", methods=["GET"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_get_mempool():
|
||||
mempool = await get_mempool(g.wallet.user)
|
||||
if not mempool:
|
||||
mempool = await create_mempool(user=g.wallet.user)
|
||||
return jsonify(mempool._asdict()), HTTPStatus.OK
|
||||
@@ -4,7 +4,6 @@ from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from . import db
|
||||
from .models import Domains, Subdomains
|
||||
from lnbits.extensions import subdomains
|
||||
|
||||
|
||||
async def create_subdomain(
|
||||
@@ -37,9 +36,9 @@ async def create_subdomain(
|
||||
),
|
||||
)
|
||||
|
||||
subdomain = await get_subdomain(payment_hash)
|
||||
assert subdomain, "Newly created subdomain couldn't be retrieved"
|
||||
return subdomain
|
||||
new_subdomain = await get_subdomain(payment_hash)
|
||||
assert new_subdomain, "Newly created subdomain couldn't be retrieved"
|
||||
return new_subdomain
|
||||
|
||||
|
||||
async def set_subdomain_paid(payment_hash: str) -> Subdomains:
|
||||
@@ -70,8 +69,9 @@ async def set_subdomain_paid(payment_hash: str) -> Subdomains:
|
||||
(amount, row[1]),
|
||||
)
|
||||
|
||||
subdomain = await get_subdomain(payment_hash)
|
||||
return subdomain
|
||||
new_subdomain = await get_subdomain(payment_hash)
|
||||
assert new_subdomain, "Newly paid subdomain couldn't be retrieved"
|
||||
return new_subdomain
|
||||
|
||||
|
||||
async def get_subdomain(subdomain_id: str) -> Optional[Subdomains]:
|
||||
@@ -79,7 +79,6 @@ async def get_subdomain(subdomain_id: str) -> Optional[Subdomains]:
|
||||
"SELECT s.*, d.domain as domain_name FROM subdomain s INNER JOIN domain d ON (s.domain = d.id) WHERE s.id = ?",
|
||||
(subdomain_id,),
|
||||
)
|
||||
print(row)
|
||||
return Subdomains(**row) if row else None
|
||||
|
||||
|
||||
@@ -143,9 +142,9 @@ async def create_domain(
|
||||
),
|
||||
)
|
||||
|
||||
domain = await get_domain(domain_id)
|
||||
assert domain, "Newly created domain couldn't be retrieved"
|
||||
return domain
|
||||
new_domain = await get_domain(domain_id)
|
||||
assert new_domain, "Newly created domain couldn't be retrieved"
|
||||
return new_domain
|
||||
|
||||
|
||||
async def update_domain(domain_id: str, **kwargs) -> Domains:
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import re
|
||||
from quart import g, jsonify, request
|
||||
from http import HTTPStatus
|
||||
from lnbits.core import crud
|
||||
import json
|
||||
|
||||
import httpx
|
||||
from lnbits.core.crud import get_user, get_wallet
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.core.services import create_invoice, check_invoice_status
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
|
||||
from .util import isValidDomain, isvalidIPAddress
|
||||
from . import subdomains_ext
|
||||
from .crud import (
|
||||
create_subdomain,
|
||||
@@ -169,12 +164,16 @@ async def api_subdomain_make_subdomain(domain_id):
|
||||
|
||||
## ALL OK - create an invoice and return it to the user
|
||||
sats = g.data["sats"]
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=domain.wallet,
|
||||
amount=sats,
|
||||
memo=f"subdomain {g.data['subdomain']}.{domain.domain} for {sats} sats for {g.data['duration']} days",
|
||||
extra={"tag": "lnsubdomain"},
|
||||
)
|
||||
|
||||
try:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=domain.wallet,
|
||||
amount=sats,
|
||||
memo=f"subdomain {g.data['subdomain']}.{domain.domain} for {sats} sats for {g.data['duration']} days",
|
||||
extra={"tag": "lnsubdomain"},
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
subdomain = await create_subdomain(
|
||||
payment_hash=payment_hash, wallet=domain.wallet, **g.data
|
||||
|
||||
@@ -89,12 +89,17 @@ async def get_usermanager_wallet(wallet_id: str) -> Optional[Wallets]:
|
||||
return Wallets(**row) if row else None
|
||||
|
||||
|
||||
async def get_usermanager_wallets(user_id: str) -> List[Wallets]:
|
||||
rows = await db.fetchall("SELECT * FROM wallets WHERE admin = ?", (user_id,))
|
||||
async def get_usermanager_wallets(admin_id: str) -> Optional[Wallets]:
|
||||
rows = await db.fetchall("SELECT * FROM wallets WHERE admin = ?", (admin_id,))
|
||||
return [Wallets(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_usermanager_wallet_transactions(wallet_id: str) -> List[Payment]:
|
||||
async def get_usermanager_users_wallets(user_id: str) -> Optional[Wallets]:
|
||||
rows = await db.fetchall("SELECT * FROM wallets WHERE user = ?", (user_id,))
|
||||
return [Wallets(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_usermanager_wallet_transactions(wallet_id: str) -> Optional[Payment]:
|
||||
return await get_payments(
|
||||
wallet_id=wallet_id, complete=True, pending=False, outgoing=True, incoming=True
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from typing import NamedTuple
|
||||
from sqlite3 import Row
|
||||
|
||||
|
||||
class Users(NamedTuple):
|
||||
@@ -16,3 +17,7 @@ class Wallets(NamedTuple):
|
||||
user: str
|
||||
adminkey: str
|
||||
inkey: str
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Wallets":
|
||||
return cls(**dict(row))
|
||||
|
||||
@@ -14,6 +14,7 @@ from .crud import (
|
||||
create_usermanager_wallet,
|
||||
get_usermanager_wallet,
|
||||
get_usermanager_wallets,
|
||||
get_usermanager_users_wallets,
|
||||
delete_usermanager_wallet,
|
||||
)
|
||||
from lnbits.core import update_user_extension
|
||||
@@ -83,18 +84,6 @@ async def api_usermanager_activate_extension():
|
||||
###Wallets
|
||||
|
||||
|
||||
@usermanager_ext.route("/api/v1/wallets", methods=["GET"])
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
async def api_usermanager_wallets():
|
||||
user_id = g.wallet.user
|
||||
return (
|
||||
jsonify(
|
||||
[wallet._asdict() for wallet in await get_usermanager_wallets(user_id)]
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@usermanager_ext.route("/api/v1/wallets", methods=["POST"])
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
@api_validate_post_request(
|
||||
@@ -111,6 +100,18 @@ async def api_usermanager_wallets_create():
|
||||
return jsonify(user._asdict()), HTTPStatus.CREATED
|
||||
|
||||
|
||||
@usermanager_ext.route("/api/v1/wallets", methods=["GET"])
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
async def api_usermanager_wallets():
|
||||
admin_id = g.wallet.user
|
||||
return (
|
||||
jsonify(
|
||||
[wallet._asdict() for wallet in await get_usermanager_wallets(admin_id)]
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@usermanager_ext.route("/api/v1/wallets<wallet_id>", methods=["GET"])
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
async def api_usermanager_wallet_transactions(wallet_id):
|
||||
@@ -119,8 +120,17 @@ async def api_usermanager_wallet_transactions(wallet_id):
|
||||
|
||||
@usermanager_ext.route("/api/v1/wallets/<user_id>", methods=["GET"])
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
async def api_usermanager_wallet(user_id):
|
||||
return jsonify(await get_usermanager_wallets(user_id)), HTTPStatus.OK
|
||||
async def api_usermanager_users_wallets(user_id):
|
||||
wallet = await get_usermanager_users_wallets(user_id)
|
||||
return (
|
||||
jsonify(
|
||||
[
|
||||
wallet._asdict()
|
||||
for wallet in await get_usermanager_users_wallets(user_id)
|
||||
]
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@usermanager_ext.route("/api/v1/wallets/<wallet_id>", methods=["DELETE"])
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Watch Only wallet
|
||||
|
||||
Monitor an extended public key and generate deterministic fresh public keys with this simple watch only wallet. Invoice payments can also be generated, both through a publically shareable page and API.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
from quart import Blueprint
|
||||
from lnbits.db import Database
|
||||
|
||||
db = Database("ext_watchonly")
|
||||
|
||||
|
||||
watchonly_ext: Blueprint = Blueprint(
|
||||
"watchonly", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
from .views import * # noqa
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "Watch Only",
|
||||
"short_description": "Onchain watch only wallets",
|
||||
"icon": "visibility",
|
||||
"contributors": [
|
||||
"arcbtc"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
from typing import List, Optional, Union
|
||||
|
||||
# from lnbits.db import open_ext_db
|
||||
from . import db
|
||||
from .models import Wallets, Addresses, Mempool
|
||||
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from embit.descriptor import Descriptor, Key
|
||||
from embit.descriptor.arguments import AllowedDerivation
|
||||
from embit.networks import NETWORKS
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
##########################WALLETS####################
|
||||
|
||||
|
||||
def detect_network(k):
|
||||
version = k.key.version
|
||||
for network_name in NETWORKS:
|
||||
net = NETWORKS[network_name]
|
||||
# not found in this network
|
||||
if version in [net["xpub"], net["ypub"], net["zpub"], net["Zpub"], net["Ypub"]]:
|
||||
return net
|
||||
|
||||
|
||||
def parse_key(masterpub: str):
|
||||
"""Parses masterpub or descriptor and returns a tuple: (Descriptor, network)
|
||||
To create addresses use descriptor.derive(num).address(network=network)
|
||||
"""
|
||||
network = None
|
||||
# probably a single key
|
||||
if "(" not in masterpub:
|
||||
k = Key.from_string(masterpub)
|
||||
if not k.is_extended:
|
||||
raise ValueError("The key is not a master public key")
|
||||
if k.is_private:
|
||||
raise ValueError("Private keys are not allowed")
|
||||
# check depth
|
||||
if k.key.depth != 3:
|
||||
raise ValueError(
|
||||
"Non-standard depth. Only bip44, bip49 and bip84 are supported with bare xpubs. For custom derivation paths use descriptors."
|
||||
)
|
||||
# if allowed derivation is not provided use default /{0,1}/*
|
||||
if k.allowed_derivation is None:
|
||||
k.allowed_derivation = AllowedDerivation.default()
|
||||
# get version bytes
|
||||
version = k.key.version
|
||||
for network_name in NETWORKS:
|
||||
net = NETWORKS[network_name]
|
||||
# not found in this network
|
||||
if version in [net["xpub"], net["ypub"], net["zpub"]]:
|
||||
network = net
|
||||
if version == net["xpub"]:
|
||||
desc = Descriptor.from_string("pkh(%s)" % str(k))
|
||||
elif version == net["ypub"]:
|
||||
desc = Descriptor.from_string("sh(wpkh(%s))" % str(k))
|
||||
elif version == net["zpub"]:
|
||||
desc = Descriptor.from_string("wpkh(%s)" % str(k))
|
||||
break
|
||||
# we didn't find correct version
|
||||
if network is None:
|
||||
raise ValueError("Unknown master public key version")
|
||||
else:
|
||||
desc = Descriptor.from_string(masterpub)
|
||||
if not desc.is_wildcard:
|
||||
raise ValueError("Descriptor should have wildcards")
|
||||
for k in desc.keys:
|
||||
if k.is_extended:
|
||||
net = detect_network(k)
|
||||
if net is None:
|
||||
raise ValueError(f"Unknown version: {k}")
|
||||
if network is not None and network != net:
|
||||
raise ValueError("Keys from different networks")
|
||||
network = net
|
||||
return desc, network
|
||||
|
||||
|
||||
async def create_watch_wallet(*, user: str, masterpub: str, title: str) -> Wallets:
|
||||
# check the masterpub is fine, it will raise an exception if not
|
||||
parse_key(masterpub)
|
||||
wallet_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO wallets (
|
||||
id,
|
||||
user,
|
||||
masterpub,
|
||||
title,
|
||||
address_no,
|
||||
balance
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
# address_no is -1 so fresh address on empty wallet can get address with index 0
|
||||
(wallet_id, user, masterpub, title, -1, 0),
|
||||
)
|
||||
# weallet_id = db.cursor.lastrowid
|
||||
|
||||
return await get_watch_wallet(wallet_id)
|
||||
|
||||
|
||||
async def get_watch_wallet(wallet_id: str) -> Wallets:
|
||||
row = await db.fetchone("SELECT * FROM wallets WHERE id = ?", (wallet_id,))
|
||||
return Wallets.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_watch_wallets(user: str) -> List[Wallets]:
|
||||
rows = await db.fetchall("SELECT * FROM wallets WHERE user = ?", (user,))
|
||||
return [Wallets(**row) for row in rows]
|
||||
|
||||
|
||||
async def update_watch_wallet(wallet_id: str, **kwargs) -> Optional[Wallets]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
|
||||
await db.execute(
|
||||
f"UPDATE wallets SET {q} WHERE id = ?", (*kwargs.values(), wallet_id)
|
||||
)
|
||||
row = await db.fetchone("SELECT * FROM wallets WHERE id = ?", (wallet_id,))
|
||||
return Wallets.from_row(row) if row else None
|
||||
|
||||
|
||||
async def delete_watch_wallet(wallet_id: str) -> None:
|
||||
await db.execute("DELETE FROM wallets WHERE id = ?", (wallet_id,))
|
||||
|
||||
########################ADDRESSES#######################
|
||||
|
||||
|
||||
async def get_derive_address(wallet_id: str, num: int):
|
||||
|
||||
wallet = await get_watch_wallet(wallet_id)
|
||||
key = wallet[2]
|
||||
desc, network = parse_key(key)
|
||||
return desc.derive(num).address(network=network)
|
||||
|
||||
|
||||
async def get_fresh_address(wallet_id: str) -> Addresses:
|
||||
wallet = await get_watch_wallet(wallet_id)
|
||||
|
||||
address = await get_derive_address(wallet_id, wallet[4] + 1)
|
||||
|
||||
await update_watch_wallet(wallet_id=wallet_id, address_no=wallet[4] + 1)
|
||||
masterpub_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO addresses (
|
||||
id,
|
||||
address,
|
||||
wallet,
|
||||
amount
|
||||
)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(masterpub_id, address, wallet_id, 0),
|
||||
)
|
||||
|
||||
return await get_address(address)
|
||||
|
||||
|
||||
async def get_address(address: str) -> Addresses:
|
||||
row = await db.fetchone("SELECT * FROM addresses WHERE address = ?", (address,))
|
||||
return Addresses.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_addresses(wallet_id: str) -> List[Addresses]:
|
||||
rows = await db.fetchall("SELECT * FROM addresses WHERE wallet = ?", (wallet_id,))
|
||||
return [Addresses(**row) for row in rows]
|
||||
|
||||
|
||||
######################MEMPOOL#######################
|
||||
|
||||
|
||||
async def create_mempool(user: str) -> Mempool:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO mempool (
|
||||
user,
|
||||
endpoint
|
||||
)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(user, "https://mempool.space"),
|
||||
)
|
||||
row = await db.fetchone("SELECT * FROM mempool WHERE user = ?", (user,))
|
||||
return Mempool.from_row(row) if row else None
|
||||
|
||||
|
||||
async def update_mempool(user: str, **kwargs) -> Optional[Mempool]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
|
||||
await db.execute(f"UPDATE mempool SET {q} WHERE user = ?", (*kwargs.values(), user))
|
||||
row = await db.fetchone("SELECT * FROM mempool WHERE user = ?", (user,))
|
||||
return Mempool.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_mempool(user: str) -> Mempool:
|
||||
row = await db.fetchone("SELECT * FROM mempool WHERE user = ?", (user,))
|
||||
return Mempool.from_row(row) if row else None
|
||||
@@ -0,0 +1,36 @@
|
||||
async def m001_initial(db):
|
||||
"""
|
||||
Initial wallet table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS wallets (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
user TEXT,
|
||||
masterpub TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
address_no INTEGER NOT NULL DEFAULT 0,
|
||||
balance INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS addresses (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
address TEXT NOT NULL,
|
||||
wallet TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS mempool (
|
||||
user TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
from sqlite3 import Row
|
||||
from typing import NamedTuple
|
||||
|
||||
|
||||
class Wallets(NamedTuple):
|
||||
id: str
|
||||
user: str
|
||||
masterpub: str
|
||||
title: str
|
||||
address_no: int
|
||||
balance: int
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Wallets":
|
||||
return cls(**dict(row))
|
||||
|
||||
|
||||
class Mempool(NamedTuple):
|
||||
user: str
|
||||
endpoint: str
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Mempool":
|
||||
return cls(**dict(row))
|
||||
|
||||
|
||||
class Addresses(NamedTuple):
|
||||
id: str
|
||||
address: str
|
||||
wallet: str
|
||||
amount: int
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Addresses":
|
||||
return cls(**dict(row))
|
||||
@@ -0,0 +1,244 @@
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
Watch Only extension uses mempool.space<br />
|
||||
For use with "account Extended Public Key"
|
||||
<a href="https://iancoleman.io/bip39/">https://iancoleman.io/bip39/</a>
|
||||
<small>
|
||||
<br />Created by,
|
||||
<a target="_blank" href="https://github.com/arcbtc">Ben Arc</a> (using,
|
||||
<a target="_blank" href="https://github.com/diybitcoinhardware/embit"
|
||||
>Embit</a
|
||||
></small
|
||||
>)
|
||||
</p>
|
||||
</q-card-section>
|
||||
|
||||
<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="List wallets">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span> /watchonly/api/v1/wallet</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<wallets_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root }}api/v1/wallet -H "X-Api-Key: {{
|
||||
g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Get wallet details"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/watchonly/api/v1/wallet/<wallet_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code>[<wallet_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root }}api/v1/wallet/<wallet_id>
|
||||
-H "X-Api-Key: {{ g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="Create wallet">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-green">POST</span> /watchonly/api/v1/wallet</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code>[<wallet_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.url_root }}api/v1/wallet -d '{"title":
|
||||
<string>, "masterpub": <string>}' -H "Content-type:
|
||||
application/json" -H "X-Api-Key: {{ g.user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Delete wallet"
|
||||
class="q-pb-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-pink">DELETE</span>
|
||||
/watchonly/api/v1/wallet/<wallet_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
|
||||
<code></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X DELETE {{ request.url_root
|
||||
}}api/v1/wallet/<wallet_id> -H "X-Api-Key: {{
|
||||
g.user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item group="api" dense expand-separator label="List addresses">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/watchonly/api/v1/addresses/<wallet_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<address_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root
|
||||
}}api/v1/addresses/<wallet_id> -H "X-Api-Key: {{
|
||||
g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Get fresh address"
|
||||
class="q-pb-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/watchonly/api/v1/address/<wallet_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<address_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root }}api/v1/address/<wallet_id>
|
||||
-H "X-Api-Key: {{ g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Get mempool.space details"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span> /watchonly/api/v1/mempool</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<mempool_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root }}api/v1/mempool -H "X-Api-Key: {{
|
||||
g.user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Update mempool.space"
|
||||
class="q-pb-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-green">POST</span>
|
||||
/watchonly/api/v1/mempool</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json)
|
||||
</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code>[<mempool_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X PUT {{ request.url_root }}api/v1/mempool -d '{"endpoint":
|
||||
<string>}' -H "Content-type: application/json" -H "X-Api-Key:
|
||||
{{ g.user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
||||
</q-card>
|
||||
@@ -0,0 +1,474 @@
|
||||
{% 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-7 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
{% raw %}
|
||||
<q-btn unelevated color="deep-purple" @click="formDialog.show = true"
|
||||
>New wallet
|
||||
</q-btn>
|
||||
<q-btn unelevated color="deep-purple" icon="edit">
|
||||
<div class="cursor-pointer">
|
||||
<q-tooltip> Point to another Mempool </q-tooltip>
|
||||
{{ this.mempool.endpoint }}
|
||||
<q-popup-edit v-model="mempool.endpoint">
|
||||
<q-input color="accent" v-model="mempool.endpoint"> </q-input>
|
||||
<center>
|
||||
<q-btn flat dense @click="updateMempool()" v-close-popup
|
||||
>set</q-btn
|
||||
>
|
||||
<q-btn flat dense v-close-popup>cancel</q-btn>
|
||||
</center>
|
||||
</q-popup-edit>
|
||||
</div>
|
||||
</q-btn>
|
||||
</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">Wallets</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-input
|
||||
borderless
|
||||
dense
|
||||
debounce="300"
|
||||
v-model="filter"
|
||||
placeholder="Search"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon name="search"></q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
flat
|
||||
dense
|
||||
:data="walletLinks"
|
||||
row-key="id"
|
||||
:columns="WalletsTable.columns"
|
||||
:pagination.sync="WalletsTable.pagination"
|
||||
:filter="filter"
|
||||
>
|
||||
<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"
|
||||
auto-width
|
||||
>
|
||||
{{ col.label }}
|
||||
</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
|
||||
size="xs"
|
||||
icon="dns"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
@click="openQrCodeDialog(props.row.id)"
|
||||
>
|
||||
<q-tooltip> Adresses </q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteWalletLink(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
<q-td
|
||||
v-for="col in props.cols"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
auto-width
|
||||
>
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
{% endraw %}
|
||||
|
||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-my-none">LNbits Watch Only Extension</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "watchonly/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="formDialog.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-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.title"
|
||||
type="text"
|
||||
label="Title"
|
||||
></q-input>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
type="textarea"
|
||||
v-model="formDialog.data.masterpub"
|
||||
height="50px"
|
||||
autogrow
|
||||
label="Account Extended Public Key; xpub, ypub, zpub; Bitcoin Descriptor"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
:disable="
|
||||
formDialog.data.masterpub == null ||
|
||||
formDialog.data.title == null"
|
||||
type="submit"
|
||||
>Create Watch-only Wallet</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="Addresses.show" position="top">
|
||||
<q-card v-if="Addresses.data" class="q-pa-lg lnbits__dialog-card">
|
||||
{% raw %}
|
||||
<h5 class="text-subtitle1 q-my-none">Addresses</h5>
|
||||
<q-separator></q-separator><br />
|
||||
<p>
|
||||
<strong>Current:</strong>
|
||||
{{ currentaddress }}
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="ms"
|
||||
icon="visibility"
|
||||
type="a"
|
||||
:href="mempool.endpoint + '/address/' + currentaddress"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
</p>
|
||||
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
|
||||
<qrcode
|
||||
:value="currentaddress"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
<p style="word-break: break-all">
|
||||
<q-scroll-area style="height: 200px; max-width: 100%">
|
||||
<q-list bordered v-for="data in Addresses.data.slice().reverse()">
|
||||
<q-item>
|
||||
<q-item-section>{{ data.address }}</q-item-section>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="ms"
|
||||
icon="visibility"
|
||||
type="a"
|
||||
:href="mempool.endpoint + '/address/' + data.address"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-scroll-area>
|
||||
</p>
|
||||
|
||||
<div class="row q-mt-lg q-gutter-sm">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="getFreshAddress(current)"
|
||||
class="q-ml-sm"
|
||||
>Get fresh address</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
{% endraw %}
|
||||
</div>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
|
||||
<style></style>
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
Vue.filter('reverse', function (value) {
|
||||
// slice to make a copy of array, then reverse the copy
|
||||
return value.slice().reverse()
|
||||
})
|
||||
var locationPath = [
|
||||
window.location.protocol,
|
||||
'//',
|
||||
window.location.hostname,
|
||||
window.location.pathname
|
||||
].join('')
|
||||
|
||||
var mapWalletLink = function (obj) {
|
||||
obj._data = _.clone(obj)
|
||||
obj.date = Quasar.utils.date.formatDate(
|
||||
new Date(obj.time * 1000),
|
||||
'YYYY-MM-DD HH:mm'
|
||||
)
|
||||
return obj
|
||||
}
|
||||
var mapAddresses = function (obj) {
|
||||
obj._data = _.clone(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 {
|
||||
filter: '',
|
||||
balance: null,
|
||||
checker: null,
|
||||
walletLinks: [],
|
||||
AddressesLinks: [],
|
||||
currentaddress: '',
|
||||
Addresses: {
|
||||
show: false,
|
||||
data: null
|
||||
},
|
||||
mempool: {
|
||||
endpoint: ''
|
||||
},
|
||||
WalletsTable: {
|
||||
columns: [
|
||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||
{
|
||||
name: 'title',
|
||||
align: 'left',
|
||||
label: 'Title',
|
||||
field: 'title'
|
||||
},
|
||||
{
|
||||
name: 'masterpub',
|
||||
align: 'left',
|
||||
label: 'MasterPub',
|
||||
field: 'masterpub'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
formDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
qrCodeDialog: {
|
||||
show: false,
|
||||
data: null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
getAddressDetails: function (address) {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/watchonly/api/v1/mempool/' + address,
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
return reponse.data
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
getAddresses: function (walletID) {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/watchonly/api/v1/addresses/' + walletID,
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.Addresses.data = response.data
|
||||
self.currentaddress =
|
||||
self.Addresses.data[self.Addresses.data.length - 1].address
|
||||
self.AddressesLinks = response.data.map(function (obj) {
|
||||
return mapAddresses(obj)
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
getFreshAddress: function (walletID) {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/watchonly/api/v1/address/' + walletID,
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.Addresses.data = response.data
|
||||
self.currentaddress =
|
||||
self.Addresses.data[self.Addresses.data.length - 1].address
|
||||
})
|
||||
},
|
||||
getMempool: function () {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/watchonly/api/v1/mempool',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.mempool.endpoint = response.data.endpoint
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
|
||||
updateMempool: function () {
|
||||
var self = this
|
||||
var wallet = this.g.user.wallets[0]
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/watchonly/api/v1/mempool',
|
||||
wallet.adminkey,
|
||||
self.mempool
|
||||
)
|
||||
.then(function (response) {
|
||||
self.mempool.endpoint = response.data.endpoint
|
||||
self.walletLinks.push(mapwalletLink(response.data))
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
getWalletLinks: function () {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/watchonly/api/v1/wallet',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.walletLinks = response.data.map(function (obj) {
|
||||
self.getAddresses(obj.id)
|
||||
return mapWalletLink(obj)
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
|
||||
closeFormDialog: function () {
|
||||
this.formDialog.data = {
|
||||
is_unique: false
|
||||
}
|
||||
},
|
||||
openQrCodeDialog: function (linkId) {
|
||||
var self = this
|
||||
var getAddresses = this.getAddresses
|
||||
this.getAddresses(linkId)
|
||||
self.current = linkId
|
||||
self.Addresses.show = true
|
||||
},
|
||||
sendFormData: function () {
|
||||
var wallet = this.g.user.wallets[0]
|
||||
var data = _.omit(this.formDialog.data, 'wallet')
|
||||
this.createWalletLink(wallet, data)
|
||||
},
|
||||
createWalletLink: function (wallet, data) {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request('POST', '/watchonly/api/v1/wallet', wallet.adminkey, data)
|
||||
.then(function (response) {
|
||||
self.walletLinks.push(mapWalletLink(response.data))
|
||||
self.formDialog.show = false
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteWalletLink: function (linkId) {
|
||||
var self = this
|
||||
var link = _.findWhere(this.walletLinks, {id: linkId})
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this pay link?')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/watchonly/api/v1/wallet/' + linkId,
|
||||
self.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.walletLinks = _.reject(self.walletLinks, function (obj) {
|
||||
return obj.id === linkId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
exportCSV: function () {
|
||||
LNbits.utils.exportCSV(this.paywallsTable.columns, this.paywalls)
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
if (this.g.user.wallets.length) {
|
||||
var getMempool = this.getMempool
|
||||
getMempool()
|
||||
var getWalletLinks = this.getWalletLinks
|
||||
getWalletLinks()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
from quart import g, abort, render_template
|
||||
from http import HTTPStatus
|
||||
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
|
||||
from . import watchonly_ext
|
||||
|
||||
|
||||
@watchonly_ext.route("/")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
async def index():
|
||||
return await render_template("watchonly/index.html", user=g.user)
|
||||
|
||||
|
||||
@watchonly_ext.route("/<charge_id>")
|
||||
async def display(charge_id):
|
||||
link = get_payment(charge_id) or abort(
|
||||
HTTPStatus.NOT_FOUND, "Charge link does not exist."
|
||||
)
|
||||
|
||||
return await render_template("watchonly/display.html", link=link)
|
||||
@@ -0,0 +1,138 @@
|
||||
import hashlib
|
||||
from quart import g, jsonify, url_for, request
|
||||
from http import HTTPStatus
|
||||
import httpx
|
||||
import json
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
|
||||
from lnbits.extensions.watchonly import watchonly_ext
|
||||
from .crud import (
|
||||
create_watch_wallet,
|
||||
get_watch_wallet,
|
||||
get_watch_wallets,
|
||||
update_watch_wallet,
|
||||
delete_watch_wallet,
|
||||
get_fresh_address,
|
||||
get_addresses,
|
||||
create_mempool,
|
||||
update_mempool,
|
||||
get_mempool,
|
||||
)
|
||||
|
||||
###################WALLETS#############################
|
||||
|
||||
|
||||
@watchonly_ext.route("/api/v1/wallet", methods=["GET"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_wallets_retrieve():
|
||||
|
||||
try:
|
||||
return (
|
||||
jsonify(
|
||||
[wallet._asdict() for wallet in await get_watch_wallets(g.wallet.user)]
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
except:
|
||||
return ""
|
||||
|
||||
|
||||
@watchonly_ext.route("/api/v1/wallet/<wallet_id>", methods=["GET"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_wallet_retrieve(wallet_id):
|
||||
wallet = await get_watch_wallet(wallet_id)
|
||||
|
||||
if not wallet:
|
||||
return jsonify({"message": "wallet does not exist"}), HTTPStatus.NOT_FOUND
|
||||
|
||||
return jsonify(wallet._asdict()), HTTPStatus.OK
|
||||
|
||||
|
||||
@watchonly_ext.route("/api/v1/wallet", methods=["POST"])
|
||||
@api_check_wallet_key("admin")
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"masterpub": {"type": "string", "empty": False, "required": True},
|
||||
"title": {"type": "string", "empty": False, "required": True},
|
||||
}
|
||||
)
|
||||
async def api_wallet_create_or_update(wallet_id=None):
|
||||
try:
|
||||
wallet = await create_watch_wallet(
|
||||
user=g.wallet.user, masterpub=g.data["masterpub"], title=g.data["title"]
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"message": str(e)}), HTTPStatus.BAD_REQUEST
|
||||
mempool = await get_mempool(g.wallet.user)
|
||||
if not mempool:
|
||||
create_mempool(user=g.wallet.user)
|
||||
return jsonify(wallet._asdict()), HTTPStatus.CREATED
|
||||
|
||||
|
||||
@watchonly_ext.route("/api/v1/wallet/<wallet_id>", methods=["DELETE"])
|
||||
@api_check_wallet_key("admin")
|
||||
async def api_wallet_delete(wallet_id):
|
||||
wallet = await get_watch_wallet(wallet_id)
|
||||
|
||||
if not wallet:
|
||||
return jsonify({"message": "Wallet link does not exist."}), HTTPStatus.NOT_FOUND
|
||||
|
||||
await delete_watch_wallet(wallet_id)
|
||||
|
||||
return jsonify({"deleted": "true"}), HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
#############################ADDRESSES##########################
|
||||
|
||||
|
||||
@watchonly_ext.route("/api/v1/address/<wallet_id>", methods=["GET"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_fresh_address(wallet_id):
|
||||
await get_fresh_address(wallet_id)
|
||||
|
||||
addresses = await get_addresses(wallet_id)
|
||||
|
||||
return jsonify([address._asdict() for address in addresses]), HTTPStatus.OK
|
||||
|
||||
|
||||
@watchonly_ext.route("/api/v1/addresses/<wallet_id>", methods=["GET"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_get_addresses(wallet_id):
|
||||
wallet = await get_watch_wallet(wallet_id)
|
||||
|
||||
if not wallet:
|
||||
return jsonify({"message": "wallet does not exist"}), HTTPStatus.NOT_FOUND
|
||||
|
||||
addresses = await get_addresses(wallet_id)
|
||||
|
||||
if not addresses:
|
||||
await get_fresh_address(wallet_id)
|
||||
addresses = await get_addresses(wallet_id)
|
||||
|
||||
return jsonify([address._asdict() for address in addresses]), HTTPStatus.OK
|
||||
|
||||
|
||||
#############################MEMPOOL##########################
|
||||
|
||||
|
||||
@watchonly_ext.route("/api/v1/mempool", methods=["PUT"])
|
||||
@api_check_wallet_key("admin")
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"endpoint": {"type": "string", "empty": False, "required": True},
|
||||
}
|
||||
)
|
||||
async def api_update_mempool():
|
||||
mempool = await update_mempool(user=g.wallet.user, **g.data)
|
||||
return jsonify(mempool._asdict()), HTTPStatus.OK
|
||||
|
||||
|
||||
@watchonly_ext.route("/api/v1/mempool", methods=["GET"])
|
||||
@api_check_wallet_key("admin")
|
||||
async def api_get_mempool():
|
||||
mempool = await get_mempool(g.wallet.user)
|
||||
if not mempool:
|
||||
mempool = await create_mempool(user=g.wallet.user)
|
||||
return jsonify(mempool._asdict()), HTTPStatus.OK
|
||||
@@ -119,11 +119,10 @@ async def api_lnurl_callback(unique_hash):
|
||||
|
||||
await update_withdraw_link(link.id, **changes)
|
||||
except ValueError as e:
|
||||
return jsonify({"status": "ERROR", "reason": str(e)}), HTTPStatus.OK
|
||||
return jsonify({"status": "ERROR", "reason": str(e)})
|
||||
except PermissionError:
|
||||
return (
|
||||
jsonify({"status": "ERROR", "reason": "Withdraw link is empty."}),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
return jsonify({"status": "ERROR", "reason": "Withdraw link is empty."})
|
||||
except Exception as e:
|
||||
return jsonify({"status": "ERROR", "reason": str(e)})
|
||||
|
||||
return jsonify({"status": "OK"}), HTTPStatus.OK
|
||||
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 34 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 457 80.82"><defs><style>.cls-1{fill:#fff}</style></defs><path d="M379.92 105.59c5.53.9 7.24 5.74 6.33 11.17l-2.11 11.57c-1.51 8.86-8.25 13.89-19.92 13.89H346l12.37-70.43h18.43c9.66 0 16.09 5 14.49 13.88l-1.51 8.85c-1.01 6.04-4.13 10.37-9.86 11.07zm-9.76 5.14h-7l-3.72 21.42h6.94c3.32 0 5.74-1.7 6.34-5.43l1.91-10.56c.66-3.73-1.15-5.43-4.47-5.43zm-5.24-9.87h6.54c3.33 0 5.74-1.71 6.44-5.43l1.41-8.15c.71-3.72-1.1-5.43-4.42-5.43h-6.54zM420.09 142.22H390.4l12.38-70.43h11.67l-10.56 60h18zM426.75 142.22l12.37-70.43h11.67l-12.37 70.43zM490.65 82.05h-11.78l-10.56 60.17h-11.67l10.56-60.17h-11.77l1.81-10.26h35.22zM516.83 142.22h-33.71l1.41-8.05 29.88-52.73h-19.32l1.62-9.65h31.79l-1.41 8-29.78 52.72h21.23z" class="cls-1" transform="translate(-71.5 -66.59)"/><path fill="#ffe000" d="M333.53 119l-17-24.25 4.94-28.13L280.86 95l17 24.25-5 28.13zm-41.61-22l18.42-12.91-2.19 12.49L322.47 117 304 129.92l2.23-12.49z" transform="translate(-71.5 -66.59)"/><path d="M117.18 86.68L115 99.26c-1.2 6.94-4.52 11.16-9.86 13.28l4.73 29.68H98.26l-3.92-28.08H88.1l-4.93 28.08H71.5l12.37-70.43h18.21c9.66 0 17.01 4.21 15.1 14.89zm-17.31-4.63h-6.13l-3.83 22h6.14c3.82 0 6.64-1.41 7.55-6.43l1.6-9.16c.91-5-1.5-6.41-5.33-6.41zM142.15 142.22l.31-15.7h-13l-5.23 15.7h-11.56l25.46-70.43h14.69l.9 70.43zm.41-25.36l.6-31.29-10.46 31.29zM188.36 96.94l1.81-9.86c.71-4.43-1.51-6-4.93-6s-6.24 1.61-6.94 6l-.91 4.83c-.6 3.62.1 5.63 3.12 7.55l7.45 4.62c6.24 3.93 9.66 7 8.35 14.09l-1.81 10.16c-1.92 10.86-8.25 14.69-19.92 14.69h-.21c-9.65 0-16.8-3.72-14.89-14.69l2.22-12.27h11.47l-1.91 10.66c-.71 4.33 1.4 6.24 4.92 6.24s6.44-1.91 7.15-6.24l1-5.53c.7-3.82-.21-5.84-3.32-7.75l-7.35-4.63c-5.43-3.42-9.36-7.85-8.25-14.09l1.59-9.25C168.94 75 176.28 71 187 71h.2c8.65 0 16.6 4 14.69 14.49l-2 11.47zM246.24 86.68l-2.32 13.38C242 110.73 235.27 115 223.6 115h-6.54l-4.83 27.27h-11.67l12.37-70.43h18.21c9.66-.05 17.01 4.16 15.1 14.84zm-12 1.81c.91-5-1.51-6.44-5.33-6.44h-6.14l-3.92 22.64H225c3.82 0 6.64-1.41 7.54-6.44zM244.35 142.22l12.38-70.43h11.67L256 142.22z" class="cls-1" transform="translate(-71.5 -66.59)"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 457 80.82"><defs><style>.cls-1{fill:#1a1a1a}</style></defs><path d="M379.92 105.59c5.53.9 7.24 5.74 6.33 11.17l-2.11 11.57c-1.51 8.86-8.25 13.89-19.92 13.89H346l12.37-70.43h18.43c9.66 0 16.09 5 14.49 13.88l-1.51 8.85c-1.01 6.04-4.13 10.37-9.86 11.07zm-9.76 5.14h-7l-3.72 21.42h6.94c3.32 0 5.74-1.7 6.34-5.43l1.91-10.56c.66-3.73-1.15-5.43-4.47-5.43zm-5.24-9.87h6.54c3.33 0 5.74-1.71 6.44-5.43l1.41-8.15c.71-3.72-1.1-5.43-4.42-5.43h-6.54zM420.09 142.22H390.4l12.38-70.43h11.67l-10.56 60h18zM426.75 142.22l12.37-70.43h11.67l-12.37 70.43zM490.65 82.05h-11.78l-10.56 60.17h-11.67l10.56-60.17h-11.77l1.81-10.26h35.22zM516.83 142.22h-33.71l1.41-8.05 29.88-52.73h-19.32l1.62-9.65h31.79l-1.41 8-29.78 52.72h21.23zM333.53 119l-17-24.25 4.94-28.13L280.86 95l17 24.25-5 28.13z" class="cls-1" transform="translate(-71.5 -66.59)"/><path fill="#ffe000" d="M234.77 50.84L220.42 30.4l18.42-12.91-2.19 12.49 14.32 20.47-18.43 12.88 2.23-12.49z"/><path d="M117.18 86.68L115 99.26c-1.2 6.94-4.52 11.16-9.86 13.28l4.73 29.68H98.26l-3.92-28.08H88.1l-4.93 28.08H71.5l12.37-70.43h18.21c9.66 0 17.01 4.21 15.1 14.89zm-17.31-4.63h-6.13l-3.83 22h6.14c3.82 0 6.64-1.41 7.55-6.43l1.6-9.16c.91-5-1.5-6.41-5.33-6.41zM142.15 142.22l.31-15.7h-13l-5.23 15.7h-11.56l25.46-70.43h14.69l.9 70.43zm.41-25.36l.6-31.29-10.46 31.29zM188.36 96.94l1.81-9.86c.71-4.43-1.51-6-4.93-6s-6.24 1.61-6.94 6l-.91 4.83c-.6 3.62.1 5.63 3.12 7.55l7.45 4.62c6.24 3.93 9.66 7 8.35 14.09l-1.81 10.16c-1.92 10.86-8.25 14.69-19.92 14.69h-.21c-9.65 0-16.8-3.72-14.89-14.69l2.22-12.27h11.47l-1.91 10.66c-.71 4.33 1.4 6.24 4.92 6.24s6.44-1.91 7.15-6.24l1-5.53c.7-3.82-.21-5.84-3.32-7.75l-7.35-4.63c-5.43-3.42-9.36-7.85-8.25-14.09l1.59-9.25C168.94 75 176.28 71 187 71h.2c8.65 0 16.6 4 14.69 14.49l-2 11.47zM246.24 86.68l-2.32 13.38C242 110.73 235.27 115 223.6 115h-6.54l-4.83 27.27h-11.67l12.37-70.43h18.21c9.66-.05 17.01 4.16 15.1 14.84zm-12 1.81c.91-5-1.51-6.44-5.33-6.44h-6.14l-3.92 22.64H225c3.82 0 6.64-1.41 7.54-6.44zM244.35 142.22l12.38-70.43h11.67L256 142.22z" class="cls-1" transform="translate(-71.5 -66.59)"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB |
@@ -65,15 +65,37 @@ window.LNbits = {
|
||||
},
|
||||
events: {
|
||||
onInvoicePaid: function (wallet, cb) {
|
||||
if (!this.pis) {
|
||||
this.pis = new EventSource(
|
||||
let listener = ev => {
|
||||
cb(JSON.parse(ev.data))
|
||||
}
|
||||
|
||||
this.listenersCount = this.listenersCount || {[wallet.inkey]: 0}
|
||||
this.listenersCount[wallet.inkey]++
|
||||
|
||||
this.listeners = this.listeners || {}
|
||||
if (!(wallet.inkey in this.listeners)) {
|
||||
this.listeners[wallet.inkey] = new EventSource(
|
||||
'/api/v1/payments/sse?api-key=' + wallet.inkey
|
||||
)
|
||||
}
|
||||
|
||||
this.pis.addEventListener('payment-received', ev =>
|
||||
cb(JSON.parse(ev.data))
|
||||
this.listeners[wallet.inkey].addEventListener(
|
||||
'payment-received',
|
||||
listener
|
||||
)
|
||||
|
||||
return () => {
|
||||
this.listeners[wallet.inkey].removeEventListener(
|
||||
'payment-received',
|
||||
listener
|
||||
)
|
||||
this.listenersCount[wallet.inkey]--
|
||||
|
||||
if (this.listenersCount[wallet.inkey] <= 0) {
|
||||
this.listeners[wallet.inkey].close()
|
||||
delete this.listeners[wallet.inkey]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
href: {
|
||||
|
||||
@@ -9,7 +9,9 @@ from lnbits.core.crud import (
|
||||
get_payments,
|
||||
get_standalone_payment,
|
||||
delete_expired_invoices,
|
||||
get_balance_checks,
|
||||
)
|
||||
from lnbits.core.services import redeem_lnurl_withdraw
|
||||
|
||||
main_app: Optional[QuartTrio] = None
|
||||
|
||||
@@ -93,6 +95,14 @@ async def check_pending_payments():
|
||||
await trio.sleep(60 * 30) # every 30 minutes
|
||||
|
||||
|
||||
async def perform_balance_checks():
|
||||
while True:
|
||||
for bc in await get_balance_checks():
|
||||
redeem_lnurl_withdraw(bc.wallet, bc.url)
|
||||
|
||||
await trio.sleep(60 * 60 * 6) # every 6 hours
|
||||
|
||||
|
||||
async def invoice_callback_dispatcher(checking_id: str):
|
||||
payment = await get_standalone_payment(checking_id)
|
||||
if payment and payment.is_in:
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||
/>
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
{% block head_scripts %}{% endblock %}
|
||||
</head>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import trio # type: ignore
|
||||
import json
|
||||
import httpx
|
||||
from os import getenv
|
||||
@@ -116,16 +117,24 @@ class LNbitsWallet(Wallet):
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
url = f"{self.endpoint}/api/v1/payments/sse"
|
||||
|
||||
async with httpx.AsyncClient(timeout=None, headers=self.key) as client:
|
||||
async with client.stream("GET", url) as r:
|
||||
async for line in r.aiter_lines():
|
||||
if line.startswith("data:"):
|
||||
try:
|
||||
data = json.loads(line[5:])
|
||||
except json.decoder.JSONDecodeError:
|
||||
continue
|
||||
while True:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=None, headers=self.key) as client:
|
||||
async with client.stream("GET", url) as r:
|
||||
async for line in r.aiter_lines():
|
||||
if line.startswith("data:"):
|
||||
try:
|
||||
data = json.loads(line[5:])
|
||||
except json.decoder.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if type(data) is not list or len(data) < 9:
|
||||
continue
|
||||
if type(data) is not dict:
|
||||
continue
|
||||
|
||||
yield data[8] # payment_hash
|
||||
yield data["payment_hash"] # payment_hash
|
||||
|
||||
except (OSError, httpx.ReadError, httpx.ConnectError, httpx.ReadTimeout):
|
||||
pass
|
||||
|
||||
print("lost connection to lnbits /payments/sse, retrying in 5 seconds")
|
||||
await trio.sleep(5)
|
||||
|
||||
@@ -102,12 +102,8 @@ class LndRestWallet(Wallet):
|
||||
timeout=180,
|
||||
)
|
||||
|
||||
if r.is_error:
|
||||
error_message = r.text
|
||||
try:
|
||||
error_message = r.json()["error"]
|
||||
except:
|
||||
pass
|
||||
if r.is_error or r.json().get("payment_error"):
|
||||
error_message = r.json().get("payment_error") or r.text
|
||||
return PaymentResponse(False, None, 0, None, error_message)
|
||||
|
||||
data = r.json()
|
||||
|
||||
@@ -146,7 +146,7 @@ class LntxbotWallet(Wallet):
|
||||
data = json.loads(line[5:])
|
||||
if "payment_hash" in data and data.get("msatoshi") > 0:
|
||||
yield data["payment_hash"]
|
||||
except (OSError, httpx.ReadError):
|
||||
except (OSError, httpx.ReadError, httpx.ReadTimeout, httpx.ConnectError):
|
||||
pass
|
||||
|
||||
print("lost connection to lntxbot /payments/stream, retrying in 5 seconds")
|
||||
|
||||
@@ -49,7 +49,7 @@ class SparkWallet(Wallet):
|
||||
timeout=40,
|
||||
)
|
||||
except (OSError, httpx.ConnectError, httpx.RequestError) as exc:
|
||||
raise SparkError("error connecting to spark: " + str(exc))
|
||||
raise UnknownError("error connecting to spark: " + str(exc))
|
||||
|
||||
try:
|
||||
data = r.json()
|
||||
@@ -123,7 +123,7 @@ class SparkWallet(Wallet):
|
||||
payment_hash = pay["payment_hash"]
|
||||
|
||||
if len(pays) > 1:
|
||||
raise Exception(
|
||||
raise SparkError(
|
||||
f"listpays({payment_hash}) returned an unexpected response: {listpays}"
|
||||
)
|
||||
|
||||
@@ -195,7 +195,7 @@ class SparkWallet(Wallet):
|
||||
data = json.loads(line[5:])
|
||||
if "pay_index" in data and data.get("status") == "paid":
|
||||
yield data["label"]
|
||||
except (OSError, httpx.ReadError, httpx.ConnectError):
|
||||
except (OSError, httpx.ReadError, httpx.ConnectError, httpx.ReadTimeout):
|
||||
pass
|
||||
|
||||
print("lost connection to spark /stream, retrying in 5 seconds")
|
||||
|
||||
@@ -5,34 +5,35 @@ bech32==1.2.0
|
||||
bitstring==3.1.7
|
||||
blinker==1.4
|
||||
brotli==1.0.9
|
||||
cerberus==1.3.2
|
||||
cerberus==1.3.3
|
||||
certifi==2020.12.5
|
||||
click==7.1.2
|
||||
ecdsa==0.16.1
|
||||
environs==9.3.1
|
||||
embit==0.2.1
|
||||
environs==9.3.2
|
||||
h11==0.12.0
|
||||
h2==4.0.0
|
||||
hpack==4.0.0
|
||||
httpcore==0.12.3
|
||||
httpx==0.16.1
|
||||
httpx==0.17.1
|
||||
hypercorn==0.11.2
|
||||
hyperframe==6.0.0
|
||||
idna==3.1
|
||||
itsdangerous==1.1.0
|
||||
jinja2==2.11.3
|
||||
lnurl==0.3.5
|
||||
lnurl==0.3.6
|
||||
markupsafe==1.1.1
|
||||
marshmallow==3.10.0
|
||||
marshmallow==3.11.1
|
||||
outcome==1.1.0
|
||||
priority==1.3.0
|
||||
pydantic==1.8
|
||||
pydantic==1.8.1
|
||||
pypng==0.0.20
|
||||
pyqrcode==1.2.1
|
||||
pyscss==1.3.7
|
||||
python-dotenv==0.15.0
|
||||
python-dotenv==0.17.0
|
||||
quart==0.14.1
|
||||
quart-compress==0.2.1
|
||||
quart-cors==0.3.0
|
||||
quart-cors==0.4.0
|
||||
quart-trio==0.7.0
|
||||
represent==1.6.0.post0
|
||||
rfc3986==1.4.0
|
||||
|
||||