Compare commits

..
111 Commits
Author SHA1 Message Date
fiatjaf bcecf6d431 handle invalid lnurl-w responses on redeem_lnurl_withdraw. 2021-04-23 19:24:04 -03:00
Ben Arc 7134821fcd disable submit if no button 2021-04-22 09:07:35 +01:00
fiatjaf b55cc82f1c evade lnurl encoding error for withdraw_full QR on localhost. 2021-04-21 23:27:57 -03:00
fiatjaf 2aa4a93da1 handle empty lnurl-withdraws to redeem. 2021-04-21 23:27:57 -03:00
fiatjaf 232de067a4 expect more errors from SSE connection. 2021-04-21 23:27:57 -03:00
Ben Arc daff019920 Fixed usermanager ext 2021-04-21 14:02:52 +01:00
Ben Arc 6ae3690797 Fixed usermanager endpoint 2021-04-21 13:41:00 +01:00
Ben Arc 33e2bdb7fb Fixed usermanager ext 2021-04-21 13:34:50 +01:00
Ben Arc d908f912ee Fixed user manager api 2021-04-21 13:12:16 +01:00
ArcandGitHub e7bb78b0c7 Update README.md 2021-04-21 09:50:11 +01:00
Pacandfiatjaf ffe39de391 API link fix for List Links docs (from v0 to v1) 2021-04-18 10:37:14 -03:00
fiatjaf 3d489bf2ee make it so LNbitsWallet reconnects if the listener stream goes off. 2021-04-18 10:29:22 -03:00
fiatjaf 42b3359d12 fix invoice listener for LNbitsWallet. 2021-04-18 00:05:45 -03:00
fiatjaf cbeb888761 fix lnurlwallet flow. 2021-04-17 23:44:26 -03:00
fiatjaf a413685160 fix redeem_lnurl_withdraw code. 2021-04-17 23:30:26 -03:00
fiatjaf 943237e7ff prettier fixes. 2021-04-17 23:21:27 -03:00
fiatjaf efd9c6917f lnurl balanceCheck and balanceNotify. 2021-04-17 23:21:19 -03:00
fiatjaf f08d86c6df livestream: a standalone QR code for each track. 2021-04-17 00:15:03 -03:00
fiatjaf 27e170d304 livestream: allow tracks to be edited. 2021-04-17 00:14:34 -03:00
Ben Arc a3bd36d44f Fixed 'runs on' images 2021-04-13 21:42:53 +01:00
1af3964039 captcha extension will use lnbits instance (#178)
Co-authored-by: pseudozach <git@pseudozach.com>
2021-04-13 16:18:14 -03:00
fiatjaf 808ded509d fix pipfile.lock and update dependencies. 2021-04-13 11:50:30 -03:00
fiatjaf 0f998419e7 basic webmanifest thing, must be improved. 2021-04-13 11:50:30 -03:00
Ben Arc 05a0861601 Added funding sources to homepage 2021-04-13 15:43:39 +01:00
Ben Arc aa2e09bbeb Hidden api keys to prevent over shoulder attacks 2021-04-13 11:10:21 +01:00
Ben Arc 9f7a97f58e Fixed ticket link button 2021-04-13 11:04:59 +01:00
Ben Arc 3d54de7976 Updated Events form 2021-04-13 10:48:28 +01:00
Ben Arc 86c03fa5d9 Deleted some prints 2021-04-13 00:18:10 +01:00
Ben Arc 237a6b369f Updated livestream description to character limit 2021-04-12 17:47:54 +01:00
ArcandGitHub 9fa05e43f9 Merge pull request #171 from lnbits/satspay
Adds WatchOnly wallet and SatsPayServer extensions
2021-04-12 17:42:54 +01:00
Ben Arc 5eba071acc Updated pip 2021-04-12 17:35:57 +01:00
ArcandGitHub 94acbd84e0 Merge pull request #172 from lnbits/livestream
DJ Livestream extension
2021-04-12 17:25:54 +01:00
Ben Arc 49827a64da Black formatting 2021-04-12 17:20:50 +01:00
ArcandGitHub 63da1b0962 Merge pull request #176 from tkijewski/master
Update default LNPay.co endpoints

Thanks for the update!
2021-04-12 17:18:55 +01:00
Ben Arc 492d710bb8 Added api docs 2021-04-12 17:14:55 +01:00
Tim KandGitHub b9b8e6a162 Update LNPay endpoint 2021-04-11 16:06:39 -04:00
Tim KandGitHub 74074d148e Update LNPay endpoint 2021-04-11 16:06:08 -04:00
Tim KandGitHub f131e18969 Update LNPay endpoint 2021-04-11 16:05:21 -04:00
fiatjaf da9723735c fix: livestream producer sharing was conflicting with internal payments. 2021-04-11 00:44:55 -03:00
fiatjaf 2a198d3186 metadata and hide. 2021-04-11 00:44:55 -03:00
fiatjaf c6f4372d23 pass the full payment_hash to the lnurl success action URL. 2021-04-11 00:44:55 -03:00
fiatjaf 529717a0f6 livestream dialog at the middle. 2021-04-11 00:44:55 -03:00
fiatjaf 69f867f692 make the livestream text more neutral. 2021-04-11 00:44:55 -03:00
fiatjaf 52e7a28fc2 make price optional then fix min/max issues. 2021-04-11 00:44:55 -03:00
fiatjaf 42cf70e158 conditional buys: get a download link when paying over x. 2021-04-11 00:44:55 -03:00
fiatjaf a3fbcd46cd livestream: notifications and comments. 2021-04-11 00:44:55 -03:00
fiatjaf e17b9422ad add price to track description. 2021-04-11 00:44:55 -03:00
fiatjaf 76199c66a1 livestream extension.
(except docs)
2021-04-11 00:44:55 -03:00
fiatjafandGitHub 10671e5732 Merge pull request #174 from pseudozach/master 2021-04-11 00:30:11 -03:00
fiatjafandGitHub 95a32b0d7b Merge branch 'master' into master 2021-04-11 00:13:10 -03:00
pseudozach 980bb69151 fix localhost:5000 in captcha.js 2021-04-10 20:07:36 -07:00
fiatjaf e38b945d5c fix some of mypy complaints. 2021-04-10 18:06:41 -03:00
fiatjaf fdf4f6c1ae catch errors in multiple places that might be destroying the async flow and causing lnbits to die silently. 2021-04-10 17:37:48 -03:00
Ben Arc 61f6e2a4f2 Fixed prettier (again) 2021-04-09 00:49:01 +01:00
Ben Arc d872f0d138 black formatting 2021-04-09 00:41:19 +01:00
Ben Arc 6a18766a82 Fixed prettier 2021-04-09 00:36:56 +01:00
Ben Arc f84e965e13 Merge remote-tracking branch 'origin/satspay' into satspay 2021-04-09 00:25:50 +01:00
Ben Arc cbdf97f949 Merge remote-tracking branch 'origin/master' into satspay 2021-04-09 00:24:23 +01:00
Ben Arc 4219513a8a Cleaned up api 2021-04-09 00:22:52 +01:00
Ben Arc 64bb1b5444 Merge branch 'watchonly' into satspay 2021-04-08 23:24:14 +01:00
ArcandGitHub 70a0ec8a1e Merge pull request #169 from stepansnigirev/watchonly
add descriptor support, more checks of user input
2021-04-08 23:23:57 +01:00
fiatjafandGitHub b48a44bcd0 Merge pull request #170 from gitnonymous/fullscreen_app
added meta-tags to base.html to allow fullscreen app launch on mobiles.
2021-04-08 12:43:51 -03:00
davidfrayne c26f6fcbdf added meta-tags to base.html to allow fullscreen app launch on mobiles. 2021-04-08 13:46:52 +01:00
Ben Arc 6a7c28eb0e Deleted some prints 2021-04-08 01:34:44 +01:00
Ben Arc 8d3d828e49 Some formatting 2021-04-08 01:04:55 +01:00
Ben Arc b7090d963b Removed ability to update charge, as caused too many issues 2021-04-08 01:01:10 +01:00
Ben Arc 2d8f85f77c SatsPayServer works apart from updating charges 2021-04-07 22:51:33 +01:00
Stepan Snigirev a27a3885f3 add descriptor support 2021-04-07 19:27:26 +02:00
Ben Arc 90319bfd8c Satspay mostly working 2021-04-07 01:50:38 +01:00
fiatjaf 82731dc901 a special error for when payments fail. 2021-04-06 14:57:51 -03:00
fiatjaf 59aaea47fd lndrest: capture the pay error properly. 2021-04-06 10:22:37 -03:00
Ben Arc f5d06653a6 Counted working 2021-04-05 01:48:35 +01:00
Ben Arc 744662fb63 Started fixing endpoints 2021-04-05 00:10:54 +01:00
Ben Arc 55748b427b Initial commit 2021-04-04 18:20:52 +01:00
Ben Arc 96082978d1 Merge remote-tracking branch 'origin/master' into watchonly 2021-04-04 13:35:42 +01:00
Ben Arc d7c3c4e51c Removed satspay ext 2021-04-04 13:32:03 +01:00
Ben Arc ed5aeccb8e remove display.html 2021-04-04 13:30:59 +01:00
Ben Arc b2841ee909 prettier and black 2021-04-04 13:28:46 +01:00
Ben Arc 69ef4d7e63 Cleaned up watchonly 2021-04-04 13:17:24 +01:00
Ben Arc ae5d45a73e Get wallets 2021-04-02 23:38:40 +01:00
Ben Arc 6f42413107 Added countedown 2021-04-01 02:31:35 +01:00
fiatjaf 946279674a stop bloating the logs with checking routine. 2021-03-31 20:24:46 -03:00
fiatjaf 11679a4fa7 actually do the fee abs() thing on the balances view. 2021-03-31 20:22:34 -03:00
Ben Arc 045e069312 All fees are negative 2021-04-01 00:02:24 +01:00
Ben Arc 0db516b6e0 watchonly working, satspay broken 2021-03-31 23:49:36 +01:00
Ben Arc b05b8c0115 Merge remote-tracking branch 'origin/master' into watchonly 2021-03-31 10:43:55 +01:00
benarc 569f43fe81 Satspay display link 2021-02-23 20:42:24 +00:00
benarc 5c894ff707 Adding satspay docs, making same as btcpay 2021-02-23 19:35:17 +00:00
benarc d95159d73b BIP44/49/84 supported 💪 2021-02-23 19:13:21 +00:00
benarc 198cff8449 Loads now, but still broken 2021-02-23 15:26:17 +00:00
benarc 81326b2368 Still have broken watchonly, but addresses added 2021-02-23 15:19:51 +00:00
benarc da20588c84 Trying to restore addresses functions, broken 2021-02-23 14:47:49 +00:00
benarc be6ee64b4e Cleaning up watchonly 2021-02-23 14:31:05 +00:00
benarc 6e4f8431b7 Auto stash before checking out "HEAD" 2021-02-22 01:14:46 +00:00
benarc a93903a183 added address dialogue 2021-02-22 01:14:28 +00:00
benarc 385406a78a Watchonly statspay basic gui working 2021-02-22 00:45:52 +00:00
benarc ecfcc167f0 Splitting into x2 extensions
Splitting extension into 2, SatsPay Server, a BTCPay Server type extension, and WatchOnly
2021-02-21 17:53:43 +00:00
benarc 9724f33111 small bug 2021-02-21 17:00:13 +00:00
ArcandGitHub 4425c43726 Nothing to see here 2021-02-21 13:45:41 +00:00
benarc 9c8836096c switched requests for httpx 2021-02-21 13:25:54 +00:00
ArcandGitHub ca60893701 Merge pull request #151 from lnbits/master
Bring branch up to date
2021-02-21 13:14:42 +00:00
benarc 7d44fa1c2d Less broken refactor 2020-12-04 10:15:46 +00:00
benarc 4c7c792390 Trying to fix 2020-12-04 10:07:59 +00:00
benarc 54c0c9f3a8 Big, broken refactor payments/addresses to charges 2020-12-04 09:59:42 +00:00
benarc 257c9c1c64 Can fetch balance from mem.space 2020-12-03 22:56:34 +00:00
benarc 2db3325727 Added paylinks to table 2020-12-03 18:37:45 +00:00
benarc c62678b3aa generated fresh addresses nicely 2020-12-03 17:26:11 +00:00
benarc 48cf23346c Added get fresh address button 2020-12-02 22:47:33 +00:00
benarc 913f2a37c1 Awaits seem to be working in watchonly 2020-12-01 21:39:33 +00:00
benarc 93417e5bdf added awaits 2020-12-01 20:29:21 +00:00
benarc a838706090 updated watchonly 2020-12-01 19:54:16 +00:00
97 changed files with 4965 additions and 452 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+3 -1
View File
@@ -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"
Generated
+137 -173
View File
@@ -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": [
+1 -1
View File
@@ -10,7 +10,7 @@ LNbits
![Lightning network wallet](https://i.imgur.com/EHvK6Lq.png)
# 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!
+2 -2
View File
@@ -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
+79 -4
View File
@@ -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
+55
View File
@@ -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)
);
"""
)
+35 -4
View File
@@ -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"])
+66 -20
View File
@@ -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))
+3
View File
@@ -211,6 +211,9 @@ new Vue({
}
},
methods: {
paymentTableRowKey: function (row) {
return row.payment_hash + row.amount
},
closeCamera: function () {
this.parse.camera.show = false
},
+15
View File
@@ -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
+75 -15
View File
@@ -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>
+39 -9
View File
@@ -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">
+44 -12
View File
@@ -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))
+109 -31
View File
@@ -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
],
}
)
+9 -6
View File
@@ -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,
+1 -1
View File
@@ -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
+7 -4
View File
@@ -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"
+13
View File
@@ -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://cdn.shopify.com/s/files/1/0826/9235/files/cryptograffiti_logo_clear_background.png?v=1504730421)](https://cryptograffiti.com/)
+19
View File
@@ -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))
+10
View File
@@ -0,0 +1,10 @@
{
"name": "DJ Livestream",
"short_description": "Sell tracks and split revenue (lnurl-pay)",
"icon": "speaker",
"contributors": [
"fiatjaf",
"cryptograffiti"
],
"hidden": false
}
+180
View File
@@ -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]
+114
View File
@@ -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
);
"""
)
+81
View File
@@ -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()
}
})
+89
View File
@@ -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": &lt;invoice_key&gt;}</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>[&lt;livestream_object&gt;, ...]</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/&lt;track_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</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/&lt;track_id&gt; -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/&lt;fee_pct&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</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/&lt;fee_pct&gt; -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": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"name": &lt;string&gt;, "download_url": &lt;string&gt;,
"price_msat": &lt;integer&gt;, "producer_id": &lt;integer&gt;,
"producer_name": &lt;string&gt;}</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": &lt;string&gt;, "download_url": &lt;string&gt;,
"price_msat": &lt;integer&gt;, "producer_id": &lt;integer&gt;,
"producer_name": &lt;string&gt;}' -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/&lt;track_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</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/&lt;track_id&gt; -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 %}
+38
View File
@@ -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)
+135
View File
@@ -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
+15 -14
View File
@@ -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]:
+10 -6
View File
@@ -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>[&lt;pay_link_object&gt;, ...]</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>
+13 -9
View File
@@ -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,
+4
View File
@@ -0,0 +1,4 @@
# SatsPay Server
Create onchain and LN charges. Includes webhooks!
+13
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
{
"name": "SatsPayServer",
"short_description": "Create onchain and LN charges",
"icon": "payment",
"contributors": [
"arcbtc"
]
}
+128
View File
@@ -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
+26
View File
@@ -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'))
);
"""
)
+39
View File
@@ -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": &lt;admin_key&gt;}</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>[&lt;charge_object&gt;, ...]</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": &lt;string, watchonly_wallet_id&gt;,
"description": &lt;string&gt;, "webhook":&lt;string&gt;, "time":
&lt;integer&gt;, "amount": &lt;integer&gt;, "lnbitswallet":
&lt;string, lnbits_wallet_id&gt;}' -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/&lt;charge_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</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>[&lt;charge_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}api/v1/charge/&lt;charge_id&gt;
-d '{"onchainwallet": &lt;string, watchonly_wallet_id&gt;,
"description": &lt;string&gt;, "webhook":&lt;string&gt;, "time":
&lt;integer&gt;, "amount": &lt;integer&gt;, "lnbitswallet":
&lt;string, lnbits_wallet_id&gt;}' -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/&lt;charge_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</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>[&lt;charge_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}api/v1/charge/&lt;charge_id&gt;
-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": &lt;invoice_key&gt;}</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>[&lt;charge_object&gt;, ...]</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/&lt;charge_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</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/&lt;charge_id&gt; -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/&lt;charge_id&gt;</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>[&lt;charge_object&gt;, ...]</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/&lt;charge_id&gt; -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 %}
+22
View File
@@ -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)
+157
View File
@@ -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
+9 -10
View File
@@ -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:
+11 -12
View File
@@ -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
+8 -3
View File
@@ -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
)
+5
View File
@@ -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))
+24 -14
View File
@@ -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"])
+4
View File
@@ -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.
+13
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
{
"name": "Watch Only",
"short_description": "Onchain watch only wallets",
"icon": "visibility",
"contributors": [
"arcbtc"
]
}
+199
View File
@@ -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
+36
View File
@@ -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
);
"""
)
+35
View File
@@ -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": &lt;invoice_key&gt;}</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>[&lt;wallets_object&gt;, ...]</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/&lt;wallet_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</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>[&lt;wallet_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}api/v1/wallet/&lt;wallet_id&gt;
-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": &lt;admin_key&gt;}</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>[&lt;wallet_object&gt;, ...]</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":
&lt;string&gt;, "masterpub": &lt;string&gt;}' -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/&lt;wallet_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</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/&lt;wallet_id&gt; -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/&lt;wallet_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</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>[&lt;address_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root
}}api/v1/addresses/&lt;wallet_id&gt; -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/&lt;wallet_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</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>[&lt;address_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}api/v1/address/&lt;wallet_id&gt;
-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": &lt;admin_key&gt;}</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>[&lt;mempool_object&gt;, ...]</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": &lt;admin_key&gt;}</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>[&lt;mempool_object&gt;, ...]</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":
&lt;string&gt;}' -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 %}
+22
View File
@@ -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)
+138
View File
@@ -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
+4 -5
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

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
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

+26 -4
View File
@@ -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: {
+10
View File
@@ -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:
+2
View File
@@ -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>
+20 -11
View File
@@ -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)
+2 -6
View File
@@ -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()
+1 -1
View File
@@ -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")
+3 -3
View File
@@ -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")
+9 -8
View File
@@ -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