Compare commits

...
216 Commits
Author SHA1 Message Date
ArcandGitHub 3b98a10c9e Merge pull request #728 from motorina0/ext_satspay_onchain_fix
`satspay` extension improvements
2022-07-28 14:46:08 +01:00
Vlad Stan ffcff3f441 fix: rebase issues 2022-07-28 16:37:42 +03:00
Vlad Stan 191124d8fc chore: code format 2022-07-28 16:34:42 +03:00
calleandGitHub f22e1e16af build instruction (#800) 2022-07-28 15:26:43 +02:00
Vlad Stan 66a3b24dd5 chore: code clean-up 2022-07-28 15:35:54 +03:00
Vlad Stan 809d03d3db fix: at least one charge must be active in order to fetch balance 2022-07-28 15:35:54 +03:00
Vlad Stan f9fd071315 feat: update API doc to use swagger 2022-07-28 15:35:54 +03:00
Vlad Stan 739c108bdf feat: add (hh:mm) to time columns 2022-07-28 15:35:39 +03:00
Vlad Stan c419afc1b1 refactor: code clean-up 2022-07-28 15:35:39 +03:00
Vlad Stan 46a7b54a00 fix: fix progress for charges 2022-07-28 15:35:39 +03:00
Vlad Stan 0f204485c3 feat: return time_elapsed, time_left and paid fields 2022-07-28 15:35:39 +03:00
Vlad Stan 8c6b31ec12 feat: mapCharge now computes fields progress, timeLeft and fixes time 2022-07-28 15:35:39 +03:00
Vlad Stan d79d4e371e feat: use datetime to compute time_left and time_elapsed 2022-07-28 15:35:39 +03:00
Vlad Stan e4078504ba fix: do not update charge if balance has not changed 2022-07-28 15:35:39 +03:00
Vlad Stan 25f37b0e4f chore: code clean-up 2022-07-28 15:35:39 +03:00
Vlad Stan 057de41678 feat: only refresh balance on the back-end if new funds are detected by the front-end refresh 2022-07-28 15:35:39 +03:00
Vlad Stan 31b6a9410c refactor: move setInterval outside method logic 2022-07-28 15:35:39 +03:00
Vlad Stan e960285c8f chore: code format 2022-07-28 15:35:39 +03:00
Vlad Stan 86f7ea64c4 fix: use the config mempool.space endpoint 2022-07-28 15:35:39 +03:00
Vlad Stan ac3f95e6c2 feat: use custom mempool_endpoint for the charge details 2022-07-28 15:35:39 +03:00
Vlad Stan 4cb54ce549 feat: allow refresh balance for individual charge
- expired or paid charges are not auto-refreshed
2022-07-28 15:35:39 +03:00
Vlad Stan 5a3e1d7fbb feat: add expanding details to rows 2022-07-28 15:35:39 +03:00
Vlad Stan 4fa8b5a292 feat: enforce order for charges ORDER BY "timestamp" DESC 2022-07-28 15:35:39 +03:00
Vlad Stan ccae7876d9 feat: show status as badge 2022-07-28 15:35:39 +03:00
Vlad Stan cce341fb39 fix: search and export csv layout 2022-07-28 15:35:39 +03:00
Vlad Stan 5317d5afd5 feat: use multiple charges balance endpoint (one call only) 2022-07-28 15:35:39 +03:00
Vlad Stan 6da62c648a feat: use single charge endpoint for balance 2022-07-28 15:35:39 +03:00
Vlad Stan 40f5cf77f6 feat: add endpoint for multiple charges balance; 2022-07-28 15:35:39 +03:00
Vlad Stan 475fd566e6 fix: remove retry logic for pending funds 2022-07-28 15:35:39 +03:00
Vlad Stan a9506cee11 feat: show pending funds 2022-07-28 15:35:39 +03:00
Vlad Stan efbf34a51e feat: show message when new payment detected 2022-07-28 15:35:39 +03:00
Vlad Stan 0cd8ba956a feat: more UI polishing 2022-07-28 15:35:39 +03:00
Vlad Stan 05bc887ad5 feat: make the panels responsive 2022-07-28 15:35:39 +03:00
Vlad Stan 80e1545920 core: code format 2022-07-28 15:35:39 +03:00
Vlad Stan 728209da7d refactor: replace mempool internal API with a more generic config API 2022-07-28 15:35:39 +03:00
Vlad Stan 3bd4253292 feat: use bootstrap tables for the layout (not fixed px values) 2022-07-28 15:34:37 +03:00
Vlad Stan 912a7e1c4b feat: add web-socket to mempool; & replace UI mappings 2022-07-28 15:34:37 +03:00
Vlad Stan d74b48b0b6 refactor: method renamings 2022-07-28 15:34:37 +03:00
Vlad Stan 615520bcb3 feat: separate invoice check from onchain check 2022-07-28 15:34:36 +03:00
Vlad Stan 1dd51a2752 refactor: extract methods to utils 2022-07-28 15:34:36 +03:00
Vlad Stan e3d36e6a3a feat: extract utils to static/js 2022-07-28 15:34:36 +03:00
Vlad Stan 09a9a0f9ec refactor: remove extra state fields 2022-07-28 15:34:36 +03:00
Vlad Stan 74322232d0 fix: add await for rescan 2022-07-28 15:34:36 +03:00
Vlad Stan 91c6d32852 feat: do client side rescan for onchain address 2022-07-28 15:34:36 +03:00
Vlad Stan 6c21bf359c choe: format 2022-07-28 15:34:36 +03:00
Vlad Stan b5e97f08a6 chore: code clean-up 2022-07-28 15:34:36 +03:00
Vlad Stan 8e05a11978 refactor: replace var with const 2022-07-28 15:34:36 +03:00
Vlad Stan 50b2dcfcaa refactor: clean-up unnecessary self 2022-07-28 15:34:36 +03:00
Vlad Stan 6cfa8e30bc refactor: replace promises with async/await 2022-07-28 15:34:36 +03:00
Vlad Stan 5a005e830c chore: add a todo 2022-07-28 15:34:36 +03:00
Vlad Stan d93b199735 fix: pass charge param as JSON 2022-07-28 15:34:36 +03:00
ArcandGitHub dc501d3f40 Merge pull request #677 from lnbits/SCRUB
SCRUB extension
2022-07-28 13:29:04 +01:00
ArcandGitHub 6c63d3000f Merge pull request #699 from motorina0/ext_watch_only_phase_2
onchain watch-only extension - phase 2
2022-07-28 13:22:51 +01:00
callebtc 370a962528 use ours 2022-07-28 14:18:32 +02:00
callebtc 885c690cc6 Merge branch 'main' into SCRUB 2022-07-28 14:15:49 +02:00
callebtc 3d417bc40a merge 2022-07-28 14:14:59 +02:00
Vlad Stan 87dc9136a4 chore: code format 2022-07-28 15:09:23 +03:00
Vlad Stan c1f6845ad9 feat: update SQLite to Postgre conversion scripts 2022-07-28 15:04:14 +03:00
Vlad Stan 3f51117ea9 refactor: move logic out of crud into the api layer 2022-07-28 15:04:14 +03:00
Vlad Stan 3d1b3b7173 fix: set default value for NOT NULL column 2022-07-28 15:04:14 +03:00
Vlad Stan cff9a59693 doc: fix typos 2022-07-28 15:04:14 +03:00
Vlad Stan 5bc2907f80 doc: update readme 2022-07-28 15:04:14 +03:00
Vlad Stan 7bb936b09b feat: allow custom file name for exportCSV 2022-07-28 15:04:14 +03:00
Vlad Stan f72421c62c chore: code clean-up 2022-07-28 15:04:14 +03:00
Vlad Stan cf75df366a fix: refresh change address when wallet is created/deleted 2022-07-28 15:04:14 +03:00
Vlad Stan 23954b501f chore: code format 2022-07-28 15:04:14 +03:00
Vlad Stan 98366cfaf1 refactor: remove snake_case from JS code 2022-07-28 15:04:13 +03:00
Vlad Stan ee2342e1ba fix: adress can have activity even if the current amount is zero 2022-07-28 15:04:13 +03:00
Vlad Stan c1289c43a4 feat: add on-chain watch-only functionality 2022-07-28 15:04:13 +03:00
Vlad Stan 92e7dde500 doc: update extension name 2022-07-28 15:04:13 +03:00
Vlad Stan 085bcb8234 doc: reference Swagger documentation for REST APIs 2022-07-28 15:04:13 +03:00
Vlad Stan 8e4e0bdf2d feat: update addresses and wallets table; add config table 2022-07-28 15:03:22 +03:00
Vlad Stan a2f48de583 doc: update extension name Watch Only -> Onchain Wallet 2022-07-28 15:03:22 +03:00
Vlad Stan dec8d4b6b3 feat: add support for static files 2022-07-28 15:03:22 +03:00
Vlad Stan 2b5fc82110 feat: update text Watch-Only -> Onchain Wallet (watch-only) 2022-07-28 15:03:22 +03:00
ArcandGitHub 2ef9548b05 Merge pull request #791 from lnbits/fix/offlineshop_fiat_decimals
Offlineshop - allow for decimals in fiat unit
2022-07-28 12:47:02 +01:00
ben 184da9958c Merge branch 'main' into SCRUB 2022-07-28 12:46:19 +01:00
ArcandGitHub ad3e1728cb Merge pull request #596 from lnbits/qrcodemaker
Adds generic QR maker endpoint, url can be embedded
2022-07-28 12:43:33 +01:00
ben 618717b529 Merge branch 'main' into qrcodemaker 2022-07-28 12:40:25 +01:00
ArcandGitHub 261a156fa3 Merge pull request #799 from lnbits/Removepipenv
Bye pipenv
2022-07-28 12:38:55 +01:00
Tiago vasconcelos a57850f391 update offline dbversion in mock data 2022-07-28 12:09:20 +01:00
ben a7d27467f0 Updated install docs 2022-07-28 11:43:31 +01:00
ben 19b6acf932 removed pipenv tests 2022-07-28 11:23:03 +01:00
ben eafefce709 Bye pipenv
Its not personal its just business
2022-07-28 11:19:30 +01:00
a3a183b86c fix test warnings when using < python3.8 (#798)
* jinja warning

* fix regex

Co-authored-by: dni <dni.khr@gmail.com>
2022-07-28 12:15:27 +02:00
Tiago vasconcelos cf11b6c42d Merge remote-tracking branch 'lnbits/main' into fix/offlineshop_fiat_decimals 2022-07-28 11:10:03 +01:00
Tiago vasconcelos 4ca701a442 Merge remote-tracking branch 'lnbits/fix/offlineshop_fiat_decimals' into fix/offlineshop_fiat_decimals 2022-07-28 11:09:06 +01:00
Tiago vasconcelos 56cac668ca add mock data and fix conv 2022-07-28 11:08:55 +01:00
Tiago vasconcelos a601e178ea add missing param in add item 2022-07-28 11:08:15 +01:00
ArcandGitHub 04114c82f2 Update installation.md 2022-07-28 11:07:44 +01:00
ArcandGitHub 7f79b5ac7f Update installation.md 2022-07-28 11:06:42 +01:00
ben 4199f1bf98 Make format 2022-07-28 11:02:49 +01:00
callebtc 51dac0275c Merge branch 'main' of github.com:lnbits/lnbits-legend 2022-07-28 11:55:06 +02:00
calleandGitHub 730add5116 Core/tag check get (#797)
* safer tag checking

* annotate
2022-07-28 11:53:40 +02:00
ben 4c8641fe00 Merge remote-tracking branch 'origin/main' into qrcodemaker 2022-07-28 10:48:12 +01:00
callebtc dab109614d tag check 2022-07-28 11:41:55 +02:00
ArcandGitHub 9a9f3ae809 Merge pull request #795 from dni/patch-2
use main branch of regtest repo in gh workflow
2022-07-28 10:38:23 +01:00
ben 822373a763 Merge remote-tracking branch 'origin/main' into patch-2 2022-07-28 10:36:47 +01:00
ArcandGitHub 4441837cb7 Merge pull request #760 from dni/fix/mypy
WIP: Enable `mypy` linting of the entire code base
2022-07-28 10:33:33 +01:00
ben 85db59c359 make format 2022-07-28 10:32:17 +01:00
ben fa0649646c Merge remote-tracking branch 'origin/main' into fix/offlineshop_fiat_decimals 2022-07-28 10:31:28 +01:00
Tiago vasconcelos 7f8ee461a2 stupid 2022-07-28 10:27:31 +01:00
Tiago vasconcelos d322e6d4dc fix conv typo in scrub schema 2022-07-28 10:24:50 +01:00
callebtc c1278dbef9 Merge branch 'main' into SCRUB 2022-07-28 11:20:44 +02:00
dni ⚡andGitHub 3ec9cb6c89 use main branch of regtest 2022-07-28 11:11:54 +02:00
Tiago vasconcelos 556eef35ab add scrub mock data 2022-07-28 10:10:23 +01:00
783502ebf8 faster regtest, docker build is not needed anymore (#794)
* docker build is not needed anymore as regtest now does not include lnbits

* faster regtest, because docker build -t lnbits . is not needed anymore

Co-authored-by: dni <dni.khr@gmail.com>
2022-07-28 11:09:03 +02:00
Tiago vasconcelos c340a55925 add scrub to conv 2022-07-28 10:00:53 +01:00
ArcandGitHub f5a3f5b503 Update README.md 2022-07-28 09:30:52 +01:00
Tiago vasconcelos 254fa67859 allow for decimals in fiat unit 2022-07-27 22:49:23 +01:00
ben bfb4006b02 make format 2022-07-27 20:22:05 +01:00
ben b60ad6974c Merge branch 'main' into SCRUB 2022-07-27 20:21:37 +01:00
ben 354fedc0b8 prettier/black 2022-07-27 20:16:32 +01:00
ben 817472c036 Merge branch 'main' into fix/mypy 2022-07-27 19:57:23 +01:00
callebtc 521c821f6b Merge branch 'main' of github.com:lnbits/lnbits-legend 2022-07-27 15:42:53 +02:00
dni c72834e47e revert attribute check 2022-07-27 09:50:16 +02:00
dni 7da496dbb4 remove asserts and add ignore instead for now 2022-07-27 09:39:52 +02:00
dni 347baa076a Merge branch 'main' into fix/mypy 2022-07-26 13:00:06 +02:00
dni 36cb1eb47b fix int type by changing the order 2022-07-26 12:46:43 +02:00
dni 7667c2f531 doesnt touch class invoice and leave fake wallet as is 2022-07-26 12:31:29 +02:00
callebtc 80dd330ec0 Merge branch 'main' of github.com:lnbits/lnbits-legend 2022-07-26 12:26:34 +02:00
dni 00dba54ac8 remove FIXME add couple of ignores 2022-07-26 12:21:21 +02:00
dni e63c4c9fa2 revert self.wallet change 2022-07-26 12:07:16 +02:00
dni 27ce17cd74 blacked 2022-07-26 10:10:07 +02:00
dni 1c543e3a84 ignore macaroon type errors 2022-07-26 09:56:46 +02:00
dni 345cf86544 fix another fastapi type 2022-07-26 09:52:57 +02:00
dni e4078910c4 fix another fastapi type 2022-07-26 09:38:31 +02:00
5810a4993b Update lnbits/core/views/api.py
Co-authored-by: calle <93376500+callebtc@users.noreply.github.com>
2022-07-26 09:08:16 +02:00
dni 8d89518271 Merge branch 'main' into fix/mypy 2022-07-25 15:06:09 +02:00
callebtc d9849435a8 Merge branch 'main' of github.com:lnbits/lnbits-legend 2022-07-25 14:32:38 +02:00
dni e67b2c0529 Merge branch 'main' into fix/mypy 2022-07-25 13:46:22 +02:00
dni 5779243d3e blacked 2022-07-25 13:30:45 +02:00
dni 3d1abfa35a last mypy fails 2022-07-25 13:27:31 +02:00
dni 0bf4457c2f fixup types 2022-07-25 13:19:35 +02:00
dni a7c10dde4a fixup types 2022-07-25 13:13:50 +02:00
dni 9cecd42467 ignore Body type 2022-07-25 12:09:29 +02:00
dni 800eda2f6d merge pipfile 2022-07-25 12:08:21 +02:00
dni df00a018f0 added FIXME tags and fix WalletTypeInfo 2022-07-25 12:05:16 +02:00
dni 66477bc392 enable mypy checks 2022-07-25 12:05:16 +02:00
b865fad95e Update tests/core/views/test_api.py
Co-authored-by: calle <93376500+callebtc@users.noreply.github.com>
2022-07-25 12:05:16 +02:00
e939b9a5aa Update tests/core/views/test_api.py
Co-authored-by: calle <93376500+callebtc@users.noreply.github.com>
2022-07-25 12:05:16 +02:00
dni a3e4f175dd add missing Pipfile.lock 2022-07-25 12:05:10 +02:00
dni 4dfa0873e5 fix isort check 2022-07-25 12:03:06 +02:00
dni 0e393111de fix up decorators for tests 2022-07-25 12:03:06 +02:00
dni 463fbb39bd make test run again, introduce mypy again 2022-07-25 12:03:06 +02:00
dni 88ee5fd6cc fixup requirements.txt and ad to Pipfile 2022-07-25 12:03:06 +02:00
dni a0707f8a32 types-mock==4.0.15 to requirements, needed for mypy tests 2022-07-25 12:03:06 +02:00
dni b9373f7283 add tests for api_payment 2022-07-25 12:03:06 +02:00
dni a80fd5de7a black formating 2022-07-25 12:03:06 +02:00
dni 9a17e4896b fix last views api bug, thanks calle 2022-07-25 12:03:06 +02:00
dni 7f2c25336c ignore eclair websockets type 2022-07-25 12:03:06 +02:00
dni f677777f07 fully fix decorators, thanks calle 2022-07-25 12:03:06 +02:00
dni 25d19244be mypy fixes, api, eclair 2022-07-25 12:03:06 +02:00
dni 704b572349 fix PaymentKwargs class for mypy 2022-07-25 12:03:06 +02:00
dni 56560fca02 mypy fixes for generic.py, decurators.py, eclair.py 2022-07-25 12:03:06 +02:00
dni 7ad9ad799e assert Optional[str] 2022-07-25 12:03:06 +02:00
dni 8e9b3190e4 mypy fakewallet, macaroon, bolt11 invoice 2022-07-25 12:03:06 +02:00
dni 808bd36e23 some more mypy fixes 2022-07-25 12:02:59 +02:00
dni 895770e26a remove commented out and unused code in app object, it is now used in lnbits/__main__.py directly, fixes mypy error 2022-07-25 12:00:22 +02:00
dni 2891a421f1 fix up mypy.ini, and properly ignore grpc and extension 2022-07-25 12:00:22 +02:00
callebtc 4e57b75fa1 Merge branch 'fix/migration_columns' 2022-07-25 07:32:17 +02:00
callebtc 20817df4de Merge branch 'main' of github.com:lnbits/lnbits-legend 2022-07-25 07:32:08 +02:00
callebtc b17aed2d37 funzt 2022-07-24 22:13:05 +02:00
callebtc d93bd4bec1 print number of rows 2022-07-24 22:10:46 +02:00
callebtc dd7f622a67 db file 2022-07-24 22:07:19 +02:00
callebtc 923fad7382 migrate with mock data 2022-07-24 22:01:51 +02:00
callebtc 52c889f1d8 Merge branch 'main' of github.com:lnbits/lnbits-legend 2022-07-24 01:12:56 +02:00
callebtc 70d270bd69 Merge branch 'main' of github.com:lnbits/lnbits-legend 2022-07-23 11:26:48 +02:00
callebtc 3b172630e3 fix tip link 2022-07-23 11:00:33 +02:00
dni 5aa30c843c added FIXME tags and fix WalletTypeInfo 2022-07-20 13:41:13 +02:00
dni 76fdce4328 enable mypy checks 2022-07-20 13:23:50 +02:00
dni 7f960d8802 Merge branch 'fix/mypy' of github.com:dni/lnbits-legend into fix/mypy 2022-07-20 13:23:14 +02:00
dni 04df2129b0 add missing Pipfile.lock 2022-07-20 13:21:42 +02:00
613c5b4667 Update tests/core/views/test_api.py
Co-authored-by: calle <93376500+callebtc@users.noreply.github.com>
2022-07-20 13:15:18 +02:00
0784ae02b1 Update tests/core/views/test_api.py
Co-authored-by: calle <93376500+callebtc@users.noreply.github.com>
2022-07-20 13:15:06 +02:00
dni f4e3a3980e fix isort check 2022-07-20 13:12:55 +02:00
dni 06b1f71d39 fix up decorators for tests 2022-07-20 12:21:39 +02:00
dni 2e0276bc60 make test run again, introduce mypy again 2022-07-20 12:17:34 +02:00
dni c186e53cef fixup requirements.txt and ad to Pipfile 2022-07-20 12:05:35 +02:00
dni 5f6dc757dc types-mock==4.0.15 to requirements, needed for mypy tests 2022-07-20 12:00:53 +02:00
dni 627959e30b add tests for api_payment 2022-07-20 11:52:49 +02:00
dni 64502c355e black formating 2022-07-20 11:33:37 +02:00
dni 56b4b0cf43 fix last views api bug, thanks calle 2022-07-20 11:21:38 +02:00
dni 8dbf64a0d0 ignore eclair websockets type 2022-07-20 10:34:01 +02:00
dni e96944a4fa fully fix decorators, thanks calle 2022-07-20 10:24:40 +02:00
dni 68a195952a mypy fixes, api, eclair 2022-07-20 10:05:30 +02:00
dni e1d1ce7edc fix PaymentKwargs class for mypy 2022-07-20 09:52:25 +02:00
dni 045212a070 mypy fixes for generic.py, decurators.py, eclair.py 2022-07-20 09:36:13 +02:00
dni 886c74f205 assert Optional[str] 2022-07-20 08:43:10 +02:00
dni 91c5a68ccc mypy fakewallet, macaroon, bolt11 invoice 2022-07-19 19:20:20 +02:00
dni 8901ff8084 some more mypy fixes 2022-07-19 18:51:35 +02:00
dni 45a13fd807 remove commented out and unused code in app object, it is now used in lnbits/__main__.py directly, fixes mypy error 2022-07-19 13:39:17 +02:00
dni 83a000d7c9 fix up mypy.ini, and properly ignore grpc and extension 2022-07-19 13:06:19 +02:00
Tiago vasconcelos 42d342cd0d blacked 2022-07-05 16:21:49 +01:00
Tiago vasconcelos c8a6caa4e3 update api section title 2022-06-28 11:12:42 +01:00
Tiago vasconcelos 6147bd23ec fix typo 2022-06-22 17:08:08 +01:00
Tiago vasconcelos 6867d65733 one final addition to readme 2022-06-22 17:04:23 +01:00
Tiago vasconcelos fea92354a8 some vanity bling 2022-06-22 16:51:14 +01:00
Tiago vasconcelos a7efff858b bring back list scrub 2022-06-22 16:46:27 +01:00
Tiago vasconcelos 267530f217 readme 2022-06-22 16:46:15 +01:00
Tiago vasconcelos 6b66ca1531 correct api instructions and add readme 2022-06-22 16:45:54 +01:00
Tiago vasconcelos 73b8bd682c clean up 2022-06-22 13:30:06 +01:00
Tiago vasconcelos c90cf6ee1b general clean up 2022-06-21 16:44:59 +01:00
Tiago vasconcelos 6a4170f4cc scrubbing works, duplicated code warning 2022-06-21 15:33:23 +01:00
Tiago vasconcelos 10e1bbcb66 pay lnurl 2022-06-21 10:31:18 +01:00
Tiago vasconcelos f6d922e1b7 update scrub links 2022-06-17 12:33:14 +01:00
Tiago vasconcelos 1a394d0a73 create scrub link 2022-06-17 11:41:12 +01:00
ben 395b4379a4 initial 2022-06-07 11:04:34 +01:00
ben 5574fa7263 Merge remote-tracking branch 'origin/main' into SCRUB 2022-05-25 17:03:12 +01:00
ben 5e28183a24 bits bobs 2022-05-19 11:39:59 +01:00
ben b0db8d8f5a Merge remote-tracking branch 'origin/main' into SCRUB 2022-05-18 14:38:35 +01:00
ben f43d8ff0c3 init 2022-05-17 20:27:52 +01:00
benarc 829e220096 another dependency 2022-04-16 11:18:27 +01:00
benarc b5703e0dfc added dependency 2022-04-16 11:17:03 +01:00
benarc c7322fd071 correct route 2022-04-16 11:15:42 +01:00
benarc a6c84ceb61 Geberic QRcode maker
Added generic qrcode maker endpoint extensions can use to make embedable qrcodes
2022-04-16 11:14:34 +01:00
77 changed files with 4926 additions and 3048 deletions
+1 -2
View File
@@ -5,10 +5,9 @@ on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
if: ${{ 'false' == 'true' }} # skip mypy for now
steps:
- uses: actions/checkout@v1
- uses: jpetrucciani/mypy-check@master
with:
mypy_flags: '--install-types --non-interactive'
path: lnbits
path: 'lnbits'
+3 -5
View File
@@ -16,7 +16,6 @@ jobs:
python-version: ${{ matrix.python-version }}
- name: Setup Regtest
run: |
docker build -t lnbits-legend .
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
cd docker
chmod +x ./tests
@@ -39,8 +38,8 @@ jobs:
LNBITS_DATA_FOLDER: ./data
LNBITS_BACKEND_WALLET_CLASS: LndRestWallet
LND_REST_ENDPOINT: https://localhost:8081/
LND_REST_CERT: docker/data/lnd-1/tls.cert
LND_REST_MACAROON: docker/data/lnd-1/data/chain/bitcoin/regtest/admin.macaroon
LND_REST_CERT: ./docker/data/lnd-1/tls.cert
LND_REST_MACAROON: ./docker/data/lnd-1/data/chain/bitcoin/regtest/admin.macaroon
run: |
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
make test-real-wallet
@@ -57,7 +56,6 @@ jobs:
python-version: ${{ matrix.python-version }}
- name: Setup Regtest
run: |
docker build -t lnbits-legend .
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
cd docker
chmod +x ./tests
@@ -79,7 +77,7 @@ jobs:
PORT: 5123
LNBITS_DATA_FOLDER: ./data
LNBITS_BACKEND_WALLET_CLASS: CLightningWallet
CLIGHTNING_RPC: docker/data/clightning-1/regtest/lightning-rpc
CLIGHTNING_RPC: ./docker/data/clightning-1/regtest/lightning-rpc
run: |
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
make test-real-wallet
+53 -6
View File
@@ -68,11 +68,11 @@ jobs:
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
pipenv-sqlite:
poetry-sqlite:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.7, 3.8, 3.9]
python-version: [3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
@@ -80,9 +80,56 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
env:
VIRTUAL_ENV: ./venv
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
run: |
pip install pipenv
pipenv install --dev
pipenv install importlib-metadata
python -m venv ${{ env.VIRTUAL_ENV }}
./venv/bin/python -m pip install --upgrade pip
./venv/bin/pip install -r requirements.txt
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
- name: Run tests
run: make test-pipenv
run: make test
poetry-postgres:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:latest
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
# maps tcp port 5432 on service container to the host
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
strategy:
matrix:
python-version: [3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
env:
VIRTUAL_ENV: ./venv
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
run: |
python -m venv ${{ env.VIRTUAL_ENV }}
./venv/bin/python -m pip install --upgrade pip
./venv/bin/pip install -r requirements.txt
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
- name: Run tests
env:
LNBITS_DATABASE_URL: postgres://postgres:postgres@0.0.0.0:5432/postgres
run: make test
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
-44
View File
@@ -1,44 +0,0 @@
[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true
[requires]
python_version = "3.8"
[packages]
bitstring = "*"
cerberus = "*"
ecdsa = "*"
environs = "*"
lnurl = "==0.3.6"
loguru = "*"
pyscss = "*"
shortuuid = "*"
typing-extensions = "*"
httpx = "*"
sqlalchemy-aio = "*"
embit = "*"
pyqrcode = "*"
pypng = "*"
sqlalchemy = "==1.3.23"
psycopg2-binary = "*"
aiofiles = "*"
asyncio = "*"
fastapi = "*"
uvicorn = {extras = ["standard"], version = "*"}
sse-starlette = "*"
jinja2 = "==3.0.1"
pyngrok = "*"
secp256k1 = "==0.14.0"
cffi = "==1.15.0"
pycryptodomex = "*"
[dev-packages]
black = "==20.8b1"
pytest = "*"
pytest-cov = "*"
mypy = "*"
pytest-asyncio = "*"
requests = "*"
mock = "*"
Generated
-1157
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -7,7 +7,7 @@ LNbits
![Lightning network wallet](https://i.imgur.com/EHvK6Lq.png)
# LNbits v0.3 BETA, free and open-source lightning-network wallet/accounts system
# LNbits v0.9 BETA, free and open-source lightning-network wallet/accounts system
(Join us on [https://t.me/lnbits](https://t.me/lnbits))
+1 -1
View File
@@ -8,7 +8,7 @@ nav_order: 1
# Installation
This guide has been moved to the [installation guide](../guide/installation.md).
To install the developer packages, use `pipenv install --dev`.
To install the developer packages for running tests etc before pr'ing, use `./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock black mypy isort`.
## Notes:
+30 -58
View File
@@ -8,7 +8,7 @@ nav_order: 2
# Basic installation
You can choose between four package managers, `poetry`, `pipenv`, `venv` and `nix`.
You can choose between four package managers, `poetry`, `nix` and `venv`.
By default, LNbits will use SQLite as its database. You can also use PostgreSQL which is recommended for applications with a high load (see guide below).
@@ -33,64 +33,13 @@ poetry run lnbits
# To change port/host pass 'poetry run lnbits --port 9000 --host 0.0.0.0'
```
## Option 2: pipenv
## Option 2: Nix
```sh
git clone https://github.com/lnbits/lnbits-legend.git
cd lnbits-legend/
sudo apt update && sudo apt install -y pipenv
pipenv install --dev
# pipenv --python 3.9 install --dev (if you wish to use a version of Python higher than 3.7)
pipenv shell
# pipenv --python 3.9 shell (if you wish to use a version of Python higher than 3.7)
# If any of the modules fails to install, try checking and upgrading your setupTool module
# pip install -U setuptools wheel
# install libffi/libpq in case "pipenv install" fails
# sudo apt-get install -y libffi-dev libpq-dev
mkdir data && cp .env.example .env
```
#### Running the server
```sh
pipenv run python -m uvicorn lnbits.__main__:app --port 5000 --host 0.0.0.0
```
Add the flag `--reload` for development (includes hot-reload).
## Option 3: venv
```sh
git clone https://github.com/lnbits/lnbits-legend.git
cd lnbits-legend/
# ensure you have virtualenv installed, on debian/ubuntu 'apt install python3-venv'
python3 -m venv venv
# If you have problems here, try `sudo apt install -y pkg-config libpq-dev`
./venv/bin/pip install -r requirements.txt
# create the data folder and the .env file
mkdir data && cp .env.example .env
```
#### Running the server
```sh
./venv/bin/uvicorn lnbits.__main__:app --port 5000
```
If you want to host LNbits on the internet, run with the option `--host 0.0.0.0`.
## Option 4: Nix
```sh
git clone https://github.com/lnbits/lnbits-legend.git
cd lnbits-legend/
# Install nix, modern debian distros usually already include
sh <(curl -L https://nixos.org/nix/install) --daemon
# Modern debian distros usually include Nix, however you can install with:
# 'sh <(curl -L https://nixos.org/nix/install) --daemon', or use setup here https://nixos.org/download.html#nix-verify-installation
nix build .#lnbits
mkdir data
@@ -104,6 +53,29 @@ mkdir data
LNBITS_DATA_FOLDER=data LNBITS_BACKEND_WALLET_CLASS=LNbitsWallet LNBITS_ENDPOINT=https://legend.lnbits.com LNBITS_KEY=7b1a78d6c78f48b09a202f2dcb2d22eb ./result/bin/lnbits --port 9000
```
## Option 3: venv
```sh
git clone https://github.com/lnbits/lnbits-legend.git
cd lnbits-legend/
# ensure you have virtualenv installed, on debian/ubuntu 'apt install python3-venv'
python3 -m venv venv
# If you have problems here, try `sudo apt install -y pkg-config libpq-dev`
./venv/bin/pip install -r requirements.txt
# create the data folder and the .env file
mkdir data && cp .env.example .env
# build the static files
./venv/bin/python build.py
```
#### Running the server
```sh
./venv/bin/uvicorn lnbits.__main__:app --port 5000
```
If you want to host LNbits on the internet, run with the option `--host 0.0.0.0`.
### Troubleshooting
Problems installing? These commands have helped us install LNbits.
@@ -112,10 +84,10 @@ Problems installing? These commands have helped us install LNbits.
sudo apt install pkg-config libffi-dev libpq-dev
# if the secp256k1 build fails:
# if you used pipenv (option 1)
pipenv install setuptools wheel
# if you used venv (option 2)
# if you used venv
./venv/bin/pip install setuptools wheel
# if you used poetry
poetry add setuptools wheel
# build essentials for debian/ubuntu
sudo apt install python3-dev gcc build-essential
```
-8
View File
@@ -17,7 +17,6 @@ from loguru import logger
import lnbits.settings
from lnbits.core.tasks import register_task_listeners
from .commands import db_migrate, handle_assets
from .core import core_app
from .core.views.generic import core_html_routes
from .helpers import (
@@ -93,7 +92,6 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
check_funding_source(app)
register_assets(app)
register_routes(app)
# register_commands(app)
register_async_tasks(app)
register_exception_handlers(app)
@@ -146,12 +144,6 @@ def register_routes(app: FastAPI) -> None:
)
def register_commands(app: FastAPI):
"""Register Click commands."""
app.cli.add_command(db_migrate)
app.cli.add_command(handle_assets)
def register_assets(app: FastAPI):
"""Serve each vendored asset separately or a bundle."""
+2 -2
View File
@@ -61,7 +61,7 @@ def decode(pr: str) -> Invoice:
invoice = Invoice()
# decode the amount from the hrp
m = re.search("[^\d]+", hrp[2:])
m = re.search(r"[^\d]+", hrp[2:])
if m:
amountstr = hrp[2 + m.end() :]
if amountstr != "":
@@ -296,7 +296,7 @@ def _unshorten_amount(amount: str) -> int:
# BOLT #11:
# A reader SHOULD fail if `amount` contains a non-digit, or is followed by
# anything except a `multiplier` in the table above.
if not re.fullmatch("\d+[pnum]?", str(amount)):
if not re.fullmatch(r"\d+[pnum]?", str(amount)):
raise ValueError("Invalid amount '{}'".format(amount))
if unit in units:
+1 -1
View File
@@ -113,7 +113,7 @@ async def create_wallet(
async def update_wallet(
wallet_id: str, new_name: str, conn: Optional[Connection] = None
) -> Optional[Wallet]:
await (conn or db).execute(
return await (conn or db).execute(
"""
UPDATE wallets SET
name = ?
+2
View File
@@ -106,6 +106,8 @@ class Payment(BaseModel):
@property
def tag(self) -> Optional[str]:
if self.extra is None:
return ""
return self.extra.get("tag")
@property
+10 -12
View File
@@ -109,18 +109,15 @@ async def pay_invoice(
raise ValueError("Amount in invoice is too high.")
# put all parameters that don't change here
PaymentKwargs = TypedDict(
"PaymentKwargs",
{
"wallet_id": str,
"payment_request": str,
"payment_hash": str,
"amount": int,
"memo": str,
"extra": Optional[Dict],
},
)
payment_kwargs: PaymentKwargs = dict(
class PaymentKwargs(TypedDict):
wallet_id: str
payment_request: str
payment_hash: str
amount: int
memo: str
extra: Optional[Dict]
payment_kwargs: PaymentKwargs = PaymentKwargs(
wallet_id=wallet_id,
payment_request=payment_request,
payment_hash=invoice.payment_hash,
@@ -272,6 +269,7 @@ async def perform_lnurlauth(
cb = urlparse(callback)
k1 = unhexlify(parse_qs(cb.query)["k1"][0])
key = wallet.wallet.lnurlauth_key(cb.netloc)
def int_to_bytes_suitable_der(x: int) -> bytes:
+1 -1
View File
@@ -55,7 +55,7 @@ async def dispatch_webhook(payment: Payment):
data = payment.dict()
try:
logger.debug("sending webhook", payment.webhook)
r = await client.post(payment.webhook, json=data, timeout=40)
r = await client.post(payment.webhook, json=data, timeout=40) # type: ignore
await mark_webhook_sent(payment, r.status_code)
except (httpx.ConnectError, httpx.RequestError):
await mark_webhook_sent(payment, -1)
+40 -22
View File
@@ -3,10 +3,12 @@ import hashlib
import json
from binascii import unhexlify
from http import HTTPStatus
from typing import Dict, List, Optional, Union
from io import BytesIO
from typing import Dict, List, Optional, Tuple, Union
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
import httpx
import pyqrcode
from fastapi import Depends, Header, Query, Request
from fastapi.exceptions import HTTPException
from fastapi.params import Body
@@ -14,6 +16,7 @@ from loguru import logger
from pydantic import BaseModel
from pydantic.fields import Field
from sse_starlette.sse import EventSourceResponse
from starlette.responses import HTMLResponse, StreamingResponse
from lnbits import bolt11, lnurl
from lnbits.core.models import Payment, Wallet
@@ -185,7 +188,7 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
assert (
data.lnurl_balance_check is not None
), "lnurl_balance_check is required"
save_balance_check(wallet.id, data.lnurl_balance_check)
await save_balance_check(wallet.id, data.lnurl_balance_check)
async with httpx.AsyncClient() as client:
try:
@@ -248,7 +251,7 @@ async def api_payments_pay_invoice(bolt11: str, wallet: Wallet):
)
async def api_payments_create(
wallet: WalletTypeInfo = Depends(require_invoice_key),
invoiceData: CreateInvoiceData = Body(...),
invoiceData: CreateInvoiceData = Body(...), # type: ignore
):
if invoiceData.out is True and wallet.wallet_type == 0:
if not invoiceData.bolt11:
@@ -291,7 +294,7 @@ async def api_payments_pay_lnurl(
timeout=40,
)
if r.is_error:
raise httpx.ConnectError
raise httpx.ConnectError("LNURL callback connection error")
except (httpx.ConnectError, httpx.RequestError):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
@@ -354,7 +357,7 @@ async def subscribe(request: Request, wallet: Wallet):
logger.debug("adding sse listener", payment_queue)
api_invoice_listeners.append(payment_queue)
send_queue: asyncio.Queue[tuple[str, Payment]] = asyncio.Queue(0)
send_queue: asyncio.Queue[Tuple[str, Payment]] = asyncio.Queue(0)
async def payment_received() -> None:
while True:
@@ -393,16 +396,13 @@ async def api_payments_sse(
async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
# We use X_Api_Key here because we want this call to work with and without keys
# If a valid key is given, we also return the field "details", otherwise not
wallet = None
try:
if X_Api_Key.extra:
logger.warning("No key")
except:
wallet = await get_wallet_for_key(X_Api_Key)
wallet = await get_wallet_for_key(X_Api_Key) if type(X_Api_Key) == str else None
# we have to specify the wallet id here, because postgres and sqlite return internal payments in different order
# and get_standalone_payment otherwise just fetches the first one, causing unpredictable results
payment = await get_standalone_payment(
payment_hash, wallet_id=wallet.id if wallet else None
) # we have to specify the wallet id here, because postgres and sqlite return internal payments in different order
# and get_standalone_payment otherwise just fetches the first one, causing unpredictable results
)
if payment is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
@@ -488,7 +488,8 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
)
try:
tag = data["tag"]
tag: str = data.get("tag")
params.update(**data)
if tag == "channelRequest":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
@@ -498,10 +499,7 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
"message": "unsupported",
},
)
params.update(**data)
if tag == "withdrawRequest":
elif tag == "withdrawRequest":
params.update(kind="withdraw")
params.update(fixed=data["minWithdrawable"] == data["maxWithdrawable"])
@@ -519,8 +517,7 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
query=urlencode(qs, doseq=True)
)
params.update(callback=urlunparse(parsed_callback))
if tag == "payRequest":
elif tag == "payRequest":
params.update(kind="pay")
params.update(fixed=data["minSendable"] == data["maxSendable"])
@@ -538,8 +535,8 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
params.update(image=data_uri)
if k == "text/email" or k == "text/identifier":
params.update(targetUser=v)
params.update(commentAllowed=data.get("commentAllowed", 0))
except KeyError as exc:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
@@ -612,8 +609,8 @@ class ConversionData(BaseModel):
async def api_fiat_as_sats(data: ConversionData):
output = {}
if data.from_ == "sat":
output["sats"] = int(data.amount)
output["BTC"] = data.amount / 100000000
output["sats"] = int(data.amount)
for currency in data.to.split(","):
output[currency.strip().upper()] = await satoshis_amount_as_fiat(
data.amount, currency.strip()
@@ -624,3 +621,24 @@ async def api_fiat_as_sats(data: ConversionData):
output["sats"] = await fiat_amount_as_satoshis(data.amount, data.from_)
output["BTC"] = output["sats"] / 100000000
return output
@core_app.get("/api/v1/qrcode/{data}", response_class=StreamingResponse)
async def img(request: Request, data):
qr = pyqrcode.create(data)
stream = BytesIO()
qr.svg(stream, scale=3)
stream.seek(0)
async def _generator(stream: BytesIO):
yield stream.getvalue()
return StreamingResponse(
_generator(stream),
headers={
"Content-Type": "image/svg+xml",
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0",
},
)
+26 -26
View File
@@ -55,9 +55,9 @@ async def home(request: Request, lightning: str = None):
)
async def extensions(
request: Request,
user: User = Depends(check_user_exists),
enable: str = Query(None),
disable: str = Query(None),
user: User = Depends(check_user_exists), # type: ignore
enable: str = Query(None), # type: ignore
disable: str = Query(None), # type: ignore
):
extension_to_enable = enable
extension_to_disable = disable
@@ -88,7 +88,7 @@ async def extensions(
# Update user as his extensions have been updated
if extension_to_enable or extension_to_disable:
user = await get_user(user.id)
user = await get_user(user.id) # type: ignore
return template_renderer().TemplateResponse(
"core/extensions.html", {"request": request, "user": user.dict()}
@@ -109,10 +109,10 @@ nothing: create everything<br>
""",
)
async def wallet(
request: Request = Query(None),
nme: Optional[str] = Query(None),
usr: Optional[UUID4] = Query(None),
wal: Optional[UUID4] = Query(None),
request: Request = Query(None), # type: ignore
nme: Optional[str] = Query(None), # type: ignore
usr: Optional[UUID4] = Query(None), # type: ignore
wal: Optional[UUID4] = Query(None), # type: ignore
):
user_id = usr.hex if usr else None
wallet_id = wal.hex if wal else None
@@ -121,7 +121,7 @@ async def wallet(
if not user_id:
user = await get_user((await create_account()).id)
logger.info(f"Create user {user.id}")
logger.info(f"Create user {user.id}") # type: ignore
else:
user = await get_user(user_id)
if not user:
@@ -135,22 +135,22 @@ async def wallet(
if LNBITS_ADMIN_USERS and user_id in LNBITS_ADMIN_USERS:
user.admin = True
if not wallet_id:
if user.wallets and not wallet_name:
wallet = user.wallets[0]
if user.wallets and not wallet_name: # type: ignore
wallet = user.wallets[0] # type: ignore
else:
wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name)
wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name) # type: ignore
logger.info(
f"Created new wallet {wallet_name if wallet_name else '(no name)'} for user {user.id}"
f"Created new wallet {wallet_name if wallet_name else '(no name)'} for user {user.id}" # type: ignore
)
return RedirectResponse(
f"/wallet?usr={user.id}&wal={wallet.id}",
f"/wallet?usr={user.id}&wal={wallet.id}", # type: ignore
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
)
logger.debug(f"Access wallet {wallet_name}{'of user '+ user.id if user else ''}")
wallet = user.get_wallet(wallet_id)
if not wallet:
userwallet = user.get_wallet(wallet_id) # type: ignore
if not userwallet:
return template_renderer().TemplateResponse(
"error.html", {"request": request, "err": "Wallet not found"}
)
@@ -159,10 +159,10 @@ async def wallet(
"core/wallet.html",
{
"request": request,
"user": user.dict(),
"wallet": wallet.dict(),
"user": user.dict(), # type: ignore
"wallet": userwallet.dict(),
"service_fee": service_fee,
"web_manifest": f"/manifest/{user.id}.webmanifest",
"web_manifest": f"/manifest/{user.id}.webmanifest", # type: ignore
},
)
@@ -216,20 +216,20 @@ async def lnurl_full_withdraw_callback(request: Request):
@core_html_routes.get("/deletewallet", response_class=RedirectResponse)
async def deletewallet(request: Request, wal: str = Query(...), usr: str = Query(...)):
async def deletewallet(request: Request, wal: str = Query(...), usr: str = Query(...)): # type: ignore
user = await get_user(usr)
user_wallet_ids = [u.id for u in user.wallets]
user_wallet_ids = [u.id for u in user.wallets] # type: ignore
if wal not in user_wallet_ids:
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
else:
await delete_wallet(user_id=user.id, wallet_id=wal)
await delete_wallet(user_id=user.id, wallet_id=wal) # type: ignore
user_wallet_ids.remove(wal)
logger.debug("Deleted wallet {wal} of user {user.id}")
if user_wallet_ids:
return RedirectResponse(
url_for("/wallet", usr=user.id, wal=user_wallet_ids[0]),
url_for("/wallet", usr=user.id, wal=user_wallet_ids[0]), # type: ignore
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
)
@@ -242,7 +242,7 @@ async def deletewallet(request: Request, wal: str = Query(...), usr: str = Query
async def lnurl_balance_notify(request: Request, service: str):
bc = await get_balance_check(request.query_params.get("wal"), service)
if bc:
redeem_lnurl_withdraw(bc.wallet, bc.url)
await redeem_lnurl_withdraw(bc.wallet, bc.url)
@core_html_routes.get(
@@ -252,7 +252,7 @@ async def lnurlwallet(request: Request):
async with db.connect() as conn:
account = await create_account(conn=conn)
user = await get_user(account.id, conn=conn)
wallet = await create_wallet(user_id=user.id, conn=conn)
wallet = await create_wallet(user_id=user.id, conn=conn) # type: ignore
asyncio.create_task(
redeem_lnurl_withdraw(
@@ -265,7 +265,7 @@ async def lnurlwallet(request: Request):
)
return RedirectResponse(
f"/wallet?usr={user.id}&wal={wallet.id}",
f"/wallet?usr={user.id}&wal={wallet.id}", # type: ignore
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
)
+21 -18
View File
@@ -1,4 +1,5 @@
from http import HTTPStatus
from typing import Union
from cerberus import Validator # type: ignore
from fastapi import status
@@ -29,20 +30,21 @@ class KeyChecker(SecurityBase):
self._key_type = "invoice"
self._api_key = api_key
if api_key:
self.model: APIKey = APIKey(
key = APIKey(
**{"in": APIKeyIn.query},
name="X-API-KEY",
description="Wallet API Key - QUERY",
)
else:
self.model: APIKey = APIKey(
key = APIKey(
**{"in": APIKeyIn.header},
name="X-API-KEY",
description="Wallet API Key - HEADER",
)
self.wallet = None
self.wallet = None # type: ignore
self.model: APIKey = key
async def __call__(self, request: Request) -> Wallet:
async def __call__(self, request: Request):
try:
key_value = (
self._api_key
@@ -52,7 +54,7 @@ class KeyChecker(SecurityBase):
# FIXME: Find another way to validate the key. A fetch from DB should be avoided here.
# Also, we should not return the wallet here - thats silly.
# Possibly store it in a Redis DB
self.wallet = await get_wallet_for_key(key_value, self._key_type)
self.wallet = await get_wallet_for_key(key_value, self._key_type) # type: ignore
if not self.wallet:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
@@ -120,8 +122,8 @@ api_key_query = APIKeyQuery(
async def get_key_type(
r: Request,
api_key_header: str = Security(api_key_header),
api_key_query: str = Security(api_key_query),
api_key_header: str = Security(api_key_header), # type: ignore
api_key_query: str = Security(api_key_query), # type: ignore
) -> WalletTypeInfo:
# 0: admin
# 1: invoice
@@ -134,9 +136,9 @@ async def get_key_type(
token = api_key_header if api_key_header else api_key_query
try:
checker = WalletAdminKeyChecker(api_key=token)
await checker.__call__(r)
wallet = WalletTypeInfo(0, checker.wallet)
admin_checker = WalletAdminKeyChecker(api_key=token)
await admin_checker.__call__(r)
wallet = WalletTypeInfo(0, admin_checker.wallet) # type: ignore
if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (
LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS
):
@@ -153,9 +155,9 @@ async def get_key_type(
raise
try:
checker = WalletInvoiceKeyChecker(api_key=token)
await checker.__call__(r)
wallet = WalletTypeInfo(1, checker.wallet)
invoice_checker = WalletInvoiceKeyChecker(api_key=token)
await invoice_checker.__call__(r)
wallet = WalletTypeInfo(1, invoice_checker.wallet) # type: ignore
if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (
LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS
):
@@ -167,15 +169,16 @@ async def get_key_type(
if e.status_code == HTTPStatus.BAD_REQUEST:
raise
if e.status_code == HTTPStatus.UNAUTHORIZED:
return WalletTypeInfo(2, None)
return WalletTypeInfo(2, None) # type: ignore
except:
raise
return wallet
async def require_admin_key(
r: Request,
api_key_header: str = Security(api_key_header),
api_key_query: str = Security(api_key_query),
api_key_header: str = Security(api_key_header), # type: ignore
api_key_query: str = Security(api_key_query), # type: ignore
):
token = api_key_header if api_key_header else api_key_query
@@ -193,8 +196,8 @@ async def require_admin_key(
async def require_invoice_key(
r: Request,
api_key_header: str = Security(api_key_header),
api_key_query: str = Security(api_key_query),
api_key_header: str = Security(api_key_header), # type: ignore
api_key_query: str = Security(api_key_query), # type: ignore
):
token = api_key_header if api_key_header else api_key_query
+2 -1
View File
@@ -133,7 +133,8 @@ async def api_ticket_send_ticket(event_id, payment_hash, data: CreateTicket):
if not ticket:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail=f"Event could not be fetched."
status_code=HTTPStatus.NOT_FOUND,
detail=f"Event could not be fetched.",
)
return {"paid": True, "ticket_id": ticket.id}
@@ -4,12 +4,7 @@
label="API info"
:content-inset-level="0.5"
>
<q-btn
flat
label="Swagger API"
type="a"
href="../docs#/lnurlpayout"
></q-btn>
<q-btn flat label="Swagger API" type="a" href="../docs#/lnurlpayout"></q-btn>
<q-expansion-item group="api" dense expand-separator label="List lnurlpayout">
<q-card>
<q-card-section>
@@ -38,7 +33,6 @@
expand-separator
label="Create a lnurlpayout"
>
<q-card>
<q-card-section>
<code
+16 -8
View File
@@ -52,14 +52,20 @@ async def set_method(shop: int, method: str, wordlist: str = "") -> Optional[Sho
async def add_item(
shop: int, name: str, description: str, image: Optional[str], price: int, unit: str
shop: int,
name: str,
description: str,
image: Optional[str],
price: int,
unit: str,
fiat_base_multiplier: int,
) -> int:
result = await db.execute(
"""
INSERT INTO offlineshop.items (shop, name, description, image, price, unit)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO offlineshop.items (shop, name, description, image, price, unit, fiat_base_multiplier)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(shop, name, description, image, price, unit),
(shop, name, description, image, price, unit, fiat_base_multiplier),
)
return result._result_proxy.lastrowid
@@ -72,6 +78,7 @@ async def update_item(
image: Optional[str],
price: int,
unit: str,
fiat_base_multiplier: int,
) -> int:
await db.execute(
"""
@@ -80,10 +87,11 @@ async def update_item(
description = ?,
image = ?,
price = ?,
unit = ?
unit = ?,
fiat_base_multiplier = ?
WHERE shop = ? AND id = ?
""",
(name, description, image, price, unit, shop, item_id),
(name, description, image, price, unit, fiat_base_multiplier, shop, item_id),
)
return item_id
@@ -92,12 +100,12 @@ async def get_item(id: int) -> Optional[Item]:
row = await db.fetchone(
"SELECT * FROM offlineshop.items WHERE id = ? LIMIT 1", (id,)
)
return Item(**dict(row)) if row else None
return Item.from_row(row) if row else None
async def get_items(shop: int) -> List[Item]:
rows = await db.fetchall("SELECT * FROM offlineshop.items WHERE shop = ?", (shop,))
return [Item(**dict(row)) for row in rows]
return [Item.from_row(row) for row in rows]
async def delete_item_from_shop(shop: int, item_id: int):
@@ -27,3 +27,13 @@ async def m001_initial(db):
);
"""
)
async def m002_fiat_base_multiplier(db):
"""
Store the multiplier for fiat prices. We store the price in cents and
remember to multiply by 100 when we use it to convert to Dollars.
"""
await db.execute(
"ALTER TABLE offlineshop.items ADD COLUMN fiat_base_multiplier INTEGER DEFAULT 1;"
)
+10 -1
View File
@@ -2,6 +2,7 @@ import base64
import hashlib
import json
from collections import OrderedDict
from sqlite3 import Row
from typing import Dict, List, Optional
from lnurl import encode as lnurl_encode # type: ignore
@@ -87,8 +88,16 @@ class Item(BaseModel):
description: str
image: Optional[str]
enabled: bool
price: int
price: float
unit: str
fiat_base_multiplier: int
@classmethod
def from_row(cls, row: Row) -> "Item":
data = dict(row)
if data["unit"] != "sat" and data["fiat_base_multiplier"]:
data["price"] /= data["fiat_base_multiplier"]
return cls(**data)
def lnurl(self, req: Request) -> str:
return lnurl_encode(req.url_for("offlineshop.lnurl_response", item_id=self.id))
@@ -124,7 +124,8 @@ new Vue({
description,
image,
price,
unit
unit,
fiat_base_multiplier: unit == 'sat' ? 1 : 100
}
try {
+14 -3
View File
@@ -1,6 +1,7 @@
from http import HTTPStatus
from typing import Optional
from fastapi import Query
from fastapi.params import Depends
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
from pydantic.main import BaseModel
@@ -34,7 +35,6 @@ async def api_shop_from_wallet(
):
shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
items = await get_items(shop.id)
try:
return {
**shop.dict(),
@@ -51,8 +51,9 @@ class CreateItemsData(BaseModel):
name: str
description: str
image: Optional[str]
price: int
price: float
unit: str
fiat_base_multiplier: int = Query(100, ge=1)
@offlineshop_ext.post("/api/v1/offlineshop/items")
@@ -61,9 +62,18 @@ async def api_add_or_update_item(
data: CreateItemsData, item_id=None, wallet: WalletTypeInfo = Depends(get_key_type)
):
shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
if data.unit != "sat":
data.price = data.price * 100
if item_id == None:
await add_item(
shop.id, data.name, data.description, data.image, data.price, data.unit
shop.id,
data.name,
data.description,
data.image,
data.price,
data.unit,
data.fiat_base_multiplier,
)
return HTMLResponse(status_code=HTTPStatus.CREATED)
else:
@@ -75,6 +85,7 @@ async def api_add_or_update_item(
data.image,
data.price,
data.unit,
data.fiat_base_multiplier,
)
+9
View File
@@ -1,6 +1,7 @@
import asyncio
from fastapi import APIRouter
from fastapi.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
@@ -11,6 +12,14 @@ db = Database("ext_satspay")
satspay_ext: APIRouter = APIRouter(prefix="/satspay", tags=["satspay"])
satspay_static_files = [
{
"path": "/satspay/static",
"app": StaticFiles(directory="lnbits/extensions/satspay/static"),
"name": "satspay_static",
}
]
def satspay_renderer():
return template_renderer(["lnbits/extensions/satspay/templates"])
+8 -6
View File
@@ -6,7 +6,7 @@ from lnbits.core.services import create_invoice
from lnbits.core.views.api import api_payment
from lnbits.helpers import urlsafe_short_hash
from ..watchonly.crud import get_fresh_address, get_mempool, get_watch_wallet
from ..watchonly.crud import get_config, get_fresh_address
# from lnbits.db import open_ext_db
from . import db
@@ -18,7 +18,6 @@ from .models import Charges, CreateCharge
async def create_charge(user: str, data: CreateCharge) -> Charges:
charge_id = urlsafe_short_hash()
if data.onchainwallet:
wallet = await get_watch_wallet(data.onchainwallet)
onchain = await get_fresh_address(data.onchainwallet)
onchainaddress = onchain.address
else:
@@ -89,7 +88,8 @@ async def get_charge(charge_id: str) -> Charges:
async def get_charges(user: str) -> List[Charges]:
rows = await db.fetchall(
"""SELECT * FROM satspay.charges WHERE "user" = ?""", (user,)
"""SELECT * FROM satspay.charges WHERE "user" = ? ORDER BY "timestamp" DESC """,
(user,),
)
return [Charges.from_row(row) for row in rows]
@@ -102,14 +102,16 @@ 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)
config = await get_config(charge.user)
try:
async with httpx.AsyncClient() as client:
r = await client.get(
mempool.endpoint + "/api/address/" + charge.onchainaddress
config.mempool_endpoint
+ "/api/address/"
+ charge.onchainaddress
)
respAmount = r.json()["chain_stats"]["funded_txo_sum"]
if respAmount >= charge.balance:
if respAmount > charge.balance:
await update_charge(charge_id=charge_id, balance=respAmount)
except Exception:
pass
+9 -5
View File
@@ -1,4 +1,4 @@
import time
from datetime import datetime, timedelta
from sqlite3 import Row
from typing import Optional
@@ -38,12 +38,16 @@ class Charges(BaseModel):
def from_row(cls, row: Row) -> "Charges":
return cls(**dict(row))
@property
def time_left(self):
now = datetime.utcnow().timestamp()
start = datetime.fromtimestamp(self.timestamp)
expiration = (start + timedelta(minutes=self.time)).timestamp()
return (expiration - now) / 60
@property
def time_elapsed(self):
if (self.timestamp + (self.time * 60)) >= time.time():
return False
else:
return True
return self.time_left < 0
@property
def paid(self):
@@ -0,0 +1,31 @@
const sleep = ms => new Promise(r => setTimeout(r, ms))
const retryWithDelay = async function (fn, retryCount = 0) {
try {
await sleep(25)
// Do not return the call directly, use result.
// Otherwise the error will not be cought in this try-catch block.
const result = await fn()
return result
} catch (err) {
if (retryCount > 100) throw err
await sleep((retryCount + 1) * 1000)
return retryWithDelay(fn, retryCount + 1)
}
}
const mapCharge = (obj, oldObj = {}) => {
const charge = _.clone(obj)
charge.progress = obj.time_left < 0 ? 1 : 1 - obj.time_left / obj.time
charge.time = minutesToTime(obj.time)
charge.timeLeft = minutesToTime(obj.time_left)
charge.expanded = false
charge.displayUrl = ['/satspay/', obj.id].join('')
charge.expanded = oldObj.expanded
charge.pendingBalance = oldObj.pendingBalance || 0
return charge
}
const minutesToTime = min =>
min > 0 ? new Date(min * 1000).toISOString().substring(14, 19) : ''
@@ -8,172 +8,10 @@
Created by, <a href="https://github.com/benarc">Ben Arc</a></small
>
</p>
<br />
<br />
<a target="_blank" href="/docs#/satspay" class="text-white"
>Swagger REST API Documentation</a
>
</q-card-section>
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-btn flat label="Swagger API" type="a" href="../docs#/satspay"></q-btn>
<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.base_url }}satspay/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: {{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.base_url
}}satspay/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:
{{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.base_url
}}satspay/api/v1/charge/&lt;charge_id&gt; -H "X-Api-Key: {{
user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Get 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.base_url }}satspay/api/v1/charges -H
"X-Api-Key: {{ user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Delete a pay link"
>
<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.base_url
}}satspay/api/v1/charge/&lt;charge_id&gt; -H "X-Api-Key: {{
user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Get balances"
class="q-pb-md"
>
<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.base_url
}}satspay/api/v1/charges/balance/&lt;charge_id&gt; -H "X-Api-Key: {{
user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>
</q-card>
@@ -1,223 +1,299 @@
{% 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 class="row items-center q-mt-md">
<div class="col-lg-4 col-md-3 col-sm-1"></div>
<div class="col-lg-4 col-md-6 col-sm-10">
<q-card>
<div class="row q-mb-md">
<div class="col text-center q-mt-md">
<span class="text-h4" v-text="charge.description"></span>
</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
<div class="row">
<div class="col text-center">
<div
color="white"
style="background-color: grey; height: 30px; padding: 5px"
v-if="!charge.timeLeft"
>
</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 lightning 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>
Time elapsed
</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 onchain 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
color="white"
style="background-color: grey; height: 30px; padding: 5px"
v-else-if="charge.paid"
>
Charge paid
</div>
<div v-else>
<center>
<span class="text-subtitle2"
>Pay this <br />
lightning-network invoice</span
<q-linear-progress
size="30px"
:value="charge.progress"
color="secondary"
>
<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 %} {{ charge.timeLeft }} {% endraw %}</span
></span
>
</q-item>
</q-item-section>
</q-linear-progress>
</div>
</div>
</div>
<div class="row q-ml-md q-mt-md q-mb-lg">
<div class="col">
<div class="row">
<div class="col-4 q-pr-lg">Charge Id:</div>
<div class="col-8 q-pr-lg">
<q-btn flat dense outline @click="copyText(charge.id)"
><span v-text="charge.id"></span
></q-btn>
</div>
</div>
<div class="row items-center">
<div class="col-4 q-pr-lg">Total to pay:</div>
<div class="col-8 q-pr-lg">
<q-badge color="blue">
<span v-text="charge.amount" class="text-subtitle2"></span> sat
</q-badge>
</div>
</div>
<div class="row items-center q-mt-sm">
<div class="col-4 q-pr-lg">Amount paid:</div>
<div class="col-8 q-pr-lg">
<q-badge color="orange">
<span v-text="charge.balance" class="text-subtitle2"></span>
sat</q-badge
>
</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 v-if="pendingFunds" class="row items-center q-mt-sm">
<div class="col-4 q-pr-lg">Amount pending:</div>
<div class="col-8 q-pr-lg">
<q-badge color="gray">
<span v-text="pendingFunds" class="text-subtitle2"></span> sat
</q-badge>
</div>
</div>
<div class="row items-center q-mt-sm">
<div class="col-4 q-pr-lg">Amount due:</div>
<div class="col-8 q-pr-lg">
<q-badge v-if="charge.amount - charge.balance > 0" color="green">
<span
v-text="charge.amount - charge.balance"
class="text-subtitle2"
></span>
sat
</q-badge>
<q-badge
v-else="charge.amount - charge.balance <= 0"
color="green"
class="text-subtitle2"
>
none</q-badge
>
</div>
</div>
</div>
</div>
<q-separator></q-separator>
<div class="row">
<div class="col">
<div class="row">
<div class="col">
<q-btn
flat
disable
v-if="!charge.lnbitswallet || charge.time_elapsed"
style="color: primary; width: 100%"
label="lightning⚡"
>
<q-tooltip>
bitcoin lightning payment method not available
</q-tooltip>
</q-btn>
<q-btn
flat
v-else
@click="payInvoice"
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 || charge.time_elapsed"
style="color: primary; width: 100%"
label="onchain⛓️"
>
<q-tooltip>
bitcoin onchain payment method not available
</q-tooltip>
</q-btn>
<q-btn
flat
v-else
@click="payOnchain"
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>
<q-card class="q-pa-lg" v-if="lnbtc">
<q-card-section class="q-pa-none">
<div class="row items-center q-mt-sm">
<div class="col-md-2 col-sm-0"></div>
<div class="col-md-8 col-sm-12">
<div v-if="!charge.timeLeft && !charge.paid">
<q-icon
name="block"
style="color: #ccc; font-size: 21.4em"
></q-icon>
</div>
<div v-else-if="charge.paid">
<q-icon
name="check"
style="color: green; font-size: 21.4em"
></q-icon>
<q-btn
outline
v-if="charge.webhook"
type="a"
:href="charge.completelink"
:label="charge.completelinktext"
></q-btn>
</div>
<div v-else>
<div class="row text-center q-mb-sm">
<div class="col text-center">
<span class="text-subtitle2"
>Pay this lightning-network invoice:</span
>
</div>
</div>
<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 text-center q-mt-lg">
<div class="col text-center">
<q-btn
outline
color="grey"
@click="copyText(charge.payment_request)"
>Copy invoice</q-btn
>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-2 col-sm-0"></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>
<div v-if="charge.timeLeft && !charge.paid" class="row items-center">
<div class="col text-center">
<a
style="color: unset"
:href="mempool_endpoint + '/address/' + charge.onchainaddress"
target="_blank"
><span
class="text-subtitle1"
v-text="charge.onchainaddress"
></span>
</a>
<div class="row q-mt-lg">
</div>
</div>
<div class="row items-center q-mt-md">
<div class="col-md-2 col-sm-0"></div>
<div class="col-md-8 col-sm-12 text-center">
<div v-if="!charge.timeLeft && !charge.paid">
<q-icon
name="block"
style="color: #ccc; font-size: 21.4em"
></q-icon>
</div>
<div v-else-if="charge.paid">
<q-icon
name="check"
style="color: green; font-size: 21.4em"
></q-icon>
<q-btn
outline
color="grey"
@click="copyText('{{ charge.onchainaddress }}')"
>Copy address</q-btn
>
v-if="charge.webhook"
type="a"
:href="charge.completelink"
:label="charge.completelinktext"
></q-btn>
</div>
<div v-else>
<div class="row items-center q-mb-sm">
<div class="col text-center">
<span class="text-subtitle2"
>Send
<span v-text="charge.amount"></span>
sats to this onchain address</span
>
</div>
</div>
<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 items-center q-mt-lg">
<div class="col text-center">
<q-btn
outline
color="grey"
@click="copyText(charge.onchainaddress)"
>Copy address</q-btn
>
</div>
</div>
</div>
</div>
<div class="col-md-2 col-sm-0"></div>
</div>
</q-card-section>
</q-card>
</q-card>
</div>
<div class="col-lg- 4 col-md-3 col-sm-1"></div>
</div>
{% endblock %} {% block scripts %}
<style>
.theCard {
width: 360px;
margin: 10px auto;
}
.theHeading {
margin: 15px;
font-size: 25px;
}
</style>
<script src="https://mempool.space/mempool.js"></script>
<script src="{{ url_for('satspay_static', path='js/utils.js') }}"></script>
<script>
Vue.component(VueQrcode.name, VueQrcode)
@@ -226,16 +302,14 @@
mixins: [windowMixin],
data() {
return {
charge: JSON.parse('{{charge_data | tojson}}'),
mempool_endpoint: '{{mempool_endpoint}}',
pendingFunds: 0,
ws: null,
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}}',
wallet: {
inkey: ''
},
@@ -245,90 +319,141 @@
methods: {
startPaymentNotifier() {
this.cancelListener()
this.cancelListener = LNbits.event.onInvoicePaid(
if (!this.lnbitswallet) return
this.cancelListener = LNbits.events.onInvoicePaid(
this.wallet,
payment => {
this.checkBalance()
this.checkInvoiceBalance()
}
)
},
checkBalance: function () {
var self = this
LNbits.api
.request(
checkBalances: async function () {
if (!this.charge.hasStaleBalance) await this.refreshCharge()
try {
const {data} = await LNbits.api.request(
'GET',
'/satspay/api/v1/charges/balance/{{ charge.id }}',
'filla'
`/satspay/api/v1/charge/balance/${this.charge.id}`
)
.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)
})
this.charge = mapCharge(data, this.charge)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
payLN: function () {
refreshCharge: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
`/satspay/api/v1/charge/${this.charge.id}`
)
this.charge = mapCharge(data, this.charge)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
checkPendingOnchain: async function () {
const {
bitcoin: {addresses: addressesAPI}
} = mempoolJS({
hostname: new URL(this.mempool_endpoint).hostname
})
try {
const utxos = await addressesAPI.getAddressTxsUtxo({
address: this.charge.onchainaddress
})
const newBalance = utxos.reduce((t, u) => t + u.value, 0)
this.charge.hasStaleBalance = this.charge.balance === newBalance
this.pendingFunds = utxos
.filter(u => !u.status.confirmed)
.reduce((t, u) => t + u.value, 0)
} catch (error) {
console.error('cannot check pending funds')
}
},
payInvoice: function () {
this.lnbtc = true
this.onbtc = false
},
payON: function () {
payOnchain: 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) {
loopRefresh: function () {
// invoice only
const refreshIntervalId = setInterval(async () => {
if (this.charge.paid || !this.charge.timeLeft) {
clearInterval(refreshIntervalId)
}
self.getTheTime()
self.getThePercentage()
self.counter++
if (self.counter % 10 === 0) {
self.checkBalance()
if (this.counter % 10 === 0) {
await this.checkBalances()
await this.checkPendingOnchain()
}
this.counter++
}, 1000)
},
initWs: async function () {
const {
bitcoin: {websocket}
} = mempoolJS({
hostname: new URL(this.mempool_endpoint).hostname
})
this.ws = new WebSocket('wss://mempool.space/api/v1/ws')
this.ws.addEventListener('open', x => {
if (this.charge.onchainaddress) {
this.trackAddress(this.charge.onchainaddress)
}
})
this.ws.addEventListener('message', async ({data}) => {
const res = JSON.parse(data.toString())
if (res['address-transactions']) {
await this.checkBalances()
this.$q.notify({
type: 'positive',
message: 'New payment received!',
timeout: 10000
})
}
})
},
loopPingWs: function () {
setInterval(() => {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) this.initWs()
this.ws.send(JSON.stringify({action: 'ping'}))
}, 30 * 1000)
},
trackAddress: async function (address, retry = 0) {
try {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) this.initWs()
this.ws.send(JSON.stringify({'track-address': address}))
} catch (error) {
await sleep(1000)
if (retry > 10) throw error
this.trackAddress(address, retry + 1)
}
}
},
created: function () {
console.log('{{ charge.onchainaddress }}' == 'None')
if ('{{ charge.lnbitswallet }}' == 'None') {
this.lnbtc = false
this.onbtc = true
}
created: async function () {
if (this.charge.lnbitswallet) this.payInvoice()
else this.payOnchain()
await this.checkBalances()
// empty for onchain
this.wallet.inkey = '{{ wallet_inkey }}'
this.getTheTime()
this.getThePercentage()
var timerCount = this.timerCount
if ('{{ charge.paid }}' == 'False') {
timerCount()
}
this.startPaymentNotifier()
if (!this.charge.paid) {
this.loopRefresh()
}
if (this.charge.onchainaddress) {
this.loopPingWs()
this.checkPendingOnchain()
this.trackAddress(this.charge.onchainaddress)
}
}
})
</script>
@@ -18,46 +18,54 @@
<h5 class="text-subtitle1 q-my-none">Charges</h5>
</div>
<div class="col-auto">
<div class="col q-pr-lg">
<q-input
borderless
dense
debounce="300"
v-model="filter"
placeholder="Search"
class="float-right"
>
<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 class="col-auto">
<q-btn outline color="grey" label="...">
<q-menu auto-close>
<q-list style="min-width: 100px">
<q-item clickable>
<q-item-section @click="exportchargeCSV"
>Export to CSV</q-item-section
>
</q-item>
</q-list>
</q-menu>
</q-btn>
</div>
</div>
<q-table
flat
dense
:data="ChargeLinks"
:data="chargeLinks"
row-key="id"
:columns="ChargesTable.columns"
:pagination.sync="ChargesTable.pagination"
: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>Status </q-th>
<q-th auto-width>Title</q-th>
<q-th auto-width>Time Left (hh:mm)</q-th>
<q-th auto-width>Time To Pay (hh:mm)</q-th>
<q-th auto-width>Amount To Pay</q-th>
<q-th auto-width>Balance</q-th>
<q-th auto-width>Pending Balance</q-th>
<q-th auto-width>Onchain Address</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
@@ -66,73 +74,179 @@
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
size="sm"
color="accent"
round
dense
size="xs"
icon="link"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
@click="props.row.expanded= !props.row.expanded"
:icon="props.row.expanded? 'remove' : 'add'"
/>
</q-td>
<q-td auto-width>
<q-badge
v-if="props.row.time_elapsed && props.row.balance < props.row.amount"
color="red"
>
<a
:href="props.row.displayUrl"
target="_blank"
style="color: unset; text-decoration: none"
>expired</a
>
</q-badge>
<q-badge
v-else-if="props.row.balance >= props.row.amount"
color="green"
>
<a
:href="props.row.displayUrl"
target="_blank"
style="color: unset; text-decoration: none"
>paid</a
>
</q-badge>
<q-badge v-else color="blue"
><a
:href="props.row.displayUrl"
target="_blank"
style="color: unset; text-decoration: none"
>waiting</a
>
</q-badge>
</q-td>
<q-td key="description" :props="props" :class="">
<a
:href="props.row.displayUrl"
target="_blank"
style="color: unset; text-decoration: none"
>{{props.row.description}}</a
>
<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-td key="timeLeft" :props="props" :class="">
<div>{{props.row.timeLeft}}</div>
<q-linear-progress
v-if="props.row.timeLeft"
:value="props.row.progress"
color="secondary"
>
<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-linear-progress>
</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 key="time to pay" :props="props" :class="">
<div>{{props.row.time}}</div>
</q-td>
<q-td key="amount" :props="props" :class="">
<div>{{props.row.amount}}</div>
</q-td>
<q-td key="balance" :props="props" :class="">
<div>{{props.row.balance}}</div>
</q-td>
<q-td key="pendingBalance" :props="props" :class="">
<div>
{{props.row.pendingBalance ? props.row.pendingBalance : ''}}
</div>
</q-td>
<q-td key="onchain address" :props="props" :class="">
<a
:href="props.row.displayUrl"
target="_blank"
style="color: unset; text-decoration: none"
>{{props.row.onchainaddress}}</a
>
</q-td>
</q-tr>
<q-tr v-show="props.row.expanded" :props="props">
<q-td colspan="100%">
<div
v-if="props.row.onchainwallet"
class="row items-center q-mt-md q-mb-lg"
>
<div class="col-2 q-pr-lg">Onchain Wallet:</div>
<div class="col-4 q-pr-lg">
{{getOnchainWalletName(props.row.onchainwallet)}}
</div>
</div>
<div
v-if="props.row.lnbitswallet"
class="row items-center q-mt-md q-mb-lg"
>
<div class="col-2 q-pr-lg">LNbits Wallet:</div>
<div class="col-4 q-pr-lg">
{{getLNbitsWalletName(props.row.lnbitswallet)}}
</div>
</div>
<div
v-if="props.row.completelink || props.row.completelinktext"
class="row items-center q-mt-md q-mb-lg"
>
<div class="col-2 q-pr-lg">Completed Link:</div>
<div class="col-4 q-pr-lg">
<a
:href="props.row.completelink"
target="_blank"
style="color: unset; text-decoration: none"
>{{props.row.completelinktext ||
props.row.completelink}}</a
>
</div>
</div>
<div
v-if="props.row.webhook"
class="row items-center q-mt-md q-mb-lg"
>
<div class="col-2 q-pr-lg">Webhook:</div>
<div class="col-4 q-pr-lg">
<a
:href="props.row.webhook"
target="_blank"
style="color: unset; text-decoration: none"
>{{props.row.webhook || props.row.webhook}}</a
>
</div>
</div>
<div class="row items-center q-mt-md q-mb-lg">
<div class="col-2 q-pr-lg">ID:</div>
<div class="col-4 q-pr-lg">{{props.row.id}}</div>
</div>
<div class="row items-center q-mt-md q-mb-lg">
<div class="col-2 q-pr-lg"></div>
<div class="col-6 q-pr-lg">
<q-btn
unelevated
color="gray"
outline
type="a"
:href="props.row.displayUrl"
target="_blank"
class="float-left q-mr-lg"
>Details</q-btn
>
<q-btn
unelevated
color="gray"
outline
type="a"
@click="refreshBalance(props.row)"
target="_blank"
class="float-left"
>Refresh Balance</q-btn
>
</div>
<div class="col-4 q-pr-lg">
<q-btn
unelevated
color="pink"
icon="cancel"
@click="deleteChargeLink(props.row.id)"
>Delete</q-btn
>
</div>
<div class="col-4"></div>
<div class="col-2 q-pr-lg"></div>
</div>
</q-td>
</q-tr>
</template>
@@ -155,11 +269,7 @@
</q-card-section>
</q-card>
</div>
<q-dialog
v-model="formDialogCharge.show"
position="top"
@hide="closeFormDialog"
>
<q-dialog v-model="formDialogCharge.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormDataCharge" class="q-gutter-md">
<q-input
@@ -225,7 +335,8 @@
<div v-else>
<q-checkbox :value="false" label="Onchain" disabled>
<q-tooltip>
Watch-Only extension MUST be activated and have a wallet
Onchain Wallet (watch-only) extension MUST be activated and
have a wallet
</q-tooltip>
</q-checkbox>
</div>
@@ -245,7 +356,7 @@
filled
dense
emit-value
v-model="formDialogCharge.data.onchainwallet"
v-model="onchainwallet"
:options="walletLinks"
label="Onchain Wallet"
/>
@@ -283,49 +394,28 @@
<!-- lnbits/static/vendor
<script src="/vendor/vue-qrcode@1.0.2/vue-qrcode.min.js"></script> -->
<style></style>
<!-- todo: use config mempool -->
<script src="https://mempool.space/mempool.js"></script>
<script src="{{ url_for('satspay_static', path='js/utils.js') }}"></script>
<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: [],
chargeLinks: [],
onchainwallet: '',
currentaddress: '',
Addresses: {
show: false,
data: null
},
rescanning: false,
mempool: {
endpoint: ''
},
ChargesTable: {
chargesTable: {
columns: [
{
name: 'theId',
@@ -340,10 +430,10 @@
field: 'description'
},
{
name: 'timeleft',
name: 'timeLeft',
align: 'left',
label: 'Time left',
field: 'date'
field: 'timeLeft'
},
{
name: 'time to pay',
@@ -363,6 +453,12 @@
label: 'Balance',
field: 'balance'
},
{
name: 'pendingBalance',
align: 'left',
label: 'Pending Balance',
field: 'pendingBalance'
},
{
name: 'onchain address',
align: 'left',
@@ -392,172 +488,218 @@
rowsPerPage: 10
}
},
formDialog: {
show: false,
data: {}
},
formDialogCharge: {
show: false,
data: {
onchain: false,
onchainwallet: '',
lnbits: false,
description: '',
time: null,
amount: null
}
}
}
},
methods: {
cancelCharge: function (data) {
this.formDialogCharge.data.description = ''
this.formDialogCharge.data.onchainwallet = ''
this.formDialogCharge.data.lnbitswallet = ''
this.formDialogCharge.data.time = null
this.formDialogCharge.data.amount = null
this.formDialogCharge.data.webhook = ''
this.formDialogCharge.data.completelink = ''
this.formDialogCharge.show = false
},
getWalletLinks: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/watchonly/api/v1/wallet',
this.g.user.wallets[0].inkey
)
this.walletLinks = data.map(w => ({
id: w.id,
label: w.title + ' - ' + w.id
}))
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
getWalletConfig: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/watchonly/api/v1/config',
this.g.user.wallets[0].inkey
)
this.mempool.endpoint = data.mempool_endpoint
const url = new URL(this.mempool.endpoint)
this.mempool.hostname = url.hostname
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
getOnchainWalletName: function (walletId) {
const wallet = this.walletLinks.find(w => w.id === walletId)
if (!wallet) return 'unknown'
return wallet.label
},
getLNbitsWalletName: function (walletId) {
const wallet = this.g.user.walletOptions.find(w => w.value === walletId)
if (!wallet) return 'unknown'
return wallet.label
},
getCharges: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/satspay/api/v1/charges',
this.g.user.wallets[0].inkey
)
this.chargeLinks = data.map(c =>
mapCharge(
c,
this.chargeLinks.find(old => old.id === c.id)
)
)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
sendFormDataCharge: function () {
const wallet = this.g.user.wallets[0].inkey
const data = this.formDialogCharge.data
data.amount = parseInt(data.amount)
data.time = parseInt(data.time)
data.onchainwallet = this.onchainwallet?.id
this.createCharge(wallet, data)
},
refreshActiveChargesBalance: async function () {
try {
const activeLinkIds = this.chargeLinks
.filter(c => !c.paid && !c.time_elapsed && !c.hasStaleBalance)
.map(c => c.id)
.join(',')
if (activeLinkIds) {
await LNbits.api.request(
'GET',
'/satspay/api/v1/charges/balance/' + activeLinkIds,
'filla'
)
}
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
await this.getCharges()
}
},
refreshBalance: async function (charge) {
try {
const {data} = await LNbits.api.request(
'GET',
'/satspay/api/v1/charge/balance/' + charge.id,
'filla'
)
charge.balance = data.balance
} catch (error) {}
},
rescanOnchainAddresses: async function () {
if (this.rescanning) return
this.rescanning = true
const {
bitcoin: {addresses: addressesAPI}
} = mempoolJS({hostname: this.mempool.hostname})
try {
const onchainActiveCharges = this.chargeLinks.filter(
c => c.onchainaddress && !c.paid && !c.time_elapsed
)
for (const charge of onchainActiveCharges) {
const fn = async () =>
addressesAPI.getAddressTxsUtxo({
address: charge.onchainaddress
})
const utxos = await retryWithDelay(fn)
const newBalance = utxos.reduce((t, u) => t + u.value, 0)
charge.pendingBalance = utxos
.filter(u => !u.status.confirmed)
.reduce((t, u) => t + u.value, 0)
charge.hasStaleBalance = charge.balance === newBalance
}
} catch (error) {
console.error(error)
} finally {
this.rescanning = false
}
},
createCharge: async function (wallet, data) {
try {
const resp = await LNbits.api.request(
'POST',
'/satspay/api/v1/charge',
wallet,
data
)
this.chargeLinks.unshift(mapCharge(resp.data))
this.formDialogCharge.show = false
this.formDialogCharge.data = {
onchain: false,
lnbits: false,
description: '',
time: null,
amount: null
}
},
qrCodeDialog: {
show: false,
data: null
} catch (error) {
LNbits.utils.notifyApiError(error)
}
}
},
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].inkey
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
self.formDialogCharge.data = {
onchain: false,
lnbits: false,
description: '',
time: null,
amount: null
}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteChargeLink: function (chargeId) {
var self = this
var link = _.findWhere(this.ChargeLinks, {id: chargeId})
const link = _.findWhere(this.chargeLinks, {id: chargeId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this pay link?')
.onOk(function () {
LNbits.api
.request(
.onOk(async () => {
try {
const response = await LNbits.api.request(
'DELETE',
'/satspay/api/v1/charge/' + chargeId,
self.g.user.wallets[0].adminkey
this.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)
this.chargeLinks = _.reject(this.chargeLinks, function (obj) {
return obj.id === chargeId
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
})
},
exportchargeCSV: function () {
var self = this
LNbits.utils.exportCSV(self.ChargesTable.columns, this.ChargeLinks)
LNbits.utils.exportCSV(
this.chargesTable.columns,
this.chargeLinks,
'charges'
)
}
},
created: function () {
console.log(this.g.user)
var self = this
var getCharges = this.getCharges
getCharges()
var getWalletLinks = this.getWalletLinks
getWalletLinks()
var timerCount = this.timerCount
timerCount()
created: async function () {
await this.getCharges()
await this.getWalletLinks()
await this.getWalletConfig()
setInterval(() => this.refreshActiveChargesBalance(), 10 * 2000)
await this.rescanOnchainAddresses()
setInterval(() => this.rescanOnchainAddresses(), 10 * 1000)
}
})
</script>
+10 -2
View File
@@ -9,6 +9,7 @@ from starlette.responses import HTMLResponse
from lnbits.core.crud import get_wallet
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from lnbits.extensions.watchonly.crud import get_config
from . import satspay_ext, satspay_renderer
from .crud import get_charge
@@ -24,14 +25,21 @@ async def index(request: Request, user: User = Depends(check_user_exists)):
@satspay_ext.get("/{charge_id}", response_class=HTMLResponse)
async def display(request: Request, charge_id):
async def display(request: Request, charge_id: str):
charge = await get_charge(charge_id)
if not charge:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Charge link does not exist."
)
wallet = await get_wallet(charge.lnbitswallet)
onchainwallet_config = await get_config(charge.user)
inkey = wallet.inkey if wallet else None
return satspay_renderer().TemplateResponse(
"satspay/display.html",
{"request": request, "charge": charge, "wallet_key": wallet.inkey},
{
"request": request,
"charge_data": charge.dict(),
"wallet_inkey": inkey,
"mempool_endpoint": onchainwallet_config.mempool_endpoint,
},
)
+25 -24
View File
@@ -1,7 +1,6 @@
from http import HTTPStatus
import httpx
from fastapi import Query
from fastapi.params import Depends
from starlette.exceptions import HTTPException
@@ -31,7 +30,12 @@ async def api_charge_create(
data: CreateCharge, wallet: WalletTypeInfo = Depends(require_invoice_key)
):
charge = await create_charge(user=wallet.wallet.user, data=data)
return charge.dict()
return {
**charge.dict(),
**{"time_elapsed": charge.time_elapsed},
**{"time_left": charge.time_left},
**{"paid": charge.paid},
}
@satspay_ext.put("/api/v1/charge/{charge_id}")
@@ -51,6 +55,7 @@ async def api_charges_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)):
{
**charge.dict(),
**{"time_elapsed": charge.time_elapsed},
**{"time_left": charge.time_left},
**{"paid": charge.paid},
}
for charge in await get_charges(wallet.wallet.user)
@@ -73,6 +78,7 @@ async def api_charge_retrieve(
return {
**charge.dict(),
**{"time_elapsed": charge.time_elapsed},
**{"time_left": charge.time_left},
**{"paid": charge.paid},
}
@@ -93,9 +99,18 @@ async def api_charge_delete(charge_id, wallet: WalletTypeInfo = Depends(get_key_
#############################BALANCE##########################
@satspay_ext.get("/api/v1/charges/balance/{charge_id}")
async def api_charges_balance(charge_id):
@satspay_ext.get("/api/v1/charges/balance/{charge_ids}")
async def api_charges_balance(charge_ids):
charge_id_list = charge_ids.split(",")
charges = []
for charge_id in charge_id_list:
charge = await api_charge_balance(charge_id)
charges.append(charge)
return charges
@satspay_ext.get("/api/v1/charge/balance/{charge_id}")
async def api_charge_balance(charge_id):
charge = await check_address_balance(charge_id)
if not charge:
@@ -125,23 +140,9 @@ async def api_charges_balance(charge_id):
)
except AssertionError:
charge.webhook = None
return charge.dict()
#############################MEMPOOL##########################
@satspay_ext.put("/api/v1/mempool")
async def api_update_mempool(
endpoint: str = Query(...), wallet: WalletTypeInfo = Depends(get_key_type)
):
mempool = await update_mempool(endpoint, user=wallet.wallet.user)
return mempool.dict()
@satspay_ext.route("/api/v1/mempool")
async def api_get_mempool(wallet: WalletTypeInfo = Depends(get_key_type)):
mempool = await get_mempool(wallet.wallet.user)
if not mempool:
mempool = await create_mempool(user=wallet.wallet.user)
return mempool.dict()
return {
**charge.dict(),
**{"time_elapsed": charge.time_elapsed},
**{"time_left": charge.time_left},
**{"paid": charge.paid},
}
+28
View File
@@ -0,0 +1,28 @@
# Scrub
## Automatically forward funds (Scrub) that get paid to the wallet to an LNURLpay or Lightning Address
SCRUB is a small but handy extension that allows a user to take advantage of all the functionalities inside **LNbits** and upon a payment received to your LNbits wallet, automatically forward it to your desired wallet via LNURL or LNAddress!
[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets)
## Usage
1. Create an scrub (New Scrub link)\
![create scrub](https://i.imgur.com/LUeNkzM.jpg)
- select the wallet to be _scrubbed_
- make a small description
- enter either an LNURL pay or a lightning address
Make sure your LNURL or LNaddress is correct!
2. A new scrub will show on the _Scrub links_ section\
![scrub](https://i.imgur.com/LNoFkeu.jpg)
- only one scrub can be created for each wallet!
- You can _edit_ or _delete_ the Scrub at any time\
![edit scrub](https://i.imgur.com/Qu65lGG.jpg)
3. On your wallet, you'll see a transaction of a payment received and another right after it as apayment sent, marked with **#scrubed**\
![wallet view](https://i.imgur.com/S6EWWCP.jpg)
+34
View File
@@ -0,0 +1,34 @@
import asyncio
from fastapi import APIRouter
from fastapi.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_scrub")
scrub_static_files = [
{
"path": "/scrub/static",
"app": StaticFiles(directory="lnbits/extensions/scrub/static"),
"name": "scrub_static",
}
]
scrub_ext: APIRouter = APIRouter(prefix="/scrub", tags=["scrub"])
def scrub_renderer():
return template_renderer(["lnbits/extensions/scrub/templates"])
from .tasks import wait_for_paid_invoices
from .views import * # noqa
from .views_api import * # noqa
def scrub_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Scrub",
"short_description": "Pass payments to LNURLp/LNaddress",
"icon": "send",
"contributors": ["arcbtc", "talvasconcelos"]
}
+80
View File
@@ -0,0 +1,80 @@
from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import CreateScrubLink, ScrubLink
async def create_scrub_link(data: CreateScrubLink) -> ScrubLink:
scrub_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO scrub.scrub_links (
id,
wallet,
description,
payoraddress
)
VALUES (?, ?, ?, ?)
""",
(
scrub_id,
data.wallet,
data.description,
data.payoraddress,
),
)
link = await get_scrub_link(scrub_id)
assert link, "Newly created link couldn't be retrieved"
return link
async def get_scrub_link(link_id: str) -> Optional[ScrubLink]:
row = await db.fetchone("SELECT * FROM scrub.scrub_links WHERE id = ?", (link_id,))
return ScrubLink(**row) if row else None
async def get_scrub_links(wallet_ids: Union[str, List[str]]) -> List[ScrubLink]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"""
SELECT * FROM scrub.scrub_links WHERE wallet IN ({q})
ORDER BY id
""",
(*wallet_ids,),
)
return [ScrubLink(**row) for row in rows]
async def update_scrub_link(link_id: int, **kwargs) -> Optional[ScrubLink]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
f"UPDATE scrub.scrub_links SET {q} WHERE id = ?",
(*kwargs.values(), link_id),
)
row = await db.fetchone("SELECT * FROM scrub.scrub_links WHERE id = ?", (link_id,))
return ScrubLink(**row) if row else None
async def delete_scrub_link(link_id: int) -> None:
await db.execute("DELETE FROM scrub.scrub_links WHERE id = ?", (link_id,))
async def get_scrub_by_wallet(wallet_id) -> Optional[ScrubLink]:
row = await db.fetchone(
"SELECT * from scrub.scrub_links WHERE wallet = ?",
(wallet_id,),
)
return ScrubLink(**row) if row else None
async def unique_scrubed_wallet(wallet_id):
(row,) = await db.fetchone(
"SELECT COUNT(wallet) FROM scrub.scrub_links WHERE wallet = ?",
(wallet_id,),
)
return row
+14
View File
@@ -0,0 +1,14 @@
async def m001_initial(db):
"""
Initial scrub table.
"""
await db.execute(
f"""
CREATE TABLE scrub.scrub_links (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
description TEXT NOT NULL,
payoraddress TEXT NOT NULL
);
"""
)
+28
View File
@@ -0,0 +1,28 @@
from sqlite3 import Row
from pydantic import BaseModel
from starlette.requests import Request
from lnbits.lnurl import encode as lnurl_encode # type: ignore
class CreateScrubLink(BaseModel):
wallet: str
description: str
payoraddress: str
class ScrubLink(BaseModel):
id: str
wallet: str
description: str
payoraddress: str
@classmethod
def from_row(cls, row: Row) -> "ScrubLink":
data = dict(row)
return cls(**data)
def lnurl(self, req: Request) -> str:
url = req.url_for("scrub.api_lnurl_response", link_id=self.id)
return lnurl_encode(url)
+143
View File
@@ -0,0 +1,143 @@
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
Vue.component(VueQrcode.name, VueQrcode)
var locationPath = [
window.location.protocol,
'//',
window.location.host,
window.location.pathname
].join('')
var mapScrubLink = obj => {
obj._data = _.clone(obj)
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.amount = new Intl.NumberFormat(LOCALE).format(obj.amount)
obj.print_url = [locationPath, 'print/', obj.id].join('')
obj.pay_url = [locationPath, obj.id].join('')
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data() {
return {
checker: null,
payLinks: [],
payLinksTable: {
pagination: {
rowsPerPage: 10
}
},
formDialog: {
show: false,
data: {}
},
qrCodeDialog: {
show: false,
data: null
}
}
},
methods: {
getScrubLinks() {
LNbits.api
.request(
'GET',
'/scrub/api/v1/links?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(response => {
this.payLinks = response.data.map(mapScrubLink)
})
.catch(err => {
clearInterval(this.checker)
LNbits.utils.notifyApiError(err)
})
},
closeFormDialog() {
this.resetFormData()
},
openUpdateDialog(linkId) {
const link = _.findWhere(this.payLinks, {id: linkId})
this.formDialog.data = _.clone(link._data)
this.formDialog.show = true
},
sendFormData() {
const wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet
})
let data = Object.freeze(this.formDialog.data)
console.log(wallet, data)
if (data.id) {
this.updateScrubLink(wallet, data)
} else {
this.createScrubLink(wallet, data)
}
},
resetFormData() {
this.formDialog = {
show: false,
data: {}
}
},
updateScrubLink(wallet, data) {
LNbits.api
.request('PUT', '/scrub/api/v1/links/' + data.id, wallet.adminkey, data)
.then(response => {
this.payLinks = _.reject(this.payLinks, obj => obj.id === data.id)
this.payLinks.push(mapScrubLink(response.data))
this.formDialog.show = false
this.resetFormData()
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
},
createScrubLink(wallet, data) {
LNbits.api
.request('POST', '/scrub/api/v1/links', wallet.adminkey, data)
.then(response => {
console.log('RES', response)
this.getScrubLinks()
this.formDialog.show = false
this.resetFormData()
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
},
deleteScrubLink(linkId) {
var link = _.findWhere(this.payLinks, {id: linkId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this pay link?')
.onOk(() => {
LNbits.api
.request(
'DELETE',
'/scrub/api/v1/links/' + linkId,
_.findWhere(this.g.user.wallets, {id: link.wallet}).adminkey
)
.then(response => {
this.payLinks = _.reject(this.payLinks, obj => obj.id === linkId)
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
})
}
},
created() {
if (this.g.user.wallets.length) {
var getScrubLinks = this.getScrubLinks
getScrubLinks()
}
}
})
+85
View File
@@ -0,0 +1,85 @@
import asyncio
import json
from http import HTTPStatus
from urllib.parse import urlparse
import httpx
from fastapi import HTTPException
from lnbits import bolt11
from lnbits.core.models import Payment
from lnbits.core.services import pay_invoice
from lnbits.tasks import register_invoice_listener
from .crud import get_scrub_by_wallet
async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue)
while True:
payment = await invoice_queue.get()
await on_invoice_paid(payment)
async def on_invoice_paid(payment: Payment) -> None:
# (avoid loops)
if "scrubed" == payment.extra.get("tag"):
# already scrubbed
return
scrub_link = await get_scrub_by_wallet(payment.wallet_id)
if not scrub_link:
return
from lnbits.core.views.api import api_lnurlscan
# DECODE LNURLP OR LNADDRESS
data = await api_lnurlscan(scrub_link.payoraddress)
# I REALLY HATE THIS DUPLICATION OF CODE!! CORE/VIEWS/API.PY, LINE 267
domain = urlparse(data["callback"]).netloc
async with httpx.AsyncClient() as client:
try:
r = await client.get(
data["callback"],
params={"amount": payment.amount},
timeout=40,
)
if r.is_error:
raise httpx.ConnectError
except (httpx.ConnectError, httpx.RequestError):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Failed to connect to {domain}.",
)
params = json.loads(r.text)
if params.get("status") == "ERROR":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} said: '{params.get('reason', '')}'",
)
invoice = bolt11.decode(params["pr"])
if invoice.amount_msat != payment.amount:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} returned an invalid invoice. Expected {payment.amount} msat, got {invoice.amount_msat}.",
)
payment_hash = await pay_invoice(
wallet_id=payment.wallet_id,
payment_request=params["pr"],
description=data["description"],
extra={"tag": "scrubed"},
)
return {
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
}
@@ -0,0 +1,136 @@
<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 scrubs">
<q-card>
<q-card-section>
<code><span class="text-blue">GET</span> /scrub/api/v1/links</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;pay_link_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}scrub/api/v1/links?all_wallets=true
-H "X-Api-Key: {{ user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Get a scrub">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/scrub/api/v1/links/&lt;scrub_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
>{"id": &lt;string&gt;, "wallet": &lt;string&gt;, "description":
&lt;string&gt;, "payoraddress": &lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}scrub/api/v1/links/&lt;pay_id&gt;
-H "X-Api-Key: {{ user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Create a scrub">
<q-card>
<q-card-section>
<code><span class="text-green">POST</span> /scrub/api/v1/links</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>
<code
>{"wallet": &lt;string&gt;, "description": &lt;string&gt;,
"payoraddress": &lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code
>{"id": &lt;string&gt;, "wallet": &lt;string&gt;, "description":
&lt;string&gt;, "payoraddress": &lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}scrub/api/v1/links -d '{"wallet":
&lt;string&gt;, "description": &lt;string&gt;, "payoraddress":
&lt;string&gt;}' -H "Content-type: application/json" -H "X-Api-Key: {{
user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Update a scrub">
<q-card>
<q-card-section>
<code
><span class="text-green">PUT</span>
/scrub/api/v1/links/&lt;pay_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>
<code
>{"wallet": &lt;string&gt;, "description": &lt;string&gt;,
"payoraddress": &lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code
>{"id": &lt;string&gt;, "wallet": &lt;string&gt;, "description":
&lt;string&gt;, "payoraddress": &lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X PUT {{ request.base_url }}scrub/api/v1/links/&lt;pay_id&gt;
-d '{"wallet": &lt;string&gt;, "description": &lt;string&gt;,
"payoraddress": &lt;string&gt;}' -H "Content-type: application/json"
-H "X-Api-Key: {{ user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Delete a scrub"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-pink">DELETE</span>
/scrub/api/v1/links/&lt;pay_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.base_url
}}scrub/api/v1/links/&lt;pay_id&gt; -H "X-Api-Key: {{
user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>
@@ -0,0 +1,28 @@
<q-expansion-item group="extras" icon="info" label="Powered by LNURL">
<q-card>
<q-card-section>
<p>
<b>WARNING: LNURL must be used over https or TOR</b><br />
LNURL is a range of lightning-network standards that allow us to use
lightning-network differently. An LNURL-pay is a link that wallets use
to fetch an invoice from a server on-demand. The link or QR code is
fixed, but each time it is read by a compatible wallet a new QR code is
issued by the service. It can be used to activate machines without them
having to maintain an electronic screen to generate and show invoices
locally, or to sell any predefined good or service automatically.
</p>
<p>
Exploring LNURL and finding use cases, is really helping inform
lightning protocol development, rather than the protocol dictating how
lightning-network should be engaged with.
</p>
<small
>Check
<a href="https://github.com/fiatjaf/awesome-lnurl" target="_blank"
>Awesome LNURL</a
>
for further information.</small
>
</q-card-section>
</q-card>
</q-expansion-item>
@@ -0,0 +1,140 @@
{% 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>
<q-btn unelevated color="primary" @click="formDialog.show = true"
>New scrub link</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">Scrub links</h5>
</div>
</div>
<q-table
dense
flat
:data="payLinks"
row-key="id"
:pagination.sync="payLinksTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props" style="text-align: left">
<q-th>Wallet</q-th>
<q-th>Description</q-th>
<q-th>LNURLPay/Address</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td>{{ props.row.wallet }}</q-td>
<q-td>{{ props.row.description }}</q-td>
<q-td>{{ props.row.payoraddress }}</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
flat
dense
size="xs"
@click="deleteScrubLink(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</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">{{SITE_TITLE}} Scrub extension</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
{% include "scrub/_api_docs.html" %}
<q-separator></q-separator>
{% include "scrub/_lnurl.html" %}
</q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormData" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="formDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
>
</q-select>
<q-input
filled
dense
v-model.trim="formDialog.data.description"
type="text"
label="Description *"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.payoraddress"
type="text"
label="LNURLPay or LNAdress *"
></q-input>
<div class="row q-mt-lg">
<q-btn
v-if="formDialog.data.id"
unelevated
color="primary"
type="submit"
>Update pay link</q-btn
>
<q-btn
v-else
unelevated
color="primary"
:disable="
formDialog.data.wallet == null ||
formDialog.data.description == null ||
formDialog.data.payoraddress == null
"
type="submit"
>Create pay link</q-btn
>
<q-btn v-close-popup 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="/scrub/static/js/index.js"></script>
{% endblock %}
+18
View File
@@ -0,0 +1,18 @@
from fastapi import Request
from fastapi.params import Depends
from fastapi.templating import Jinja2Templates
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import scrub_ext, scrub_renderer
templates = Jinja2Templates(directory="templates")
@scrub_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return scrub_renderer().TemplateResponse(
"scrub/index.html", {"request": request, "user": user.dict()}
)
+112
View File
@@ -0,0 +1,112 @@
from http import HTTPStatus
from fastapi import Request
from fastapi.param_functions import Query
from fastapi.params import Depends
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from . import scrub_ext
from .crud import (
create_scrub_link,
delete_scrub_link,
get_scrub_link,
get_scrub_links,
unique_scrubed_wallet,
update_scrub_link,
)
from .models import CreateScrubLink
@scrub_ext.get("/api/v1/links", status_code=HTTPStatus.OK)
async def api_links(
req: Request,
wallet: WalletTypeInfo = Depends(get_key_type),
all_wallets: bool = Query(False),
):
wallet_ids = [wallet.wallet.id]
if all_wallets:
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
try:
return [link.dict() for link in await get_scrub_links(wallet_ids)]
except:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="No SCRUB links made yet",
)
@scrub_ext.get("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
async def api_link_retrieve(
r: Request, link_id, wallet: WalletTypeInfo = Depends(get_key_type)
):
link = await get_scrub_link(link_id)
if not link:
raise HTTPException(
detail="Scrub link does not exist.", status_code=HTTPStatus.NOT_FOUND
)
if link.wallet != wallet.wallet.id:
raise HTTPException(
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
)
return link
@scrub_ext.post("/api/v1/links", status_code=HTTPStatus.CREATED)
@scrub_ext.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
async def api_scrub_create_or_update(
data: CreateScrubLink,
link_id=None,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
if link_id:
link = await get_scrub_link(link_id)
if not link:
raise HTTPException(
detail="Scrub link does not exist.", status_code=HTTPStatus.NOT_FOUND
)
if link.wallet != wallet.wallet.id:
raise HTTPException(
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
)
link = await update_scrub_link(**data.dict(), link_id=link_id)
else:
wallet_has_scrub = await unique_scrubed_wallet(wallet_id=data.wallet)
if wallet_has_scrub > 0:
raise HTTPException(
detail="Wallet is already being Scrubbed",
status_code=HTTPStatus.FORBIDDEN,
)
link = await create_scrub_link(data=data)
return link
@scrub_ext.delete("/api/v1/links/{link_id}")
async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(require_admin_key)):
link = await get_scrub_link(link_id)
if not link:
raise HTTPException(
detail="Scrub link does not exist.", status_code=HTTPStatus.NOT_FOUND
)
if link.wallet != wallet.wallet.id:
raise HTTPException(
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
)
await delete_scrub_link(link_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
+1 -1
View File
@@ -18,7 +18,7 @@ In the "Whitelist Users" field, input the username of a Twitch account you contr
For now, simply set the "Redirect URI" to `http://localhost`, you will change this soon.
Then, hit create:
![image](https://user-images.githubusercontent.com/28876473/127759264-ae91539a-5694-4096-a478-80eb02b7b594.png)
1. In LNbits, enable the Stream Alerts extension and optionally the SatsPayServer (to observe donations directly) and Watch Only (to accept on-chain donations) extenions:
1. In LNbits, enable the Stream Alerts extension and optionally the SatsPayServer (to observe donations directly) and Onchain Wallet (watch-only) (to accept on-chain donations) extenions:
![image](https://user-images.githubusercontent.com/28876473/127759486-0e3420c2-c498-4bf9-932e-0abfa17bd478.png)
1. Create a "NEW SERVICE" using the button. Fill in all the information (you get your Client ID and Secret from the Streamlabs App page):
![image](https://user-images.githubusercontent.com/28876473/127759512-8e8b4e90-2a64-422a-bf0a-5508d0630bed.png)
@@ -168,7 +168,8 @@
<div v-else>
<q-checkbox :value="false" label="Chain" disabled>
<q-tooltip>
Watch-Only extension MUST be activated and have a wallet
Onchain Wallet (watch-only) extension MUST be activated and
have a wallet
</q-tooltip>
</q-checkbox>
</div>
+79 -13
View File
@@ -1,19 +1,85 @@
# Watch Only wallet
# Onchain Wallet (watch-only)
## Monitor an onchain wallet and generate addresses for onchain payments
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.
1. Start by clicking "NEW WALLET"\
![new wallet](https://i.imgur.com/vgbAB7c.png)
2. Fill the requested fields:
- give the wallet a name
- paste an Extended Public Key (xpub, ypub, zpub)
- click "CREATE WATCH-ONLY WALLET"\
![fill wallet form](https://i.imgur.com/UVoG7LD.png)
3. You can then access your onchain addresses\
![get address](https://i.imgur.com/zkxTQ6l.png)
4. You can then generate bitcoin onchain adresses from LNbits\
![onchain address](https://i.imgur.com/4KVSSJn.png)
You can now use this wallet on the LNBits [SatsPayServer](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/satspay/README.md) extension
### Wallet Account
- a user can add one or more `xPubs` or `descriptors`
- the `xPub` fingerprint must be unique per user
- such and entry is called an `Wallet Account`
- the addresses in a `Wallet Account` are split into `Receive Addresses` and `Change Address`
- the user interacts directly only with the `Receive Addresses` (by sharing them)
- see [BIP44](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki#account-discovery) for more details
- same `xPub` will always generate the same addresses (deterministic)
- when a `Wallet Account` is created, there are generated `20 Receive Addresses` and `5 Change Address`
- the limits can be change from the `Config` page (see `screenshot 1`)
- regular wallets only scan up to `20` empty receive addresses. If the user generates addresses beyond this limit a warning is shown (see `screenshot 4`)
### Scan Blockchain
- when the user clicks `Scan Blockchain`, the wallet will loop over the all addresses (for each account)
- if funds are found, then the list is extended
- will scan addresses for all wallet accounts
- the search is done on the client-side (using the `mempool.space` API). `mempool.space` has a limit on the number of req/sec, therefore it is expected for the scanning to start fast, but slow down as more HTTP requests have to be retried
- addresses can also be rescanned individually form the `Address Details` section (`Addresses` tab) of each address
### New Receive Address
- the `New Receive Address` button show the user the NEXT un-used address
- un-used means funds have not already been sent to that address AND the address has not already been shared
- internally there is a counter that keeps track of the last shared address
- it is possible to add a `Note` to each address in order to remember when/with whom it was shared
- mind the gap (`screenshot 4`)
### Addresses Tab
- the `Addresses` tab contains a list with the addresses for all the `Wallet Accounts`
- only one entry per address will be shown (even if there are multiple UTXOs at that address)
- several filter criteria can be applied
- unconfirmed funds are also taken into account
- `Address Details` can be viewed by clicking the `Expand` button
### History Tap
- shows the chronological order of transactions
- it shows unconfirmed transactions at the top
- it can be exported as CSV file
### Coins Tab
- shows the UTXOs for all wallets
- there can be multiple UTXOs for the same address
### Make Payment
- create a new `Partially Signed Bitcoin Transaction`
- multiple `Send Addresses` can be added
- the `Max` button next to an address is for sending the remaining funds to this address (no change)
- the user can select the inputs (UTXOs) manually, or it can use of the basic selection algorithms
- amounts have to be provided for the `Send Addresses` beforehand (so the algorithm knows the amount to be selected)
- `Show Advanced` allows to (see `screenshot 2`):
- select from which account the change address will be selected (defaults to the first one)
- select the `Fee Rate`
- it defaults to the `Medium` value at the moment the `Make Payment` button was clicked
- it can be refreshed
- warnings are shown if the fee is too Low or to High
### Create PSBT
- based on the Inputs & Outputs selected by the user a PSBT will be generated
- this wallet is watch-only, therefore does not support signing
- it is not mandatory for the `Selected Amount` to be grater than `Payed Amount`
- the generated PSBT can be combined with other PSBTs that add more inputs.
- the generated PSBT can be imported for signing into different wallets like Electrum
- import the PSBT into Electrum and check the In/Outs/Fee (see `screenshot 3`)
## Screensots
- screenshot 1:
![image](https://user-images.githubusercontent.com/2951406/177181611-eeeac70c-c245-4b45-b80b-8bbb511f6d1d.png)
- screenshot 2:
![image](https://user-images.githubusercontent.com/2951406/177331468-f9b43626-548a-4608-b0d0-44007f402404.png)
- screenshot 3:
![image](https://user-images.githubusercontent.com/2951406/177333755-4a9118fb-3eaf-43d6-bc7e-c3d8c80bc61e.png)
- screenshot 4:
![image](https://user-images.githubusercontent.com/2951406/177337474-bfcf7a7c-501a-4ebb-916e-ca391e63f6a7.png)
+8
View File
@@ -1,10 +1,18 @@
from fastapi import APIRouter
from fastapi.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
db = Database("ext_watchonly")
watchonly_static_files = [
{
"path": "/watchonly/static",
"app": StaticFiles(directory="lnbits/extensions/watchonly/static"),
"name": "watchonly_static",
}
]
watchonly_ext: APIRouter = APIRouter(prefix="/watchonly", tags=["watchonly"])
+3 -2
View File
@@ -1,8 +1,9 @@
{
"name": "Watch Only",
"name": "Onchain Wallet",
"short_description": "Onchain watch only wallets",
"icon": "visibility",
"contributors": [
"arcbtc"
"arcbtc",
"motorina0"
]
}
+157 -129
View File
@@ -1,81 +1,16 @@
import json
from typing import List, Optional
from embit.descriptor import Descriptor, Key # type: ignore
from embit.descriptor.arguments import AllowedDerivation # type: ignore
from embit.networks import NETWORKS # type: ignore
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import Addresses, Mempool, Wallets
from .helpers import derive_address, parse_key
from .models import Address, Config, Mempool, WalletAccount
##########################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)
async def create_watch_wallet(w: WalletAccount) -> WalletAccount:
wallet_id = urlsafe_short_hash()
await db.execute(
"""
@@ -83,34 +18,44 @@ async def create_watch_wallet(user: str, masterpub: str, title: str) -> Wallets:
id,
"user",
masterpub,
fingerprint,
title,
type,
address_no,
balance
)
VALUES (?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
# address_no is -1 so fresh address on empty wallet can get address with index 0
(wallet_id, user, masterpub, title, -1, 0),
(
wallet_id,
w.user,
w.masterpub,
w.fingerprint,
w.title,
w.type,
w.address_no,
w.balance,
),
)
return await get_watch_wallet(wallet_id)
async def get_watch_wallet(wallet_id: str) -> Optional[Wallets]:
async def get_watch_wallet(wallet_id: str) -> Optional[WalletAccount]:
row = await db.fetchone(
"SELECT * FROM watchonly.wallets WHERE id = ?", (wallet_id,)
)
return Wallets.from_row(row) if row else None
return WalletAccount.from_row(row) if row else None
async def get_watch_wallets(user: str) -> List[Wallets]:
async def get_watch_wallets(user: str) -> List[WalletAccount]:
rows = await db.fetchall(
"""SELECT * FROM watchonly.wallets WHERE "user" = ?""", (user,)
)
return [Wallets(**row) for row in rows]
return [WalletAccount(**row) for row in rows]
async def update_watch_wallet(wallet_id: str, **kwargs) -> Optional[Wallets]:
async def update_watch_wallet(wallet_id: str, **kwargs) -> Optional[WalletAccount]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
@@ -119,94 +64,177 @@ async def update_watch_wallet(wallet_id: str, **kwargs) -> Optional[Wallets]:
row = await db.fetchone(
"SELECT * FROM watchonly.wallets WHERE id = ?", (wallet_id,)
)
return Wallets.from_row(row) if row else None
return WalletAccount.from_row(row) if row else None
async def delete_watch_wallet(wallet_id: str) -> None:
await db.execute("DELETE FROM watchonly.wallets WHERE id = ?", (wallet_id,))
########################ADDRESSES#######################
########################ADDRESSES#######################
async def get_derive_address(wallet_id: str, num: int):
wallet = await get_watch_wallet(wallet_id)
key = wallet.masterpub
desc, network = parse_key(key)
return desc.derive(num).address(network=network)
async def get_fresh_address(wallet_id: str) -> Optional[Addresses]:
async def get_fresh_address(wallet_id: str) -> Optional[Address]:
# todo: move logic to views_api after satspay refactoring
wallet = await get_watch_wallet(wallet_id)
if not wallet:
return None
address = await get_derive_address(wallet_id, wallet.address_no + 1)
wallet_addresses = await get_addresses(wallet_id)
receive_addresses = list(
filter(
lambda addr: addr.branch_index == 0 and addr.has_activity, wallet_addresses
)
)
last_receive_index = (
receive_addresses.pop().address_index if receive_addresses else -1
)
address_index = (
last_receive_index
if last_receive_index > wallet.address_no
else wallet.address_no
)
await update_watch_wallet(wallet_id=wallet_id, address_no=wallet.address_no + 1)
masterpub_id = urlsafe_short_hash()
await db.execute(
"""
address = await get_address_at_index(wallet_id, 0, address_index + 1)
if not address:
addresses = await create_fresh_addresses(
wallet_id, address_index + 1, address_index + 2
)
address = addresses.pop()
await update_watch_wallet(wallet_id, **{"address_no": address_index + 1})
return address
async def create_fresh_addresses(
wallet_id: str,
start_address_index: int,
end_address_index: int,
change_address=False,
) -> List[Address]:
if start_address_index > end_address_index:
return None
wallet = await get_watch_wallet(wallet_id)
if not wallet:
return None
branch_index = 1 if change_address else 0
for address_index in range(start_address_index, end_address_index):
address = await derive_address(wallet.masterpub, address_index, branch_index)
await db.execute(
"""
INSERT INTO watchonly.addresses (
id,
address,
wallet,
amount
amount,
branch_index,
address_index
)
VALUES (?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?)
""",
(masterpub_id, address, wallet_id, 0),
(urlsafe_short_hash(), address, wallet_id, 0, branch_index, address_index),
)
# return fresh addresses
rows = await db.fetchall(
"""
SELECT * FROM watchonly.addresses
WHERE wallet = ? AND branch_index = ? AND address_index >= ? AND address_index < ?
ORDER BY branch_index, address_index
""",
(wallet_id, branch_index, start_address_index, end_address_index),
)
return await get_address(address)
return [Address(**row) for row in rows]
async def get_address(address: str) -> Optional[Addresses]:
async def get_address(address: str) -> Optional[Address]:
row = await db.fetchone(
"SELECT * FROM watchonly.addresses WHERE address = ?", (address,)
)
return Addresses.from_row(row) if row else None
return Address.from_row(row) if row else None
async def get_addresses(wallet_id: str) -> List[Addresses]:
rows = await db.fetchall(
"SELECT * FROM watchonly.addresses WHERE wallet = ?", (wallet_id,)
)
return [Addresses(**row) for row in rows]
######################MEMPOOL#######################
async def create_mempool(user: str) -> Optional[Mempool]:
await db.execute(
"""
INSERT INTO watchonly.mempool ("user",endpoint)
VALUES (?, ?)
""",
(user, "https://mempool.space"),
)
async def get_address_at_index(
wallet_id: str, branch_index: int, address_index: int
) -> Optional[Address]:
row = await db.fetchone(
"""SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,)
"""
SELECT * FROM watchonly.addresses
WHERE wallet = ? AND branch_index = ? AND address_index = ?
""",
(
wallet_id,
branch_index,
address_index,
),
)
return Mempool.from_row(row) if row else None
return Address.from_row(row) if row else None
async def update_mempool(user: str, **kwargs) -> Optional[Mempool]:
async def get_addresses(wallet_id: str) -> List[Address]:
rows = await db.fetchall(
"""
SELECT * FROM watchonly.addresses WHERE wallet = ?
ORDER BY branch_index, address_index
""",
(wallet_id,),
)
return [Address(**row) for row in rows]
async def update_address(id: str, **kwargs) -> Optional[Address]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
f"""UPDATE watchonly.mempool SET {q} WHERE "user" = ?""",
(*kwargs.values(), user),
f"""UPDATE watchonly.addresses SET {q} WHERE id = ? """,
(*kwargs.values(), id),
)
row = await db.fetchone(
"""SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,)
)
return Mempool.from_row(row) if row else None
row = await db.fetchone("SELECT * FROM watchonly.addresses WHERE id = ?", (id))
return Address.from_row(row) if row else None
async def get_mempool(user: str) -> Mempool:
row = await db.fetchone(
"""SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,)
async def delete_addresses_for_wallet(wallet_id: str) -> None:
await db.execute("DELETE FROM watchonly.addresses WHERE wallet = ?", (wallet_id,))
######################CONFIG#######################
async def create_config(user: str) -> Config:
config = Config()
await db.execute(
"""
INSERT INTO watchonly.config ("user", json_data)
VALUES (?, ?)
""",
(user, json.dumps(config.dict())),
)
return Mempool.from_row(row) if row else None
row = await db.fetchone(
"""SELECT json_data FROM watchonly.config WHERE "user" = ?""", (user,)
)
return json.loads(row[0], object_hook=lambda d: Config(**d))
async def update_config(config: Config, user: str) -> Optional[Config]:
await db.execute(
f"""UPDATE watchonly.config SET json_data = ? WHERE "user" = ?""",
(json.dumps(config.dict()), user),
)
row = await db.fetchone(
"""SELECT json_data FROM watchonly.config WHERE "user" = ?""", (user,)
)
return json.loads(row[0], object_hook=lambda d: Config(**d))
async def get_config(user: str) -> Optional[Config]:
row = await db.fetchone(
"""SELECT json_data FROM watchonly.config WHERE "user" = ?""", (user,)
)
return json.loads(row[0], object_hook=lambda d: Config(**d)) if row else None
+69
View File
@@ -0,0 +1,69 @@
from embit.descriptor import Descriptor, Key # type: ignore
from embit.descriptor.arguments import AllowedDerivation # type: ignore
from embit.networks import NETWORKS # type: ignore
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) -> Descriptor:
"""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 derive_address(masterpub: str, num: int, branch_index=0):
desc, network = parse_key(masterpub)
return desc.derive(num, branch_index).address(network=network)
+47
View File
@@ -34,3 +34,50 @@ async def m001_initial(db):
);
"""
)
async def m002_add_columns_to_adresses(db):
"""
Add 'branch_index', 'address_index', 'has_activity' and 'note' columns to the 'addresses' table
"""
await db.execute(
"ALTER TABLE watchonly.addresses ADD COLUMN branch_index INTEGER NOT NULL DEFAULT 0;"
)
await db.execute(
"ALTER TABLE watchonly.addresses ADD COLUMN address_index INTEGER NOT NULL DEFAULT 0;"
)
await db.execute(
"ALTER TABLE watchonly.addresses ADD COLUMN has_activity BOOLEAN DEFAULT false;"
)
await db.execute("ALTER TABLE watchonly.addresses ADD COLUMN note TEXT;")
async def m003_add_columns_to_wallets(db):
"""
Add 'type' and 'fingerprint' columns to the 'wallets' table
"""
await db.execute("ALTER TABLE watchonly.wallets ADD COLUMN type TEXT;")
await db.execute(
"ALTER TABLE watchonly.wallets ADD COLUMN fingerprint TEXT NOT NULL DEFAULT '';"
)
async def m004_create_config_table(db):
"""
Allow the extension to persist and retrieve any number of config values.
Each user has its configurations saved as a JSON string
"""
await db.execute(
"""CREATE TABLE watchonly.config (
"user" TEXT NOT NULL,
json_data TEXT NOT NULL
);"""
)
### TODO: fix statspay dependcy first
# await db.execute(
# "DROP TABLE watchonly.wallets;"
# )
+52 -5
View File
@@ -1,4 +1,5 @@
from sqlite3 import Row
from typing import List
from fastapi.param_functions import Query
from pydantic import BaseModel
@@ -9,19 +10,22 @@ class CreateWallet(BaseModel):
title: str = Query("")
class Wallets(BaseModel):
class WalletAccount(BaseModel):
id: str
user: str
masterpub: str
fingerprint: str
title: str
address_no: int
balance: int
type: str = ""
@classmethod
def from_row(cls, row: Row) -> "Wallets":
def from_row(cls, row: Row) -> "WalletAccount":
return cls(**dict(row))
### TODO: fix statspay dependcy and remove
class Mempool(BaseModel):
user: str
endpoint: str
@@ -31,12 +35,55 @@ class Mempool(BaseModel):
return cls(**dict(row))
class Addresses(BaseModel):
class Address(BaseModel):
id: str
address: str
wallet: str
amount: int
amount: int = 0
branch_index: int = 0
address_index: int
note: str = None
has_activity: bool = False
@classmethod
def from_row(cls, row: Row) -> "Addresses":
def from_row(cls, row: Row) -> "Address":
return cls(**dict(row))
class TransactionInput(BaseModel):
tx_id: str
vout: int
amount: int
address: str
branch_index: int
address_index: int
masterpub_fingerprint: str
tx_hex: str
class TransactionOutput(BaseModel):
amount: int
address: str
branch_index: int = None
address_index: int = None
masterpub_fingerprint: str = None
class MasterPublicKey(BaseModel):
public_key: str
fingerprint: str
class CreatePsbt(BaseModel):
masterpubs: List[MasterPublicKey]
inputs: List[TransactionInput]
outputs: List[TransactionOutput]
fee_rate: int
tx_size: int
class Config(BaseModel):
mempool_endpoint = "https://mempool.space"
receive_gap_limit = 20
change_gap_limit = 5
sats_denominated = True
@@ -0,0 +1,743 @@
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()
})
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
DUST_LIMIT: 546,
filter: '',
scan: {
scanning: false,
scanCount: 0,
scanIndex: 0
},
currentAddress: null,
tab: 'addresses',
config: {
data: {
mempool_endpoint: 'https://mempool.space',
receive_gap_limit: 20,
change_gap_limit: 5
},
DEFAULT_RECEIVE_GAP_LIMIT: 20,
show: false
},
formDialog: {
show: false,
data: {}
},
qrCodeDialog: {
show: false,
data: null
},
...tables,
...tableData
}
},
methods: {
//################### CONFIG ###################
getConfig: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/watchonly/api/v1/config',
this.g.user.wallets[0].adminkey
)
this.config.data = data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
updateConfig: async function () {
const wallet = this.g.user.wallets[0]
try {
await LNbits.api.request(
'PUT',
'/watchonly/api/v1/config',
wallet.adminkey,
this.config.data
)
this.config.show = false
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
//################### WALLETS ###################
getWalletName: function (walletId) {
const wallet = this.walletAccounts.find(wl => wl.id === walletId)
return wallet ? wallet.title : 'unknown'
},
addWalletAccount: async function () {
const wallet = this.g.user.wallets[0]
const data = _.omit(this.formDialog.data, 'wallet')
await this.createWalletAccount(wallet, data)
},
createWalletAccount: async function (wallet, data) {
try {
const response = await LNbits.api.request(
'POST',
'/watchonly/api/v1/wallet',
wallet.adminkey,
data
)
this.walletAccounts.push(mapWalletAccount(response.data))
this.formDialog.show = false
await this.refreshWalletAccounts()
await this.refreshAddresses()
if (!this.payment.changeWallett) {
this.payment.changeWallet = this.walletAccounts[0]
this.selectChangeAddress(this.payment.changeWallet)
}
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
deleteWalletAccount: function (walletAccountId) {
LNbits.utils
.confirmDialog(
'Are you sure you want to delete this watch only wallet?'
)
.onOk(async () => {
try {
await LNbits.api.request(
'DELETE',
'/watchonly/api/v1/wallet/' + walletAccountId,
this.g.user.wallets[0].adminkey
)
this.walletAccounts = _.reject(this.walletAccounts, function (obj) {
return obj.id === walletAccountId
})
await this.refreshWalletAccounts()
await this.refreshAddresses()
if (
this.payment.changeWallet &&
this.payment.changeWallet.id === walletAccountId
) {
this.payment.changeWallet = this.walletAccounts[0]
this.selectChangeAddress(this.payment.changeWallet)
}
await this.scanAddressWithAmount()
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Error while deleting wallet account. Please try again.',
timeout: 10000
})
}
})
},
getAddressesForWallet: async function (walletId) {
try {
const {data} = await LNbits.api.request(
'GET',
'/watchonly/api/v1/addresses/' + walletId,
this.g.user.wallets[0].inkey
)
return data.map(mapAddressesData)
} catch (err) {
this.$q.notify({
type: 'warning',
message: `Failed to fetch addresses for wallet with id ${walletId}.`,
timeout: 10000
})
LNbits.utils.notifyApiError(err)
}
return []
},
getWatchOnlyWallets: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/watchonly/api/v1/wallet',
this.g.user.wallets[0].inkey
)
return data
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to fetch wallets.',
timeout: 10000
})
LNbits.utils.notifyApiError(error)
}
return []
},
refreshWalletAccounts: async function () {
const wallets = await this.getWatchOnlyWallets()
this.walletAccounts = wallets.map(w => mapWalletAccount(w))
},
getAmmountForWallet: function (walletId) {
const amount = this.addresses.data
.filter(a => a.wallet === walletId)
.reduce((t, a) => t + a.amount || 0, 0)
return this.satBtc(amount)
},
//################### ADDRESSES ###################
refreshAddresses: async function () {
const wallets = await this.getWatchOnlyWallets()
this.addresses.data = []
for (const {id, type} of wallets) {
const newAddresses = await this.getAddressesForWallet(id)
const uniqueAddresses = newAddresses.filter(
newAddr =>
!this.addresses.data.find(a => a.address === newAddr.address)
)
const lastAcctiveAddress =
uniqueAddresses.filter(a => !a.isChange && a.hasActivity).pop() || {}
uniqueAddresses.forEach(a => {
a.expanded = false
a.accountType = type
a.gapLimitExceeded =
!a.isChange &&
a.addressIndex >
lastAcctiveAddress.addressIndex +
this.config.DEFAULT_RECEIVE_GAP_LIMIT
})
this.addresses.data.push(...uniqueAddresses)
}
},
updateAmountForAddress: async function (addressData, amount = 0) {
try {
const wallet = this.g.user.wallets[0]
addressData.amount = amount
if (!addressData.isChange) {
const addressWallet = this.walletAccounts.find(
w => w.id === addressData.wallet
)
if (
addressWallet &&
addressWallet.address_no < addressData.addressIndex
) {
addressWallet.address_no = addressData.addressIndex
}
}
await LNbits.api.request(
'PUT',
`/watchonly/api/v1/address/${addressData.id}`,
wallet.adminkey,
{amount}
)
} catch (err) {
addressData.error = 'Failed to refresh amount for address'
this.$q.notify({
type: 'warning',
message: `Failed to refresh amount for address ${addressData.address}`,
timeout: 10000
})
LNbits.utils.notifyApiError(err)
}
},
updateNoteForAddress: async function (addressData, note) {
try {
const wallet = this.g.user.wallets[0]
await LNbits.api.request(
'PUT',
`/watchonly/api/v1/address/${addressData.id}`,
wallet.adminkey,
{note: addressData.note}
)
const updatedAddress =
this.addresses.data.find(a => a.id === addressData.id) || {}
updatedAddress.note = note
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
getFilteredAddresses: function () {
const selectedWalletId = this.addresses.selectedWallet?.id
const filter = this.addresses.filterValues || []
const includeChangeAddrs = filter.includes('Show Change Addresses')
const includeGapAddrs = filter.includes('Show Gap Addresses')
const excludeNoAmount = filter.includes('Only With Amount')
const walletsLimit = this.walletAccounts.reduce((r, w) => {
r[`_${w.id}`] = w.address_no
return r
}, {})
const addresses = this.addresses.data.filter(
a =>
(includeChangeAddrs || !a.isChange) &&
(includeGapAddrs ||
a.isChange ||
a.addressIndex <= walletsLimit[`_${a.wallet}`]) &&
!(excludeNoAmount && a.amount === 0) &&
(!selectedWalletId || a.wallet === selectedWalletId)
)
return addresses
},
openGetFreshAddressDialog: async function (walletId) {
const {data} = await LNbits.api.request(
'GET',
`/watchonly/api/v1/address/${walletId}`,
this.g.user.wallets[0].inkey
)
const addressData = mapAddressesData(data)
addressData.note = `Shared on ${currentDateTime()}`
const lastAcctiveAddress =
this.addresses.data
.filter(
a => a.wallet === addressData.wallet && !a.isChange && a.hasActivity
)
.pop() || {}
addressData.gapLimitExceeded =
!addressData.isChange &&
addressData.addressIndex >
lastAcctiveAddress.addressIndex +
this.config.DEFAULT_RECEIVE_GAP_LIMIT
this.openQrCodeDialog(addressData)
const wallet = this.walletAccounts.find(w => w.id === walletId) || {}
wallet.address_no = addressData.addressIndex
await this.refreshAddresses()
},
//################### ADDRESS HISTORY ###################
addressHistoryFromTxs: function (addressData, txs) {
const addressHistory = []
txs.forEach(tx => {
const sent = tx.vin
.filter(
vin => vin.prevout.scriptpubkey_address === addressData.address
)
.map(vin => mapInputToSentHistory(tx, addressData, vin))
const received = tx.vout
.filter(vout => vout.scriptpubkey_address === addressData.address)
.map(vout => mapOutputToReceiveHistory(tx, addressData, vout))
addressHistory.push(...sent, ...received)
})
return addressHistory
},
getFilteredAddressesHistory: function () {
return this.addresses.history.filter(
a => (!a.isChange || a.sent) && !a.isSubItem
)
},
exportHistoryToCSV: function () {
const history = this.getFilteredAddressesHistory().map(a => ({
...a,
action: a.sent ? 'Sent' : 'Received'
}))
LNbits.utils.exportCSV(
this.historyTable.exportColums,
history,
'address-history'
)
},
markSameTxAddressHistory: function () {
this.addresses.history
.filter(s => s.sent)
.forEach((el, i, arr) => {
if (el.isSubItem) return
const sameTxItems = arr.slice(i + 1).filter(e => e.txId === el.txId)
if (!sameTxItems.length) return
sameTxItems.forEach(e => {
e.isSubItem = true
})
el.totalAmount =
el.amount + sameTxItems.reduce((t, e) => (t += e.amount || 0), 0)
el.sameTxItems = sameTxItems
})
},
showAddressHistoryDetails: function (addressHistory) {
addressHistory.expanded = true
},
//################### PAYMENT ###################
createTx: function (excludeChange = false) {
const tx = {
fee_rate: this.payment.feeRate,
tx_size: this.payment.txSize,
masterpubs: this.walletAccounts.map(w => ({
public_key: w.masterpub,
fingerprint: w.fingerprint
}))
}
tx.inputs = this.utxos.data
.filter(utxo => utxo.selected)
.map(mapUtxoToPsbtInput)
.sort((a, b) =>
a.tx_id < b.tx_id ? -1 : a.tx_id > b.tx_id ? 1 : a.vout - b.vout
)
tx.outputs = this.payment.data.map(out => ({
address: out.address,
amount: out.amount
}))
if (excludeChange) {
this.payment.changeAmount = 0
} else {
const change = this.createChangeOutput()
this.payment.changeAmount = change.amount
if (change.amount >= this.DUST_LIMIT) {
tx.outputs.push(change)
}
}
// Only sort by amount on UI level (no lib for address decode)
// Should sort by scriptPubKey (as byte array) on the backend
tx.outputs.sort((a, b) => a.amount - b.amount)
return tx
},
createChangeOutput: function () {
const change = this.payment.changeAddress
const fee = this.payment.feeRate * this.payment.txSize
const inputAmount = this.getTotalSelectedUtxoAmount()
const payedAmount = this.getTotalPaymentAmount()
const walletAcount =
this.walletAccounts.find(w => w.id === change.wallet) || {}
return {
address: change.address,
amount: inputAmount - payedAmount - fee,
addressIndex: change.addressIndex,
addressIndex: change.addressIndex,
masterpub_fingerprint: walletAcount.fingerprint
}
},
computeFee: function () {
const tx = this.createTx()
this.payment.txSize = Math.round(txSize(tx))
return this.payment.feeRate * this.payment.txSize
},
createPsbt: async function () {
const wallet = this.g.user.wallets[0]
try {
this.computeFee()
const tx = this.createTx()
txSize(tx)
for (const input of tx.inputs) {
input.tx_hex = await this.fetchTxHex(input.tx_id)
}
const {data} = await LNbits.api.request(
'POST',
'/watchonly/api/v1/psbt',
wallet.adminkey,
tx
)
this.payment.psbtBase64 = data
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
deletePaymentAddress: function (v) {
const index = this.payment.data.indexOf(v)
if (index !== -1) {
this.payment.data.splice(index, 1)
}
},
initPaymentData: async function () {
if (!this.payment.show) return
await this.refreshAddresses()
this.payment.showAdvanced = false
this.payment.changeWallet = this.walletAccounts[0]
this.selectChangeAddress(this.payment.changeWallet)
await this.refreshRecommendedFees()
this.payment.feeRate = this.payment.recommededFees.halfHourFee
},
getFeeRateLabel: function (feeRate) {
const fees = this.payment.recommededFees
if (feeRate >= fees.fastestFee) return `High Priority (${feeRate} sat/vB)`
if (feeRate >= fees.halfHourFee)
return `Medium Priority (${feeRate} sat/vB)`
if (feeRate >= fees.hourFee) return `Low Priority (${feeRate} sat/vB)`
return `No Priority (${feeRate} sat/vB)`
},
addPaymentAddress: function () {
this.payment.data.push({address: '', amount: undefined})
},
getTotalPaymentAmount: function () {
return this.payment.data.reduce((t, a) => t + (a.amount || 0), 0)
},
selectChangeAddress: function (wallet = {}) {
this.payment.changeAddress =
this.addresses.data.find(
a => a.wallet === wallet.id && a.isChange && !a.hasActivity
) || {}
},
goToPaymentView: async function () {
this.payment.show = true
this.tab = 'utxos'
await this.initPaymentData()
},
sendMaxToAddress: function (paymentAddress = {}) {
paymentAddress.amount = 0
const tx = this.createTx(true)
this.payment.txSize = Math.round(txSize(tx))
const fee = this.payment.feeRate * this.payment.txSize
const inputAmount = this.getTotalSelectedUtxoAmount()
const payedAmount = this.getTotalPaymentAmount()
paymentAddress.amount = Math.max(0, inputAmount - payedAmount - fee)
},
//################### UTXOs ###################
scanAllAddresses: async function () {
await this.refreshAddresses()
this.addresses.history = []
let addresses = this.addresses.data
this.utxos.data = []
this.utxos.total = 0
// Loop while new funds are found on the gap adresses.
// Use 1000 limit as a safety check (scan 20 000 addresses max)
for (let i = 0; i < 1000 && addresses.length; i++) {
await this.updateUtxosForAddresses(addresses)
const oldAddresses = this.addresses.data.slice()
await this.refreshAddresses()
const newAddresses = this.addresses.data.slice()
// check if gap addresses have been extended
addresses = newAddresses.filter(
newAddr => !oldAddresses.find(oldAddr => oldAddr.id === newAddr.id)
)
if (addresses.length) {
this.$q.notify({
type: 'positive',
message: 'Funds found! Scanning for more...',
timeout: 10000
})
}
}
},
scanAddressWithAmount: async function () {
this.utxos.data = []
this.utxos.total = 0
this.addresses.history = []
const addresses = this.addresses.data.filter(a => a.hasActivity)
await this.updateUtxosForAddresses(addresses)
},
scanAddress: async function (addressData) {
this.updateUtxosForAddresses([addressData])
this.$q.notify({
type: 'positive',
message: 'Address Rescanned',
timeout: 10000
})
},
updateUtxosForAddresses: async function (addresses = []) {
this.scan = {scanning: true, scanCount: addresses.length, scanIndex: 0}
try {
for (addrData of addresses) {
const addressHistory = await this.getAddressTxsDelayed(addrData)
// remove old entries
this.addresses.history = this.addresses.history.filter(
h => h.address !== addrData.address
)
// add new entrie
this.addresses.history.push(...addressHistory)
this.addresses.history.sort((a, b) =>
!a.height ? -1 : b.height - a.height
)
this.markSameTxAddressHistory()
if (addressHistory.length) {
// search only if it ever had any activity
const utxos = await this.getAddressTxsUtxoDelayed(addrData.address)
this.updateUtxosForAddress(addrData, utxos)
}
this.scan.scanIndex++
}
} catch (error) {
console.error(error)
this.$q.notify({
type: 'warning',
message: 'Failed to scan addresses',
timeout: 10000
})
} finally {
this.scan.scanning = false
}
},
updateUtxosForAddress: function (addressData, utxos = []) {
const wallet =
this.walletAccounts.find(w => w.id === addressData.wallet) || {}
const newUtxos = utxos.map(utxo =>
mapAddressDataToUtxo(wallet, addressData, utxo)
)
// remove old utxos
this.utxos.data = this.utxos.data.filter(
u => u.address !== addressData.address
)
// add new utxos
this.utxos.data.push(...newUtxos)
if (utxos.length) {
this.utxos.data.sort((a, b) => b.sort - a.sort)
this.utxos.total = this.utxos.data.reduce(
(total, y) => (total += y?.amount || 0),
0
)
}
const addressTotal = utxos.reduce(
(total, y) => (total += y?.value || 0),
0
)
this.updateAmountForAddress(addressData, addressTotal)
},
getTotalSelectedUtxoAmount: function () {
const total = this.utxos.data
.filter(u => u.selected)
.reduce((t, a) => t + (a.amount || 0), 0)
return total
},
applyUtxoSelectionMode: function () {
const payedAmount = this.getTotalPaymentAmount()
const mode = this.payment.utxoSelectionMode
this.utxos.data.forEach(u => (u.selected = false))
const isManual = mode === 'Manual'
if (isManual || !payedAmount) return
const isSelectAll = mode === 'Select All'
if (isSelectAll || payedAmount >= this.utxos.total) {
this.utxos.data.forEach(u => (u.selected = true))
return
}
const isSmallerFirst = mode === 'Smaller Inputs First'
const isLargerFirst = mode === 'Larger Inputs First'
let selectedUtxos = this.utxos.data.slice()
if (isSmallerFirst || isLargerFirst) {
const sortFn = isSmallerFirst
? (a, b) => a.amount - b.amount
: (a, b) => b.amount - a.amount
selectedUtxos.sort(sortFn)
} else {
// default to random order
selectedUtxos = _.shuffle(selectedUtxos)
}
selectedUtxos.reduce((total, utxo) => {
utxo.selected = total < payedAmount
total += utxo.amount
return total
}, 0)
},
//################### MEMPOOL API ###################
getAddressTxsDelayed: async function (addrData) {
const {
bitcoin: {addresses: addressesAPI}
} = mempoolJS({
hostname: new URL(this.config.data.mempool_endpoint).hostname
})
const fn = async () =>
addressesAPI.getAddressTxs({
address: addrData.address
})
const addressTxs = await retryWithDelay(fn)
return this.addressHistoryFromTxs(addrData, addressTxs)
},
refreshRecommendedFees: async function () {
const {
bitcoin: {fees: feesAPI}
} = mempoolJS({
hostname: new URL(this.config.data.mempool_endpoint).hostname
})
const fn = async () => feesAPI.getFeesRecommended()
this.payment.recommededFees = await retryWithDelay(fn)
},
getAddressTxsUtxoDelayed: async function (address) {
const {
bitcoin: {addresses: addressesAPI}
} = mempoolJS({
hostname: new URL(this.config.data.mempool_endpoint).hostname
})
const fn = async () =>
addressesAPI.getAddressTxsUtxo({
address
})
return retryWithDelay(fn)
},
fetchTxHex: async function (txId) {
const {
bitcoin: {transactions: transactionsAPI}
} = mempoolJS({
hostname: new URL(this.config.data.mempool_endpoint).hostname
})
try {
const response = await transactionsAPI.getTxHex({txid: txId})
return response
} catch (error) {
this.$q.notify({
type: 'warning',
message: `Failed to fetch transaction details for tx id: '${txId}'`,
timeout: 10000
})
LNbits.utils.notifyApiError(error)
throw error
}
},
//################### OTHER ###################
closeFormDialog: function () {
this.formDialog.data = {
is_unique: false
}
},
openQrCodeDialog: function (addressData) {
this.currentAddress = addressData
this.addresses.note = addressData.note || ''
this.addresses.show = true
},
searchInTab: function (tab, value) {
this.tab = tab
this[`${tab}Table`].filter = value
},
satBtc(val, showUnit = true) {
const value = this.config.data.sats_denominated
? LNbits.utils.formatSat(val)
: val == 0
? 0.0
: (val / 100000000).toFixed(8)
if (!showUnit) return value
return this.config.data.sats_denominated ? value + ' sat' : value + ' BTC'
},
getAccountDescription: function (accountType) {
return getAccountDescription(accountType)
}
},
created: async function () {
if (this.g.user.wallets.length) {
await this.getConfig()
await this.refreshWalletAccounts()
await this.refreshAddresses()
await this.scanAddressWithAmount()
}
}
})
@@ -0,0 +1,80 @@
const mapAddressesData = a => ({
id: a.id,
address: a.address,
amount: a.amount,
wallet: a.wallet,
note: a.note,
isChange: a.branch_index === 1,
addressIndex: a.address_index,
hasActivity: a.has_activity
})
const mapInputToSentHistory = (tx, addressData, vin) => ({
sent: true,
txId: tx.txid,
address: addressData.address,
isChange: addressData.isChange,
amount: vin.prevout.value,
date: blockTimeToDate(tx.status.block_time),
height: tx.status.block_height,
confirmed: tx.status.confirmed,
fee: tx.fee,
expanded: false
})
const mapOutputToReceiveHistory = (tx, addressData, vout) => ({
received: true,
txId: tx.txid,
address: addressData.address,
isChange: addressData.isChange,
amount: vout.value,
date: blockTimeToDate(tx.status.block_time),
height: tx.status.block_height,
confirmed: tx.status.confirmed,
fee: tx.fee,
expanded: false
})
const mapUtxoToPsbtInput = utxo => ({
tx_id: utxo.txId,
vout: utxo.vout,
amount: utxo.amount,
address: utxo.address,
branch_index: utxo.isChange ? 1 : 0,
address_index: utxo.addressIndex,
masterpub_fingerprint: utxo.masterpubFingerprint,
accountType: utxo.accountType,
txHex: ''
})
const mapAddressDataToUtxo = (wallet, addressData, utxo) => ({
id: addressData.id,
address: addressData.address,
isChange: addressData.isChange,
addressIndex: addressData.addressIndex,
wallet: addressData.wallet,
accountType: addressData.accountType,
masterpubFingerprint: wallet.fingerprint,
txId: utxo.txid,
vout: utxo.vout,
confirmed: utxo.status.confirmed,
amount: utxo.value,
date: blockTimeToDate(utxo.status?.block_time),
sort: utxo.status?.block_time,
expanded: false,
selected: false
})
const mapWalletAccount = function (obj) {
obj._data = _.clone(obj)
obj.date = obj.time
? Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
: ''
obj.label = obj.title // for drop-downs
obj.expanded = false
return obj
}
@@ -0,0 +1,277 @@
const tables = {
walletsTable: {
columns: [
{
name: 'new',
align: 'left',
label: ''
},
{
name: 'title',
align: 'left',
label: 'Title',
field: 'title'
},
{
name: 'amount',
align: 'left',
label: 'Amount'
},
{
name: 'type',
align: 'left',
label: 'Type',
field: 'type'
},
{name: 'id', align: 'left', label: 'ID', field: 'id'}
],
pagination: {
rowsPerPage: 10
},
filter: ''
},
utxosTable: {
columns: [
{
name: 'expand',
align: 'left',
label: ''
},
{
name: 'selected',
align: 'left',
label: ''
},
{
name: 'status',
align: 'center',
label: 'Status',
sortable: true
},
{
name: 'address',
align: 'left',
label: 'Address',
field: 'address',
sortable: true
},
{
name: 'amount',
align: 'left',
label: 'Amount',
field: 'amount',
sortable: true
},
{
name: 'date',
align: 'left',
label: 'Date',
field: 'date',
sortable: true
},
{
name: 'wallet',
align: 'left',
label: 'Account',
field: 'wallet',
sortable: true
}
],
pagination: {
rowsPerPage: 10
},
filter: ''
},
paymentTable: {
columns: [
{
name: 'data',
align: 'left'
}
],
pagination: {
rowsPerPage: 10
},
filter: ''
},
summaryTable: {
columns: [
{
name: 'totalInputs',
align: 'center',
label: 'Selected Amount'
},
{
name: 'totalOutputs',
align: 'center',
label: 'Payed Amount'
},
{
name: 'fees',
align: 'center',
label: 'Fees'
},
{
name: 'change',
align: 'center',
label: 'Change'
}
]
},
addressesTable: {
columns: [
{
name: 'expand',
align: 'left',
label: ''
},
{
name: 'address',
align: 'left',
label: 'Address',
field: 'address',
sortable: true
},
{
name: 'amount',
align: 'left',
label: 'Amount',
field: 'amount',
sortable: true
},
{
name: 'note',
align: 'left',
label: 'Note',
field: 'note',
sortable: true
},
{
name: 'wallet',
align: 'left',
label: 'Account',
field: 'wallet',
sortable: true
}
],
pagination: {
rowsPerPage: 0,
sortBy: 'amount',
descending: true
},
filter: ''
},
historyTable: {
columns: [
{
name: 'expand',
align: 'left',
label: ''
},
{
name: 'status',
align: 'left',
label: 'Status'
},
{
name: 'amount',
align: 'left',
label: 'Amount',
field: 'amount',
sortable: true
},
{
name: 'address',
align: 'left',
label: 'Address',
field: 'address',
sortable: true
},
{
name: 'date',
align: 'left',
label: 'Date',
field: 'date',
sortable: true
}
],
exportColums: [
{
label: 'Action',
field: 'action'
},
{
label: 'Date&Time',
field: 'date'
},
{
label: 'Amount',
field: 'amount'
},
{
label: 'Fee',
field: 'fee'
},
{
label: 'Transaction Id',
field: 'txId'
}
],
pagination: {
rowsPerPage: 0
},
filter: ''
}
}
const tableData = {
walletAccounts: [],
addresses: {
show: false,
data: [],
history: [],
selectedWallet: null,
note: '',
filterOptions: [
'Show Change Addresses',
'Show Gap Addresses',
'Only With Amount'
],
filterValues: []
},
utxos: {
data: [],
total: 0
},
payment: {
data: [{address: '', amount: undefined}],
changeWallet: null,
changeAddress: {},
changeAmount: 0,
feeRate: 1,
recommededFees: {
fastestFee: 1,
halfHourFee: 1,
hourFee: 1,
economyFee: 1,
minimumFee: 1
},
fee: 0,
txSize: 0,
psbtBase64: '',
utxoSelectionModes: [
'Manual',
'Random',
'Select All',
'Smaller Inputs First',
'Larger Inputs First'
],
utxoSelectionMode: 'Manual',
show: false,
showAdvanced: false
},
summary: {
data: [{totalInputs: 0, totalOutputs: 0, fees: 0, change: 0}]
}
}
@@ -0,0 +1,99 @@
const blockTimeToDate = blockTime =>
blockTime ? moment(blockTime * 1000).format('LLL') : ''
const currentDateTime = () => moment().format('LLL')
const sleep = ms => new Promise(r => setTimeout(r, ms))
const retryWithDelay = async function (fn, retryCount = 0) {
try {
await sleep(25)
// Do not return the call directly, use result.
// Otherwise the error will not be cought in this try-catch block.
const result = await fn()
return result
} catch (err) {
if (retryCount > 100) throw err
await sleep((retryCount + 1) * 1000)
return retryWithDelay(fn, retryCount + 1)
}
}
const txSize = tx => {
// https://bitcoinops.org/en/tools/calc-size/
// overhead size
const nVersion = 4
const inCount = 1
const outCount = 1
const nlockTime = 4
const hasSegwit = !!tx.inputs.find(inp =>
['p2wsh', 'p2wpkh', 'p2tr'].includes(inp.accountType)
)
const segwitFlag = hasSegwit ? 0.5 : 0
const overheadSize = nVersion + inCount + outCount + nlockTime + segwitFlag
// inputs size
const outpoint = 36 // txId plus vout index number
const scriptSigLength = 1
const nSequence = 4
const inputsSize = tx.inputs.reduce((t, inp) => {
const scriptSig =
inp.accountType === 'p2pkh' ? 107 : inp.accountType === 'p2sh' ? 254 : 0
const witnessItemCount = hasSegwit ? 0.25 : 0
const witnessItems =
inp.accountType === 'p2wpkh'
? 27
: inp.accountType === 'p2wsh'
? 63.5
: inp.accountType === 'p2tr'
? 16.5
: 0
t +=
outpoint +
scriptSigLength +
nSequence +
scriptSig +
witnessItemCount +
witnessItems
return t
}, 0)
// outputs size
const nValue = 8
const scriptPubKeyLength = 1
const outputsSize = tx.outputs.reduce((t, out) => {
const type = guessAddressType(out.address)
const scriptPubKey =
type === 'p2pkh'
? 25
: type === 'p2wpkh'
? 22
: type === 'p2sh'
? 23
: type === 'p2wsh'
? 34
: 34 // default to the largest size (p2tr included)
t += nValue + scriptPubKeyLength + scriptPubKey
return t
}, 0)
return overheadSize + inputsSize + outputsSize
}
const guessAddressType = (a = '') => {
if (a.startsWith('1') || a.startsWith('n')) return 'p2pkh'
if (a.startsWith('3') || a.startsWith('2')) return 'p2sh'
if (a.startsWith('bc1q') || a.startsWith('tb1q'))
return a.length === 42 ? 'p2wpkh' : 'p2wsh'
if (a.startsWith('bc1p') || a.startsWith('tb1p')) return 'p2tr'
}
const ACCOUNT_TYPES = {
p2tr: 'Taproot, BIP86, P2TR, Bech32m',
p2wpkh: 'SegWit, BIP84, P2WPKH, Bech32',
p2sh: 'BIP49, P2SH-P2WPKH, Base58',
p2pkh: 'Legacy, BIP44, P2PKH, Base58'
}
const getAccountDescription = type => ACCOUNT_TYPES[type] || 'nonstandard'
@@ -1,248 +1,27 @@
<q-card>
<q-card-section>
<p>
Watch Only extension uses mempool.space<br />
Onchain Wallet (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"
<a target="_blank" class="text-white" href="https://github.com/arcbtc"
>Ben Arc</a
>
(using,
<a
target="_blank"
class="text-white"
href="https://github.com/diybitcoinhardware/embit"
>Embit</a
></small
>)
<br />
<br />
<a target="_blank" href="/docs#/watchonly" class="text-white"
>Swagger REST API Documentation</a
>
</p>
</q-card-section>
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-btn flat label="Swagger API" type="a" href="../docs#/watchonly"></q-btn>
<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.base_url }}watchonly/api/v1/wallet -H
"X-Api-Key: {{ user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Get 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.base_url
}}watchonly/api/v1/wallet/&lt;wallet_id&gt; -H "X-Api-Key: {{
user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="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.base_url }}watchonly/api/v1/wallet -d
'{"title": &lt;string&gt;, "masterpub": &lt;string&gt;}' -H
"Content-type: application/json" -H "X-Api-Key: {{
user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="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.base_url
}}watchonly/api/v1/wallet/&lt;wallet_id&gt; -H "X-Api-Key: {{
user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="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.base_url
}}watchonly/api/v1/addresses/&lt;wallet_id&gt; -H "X-Api-Key: {{
user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Get 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.base_url
}}watchonly/api/v1/address/&lt;wallet_id&gt; -H "X-Api-Key: {{
user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Get mempool.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.base_url }}watchonly/api/v1/mempool -H
"X-Api-Key: {{ user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="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.base_url }}watchonly/api/v1/mempool -d
'{"endpoint": &lt;string&gt;}' -H "Content-type: application/json"
-H "X-Api-Key: {{ user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>
</q-card>
File diff suppressed because it is too large Load Diff
+182 -26
View File
@@ -1,6 +1,11 @@
from http import HTTPStatus
from fastapi import Query
from embit import script
from embit.descriptor import Descriptor, Key
from embit.ec import PublicKey
from embit.psbt import PSBT, DerivationPath
from embit.transaction import Transaction, TransactionInput, TransactionOutput
from fastapi import Query, Request
from fastapi.params import Depends
from starlette.exceptions import HTTPException
@@ -8,17 +13,22 @@ from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from lnbits.extensions.watchonly import watchonly_ext
from .crud import (
create_mempool,
create_config,
create_fresh_addresses,
create_watch_wallet,
delete_addresses_for_wallet,
delete_watch_wallet,
get_addresses,
get_config,
get_fresh_address,
get_mempool,
get_watch_wallet,
get_watch_wallets,
update_mempool,
update_address,
update_config,
update_watch_wallet,
)
from .models import CreateWallet
from .helpers import parse_key
from .models import Config, CreatePsbt, CreateWallet, WalletAccount
###################WALLETS#############################
@@ -48,18 +58,42 @@ async def api_wallet_retrieve(
@watchonly_ext.post("/api/v1/wallet")
async def api_wallet_create_or_update(
data: CreateWallet, wallet_id=None, w: WalletTypeInfo = Depends(require_admin_key)
data: CreateWallet, w: WalletTypeInfo = Depends(require_admin_key)
):
try:
wallet = await create_watch_wallet(
user=w.wallet.user, masterpub=data.masterpub, title=data.title
(descriptor, _) = parse_key(data.masterpub)
new_wallet = WalletAccount(
id="none",
user=w.wallet.user,
masterpub=data.masterpub,
fingerprint=descriptor.keys[0].fingerprint.hex(),
type=descriptor.scriptpubkey_type(),
title=data.title,
address_no=-1, # so fresh address on empty wallet can get address with index 0
balance=0,
)
wallets = await get_watch_wallets(w.wallet.user)
existing_wallet = next(
(ew for ew in wallets if ew.fingerprint == new_wallet.fingerprint), None
)
if existing_wallet:
raise ValueError(
"Account '{}' has the same master pulic key".format(
existing_wallet.title
)
)
wallet = await create_watch_wallet(new_wallet)
await api_get_addresses(wallet.id, w)
except Exception as e:
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
mempool = await get_mempool(w.wallet.user)
if not mempool:
create_mempool(user=w.wallet.user)
config = await get_config(w.wallet.user)
if not config:
await create_config(user=w.wallet.user)
return wallet.dict()
@@ -73,6 +107,7 @@ async def api_wallet_delete(wallet_id, w: WalletTypeInfo = Depends(require_admin
)
await delete_watch_wallet(wallet_id)
await delete_addresses_for_wallet(wallet_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
@@ -83,42 +118,163 @@ async def api_wallet_delete(wallet_id, w: WalletTypeInfo = Depends(require_admin
@watchonly_ext.get("/api/v1/address/{wallet_id}")
async def api_fresh_address(wallet_id, w: WalletTypeInfo = Depends(get_key_type)):
address = await get_fresh_address(wallet_id)
return address.dict()
return [address.dict()]
@watchonly_ext.put("/api/v1/address/{id}")
async def api_update_address(
id: str, req: Request, w: WalletTypeInfo = Depends(require_admin_key)
):
body = await req.json()
params = {}
# amout is only updated if the address has history
if "amount" in body:
params["amount"] = int(body["amount"])
params["has_activity"] = True
if "note" in body:
params["note"] = str(body["note"])
address = await update_address(**params, id=id)
wallet = (
await get_watch_wallet(address.wallet)
if address.branch_index == 0 and address.amount != 0
else None
)
if wallet and wallet.address_no < address.address_index:
await update_watch_wallet(
address.wallet, **{"address_no": address.address_index}
)
return address
@watchonly_ext.get("/api/v1/addresses/{wallet_id}")
async def api_get_addresses(wallet_id, w: WalletTypeInfo = Depends(get_key_type)):
wallet = await get_watch_wallet(wallet_id)
if not wallet:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
)
addresses = await get_addresses(wallet_id)
config = await get_config(w.wallet.user)
if not addresses:
await get_fresh_address(wallet_id)
await create_fresh_addresses(wallet_id, 0, config.receive_gap_limit)
await create_fresh_addresses(wallet_id, 0, config.change_gap_limit, True)
addresses = await get_addresses(wallet_id)
receive_addresses = list(filter(lambda addr: addr.branch_index == 0, addresses))
change_addresses = list(filter(lambda addr: addr.branch_index == 1, addresses))
last_receive_address = list(
filter(lambda addr: addr.has_activity, receive_addresses)
)[-1:]
last_change_address = list(
filter(lambda addr: addr.has_activity, change_addresses)
)[-1:]
if last_receive_address:
current_index = receive_addresses[-1].address_index
address_index = last_receive_address[0].address_index
await create_fresh_addresses(
wallet_id, current_index + 1, address_index + config.receive_gap_limit + 1
)
if last_change_address:
current_index = change_addresses[-1].address_index
address_index = last_change_address[0].address_index
await create_fresh_addresses(
wallet_id,
current_index + 1,
address_index + config.change_gap_limit + 1,
True,
)
addresses = await get_addresses(wallet_id)
return [address.dict() for address in addresses]
#############################MEMPOOL##########################
#############################PSBT##########################
@watchonly_ext.put("/api/v1/mempool")
async def api_update_mempool(
endpoint: str = Query(...), w: WalletTypeInfo = Depends(require_admin_key)
@watchonly_ext.post("/api/v1/psbt")
async def api_psbt_create(
data: CreatePsbt, w: WalletTypeInfo = Depends(require_admin_key)
):
mempool = await update_mempool(**{"endpoint": endpoint}, user=w.wallet.user)
return mempool.dict()
try:
vin = [
TransactionInput(bytes.fromhex(inp.tx_id), inp.vout) for inp in data.inputs
]
vout = [
TransactionOutput(out.amount, script.address_to_scriptpubkey(out.address))
for out in data.outputs
]
descriptors = {}
for _, masterpub in enumerate(data.masterpubs):
descriptors[masterpub.fingerprint] = parse_key(masterpub.public_key)
inputs_extra = []
bip32_derivations = {}
for i, inp in enumerate(data.inputs):
descriptor = descriptors[inp.masterpub_fingerprint][0]
d = descriptor.derive(inp.address_index, inp.branch_index)
for k in d.keys:
bip32_derivations[PublicKey.parse(k.sec())] = DerivationPath(
k.origin.fingerprint, k.origin.derivation
)
inputs_extra.append(
{
"bip32_derivations": bip32_derivations,
"non_witness_utxo": Transaction.from_string(inp.tx_hex),
}
)
tx = Transaction(vin=vin, vout=vout)
psbt = PSBT(tx)
for i, inp in enumerate(inputs_extra):
psbt.inputs[i].bip32_derivations = inp["bip32_derivations"]
psbt.inputs[i].non_witness_utxo = inp.get("non_witness_utxo", None)
outputs_extra = []
bip32_derivations = {}
for i, out in enumerate(data.outputs):
if out.branch_index == 1:
descriptor = descriptors[out.masterpub_fingerprint][0]
d = descriptor.derive(out.address_index, out.branch_index)
for k in d.keys:
bip32_derivations[PublicKey.parse(k.sec())] = DerivationPath(
k.origin.fingerprint, k.origin.derivation
)
outputs_extra.append({"bip32_derivations": bip32_derivations})
for i, out in enumerate(outputs_extra):
psbt.outputs[i].bip32_derivations = out["bip32_derivations"]
return psbt.to_string()
except Exception as e:
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
@watchonly_ext.get("/api/v1/mempool")
async def api_get_mempool(w: WalletTypeInfo = Depends(require_admin_key)):
mempool = await get_mempool(w.wallet.user)
if not mempool:
mempool = await create_mempool(user=w.wallet.user)
return mempool.dict()
#############################CONFIG##########################
@watchonly_ext.put("/api/v1/config")
async def api_update_config(
data: Config, w: WalletTypeInfo = Depends(require_admin_key)
):
config = await update_config(data, user=w.wallet.user)
return config.dict()
@watchonly_ext.get("/api/v1/config")
async def api_get_config(w: WalletTypeInfo = Depends(get_key_type)):
config = await get_config(w.wallet.user)
if not config:
config = await create_config(user=w.wallet.user)
return config.dict()
+1 -1
View File
@@ -34,7 +34,7 @@ class ExtensionManager:
@property
def extensions(self) -> List[Extension]:
output = []
output: List[Extension] = []
if "all" in self._disabled:
return output
+1 -1
View File
@@ -21,7 +21,7 @@ class Jinja2Templates(templating.Jinja2Templates):
self.env = self.get_environment(loader)
def get_environment(self, loader: "jinja2.BaseLoader") -> "jinja2.Environment":
@jinja2.contextfunction
@jinja2.pass_context
def url_for(context: dict, name: str, **path_params: typing.Any) -> str:
request: Request = context["request"]
return request.app.url_path_for(name, **path_params)
+2 -2
View File
@@ -261,7 +261,7 @@ window.LNbits = {
return data
}
},
exportCSV: function (columns, data) {
exportCSV: function (columns, data, fileName) {
var wrapCsvValue = function (val, formatFn) {
var formatted = formatFn !== void 0 ? formatFn(val) : val
@@ -295,7 +295,7 @@ window.LNbits = {
.join('\r\n')
var status = Quasar.utils.exportFile(
'table-export.csv',
`${fileName || 'table-export'}.csv`,
content,
'text/csv'
)
+1 -1
View File
@@ -66,7 +66,7 @@ async def webhook_handler():
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
internal_invoice_queue = asyncio.Queue(0)
internal_invoice_queue: asyncio.Queue = asyncio.Queue(0)
async def internal_invoice_listener():
+4 -4
View File
@@ -1,5 +1,5 @@
import asyncio
from typing import Callable, NamedTuple
from typing import Callable, List, NamedTuple
import httpx
from loguru import logger
@@ -227,10 +227,10 @@ async def btc_price(currency: str) -> float:
"TO": currency.upper(),
"to": currency.lower(),
}
rates = []
tasks = []
rates: List[float] = []
tasks: List[asyncio.Task] = []
send_channel = asyncio.Queue()
send_channel: asyncio.Queue = asyncio.Queue()
async def controller():
failures = 0
+4 -1
View File
@@ -7,7 +7,10 @@ from typing import AsyncGenerator, Dict, Optional
import httpx
from loguru import logger
from websockets import connect
# TODO: https://github.com/lnbits/lnbits-legend/issues/764
# mypy https://github.com/aaugustin/websockets/issues/940
from websockets import connect # type: ignore
from websockets.exceptions import (
ConnectionClosed,
ConnectionClosedError,
+3 -3
View File
@@ -28,7 +28,7 @@ class FakeWallet(Wallet):
logger.info(
"FakeWallet funding source is for using LNbits as a centralised, stand-alone payment system with brrrrrr."
)
return StatusResponse(None, float("inf"))
return StatusResponse(None, 1000000000)
async def create_invoice(
self,
@@ -82,7 +82,7 @@ class FakeWallet(Wallet):
invoice = decode(bolt11)
if (
hasattr(invoice, "checking_id")
and invoice.checking_id[6:] == data["privkey"][:6]
and invoice.checking_id[6:] == data["privkey"][:6] # type: ignore
):
return PaymentResponse(True, invoice.payment_hash, 0)
else:
@@ -97,7 +97,7 @@ class FakeWallet(Wallet):
return PaymentStatus(None)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
self.queue = asyncio.Queue(0)
self.queue: asyncio.Queue = asyncio.Queue(0)
while True:
value = await self.queue.get()
yield value
+1 -1
View File
@@ -119,7 +119,7 @@ class LNPayWallet(Wallet):
return PaymentStatus(statuses[r.json()["settled"]])
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
self.queue = asyncio.Queue(0)
self.queue: asyncio.Queue = asyncio.Queue(0)
while True:
value = await self.queue.get()
yield value
+3 -3
View File
@@ -73,10 +73,10 @@ class AESCipher(object):
final_key += key
return final_key[:output]
def decrypt(self, encrypted: str) -> str:
def decrypt(self, encrypted: str) -> str: # type: ignore
"""Decrypts a string using AES-256-CBC."""
passphrase = self.passphrase
encrypted = base64.b64decode(encrypted)
encrypted = base64.b64decode(encrypted) # type: ignore
assert encrypted[0:8] == b"Salted__"
salt = encrypted[8:16]
key_iv = self.bytes_to_key(passphrase.encode(), salt, 32 + 16)
@@ -84,7 +84,7 @@ class AESCipher(object):
iv = key_iv[32:]
aes = AES.new(key, AES.MODE_CBC, iv)
try:
return self.unpad(aes.decrypt(encrypted[16:])).decode()
return self.unpad(aes.decrypt(encrypted[16:])).decode() # type: ignore
except UnicodeDecodeError:
raise ValueError("Wrong passphrase")
+1 -1
View File
@@ -127,7 +127,7 @@ class OpenNodeWallet(Wallet):
return PaymentStatus(statuses[r.json()["data"]["status"]])
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
self.queue = asyncio.Queue(0)
self.queue: asyncio.Queue = asyncio.Queue(0)
while True:
value = await self.queue.get()
yield value
+4 -3
View File
@@ -1,7 +1,8 @@
[mypy]
ignore_missing_imports = True
exclude = lnbits/wallets/lnd_grpc_files/
exclude = lnbits/extensions/
exclude = (?x)(
^lnbits/extensions.
| ^lnbits/wallets/lnd_grpc_files.
)
[mypy-lnbits.wallets.lnd_grpc_files.*]
follow_imports = skip
Symlink
+1
View File
@@ -0,0 +1 @@
/nix/store/ds9c48q7hnkdmpzy3aq14kc1x9wrrszd-python3.9-lnbits-0.1.0
+24
View File
@@ -1,6 +1,7 @@
import pytest
import pytest_asyncio
from lnbits.core.crud import get_wallet
from lnbits.core.views.api import api_payment
from ...helpers import get_random_invoice_data
@@ -155,3 +156,26 @@ async def test_decode_invoice(client, invoice):
)
assert response.status_code < 300
assert response.json()["payment_hash"] == invoice["payment_hash"]
# check api_payment() internal function call (NOT API): payment status
@pytest.mark.asyncio
async def test_api_payment_without_key(invoice):
# check the payment status
response = await api_payment(invoice["payment_hash"])
assert type(response) == dict
assert response["paid"] == True
# no key, that's why no "details"
assert "details" not in response
# check api_payment() internal function call (NOT API): payment status
@pytest.mark.asyncio
async def test_api_payment_with_key(invoice, inkey_headers_from):
# check the payment status
response = await api_payment(
invoice["payment_hash"], inkey_headers_from["X-Api-Key"]
)
assert type(response) == dict
assert response["paid"] == True
assert "details" in response
Binary file not shown.
+27 -13
View File
@@ -1,9 +1,8 @@
import psycopg2
import sqlite3
import os
import argparse
import os
import sqlite3
import psycopg2
from environs import Env # type: ignore
env = Env()
@@ -284,22 +283,24 @@ def migrate_ext(sqlite_db_file, schema, ignore_missing=True):
masterpub,
title,
address_no,
balance
balance,
type,
fingerprint
)
VALUES (%s, %s, %s, %s, %s, %s);
VALUES (%s, %s, %s, %s, %s, %s, %s, %s);
"""
insert_to_pg(q, res.fetchall())
# ADDRESSES
res = sq.execute("SELECT * FROM addresses;")
q = f"""
INSERT INTO watchonly.addresses (id, address, wallet, amount)
VALUES (%s, %s, %s, %s);
INSERT INTO watchonly.addresses (id, address, wallet, amount, branch_index, address_index, has_activity, note)
VALUES (%s, %s, %s, %s, %s, %s, %s::boolean, %s);
"""
insert_to_pg(q, res.fetchall())
# MEMPOOL
res = sq.execute("SELECT * FROM mempool;")
# CONFIG
res = sq.execute("SELECT * FROM config;")
q = f"""
INSERT INTO watchonly.mempool ("user", endpoint)
INSERT INTO watchonly.config ("user", json_data)
VALUES (%s, %s);
"""
insert_to_pg(q, res.fetchall())
@@ -540,8 +541,8 @@ def migrate_ext(sqlite_db_file, schema, ignore_missing=True):
# ITEMS
res = sq.execute("SELECT * FROM items;")
q = f"""
INSERT INTO offlineshop.items (shop, id, name, description, image, enabled, price, unit)
VALUES (%s, %s, %s, %s, %s, %s::boolean, %s, %s);
INSERT INTO offlineshop.items (shop, id, name, description, image, enabled, price, unit, fiat_base_multiplier)
VALUES (%s, %s, %s, %s, %s, %s::boolean, %s, %s, %s);
"""
items = res.fetchall()
insert_to_pg(q, items)
@@ -703,6 +704,19 @@ def migrate_ext(sqlite_db_file, schema, ignore_missing=True):
VALUES (%s, %s, %s, %s, %s, %s);
"""
insert_to_pg(q, res.fetchall())
elif schema == "scrub":
# SCRUB LINKS
res = sq.execute("SELECT * FROM scrub_links;")
q = f"""
INSERT INTO scrub.scrub_links (
id,
wallet,
description,
payoraddress
)
VALUES (%s, %s, %s, %s);
"""
insert_to_pg(q, res.fetchall())
else:
print(f"❌ Not implemented: {schema}")
sq.close()