Compare commits

..
387 Commits
Author SHA1 Message Date
calleandGitHub e7a6e86e7a Fix/duplicate payments (#973)
* check if wallet exists

* check wallet existence in key check

* fix duplicate removal
2022-09-18 16:27:03 +03:00
calleandGitHub 232d50baaa Fix: tasks.py reuse db connection for invoice deletion (#971)
* check if wallet exists

* check wallet existence in key check

* reuse db connection for payment deletion
2022-09-15 14:48:59 +03:00
ArcandGitHub 6f9ad06449 Merge pull request #943 from lnbits/fix/tpos_dismiss_btn
Fix/tpos dismiss btn
2022-09-13 15:52:51 +01:00
ArcandGitHub 2abf741f28 Merge pull request #944 from lnbits/fix/event_ticket_counter
Fix/event ticket counter
2022-09-13 15:51:25 +01:00
ArcandGitHub 2782a92aaa Merge pull request #957 from iWarpBTC/fix/withdrawQR
fix for uppercase QR
2022-09-13 15:49:27 +01:00
ArcandGitHub 9e652d98ea Merge pull request #958 from AaronDewes/patch-1
Change message about login functionality
2022-09-13 15:48:37 +01:00
ArcandGitHub bfc95dc4dd Merge pull request #965 from motorina0/watchonly_browser_flash_link
feat: add link for `hardware-wallet` browser flash
2022-09-13 14:27:29 +01:00
Vlad Stan 7ee6d09585 feat: add link for hardware-wallet browser flash 2022-09-13 16:24:30 +03:00
calleandGitHub d0ca0b18da Fix/db reuse connection mark pending (#964)
* check if wallet exists

* check wallet existence in key check

* reuse connection for stataus update

* make format
2022-09-12 20:57:23 +03:00
calleandGitHub 1660b9dcf1 Revert "Revert "API key check: assert that wallet exists (#961)" (#962)" (#963)
This reverts commit 57fffa0c7f.
2022-09-12 18:49:57 +03:00
calleandGitHub 57fffa0c7f Revert "API key check: assert that wallet exists (#961)" (#962)
This reverts commit 0930fca7ec.
2022-09-12 18:41:27 +03:00
calleandGitHub 0930fca7ec API key check: assert that wallet exists (#961)
* check if wallet exists

* check wallet existence in key check
2022-09-12 18:39:53 +03:00
ArcandGitHub e080e11bf5 Merge pull request #960 from lnbits/dni-patch-1
Fix donate link on start page
2022-09-12 15:13:41 +01:00
dni ⚡andGitHub d6e3eb5837 Fix donate link on start page 2022-09-12 14:38:37 +02:00
Aaron DewesandGitHub b8531f9e0e Change message about login functionality
According to the README, Lnbits is already at 0.9. Claiming a feature will be added in 0.2 doesn't make sense.
2022-09-12 12:32:03 +02:00
Gene TakavicandGitHub 8c75ea600f fix for uppercase QR 2022-09-12 10:17:45 +02:00
Tiago vasconcelos 15e9eeb682 Merge remote-tracking branch 'lnbits/main' into fix/tpos_dismiss_btn 2022-09-12 08:52:25 +01:00
Tiago vasconcelos fad040edf1 remove toast - click checkmark to dismiss 2022-09-12 08:49:12 +01:00
calleandGitHub f3a16a28cc Update installation.md (#955) 2022-09-11 13:06:55 +03:00
Jesse de WitandGitHub f30ca2cf43 lndhub: remove delays (#954)
Remove the delays in gettxs and getuserinvoices calls to support wallets that use
the lndhub extension.
2022-09-09 17:03:02 +03:00
calleandGitHub 4662a79c3b little more logging (#953) 2022-09-09 16:46:27 +03:00
calleandGitHub d757154125 print time of expiration (#952) 2022-09-09 16:27:37 +03:00
calleandGitHub 54305c8f91 reuse db connection for pending and expired tasks (#951)
* reuse db connection for pending and expired tasks

* make format
2022-09-09 15:02:07 +03:00
ArcandGitHub 7d0b5ade3e Merge pull request #947 from lnbits/fix/copilot_ws
remove already added prefix ou route - ws working again
2022-09-09 10:05:55 +01:00
ArcandGitHub 9296e295ea Merge pull request #950 from motorina0/fix_onchain_account_type
fix: type can be `None` due to migration issue
2022-09-09 10:00:40 +01:00
Vlad Stan 2704552685 fix: type can be None due to migration issue 2022-09-09 11:55:12 +03:00
Tiago vasconcelos d1eb0b0c4d rollback on removing NFC button 2022-09-08 14:59:19 +01:00
Tiago vasconcelos 0177316075 display NFC button on supported and fix null tip options 2022-09-08 14:59:19 +01:00
Tiago vasconcelos d2f00435cc remove already added prefix ou route - ws working again 2022-09-06 12:30:20 +01:00
Tiago vasconcelos dc6098e1ca remove call to set ticket paid 2022-09-05 12:56:40 +01:00
Tiago vasconcelos e0c048339c update event data on ticket sold 2022-09-05 12:54:37 +01:00
Tiago vasconcelos ddc3ec7e2f add a tooltip and label for adding tip options 2022-09-05 11:55:24 +01:00
Tiago vasconcelos e5644ec4f6 show a dismissable notification after payment succeed 2022-09-05 11:47:27 +01:00
ArcandGitHub d96ac5f3ef Merge pull request #940 from lnbits/dailylimitfix
Fixed daily limit
2022-08-31 21:43:13 +01:00
ben 39800fd5fd Fixed daily limit 2022-08-31 21:42:27 +01:00
ArcandGitHub 83605a2524 Merge pull request #939 from lnbits/boltcardappfix
Hotfix: Fixes issue with new app
2022-08-31 21:35:25 +01:00
ben 7e90c47008 Works with new app 2022-08-31 21:32:53 +01:00
ben d95a3032e6 Fix for boltcard app change 2022-08-31 20:27:42 +01:00
ArcandGitHub 3080094273 Merge pull request #938 from lnbits/boltcardfix
Fixes daily limit issue
2022-08-31 19:30:46 +01:00
ben c52ffe846d import dattime 2022-08-31 19:26:59 +01:00
ben dda5784a47 added amount to spend 2022-08-31 19:24:50 +01:00
ArcandGitHub 7b2be9cd49 Merge pull request #937 from lnbits/boltzfix
Ruined extension layout
2022-08-31 19:08:48 +01:00
ben bcbdda657b Ruined extension layout 2022-08-31 19:04:07 +01:00
ArcandGitHub ee76a58c27 Merge pull request #936 from lnbits/revertremovinnfcbutton
Reverted nfc button not showing
2022-08-31 18:58:53 +01:00
ben 609bdae1e4 Merge branch 'revertremovinnfcbutton' into boltcardfix 2022-08-31 18:52:38 +01:00
ben fa4b89b9a2 Reverted nfc button not showing
How it was before is better, as people know nfc functionality exists. It pushes them to use the right browser
2022-08-31 18:52:12 +01:00
ben aeab899e4d Fix daily check 2022-08-31 18:32:13 +01:00
ArcandGitHub 13dd3bbe43 Merge pull request #935 from lnbits/arcbtc-patch-1
Update README.md
2022-08-31 15:49:52 +01:00
ArcandGitHub 01d0fdeac6 Update README.md 2022-08-31 15:49:31 +01:00
ArcandGitHub d5df15707e Merge pull request #876 from leesalminen/ext-boltcards-2
Boltcards extension, round 2
2022-08-31 00:03:56 +01:00
ben b3093cafdb Added video tutorial 2022-08-30 23:57:28 +01:00
Lee Salminen 36b2b82186 Merge branch 'main' into ext-boltcards-2 2022-08-30 10:20:24 -06:00
Lee Salminen c114895219 text change, fix copy pasta 2022-08-30 08:08:11 -06:00
Lee Salminen ad60c1614e hide nfc button if not supported on device, per issue #907 2022-08-30 08:06:05 -06:00
Lee Salminen 7a7413bb6f add lnurlw_base to auth response, per developers of the android app a future version will use this to pull the base url directly into the app 2022-08-30 07:35:31 -06:00
calleandGitHub 2ee10e28c5 Backend: Unstuck outgoing payments (#857)
* first attempts

* lndrest works

* fix details

* optional fee update

* use base64.urlsafe_b64encode

* return paymentstatus

* CLN: return pending for pending invoices

* grpc wip

* lndgrpc works

* cln: return pending for pending invoices really this time

* retry wallet out of exception

* wip eclair

* take all routines into try except

* cliche: return error

* rename payment.check_pending() to payment.check_status()

* rename payment.check_pending() to payment.check_status()

* eclair: works

* eclair: better error check

* opennode: works

* check payment.checking_id istead of payment.ok

* payment.ok check as well

* cln: works?

* cln: works

* lntxbot: works

* lnbits/wallets/lnpay.py

* cln: error handling

* make format

* lndhub full detail update

* spark: wip

* error to False

* wallets: return clean PaymentResponse

* opennode: strict error

* cliche: works

* lnbits: works

* cln: dont throw error

* preimage not error

* fix cln

* do not add duplicate payments

* revert cln

* extra safety for cln

* undo crud changes until tests work

* tasks: better logging and 0.5s sleep for regular status check

* 0.1 s

* check if wallet exists

* lnbits unhashed description

* remove sleep

* revert app.py

* cleanup

* add check

* clean error

* readd app.py

* fix eclaid
2022-08-30 13:28:58 +02:00
78a98ca97d Boltz.exchange Extension (#922)
* initial commit and still draft, ready for review

* forgot to uncomment this line

* fee estimation and blockheight

* resolve conversation with michael, to use mempool websockets instead of boltz status event

* Update lnbits/extensions/boltz/boltz.py

Co-authored-by: michael1011 <me@michael1011.at>

* add status to swaps, add sorting and data into listing

* add swap status checks, change urls to docker test setup, dynamic minimum and maximum limits

* fix docker hosts for development

* add api endpoints to _api_docs

* add wallet name and id, to list and status information

* fix status_update for reverse_swaps

* chore: format with black

* more blackformatting and refactoring create_swap()

* fix variable bug

* check if swap is already refunded

* use create_task instead of ensure_future

* add mempool and boltz urls depending on DEBUG .env

* raise exception in mempool fails

* fix onchain txs, sending funds to wrong address and add a refund address for normal swaps beforehand

* add status to swaps, add sorting and data into listing

* add swap status checks, change urls to docker test setup, dynamic minimum and maximum limits

* add wallet name and id, to list and status information

* fix status_update for reverse_swaps

* chore: format with black

* use create_task instead of ensure_future

* add mempool and boltz urls depending on DEBUG .env

* fix onchain txs, sending funds to wrong address and add a refund address for normal swaps beforehand

* black formatting

* add some logging with loguru, and remove function duplication

* cleanup readme

* updates/suggestions from calle

Co-authored-by: calle <93376500+callebtc@users.noreply.github.com>

* remove unused comments

* Update API Endpoints

Co-authored-by: calle <93376500+callebtc@users.noreply.github.com>

* un-factor get_boltz_pairs

* added a explaination for the onchain tx

* remove unused template file

* rename api endpoints

* fix isort and prettier

* more verbose logging!!

* add boltz to mock_data.zip

* new mockdata

* remove comment

* better readme

* fix mempool urls

* change /refund /check /status to post requests

* first step in tests2

* add first tests

* change refund,check,status to post requests

* next try on tests

* overall code improvements

* just testing tests

* throw http exceptions in views_api

* require admincheck for refund,check,status and added fastapi documentation for those

* added more tests

* black

* many code improvements

* adding tests

* temp fix test

* fix race condition when pay_invoice fails

* test are working

* add boltz env variables

* add startup check, bugfixes, improvements

* improve on status checking

* remove check_invoice_status

* more fixes and tests

* testing testing testing

* make tests run again inside regtest

* fix bad error :O

* fix postgres boolean bug and add swap test

* Update README.md

Update README.md

Update README.md

Update README.md

* some mypy

* blacked

* the missing commit?

* fix api_docs readme link

* better refunding error catching

fix

* check swaps now also shows pending reverse swap, ui improvements, tooltips

* add backend check for boltz limits

fixup

* many improvements, startup check for swaps working, reverse needs more testing

* little last fixes

* remove unused logic

* fastapi documentation

fixup

* formatting and remove unused tests

* fix test

* fix swapstatus model

* Update lnbits/extensions/boltz/tasks.py

Co-authored-by: calle <93376500+callebtc@users.noreply.github.com>

* Update lnbits/extensions/boltz/views_api.py

Co-authored-by: calle <93376500+callebtc@users.noreply.github.com>

* balance check msg, format

* fix mypy data override

* fix swapstatus, remove can refund column

* Update lnbits/extensions/boltz/README.md

Co-authored-by: michael1011 <me@michael1011.at>

* empty lines

* fix error message when swap is not found

* remove preimage_hash from database

* fix api_docs html

fix api_docs html

* catch boltz network exceptions better

* formatting

* check for timeout on swap at get request

Co-authored-by: michael1011 <me@michael1011.at>
Co-authored-by: fusion44 <some.fusion@gmail.com>
Co-authored-by: calle <93376500+callebtc@users.noreply.github.com>
2022-08-30 12:51:17 +02:00
Lee Salminen 4e63662f42 add external_id field for external use 2022-08-29 14:51:18 -06:00
Lee Salminen 81ed71a2e4 update README to reflect latest changes 2022-08-29 12:04:08 -06:00
Lee Salminen 993511bcd4 fix validation check for existing card UID, was preventing all edits of cards. we should validate a bit differently for updates vs. creates 2022-08-29 09:40:35 -06:00
Lee Salminen a6cf19c29d potential race condition here, hits is dependent on cards to get the card name in hits 2022-08-29 09:33:28 -06:00
dni ⚡andGitHub 5fecb02b8d fix typo in view api.py (#933) 2022-08-29 17:32:36 +02:00
Lee Salminen 88210129b7 make refunds work properly 2022-08-29 09:32:13 -06:00
Lee Salminen 7baec1de15 success_action was not defined, does not appear to be necessary for this 2022-08-29 09:05:00 -06:00
Lee Salminen 82904e509d Merge branch 'ext-boltcards-2' of github.com:leesalminen/lnbits-legend into ext-boltcards-2 2022-08-29 08:51:41 -06:00
Lee Salminen fe83cad7a3 formatting 2022-08-29 08:51:32 -06:00
ben d4da94e10b Merge remote-tracking branch 'leesalminen/ext-boltcards-2' into ext-boltcards-2 2022-08-29 15:51:32 +01:00
ben 56b4eb4a5e Added "lnurl://" to refund 2022-08-29 15:51:22 +01:00
Lee Salminen 6d9d037c06 add link to play store for bolt card app listing 2022-08-29 08:51:15 -06:00
Lee Salminen b7c7b28dfc remove v-el:keybtn, was removed in vue v2. unnecessary ref anyways 2022-08-29 08:49:57 -06:00
Lee Salminen 51f9166744 define lnurlLink 2022-08-29 08:49:04 -06:00
ben 1493e73724 Merge remote-tracking branch 'leesalminen/ext-boltcards-2' into ext-boltcards-2 2022-08-29 15:40:31 +01:00
Lee Salminen 8e41f7867e Revert "make format"
This reverts commit 8832d6e4eb.
2022-08-29 08:39:37 -06:00
Lee Salminen 93483f76ab Revert "make copy lnurl to clipboard work"
This reverts commit c8b725830b.
2022-08-29 08:39:30 -06:00
Lee Salminen e715ff52d2 Revert "link to play store app in qr code dialog"
This reverts commit 7400d7f430.
2022-08-29 08:39:16 -06:00
Lee Salminen 5dd6c0d2f4 Revert "make scan nfc button work"
This reverts commit a8ac90da5c.
2022-08-29 08:39:02 -06:00
Lee Salminen d7696fdc0f Revert "remove v-el from element, this was removed in Vue v2, ref does not appear to be used anywhere"
This reverts commit c35ae109a4.
2022-08-29 08:38:54 -06:00
Lee Salminen a086db43ba Revert "do not add lnurlw:// to the string"
This reverts commit 630cc296fb.
2022-08-29 08:38:48 -06:00
ben d858a3c7cd Merge branch 'bensbits' into ext-boltcards-2 2022-08-29 15:37:55 +01:00
ben 33f9ae9f66 Added some unique checks so only 1 record can be made per card 2022-08-29 15:37:31 +01:00
Lee Salminen 630cc296fb do not add lnurlw:// to the string 2022-08-29 08:22:51 -06:00
Lee Salminen c35ae109a4 remove v-el from element, this was removed in Vue v2, ref does not appear to be used anywhere 2022-08-29 08:21:47 -06:00
Lee Salminen a8ac90da5c make scan nfc button work 2022-08-29 08:19:39 -06:00
ben c0daf8e7b2 Merge branch 'bensbits' into ext-boltcards-2 2022-08-29 15:12:27 +01:00
ben 7ea3830fed Added NFC to get UID 2022-08-29 15:11:25 +01:00
Lee Salminen 7400d7f430 link to play store app in qr code dialog 2022-08-29 07:58:29 -06:00
Lee Salminen c8b725830b make copy lnurl to clipboard work 2022-08-29 07:55:51 -06:00
Lee Salminen 8832d6e4eb make format 2022-08-29 07:34:56 -06:00
Lee SalminenandGitHub b987059386 Merge branch 'lnbits:main' into ext-boltcards-2 2022-08-29 07:33:18 -06:00
ben 59876fc17c Merge branch 'ext-boltcards-2' into bensbits 2022-08-29 14:18:36 +01:00
ben 7bfc0848ef Black/prettier 2022-08-29 14:18:18 +01:00
dni ⚡andGitHub 88d38f7294 change docker image name for regtest tests (#931) 2022-08-29 10:55:35 +02:00
ben 9e3cd3f00e Merge branch 'bensbits' into ext-boltcards-2 2022-08-28 12:29:45 +01:00
ben 17eeed48f5 Added pretty "add card" button 2022-08-28 12:29:27 +01:00
ben 25697a8b00 Merge branch 'bensbits' into ext-boltcards-2 2022-08-28 12:16:30 +01:00
ben 6e9fd28d28 UI tidy 2022-08-28 12:16:23 +01:00
ben 6f5c35d4d3 Merge branch 'ext-boltcards-2' into bensbits 2022-08-28 11:59:55 +01:00
ben 8cabd7b5a4 withdraws working 2022-08-28 11:59:21 +01:00
ben cb5c11fae4 Fixed enable 2022-08-28 11:05:26 +01:00
ben 4f920fe563 Merge branch 'bensbits' into ext-boltcards-2 2022-08-28 10:58:54 +01:00
ben 977f9bb8c4 Added enable disable card 2022-08-28 10:58:17 +01:00
ben 77ecc5a1d2 Merge branch 'bensbits' into ext-boltcards-2 2022-08-27 17:26:28 +01:00
ben 20dbd879b1 bug 2022-08-27 17:26:17 +01:00
ben 5ba624761c Merge branch 'ext-boltcards-2' into bensbits 2022-08-27 16:41:16 +01:00
ben 99595ba96b Added refunds 2022-08-27 16:37:31 +01:00
ben 330a026379 Pretty much works 2022-08-26 19:22:03 +01:00
ben c3870e65e3 Added UID scan for form 2022-08-25 14:17:03 +01:00
ben 4e8766ce08 removed uid for justa card id 2022-08-25 13:07:18 +01:00
ben 59d6b654c5 push before removing uuid 2022-08-24 09:33:38 +01:00
ben c10f89e1d6 Boots, errors, needs work 2022-08-22 23:29:42 +01:00
ben 56c9234aff LNURLs handled internally
Has bugs
2022-08-22 22:33:20 +01:00
ben 4e68c114fd UI updated 2022-08-22 20:14:19 +01:00
ben baf993f185 Merge remote-tracking branch 'origin/main' into ext-boltcards-2 2022-08-22 16:05:12 +01:00
ArcandGitHub ff8d32ec83 Merge pull request #915 from leesalminen/fly-io-docs
Add docs for hosting LNBits on Fly.io (for free)
2022-08-22 15:55:05 +01:00
ArcandGitHub ccc76d8ed1 Merge pull request #910 from motorina0/secure_communication
[onchain-ext] Secure communication
2022-08-22 14:13:55 +01:00
Vlad Stan 9bbcd28452 feat: UI improvements and fixes 2022-08-22 10:09:26 +03:00
Vlad Stan d08b7455e7 feat: add passphrase support 2022-08-22 09:04:16 +03:00
Tiago VasconcelosandGitHub 551c75c2cf set focus on amount (#916) 2022-08-21 23:07:18 +02:00
Tiago VasconcelosandGitHub 1d55fe1d1a add copy invoice button (#917) 2022-08-21 23:06:47 +02:00
Lee Salminen a3f910acf4 additional validation 2022-08-21 13:17:44 -06:00
Lee SalminenandGitHub aff7e11089 Update installation.md 2022-08-21 12:57:28 -06:00
Lee SalminenandGitHub 218ccbc3a7 Update installation.md 2022-08-21 11:46:11 -06:00
Lee SalminenandGitHub 5c2045927a Update installation.md 2022-08-21 11:39:34 -06:00
Lee SalminenandGitHub d9ddd746e0 Update installation.md 2022-08-21 11:25:39 -06:00
Lee Salminen a6f0acc030 update readme 2022-08-21 10:31:47 -06:00
Lee Salminen f6a301668b Revert "convert to using lnbits card id instead of card uid"
This reverts commit 971d8f34e8.
2022-08-21 10:30:25 -06:00
Lee Salminen 38ef4f3a78 Revert "surface the ID"
This reverts commit b11ddf3d61.
2022-08-21 10:30:10 -06:00
Lee Salminen c4ffd45485 more readme 2022-08-21 10:19:34 -06:00
Lee Salminen ec9005ffca add info about {card_id} in readme 2022-08-21 10:18:28 -06:00
Lee Salminen b11ddf3d61 surface the ID 2022-08-21 10:06:39 -06:00
Lee Salminen 971d8f34e8 convert to using lnbits card id instead of card uid 2022-08-21 10:00:16 -06:00
Lee Salminen a8bc3ea870 card_uid is always upper 2022-08-21 09:47:14 -06:00
Lee Salminen f41c43cbf4 get the uid back into bytes 2022-08-21 09:09:01 -06:00
Lee Salminen 9dd7d30716 format 2022-08-21 09:02:20 -06:00
Lee Salminen 4242a82029 Add a secondary route with the card_uid appended to it. 2022-08-21 08:58:38 -06:00
Lee Salminen db83d803f8 add .upper() to other side of the condition 2022-08-21 07:32:11 -06:00
Lee Salminen 6898412e6c more validation 2022-08-19 16:54:06 -06:00
Lee Salminen 302ccfd429 add better error handling 2022-08-19 16:52:06 -06:00
Lee Salminen b80f2f8e4d error handling if bad input data in db 2022-08-19 16:47:33 -06:00
Lee SalminenandGitHub 7d56878f55 Update installation.md 2022-08-19 16:33:17 -06:00
Lee Salminen 70deb7e702 add docs for hosting lnbits on fly.io 2022-08-19 16:29:32 -06:00
Vlad Stan 739646f702 fix: show message when device not paired 2022-08-19 18:54:54 +03:00
Vlad Stan 95e7633e19 fix: await async method 2022-08-19 18:05:05 +03:00
Vlad Stan 5b59cdcd0d feat: buttonConfig pins are optional 2022-08-19 17:49:01 +03:00
Lee Salminen a28bedf430 update link, this will work once merged to main 2022-08-19 08:45:27 -06:00
Lee Salminen 43db91641a .upper() on p and c to handle breez wallet lower-casing the whole url 2022-08-19 07:34:46 -06:00
Vlad Stan 83f998fe37 feat: remember paired devices 2022-08-19 14:59:59 +03:00
Vlad Stan 713909292e chore: format code 2022-08-19 08:51:57 +03:00
Vlad StanandGitHub fb7030c30f Merge branch 'main' into secure_communication 2022-08-19 01:42:17 -04:00
Vlad Stan 218ee77acd feat: handle forced logout 2022-08-18 18:57:20 +03:00
Vlad Stan 90938e89e1 feat: show pairing code 2022-08-18 17:54:26 +03:00
Vlad Stan d8d4d4d057 feat: add buttons config 2022-08-18 16:33:10 +03:00
calleandGitHub ae1a2ac383 Frontend: LNURLp/w icons with tooltips (#905)
* rename button to copy link

* icon buttons with tooltips
2022-08-18 13:24:34 +02:00
dni ⚡andGitHub e2c9673517 subdomains docs fix: #900 (#904)
fix subdomain link to docs: also mentioned in #900
2022-08-18 10:17:32 +02:00
dni ⚡andGitHub 853536ea32 lnAdress fix link to docs (#903)
Fixes: https://github.com/lnbits/lnbits-legend/issues/900
2022-08-18 10:17:16 +02:00
Vlad Stan 61d3ca8c0b feat: hand pairing response 2022-08-18 10:32:56 +03:00
Vlad Stan e37fc0b898 fix: check-paring message 2022-08-18 09:01:05 +03:00
Vlad Stan faa9a017a4 refactor: rename command 2022-08-18 08:49:46 +03:00
Vlad Stan fb60673f67 feat: add ping command 2022-08-18 08:38:40 +03:00
31a51f3cbc add debug logging into tests (#895)
Co-authored-by: dni <dni.khr@gmail.com>
2022-08-17 15:57:56 +02:00
d649e6a5c0 fix migrations, add documentation, hopefully fix workflow (#899)
* fix migrations, add documentation, hopefully fix workflow

* renaming to test-migration and add migration

Co-authored-by: dni <dni.khr@gmail.com>
2022-08-17 15:42:01 +02:00
calleandGitHub 24de8f6611 isort api.py (#898) 2022-08-17 14:27:52 +02:00
callebtc 65f27afd76 upadte systemd docs to poetry 2022-08-17 14:25:19 +02:00
calleandGitHub 790f0efda7 Chore/unhashed description expressive error (#894)
* dont assume field

* expressive error for desciprion_hash and unhashed_description in format that isnt hex
2022-08-17 00:59:11 +02:00
Tiago VasconcelosandGitHub 7bba742faf delete tips first... (#888) 2022-08-16 17:01:53 +02:00
3dc00f67db add pending column to CSV export on wallet page, issue #887 (#889)
* add pending, isPaid columns to CSV export on wallet page, issue #887

* prettier formatting

* remove isPaid from csv export

* added comment

Co-authored-by: dni <dni.khr@gmail.com>
2022-08-16 17:01:32 +02:00
ee4049ec1f fix javascript issue, Fixes: #890 (#891)
Co-authored-by: dni <dni.khr@gmail.com>
2022-08-16 17:01:20 +02:00
88ec440ea1 another testcase the fails similar to #847 (#892)
* another testcase the fails similar to #847

* proper fix

* revert to statuscode 401 and code improvements @calle

Co-authored-by: dni <dni.khr@gmail.com>
2022-08-16 17:01:05 +02:00
Vlad Stan f73f5a90c9 feat: only allow DH key exchange in the first 60 seconds 2022-08-16 14:08:04 +03:00
Vlad Stan 7c796f428a feat: allow COMMAND_PASSWORD_CLEAR in clear text 2022-08-16 12:16:09 +03:00
JoeandGitHub 8956bdc954 Update installation.md (#884)
* Update installation.md

I needed to run this additional command to install python3.9-specific distutils. Without this, installation of several poetry dependencies (idna, pyparsing, sniffio) failed with message: 
ModuleNotFoundError: No module named 'distutils.cmd'

* Update installation.md
2022-08-15 16:22:20 +02:00
Lee SalminenandGitHub 6aef9bfd45 Add NFC support to TPOS Extension (#856)
* WIP

* styling

* WIP

* WIP

* WIP

* Clean up for PR

* isort

* hold abortContoller for a while longer
2022-08-15 15:37:10 +02:00
Vlad Stan 32bb59255d feat: restore takes both the password and the seed as args 2022-08-15 15:59:49 +03:00
Vlad Stan 01d2db5a1b fix: bad command for restore 2022-08-15 15:43:35 +03:00
Lee Salminen 89130bd1fb once over on README 2022-08-14 18:34:19 -06:00
calleandGitHub 4ac73f1a79 Lnurlp/url_validation_fix (#883)
* dont assume field

* fix https check
2022-08-15 00:20:57 +02:00
Gene Takavic 0e5f6ac586 adapt to bolt-nfc-android-app 2022-08-14 23:52:55 +02:00
calleandGitHub befe96257d Fix/header_check_fix_2 (#880)
* dont assume field

* check better
2022-08-14 23:17:53 +02:00
calleandGitHub 9bc99f796b Fix/header_lookup_none (#879)
* dont assume field

* fix header lookup
2022-08-14 22:59:02 +02:00
calleandGitHub 0f760bf39e Lnurlp: use unhashed_description instead of description_hash (#878)
* dont assume field

* pass unhashed_description instead of description_hash for LNURL stuff

* more
2022-08-14 21:59:36 +02:00
Lee SalminenandGitHub 3df66334db clean up mobile layout per @callebtc (#874) 2022-08-14 19:41:26 +02:00
calleandGitHub c85be8dd22 dont assume field (#875) 2022-08-14 19:40:46 +02:00
Lee SalminenandGitHub fc427e7ccc resolves issue #872 & #815, error deleting lnurlp/lnurlw links (#877)
* resolves issue #872, error deleting lnurlp link

* apply the same fix for lnurlw in issue #815
2022-08-14 19:38:26 +02:00
Lee Salminen 293e5394a8 run make format 2022-08-14 10:58:35 -06:00
Lee Salminen 4c758f4665 re-create @iWarpBTC commit ID a9df953990 2022-08-14 10:52:58 -06:00
iWarpBTCandLee Salminen f8d4c39b1e basic readme 2022-08-14 10:52:58 -06:00
iWarpBTCandLee Salminen c04b0a1905 Update index.html
prettier
2022-08-14 10:52:58 -06:00
Gene TakavicandLee Salminen 5b8d317441 black & isort 2022-08-14 10:52:58 -06:00
iWarpBTCandLee Salminen 5af49e3801 comments and hints 2022-08-14 10:52:58 -06:00
iWarpBTCandLee Salminen 2f497ac0ee retreiving hits 2022-08-14 10:52:58 -06:00
iWarpBTCandLee Salminen 3cb62d1899 recording card tapping 2022-08-14 10:52:58 -06:00
iWarpBTCandLee Salminen 4fab2d3101 new extension
just proof of concept
2022-08-14 10:52:58 -06:00
c32ff1de59 New Extension: Invoicing (#733)
* initial commit

* add docs

* black & prettier

* mobile styles

* add print view

* prettier

* make format

* initial migrations un-messed

* make migrations work for sqlite

* add invoices table

* clean migrations

* add migration to conv

* fix card size

* hopefully fix test migration

* add missing status

* timestamp

* init testing

* remove draft invoice by default on create

* what should i test

* make format

* raise if not invoice

* new test and renaming

* fix issue reported by @talvasconcelos which prevented users from setting status on creation

* readme

* run black

* trying to make tests work

* make it work again

* send paid amount

* partial pay flow

* good coding

* can't get these test to work

* clean up and commenting

* make format

* validation for 2 decimals

Co-authored-by: ben <ben@arc.wales>
Co-authored-by: Tiago vasconcelos <talvasconcelos@gmail.com>
2022-08-13 21:37:44 +02:00
197ff7d054 Core: reserve fee as an .env variable (#631)
* reserve fee as an .env variable

* fixed bad import

* Update .env.example

Co-authored-by: calle <93376500+callebtc@users.noreply.github.com>

* Update lnbits/core/services.py

Co-authored-by: calle <93376500+callebtc@users.noreply.github.com>

* Update lnbits/core/services.py

Co-authored-by: calle <93376500+callebtc@users.noreply.github.com>

* Update lnbits/settings.py

Co-authored-by: calle <93376500+callebtc@users.noreply.github.com>

* variable consistent names

* fix services.py variable names

* percent default fix

* Update lnbits/settings.py

int to float

* Update lnbits/settings.py

int to float

* Update .env.example

int to float

* make format

* fixed failing bleskomat test, expecting 2000 msats fee

* Update tests/extensions/bleskomat/test_lnurl_api.py

revert to 2 sat

* Update .env.example

* Update lnbits/settings.py

Co-authored-by: calle <93376500+callebtc@users.noreply.github.com>
2022-08-13 20:19:08 +02:00
d989441587 added test for issue #847 and fixed it! (#848)
* added test for empty post data, for issue #847

* black

* fixed the failing testcase

* Update lnbits/decorators.py

Co-authored-by: calle <93376500+callebtc@users.noreply.github.com>

Co-authored-by: dni <dni.khr@gmail.com>
Co-authored-by: calle <93376500+callebtc@users.noreply.github.com>
2022-08-13 14:47:29 +02:00
034813a1ab stuck in retrying backend loop, fix issue #652 (#862)
* maybe solution to issue #652

* formatting, when precommit merge?

* Update lnbits/app.py

* Update lnbits/app.py

Co-authored-by: dni <dni.khr@gmail.com>
Co-authored-by: calle <93376500+callebtc@users.noreply.github.com>
2022-08-13 14:46:47 +02:00
28661903b6 return http error UNAUTHORIZED instead of BAD_REQUEST if key is missing (#869)
* return http error UNAUTHORIZED instead of BAD_REQUEST if key is missing

* fix regtest (#867)

Co-authored-by: dni <dni.khr@gmail.com>

* return http error UNAUTHORIZED instead of BAD_REQUEST if key is missing

Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: dni <dni.khr@gmail.com>
2022-08-13 14:41:44 +02:00
32a09f7359 add apache2 reverse proxy documentation (#868)
* add apache2 reverse proxy documentation

* add certbot

Co-authored-by: dni <dni.khr@gmail.com>
2022-08-13 14:41:23 +02:00
calleandGitHub e5d8c500d2 Wallets/unhashed_description (#870)
* new argument: unhashed_description

* accept in api

* set unhashed_description for memo case

* bolt11.py: dont be like CLN, accept the hash

* send hash to lnd in b64

* fix cln

* skip descr_hash for cln

* skip

* format
2022-08-13 14:29:04 +02:00
3457ff101e fix regtest (#867)
Co-authored-by: dni <dni.khr@gmail.com>
2022-08-12 21:05:13 +02:00
Vlad Stan a32754610a feat: use random IV for each request 2022-08-12 16:52:55 +03:00
Vlad Stan 19dfef6044 fix: help command 2022-08-12 15:55:19 +03:00
Vlad Stan 1c03b28b77 feat: add more logging for HWW 2022-08-12 15:29:48 +03:00
Vlad Stan 58ab68b876 feat: use aes lib foe encryption 2022-08-12 15:12:03 +03:00
Vlad Stan e6c8865eca feat: decrypt data from HWW 2022-08-12 14:12:40 +03:00
Vlad Stan 174cc28fe1 feat: special log for hww 2022-08-12 12:07:41 +03:00
Vlad Stan 3f71df7829 feat: use external aes lib 2022-08-12 10:25:54 +03:00
ArcandGitHub d1e1cbed41 Merge pull request #863 from motorina0/onchain_review_fixes
onchain extension - review & fix
2022-08-11 13:24:17 +01:00
Vlad Stan e20cfd3e6d fix: handle serial port disconnect 2022-08-11 15:06:32 +03:00
Vlad Stan 6b57c8b2a7 chore: code format 2022-08-11 14:31:14 +03:00
Vlad Stan e884e3b003 fix: look&feel on small screens 2022-08-11 14:22:14 +03:00
Vlad Stan 3d1ea76676 feat: add Copy button for addresses 2022-08-11 13:20:29 +03:00
Vlad Stan a6fba27554 fix: address link to mempool.space 2022-08-11 13:02:02 +03:00
Vlad Stan f24149050d feat: encrypt outgoing commands 2022-08-11 08:59:06 +03:00
Vlad Stan 6d4116b049 feat: working version. commit not to lose 2022-08-10 16:53:39 +03:00
calleandGitHub 8dfb4329d1 ssl cert creation method using openssl (#860) 2022-08-10 10:32:43 +02:00
calleandGitHub 05edd908f0 fix lnurlw balance_check (#854) 2022-08-09 12:48:10 +02:00
calleandGitHub 4fc0a25d41 Mega-merge 4: Reenable LndWallet gRPC and use TrackPaymentV2 (#745)
* readd lndgrpc

* debug logging

* Use TrackPaymentV2

* /v2/router/track

* lnd_router_grpc

* flag for blocking check

* error handling

* fix name

* regtest lndgrpc

* new test pipeline

* fix env

* check for description hash

* remove unnecessary asserts for clarity

* assume that description_hash is a hash already

* no lock

* description hashing in backend

* restore bolt11.py

* /api/v1/payments with hex of description

* comment

* refactor wallets

* forgot eclair

* fix lnpay

* bytes directly

* make format

* mypy check

* make format

* remove old code

* WIP status check

* LND GRPC docs

* restore cln to main

* fix regtest

* import

* remove unused import

* format

* do not expect ok

* check ok

* delete comments
2022-08-09 11:49:39 +02:00
Vlad StanandGitHub 1f139884fe WatchOnly Extension - add Serial Port communication (#839)
* feat: add `Share PSBT` button with options

* feat: add basic communication via the serial port

* chore: code format

* feat: send data to and from serial port

* fix: port disconnect

* feat: handle psbt extract

* feat: show signed transaction details

* fix: handle Connect/Disconnect failure state

* feat:small UI improvements

* feat: broadcast transaction (partial solution)

* feat: integrate psbt response from HWW

* feat: login and send commands to HWW

* feat: ui improvements

* feat: ui/ux improvements

* feat: more small UI impreovemsnts

* feat: simplify UI

* feat: add `help` command

* feat: add wipe command

* feet: add `seed` command

* feat: add `restore` command

* feat: always show PSBT input text (for outside PSBTs)

* feat: show spinner while signing tx

* feat: hide panels after transaction is broadcast

* feat: basic use of custom components

* refactor: move components one folder up

* refactor: extract wallet-config

* refactor: extract `wallet-list` component

* refactor: clean-up

* chore: code format html component files

* refactor: extract address-list component

* refactor: extract `history` component

* refactor: extract `utxo-list` component

* feat: UI/UX improvements

* feat: partial payment redesign

* refactor: rename `fee` to `fee-rate`

* refactor: rename component

* refactor: extract `send-to` component

* refactor: payment: first migration

* fix: init `sendToList`

* fix: change address

* fix: change address and `Select All` coins

* feat: show custom fees & two way binding for addresses

* fix: scanAddressesWithAmount

* fix: max amount

* fix: coin selection mode

* chore: code clean-up

* feat: shuffle the UI

* fix: change amount

* feat: update tx size in real time

* fix: coin selection

* fix: show erro messages

* fix: psbt generation

* refactor: move serial port logic

* refactor: payment component

* refactor: code clean-up; use `slot` for `serial-signer`

* feat: toggle serial port

* feat: add Disconnect command

* feat: prompt for `Connect` and `Login` before signing

* refactor: send psbt to device

* feat: extract signed transaction

* refactor: code clean-up

* feat: show auth green icon

* chore: code clean-up

* feat: show console

* feat: allow `Connect` from dropdown menu

* fix: stop if serial port cannot be open

* feat: confirm outputs and fee

* feat: add cancel command

* fix:  add `sats-denominated` for confirmations

* feat: wait for HWW to authenticate, then open dialog

* feat: share PSBT as text

* refactor: extract `refreshAddresses`

* feat: small UI improvements

* feat: add default `Mainnet` network

* feat: fix mempool endpint

* feat: propagate config update only when explicitly updated

* feat: add network for wallet accounts

* fix: stop scanning when network changed

* chore: code clean-up

* chore: code clean-up

* feat: show hardware device Xpub option

* fix: handle failed to parse psbt

* feat: add accounts using the HWW

* fix: testnet is in the bip32 derivation path

* feat: add spinner while wallet account is created

* fix: check network and masterpub for duplicate accounts

* feat: integrate transaction broadcast

* feat: add password confirmation for `Wipe` and `Restore`

* fix: fingerprint is not unique per account (it is the fingerprint of the master)

* chore: code clean-up, remove `masterpub_fingerprint`

* fix: account name diplay

* chore: code format

* fix: memppol links

* fix: shortcut buttons

* fix: note update

* chore: code format

* chore: clean-up rebase left overs

* chore: clean-up

* feat: less technical labels for addresses

* feat: add serial port config params

* fix: address type selection

* chore: drop `mempool` table

* fix: change & fee value

* fix: handle no input signed scenario

* fix: sat/btc unit

* fix: small UI stuff

* doc: update the readme

* Update README.md
2022-08-09 11:00:09 +02:00
Vlad StanandGitHub 63849a0894 Generalize conv.py (#849)
* feat: generalize the script for  `sqlite` to `postgres`

* fix: revert db connection params

* doc: remove manual conversion docs

* chore: fix formatting

* fix: add back instructions for `mock_data.zip`

* fix: exclude SQLite tables from migration

* fix: rename `your_extension.db` to `your_extension.sqlite3`
2022-08-09 10:45:50 +02:00
Tiago VasconcelosandGitHub af8af54376 Fix/hotfix custom design (#850)
* allow to toggle custom desing on and off

* make custom design off by default, toggle custom design
2022-08-09 10:05:37 +02:00
Vlad StanandGitHub 1f158dfd03 Update README.md 2022-08-05 16:32:03 +03:00
Vlad Stan ac203046a6 doc: update the readme 2022-08-05 16:31:29 +03:00
calleandGitHub b83fb1d5d8 VoidWallet: return PaymentStatus(None) (#845) 2022-08-04 18:40:46 +02:00
calleandGitHub 7fc1361475 cln invoice only int (#844) 2022-08-04 17:40:04 +02:00
calleandGitHub 9b27c2da01 argparser respect boolean arguments (#842) 2022-08-04 16:37:48 +02:00
Vlad Stan 8075503566 fix: small UI stuff 2022-08-04 15:19:03 +03:00
Vlad Stan e55044da5a fix: sat/btc unit 2022-08-04 14:09:42 +03:00
Vlad Stan 337234cb0c fix: handle no input signed scenario 2022-08-04 14:04:49 +03:00
Vlad Stan 40ca51af0e fix: change & fee value 2022-08-04 13:47:20 +03:00
Vlad Stan a0450cbdd9 chore: drop mempool table 2022-08-04 12:19:31 +03:00
Vlad Stan 798b8e5fdf fix: add network column for migration 2022-08-04 12:01:46 +03:00
Vlad Stan 48ab496cb4 fix: address type selection 2022-08-04 11:05:27 +03:00
Vlad Stan 4ab43e0743 feat: add serial port config params 2022-08-04 10:11:06 +03:00
48a57513f4 create new Dockerfile using poetry (#806)
* create new Dockerfile using poetry

* dont use unsure docker hub image

* add example env variable to docker command

* remove copy of lnbits, and order of commands

* add build.py for static files

* add compiled resources to docker ignore, so we don't accidently add it to image

* use generic python

* CMD with arguments

Co-authored-by: dni <dni.khr@gmail.com>
2022-08-04 08:05:51 +02:00
Vlad Stan b46ec8f149 feat: less technical labels for addresses 2022-08-04 08:06:17 +03:00
2624789andGitHub fa2a0ab1bd Fix Get a withdraw link (#617) 2022-08-03 15:06:25 +02:00
8535d70d36 Poetry dev (#837)
* black and isort for tests

* black and isort for build

* use poetry in Makefile, update pyproject.toml dependencies and add configs for black, pytest, mypy, isort there

* switching github workflow to poetry

* set mininum version to python 3.7

* fix tests

* add types-protobuf to dev packages

* fix cln regtest

* update docs

* try fix 1

* mypy fix2

* fix isort formatting workflow

* add prettier to dev docs

* multiple valid python version for pyproject

* update poetry.lock

* remove development installation, not needed anymore

* fix migration workflows

* format into one test

* fix yaml

* fix pipeline

* fix pipeline

* fix again

* fix

* rename checks

* remove venv tests

* venv test once

Co-authored-by: dni <dni.khr@gmail.com>
2022-08-03 14:10:32 +02:00
Vlad Stan 05b758a784 chore: clean-up 2022-08-03 14:43:31 +03:00
2edaa0ee03 Poetry: uvicorn with .env and command line arguments (#836)
* fixed poetry logs

* make isort happy

* Poetry: uvicorn .env and command line arguments

* restore commands.py

* format

* own it

Co-authored-by: dni <dni.khr@gmail.com>
2022-08-03 13:16:50 +02:00
Vlad Stan 6c332ce0c5 chore: clean-up rebase left overs 2022-08-03 13:54:34 +03:00
Vlad Stan 828e4a78d1 chore: code format 2022-08-03 13:50:59 +03:00
Vlad Stan f1976e624d fix: note update 2022-08-03 13:50:07 +03:00
Vlad Stan f11a51a4cc fix: shortcut buttons 2022-08-03 13:50:07 +03:00
Vlad Stan d14fbd68aa fix: memppol links 2022-08-03 13:50:07 +03:00
Vlad Stan d078b7b1f2 chore: code format 2022-08-03 13:50:07 +03:00
Vlad Stan b83f19e5d1 fix: account name diplay 2022-08-03 13:50:07 +03:00
Vlad Stan 04327a98cb chore: code clean-up, remove masterpub_fingerprint 2022-08-03 13:50:07 +03:00
Vlad Stan cb137e7142 fix: fingerprint is not unique per account (it is the fingerprint of the master) 2022-08-03 13:50:07 +03:00
Vlad Stan 2ae4df70ea feat: add password confirmation for Wipe and Restore 2022-08-03 13:50:07 +03:00
Vlad Stan c407e054fd feat: integrate transaction broadcast 2022-08-03 13:50:07 +03:00
Vlad Stan a030b20971 fix: check network and masterpub for duplicate accounts 2022-08-03 13:50:07 +03:00
Vlad Stan 35ab1e7d9c feat: add spinner while wallet account is created 2022-08-03 13:50:07 +03:00
Vlad Stan f202d80241 fix: testnet is in the bip32 derivation path 2022-08-03 13:50:07 +03:00
Vlad Stan 2ac8ee95b0 feat: add accounts using the HWW 2022-08-03 13:50:07 +03:00
Vlad Stan 73265a9951 fix: handle failed to parse psbt 2022-08-03 13:50:07 +03:00
Vlad Stan ae479ee0d0 feat: show hardware device Xpub option 2022-08-03 13:50:07 +03:00
Vlad Stan a50fc2d4ae chore: code clean-up 2022-08-03 13:50:07 +03:00
Vlad Stan 6f19dab416 chore: code clean-up 2022-08-03 13:50:07 +03:00
Vlad Stan 5697bd3b31 fix: stop scanning when network changed 2022-08-03 13:50:07 +03:00
Vlad Stan 6150b767e4 feat: add network for wallet accounts 2022-08-03 13:50:07 +03:00
Vlad Stan 4671954896 feat: propagate config update only when explicitly updated 2022-08-03 13:50:07 +03:00
Vlad Stan 657ed7a37c feat: fix mempool endpint 2022-08-03 13:50:07 +03:00
Vlad Stan 51c8b58fdb feat: add default Mainnet network 2022-08-03 13:50:07 +03:00
Vlad Stan 68f45c471e feat: small UI improvements 2022-08-03 13:50:07 +03:00
Vlad Stan 6de8b0b06d refactor: extract refreshAddresses 2022-08-03 13:50:07 +03:00
Vlad Stan 5e20c4314b feat: share PSBT as text 2022-08-03 13:50:07 +03:00
Vlad Stan 5414e3e772 feat: wait for HWW to authenticate, then open dialog 2022-08-03 13:50:07 +03:00
Vlad Stan 1089be017b fix: add sats-denominated for confirmations 2022-08-03 13:50:07 +03:00
Vlad Stan bf5e6330a8 feat: add cancel command 2022-08-03 13:50:07 +03:00
Vlad Stan 8746045428 feat: confirm outputs and fee 2022-08-03 13:50:07 +03:00
Vlad Stan d541bebf38 fix: stop if serial port cannot be open 2022-08-03 13:50:07 +03:00
Vlad Stan 3054248858 feat: allow Connect from dropdown menu 2022-08-03 13:50:07 +03:00
Vlad Stan 9374433153 feat: show console 2022-08-03 13:50:07 +03:00
Vlad Stan 87f6eda206 chore: code clean-up 2022-08-03 13:50:07 +03:00
Vlad Stan a27bbe628c feat: show auth green icon 2022-08-03 13:50:07 +03:00
Vlad Stan b664500061 refactor: code clean-up 2022-08-03 13:50:07 +03:00
Vlad Stan 32486d62bf feat: extract signed transaction 2022-08-03 13:50:07 +03:00
Vlad Stan d5cc8d9187 refactor: send psbt to device 2022-08-03 13:50:07 +03:00
Vlad Stan 5d1d2d634a feat: prompt for Connect and Login before signing 2022-08-03 13:50:07 +03:00
Vlad Stan 7a3b675119 feat: add Disconnect command 2022-08-03 13:50:07 +03:00
Vlad Stan 689cd09aef feat: toggle serial port 2022-08-03 13:50:07 +03:00
Vlad Stan 8dfc133460 refactor: code clean-up; use slot for serial-signer 2022-08-03 13:50:07 +03:00
Vlad Stan 5ee81f2e5a refactor: payment component 2022-08-03 13:50:07 +03:00
Vlad Stan df7ebc6699 refactor: move serial port logic 2022-08-03 13:50:07 +03:00
Vlad Stan 4e9cfc0f74 fix: psbt generation 2022-08-03 13:50:07 +03:00
Vlad Stan b0216bff75 fix: show erro messages 2022-08-03 13:50:07 +03:00
Vlad Stan 59d960b5f9 fix: coin selection 2022-08-03 13:50:07 +03:00
Vlad Stan 24d162f3fc feat: update tx size in real time 2022-08-03 13:50:07 +03:00
Vlad Stan cec44f4a99 fix: change amount 2022-08-03 13:50:07 +03:00
Vlad Stan 31729e41cf feat: shuffle the UI 2022-08-03 13:50:07 +03:00
Vlad Stan 1bd74aba4d chore: code clean-up 2022-08-03 13:50:07 +03:00
Vlad Stan 74328bfeb5 fix: coin selection mode 2022-08-03 13:50:07 +03:00
Vlad Stan eedd0ee6a3 fix: max amount 2022-08-03 13:50:07 +03:00
Vlad Stan b576ef4f21 fix: scanAddressesWithAmount 2022-08-03 13:50:07 +03:00
Vlad Stan dc2ecfe9a2 feat: show custom fees & two way binding for addresses 2022-08-03 13:50:07 +03:00
Vlad Stan 86d3ebd5dc fix: change address and Select All coins 2022-08-03 13:50:07 +03:00
Vlad Stan dc95143998 fix: change address 2022-08-03 13:50:07 +03:00
Vlad Stan c76644c561 fix: init sendToList 2022-08-03 13:50:07 +03:00
Vlad Stan 5638bca2d7 refactor: payment: first migration 2022-08-03 13:50:07 +03:00
Vlad Stan 3765900be0 refactor: extract send-to component 2022-08-03 13:50:07 +03:00
Vlad Stan 3e7ce185f6 refactor: rename component 2022-08-03 13:50:07 +03:00
Vlad Stan be6dbb68c6 refactor: rename fee to fee-rate 2022-08-03 13:50:07 +03:00
Vlad Stan e98e3504ad feat: partial payment redesign 2022-08-03 13:50:07 +03:00
Vlad Stan 799fb99661 feat: UI/UX improvements 2022-08-03 13:50:07 +03:00
Vlad Stan e6b46301df refactor: extract utxo-list component 2022-08-03 13:50:07 +03:00
Vlad Stan d6b285feb5 refactor: extract history component 2022-08-03 13:50:07 +03:00
Vlad Stan ff29aacace refactor: extract address-list component 2022-08-03 13:50:07 +03:00
Vlad Stan 8376f08e8b chore: code format html component files 2022-08-03 13:50:07 +03:00
Vlad Stan afbdfeff8c refactor: clean-up 2022-08-03 13:50:07 +03:00
Vlad Stan c5755ac587 refactor: extract wallet-list component 2022-08-03 13:50:07 +03:00
Vlad Stan b18a4d37e3 refactor: extract wallet-config 2022-08-03 13:50:07 +03:00
Vlad Stan 46dfd337b2 refactor: move components one folder up 2022-08-03 13:50:07 +03:00
Vlad Stan b96bdf25f6 feat: basic use of custom components 2022-08-03 13:50:07 +03:00
Vlad Stan f102928812 feat: hide panels after transaction is broadcast 2022-08-03 13:50:01 +03:00
Vlad Stan 63424d1ca4 feat: show spinner while signing tx 2022-08-03 13:50:01 +03:00
Vlad Stan 99813a48cd feat: always show PSBT input text (for outside PSBTs) 2022-08-03 13:50:01 +03:00
Vlad Stan 412dc8b579 feat: add restore command 2022-08-03 13:50:01 +03:00
Vlad Stan 559b4a663c feet: add seed command 2022-08-03 13:50:01 +03:00
Vlad Stan 9cbd01d514 feat: add wipe command 2022-08-03 13:50:01 +03:00
Vlad Stan 5f7537f600 feat: add help command 2022-08-03 13:50:01 +03:00
Vlad Stan 01c81c1a83 feat: simplify UI 2022-08-03 13:50:01 +03:00
Vlad Stan ded6df61b1 feat: more small UI impreovemsnts 2022-08-03 13:50:01 +03:00
Vlad Stan 7f32139bcd feat: ui/ux improvements 2022-08-03 13:50:01 +03:00
Vlad Stan 9c343acf86 feat: ui improvements 2022-08-03 13:50:01 +03:00
Vlad Stan a0d56a7e06 feat: login and send commands to HWW 2022-08-03 13:50:01 +03:00
Vlad Stan b7c4a411b1 feat: integrate psbt response from HWW 2022-08-03 13:50:01 +03:00
Vlad Stan 367faf437a feat: broadcast transaction (partial solution) 2022-08-03 13:50:01 +03:00
Vlad Stan 6098b8308e feat:small UI improvements 2022-08-03 13:50:01 +03:00
Vlad Stan 0e87e6608f fix: handle Connect/Disconnect failure state 2022-08-03 13:49:56 +03:00
Vlad Stan d80ae7de1b feat: show signed transaction details 2022-08-03 13:49:56 +03:00
Vlad Stan 73adc4a7e8 feat: handle psbt extract 2022-08-03 13:49:56 +03:00
Vlad Stan 3bf0bb1e63 fix: port disconnect 2022-08-03 13:48:46 +03:00
Vlad Stan 7594474189 feat: send data to and from serial port 2022-08-03 13:48:46 +03:00
Vlad Stan ae68c4dc6e chore: code format 2022-08-03 13:48:46 +03:00
Vlad Stan 41608b4b30 feat: add basic communication via the serial port 2022-08-03 13:48:46 +03:00
Vlad Stan 7b75cbf59c feat: add Share PSBT button with options 2022-08-03 13:48:46 +03:00
calleandGitHub 91fefdb83d migration for extensions (#835) 2022-08-03 11:27:08 +02:00
989c6b51ba Updates to documentation (#574)
* fix: rpc library install when using LND

fixes #563

* chore: improve some out of date docs

Co-authored-by: calle <93376500+callebtc@users.noreply.github.com>
2022-08-03 10:03:01 +02:00
ArcandGitHub d38ff896f6 update dev docs for internal testing before pushing (#801) 2022-08-03 09:55:20 +02:00
MatthewCroughanandGitHub 28af0a2100 nix: add defaultText to services.lnbits.package (#803)
Without this, evaluating the module doesn't provide a default value visible on search.nixos.org
2022-08-03 09:52:04 +02:00
Tiago VasconcelosandGitHub 716a30a0fc make instant wallet available in custom vouchers (#830) 2022-08-03 09:51:16 +02:00
ArcandGitHub 972a1d4ac9 Update installation.md 2022-08-02 22:02:53 +01:00
ArcandGitHub a948f0f8d9 Update installation.md 2022-08-02 22:01:09 +01:00
ArcandGitHub 08fd967f8a Update installation.md 2022-08-02 22:00:55 +01:00
ArcandGitHub 3b6c2b0103 Update installation.md 2022-08-02 21:59:55 +01:00
ArcandGitHub 88ef3a38fc Update installation.md 2022-08-02 21:01:58 +01:00
calleandGitHub b3c866fd1d Code health: Small linting fix (#813)
* small linting fix

* je lint therefore je suis
2022-08-02 16:17:47 +02:00
Tiago VasconcelosandGitHub 9154660479 Fix/tipjar desc and satspay (#824)
* fix tipjar description from screwing charges

* small fix for charges when onchain isn't set

* make dni happy

* make black happy
2022-08-02 16:16:32 +02:00
Tiago VasconcelosandGitHub c36da4172c reset the form after close (#821) 2022-08-02 16:14:52 +02:00
ArcandGitHub 1374aabf92 Merge pull request #826 from lnbits/poetrypy
poetry python dependency to any 3.9
2022-08-01 18:59:10 +01:00
ben b8c1b2ad8f Set dep in poetry 2022-08-01 18:58:36 +01:00
ArcandGitHub 0b2c01e388 Merge pull request #825 from lnbits/poetrypy
poetry python dependency to any 3.9
2022-08-01 18:49:19 +01:00
ben cb87b55e9d format 2022-08-01 18:48:56 +01:00
ben da57f55493 poetry python dependency to any 3.9 2022-08-01 18:46:28 +01:00
ben fed01b70bd Set python version in Poetry 2022-08-01 18:37:37 +01:00
calleandGitHub d841690460 try except for check payment status (#820) 2022-08-01 16:51:19 +02:00
calleandGitHub 9c19b61e4a Mega-merge 3: CLN update client lib with descriptionhash support (WIP) (#792)
* CoreLightningWallet
2022-08-01 16:41:50 +02:00
calleandGitHub f1ec7e33f0 Mega-merge 2: Invoice/refactor description hash (WIP) (#814)
* description hashing in backend
2022-08-01 16:20:25 +02:00
calleandGitHub c88e6b0e62 Mega-merge 1: Test invoice creation with description hash (WIP) (#812)
* check for description hash

* remove unnecessary asserts for clarity

* assume that description_hash is a hash already

* no lock

* restore bolt11.py

* /api/v1/payments with hex of description

* comment
2022-08-01 16:12:25 +02:00
Tiago VasconcelosandGitHub a1919e0619 reset data (#819) 2022-08-01 12:57:30 +02:00
Tiago VasconcelosandGitHub 925a7735da change z-index for wallet tab (#818) 2022-08-01 12:56:56 +02:00
ArcandGitHub 28c50d93ce Merge pull request #817 from lnbits/clichefund
Adds Cliche as a funding source
2022-08-01 10:47:26 +01:00
ben 457829de0c Make format 2022-08-01 10:22:51 +01:00
ben cf85018f9d Queens English 2022-08-01 10:19:07 +01:00
f7a675962a Update lnbits/wallets/cliche.py
Co-authored-by: calle <93376500+callebtc@users.noreply.github.com>
2022-08-01 09:51:52 +01:00
ben 3e40d91466 changed to hex 2022-08-01 09:50:24 +01:00
ben 84cb581207 mypy 2022-08-01 09:48:00 +01:00
ben 0d4ccad6d7 some mypy 2022-08-01 09:41:03 +01:00
ben 17cd1dec47 Added dependencies 2022-08-01 09:26:15 +01:00
ben 723d7fffd2 Changed back to VoidWallet 2022-08-01 08:58:21 +01:00
ben c6b72f7f47 Make format 2022-08-01 08:56:37 +01:00
ben 940ef26003 format 2022-07-31 23:51:57 +01:00
ben cac7e1e3c0 Works almost
Stream needs work
2022-07-31 23:46:42 +01:00
calleandGitHub c9c3dd3f66 add coverage report for regtest tests (#816) 2022-07-31 19:48:20 +02:00
ArcandGitHub c075b28a96 Put venv server un direction back 2022-07-31 10:22:29 +01:00
f24d593f00 Fix docker staticfiles (#811)
* Fix staticfiles for docker install

* docker build instructions

* all tests python 3.9

Co-authored-by: cryptoteun <73664763+cryptoteun@users.noreply.github.com>
2022-07-30 17:09:14 +02:00
ArcandGitHub 19fda31afb Merge pull request #809 from lnbits/opennodedirtyfix
Dirty fix
2022-07-29 12:43:20 +01:00
ben 166618c452 make format 2022-07-29 12:37:19 +01:00
ben 1b1a7cc3ed Dirty fix
Will look at adding webhook url back
2022-07-29 12:36:29 +01:00
ArcandGitHub 9387d9cc48 Merge pull request #808 from dni/docsfix-1
fix docs urls
2022-07-29 12:04:59 +01:00
dni 096778eb43 wrong link 2022-07-29 13:02:43 +02:00
dni 909691bd9b add swagger api docs 2022-07-29 13:01:03 +02:00
dni f7209bbfd7 fix links to docs 2022-07-29 12:53:29 +02:00
ArcandGitHub f4d47c3234 Merge pull request #807 from dni/docsfix
change docs url
2022-07-29 11:45:19 +01:00
dni 2e715d6eea change url 2022-07-29 12:44:42 +02:00
ArcandGitHub e09a1bdf90 Update CNAME 2022-07-29 11:39:35 +01:00
183 changed files with 18770 additions and 25639 deletions
+4
View File
@@ -6,6 +6,10 @@ tests
venv
tools
lnbits/static/css/*
lnbits/static/bundle.js
lnbits/static/bundle.css
*.md
*.log
+10 -4
View File
@@ -25,6 +25,8 @@ LNBITS_DATA_FOLDER="./data"
LNBITS_FORCE_HTTPS=true
LNBITS_SERVICE_FEE="0.0"
LNBITS_RESERVE_FEE_MIN=2000 # value in millisats
LNBITS_RESERVE_FEE_PERCENT=1.0 # value in percent
# Change theme
LNBITS_SITE_TITLE="LNbits"
@@ -34,19 +36,23 @@ LNBITS_SITE_DESCRIPTION="Some description about your service, will display if ti
LNBITS_THEME_OPTIONS="classic, bitcoin, freedom, mint, autumn, monochrome, salvador"
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet,
# LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet, FakeWallet, EclairWallet
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, ClicheWallet
# LndRestWallet, CoreLightningWallet, LNbitsWallet, SparkWallet, FakeWallet, EclairWallet
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
# just so you can see the UI before dealing with this file.
# Set one of these blocks depending on the wallet kind you chose above:
# ClicheWallet
CLICHE_ENDPOINT=ws://127.0.0.1:12000
# SparkWallet
SPARK_URL=http://localhost:9737/rpc
SPARK_TOKEN=myaccesstoken
# CLightningWallet
CLIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
# CoreLightningWallet
CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
# LnbitsWallet
LNBITS_ENDPOINT=https://legend.lnbits.com
+12 -23
View File
@@ -7,30 +7,19 @@ on:
branches: [ main ]
jobs:
black:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- run: sudo apt-get install python3-venv
- run: python3 -m venv venv
- run: ./venv/bin/pip install black
- run: make checkblack
isort:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: abatilo/actions-poetry@v2.1.3
- name: Install packages
run: poetry install
- name: Check black
run: make checkblack
- name: Check isort
run: make checkisort
- uses: actions/setup-node@v3
- run: sudo apt-get install python3-venv
- run: python3 -m venv venv
- run: ./venv/bin/pip install isort
- run: make checkisort
prettier:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- run: sudo apt-get install python3-venv
- run: python3 -m venv venv
- run: npm install prettier
- run: ./node_modules/.bin/prettier --check lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js
- name: Check prettier
run: |
npm install prettier
make checkprettier
+7 -19
View File
@@ -9,9 +9,9 @@ jobs:
postgres:
image: postgres:latest
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
POSTGRES_USER: lnbits
POSTGRES_PASSWORD: lnbits
POSTGRES_DB: migration
ports:
# maps tcp port 5432 on service container to the host
- 5432:5432
@@ -22,30 +22,18 @@ jobs:
--health-retries 5
strategy:
matrix:
python-version: [3.8]
python-version: [3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- uses: abatilo/actions-poetry@v2.1.3
- name: Install dependencies
env:
VIRTUAL_ENV: ./venv
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
run: |
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
poetry install
sudo apt install unzip
- name: Run migrations
run: |
rm -rf ./data
mkdir -p ./data
export LNBITS_DATA_FOLDER="./data"
unzip tests/data/mock_data.zip -d ./data
timeout 5s ./venv/bin/uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5001 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
export LNBITS_DATABASE_URL="postgres://postgres:postgres@0.0.0.0:5432/postgres"
timeout 5s ./venv/bin/uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5001 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
./venv/bin/python tools/conv.py
make test-migration
+13 -4
View File
@@ -5,9 +5,18 @@ on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.9]
steps:
- uses: actions/checkout@v1
- uses: jpetrucciani/mypy-check@master
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
mypy_flags: '--install-types --non-interactive'
path: 'lnbits'
python-version: ${{ matrix.python-version }}
- uses: abatilo/actions-poetry@v2.1.3
- name: Install dependencies
run: |
poetry install
- name: Run tests
run: poetry run mypy
+60 -20
View File
@@ -7,30 +7,25 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.8]
python-version: [3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- uses: abatilo/actions-poetry@v2.1.3
- name: Setup Regtest
run: |
docker build -t lnbitsdocker/lnbits-legend .
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
cd docker
chmod +x ./tests
./tests
sudo chmod -R a+rwx .
- name: Install dependencies
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 pylightning
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
poetry install
- name: Run tests
env:
PYTHONUNBUFFERED: 1
@@ -43,7 +38,11 @@ jobs:
run: |
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
make test-real-wallet
CLightningWallet:
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
LndWallet:
runs-on: ubuntu-latest
strategy:
matrix:
@@ -54,30 +53,71 @@ jobs:
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- uses: abatilo/actions-poetry@v2.1.3
- name: Setup Regtest
run: |
docker build -t lnbitsdocker/lnbits-legend .
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
cd docker
chmod +x ./tests
./tests
sudo chmod -R a+rwx .
- name: Install dependencies
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 pylightning
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
poetry install
poetry add grpcio protobuf
- name: Run tests
env:
PYTHONUNBUFFERED: 1
PORT: 5123
LNBITS_DATA_FOLDER: ./data
LNBITS_BACKEND_WALLET_CLASS: CLightningWallet
CLIGHTNING_RPC: ./docker/data/clightning-1/regtest/lightning-rpc
LNBITS_BACKEND_WALLET_CLASS: LndWallet
LND_GRPC_ENDPOINT: localhost
LND_GRPC_PORT: 10009
LND_GRPC_CERT: docker/data/lnd-1/tls.cert
LND_GRPC_MACAROON: docker/data/lnd-1/data/chain/bitcoin/regtest/admin.macaroon
run: |
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
make test-real-wallet
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
CoreLightningWallet:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- uses: abatilo/actions-poetry@v2.1.3
- name: Setup Regtest
run: |
docker build -t lnbitsdocker/lnbits-legend .
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
cd docker
chmod +x ./tests
./tests
sudo chmod -R a+rwx .
- name: Install dependencies
run: |
poetry install
poetry add pyln-client
- name: Run tests
env:
PYTHONUNBUFFERED: 1
PORT: 5123
LNBITS_DATA_FOLDER: ./data
LNBITS_BACKEND_WALLET_CLASS: CoreLightningWallet
CORELIGHTNING_RPC: ./docker/data/clightning-1/regtest/lightning-rpc
run: |
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
make test-real-wallet
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
+25 -75
View File
@@ -7,7 +7,7 @@ jobs:
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 }}
@@ -23,9 +23,29 @@ jobs:
./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-venv
sqlite:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- uses: abatilo/actions-poetry@v2.1.3
- name: Install dependencies
env:
VIRTUAL_ENV: ./venv
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
run: |
poetry install
- name: Run tests
run: make test
venv-postgres:
postgres:
runs-on: ubuntu-latest
services:
postgres:
@@ -44,22 +64,17 @@ jobs:
--health-retries 5
strategy:
matrix:
python-version: [3.8]
python-version: [3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- uses: abatilo/actions-poetry@v2.1.3
- name: Install dependencies
env:
VIRTUAL_ENV: ./venv
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
run: |
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
poetry install
- name: Run tests
env:
LNBITS_DATABASE_URL: postgres://postgres:postgres@0.0.0.0:5432/postgres
@@ -68,68 +83,3 @@ jobs:
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
poetry-sqlite:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- 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
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
+9 -42
View File
@@ -1,45 +1,12 @@
# Build image
FROM python:3.7-slim as builder
# Setup virtualenv
ENV VIRTUAL_ENV=/opt/venv
RUN python -m venv $VIRTUAL_ENV
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
# Install build deps
FROM python:3.9-slim
RUN apt-get update
RUN apt-get install -y --no-install-recommends build-essential pkg-config libpq-dev
RUN python -m pip install --upgrade pip
RUN pip install wheel
# Install runtime deps
COPY requirements.txt /tmp/requirements.txt
RUN pip install -r /tmp/requirements.txt
# Install c-lightning specific deps
RUN pip install pylightning
# Install LND specific deps
RUN pip install lndgrpc
# Production image
FROM python:3.7-slim as lnbits
# Run as non-root
USER 1000:1000
# Copy over virtualenv
ENV VIRTUAL_ENV="/opt/venv"
COPY --from=builder --chown=1000:1000 $VIRTUAL_ENV $VIRTUAL_ENV
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
# Copy in app source
RUN apt-get install -y curl
RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH"
WORKDIR /app
COPY --chown=1000:1000 lnbits /app/lnbits
ENV LNBITS_PORT="5000"
ENV LNBITS_HOST="0.0.0.0"
COPY . .
RUN poetry config virtualenvs.create false
RUN poetry install --no-dev --no-root
RUN poetry run python build.py
EXPOSE 5000
CMD ["sh", "-c", "uvicorn lnbits.__main__:app --port $LNBITS_PORT --host $LNBITS_HOST"]
CMD ["poetry", "run", "lnbits", "--port", "5000", "--host", "0.0.0.0"]
+43 -28
View File
@@ -4,58 +4,73 @@ all: format check requirements.txt
format: prettier isort black
check: mypy checkprettier checkblack
check: mypy checkprettier checkisort checkblack
prettier: $(shell find lnbits -name "*.js" -name ".html")
./node_modules/.bin/prettier --write lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js
./node_modules/.bin/prettier --write lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html
black: $(shell find lnbits -name "*.py")
./venv/bin/black lnbits
black:
poetry run black .
mypy: $(shell find lnbits -name "*.py")
./venv/bin/mypy lnbits
./venv/bin/mypy lnbits/core
./venv/bin/mypy lnbits/extensions/*
mypy:
poetry run mypy
isort: $(shell find lnbits -name "*.py")
./venv/bin/isort --profile black lnbits
isort:
poetry run isort .
checkprettier: $(shell find lnbits -name "*.js" -name ".html")
./node_modules/.bin/prettier --check lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js
./node_modules/.bin/prettier --check lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html
checkblack: $(shell find lnbits -name "*.py")
./venv/bin/black --check lnbits
checkblack:
poetry run black --check .
checkisort: $(shell find lnbits -name "*.py")
./venv/bin/isort --profile black --check-only lnbits
Pipfile.lock: Pipfile
./venv/bin/pipenv lock
requirements.txt: Pipfile.lock
cat Pipfile.lock | jq -r '.default | map_values(.version) | to_entries | map("\(.key)\(.value)") | join("\n")' > requirements.txt
checkisort:
poetry run isort --check-only .
test:
mkdir -p ./tests/data
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
FAKE_WALLET_SECRET="ToTheMoon1" \
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
./venv/bin/pytest --durations=1 -s --cov=lnbits --cov-report=xml tests
DEBUG=true \
poetry run pytest
test-real-wallet:
mkdir -p ./tests/data
BOLTZ_NETWORK="regtest" \
BOLTZ_URL="http://127.0.0.1:9001" \
BOLTZ_MEMPOOL_SPACE_URL="http://127.0.0.1:8080" \
BOLTZ_MEMPOOL_SPACE_URL_WS="ws://127.0.0.1:8080" \
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
./venv/bin/pytest --durations=1 -s --cov=lnbits --cov-report=xml tests
DEBUG=true \
poetry run pytest
test-pipenv:
mkdir -p ./tests/data
test-venv:
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
FAKE_WALLET_SECRET="ToTheMoon1" \
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
pipenv run pytest --durations=1 -s --cov=lnbits --cov-report=xml tests
DEBUG=true \
./venv/bin/pytest --durations=1 -s --cov=lnbits --cov-report=xml tests
test-migration:
rm -rf ./migration-data
mkdir -p ./migration-data
unzip tests/data/mock_data.zip -d ./migration-data
HOST=0.0.0.0 \
PORT=5002 \
LNBITS_DATA_FOLDER="./migration-data" \
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
HOST=0.0.0.0 \
PORT=5002 \
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
LNBITS_DATA_FOLDER="./migration-data" \
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
poetry run python tools/conv.py
migration:
poetry run python tools/conv.py
bak:
# LNBITS_DATABASE_URL=postgres://postgres:postgres@0.0.0.0:5432/postgres
+3 -3
View File
@@ -25,7 +25,7 @@ LNbits is a very simple Python server that sits on top of any funding source, an
LNbits can run on top of any lightning-network funding source, currently there is support for LND, c-lightning, Spark, LNpay, OpenNode, lntxbot, with more being added regularly.
See [lnbits.org](https://lnbits.org) for more detailed documentation.
See [legend.lnbits.org](https://legend.lnbits.org) for more detailed documentation.
Checkout the LNbits [YouTube](https://www.youtube.com/playlist?list=PLPj3KCksGbSYG0ciIQUWJru1dWstPHshe) video series.
@@ -54,7 +54,7 @@ LNURL has a fallback scheme, so if scanned by a regular QR code reader it can de
![lnurl fallback](https://i.imgur.com/CPBKHIv.png)
Using **lnbits.com/?lightning="LNURL-withdraw"** will trigger a withdraw that builds an LNbits wallet.
Example use would be an ATM, which utilises LNURL, if the user scans the QR with a regular QR code scanner app, they will stilll be able to access the funds.
Example use would be an ATM, which utilises LNURL, if the user scans the QR with a regular QR code scanner app, they will still be able to access the funds.
![lnurl ATM](https://i.imgur.com/Gi6bn3L.jpg)
@@ -70,7 +70,7 @@ Wallets can be easily generated and given out to people at events (one click mul
If you like this project and might even use or extend it, why not [send some tip love](https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK)!
[docs]: https://lnbits.org/
[docs]: https://legend.lnbits.org/
[docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg
[github-mypy]: https://github.com/lnbits/lnbits/actions?query=workflow%3Amypy
[github-mypy-badge]: https://github.com/lnbits/lnbits/workflows/mypy/badge.svg
+8 -13
View File
@@ -1,13 +1,14 @@
import warnings
import subprocess
import glob
import os
import subprocess
import warnings
from os import path
from typing import Any, List, NamedTuple, Optional
from pathlib import Path
from typing import Any, List, NamedTuple, Optional
LNBITS_PATH = path.dirname(path.realpath(__file__)) + "/lnbits"
def get_js_vendored(prefer_minified: bool = False) -> List[str]:
paths = get_vendored(".js", prefer_minified)
@@ -71,6 +72,7 @@ def get_vendored(ext: str, prefer_minified: bool = False) -> List[str]:
def url_for_vendored(abspath: str) -> str:
return "/" + os.path.relpath(abspath, LNBITS_PATH)
def transpile_scss():
with warnings.catch_warnings():
warnings.simplefilter("ignore")
@@ -80,6 +82,7 @@ def transpile_scss():
with open(os.path.join(LNBITS_PATH, "static/css/base.css"), "w") as css:
css.write(compile_string(scss.read()))
def bundle_vendored():
for getfiles, outputpath in [
(get_js_vendored, os.path.join(LNBITS_PATH, "static/bundle.js")),
@@ -96,15 +99,7 @@ def bundle_vendored():
def build():
transpile_scss()
bundle_vendored()
# root = Path("lnbits/static/foo")
# root.mkdir(parents=True)
# root.joinpath("example.css").write_text("")
if __name__ == "__main__":
build()
#def build(setup_kwargs):
# """Build """
# transpile_scss()
# bundle_vendored()
# subprocess.run(["ls", "-la", "./lnbits/static"])
build()
+1 -1
View File
@@ -1 +1 @@
lnbits.org
legend.lnbits.org
+1 -1
View File
@@ -3,7 +3,7 @@ title: "LNbits docs"
remote_theme: pmarsceill/just-the-docs
logo: "/logos/lnbits-full.png"
search_enabled: true
url: https://lnbits.org
url: https://legend.lnbits.org
aux_links:
"LNbits on GitHub":
- "//github.com/lnbits/lnbits"
+1 -1
View File
@@ -9,4 +9,4 @@ nav_order: 3
API reference
=============
Coming soon...
[Swagger Docs](https://legend.lnbits.org/devs/swagger.html)
+17 -1
View File
@@ -17,10 +17,26 @@ Tests
This project has unit tests that help prevent regressions. Before you can run the tests, you must install a few dependencies:
```bash
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
poetry install
npm i
```
Then to run the tests:
```bash
make test
```
Run formatting:
```bash
make format
```
Run mypy checks:
```bash
poetry run mypy
```
Run everything:
```bash
make all
```
+35 -7
View File
@@ -28,17 +28,45 @@ Going over the example extension's structure:
Adding new dependencies
-----------------------
If for some reason your extensions needs a new python package to work, you can add a new package using Pipenv:
If for some reason your extensions needs a new python package to work, you can add a new package using `venv`, or `poerty`:
```sh
$ pipenv install new_package_name
$ poetry add <package>
# or
$ ./venv/bin/pip install <package>
```
This will create a new entry in the `Pipenv` file.
**But we need an extra step to make sure LNbits doesn't break in production.**
All tests and deployments should run against the `requirements.txt` file so every time a new package is added
it is necessary to run the Pipenv `lock` command and manually update the requirements file:
Dependencies need to be added to `pyproject.toml` and `requirements.txt`, then tested by running on `venv` and `poetry`.
`nix` compatability can be tested with `nix build .#checks.x86_64-linux.vmTest`.
```sh
$ pipenv lock -r
SQLite to PostgreSQL migration
-----------------------
LNbits currently supports SQLite and PostgreSQL databases. There is a migration script `tools/conv.py` that helps users migrate from SQLite to PostgreSQL. This script also copies all extension databases to the new backend.
### Adding mock data to `mock_data.zip`
`mock_data.zip` contains a few lines of sample SQLite data and is used in automated GitHub test to see whether your migration in `conv.py` works. Run your extension and save a few lines of data into a SQLite `your_extension.sqlite3` file. Unzip `tests/data/mock_data.zip`, add `your_extension.sqlite3`, updated `database.sqlite3` and zip it again. Add the updated `mock_data.zip` to your PR.
### running migration locally
you will need a running postgres database
#### create lnbits user for migration database
```console
sudo su - postgres -c "psql -c 'CREATE ROLE lnbits LOGIN PASSWORD 'lnbits';'"
```
#### create migration database
```console
sudo su - postgres -c "psql -c 'CREATE DATABASE migration;'"
```
#### run the migration
```console
make test-migration
```
sudo su - postgres -c "psql -c 'CREATE ROLE lnbits LOGIN PASSWORD 'lnbits';'"
#### clean migration database afterwards, fails if you try again
```console
sudo su - postgres -c "psql -c 'DROP DATABASE IF EXISTS migration;'"
```
-16
View File
@@ -1,16 +0,0 @@
---
layout: default
parent: For developers
title: Installation
nav_order: 1
---
# Installation
This guide has been moved to the [installation guide](../guide/installation.md).
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:
* We recommend using <a href="https://caddyserver.com/docs/install#debian-ubuntu-raspbian">Caddy</a> for a reverse-proxy if you want to serve your install through a domain, alternatively you can use [ngrok](https://ngrok.com/).
* <a href="https://linuxize.com/post/how-to-use-linux-screen/#starting-linux-screen">Screen</a> works well if you want LNbits to continue running when you close your terminal session.
+29
View File
@@ -0,0 +1,29 @@
<html>
<head>
<!-- Load the latest Swagger UI code and style from npm using unpkg.com -->
<script src="https://unpkg.com/swagger-ui-dist@3/swagger-ui-bundle.js"></script>
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@3/swagger-ui.css"/>
<title>My New API</title>
</head>
<body>
<div id="swagger-ui"></div> <!-- Div to hold the UI component -->
<script>
window.onload = function () {
// Begin Swagger UI call region
const ui = SwaggerUIBundle({
url: "https://legend.lnbits.com/openapi.json", //Location of Open API spec in the repo
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
})
window.ui = ui
}
</script>
</body>
</html>
+177 -39
View File
@@ -4,30 +4,38 @@ title: Basic installation
nav_order: 2
---
# Basic installation
You can choose between four package managers, `poetry`, `nix` and `venv`.
By default, LNbits will use SQLite as its database. You can also use PostgreSQL which is recommended for applications with a high load (see guide below).
## Option 1: poetry
## Option 1 (recommended): poetry
```sh
git clone https://github.com/lnbits/lnbits-legend.git
cd lnbits-legend/
# for making sure python 3.9 is installed, skip if installed
sudo apt update
sudo apt install software-properties-common
sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt install python3.9 python3.9-distutils
curl -sSL https://install.python-poetry.org | python3 -
poetry install
export PATH="/home/ubuntu/.local/bin:$PATH" # or whatever is suggested in the poetry install notes printed to terminal
poetry env use python3.9
poetry install --no-dev
# You may need to install python 3.9, update your python following this guide https://linuxize.com/post/how-to-install-python-3-9-on-ubuntu-20-04/
mkdir data
cp .env.example .env
sudo nano .env # set funding source
mkdir data && cp .env.example .env
```
```
#### Running the server
```sh
poetry run lnbits
# To change port/host pass 'poetry run lnbits --port 9000 --host 0.0.0.0'
@@ -41,7 +49,7 @@ cd lnbits-legend/
# Modern debian distros usually include Nix, however you can install with:
# 'sh <(curl -L https://nixos.org/nix/install) --daemon', or use setup here https://nixos.org/download.html#nix-verify-installation
nix build .#lnbits
nix build .#lnbits
mkdir data
```
@@ -74,20 +82,105 @@ mkdir data && cp .env.example .env
./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`.
If you want to host LNbits on the internet, run with the option `--host 0.0.0.0`.
## Option 4: Docker
```sh
git clone https://github.com/lnbits/lnbits-legend.git
cd lnbits-legend
docker build -t lnbits-legend .
cp .env.example .env
mkdir data
docker run --detach --publish 5000:5000 --name lnbits-legend --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits-legend
```
## Option 5: Fly.io
Fly.io is a docker container hosting platform that has a generous free tier. You can host LNBits for free on Fly.io for personal use.
First, sign up for an account at [Fly.io](https://fly.io) (no credit card required).
Then, install the Fly.io CLI onto your device [here](https://fly.io/docs/getting-started/installing-flyctl/).
After install is complete, the command will output a command you should copy/paste/run to get `fly` into your `$PATH`. Something like:
```
flyctl was installed successfully to /home/ubuntu/.fly/bin/flyctl
Manually add the directory to your $HOME/.bash_profile (or similar)
export FLYCTL_INSTALL="/home/ubuntu/.fly"
export PATH="$FLYCTL_INSTALL/bin:$PATH"
```
You can either run those commands, then `source ~/.bash_profile` or, if you don't, you'll have to call Fly from `~/.fly/bin/flyctl`.
Once installed, run the following commands.
```
git clone https://github.com/lnbits/lnbits-legend.git
cd lnbits-legend
fly auth login
[complete login process]
fly launch
```
You'll be prompted to enter an app name, region, postgres (choose no), deploy now (choose no).
You'll now find a file in the directory called `fly.toml`. Open that file and modify/add the following settings.
Note: Be sure to replace `${PUT_YOUR_LNBITS_ENV_VARS_HERE}` with all relevant environment variables in `.env` or `.env.example`. Environment variable strings should be quoted here, so if in `.env` you have `LNBITS_ENDPOINT=https://legend.lnbits.com` in `fly.toml` you should have `LNBITS_ENDPOINT="https://legend.lnbits.com"`.
Note: Don't enter secret environment variables here. Fly.io offers secrets (via the `fly secrets` command) that are exposed as environment variables in your runtime. So, for example, if using the LND_REST funding source, you can run `fly secrets set LND_REST_MACAROON=<hex_macaroon_data>`.
```
...
kill_timeout = 30
...
...
[mounts]
source="lnbits_data"
destination="/data"
...
...
[env]
HOST="127.0.0.1"
PORT=5000
LNBITS_FORCE_HTTPS=true
LNBITS_DATA_FOLDER="/data"
${PUT_YOUR_LNBITS_ENV_VARS_HERE}
...
...
[[services]]
internal_port = 5000
...
```
Next, create a volume to store the sqlite database for LNBits. Be sure to choose the same region for the volume that you chose earlier.
```
fly volumes create lnbits_data --size 1
```
You're ready to deploy! Run `fly deploy` and follow the steps to finish deployment. You'll select a `region` (up to you, choose the same as you did for the storage volume previously created), `postgres` (choose no), `deploy` (choose yes).
You can use `fly logs` to view the application logs, or `fly ssh console` to get a ssh shell in the running container.
### Troubleshooting
Problems installing? These commands have helped us install LNbits.
Problems installing? These commands have helped us install LNbits.
```sh
sudo apt install pkg-config libffi-dev libpq-dev
# if the secp256k1 build fails:
# if you used venv
./venv/bin/pip install setuptools wheel
./venv/bin/pip install setuptools wheel
# if you used poetry
poetry add setuptools wheel
poetry add setuptools wheel
# build essentials for debian/ubuntu
sudo apt install python3-dev gcc build-essential
```
@@ -122,13 +215,13 @@ LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits"
# Using LNbits
Now you can visit your LNbits at http://localhost:5000/.
Now you can visit your LNbits at http://localhost:5000/.
Now modify the `.env` file with any settings you prefer and add a proper [funding source](./wallets.md) by modifying the value of `LNBITS_BACKEND_WALLET_CLASS` and providing the extra information and credentials related to the chosen funding source.
Now modify the `.env` file with any settings you prefer and add a proper [funding source](./wallets.md) by modifying the value of `LNBITS_BACKEND_WALLET_CLASS` and providing the extra information and credentials related to the chosen funding source.
Then you can restart it and it will be using the new settings.
You might also need to install additional packages or perform additional setup steps, depending on the chosen backend. See [the short guide](./wallets.md) on each different funding source.
You might also need to install additional packages or perform additional setup steps, depending on the chosen backend. See [the short guide](./wallets.md) on each different funding source.
Take a look at [Polar](https://lightningpolar.com/) for an excellent way of spinning up a Lightning Network dev environment.
@@ -151,8 +244,9 @@ LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits"
# START LNbits
# STOP LNbits
# on the LNBits folder, locate and edit 'tools/conv.py' with the relevant credentials
python3 tools/conv.py
poetry run python tools/conv.py
# or
make migration
```
Hopefully, everything works and get migrated... Launch LNbits again and check if everything is working properly.
@@ -170,21 +264,20 @@ Systemd is great for taking care of your LNbits instance. It will start it on bo
Description=LNbits
# you can uncomment these lines if you know what you're doing
# it will make sure that lnbits starts after lnd (replace with your own backend service)
#Wants=lnd.service
#After=lnd.service
#Wants=lnd.service
#After=lnd.service
[Service]
# replace with the absolute path of your lnbits installation
WorkingDirectory=/home/bitcoin/lnbits
# same here
ExecStart=/home/bitcoin/lnbits/venv/bin/uvicorn lnbits.__main__:app --port 5000
WorkingDirectory=/home/lnbits/lnbits-legend
# same here. run `which poetry` if you can't find the poetry binary
ExecStart=/home/lnbits/.local/bin/poetry run lnbits
# replace with the user that you're running lnbits on
User=bitcoin
User=lnbits
Restart=always
TimeoutSec=120
RestartSec=30
# this makes sure that you receive logs in real time
Environment=PYTHONUNBUFFERED=1
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
@@ -197,13 +290,54 @@ sudo systemctl enable lnbits.service
sudo systemctl start lnbits.service
```
## Running behind an apache2 reverse proxy over https
Install apache2 and enable apache2 mods
```sh
apt-get install apache2 certbot
a2enmod headers ssl proxy proxy-http
```
create a ssl certificate with letsencrypt
```sh
certbot certonly --webroot --agree-tos --text --non-interactive --webroot-path /var/www/html -d lnbits.org
```
create a apache2 vhost at: /etc/apache2/sites-enabled/lnbits.conf
```sh
cat <<EOF > /etc/apache2/sites-enabled/lnbits.conf
<VirtualHost *:443>
ServerName lnbits.org
SSLEngine On
SSLProxyEngine On
SSLCertificateFile /etc/letsencrypt/live/lnbits.org/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/lnbits.org/privkey.pem
Include /etc/letsencrypt/options-ssl-apache.conf
LogLevel info
ErrorLog /var/log/apache2/lnbits.log
CustomLog /var/log/apache2/lnbits-access.log combined
RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME}
RequestHeader set "X-Forwarded-SSL" expr=%{HTTPS}
ProxyPreserveHost On
ProxyPass / http://localhost:5000/
ProxyPassReverse / http://localhost:5000/
<Proxy *>
Order deny,allow
Allow from all
</Proxy>
</VirtualHost>
EOF
```
restart apache2
```sh
service restart apache2
```
## Using https without reverse proxy
The most common way of using LNbits via https is to use a reverse proxy such as Caddy, nginx, or ngriok. However, you can also run LNbits via https without additional software. This is useful for development purposes or if you want to use LNbits in your local network.
The most common way of using LNbits via https is to use a reverse proxy such as Caddy, nginx, or ngriok. However, you can also run LNbits via https without additional software. This is useful for development purposes or if you want to use LNbits in your local network.
We have to create a self-signed certificate using `mkcert`. Note that this certiciate is not "trusted" by most browsers but that's fine (since you know that you have created it) and encryption is always better than clear text.
#### Install mkcert
You can find the install instructions for `mkcert` [here](https://github.com/FiloSottile/mkcert).
You can find the install instructions for `mkcert` [here](https://github.com/FiloSottile/mkcert).
Install mkcert on Ubuntu:
```sh
@@ -213,16 +347,22 @@ chmod +x mkcert-v*-linux-amd64
sudo cp mkcert-v*-linux-amd64 /usr/local/bin/mkcert
```
#### Create certificate
To create a certificate, first `cd` into your lnbits folder and execute the following command ([more info](https://kifarunix.com/how-to-create-self-signed-ssl-certificate-with-mkcert-on-ubuntu-18-04/))
To create a certificate, first `cd` into your LNbits folder and execute the following command on Linux:
```sh
openssl req -new -newkey rsa:4096 -x509 -sha256 -days 3650 -nodes -out cert.pem -keyout key.pem
```
This will create two new files (`key.pem` and `cert.pem `).
Alternatively, you can use mkcert ([more info](https://kifarunix.com/how-to-create-self-signed-ssl-certificate-with-mkcert-on-ubuntu-18-04/)):
```sh
# add your local IP (192.x.x.x) as well if you want to use it in your local network
mkcert localhost 127.0.0.1 ::1
mkcert localhost 127.0.0.1 ::1
```
This will create two new files (`localhost-key.pem` and `localhost.pem `) which you can then pass to uvicorn when you start LNbits:
You can then pass the certificate files to uvicorn when you start LNbits:
```sh
./venv/bin/uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5000 --ssl-keyfile ./localhost-key.pem --ssl-certfile ./localhost.pem
./venv/bin/uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5000 --ssl-keyfile ./key.pem --ssl-certfile ./cert.pem
```
@@ -235,9 +375,9 @@ If you want to run LNbits on your Umbrel but want it to be reached through clear
To install using docker you first need to build the docker image as:
```
git clone https://github.com/lnbits/lnbits.git
cd lnbits/ # ${PWD} referred as <lnbits_repo>
docker build -t lnbits .
git clone https://github.com/lnbits/lnbits-legend.git
cd lnbits-legend
docker build -t lnbits-legend .
```
You can launch the docker in a different directory, but make sure to copy `.env.example` from lnbits there
@@ -248,17 +388,15 @@ cp <lnbits_repo>/.env.example .env
and change the configuration in `.env` as required.
Then create the data directory for the user ID 1000, which is the user that runs the lnbits within the docker container.
Then create the data directory
```
mkdir data
sudo chown 1000:1000 ./data/
```
Then the image can be run as:
```
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits
docker run --detach --publish 5000:5000 --name lnbits-legend -e "LNBITS_BACKEND_WALLET_CLASS='FakeWallet'" --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits-legend
```
Finally you can access your lnbits on your machine at port 5000.
+15 -16
View File
@@ -8,18 +8,17 @@ nav_order: 3
Backend wallets
===============
LNbits can run on top of many lightning-network funding sources. Currently there is support for
CLightning, LND, LNbits, LNPay, lntxbot and OpenNode, with more being added regularily.
LNbits can run on top of many lightning-network funding sources. Currently there is support for CoreLightning, LND, LNbits, LNPay, lntxbot and OpenNode, with more being added regularly.
A backend wallet can be configured using the following LNbits environment variables:
### CLightning
### CoreLightning
Using this wallet requires the installation of the `pylightning` Python package.
- `LNBITS_BACKEND_WALLET_CLASS`: **CLightningWallet**
- `CLIGHTNING_RPC`: /file/path/lightning-rpc
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
- `CORELIGHTNING_RPC`: /file/path/lightning-rpc
### Spark (c-lightning)
@@ -27,6 +26,17 @@ Using this wallet requires the installation of the `pylightning` Python package.
- `SPARK_URL`: http://10.147.17.230:9737/rpc
- `SPARK_TOKEN`: secret_access_key
### LND (REST)
- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
- `LND_REST_ENDPOINT`: http://10.147.17.230:8080/
- `LND_REST_CERT`: /file/path/tls.cert
- `LND_REST_MACAROON`: /file/path/admin.macaroon or Bech64/Hex
or
- `LND_REST_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
### LND (gRPC)
Using this wallet requires the installation of the `grpcio` and `protobuf` Python packages.
@@ -43,17 +53,6 @@ You can also use an AES-encrypted macaroon (more info) instead by using
To encrypt your macaroon, run `./venv/bin/python lnbits/wallets/macaroon/macaroon.py`.
### LND (REST)
- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
- `LND_REST_ENDPOINT`: http://10.147.17.230:8080/
- `LND_REST_CERT`: /file/path/tls.cert
- `LND_REST_MACAROON`: /file/path/admin.macaroon or Bech64/Hex
or
- `LND_REST_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
### LNbits
- `LNBITS_BACKEND_WALLET_CLASS`: **LNbitsWallet**
+29 -9
View File
@@ -1,6 +1,7 @@
import asyncio
import importlib
import logging
import signal
import sys
import traceback
import warnings
@@ -75,7 +76,11 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
# Only the browser sends "text/html" request
# not fail proof, but everything else get's a JSON response
if "text/html" in request.headers["accept"]:
if (
request.headers
and "accept" in request.headers
and "text/html" in request.headers["accept"]
):
return template_renderer().TemplateResponse(
"error.html",
{"request": request, "err": f"{exc.errors()} is not a valid UUID."},
@@ -101,16 +106,27 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
def check_funding_source(app: FastAPI) -> None:
@app.on_event("startup")
async def check_wallet_status():
original_sigint_handler = signal.getsignal(signal.SIGINT)
def signal_handler(signal, frame):
logger.debug(f"SIGINT received, terminating LNbits.")
sys.exit(1)
signal.signal(signal.SIGINT, signal_handler)
while True:
error_message, balance = await WALLET.status()
if not error_message:
break
logger.error(
f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
RuntimeWarning,
)
try:
error_message, balance = await WALLET.status()
if not error_message:
break
logger.error(
f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
RuntimeWarning,
)
except:
pass
logger.info("Retrying connection to backend in 5 seconds...")
await asyncio.sleep(5)
signal.signal(signal.SIGINT, original_sigint_handler)
logger.info(
f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat."
)
@@ -185,7 +201,11 @@ def register_exception_handlers(app: FastAPI):
traceback.print_exception(etype, err, tb)
exc = traceback.format_exc()
if "text/html" in request.headers["accept"]:
if (
request.headers
and "accept" in request.headers
and "text/html" in request.headers["accept"]
):
return template_renderer().TemplateResponse(
"error.html", {"request": request, "err": err}
)
+1 -1
View File
@@ -216,7 +216,7 @@ def lnencode(addr, privkey):
expirybits = expirybits[5:]
data += tagged("x", expirybits)
elif k == "h":
data += tagged_bytes("h", hashlib.sha256(v.encode("utf-8")).digest())
data += tagged_bytes("h", v)
elif k == "n":
data += tagged_bytes("n", v)
else:
+54 -1
View File
@@ -4,6 +4,8 @@ from typing import Any, Dict, List, Optional
from urllib.parse import urlparse
from uuid import uuid4
from loguru import logger
from lnbits import bolt11
from lnbits.db import COCKROACH, POSTGRES, Connection
from lnbits.settings import DEFAULT_WALLET_NAME, LNBITS_ADMIN_USERS
@@ -325,6 +327,7 @@ async def delete_expired_invoices(
AND amount > 0 AND time < {db.timestamp_now} - {db.interval_seconds(86400)}
"""
)
logger.debug(f"Checking expiry of {len(rows)} invoices")
for (payment_request,) in rows:
try:
invoice = bolt11.decode(payment_request)
@@ -334,7 +337,9 @@ async def delete_expired_invoices(
expiration_date = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
if expiration_date > datetime.datetime.utcnow():
continue
logger.debug(
f"Deleting expired invoice: {invoice.payment_hash} (expired: {expiration_date})"
)
await (conn or db).execute(
"""
DELETE FROM apipayments
@@ -363,6 +368,11 @@ async def create_payment(
webhook: Optional[str] = None,
conn: Optional[Connection] = None,
) -> Payment:
# todo: add this when tests are fixed
# previous_payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
# assert previous_payment is None, "Payment already exists"
await (conn or db).execute(
"""
INSERT INTO apipayments
@@ -402,12 +412,55 @@ async def update_payment_status(
)
async def update_payment_details(
checking_id: str,
pending: Optional[bool] = None,
fee: Optional[int] = None,
preimage: Optional[str] = None,
new_checking_id: Optional[str] = None,
conn: Optional[Connection] = None,
) -> None:
set_clause: List[str] = []
set_variables: List[Any] = []
if new_checking_id is not None:
set_clause.append("checking_id = ?")
set_variables.append(new_checking_id)
if pending is not None:
set_clause.append("pending = ?")
set_variables.append(pending)
if fee is not None:
set_clause.append("fee = ?")
set_variables.append(fee)
if preimage is not None:
set_clause.append("preimage = ?")
set_variables.append(preimage)
set_variables.append(checking_id)
await (conn or db).execute(
f"UPDATE apipayments SET {', '.join(set_clause)} WHERE checking_id = ?",
tuple(set_variables),
)
return
async def delete_payment(checking_id: str, conn: Optional[Connection] = None) -> None:
await (conn or db).execute(
"DELETE FROM apipayments WHERE checking_id = ?", (checking_id,)
)
async def delete_wallet_payment(
checking_id: str, wallet_id: str, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"DELETE FROM apipayments WHERE checking_id = ? AND wallet = ?",
(checking_id, wallet_id),
)
async def check_internal(
payment_hash: str, conn: Optional[Connection] = None
) -> Optional[str]:
+36 -11
View File
@@ -9,8 +9,10 @@ from lnurl import encode as lnurl_encode # type: ignore
from loguru import logger
from pydantic import BaseModel
from lnbits.db import Connection
from lnbits.helpers import url_for
from lnbits.settings import WALLET
from lnbits.wallets.base import PaymentStatus
class Wallet(BaseModel):
@@ -128,8 +130,21 @@ class Payment(BaseModel):
@property
def is_uncheckable(self) -> bool:
return self.checking_id.startswith("temp_") or self.checking_id.startswith(
"internal_"
return self.checking_id.startswith("internal_")
async def update_status(
self,
status: PaymentStatus,
conn: Optional[Connection] = None,
) -> None:
from .crud import update_payment_details
await update_payment_details(
checking_id=self.checking_id,
pending=status.pending,
fee=status.fee_msat,
preimage=status.preimage,
conn=conn,
)
async def set_pending(self, pending: bool) -> None:
@@ -137,30 +152,40 @@ class Payment(BaseModel):
await update_payment_status(self.checking_id, pending)
async def check_pending(self) -> None:
async def check_status(
self,
conn: Optional[Connection] = None,
) -> PaymentStatus:
if self.is_uncheckable:
return
return PaymentStatus(None)
logger.debug(
f"Checking {'outgoing' if self.is_out else 'incoming'} pending payment {self.checking_id}"
)
if self.is_out:
status = await WALLET.get_payment_status(self.checking_id)
else:
status = await WALLET.get_invoice_status(self.checking_id)
logger.debug(f"Status: {status}")
if self.is_out and status.failed:
logger.info(
f" - deleting outgoing failed payment {self.checking_id}: {status}"
logger.warning(
f"Deleting outgoing failed payment {self.checking_id}: {status}"
)
await self.delete()
await self.delete(conn)
elif not status.pending:
logger.info(
f" - marking '{'in' if self.is_in else 'out'}' {self.checking_id} as not pending anymore: {status}"
f"Marking '{'in' if self.is_in else 'out'}' {self.checking_id} as not pending anymore: {status}"
)
await self.set_pending(status.pending)
await self.update_status(status, conn=conn)
return status
async def delete(self) -> None:
async def delete(self, conn: Optional[Connection] = None) -> None:
from .crud import delete_payment
await delete_payment(self.checking_id)
await delete_payment(self.checking_id, conn=conn)
class BalanceCheck(BaseModel):
+55 -33
View File
@@ -21,18 +21,20 @@ from lnbits.decorators import (
)
from lnbits.helpers import url_for, urlsafe_short_hash
from lnbits.requestvars import g
from lnbits.settings import FAKE_WALLET, WALLET
from lnbits.settings import FAKE_WALLET, RESERVE_FEE_MIN, RESERVE_FEE_PERCENT, WALLET
from lnbits.wallets.base import PaymentResponse, PaymentStatus
from . import db
from .crud import (
check_internal,
create_payment,
delete_payment,
delete_wallet_payment,
get_wallet,
get_wallet_payment,
update_payment_details,
update_payment_status,
)
from .models import Payment
try:
from typing import TypedDict # type: ignore
@@ -54,6 +56,7 @@ async def create_invoice(
amount: int, # in satoshis
memo: str,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
extra: Optional[Dict] = None,
webhook: Optional[str] = None,
internal: Optional[bool] = False,
@@ -65,7 +68,10 @@ async def create_invoice(
wallet = FAKE_WALLET if internal else WALLET
ok, checking_id, payment_request, error_message = await wallet.create_invoice(
amount=amount, memo=invoice_memo, description_hash=description_hash
amount=amount,
memo=invoice_memo,
description_hash=description_hash,
unhashed_description=unhashed_description,
)
if not ok:
raise InvoiceFailure(error_message or "unexpected backend error.")
@@ -97,11 +103,20 @@ async def pay_invoice(
description: str = "",
conn: Optional[Connection] = None,
) -> str:
"""
Pay a Lightning invoice.
First, we create a temporary payment in the database with fees set to the reserve fee.
We then check whether the balance of the payer would go negative.
We then attempt to pay the invoice through the backend.
If the payment is successful, we update the payment in the database with the payment details.
If the payment is unsuccessful, we delete the temporary payment.
If the payment is still in flight, we hope that some other process will regularly check for the payment.
"""
invoice = bolt11.decode(payment_request)
fee_reserve_msat = fee_reserve(invoice.amount_msat)
async with (db.reuse_conn(conn) if conn else db.connect()) as conn:
temp_id = f"temp_{urlsafe_short_hash()}"
internal_id = f"internal_{urlsafe_short_hash()}"
temp_id = invoice.payment_hash
internal_id = f"internal_{invoice.payment_hash}"
if invoice.amount_msat == 0:
raise ValueError("Amountless invoices not supported.")
@@ -156,7 +171,7 @@ async def pay_invoice(
logger.debug("balance is too low, deleting temporary payment")
if not internal_checking_id and wallet.balance_msat > -fee_reserve_msat:
raise PaymentFailure(
f"You must reserve at least 1% ({round(fee_reserve_msat/1000)} sat) to cover potential routing fees."
f"You must reserve at least ({round(fee_reserve_msat/1000)} sat) to cover potential routing fees."
)
raise PermissionError("Insufficient balance.")
@@ -181,30 +196,41 @@ async def pay_invoice(
payment: PaymentResponse = await WALLET.pay_invoice(
payment_request, fee_reserve_msat
)
if payment.checking_id and payment.checking_id != temp_id:
logger.warning(
f"backend sent unexpected checking_id (expected: {temp_id} got: {payment.checking_id})"
)
logger.debug(f"backend: pay_invoice finished {temp_id}")
if payment.checking_id:
logger.debug(f"creating final payment {payment.checking_id}")
if payment.checking_id and payment.ok != False:
# payment.ok can be True (paid) or None (pending)!
logger.debug(f"updating payment {temp_id}")
async with db.connect() as conn:
await create_payment(
checking_id=payment.checking_id,
await update_payment_details(
checking_id=temp_id,
pending=payment.ok != True,
fee=payment.fee_msat,
preimage=payment.preimage,
pending=payment.ok == None,
new_checking_id=payment.checking_id,
conn=conn,
**payment_kwargs,
)
logger.debug(f"deleting temporary payment {temp_id}")
await delete_payment(temp_id, conn=conn)
else:
logger.debug(f"backend payment failed, no checking_id {temp_id}")
logger.debug(f"payment successful {payment.checking_id}")
elif payment.checking_id is None and payment.ok == False:
# payment failed
logger.warning(f"backend sent payment failure")
async with db.connect() as conn:
logger.debug(f"deleting temporary payment {temp_id}")
await delete_payment(temp_id, conn=conn)
await delete_wallet_payment(temp_id, wallet_id, conn=conn)
raise PaymentFailure(
payment.error_message
or "Payment failed, but backend didn't give us an error message."
f"payment failed: {payment.error_message}"
or "payment failed, but backend didn't give us an error message"
)
logger.debug(f"payment successful {payment.checking_id}")
else:
logger.warning(
f"didn't receive checking_id from backend, payment may be stuck in database: {temp_id}"
)
return invoice.payment_hash
@@ -337,26 +363,22 @@ async def perform_lnurlauth(
)
async def check_invoice_status(
async def check_transaction_status(
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
) -> PaymentStatus:
payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
payment: Optional[Payment] = await get_wallet_payment(
wallet_id, payment_hash, conn=conn
)
if not payment:
return PaymentStatus(None)
status = await WALLET.get_invoice_status(payment.checking_id)
if not payment.pending:
return status
if payment.is_out and status.failed:
logger.info(f"deleting outgoing failed payment {payment.checking_id}: {status}")
await payment.delete()
elif not status.pending:
logger.info(
f"marking '{'in' if payment.is_in else 'out'}' {payment.checking_id} as not pending anymore: {status}"
)
await payment.set_pending(status.pending)
# note: before, we still checked the status of the payment again
return PaymentStatus(True)
status: PaymentStatus = await payment.check_status()
return status
# WARN: this same value must be used for balance check and passed to WALLET.pay_invoice(), it may cause a vulnerability if the values differ
def fee_reserve(amount_msat: int) -> int:
return max(2000, int(amount_msat * 0.01))
return max(int(RESERVE_FEE_MIN), int(amount_msat * RESERVE_FEE_PERCENT / 100.0))
+17 -3
View File
@@ -232,6 +232,9 @@ new Vue({
generateChart(this.$refs.canvas, this.payments)
})
},
focusInput(el) {
this.$nextTick(() => this.$refs[el].focus())
},
showReceiveDialog: function () {
this.receive.show = true
this.receive.status = 'pending'
@@ -243,6 +246,7 @@ new Vue({
this.receive.paymentChecker = null
this.receive.minMax = [0, 2100000000000000]
this.receive.lnurl = null
this.focusInput('setAmount')
},
showParseDialog: function () {
this.parse.show = true
@@ -365,9 +369,9 @@ new Vue({
decodeRequest: function () {
this.parse.show = true
let req = this.parse.data.request.toLowerCase()
if (this.parse.data.request.startsWith('lightning:')) {
if (this.parse.data.request.toLowerCase().startsWith('lightning:')) {
this.parse.data.request = this.parse.data.request.slice(10)
} else if (this.parse.data.request.startsWith('lnurl:')) {
} else if (this.parse.data.request.toLowerCase().startsWith('lnurl:')) {
this.parse.data.request = this.parse.data.request.slice(6)
} else if (req.indexOf('lightning=lnurl1') !== -1) {
this.parse.data.request = this.parse.data.request
@@ -668,7 +672,17 @@ new Vue({
})
},
exportCSV: function () {
LNbits.utils.exportCSV(this.paymentsTable.columns, this.payments)
// status is important for export but it is not in paymentsTable
// because it is manually added with payment detail link and icons
// and would cause duplication in the list
let columns = this.paymentsTable.columns
columns.unshift({
name: 'pending',
align: 'left',
label: 'Pending',
field: 'pending'
})
LNbits.utils.exportCSV(columns, this.payments)
}
},
watch: {
+2 -2
View File
@@ -66,7 +66,7 @@
outline
color="grey"
type="a"
href="https://github.com/lnbits/lnbits"
href="https://github.com/lnbits/lnbits-legend"
target="_blank"
rel="noopener"
>View project in GitHub</q-btn
@@ -75,7 +75,7 @@
outline
color="grey"
type="a"
href="https://lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK"
href="https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK"
target="_blank"
rel="noopener"
>Donate</q-btn
+3 -2
View File
@@ -428,6 +428,7 @@
:options="receive.units"
></q-select>
<q-input
ref="setAmount"
filled
dense
v-model.number="receive.data.amount"
@@ -689,7 +690,7 @@
</q-card>
</q-dialog>
<q-tabs
class="lt-md fixed-bottom left-0 right-0 bg-primary text-white shadow-2 z-max"
class="lt-md fixed-bottom left-0 right-0 bg-primary text-white shadow-2 z-top"
active-class="px-0"
indicator-color="transparent"
>
@@ -710,7 +711,7 @@
<q-card class="q-pa-lg">
<h6 class="q-my-md text-primary">Warning</h6>
<p>
Login functionality to be released in v0.2, for now,
Login functionality to be released in a future update, for now,
<strong
>make sure you bookmark this page for future access to your
wallet</strong
+29 -14
View File
@@ -1,7 +1,7 @@
import asyncio
import binascii
import hashlib
import json
from binascii import unhexlify
from http import HTTPStatus
from io import BytesIO
from typing import Dict, List, Optional, Tuple, Union
@@ -48,7 +48,7 @@ from ..crud import (
from ..services import (
InvoiceFailure,
PaymentFailure,
check_invoice_status,
check_transaction_status,
create_invoice,
pay_invoice,
perform_lnurlauth,
@@ -123,7 +123,7 @@ async def api_payments(
offset=offset,
)
for payment in pendingPayments:
await check_invoice_status(
await check_transaction_status(
wallet_id=payment.wallet_id, payment_hash=payment.payment_hash
)
return await get_payments(
@@ -141,6 +141,7 @@ class CreateInvoiceData(BaseModel):
memo: Optional[str] = None
unit: Optional[str] = "sat"
description_hash: Optional[str] = None
unhashed_description: Optional[str] = None
lnurl_callback: Optional[str] = None
lnurl_balance_check: Optional[str] = None
extra: Optional[dict] = None
@@ -151,10 +152,28 @@ class CreateInvoiceData(BaseModel):
async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
if data.description_hash:
description_hash = unhexlify(data.description_hash)
try:
description_hash = binascii.unhexlify(data.description_hash)
except binascii.Error:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="'description_hash' must be a valid hex string",
)
unhashed_description = b""
memo = ""
elif data.unhashed_description:
try:
unhashed_description = binascii.unhexlify(data.unhashed_description)
except binascii.Error:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="'unhashed_description' must be a valid hex string",
)
description_hash = b""
memo = ""
else:
description_hash = b""
unhashed_description = b""
memo = data.memo or LNBITS_SITE_TITLE
if data.unit == "sat":
amount = int(data.amount)
@@ -170,6 +189,7 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
amount=amount,
memo=memo,
description_hash=description_hash,
unhashed_description=unhashed_description,
extra=data.extra,
webhook=data.webhook,
internal=data.internal,
@@ -184,10 +204,7 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
lnurl_response: Union[None, bool, str] = None
if data.lnurl_callback:
if "lnurl_balance_check" in data:
assert (
data.lnurl_balance_check is not None
), "lnurl_balance_check is required"
if data.lnurl_balance_check is not None:
await save_balance_check(wallet.id, data.lnurl_balance_check)
async with httpx.AsyncClient() as client:
@@ -245,8 +262,6 @@ async def api_payments_pay_invoice(bolt11: str, wallet: Wallet):
@core_app.post(
"/api/v1/payments",
# deprecated=True,
# description="DEPRECATED. Use /api/v2/TBD and /api/v2/TBD instead",
status_code=HTTPStatus.CREATED,
)
async def api_payments_create(
@@ -267,7 +282,7 @@ async def api_payments_create(
return await api_payments_create_invoice(invoiceData, wallet.wallet)
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
status_code=HTTPStatus.UNAUTHORIZED,
detail="Invoice (or Admin) key required.",
)
@@ -363,7 +378,7 @@ async def subscribe(request: Request, wallet: Wallet):
while True:
payment: Payment = await payment_queue.get()
if payment.wallet_id == this_wallet_id:
logger.debug("payment receieved", payment)
logger.debug("payment received", payment)
await send_queue.put(("payment-received", payment))
asyncio.create_task(payment_received())
@@ -407,7 +422,7 @@ async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
)
await check_invoice_status(payment.wallet_id, payment_hash)
await check_transaction_status(payment.wallet_id, payment_hash)
payment = await get_standalone_payment(
payment_hash, wallet_id=wallet.id if wallet else None
)
@@ -421,7 +436,7 @@ async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
return {"paid": True, "preimage": payment.preimage}
try:
await payment.check_pending()
await payment.check_status()
except Exception:
if wallet and wallet.id == payment.wallet_id:
return {"paid": False, "details": payment}
+3 -1
View File
@@ -148,7 +148,9 @@ async def wallet(
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
)
logger.debug(f"Access wallet {wallet_name}{'of user '+ user.id if user else ''}")
logger.debug(
f"Access {'user '+ user.id + ' ' if user else ''} {'wallet ' + wallet_name if wallet_name else ''}"
)
userwallet = user.get_wallet(wallet_id) # type: ignore
if not userwallet:
return template_renderer().TemplateResponse(
+49 -42
View File
@@ -130,49 +130,42 @@ async def get_key_type(
# 2: invalid
pathname = r["path"].split("/")[1]
if not api_key_header and not api_key_query:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
token = api_key_header or api_key_query
token = api_key_header if api_key_header else api_key_query
if not token:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="Invoice (or Admin) key required.",
)
try:
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
):
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized."
)
return wallet
except HTTPException as e:
if e.status_code == HTTPStatus.BAD_REQUEST:
for typenr, WalletChecker in zip(
[0, 1], [WalletAdminKeyChecker, WalletInvoiceKeyChecker]
):
try:
checker = WalletChecker(api_key=token)
await checker.__call__(r)
wallet = WalletTypeInfo(typenr, checker.wallet) # type: ignore
if wallet is None or wallet.wallet is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
)
if (
LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS
) and (LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS):
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized."
)
return wallet
except HTTPException as e:
if e.status_code == HTTPStatus.BAD_REQUEST:
raise
if e.status_code == HTTPStatus.UNAUTHORIZED:
pass
except:
raise
if e.status_code == HTTPStatus.UNAUTHORIZED:
pass
except:
raise
try:
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
):
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized."
)
return wallet
except HTTPException as e:
if e.status_code == HTTPStatus.BAD_REQUEST:
raise
if e.status_code == HTTPStatus.UNAUTHORIZED:
return WalletTypeInfo(2, None) # type: ignore
except:
raise
return wallet
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
)
async def require_admin_key(
@@ -180,7 +173,14 @@ async def require_admin_key(
api_key_header: str = Security(api_key_header), # type: ignore
api_key_query: str = Security(api_key_query), # type: ignore
):
token = api_key_header if api_key_header else api_key_query
token = api_key_header or api_key_query
if not token:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="Admin key required.",
)
wallet = await get_key_type(r, token)
@@ -199,7 +199,14 @@ async def require_invoice_key(
api_key_header: str = Security(api_key_header), # type: ignore
api_key_query: str = Security(api_key_query), # type: ignore
):
token = api_key_header if api_key_header else api_key_query
token = api_key_header or api_key_query
if not token:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="Invoice (or Admin) key required.",
)
wallet = await get_key_type(r, token)
+73
View File
@@ -0,0 +1,73 @@
# Bolt cards (NXP NTAG) Extension
This extension allows you to link your Bolt Card (or other compatible NXP NTAG device) with a LNbits instance and use it in a more secure way than a static LNURLw. A technology called [Secure Unique NFC](https://mishka-scan.com/blog/secure-unique-nfc) is utilized in this workflow.
<a href="https://www.youtube.com/watch?v=wJ7QLFTRjK0">Tutorial</a>
**Disclaimer:** ***Use this only if you either know what you are doing or are a reckless lightning pioneer. Only you are responsible for all your sats, cards and other devices. Always backup all your card keys!***
***In order to use this extension you need to be able to setup your own card.*** That means writing a URL template pointing to your LNBits instance, configuring some SUN (SDM) settings and optionally changing the card's keys. There's a [guide](https://www.whitewolftech.com/articles/payment-card/) to set it up with a card reader connected to your computer. It can be done (without setting the keys) with [TagWriter app by NXP](https://play.google.com/store/apps/details?id=com.nxp.nfc.tagwriter) Android app. Last but not least, an OSS android app by name [bolt-nfc-android-app](https://github.com/boltcard/bolt-nfc-android-app) is being developed for these purposes. It's available from Google Play [here](https://play.google.com/store/apps/details?id=com.lightningnfcapp).
## About the keys
Up to five 16-byte keys can be stored on the card, numbered from 00 to 04. In the empty state they all should be set to zeros (00000000000000000000000000000000). For this extension only two keys need to be set:
One for encrypting the card UID and the counter (p parameter), let's called it meta key, key #01 or K1.
One for calculating CMAC (c parameter), let's called it file key, key #02 or K2.
The key #00, K0 (also know as auth key) is skipped to be use as authentification key. Is not needed by this extension, but can be filled in order to write the keys in cooperation with bolt-nfc-android-app.
***Always backup all keys that you're trying to write on the card. Without them you may not be able to change them in the future!***
## Setting the card - bolt-nfc-android-app (easy way)
So far, regarding the keys, the app can only write a new key set on an empty card (with zero keys). **When you write non zero (and 'non debug') keys, they can't be rewrite with this app.** You have to do it on your computer.
- Read the card with the app. Note UID so you can fill it in the extension later.
- Write the link on the card. It shoud be like `YOUR_LNBITS_DOMAIN/boltcards/api/v1/scan/{external_id}`
- `{external_id}` should be replaced with the External ID found in the LNBits dialog.
- Add new card in the extension.
- Set a max sats per transaction. Any transaction greater than this amount will be rejected.
- Set a max sats per day. After the card spends this amount of sats in a day, additional transactions will be rejected.
- Set a card name. This is just for your reference inside LNBits.
- Set the card UID. This is the unique identifier on your NFC card and is 7 bytes.
- If on an Android device with a newish version of Chrome, you can click the icon next to the input and tap your card to autofill this field.
- Advanced Options
- Card Keys (k0, k1, k2) will be automatically generated if not explicitly set.
- Set to 16 bytes of 0s (00000000000000000000000000000000) to leave the keys in debug mode.
- GENERATE KEY button fill the keys randomly. If there is "debug" in the card name, a debug set of keys is filled instead.
- Click CREATE CARD button
- Click the QR code button next to a card to view its details. You can scan the QR code with the Android app to import the keys.
- Click the "KEYS / AUTH LINK" button to copy the auth URL to the clipboard. You can then paste this into the Android app to import the keys.
- Tap the NFC card to write the keys to the card.
## Setting the card - computer (hard way)
Follow the guide.
The URI should be `lnurlw://YOUR-DOMAIN.COM/boltcards/api/v1/scan/{YOUR_card_external_id}?p=00000000000000000000000000000000&c=0000000000000000`
Then fill up the card parameters in the extension. Card Auth key (K0) can be omitted. Initical counter can be 0.
## Setting the card - android NXP app (hard way)
- If you don't know the card ID, use NXP TagInfo app to find it out.
- In the TagWriter app tap Write tags
- New Data Set > Link
- Set URI type to Custom URL
- URL should look like lnurlw://YOUR_LNBITS_DOMAIN/boltcards/api/v1/scan/{YOUR_card_external_id}?p=00000000000000000000000000000000&c=0000000000000000
- click Configure mirroring options
- Select Card Type NTAG 424 DNA
- Check Enable SDM Mirroring
- Select SDM Meta Read Access Right to 01
- Check Enable UID Mirroring
- Check Enable Counter Mirroring
- Set SDM Counter Retrieval Key to 0E
- Set PICC Data Offset to immediately after e=
- Set Derivation Key for CMAC Calculation to 00
- Set SDM MAC Input Offset to immediately after c=
- Set SDM MAC Offset to immediately after c=
- Save & Write
- Scan with compatible Wallet
This app afaik cannot change the keys. If you cannot change them any other way, leave them empty in the extension dialog and remember you're not secure. Card Auth key (K0) can be omitted anyway. Initical counter can be 0.
+37
View File
@@ -0,0 +1,37 @@
import asyncio
from fastapi import APIRouter
from starlette.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_boltcards")
boltcards_static_files = [
{
"path": "/boltcards/static",
"app": StaticFiles(packages=[("lnbits", "extensions/boltcards/static")]),
"name": "boltcards_static",
}
]
boltcards_ext: APIRouter = APIRouter(prefix="/boltcards", tags=["boltcards"])
def boltcards_renderer():
return template_renderer(["lnbits/extensions/boltcards/templates"])
from .lnurl import * # noqa
from .tasks import * # noqa
def boltcards_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
from .views import * # noqa
from .views_api import * # noqa
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Bolt Cards",
"short_description": "Self custody Bolt Cards with one time LNURLw",
"icon": "payment",
"contributors": ["iwarpbtc", "arcbtc", "leesalminen"]
}
+273
View File
@@ -0,0 +1,273 @@
import secrets
from datetime import date, datetime
from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import Card, CreateCardData, Hit, Refund
async def create_card(data: CreateCardData, wallet_id: str) -> Card:
card_id = urlsafe_short_hash().upper()
extenal_id = urlsafe_short_hash().lower()
await db.execute(
"""
INSERT INTO boltcards.cards (
id,
uid,
external_id,
wallet,
card_name,
counter,
tx_limit,
daily_limit,
enable,
k0,
k1,
k2,
otp
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
card_id,
data.uid.upper(),
extenal_id,
wallet_id,
data.card_name,
data.counter,
data.tx_limit,
data.daily_limit,
True,
data.k0,
data.k1,
data.k2,
secrets.token_hex(16),
),
)
card = await get_card(card_id)
assert card, "Newly created card couldn't be retrieved"
return card
async def update_card(card_id: str, **kwargs) -> Optional[Card]:
if "is_unique" in kwargs:
kwargs["is_unique"] = int(kwargs["is_unique"])
if "uid" in kwargs:
kwargs["uid"] = kwargs["uid"].upper()
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
f"UPDATE boltcards.cards SET {q} WHERE id = ?",
(*kwargs.values(), card_id),
)
row = await db.fetchone("SELECT * FROM boltcards.cards WHERE id = ?", (card_id,))
return Card(**row) if row else None
async def get_cards(wallet_ids: Union[str, List[str]]) -> List[Card]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM boltcards.cards WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Card(**row) for row in rows]
async def get_card(card_id: str) -> Optional[Card]:
row = await db.fetchone("SELECT * FROM boltcards.cards WHERE id = ?", (card_id,))
if not row:
return None
card = dict(**row)
return Card.parse_obj(card)
async def get_card_by_uid(card_uid: str) -> Optional[Card]:
row = await db.fetchone(
"SELECT * FROM boltcards.cards WHERE uid = ?", (card_uid.upper(),)
)
if not row:
return None
card = dict(**row)
return Card.parse_obj(card)
async def get_card_by_external_id(external_id: str) -> Optional[Card]:
row = await db.fetchone(
"SELECT * FROM boltcards.cards WHERE external_id = ?", (external_id.lower(),)
)
if not row:
return None
card = dict(**row)
return Card.parse_obj(card)
async def get_card_by_otp(otp: str) -> Optional[Card]:
row = await db.fetchone("SELECT * FROM boltcards.cards WHERE otp = ?", (otp,))
if not row:
return None
card = dict(**row)
return Card.parse_obj(card)
async def delete_card(card_id: str) -> None:
# Delete cards
card = await get_card(card_id)
await db.execute("DELETE FROM boltcards.cards WHERE id = ?", (card_id,))
# Delete hits
hits = await get_hits([card_id])
for hit in hits:
await db.execute("DELETE FROM boltcards.hits WHERE id = ?", (hit.id,))
# Delete refunds
refunds = await get_refunds([hit])
for refund in refunds:
await db.execute(
"DELETE FROM boltcards.refunds WHERE id = ?", (refund.hit_id,)
)
async def update_card_counter(counter: int, id: str):
await db.execute(
"UPDATE boltcards.cards SET counter = ? WHERE id = ?",
(counter, id),
)
async def enable_disable_card(enable: bool, id: str) -> Optional[Card]:
row = await db.execute(
"UPDATE boltcards.cards SET enable = ? WHERE id = ?",
(enable, id),
)
return await get_card(id)
async def update_card_otp(otp: str, id: str):
await db.execute(
"UPDATE boltcards.cards SET otp = ? WHERE id = ?",
(otp, id),
)
async def get_hit(hit_id: str) -> Optional[Hit]:
row = await db.fetchone(f"SELECT * FROM boltcards.hits WHERE id = ?", (hit_id))
if not row:
return None
hit = dict(**row)
return Hit.parse_obj(hit)
async def get_hits(cards_ids: Union[str, List[str]]) -> List[Hit]:
q = ",".join(["?"] * len(cards_ids))
rows = await db.fetchall(
f"SELECT * FROM boltcards.hits WHERE card_id IN ({q})", (*cards_ids,)
)
return [Hit(**row) for row in rows]
async def get_hits_today(card_id: str) -> Optional[Hit]:
rows = await db.fetchall(
f"SELECT * FROM boltcards.hits WHERE card_id = ?",
(card_id,),
)
updatedrow = []
for row in rows:
if datetime.now().date() == datetime.fromtimestamp(row.time).date():
updatedrow.append(row)
return [Hit(**row) for row in updatedrow]
async def spend_hit(id: str, amount: int):
await db.execute(
"UPDATE boltcards.hits SET spent = ?, amount = ? WHERE id = ?",
(True, amount, id),
)
return await get_hit(id)
async def create_hit(card_id, ip, useragent, old_ctr, new_ctr) -> Hit:
hit_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO boltcards.hits (
id,
card_id,
ip,
spent,
useragent,
old_ctr,
new_ctr,
amount
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
hit_id,
card_id,
ip,
False,
useragent,
old_ctr,
new_ctr,
0,
),
)
hit = await get_hit(hit_id)
assert hit, "Newly recorded hit couldn't be retrieved"
return hit
async def create_refund(hit_id, refund_amount) -> Refund:
refund_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO boltcards.refunds (
id,
hit_id,
refund_amount
)
VALUES (?, ?, ?)
""",
(
refund_id,
hit_id,
refund_amount,
),
)
refund = await get_refund(refund_id)
assert refund, "Newly recorded hit couldn't be retrieved"
return refund
async def get_refund(refund_id: str) -> Optional[Refund]:
row = await db.fetchone(
f"SELECT * FROM boltcards.refunds WHERE id = ?", (refund_id)
)
if not row:
return None
refund = dict(**row)
return Refund.parse_obj(refund)
async def get_refunds(hits_ids: Union[str, List[str]]) -> List[Refund]:
q = ",".join(["?"] * len(hits_ids))
rows = await db.fetchall(
f"SELECT * FROM boltcards.refunds WHERE hit_id IN ({q})", (*hits_ids,)
)
return [Refund(**row) for row in rows]
+219
View File
@@ -0,0 +1,219 @@
import base64
import hashlib
import hmac
import json
import secrets
from http import HTTPStatus
from io import BytesIO
from typing import Optional
from urllib.parse import urlparse
from embit import bech32, compact
from fastapi import Request
from fastapi.param_functions import Query
from fastapi.params import Depends, Query
from lnurl import Lnurl, LnurlWithdrawResponse
from lnurl import encode as lnurl_encode # type: ignore
from lnurl.types import LnurlPayMetadata # type: ignore
from loguru import logger
from starlette.exceptions import HTTPException
from starlette.requests import Request
from starlette.responses import HTMLResponse
from lnbits import bolt11
from lnbits.core.services import create_invoice
from lnbits.core.views.api import pay_invoice
from . import boltcards_ext
from .crud import (
create_hit,
get_card,
get_card_by_external_id,
get_card_by_otp,
get_hit,
get_hits_today,
spend_hit,
update_card,
update_card_counter,
update_card_otp,
)
from .models import CreateCardData
from .nxp424 import decryptSUN, getSunMAC
###############LNURLWITHDRAW#################
# /boltcards/api/v1/scan?p=00000000000000000000000000000000&c=0000000000000000
@boltcards_ext.get("/api/v1/scan/{external_id}")
async def api_scan(p, c, request: Request, external_id: str = None):
# some wallets send everything as lower case, no bueno
p = p.upper()
c = c.upper()
card = None
counter = b""
card = await get_card_by_external_id(external_id)
if not card:
return {"status": "ERROR", "reason": "No card."}
if not card.enable:
return {"status": "ERROR", "reason": "Card is disabled."}
try:
card_uid, counter = decryptSUN(bytes.fromhex(p), bytes.fromhex(card.k1))
if card.uid.upper() != card_uid.hex().upper():
return {"status": "ERROR", "reason": "Card UID mis-match."}
if c != getSunMAC(card_uid, counter, bytes.fromhex(card.k2)).hex().upper():
return {"status": "ERROR", "reason": "CMAC does not check."}
except:
return {"status": "ERROR", "reason": "Error decrypting card."}
ctr_int = int.from_bytes(counter, "little")
if ctr_int <= card.counter:
return {"status": "ERROR", "reason": "This link is already used."}
await update_card_counter(ctr_int, card.id)
# gathering some info for hit record
ip = request.client.host
if "x-real-ip" in request.headers:
ip = request.headers["x-real-ip"]
elif "x-forwarded-for" in request.headers:
ip = request.headers["x-forwarded-for"]
agent = request.headers["user-agent"] if "user-agent" in request.headers else ""
todays_hits = await get_hits_today(card.id)
hits_amount = 0
for hit in todays_hits:
hits_amount = hits_amount + hit.amount
if hits_amount > card.daily_limit:
return {"status": "ERROR", "reason": "Max daily limit spent."}
hit = await create_hit(card.id, ip, agent, card.counter, ctr_int)
lnurlpay = lnurl_encode(request.url_for("boltcards.lnurlp_response", hit_id=hit.id))
return {
"tag": "withdrawRequest",
"callback": request.url_for("boltcards.lnurl_callback", hitid=hit.id),
"k1": hit.id,
"minWithdrawable": 1 * 1000,
"maxWithdrawable": card.tx_limit * 1000,
"defaultDescription": f"Boltcard (refund address lnurl://{lnurlpay})",
}
@boltcards_ext.get(
"/api/v1/lnurl/cb/{hitid}",
status_code=HTTPStatus.OK,
name="boltcards.lnurl_callback",
)
async def lnurl_callback(
request: Request,
pr: str = Query(None),
k1: str = Query(None),
):
hit = await get_hit(k1)
card = await get_card(hit.card_id)
if not hit:
return {"status": "ERROR", "reason": f"LNURL-pay record not found."}
if hit.id != k1:
return {"status": "ERROR", "reason": "Bad K1"}
if hit.spent:
return {"status": "ERROR", "reason": f"Payment already claimed"}
invoice = bolt11.decode(pr)
hit = await spend_hit(id=hit.id, amount=int(invoice.amount_msat / 1000))
try:
await pay_invoice(
wallet_id=card.wallet,
payment_request=pr,
max_sat=card.tx_limit,
extra={"tag": "boltcard", "tag": hit.id},
)
return {"status": "OK"}
except:
return {"status": "ERROR", "reason": f"Payment failed"}
# /boltcards/api/v1/auth?a=00000000000000000000000000000000
@boltcards_ext.get("/api/v1/auth")
async def api_auth(a, request: Request):
if a == "00000000000000000000000000000000":
response = {"k0": "0" * 32, "k1": "1" * 32, "k2": "2" * 32}
return response
card = await get_card_by_otp(a)
if not card:
raise HTTPException(
detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND
)
new_otp = secrets.token_hex(16)
await update_card_otp(new_otp, card.id)
lnurlw_base = (
f"{urlparse(str(request.url)).netloc}/boltcards/api/v1/scan/{card.external_id}"
)
response = {
"card_name": card.card_name,
"id": 1,
"k0": card.k0,
"k1": card.k1,
"k2": card.k2,
"k3": card.k1,
"k4": card.k2,
"lnurlw_base": "lnurlw://" + lnurlw_base,
"protocol_name": "new_bolt_card_response",
"protocol_version": 1,
}
return response
###############LNURLPAY REFUNDS#################
@boltcards_ext.get(
"/api/v1/lnurlp/{hit_id}",
response_class=HTMLResponse,
name="boltcards.lnurlp_response",
)
async def lnurlp_response(req: Request, hit_id: str = Query(None)):
hit = await get_hit(hit_id)
card = await get_card(hit.card_id)
if not hit:
return {"status": "ERROR", "reason": f"LNURL-pay record not found."}
if not card.enable:
return {"status": "ERROR", "reason": "Card is disabled."}
payResponse = {
"tag": "payRequest",
"callback": req.url_for("boltcards.lnurlp_callback", hit_id=hit_id),
"metadata": LnurlPayMetadata(json.dumps([["text/plain", "Refund"]])),
"minSendable": 1 * 1000,
"maxSendable": card.tx_limit * 1000,
}
return json.dumps(payResponse)
@boltcards_ext.get(
"/api/v1/lnurlp/cb/{hit_id}",
response_class=HTMLResponse,
name="boltcards.lnurlp_callback",
)
async def lnurlp_callback(
req: Request, hit_id: str = Query(None), amount: str = Query(None)
):
hit = await get_hit(hit_id)
card = await get_card(hit.card_id)
if not hit:
return {"status": "ERROR", "reason": f"LNURL-pay record not found."}
payment_hash, payment_request = await create_invoice(
wallet_id=card.wallet,
amount=int(amount) / 1000,
memo=f"Refund {hit_id}",
unhashed_description=LnurlPayMetadata(
json.dumps([["text/plain", "Refund"]])
).encode("utf-8"),
extra={"refund": hit_id},
)
payResponse = {"pr": payment_request, "routes": []}
return json.dumps(payResponse)
+60
View File
@@ -0,0 +1,60 @@
from lnbits.helpers import urlsafe_short_hash
async def m001_initial(db):
await db.execute(
"""
CREATE TABLE boltcards.cards (
id TEXT PRIMARY KEY UNIQUE,
wallet TEXT NOT NULL,
card_name TEXT NOT NULL,
uid TEXT NOT NULL UNIQUE,
external_id TEXT NOT NULL UNIQUE,
counter INT NOT NULL DEFAULT 0,
tx_limit TEXT NOT NULL,
daily_limit TEXT NOT NULL,
enable BOOL NOT NULL,
k0 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
k1 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
k2 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
prev_k0 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
prev_k1 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
prev_k2 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
otp TEXT NOT NULL DEFAULT '',
time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now
+ """
);
"""
)
await db.execute(
"""
CREATE TABLE boltcards.hits (
id TEXT PRIMARY KEY UNIQUE,
card_id TEXT NOT NULL,
ip TEXT NOT NULL,
spent BOOL NOT NULL DEFAULT True,
useragent TEXT,
old_ctr INT NOT NULL DEFAULT 0,
new_ctr INT NOT NULL DEFAULT 0,
amount INT NOT NULL,
time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now
+ """
);
"""
)
await db.execute(
"""
CREATE TABLE boltcards.refunds (
id TEXT PRIMARY KEY UNIQUE,
hit_id TEXT NOT NULL,
refund_amount INT NOT NULL,
time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now
+ """
);
"""
)
+83
View File
@@ -0,0 +1,83 @@
from sqlite3 import Row
from typing import Optional
from fastapi import Request
from fastapi.params import Query
from lnurl import Lnurl
from lnurl import encode as lnurl_encode # type: ignore
from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore
from lnurl.types import LnurlPayMetadata # type: ignore
from pydantic import BaseModel
from pydantic.main import BaseModel
ZERO_KEY = "00000000000000000000000000000000"
class Card(BaseModel):
id: str
wallet: str
card_name: str
uid: str
external_id: str
counter: int
tx_limit: int
daily_limit: int
enable: bool
k0: str
k1: str
k2: str
prev_k0: str
prev_k1: str
prev_k2: str
otp: str
time: int
def from_row(cls, row: Row) -> "Card":
return cls(**dict(row))
def lnurl(self, req: Request) -> Lnurl:
url = req.url_for("boltcard.lnurl_response", device_id=self.id, _external=True)
return lnurl_encode(url)
async def lnurlpay_metadata(self) -> LnurlPayMetadata:
return LnurlPayMetadata(json.dumps([["text/plain", self.title]]))
class CreateCardData(BaseModel):
card_name: str = Query(...)
uid: str = Query(...)
counter: int = Query(0)
tx_limit: int = Query(0)
daily_limit: int = Query(0)
enable: bool = Query(True)
k0: str = Query(ZERO_KEY)
k1: str = Query(ZERO_KEY)
k2: str = Query(ZERO_KEY)
prev_k0: str = Query(ZERO_KEY)
prev_k1: str = Query(ZERO_KEY)
prev_k2: str = Query(ZERO_KEY)
class Hit(BaseModel):
id: str
card_id: str
ip: str
spent: bool
useragent: str
old_ctr: int
new_ctr: int
amount: int
time: int
def from_row(cls, row: Row) -> "Hit":
return cls(**dict(row))
class Refund(BaseModel):
id: str
hit_id: str
refund_amount: int
time: int
def from_row(cls, row: Row) -> "Refund":
return cls(**dict(row))
+36
View File
@@ -0,0 +1,36 @@
# https://www.nxp.com/docs/en/application-note/AN12196.pdf
from typing import Tuple
from Cryptodome.Cipher import AES
from Cryptodome.Hash import CMAC
SV2 = "3CC300010080"
def myCMAC(key: bytes, msg: bytes = b"") -> bytes:
cobj = CMAC.new(key, ciphermod=AES)
if msg != b"":
cobj.update(msg)
return cobj.digest()
def decryptSUN(sun: bytes, key: bytes) -> Tuple[bytes, bytes]:
IVbytes = b"\x00" * 16
cipher = AES.new(key, AES.MODE_CBC, IVbytes)
sun_plain = cipher.decrypt(sun)
UID = sun_plain[1:8]
counter = sun_plain[8:11]
return UID, counter
def getSunMAC(UID: bytes, counter: bytes, key: bytes) -> bytes:
sv2prefix = bytes.fromhex(SV2)
sv2bytes = sv2prefix + UID + counter
mac1 = myCMAC(key, sv2bytes)
mac2 = myCMAC(mac1)
return mac2[1::2]
@@ -0,0 +1,435 @@
Vue.component(VueQrcode.name, VueQrcode)
const mapCards = obj => {
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
toggleAdvanced: false,
nfcTagReading: false,
lnurlLink: `${window.location.host}/boltcards/api/v1/scan/`,
cards: [],
hits: [],
refunds: [],
cardDialog: {
show: false,
data: {
counter: 1,
k0: '',
k1: '',
k2: '',
uid: '',
card_name: ''
},
temp: {}
},
cardsTable: {
columns: [
{
name: 'card_name',
align: 'left',
label: 'Card name',
field: 'card_name'
},
{
name: 'counter',
align: 'left',
label: 'Counter',
field: 'counter'
},
{
name: 'wallet',
align: 'left',
label: 'Wallet',
field: 'wallet'
},
{
name: 'tx_limit',
align: 'left',
label: 'Max tx',
field: 'tx_limit'
},
{
name: 'daily_limit',
align: 'left',
label: 'Daily tx limit',
field: 'daily_limit'
}
],
pagination: {
rowsPerPage: 10
}
},
refundsTable: {
columns: [
{
name: 'hit_id',
align: 'left',
label: 'Hit ID',
field: 'hit_id'
},
{
name: 'refund_amount',
align: 'left',
label: 'Refund Amount',
field: 'refund_amount'
},
{
name: 'date',
align: 'left',
label: 'Time',
field: 'date'
}
],
pagination: {
rowsPerPage: 10,
sortBy: 'date',
descending: true
}
},
hitsTable: {
columns: [
{
name: 'card_name',
align: 'left',
label: 'Card name',
field: 'card_name'
},
{
name: 'amount',
align: 'left',
label: 'Amount',
field: 'amount'
},
{
name: 'old_ctr',
align: 'left',
label: 'Old counter',
field: 'old_ctr'
},
{
name: 'new_ctr',
align: 'left',
label: 'New counter',
field: 'new_ctr'
},
{
name: 'date',
align: 'left',
label: 'Time',
field: 'date'
},
{
name: 'ip',
align: 'left',
label: 'IP',
field: 'ip'
},
{
name: 'useragent',
align: 'left',
label: 'User agent',
field: 'useragent'
}
],
pagination: {
rowsPerPage: 10,
sortBy: 'date',
descending: true
}
},
qrCodeDialog: {
show: false,
data: null
}
}
},
methods: {
readNfcTag: function () {
try {
const self = this
if (typeof NDEFReader == 'undefined') {
throw {
toString: function () {
return 'NFC not supported on this device or browser.'
}
}
}
const ndef = new NDEFReader()
const readerAbortController = new AbortController()
readerAbortController.signal.onabort = event => {
console.log('All NFC Read operations have been aborted.')
}
this.nfcTagReading = true
this.$q.notify({
message: 'Tap your NFC tag to copy its UID here.'
})
return ndef.scan({signal: readerAbortController.signal}).then(() => {
ndef.onreadingerror = () => {
self.nfcTagReading = false
this.$q.notify({
type: 'negative',
message: 'There was an error reading this NFC tag.'
})
readerAbortController.abort()
}
ndef.onreading = ({message, serialNumber}) => {
//Decode NDEF data from tag
var self = this
self.cardDialog.data.uid = serialNumber
.toUpperCase()
.replaceAll(':', '')
this.$q.notify({
type: 'positive',
message: 'NFC tag read successfully.'
})
}
})
} catch (error) {
this.nfcTagReading = false
this.$q.notify({
type: 'negative',
message: error
? error.toString()
: 'An unexpected error has occurred.'
})
}
},
getCards: function () {
var self = this
LNbits.api
.request(
'GET',
'/boltcards/api/v1/cards?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.cards = response.data.map(function (obj) {
return mapCards(obj)
})
})
.then(function () {
self.getHits()
})
},
getHits: function () {
var self = this
LNbits.api
.request(
'GET',
'/boltcards/api/v1/hits?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.hits = response.data.map(function (obj) {
obj.card_name = self.cards.find(d => d.id == obj.card_id).card_name
return mapCards(obj)
})
})
},
getRefunds: function () {
var self = this
LNbits.api
.request(
'GET',
'/boltcards/api/v1/refunds?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.refunds = response.data.map(function (obj) {
return mapCards(obj)
})
})
},
openQrCodeDialog(cardId) {
var card = _.findWhere(this.cards, {id: cardId})
this.qrCodeDialog.data = {
link: window.location.origin + '/boltcards/api/v1/auth?a=' + card.otp,
name: card.card_name,
uid: card.uid,
external_id: card.external_id,
k0: card.k0,
k1: card.k1,
k2: card.k2,
k3: card.k1,
k4: card.k2
}
this.qrCodeDialog.show = true
},
addCardOpen: function () {
this.cardDialog.show = true
this.generateKeys()
},
generateKeys: function () {
var self = this
const genRanHex = size =>
[...Array(size)]
.map(() => Math.floor(Math.random() * 16).toString(16))
.join('')
debugcard =
typeof this.cardDialog.data.card_name === 'string' &&
this.cardDialog.data.card_name.search('debug') > -1
self.cardDialog.data.k0 = debugcard
? '11111111111111111111111111111111'
: genRanHex(32)
self.cardDialog.data.k1 = debugcard
? '22222222222222222222222222222222'
: genRanHex(32)
self.cardDialog.data.k2 = debugcard
? '33333333333333333333333333333333'
: genRanHex(32)
},
closeFormDialog: function () {
this.cardDialog.data = {}
},
sendFormData: function () {
let wallet = _.findWhere(this.g.user.wallets, {
id: this.cardDialog.data.wallet
})
let data = this.cardDialog.data
if (data.id) {
this.updateCard(wallet, data)
} else {
this.createCard(wallet, data)
}
},
createCard: function (wallet, data) {
var self = this
LNbits.api
.request('POST', '/boltcards/api/v1/cards', wallet.adminkey, data)
.then(function (response) {
self.cards.push(mapCards(response.data))
self.cardDialog.show = false
self.cardDialog.data = {}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
updateCardDialog: function (formId) {
var card = _.findWhere(this.cards, {id: formId})
this.cardDialog.data = _.clone(card)
this.cardDialog.temp.k0 = this.cardDialog.data.k0
this.cardDialog.temp.k1 = this.cardDialog.data.k1
this.cardDialog.temp.k2 = this.cardDialog.data.k2
this.cardDialog.show = true
},
updateCard: function (wallet, data) {
var self = this
if (
this.cardDialog.temp.k0 != data.k0 ||
this.cardDialog.temp.k1 != data.k1 ||
this.cardDialog.temp.k2 != data.k2
) {
data.prev_k0 = this.cardDialog.temp.k0
data.prev_k1 = this.cardDialog.temp.k1
data.prev_k2 = this.cardDialog.temp.k2
}
LNbits.api
.request(
'PUT',
'/boltcards/api/v1/cards/' + data.id,
wallet.adminkey,
data
)
.then(function (response) {
self.cards = _.reject(self.cards, function (obj) {
return obj.id == data.id
})
self.cards.push(mapCards(response.data))
self.cardDialog.show = false
self.cardDialog.data = {}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
enableCard: function (wallet, card_id, enable) {
var self = this
let fullWallet = _.findWhere(self.g.user.wallets, {
id: wallet
})
LNbits.api
.request(
'GET',
'/boltcards/api/v1/cards/enable/' + card_id + '/' + enable,
fullWallet.adminkey
)
.then(function (response) {
console.log(response.data)
self.cards = _.reject(self.cards, function (obj) {
return obj.id == response.data.id
})
self.cards.push(mapCards(response.data))
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteCard: function (cardId) {
let self = this
let cards = _.findWhere(this.cards, {id: cardId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this card')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/boltcards/api/v1/cards/' + cardId,
_.findWhere(self.g.user.wallets, {id: cards.wallet}).adminkey
)
.then(function (response) {
self.cards = _.reject(self.cards, function (obj) {
return obj.id == cardId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportCardsCSV: function () {
LNbits.utils.exportCSV(this.cardsTable.columns, this.cards)
},
exportHitsCSV: function () {
LNbits.utils.exportCSV(this.hitsTable.columns, this.hits)
},
exportRefundsCSV: function () {
LNbits.utils.exportCSV(this.refundsTable.columns, this.refunds)
}
},
created: function () {
if (this.g.user.wallets.length) {
this.getCards()
this.getRefunds()
}
}
})
+47
View File
@@ -0,0 +1,47 @@
import asyncio
import json
import httpx
from lnbits.core import db as core_db
from lnbits.core.models import Payment
from lnbits.tasks import register_invoice_listener
from .crud import create_refund, get_hit
async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue)
while True:
payment = await invoice_queue.get()
await on_invoice_paid(payment)
async def on_invoice_paid(payment: Payment) -> None:
if not payment.extra.get("refund"):
return
if payment.extra.get("wh_status"):
# this webhook has already been sent
return
hit = await get_hit(payment.extra.get("refund"))
if hit:
refund = await create_refund(
hit_id=hit.id, refund_amount=(payment.amount / 1000)
)
await mark_webhook_sent(payment, 1)
async def mark_webhook_sent(payment: Payment, status: int) -> None:
payment.extra["wh_status"] = status
await core_db.execute(
"""
UPDATE apipayments SET extra = ?
WHERE hash = ?
""",
(json.dumps(payment.extra), payment.payment_hash),
)
@@ -0,0 +1,21 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="About Bolt Cards"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<h5 class="text-subtitle1 q-my-none">Be your own card association</h5>
<p>
Manage your Bolt Cards self custodian way<br />
<a
href="https://github.com/lnbits/lnbits/tree/main/lnbits/extensions/boltcards"
>More details</a
>
<br />
</p>
</q-card-section>
</q-card>
</q-expansion-item>
@@ -0,0 +1,405 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<div class="row justify-start" style="width: 150px">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Cards</h5>
</div>
<div class="col">
<q-btn
round
size="sm"
icon="add"
unelevated
color="primary"
@click="addCardOpen"
>
<q-tooltip>Add card</q-tooltip>
</q-btn>
</div>
</div>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportCardsCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
dense
flat
:data="cards"
row-key="id"
:columns="cardsTable.columns"
:pagination.sync="cardsTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
icon="qr_code"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="openQrCodeDialog(props.row.id)"
>
<q-tooltip>Card key credentials</q-tooltip>
</q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
<q-td auto-width>
<q-btn
v-if="props.row.enable"
dense
@click="enableCard(props.row.wallet, props.row.id, false)"
color="pink"
>DISABLE</q-btn
>
<q-btn
v-else
dense
@click="enableCard(props.row.wallet, props.row.id, true)"
color="green"
>ENABLE
</q-btn>
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="updateCardDialog(props.row.id)"
icon="edit"
color="light-blue"
>
<q-tooltip>Edit card</q-tooltip>
</q-btn>
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deleteCard(props.row.id)"
icon="cancel"
color="pink"
>
<q-tooltip
>Deleting card will also delete all records</q-tooltip
>
</q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Hits</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportCardsCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
dense
flat
:data="hits"
row-key="id"
:columns="hitsTable.columns"
:pagination.sync="hitsTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Refunds</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportRefundsCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
dense
flat
:data="refunds"
row-key="id"
:columns="refundsTable.columns"
:pagination.sync="refundsTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} Bolt Cards extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "boltcards/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="cardDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormData" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="cardDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
>
</q-select>
<div class="row">
<div class="col">
<q-input
filled
dense
emit-value
v-model.trim="cardDialog.data.tx_limit"
type="number"
label="Max transaction (sats)"
class="q-pr-sm"
></q-input>
</div>
<div class="col">
<q-input
filled
dense
emit-value
v-model.trim="cardDialog.data.daily_limit"
type="number"
label="Daily limit (sats)"
></q-input>
</div>
</div>
<q-input
filled
dense
emit-value
v-model.trim="cardDialog.data.card_name"
type="text"
label="Card name "
>
</q-input>
<div class="row">
<div class="col-10">
<q-input
filled
dense
emit-value
v-model.trim="cardDialog.data.uid"
type="text"
label="Card UID "
>
<q-tooltip
>Get from the card you'll use, using an NFC app</q-tooltip
>
</q-input>
</div>
<div class="col-2 q-pl-sm">
<q-btn
outline
disable
color="grey"
icon="nfc"
:disable="nfcTagReading"
@click="readNfcTag()"
>
<q-tooltip>Tap card to scan UID</q-tooltip>
</q-btn>
</div>
</div>
<q-toggle
v-model="toggleAdvanced"
label="Show advanced options"
></q-toggle>
<div v-show="toggleAdvanced">
<q-input
filled
dense
v-model.trim="cardDialog.data.k0"
type="text"
label="Card Auth key (K0)"
hint="Used to authentificate with the card (16 bytes in HEX). "
@randomkey
>
</q-input>
<q-input
filled
dense
v-model.trim="cardDialog.data.k1"
type="text"
label="Card Meta key (K1)"
hint="Used for encypting of the message (16 bytes in HEX)."
></q-input>
<q-input
filled
dense
v-model.trim="cardDialog.data.k2"
type="text"
label="Card File key (K2)"
hint="Used for CMAC of the message (16 bytes in HEX)."
>
</q-input>
<q-input
filled
dense
v-model.number="cardDialog.data.counter"
type="number"
label="Initial counter"
>
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left"
>Zero if you don't know.</q-tooltip
>
</q-input>
<q-btn
unelevated
color="primary"
class="q-ml-auto"
v-on:click="generateKeys"
v-on:click.right="debugKeys"
>Generate keys</q-btn
>
</div>
<div class="row q-mt-lg">
<q-btn
v-if="cardDialog.data.id"
unelevated
color="primary"
type="submit"
>Update Card</q-btn
>
<q-btn
v-else
unelevated
color="primary"
:disable="cardDialog.data.uid == null"
type="submit"
>Create Card
</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="qrCodeDialog.show" position="top">
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
{% raw %}
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode
:value="qrCodeDialog.data.link"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
<p style="word-break: break-all" class="text-center">
(Keys for
<a
href="https://play.google.com/store/apps/details?id=com.lightningnfcapp"
target="_blank"
>bolt-nfc-android-app</a
>)
</p>
<p style="word-break: break-all">
<strong>Name:</strong> {{ qrCodeDialog.data.name }}<br />
<strong>UID:</strong> {{ qrCodeDialog.data.uid }}<br />
<strong>External ID:</strong> {{ qrCodeDialog.data.external_id }}<br />
<strong>Lock key:</strong> {{ qrCodeDialog.data.k0 }}<br />
<strong>Meta key:</strong> {{ qrCodeDialog.data.k1 }}<br />
<strong>File key:</strong> {{ qrCodeDialog.data.k2 }}<br />
</p>
<br />
<q-btn
unelevated
outline
color="grey"
@click="copyText(qrCodeDialog.data.link)"
label="Keys/Auth link"
>
</q-btn>
<q-tooltip>Click to copy, then add to NFC card</q-tooltip>
{% endraw %}
<div class="row q-mt-lg q-gutter-sm">
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="/boltcards/static/js/index.js"></script>
{% endblock %}
+18
View File
@@ -0,0 +1,18 @@
from fastapi import FastAPI, Request
from fastapi.params import Depends
from fastapi.templating import Jinja2Templates
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import boltcards_ext, boltcards_renderer
templates = Jinja2Templates(directory="templates")
@boltcards_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return boltcards_renderer().TemplateResponse(
"boltcards/index.html", {"request": request, "user": user.dict()}
)
+170
View File
@@ -0,0 +1,170 @@
import secrets
from http import HTTPStatus
from fastapi.params import Depends, Query
from loguru import logger
from starlette.exceptions import HTTPException
from starlette.requests import Request
from lnbits.core.crud import get_user
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from . import boltcards_ext
from .crud import (
create_card,
create_hit,
delete_card,
enable_disable_card,
get_card,
get_card_by_otp,
get_card_by_uid,
get_cards,
get_hits,
get_refunds,
update_card,
update_card_counter,
update_card_otp,
)
from .models import CreateCardData
from .nxp424 import decryptSUN, getSunMAC
@boltcards_ext.get("/api/v1/cards")
async def api_cards(
g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)
):
wallet_ids = [g.wallet.id]
if all_wallets:
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
return [card.dict() for card in await get_cards(wallet_ids)]
@boltcards_ext.post("/api/v1/cards", status_code=HTTPStatus.CREATED)
@boltcards_ext.put("/api/v1/cards/{card_id}", status_code=HTTPStatus.OK)
async def api_card_create_or_update(
# req: Request,
data: CreateCardData,
card_id: str = None,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
try:
if len(bytes.fromhex(data.uid)) != 7:
raise HTTPException(
detail="Invalid bytes for card uid.", status_code=HTTPStatus.BAD_REQUEST
)
if len(bytes.fromhex(data.k0)) != 16:
raise HTTPException(
detail="Invalid bytes for k0.", status_code=HTTPStatus.BAD_REQUEST
)
if len(bytes.fromhex(data.k1)) != 16:
raise HTTPException(
detail="Invalid bytes for k1.", status_code=HTTPStatus.BAD_REQUEST
)
if len(bytes.fromhex(data.k2)) != 16:
raise HTTPException(
detail="Invalid bytes for k2.", status_code=HTTPStatus.BAD_REQUEST
)
except:
raise HTTPException(
detail="Invalid byte data provided.", status_code=HTTPStatus.BAD_REQUEST
)
if card_id:
card = await get_card(card_id)
if not card:
raise HTTPException(
detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND
)
if card.wallet != wallet.wallet.id:
raise HTTPException(
detail="Not your card.", status_code=HTTPStatus.FORBIDDEN
)
checkUid = await get_card_by_uid(data.uid)
if checkUid and checkUid.id != card_id:
raise HTTPException(
detail="UID already registered. Delete registered card and try again.",
status_code=HTTPStatus.BAD_REQUEST,
)
card = await update_card(card_id, **data.dict())
else:
checkUid = await get_card_by_uid(data.uid)
if checkUid:
raise HTTPException(
detail="UID already registered. Delete registered card and try again.",
status_code=HTTPStatus.BAD_REQUEST,
)
card = await create_card(wallet_id=wallet.wallet.id, data=data)
return card.dict()
@boltcards_ext.get("/api/v1/cards/enable/{card_id}/{enable}", status_code=HTTPStatus.OK)
async def enable_card(
card_id,
enable,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
card = await get_card(card_id)
if not card:
raise HTTPException(detail="No card found.", status_code=HTTPStatus.NOT_FOUND)
if card.wallet != wallet.wallet.id:
raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN)
card = await enable_disable_card(enable=enable, id=card_id)
return card.dict()
@boltcards_ext.delete("/api/v1/cards/{card_id}")
async def api_card_delete(card_id, wallet: WalletTypeInfo = Depends(require_admin_key)):
card = await get_card(card_id)
if not card:
raise HTTPException(
detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND
)
if card.wallet != wallet.wallet.id:
raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN)
await delete_card(card_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
@boltcards_ext.get("/api/v1/hits")
async def api_hits(
g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)
):
wallet_ids = [g.wallet.id]
if all_wallets:
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
cards = await get_cards(wallet_ids)
cards_ids = []
for card in cards:
cards_ids.append(card.id)
return [hit.dict() for hit in await get_hits(cards_ids)]
@boltcards_ext.get("/api/v1/refunds")
async def api_hits(
g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)
):
wallet_ids = [g.wallet.id]
if all_wallets:
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
cards = await get_cards(wallet_ids)
cards_ids = []
for card in cards:
cards_ids.append(card.id)
hits = await get_hits(cards_ids)
hits_ids = []
for hit in hits:
hits_ids.append(hit.id)
return [refund.dict() for refund in await get_refunds(hits_ids)]
+40
View File
@@ -0,0 +1,40 @@
# Swap on [Boltz](https://boltz.exchange)
providing **trustless** and **account-free** swap services since **2018.**
move **IN** and **OUT** of the **lightning network** and remain in control of your bitcoin, at all times.
* [Lightning Node](https://amboss.space/node/026165850492521f4ac8abd9bd8088123446d126f648ca35e60f88177dc149ceb2)
* [Documentation](https://docs.boltz.exchange/en/latest/)
* [Discord](https://discord.gg/d6EK85KK)
* [Twitter](https://twitter.com/Boltzhq)
# usage
This extension lets you create swaps, reverse swaps and in the case of failure refund your onchain funds.
## create normal swap
1. click on "Swap (IN)" button to open following dialog, select a wallet, choose a proper amount in the min-max range and choose a onchain address to do your refund to if the swap fails after you already commited onchain funds.
---
![create swap](https://imgur.com/OyOh3Nm.png)
---
2. after you confirm your inputs, following dialog with the QR code for the onchain transaction, onchain- address and amount, will pop up.
---
![pay onchain tx](https://imgur.com/r2UhwCY.png)
---
3. after you pay this onchain address with the correct amount, boltz will see it and will pay your invoice and the sats will appear on your wallet.
if anything goes wrong when boltz is trying to pay your invoice, the swap will fail and you will need to refund your onchain funds after the timeout block height hit. (if boltz can pay the invoice, it wont be able to redeem your onchain funds either).
## create reverse swap
1. click on "Swap (OUT)" button to open following dialog, select a wallet, choose a proper amount in the min-max range and choose a onchain address to receive your funds to. Instant settlement: means that LNbits will create the onchain claim transaction if it sees the boltz lockup transaction in the mempool, but it is not confirmed yet. it is advised to leave this checked because it is faster and the longer is takes to settle, the higher the chances are that the lightning invoice expires and the swap fails.
---
![reverse swap](https://imgur.com/UEAPpbs.png)
---
if this swap fails, boltz is doing the onchain refunding, because they have to commit onchain funds.
# refund locked onchain funds from a normal swap
if for some reason the normal swap fails and you already paid onchain, you can easily refund your btc.
this can happen if boltz is not able to pay your lightning invoice after you locked up your funds.
in case that happens, there is a info icon in the Swap (In) List which opens following dialog.
---
![refund](https://imgur.com/pN81ltf.png)
----
if the timeout block height is exceeded you can either press refund and lnbits will do the refunding to the address you specified when creating the swap. Or download the refundfile so you can manually refund your onchain directly on the boltz.exchange website.
if you think there is something wrong and/or you are unsure, you can ask for help either in LNbits telegram or in Boltz [Discord](https://discord.gg/d6EK85KK)
+26
View File
@@ -0,0 +1,26 @@
import asyncio
from fastapi import APIRouter
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_boltz")
boltz_ext: APIRouter = APIRouter(prefix="/boltz", tags=["boltz"])
def boltz_renderer():
return template_renderer(["lnbits/extensions/boltz/templates"])
from .tasks import check_for_pending_swaps, wait_for_paid_invoices
from .views import * # noqa
from .views_api import * # noqa
def boltz_start():
loop = asyncio.get_event_loop()
loop.create_task(check_for_pending_swaps())
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
+424
View File
@@ -0,0 +1,424 @@
import asyncio
import os
from binascii import hexlify, unhexlify
from hashlib import sha256
from typing import Awaitable, Union
import httpx
from embit import ec, script
from embit.networks import NETWORKS
from embit.transaction import SIGHASH, Transaction, TransactionInput, TransactionOutput
from loguru import logger
from lnbits.core.services import create_invoice, pay_invoice
from lnbits.helpers import urlsafe_short_hash
from lnbits.settings import BOLTZ_NETWORK, BOLTZ_URL
from .crud import update_swap_status
from .mempool import (
get_fee_estimation,
get_mempool_blockheight,
get_mempool_fees,
get_mempool_tx,
get_mempool_tx_from_txs,
send_onchain_tx,
wait_for_websocket_message,
)
from .models import (
CreateReverseSubmarineSwap,
CreateSubmarineSwap,
ReverseSubmarineSwap,
SubmarineSwap,
SwapStatus,
)
from .utils import check_balance, get_timestamp, req_wrap
net = NETWORKS[BOLTZ_NETWORK]
logger.debug(f"BOLTZ_URL: {BOLTZ_URL}")
logger.debug(f"Bitcoin Network: {net['name']}")
async def create_swap(data: CreateSubmarineSwap) -> SubmarineSwap:
if not check_boltz_limits(data.amount):
msg = f"Boltz - swap not in boltz limits"
logger.warning(msg)
raise Exception(msg)
swap_id = urlsafe_short_hash()
try:
payment_hash, payment_request = await create_invoice(
wallet_id=data.wallet,
amount=data.amount,
memo=f"swap of {data.amount} sats on boltz.exchange",
extra={"tag": "boltz", "swap_id": swap_id},
)
except Exception as exc:
msg = f"Boltz - create_invoice failed {str(exc)}"
logger.error(msg)
raise
refund_privkey = ec.PrivateKey(os.urandom(32), True, net)
refund_pubkey_hex = hexlify(refund_privkey.sec()).decode("UTF-8")
res = req_wrap(
"post",
f"{BOLTZ_URL}/createswap",
json={
"type": "submarine",
"pairId": "BTC/BTC",
"orderSide": "sell",
"refundPublicKey": refund_pubkey_hex,
"invoice": payment_request,
"referralId": "lnbits",
},
headers={"Content-Type": "application/json"},
)
res = res.json()
logger.info(
f"Boltz - created normal swap, boltz_id: {res['id']}. wallet: {data.wallet}"
)
return SubmarineSwap(
id=swap_id,
time=get_timestamp(),
wallet=data.wallet,
amount=data.amount,
payment_hash=payment_hash,
refund_privkey=refund_privkey.wif(net),
refund_address=data.refund_address,
boltz_id=res["id"],
status="pending",
address=res["address"],
expected_amount=res["expectedAmount"],
timeout_block_height=res["timeoutBlockHeight"],
bip21=res["bip21"],
redeem_script=res["redeemScript"],
)
"""
explanation taken from electrum
send on Lightning, receive on-chain
- User generates preimage, RHASH. Sends RHASH to server.
- Server creates an LN invoice for RHASH.
- User pays LN invoice - except server needs to hold the HTLC as preimage is unknown.
- Server creates on-chain output locked to RHASH.
- User spends on-chain output, revealing preimage.
- Server fulfills HTLC using preimage.
Note: expected_onchain_amount_sat is BEFORE deducting the on-chain claim tx fee.
"""
async def create_reverse_swap(
data: CreateReverseSubmarineSwap,
) -> [ReverseSubmarineSwap, asyncio.Task]:
if not check_boltz_limits(data.amount):
msg = f"Boltz - reverse swap not in boltz limits"
logger.warning(msg)
raise Exception(msg)
swap_id = urlsafe_short_hash()
if not await check_balance(data):
logger.error(f"Boltz - reverse swap, insufficient balance.")
return False
claim_privkey = ec.PrivateKey(os.urandom(32), True, net)
claim_pubkey_hex = hexlify(claim_privkey.sec()).decode("UTF-8")
preimage = os.urandom(32)
preimage_hash = sha256(preimage).hexdigest()
res = req_wrap(
"post",
f"{BOLTZ_URL}/createswap",
json={
"type": "reversesubmarine",
"pairId": "BTC/BTC",
"orderSide": "buy",
"invoiceAmount": data.amount,
"preimageHash": preimage_hash,
"claimPublicKey": claim_pubkey_hex,
"referralId": "lnbits",
},
headers={"Content-Type": "application/json"},
)
res = res.json()
logger.info(
f"Boltz - created reverse swap, boltz_id: {res['id']}. wallet: {data.wallet}"
)
swap = ReverseSubmarineSwap(
id=swap_id,
amount=data.amount,
wallet=data.wallet,
onchain_address=data.onchain_address,
instant_settlement=data.instant_settlement,
claim_privkey=claim_privkey.wif(net),
preimage=preimage.hex(),
status="pending",
boltz_id=res["id"],
timeout_block_height=res["timeoutBlockHeight"],
lockup_address=res["lockupAddress"],
onchain_amount=res["onchainAmount"],
redeem_script=res["redeemScript"],
invoice=res["invoice"],
time=get_timestamp(),
)
logger.debug(f"Boltz - waiting for onchain tx, reverse swap_id: {swap.id}")
task = create_task_log_exception(
swap.id, wait_for_onchain_tx(swap, swap_websocket_callback_initial)
)
return swap, task
def start_onchain_listener(swap: ReverseSubmarineSwap) -> asyncio.Task:
return create_task_log_exception(
swap.id, wait_for_onchain_tx(swap, swap_websocket_callback_restart)
)
async def start_confirmation_listener(
swap: ReverseSubmarineSwap, mempool_lockup_tx
) -> asyncio.Task:
logger.debug(f"Boltz - reverse swap, waiting for confirmation...")
tx, txid, *_ = mempool_lockup_tx
confirmed = await wait_for_websocket_message({"track-tx": txid}, "txConfirmed")
if confirmed:
logger.debug(f"Boltz - reverse swap lockup transaction confirmed! claiming...")
await create_claim_tx(swap, mempool_lockup_tx)
else:
logger.debug(f"Boltz - reverse swap lockup transaction still not confirmed.")
def create_task_log_exception(swap_id: str, awaitable: Awaitable) -> asyncio.Task:
async def _log_exception(awaitable):
try:
return await awaitable
except Exception as e:
logger.error(f"Boltz - reverse swap failed!: {swap_id} - {e}")
await update_swap_status(swap_id, "failed")
return asyncio.create_task(_log_exception(awaitable))
async def swap_websocket_callback_initial(swap):
wstask = asyncio.create_task(
wait_for_websocket_message(
{"track-address": swap.lockup_address}, "address-transactions"
)
)
logger.debug(
f"Boltz - created task, waiting on mempool websocket for address: {swap.lockup_address}"
)
# create_task is used because pay_invoice is stuck as long as boltz does not
# see the onchain claim tx and it ends up in deadlock
task: asyncio.Task = create_task_log_exception(
swap.id,
pay_invoice(
wallet_id=swap.wallet,
payment_request=swap.invoice,
description=f"reverse swap for {swap.amount} sats on boltz.exchange",
extra={"tag": "boltz", "swap_id": swap.id, "reverse": True},
),
)
logger.debug(f"Boltz - task pay_invoice created, reverse swap_id: {swap.id}")
done, pending = await asyncio.wait(
[task, wstask], return_when=asyncio.FIRST_COMPLETED
)
message = done.pop().result()
# pay_invoice already failed, do not wait for onchain tx anymore
if message is None:
logger.debug(f"Boltz - pay_invoice already failed cancel websocket task.")
wstask.cancel()
raise
return task, message
async def swap_websocket_callback_restart(swap):
logger.debug(f"Boltz - swap_websocket_callback_restart called...")
message = await wait_for_websocket_message(
{"track-address": swap.lockup_address}, "address-transactions"
)
return None, message
async def wait_for_onchain_tx(swap: ReverseSubmarineSwap, callback):
task, txs = await callback(swap)
mempool_lockup_tx = get_mempool_tx_from_txs(txs, swap.lockup_address)
if mempool_lockup_tx:
tx, txid, *_ = mempool_lockup_tx
if swap.instant_settlement or tx["status"]["confirmed"]:
logger.debug(
f"Boltz - reverse swap instant settlement, claiming immediatly..."
)
await create_claim_tx(swap, mempool_lockup_tx)
else:
await start_confirmation_listener(swap, mempool_lockup_tx)
try:
if task:
await task
except:
logger.error(
f"Boltz - could not await pay_invoice task, but sent onchain. should never happen!"
)
else:
logger.error(f"Boltz - mempool lockup tx not found.")
async def create_claim_tx(swap: ReverseSubmarineSwap, mempool_lockup_tx):
tx = await create_onchain_tx(swap, mempool_lockup_tx)
await send_onchain_tx(tx)
logger.debug(f"Boltz - onchain tx sent, reverse swap completed")
await update_swap_status(swap.id, "complete")
async def create_refund_tx(swap: SubmarineSwap):
mempool_lockup_tx = get_mempool_tx(swap.address)
tx = await create_onchain_tx(swap, mempool_lockup_tx)
await send_onchain_tx(tx)
def check_block_height(block_height: int):
current_block_height = get_mempool_blockheight()
if current_block_height <= block_height:
msg = f"refund not possible, timeout_block_height ({block_height}) is not yet exceeded ({current_block_height})"
logger.debug(msg)
raise Exception(msg)
"""
a submarine swap consists of 2 onchain tx's a lockup and a redeem tx.
we create a tx to redeem the funds locked by the onchain lockup tx.
claim tx for reverse swaps, refund tx for normal swaps they are the same
onchain redeem tx, the difference between them is the private key, onchain_address,
input sequence and input script_sig
"""
async def create_onchain_tx(
swap: Union[ReverseSubmarineSwap, SubmarineSwap], mempool_lockup_tx
) -> Transaction:
is_refund_tx = type(swap) == SubmarineSwap
if is_refund_tx:
check_block_height(swap.timeout_block_height)
privkey = ec.PrivateKey.from_wif(swap.refund_privkey)
onchain_address = swap.refund_address
preimage = b""
sequence = 0xFFFFFFFE
else:
privkey = ec.PrivateKey.from_wif(swap.claim_privkey)
preimage = unhexlify(swap.preimage)
onchain_address = swap.onchain_address
sequence = 0xFFFFFFFF
locktime = swap.timeout_block_height
redeem_script = unhexlify(swap.redeem_script)
fees = get_fee_estimation()
tx, txid, vout_cnt, vout_amount = mempool_lockup_tx
script_pubkey = script.address_to_scriptpubkey(onchain_address)
vin = [TransactionInput(unhexlify(txid), vout_cnt, sequence=sequence)]
vout = [TransactionOutput(vout_amount - fees, script_pubkey)]
tx = Transaction(vin=vin, vout=vout)
if is_refund_tx:
tx.locktime = locktime
# TODO: 2 rounds for fee calculation, look at vbytes after signing and do another TX
s = script.Script(data=redeem_script)
for i, inp in enumerate(vin):
if is_refund_tx:
rs = bytes([34]) + bytes([0]) + bytes([32]) + sha256(redeem_script).digest()
tx.vin[i].script_sig = script.Script(data=rs)
h = tx.sighash_segwit(i, s, vout_amount)
sig = privkey.sign(h).serialize() + bytes([SIGHASH.ALL])
witness_items = [sig, preimage, redeem_script]
tx.vin[i].witness = script.Witness(items=witness_items)
return tx
def get_swap_status(swap: Union[SubmarineSwap, ReverseSubmarineSwap]) -> SwapStatus:
swap_status = SwapStatus(
wallet=swap.wallet,
swap_id=swap.id,
)
try:
boltz_request = get_boltz_status(swap.boltz_id)
swap_status.boltz = boltz_request["status"]
except httpx.HTTPStatusError as exc:
json = exc.response.json()
swap_status.boltz = json["error"]
if "could not find" in swap_status.boltz:
swap_status.exists = False
if type(swap) == SubmarineSwap:
swap_status.reverse = False
swap_status.address = swap.address
else:
swap_status.reverse = True
swap_status.address = swap.lockup_address
swap_status.block_height = get_mempool_blockheight()
swap_status.timeout_block_height = (
f"{str(swap.timeout_block_height)} -> current: {str(swap_status.block_height)}"
)
if swap_status.block_height >= swap.timeout_block_height:
swap_status.hit_timeout = True
mempool_tx = get_mempool_tx(swap_status.address)
swap_status.lockup = mempool_tx
if mempool_tx == None:
swap_status.has_lockup = False
swap_status.confirmed = False
swap_status.mempool = "transaction.unknown"
swap_status.message = "lockup tx not in mempool"
else:
swap_status.has_lockup = True
tx, *_ = mempool_tx
if tx["status"]["confirmed"] == True:
swap_status.mempool = "transaction.confirmed"
swap_status.confirmed = True
else:
swap_status.confirmed = False
swap_status.mempool = "transaction.unconfirmed"
return swap_status
def check_boltz_limits(amount):
try:
pairs = get_boltz_pairs()
limits = pairs["pairs"]["BTC/BTC"]["limits"]
return amount >= limits["minimal"] and amount <= limits["maximal"]
except:
return False
def get_boltz_pairs():
res = req_wrap(
"get",
f"{BOLTZ_URL}/getpairs",
headers={"Content-Type": "application/json"},
)
return res.json()
def get_boltz_status(boltzid):
res = req_wrap(
"post",
f"{BOLTZ_URL}/swapstatus",
json={"id": boltzid},
)
return res.json()
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Boltz",
"short_description": "Perform onchain/offchain swaps",
"icon": "swap_horiz",
"contributors": ["dni"]
}
+225
View File
@@ -0,0 +1,225 @@
from http import HTTPStatus
from typing import List, Optional, Union
from loguru import logger
from starlette.exceptions import HTTPException
from . import db
from .models import (
CreateReverseSubmarineSwap,
CreateSubmarineSwap,
ReverseSubmarineSwap,
SubmarineSwap,
)
"""
Submarine Swaps
"""
async def get_submarine_swaps(wallet_ids: Union[str, List[str]]) -> List[SubmarineSwap]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM boltz.submarineswap WHERE wallet IN ({q}) order by time DESC",
(*wallet_ids,),
)
return [SubmarineSwap(**row) for row in rows]
async def get_pending_submarine_swaps(
wallet_ids: Union[str, List[str]]
) -> List[SubmarineSwap]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM boltz.submarineswap WHERE wallet IN ({q}) and status='pending' order by time DESC",
(*wallet_ids,),
)
return [SubmarineSwap(**row) for row in rows]
async def get_all_pending_submarine_swaps() -> List[SubmarineSwap]:
rows = await db.fetchall(
f"SELECT * FROM boltz.submarineswap WHERE status='pending' order by time DESC",
)
return [SubmarineSwap(**row) for row in rows]
async def get_submarine_swap(swap_id) -> SubmarineSwap:
row = await db.fetchone(
"SELECT * FROM boltz.submarineswap WHERE id = ?", (swap_id,)
)
return SubmarineSwap(**row) if row else None
async def create_submarine_swap(swap: SubmarineSwap) -> Optional[SubmarineSwap]:
await db.execute(
"""
INSERT INTO boltz.submarineswap (
id,
wallet,
payment_hash,
status,
boltz_id,
refund_privkey,
refund_address,
expected_amount,
timeout_block_height,
address,
bip21,
redeem_script,
amount
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
swap.id,
swap.wallet,
swap.payment_hash,
swap.status,
swap.boltz_id,
swap.refund_privkey,
swap.refund_address,
swap.expected_amount,
swap.timeout_block_height,
swap.address,
swap.bip21,
swap.redeem_script,
swap.amount,
),
)
return await get_submarine_swap(swap.id)
async def delete_submarine_swap(swap_id):
await db.execute("DELETE FROM boltz.submarineswap WHERE id = ?", (swap_id,))
async def get_reverse_submarine_swaps(
wallet_ids: Union[str, List[str]]
) -> List[ReverseSubmarineSwap]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM boltz.reverse_submarineswap WHERE wallet IN ({q}) order by time DESC",
(*wallet_ids,),
)
return [ReverseSubmarineSwap(**row) for row in rows]
async def get_pending_reverse_submarine_swaps(
wallet_ids: Union[str, List[str]]
) -> List[ReverseSubmarineSwap]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM boltz.reverse_submarineswap WHERE wallet IN ({q}) and status='pending' order by time DESC",
(*wallet_ids,),
)
return [ReverseSubmarineSwap(**row) for row in rows]
async def get_all_pending_reverse_submarine_swaps() -> List[ReverseSubmarineSwap]:
rows = await db.fetchall(
f"SELECT * FROM boltz.reverse_submarineswap WHERE status='pending' order by time DESC"
)
return [ReverseSubmarineSwap(**row) for row in rows]
async def get_reverse_submarine_swap(swap_id) -> SubmarineSwap:
row = await db.fetchone(
"SELECT * FROM boltz.reverse_submarineswap WHERE id = ?", (swap_id,)
)
return ReverseSubmarineSwap(**row) if row else None
async def create_reverse_submarine_swap(
swap: ReverseSubmarineSwap,
) -> Optional[ReverseSubmarineSwap]:
await db.execute(
"""
INSERT INTO boltz.reverse_submarineswap (
id,
wallet,
status,
boltz_id,
instant_settlement,
preimage,
claim_privkey,
lockup_address,
invoice,
onchain_amount,
onchain_address,
timeout_block_height,
redeem_script,
amount
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
swap.id,
swap.wallet,
swap.status,
swap.boltz_id,
swap.instant_settlement,
swap.preimage,
swap.claim_privkey,
swap.lockup_address,
swap.invoice,
swap.onchain_amount,
swap.onchain_address,
swap.timeout_block_height,
swap.redeem_script,
swap.amount,
),
)
return await get_reverse_submarine_swap(swap.id)
async def update_swap_status(swap_id: str, status: str):
reverse = ""
swap = await get_submarine_swap(swap_id)
if swap is None:
swap = await get_reverse_submarine_swap(swap_id)
if swap is None:
return None
if type(swap) == SubmarineSwap:
await db.execute(
"UPDATE boltz.submarineswap SET status='"
+ status
+ "' WHERE id='"
+ swap.id
+ "'"
)
if type(swap) == ReverseSubmarineSwap:
reverse = "reverse"
await db.execute(
"UPDATE boltz.reverse_submarineswap SET status='"
+ status
+ "' WHERE id='"
+ swap.id
+ "'"
)
message = f"Boltz - {reverse} swap status change: {status}. boltz_id: {swap.boltz_id}, wallet: {swap.wallet}"
logger.info(message)
return swap
+97
View File
@@ -0,0 +1,97 @@
import asyncio
import json
from binascii import hexlify
import httpx
import websockets
from embit.transaction import Transaction
from loguru import logger
from lnbits.settings import BOLTZ_MEMPOOL_SPACE_URL, BOLTZ_MEMPOOL_SPACE_URL_WS
from .utils import req_wrap
logger.debug(f"BOLTZ_MEMPOOL_SPACE_URL: {BOLTZ_MEMPOOL_SPACE_URL}")
logger.debug(f"BOLTZ_MEMPOOL_SPACE_URL_WS: {BOLTZ_MEMPOOL_SPACE_URL_WS}")
websocket_url = f"{BOLTZ_MEMPOOL_SPACE_URL_WS}/api/v1/ws"
async def wait_for_websocket_message(send, message_string):
async for websocket in websockets.connect(websocket_url):
try:
await websocket.send(json.dumps({"action": "want", "data": ["blocks"]}))
await websocket.send(json.dumps(send))
async for raw in websocket:
message = json.loads(raw)
if message_string in message:
return message.get(message_string)
except websockets.ConnectionClosed:
continue
def get_mempool_tx(address):
res = req_wrap(
"get",
f"{BOLTZ_MEMPOOL_SPACE_URL}/api/address/{address}/txs",
headers={"Content-Type": "text/plain"},
)
txs = res.json()
return get_mempool_tx_from_txs(txs, address)
def get_mempool_tx_from_txs(txs, address):
if len(txs) == 0:
return None
tx = txid = vout_cnt = vout_amount = None
for a_tx in txs:
for i, vout in enumerate(a_tx["vout"]):
if vout["scriptpubkey_address"] == address:
tx = a_tx
txid = a_tx["txid"]
vout_cnt = i
vout_amount = vout["value"]
# should never happen
if tx == None:
raise Exception("mempool tx not found")
if txid == None:
raise Exception("mempool txid not found")
return tx, txid, vout_cnt, vout_amount
def get_fee_estimation() -> int:
# TODO: hardcoded maximum tx size, in the future we try to get the size of the tx via embit
# we need a function like Transaction.vsize()
tx_size_vbyte = 200
mempool_fees = get_mempool_fees()
return mempool_fees * tx_size_vbyte
def get_mempool_fees() -> int:
res = req_wrap(
"get",
f"{BOLTZ_MEMPOOL_SPACE_URL}/api/v1/fees/recommended",
headers={"Content-Type": "text/plain"},
)
fees = res.json()
return int(fees["economyFee"])
def get_mempool_blockheight() -> int:
res = req_wrap(
"get",
f"{BOLTZ_MEMPOOL_SPACE_URL}/api/blocks/tip/height",
headers={"Content-Type": "text/plain"},
)
return int(res.text)
async def send_onchain_tx(tx: Transaction):
raw = hexlify(tx.serialize())
logger.debug(f"Boltz - mempool sending onchain tx...")
req_wrap(
"post",
f"{BOLTZ_MEMPOOL_SPACE_URL}/api/tx",
headers={"Content-Type": "text/plain"},
content=raw,
)
+46
View File
@@ -0,0 +1,46 @@
async def m001_initial(db):
await db.execute(
"""
CREATE TABLE boltz.submarineswap (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
payment_hash TEXT NOT NULL,
amount INT NOT NULL,
status TEXT NOT NULL,
boltz_id TEXT NOT NULL,
refund_address TEXT NOT NULL,
refund_privkey TEXT NOT NULL,
expected_amount INT NOT NULL,
timeout_block_height INT NOT NULL,
address TEXT NOT NULL,
bip21 TEXT NOT NULL,
redeem_script TEXT NOT NULL,
time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now
+ """
);
"""
)
await db.execute(
"""
CREATE TABLE boltz.reverse_submarineswap (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
onchain_address TEXT NOT NULL,
amount INT NOT NULL,
instant_settlement BOOLEAN NOT NULL,
status TEXT NOT NULL,
boltz_id TEXT NOT NULL,
timeout_block_height INT NOT NULL,
redeem_script TEXT NOT NULL,
preimage TEXT NOT NULL,
claim_privkey TEXT NOT NULL,
lockup_address TEXT NOT NULL,
invoice TEXT NOT NULL,
onchain_amount INT NOT NULL,
time TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now
+ """
);
"""
)
+75
View File
@@ -0,0 +1,75 @@
import json
from typing import Dict, List, Optional
from fastapi.params import Query
from pydantic.main import BaseModel
from sqlalchemy.engine import base # type: ignore
class SubmarineSwap(BaseModel):
id: str
wallet: str
amount: int
payment_hash: str
time: int
status: str
refund_privkey: str
refund_address: str
boltz_id: str
expected_amount: int
timeout_block_height: int
address: str
bip21: str
redeem_script: str
class CreateSubmarineSwap(BaseModel):
wallet: str = Query(...) # type: ignore
refund_address: str = Query(...) # type: ignore
amount: int = Query(...) # type: ignore
class ReverseSubmarineSwap(BaseModel):
id: str
wallet: str
amount: int
onchain_address: str
instant_settlement: bool
time: int
status: str
boltz_id: str
preimage: str
claim_privkey: str
lockup_address: str
invoice: str
onchain_amount: int
timeout_block_height: int
redeem_script: str
class CreateReverseSubmarineSwap(BaseModel):
wallet: str = Query(...) # type: ignore
amount: int = Query(...) # type: ignore
instant_settlement: bool = Query(...) # type: ignore
# validate on-address, bcrt1 for regtest addresses
onchain_address: str = Query(
..., regex="^(bcrt1|bc1|[13])[a-zA-HJ-NP-Z0-9]{25,39}$"
) # type: ignore
class SwapStatus(BaseModel):
swap_id: str
wallet: str
status: str = ""
message: str = ""
boltz: str = ""
mempool: str = ""
address: str = ""
block_height: int = 0
timeout_block_height: str = ""
lockup: Optional[dict] = {}
has_lockup: bool = False
hit_timeout: bool = False
confirmed: bool = True
exists: bool = True
reverse: bool = False
+153
View File
@@ -0,0 +1,153 @@
import asyncio
import httpx
from loguru import logger
from lnbits.core.models import Payment
from lnbits.core.services import check_transaction_status
from lnbits.tasks import register_invoice_listener
from .boltz import (
create_claim_tx,
create_refund_tx,
get_swap_status,
start_confirmation_listener,
start_onchain_listener,
)
from .crud import (
get_all_pending_reverse_submarine_swaps,
get_all_pending_submarine_swaps,
get_reverse_submarine_swap,
get_submarine_swap,
update_swap_status,
)
"""
testcases for boltz startup
A. normal swaps
1. test: create -> kill -> start -> startup invoice listeners -> pay onchain funds -> should complete
2. test: create -> kill -> pay onchain funds -> start -> startup check -> should complete
3. test: create -> kill -> mine blocks and hit timeout -> start -> should go timeout/failed
4. test: create -> kill -> pay to less onchain funds -> mine blocks hit timeout -> start lnbits -> should be refunded
B. reverse swaps
1. test: create instant -> kill -> boltz does lockup -> not confirmed -> start lnbits -> should claim/complete
2. test: create instant -> kill -> no lockup -> start lnbits -> should start onchain listener -> boltz does lockup -> should claim/complete (difficult to test)
3. test: create -> kill -> boltz does lockup -> not confirmed -> start lnbits -> should start tx listener -> after confirmation -> should claim/complete
4. test: create -> kill -> boltz does lockup -> confirmed -> start lnbits -> should claim/complete
5. test: create -> kill -> boltz does lockup -> hit timeout -> boltz refunds -> start -> should timeout
"""
async def check_for_pending_swaps():
try:
swaps = await get_all_pending_submarine_swaps()
reverse_swaps = await get_all_pending_reverse_submarine_swaps()
if len(swaps) > 0 or len(reverse_swaps) > 0:
logger.debug(f"Boltz - startup swap check")
except:
# database is not created yet, do nothing
return
if len(swaps) > 0:
logger.debug(f"Boltz - {len(swaps)} pending swaps")
for swap in swaps:
try:
swap_status = get_swap_status(swap)
# should only happen while development when regtest is reset
if swap_status.exists is False:
logger.warning(f"Boltz - swap: {swap.boltz_id} does not exist.")
await update_swap_status(swap.id, "failed")
continue
payment_status = await check_transaction_status(
swap.wallet, swap.payment_hash
)
if payment_status.paid:
logger.debug(
f"Boltz - swap: {swap.boltz_id} got paid while offline."
)
await update_swap_status(swap.id, "complete")
else:
if swap_status.hit_timeout:
if not swap_status.has_lockup:
logger.warning(
f"Boltz - swap: {swap.id} hit timeout, but no lockup tx..."
)
await update_swap_status(swap.id, "timeout")
else:
logger.debug(f"Boltz - refunding swap: {swap.id}...")
await create_refund_tx(swap)
await update_swap_status(swap.id, "refunded")
except Exception as exc:
logger.error(f"Boltz - swap: {swap.id} - {str(exc)}")
if len(reverse_swaps) > 0:
logger.debug(f"Boltz - {len(reverse_swaps)} pending reverse swaps")
for reverse_swap in reverse_swaps:
try:
swap_status = get_swap_status(reverse_swap)
if swap_status.exists is False:
logger.debug(
f"Boltz - reverse_swap: {reverse_swap.boltz_id} does not exist."
)
await update_swap_status(reverse_swap.id, "failed")
continue
# if timeout hit, boltz would have already refunded
if swap_status.hit_timeout:
logger.debug(
f"Boltz - reverse_swap: {reverse_swap.boltz_id} timeout."
)
await update_swap_status(reverse_swap.id, "timeout")
continue
if not swap_status.has_lockup:
# start listener for onchain address
logger.debug(
f"Boltz - reverse_swap: {reverse_swap.boltz_id} restarted onchain address listener."
)
await start_onchain_listener(reverse_swap)
continue
if reverse_swap.instant_settlement or swap_status.confirmed:
await create_claim_tx(reverse_swap, swap_status.lockup)
else:
logger.debug(
f"Boltz - reverse_swap: {reverse_swap.boltz_id} restarted confirmation listener."
)
await start_confirmation_listener(reverse_swap, swap_status.lockup)
except Exception as exc:
logger.error(f"Boltz - reverse swap: {reverse_swap.id} - {str(exc)}")
async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue)
while True:
payment = await invoice_queue.get()
await on_invoice_paid(payment)
async def on_invoice_paid(payment: Payment) -> None:
if "boltz" != payment.extra.get("tag"):
# not a boltz invoice
return
await payment.set_pending(False)
swap_id = payment.extra.get("swap_id")
swap = await get_submarine_swap(swap_id)
if not swap:
logger.error(f"swap_id: {swap_id} not found.")
return
logger.info(
f"Boltz - lightning invoice is paid, normal swap completed. swap_id: {swap_id}"
)
await update_swap_status(swap_id, "complete")
@@ -0,0 +1,236 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="About Boltz"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<img
src="https://boltz.exchange/static/media/Shape.6c1a92b3.svg"
alt=""
/>
<img
src="https://boltz.exchange/static/media/Boltz.02fb7acb.svg"
style="padding: 5px 9px"
alt=""
/>
<h5 class="text-subtitle1 q-my-none">
Boltz.exchange: Do onchain to offchain and vice-versa swaps
</h5>
<p>
Submarine and Reverse Submarine Swaps on LNbits via boltz.exchange
API<br />
</p>
<p>
Link :
<a target="_blank" href="https://boltz.exchange"
>https://boltz.exchange
</a>
</p>
<p>
<a
target="_blank"
href="https://github.com/lnbits/lnbits-legend/tree/main/lnbits/extensions/boltz"
>More details</a
>
</p>
<p>
<small
>Created by,
<a target="_blank" href="https://github.com/dni">dni</a></small
>
</p>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-expansion-item group="api" dense expand-separator label="GET swap/reverse">
<q-card>
<q-card-section>
<code
><span class="text-light-blue">GET</span>
/boltz/api/v1/swap/reverse</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>JSON list of reverse submarine swaps</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ root_url }}/boltz/api/v1/swap/reverse -H "X-Api-Key:
{{ user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="POST swap/reverse"
>
<q-card>
<q-card-section>
<code
><span class="text-light-blue">POST</span>
/boltz/api/v1/swap/reverse</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"wallet": &lt;string&gt;, "onchain_address": &lt;string&gt;,
"amount": &lt;integer&gt;, "instant_settlement":
&lt;boolean&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>JSON create a reverse-submarine swaps</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ root_url }}/boltz/api/v1/swap/reverse -H "X-Api-Key:
{{ user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="GET swap">
<q-card>
<q-card-section>
<code><span class="text-light-blue">GET</span> /boltz/api/v1/swap</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>JSON list of submarine swaps</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ root_url }}/boltz/api/v1/swap -H "X-Api-Key: {{
user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="POST swap">
<q-card>
<q-card-section>
<code
><span class="text-light-blue">POST</span> /boltz/api/v1/swap</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"wallet": &lt;string&gt;, "refund_address": &lt;string&gt;,
"amount": &lt;integer&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>JSON create a submarine swaps</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ root_url }}/boltz/api/v1/swap -H "X-Api-Key: {{
user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="GET swap/refund">
<q-card>
<q-card-section>
<code
><span class="text-light-blue">POST</span>
/boltz/api/v1/swap/refund/{swap_id}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>JSON submarine swap</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ root_url }}/boltz/api/v1/swap/refund/{swap_id} -H
"X-Api-Key: {{ user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="GET swap/status">
<q-card>
<q-card-section>
<code
><span class="text-light-blue">POST</span>
/boltz/api/v1/swap/status/{swap_id}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (text/plain)
</h5>
<code>swap status</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ root_url }}/boltz/api/v1/swap/status/{swap_id} -H
"X-Api-Key: {{ user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="GET swap/check">
<q-card>
<q-card-section>
<code
><span class="text-light-blue">GET</span>
/boltz/api/v1/swap/check</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>JSON pending swaps</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ root_url }}/boltz/api/v1/swap/check -H "X-Api-Key: {{
user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="GET boltz-config">
<q-card>
<q-card-section>
<code
><span class="text-light-blue">GET</span>
/boltz/api/v1/swap/boltz</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (text/plain)
</h5>
<code>JSON boltz config</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ root_url }}/boltz/api/v1/swap/boltz -H "X-Api-Key: {{
user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="GET mempool-url">
<q-card>
<q-card-section>
<code
><span class="text-light-blue">GET</span>
/boltz/api/v1/swap/mempool</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (text/plain)
</h5>
<code>mempool url</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ root_url }}/boltz/api/v1/swap/mempool -H "X-Api-Key:
{{ user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
import calendar
import datetime
import httpx
from loguru import logger
from lnbits.core.services import fee_reserve, get_wallet
async def check_balance(data) -> bool:
# check if we can pay the invoice before we create the actual swap on boltz
amount_msat = data.amount * 1000
fee_reserve_msat = fee_reserve(amount_msat)
wallet = await get_wallet(data.wallet)
assert wallet
if wallet.balance_msat - fee_reserve_msat < amount_msat:
return False
return True
def get_timestamp():
date = datetime.datetime.utcnow()
return calendar.timegm(date.utctimetuple())
def req_wrap(funcname, *args, **kwargs):
try:
try:
func = getattr(httpx, funcname)
except AttributeError:
logger.error('httpx function not found "%s"' % funcname)
else:
res = func(*args, timeout=30, **kwargs)
res.raise_for_status()
return res
except httpx.RequestError as exc:
msg = f"Unreachable: {exc.request.url!r}."
logger.error(msg)
raise
except httpx.HTTPStatusError as exc:
msg = f"HTTP Status Error: {exc.response.status_code} while requesting {exc.request.url!r}."
logger.error(msg)
logger.error(exc.response.json()["error"])
raise
+23
View File
@@ -0,0 +1,23 @@
from urllib.parse import urlparse
from fastapi import Request
from fastapi.params import Depends
from fastapi.templating import Jinja2Templates
from starlette.responses import HTMLResponse
from lnbits.core.models import Payment, User
from lnbits.decorators import check_user_exists
from . import boltz_ext, boltz_renderer
templates = Jinja2Templates(directory="templates")
@boltz_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
root_url = urlparse(str(request.url)).netloc
wallet_ids = [wallet.id for wallet in user.wallets]
return boltz_renderer().TemplateResponse(
"boltz/index.html",
{"request": request, "user": user.dict(), "root_url": root_url},
)
+338
View File
@@ -0,0 +1,338 @@
from datetime import datetime
from http import HTTPStatus
from typing import List
import httpx
from fastapi import status
from fastapi.encoders import jsonable_encoder
from fastapi.param_functions import Body
from fastapi.params import Depends, Query
from loguru import logger
from pydantic import BaseModel
from starlette.exceptions import HTTPException
from starlette.requests import Request
from lnbits.core.crud import get_user
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from lnbits.settings import BOLTZ_MEMPOOL_SPACE_URL
from . import boltz_ext
from .boltz import (
create_refund_tx,
create_reverse_swap,
create_swap,
get_boltz_pairs,
get_swap_status,
)
from .crud import (
create_reverse_submarine_swap,
create_submarine_swap,
get_pending_reverse_submarine_swaps,
get_pending_submarine_swaps,
get_reverse_submarine_swap,
get_reverse_submarine_swaps,
get_submarine_swap,
get_submarine_swaps,
update_swap_status,
)
from .models import (
CreateReverseSubmarineSwap,
CreateSubmarineSwap,
ReverseSubmarineSwap,
SubmarineSwap,
)
from .utils import check_balance
@boltz_ext.get(
"/api/v1/swap/mempool",
name=f"boltz.get /swap/mempool",
summary="get a the mempool url",
description="""
This endpoint gets the URL from mempool.space
""",
response_description="mempool.space url",
response_model=str,
)
async def api_mempool_url():
return BOLTZ_MEMPOOL_SPACE_URL
# NORMAL SWAP
@boltz_ext.get(
"/api/v1/swap",
name=f"boltz.get /swap",
summary="get a list of swaps a swap",
description="""
This endpoint gets a list of normal swaps.
""",
response_description="list of normal swaps",
dependencies=[Depends(get_key_type)],
response_model=List[SubmarineSwap],
)
async def api_submarineswap(
g: WalletTypeInfo = Depends(get_key_type),
all_wallets: bool = Query(False),
):
wallet_ids = [g.wallet.id]
if all_wallets:
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
for swap in await get_pending_submarine_swaps(wallet_ids):
swap_status = get_swap_status(swap)
if swap_status.hit_timeout:
if not swap_status.has_lockup:
logger.warning(
f"Boltz - swap: {swap.id} hit timeout, but no lockup tx..."
)
await update_swap_status(swap.id, "timeout")
return [swap.dict() for swap in await get_submarine_swaps(wallet_ids)]
@boltz_ext.post(
"/api/v1/swap/refund",
name=f"boltz.swap_refund",
summary="refund of a swap",
description="""
This endpoint attempts to refund a normal swaps, creates onchain tx and sets swap status ro refunded.
""",
response_description="refunded swap with status set to refunded",
dependencies=[Depends(require_admin_key)],
response_model=SubmarineSwap,
responses={
400: {"description": "when swap_id is missing"},
404: {"description": "when swap is not found"},
405: {"description": "when swap is not pending"},
500: {
"description": "when something goes wrong creating the refund onchain tx"
},
},
)
async def api_submarineswap_refund(
swap_id: str,
g: WalletTypeInfo = Depends(require_admin_key), # type: ignore
):
if swap_id == None:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="swap_id missing"
)
swap = await get_submarine_swap(swap_id)
if swap == None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="swap does not exist."
)
if swap.status != "pending":
raise HTTPException(
status_code=HTTPStatus.METHOD_NOT_ALLOWED, detail="swap is not pending."
)
try:
await create_refund_tx(swap)
except httpx.RequestError as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Unreachable: {exc.request.url!r}.",
)
except Exception as exc:
raise HTTPException(status_code=HTTPStatus.METHOD_NOT_ALLOWED, detail=str(exc))
await update_swap_status(swap.id, "refunded")
return swap
@boltz_ext.post(
"/api/v1/swap",
status_code=status.HTTP_201_CREATED,
name=f"boltz.post /swap",
summary="create a submarine swap",
description="""
This endpoint creates a submarine swap
""",
response_description="create swap",
response_model=SubmarineSwap,
responses={
405: {"description": "not allowed method, insufficient balance"},
500: {"description": "boltz error"},
},
)
async def api_submarineswap_create(
data: CreateSubmarineSwap,
wallet: WalletTypeInfo = Depends(require_admin_key), # type: ignore
):
try:
swap_data = await create_swap(data)
except httpx.RequestError as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Unreachable: {exc.request.url!r}.",
)
except Exception as exc:
raise HTTPException(status_code=HTTPStatus.METHOD_NOT_ALLOWED, detail=str(exc))
except httpx.HTTPStatusError as exc:
raise HTTPException(
status_code=exc.response.status_code, detail=exc.response.json()["error"]
)
swap = await create_submarine_swap(swap_data)
return swap.dict()
# REVERSE SWAP
@boltz_ext.get(
"/api/v1/swap/reverse",
name=f"boltz.get /swap/reverse",
summary="get a list of reverse swaps a swap",
description="""
This endpoint gets a list of reverse swaps.
""",
response_description="list of reverse swaps",
dependencies=[Depends(get_key_type)],
response_model=List[ReverseSubmarineSwap],
)
async def api_reverse_submarineswap(
g: WalletTypeInfo = Depends(get_key_type), # type:ignore
all_wallets: bool = Query(False),
):
wallet_ids = [g.wallet.id]
if all_wallets:
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
return [swap.dict() for swap in await get_reverse_submarine_swaps(wallet_ids)]
@boltz_ext.post(
"/api/v1/swap/reverse",
status_code=status.HTTP_201_CREATED,
name=f"boltz.post /swap/reverse",
summary="create a reverse submarine swap",
description="""
This endpoint creates a reverse submarine swap
""",
response_description="create reverse swap",
response_model=ReverseSubmarineSwap,
responses={
405: {"description": "not allowed method, insufficient balance"},
500: {"description": "boltz error"},
},
)
async def api_reverse_submarineswap_create(
data: CreateReverseSubmarineSwap,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
if not await check_balance(data):
raise HTTPException(
status_code=HTTPStatus.METHOD_NOT_ALLOWED, detail="Insufficient balance."
)
try:
swap_data, task = await create_reverse_swap(data)
except httpx.RequestError as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Unreachable: {exc.request.url!r}.",
)
except httpx.HTTPStatusError as exc:
raise HTTPException(
status_code=exc.response.status_code, detail=exc.response.json()["error"]
)
except Exception as exc:
raise HTTPException(status_code=HTTPStatus.METHOD_NOT_ALLOWED, detail=str(exc))
swap = await create_reverse_submarine_swap(swap_data)
return swap.dict()
@boltz_ext.post(
"/api/v1/swap/status",
name=f"boltz.swap_status",
summary="shows the status of a swap",
description="""
This endpoint attempts to get the status of the swap.
""",
response_description="status of swap json",
responses={
404: {"description": "when swap_id is not found"},
},
)
async def api_swap_status(
swap_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) # type: ignore
):
swap = await get_submarine_swap(swap_id) or await get_reverse_submarine_swap(
swap_id
)
if swap == None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="swap does not exist."
)
try:
status = get_swap_status(swap)
except httpx.RequestError as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Unreachable: {exc.request.url!r}.",
)
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
)
return status
@boltz_ext.post(
"/api/v1/swap/check",
name=f"boltz.swap_check",
summary="list all pending swaps",
description="""
This endpoint gives you 2 lists of pending swaps and reverse swaps.
""",
response_description="list of pending swaps",
)
async def api_check_swaps(
g: WalletTypeInfo = Depends(require_admin_key), # type: ignore
all_wallets: bool = Query(False),
):
wallet_ids = [g.wallet.id]
if all_wallets:
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
status = []
try:
for swap in await get_pending_submarine_swaps(wallet_ids):
status.append(get_swap_status(swap))
for reverseswap in await get_pending_reverse_submarine_swaps(wallet_ids):
status.append(get_swap_status(reverseswap))
except httpx.RequestError as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Unreachable: {exc.request.url!r}.",
)
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
)
return status
@boltz_ext.get(
"/api/v1/swap/boltz",
name=f"boltz.get /swap/boltz",
summary="get a boltz configuration",
description="""
This endpoint gets configuration for boltz. (limits, fees...)
""",
response_description="dict of boltz config",
response_model=dict,
)
async def api_boltz_config():
try:
res = get_boltz_pairs()
except httpx.RequestError as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Unreachable: {exc.request.url!r}.",
)
except Exception as e:
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
return res["pairs"]["BTC/BTC"]
+3 -5
View File
@@ -73,11 +73,9 @@ async def lnurl_callback(
wallet_id=cp.wallet,
amount=int(amount_received / 1000),
memo=cp.lnurl_title,
description_hash=hashlib.sha256(
(
LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]]))
).encode("utf-8")
).digest(),
unhashed_description=(
LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]]))
).encode("utf-8"),
extra={"tag": "copilot", "copilotid": cp.id, "comment": comment},
)
payResponse = {"pr": payment_request, "routes": []}
+1 -1
View File
@@ -63,7 +63,7 @@ class ConnectionManager:
manager = ConnectionManager()
@copilot_ext.websocket("/copilot/ws/{copilot_id}", name="copilot.websocket_by_id")
@copilot_ext.websocket("/ws/{copilot_id}", name="copilot.websocket_by_id")
async def websocket_endpoint(websocket: WebSocket, copilot_id: str):
await manager.connect(websocket, copilot_id)
try:
+14 -31
View File
@@ -19,42 +19,25 @@ async def create_ticket(
(payment_hash, wallet, event, name, email, False, True),
)
# UPDATE EVENT DATA ON SOLD TICKET
eventdata = await get_event(event)
assert eventdata, "Couldn't get event from ticket being paid"
sold = eventdata.sold + 1
amount_tickets = eventdata.amount_tickets - 1
await db.execute(
"""
UPDATE events.events
SET sold = ?, amount_tickets = ?
WHERE id = ?
""",
(sold, amount_tickets, event),
)
ticket = await get_ticket(payment_hash)
assert ticket, "Newly created ticket couldn't be retrieved"
return ticket
async def set_ticket_paid(payment_hash: str) -> Tickets:
row = await db.fetchone("SELECT * FROM events.ticket WHERE id = ?", (payment_hash,))
if row[6] != True:
await db.execute(
"""
UPDATE events.ticket
SET paid = true
WHERE id = ?
""",
(payment_hash,),
)
eventdata = await get_event(row[2])
assert eventdata, "Couldn't get event from ticket being paid"
sold = eventdata.sold + 1
amount_tickets = eventdata.amount_tickets - 1
await db.execute(
"""
UPDATE events.events
SET sold = ?, amount_tickets = ?
WHERE id = ?
""",
(sold, amount_tickets, row[2]),
)
ticket = await get_ticket(payment_hash)
assert ticket, "Newly updated ticket couldn't be retrieved"
return ticket
async def get_ticket(payment_hash: str) -> Optional[Tickets]:
row = await db.fetchone("SELECT * FROM events.ticket WHERE id = ?", (payment_hash,))
return Tickets(**row) if row else None
-1
View File
@@ -24,7 +24,6 @@ from .crud import (
get_ticket,
get_tickets,
reg_ticket,
set_ticket_paid,
update_event,
)
+19
View File
@@ -0,0 +1,19 @@
# Invoices
## Create invoices that you can send to your client to pay online over Lightning.
This extension allows users to create "traditional" invoices (not in the lightning sense) that contain one or more line items. Line items are denominated in a user-configurable fiat currency. Each invoice contains one or more payments up to the total of the invoice. Each invoice creates a public link that can be shared with a customer that they can use to (partially or in full) pay the invoice.
## Usage
1. Create an invoice by clicking "NEW INVOICE"\
![create new invoice](https://imgur.com/a/Dce3wrr.png)
2. Fill the options for your INVOICE
- select the wallet
- select the fiat currency the invoice will be denominated in
- select a status for the invoice (default is draft)
- enter a company name, first name, last name, email, phone & address (optional)
- add one or more line items
- enter a name & price for each line item
3. You can then use share your invoice link with your customer to receive payment\
![invoice link](https://imgur.com/a/L0JOj4T.png)
+36
View File
@@ -0,0 +1,36 @@
import asyncio
from fastapi import APIRouter
from starlette.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_invoices")
invoices_static_files = [
{
"path": "/invoices/static",
"app": StaticFiles(directory="lnbits/extensions/invoices/static"),
"name": "invoices_static",
}
]
invoices_ext: APIRouter = APIRouter(prefix="/invoices", tags=["invoices"])
def invoices_renderer():
return template_renderer(["lnbits/extensions/invoices/templates"])
from .tasks import wait_for_paid_invoices
def invoices_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
from .views import * # noqa
from .views_api import * # noqa
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Invoices",
"short_description": "Create invoices for your clients.",
"icon": "request_quote",
"contributors": ["leesalminen"]
}
+206
View File
@@ -0,0 +1,206 @@
from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import (
CreateInvoiceData,
CreateInvoiceItemData,
CreatePaymentData,
Invoice,
InvoiceItem,
Payment,
UpdateInvoiceData,
UpdateInvoiceItemData,
)
async def get_invoice(invoice_id: str) -> Optional[Invoice]:
row = await db.fetchone(
"SELECT * FROM invoices.invoices WHERE id = ?", (invoice_id,)
)
return Invoice.from_row(row) if row else None
async def get_invoice_items(invoice_id: str) -> List[InvoiceItem]:
rows = await db.fetchall(
f"SELECT * FROM invoices.invoice_items WHERE invoice_id = ?", (invoice_id,)
)
return [InvoiceItem.from_row(row) for row in rows]
async def get_invoice_item(item_id: str) -> InvoiceItem:
row = await db.fetchone(
"SELECT * FROM invoices.invoice_items WHERE id = ?", (item_id,)
)
return InvoiceItem.from_row(row) if row else None
async def get_invoice_total(items: List[InvoiceItem]) -> int:
return sum(item.amount for item in items)
async def get_invoices(wallet_ids: Union[str, List[str]]) -> List[Invoice]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM invoices.invoices WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Invoice.from_row(row) for row in rows]
async def get_invoice_payments(invoice_id: str) -> List[Payment]:
rows = await db.fetchall(
f"SELECT * FROM invoices.payments WHERE invoice_id = ?", (invoice_id,)
)
return [Payment.from_row(row) for row in rows]
async def get_invoice_payment(payment_id: str) -> Payment:
row = await db.fetchone(
"SELECT * FROM invoices.payments WHERE id = ?", (payment_id,)
)
return Payment.from_row(row) if row else None
async def get_payments_total(payments: List[Payment]) -> int:
return sum(item.amount for item in payments)
async def create_invoice_internal(wallet_id: str, data: CreateInvoiceData) -> Invoice:
invoice_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO invoices.invoices (id, wallet, status, currency, company_name, first_name, last_name, email, phone, address)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
invoice_id,
wallet_id,
data.status,
data.currency,
data.company_name,
data.first_name,
data.last_name,
data.email,
data.phone,
data.address,
),
)
invoice = await get_invoice(invoice_id)
assert invoice, "Newly created invoice couldn't be retrieved"
return invoice
async def create_invoice_items(
invoice_id: str, data: List[CreateInvoiceItemData]
) -> List[InvoiceItem]:
for item in data:
item_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO invoices.invoice_items (id, invoice_id, description, amount)
VALUES (?, ?, ?, ?)
""",
(
item_id,
invoice_id,
item.description,
int(item.amount * 100),
),
)
invoice_items = await get_invoice_items(invoice_id)
return invoice_items
async def update_invoice_internal(wallet_id: str, data: UpdateInvoiceData) -> Invoice:
await db.execute(
"""
UPDATE invoices.invoices
SET wallet = ?, currency = ?, status = ?, company_name = ?, first_name = ?, last_name = ?, email = ?, phone = ?, address = ?
WHERE id = ?
""",
(
wallet_id,
data.currency,
data.status,
data.company_name,
data.first_name,
data.last_name,
data.email,
data.phone,
data.address,
data.id,
),
)
invoice = await get_invoice(data.id)
assert invoice, "Newly updated invoice couldn't be retrieved"
return invoice
async def update_invoice_items(
invoice_id: str, data: List[UpdateInvoiceItemData]
) -> List[InvoiceItem]:
updated_items = []
for item in data:
if item.id:
updated_items.append(item.id)
await db.execute(
"""
UPDATE invoices.invoice_items
SET description = ?, amount = ?
WHERE id = ?
""",
(item.description, int(item.amount * 100), item.id),
)
placeholders = ",".join("?" for i in range(len(updated_items)))
if not placeholders:
placeholders = "?"
updated_items = ("skip",)
await db.execute(
f"""
DELETE FROM invoices.invoice_items
WHERE invoice_id = ?
AND id NOT IN ({placeholders})
""",
(
invoice_id,
*tuple(updated_items),
),
)
for item in data:
if not item.id:
await create_invoice_items(invoice_id=invoice_id, data=[item])
invoice_items = await get_invoice_items(invoice_id)
return invoice_items
async def create_invoice_payment(invoice_id: str, amount: int) -> Payment:
payment_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO invoices.payments (id, invoice_id, amount)
VALUES (?, ?, ?)
""",
(
payment_id,
invoice_id,
amount,
),
)
payment = await get_invoice_payment(payment_id)
assert payment, "Newly created payment couldn't be retrieved"
return payment
+55
View File
@@ -0,0 +1,55 @@
async def m001_initial_invoices(db):
# STATUS COLUMN OPTIONS: 'draft', 'open', 'paid', 'canceled'
await db.execute(
f"""
CREATE TABLE invoices.invoices (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
currency TEXT NOT NULL,
company_name TEXT DEFAULT NULL,
first_name TEXT DEFAULT NULL,
last_name TEXT DEFAULT NULL,
email TEXT DEFAULT NULL,
phone TEXT DEFAULT NULL,
address TEXT DEFAULT NULL,
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
await db.execute(
f"""
CREATE TABLE invoices.invoice_items (
id TEXT PRIMARY KEY,
invoice_id TEXT NOT NULL,
description TEXT NOT NULL,
amount INTEGER NOT NULL,
FOREIGN KEY(invoice_id) REFERENCES {db.references_schema}invoices(id)
);
"""
)
await db.execute(
f"""
CREATE TABLE invoices.payments (
id TEXT PRIMARY KEY,
invoice_id TEXT NOT NULL,
amount INT NOT NULL,
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
FOREIGN KEY(invoice_id) REFERENCES {db.references_schema}invoices(id)
);
"""
)
+104
View File
@@ -0,0 +1,104 @@
from enum import Enum
from sqlite3 import Row
from typing import List, Optional
from fastapi.param_functions import Query
from pydantic import BaseModel
class InvoiceStatusEnum(str, Enum):
draft = "draft"
open = "open"
paid = "paid"
canceled = "canceled"
class CreateInvoiceItemData(BaseModel):
description: str
amount: float = Query(..., ge=0.01)
class CreateInvoiceData(BaseModel):
status: InvoiceStatusEnum = InvoiceStatusEnum.draft
currency: str
company_name: Optional[str]
first_name: Optional[str]
last_name: Optional[str]
email: Optional[str]
phone: Optional[str]
address: Optional[str]
items: List[CreateInvoiceItemData]
class Config:
use_enum_values = True
class UpdateInvoiceItemData(BaseModel):
id: Optional[str]
description: str
amount: float = Query(..., ge=0.01)
class UpdateInvoiceData(BaseModel):
id: str
wallet: str
status: InvoiceStatusEnum = InvoiceStatusEnum.draft
currency: str
company_name: Optional[str]
first_name: Optional[str]
last_name: Optional[str]
email: Optional[str]
phone: Optional[str]
address: Optional[str]
items: List[UpdateInvoiceItemData]
class Invoice(BaseModel):
id: str
wallet: str
status: InvoiceStatusEnum = InvoiceStatusEnum.draft
currency: str
company_name: Optional[str]
first_name: Optional[str]
last_name: Optional[str]
email: Optional[str]
phone: Optional[str]
address: Optional[str]
time: int
class Config:
use_enum_values = True
@classmethod
def from_row(cls, row: Row) -> "Invoice":
return cls(**dict(row))
class InvoiceItem(BaseModel):
id: str
invoice_id: str
description: str
amount: int
class Config:
orm_mode = True
@classmethod
def from_row(cls, row: Row) -> "InvoiceItem":
return cls(**dict(row))
class Payment(BaseModel):
id: str
invoice_id: str
amount: int
time: int
@classmethod
def from_row(cls, row: Row) -> "Payment":
return cls(**dict(row))
class CreatePaymentData(BaseModel):
invoice_id: str
amount: int
@@ -0,0 +1,65 @@
#invoicePage>.row:first-child>.col-md-6 {
display: flex;
}
#invoicePage>.row:first-child>.col-md-6>.q-card {
flex: 1;
}
#invoicePage .clear {
margin-bottom: 25px;
}
#printQrCode {
display: none;
}
@media (min-width: 1024px) {
#invoicePage>.row:first-child>.col-md-6:first-child>div {
margin-right: 5px;
}
#invoicePage>.row:first-child>.col-md-6:nth-child(2)>div {
margin-left: 5px;
}
}
@media print {
* {
color: black !important;
}
header, button, #payButtonContainer {
display: none !important;
}
main, .q-page-container {
padding-top: 0px !important;
}
.q-card {
box-shadow: none !important;
border: 1px solid black;
}
.q-item {
padding: 5px;
}
.q-card__section {
padding: 5px;
}
#printQrCode {
display: block;
}
p {
margin-bottom: 0px !important;
}
#invoicePage .clear {
margin-bottom: 10px !important;
}
}
+51
View File
@@ -0,0 +1,51 @@
import asyncio
import json
from lnbits.core.models import Payment
from lnbits.helpers import urlsafe_short_hash
from lnbits.tasks import internal_invoice_queue, register_invoice_listener
from .crud import (
create_invoice_payment,
get_invoice,
get_invoice_items,
get_invoice_payments,
get_invoice_total,
get_payments_total,
update_invoice_internal,
)
async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue)
while True:
payment = await invoice_queue.get()
await on_invoice_paid(payment)
async def on_invoice_paid(payment: Payment) -> None:
if payment.extra.get("tag") != "invoices":
# not relevant
return
invoice_id = payment.extra.get("invoice_id")
payment = await create_invoice_payment(
invoice_id=invoice_id, amount=payment.extra.get("famount")
)
invoice = await get_invoice(invoice_id)
invoice_items = await get_invoice_items(invoice_id)
invoice_total = await get_invoice_total(invoice_items)
invoice_payments = await get_invoice_payments(invoice_id)
payments_total = await get_payments_total(invoice_payments)
if payments_total >= invoice_total:
invoice.status = "paid"
await update_invoice_internal(invoice.wallet, invoice)
return
@@ -0,0 +1,153 @@
<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 Invoices">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span> /invoices/api/v1/invoices</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;invoice_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}invoices/api/v1/invoices -H
"X-Api-Key: &lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Fetch Invoice">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/invoices/api/v1/invoice/{invoice_id}</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>{invoice_object}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url
}}invoices/api/v1/invoice/{invoice_id} -H "X-Api-Key:
&lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Create Invoice">
<q-card>
<q-card-section>
<code
><span class="text-blue">POST</span> /invoices/api/v1/invoice</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>{invoice_object}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}invoices/api/v1/invoice -H
"X-Api-Key: &lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Update Invoice">
<q-card>
<q-card-section>
<code
><span class="text-blue">POST</span>
/invoices/api/v1/invoice/{invoice_id}</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>{invoice_object}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url
}}invoices/api/v1/invoice/{invoice_id} -H "X-Api-Key:
&lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Create Invoice Payment"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">POST</span>
/invoices/api/v1/invoice/{invoice_id}/payments</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<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>{payment_object}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url
}}invoices/api/v1/invoice/{invoice_id}/payments -H "X-Api-Key:
&lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Check Invoice Payment Status"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/invoices/api/v1/invoice/{invoice_id}/payments/{payment_hash}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<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>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url
}}invoices/api/v1/invoice/{invoice_id}/payments/{payment_hash} -H
"X-Api-Key: &lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>
@@ -0,0 +1,571 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="primary" @click="formDialog.show = true"
>New Invoice</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">Invoices</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
</div>
</div>
<q-table
dense
flat
:data="invoices"
row-key="id"
:columns="invoicesTable.columns"
:pagination.sync="invoicesTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="edit"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="showEditModal(props.row)"
></q-btn>
<q-btn
unelevated
dense
size="xs"
icon="launch"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="'pay/' + props.row.id"
target="_blank"
></q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} Invoices extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "invoices/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="saveInvoice" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="formDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
></q-select>
<q-select
filled
dense
emit-value
v-model="formDialog.data.currency"
:options="currencyOptions"
label="Currency *"
></q-select>
<q-select
filled
dense
emit-value
v-model="formDialog.data.status"
:options="['draft', 'open', 'paid', 'canceled']"
label="Status *"
></q-select>
<q-input
filled
dense
v-model.trim="formDialog.data.company_name"
label="Company Name"
placeholder="LNBits Labs"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.first_name"
label="First Name"
placeholder="Satoshi"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.last_name"
label="Last Name"
placeholder="Nakamoto"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.email"
label="Email"
placeholder="satoshi@gmail.com"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.phone"
label="Phone"
placeholder="+81 (012)-345-6789"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.address"
label="Address"
placeholder="1600 Pennsylvania Ave."
type="textarea"
></q-input>
<q-list bordered separator>
<q-item
clickable
v-ripple
v-for="(item, index) in formDialog.invoiceItems"
:key="index"
>
<q-item-section>
<q-input
filled
dense
label="Item"
placeholder="Jelly Beans"
v-model="formDialog.invoiceItems[index].description"
></q-input>
</q-item-section>
<q-item-section>
<q-input
filled
dense
label="Amount"
placeholder="4.20"
v-model="formDialog.invoiceItems[index].amount"
></q-input>
</q-item-section>
<q-item-section side>
<q-btn
unelevated
dense
size="xs"
icon="delete"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="formDialog.invoiceItems.splice(index, 1)"
></q-btn>
</q-item-section>
</q-item>
<q-item clickable v-ripple>
<q-btn flat icon="add" @click="formDialog.invoiceItems.push({})">
Add Line Item
</q-btn>
</q-item>
</q-list>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="formDialog.data.wallet == null || formDialog.data.currency == null"
type="submit"
v-if="typeof formDialog.data.id == 'undefined'"
>Create Invoice</q-btn
>
<q-btn
unelevated
color="primary"
:disable="formDialog.data.wallet == null || formDialog.data.currency == null"
type="submit"
v-if="typeof formDialog.data.id !== 'undefined'"
>Save Invoice</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>
var mapInvoice = function (obj) {
obj.time = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
return obj
}
var mapInvoiceItems = function (obj) {
obj.amount = parseFloat(obj.amount / 100).toFixed(2)
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
invoices: [],
currencyOptions: [
'USD',
'EUR',
'GBP',
'AED',
'AFN',
'ALL',
'AMD',
'ANG',
'AOA',
'ARS',
'AUD',
'AWG',
'AZN',
'BAM',
'BBD',
'BDT',
'BGN',
'BHD',
'BIF',
'BMD',
'BND',
'BOB',
'BRL',
'BSD',
'BTN',
'BWP',
'BYN',
'BZD',
'CAD',
'CDF',
'CHF',
'CLF',
'CLP',
'CNH',
'CNY',
'COP',
'CRC',
'CUC',
'CUP',
'CVE',
'CZK',
'DJF',
'DKK',
'DOP',
'DZD',
'EGP',
'ERN',
'ETB',
'EUR',
'FJD',
'FKP',
'GBP',
'GEL',
'GGP',
'GHS',
'GIP',
'GMD',
'GNF',
'GTQ',
'GYD',
'HKD',
'HNL',
'HRK',
'HTG',
'HUF',
'IDR',
'ILS',
'IMP',
'INR',
'IQD',
'IRR',
'IRT',
'ISK',
'JEP',
'JMD',
'JOD',
'JPY',
'KES',
'KGS',
'KHR',
'KMF',
'KPW',
'KRW',
'KWD',
'KYD',
'KZT',
'LAK',
'LBP',
'LKR',
'LRD',
'LSL',
'LYD',
'MAD',
'MDL',
'MGA',
'MKD',
'MMK',
'MNT',
'MOP',
'MRO',
'MUR',
'MVR',
'MWK',
'MXN',
'MYR',
'MZN',
'NAD',
'NGN',
'NIO',
'NOK',
'NPR',
'NZD',
'OMR',
'PAB',
'PEN',
'PGK',
'PHP',
'PKR',
'PLN',
'PYG',
'QAR',
'RON',
'RSD',
'RUB',
'RWF',
'SAR',
'SBD',
'SCR',
'SDG',
'SEK',
'SGD',
'SHP',
'SLL',
'SOS',
'SRD',
'SSP',
'STD',
'SVC',
'SYP',
'SZL',
'THB',
'TJS',
'TMT',
'TND',
'TOP',
'TRY',
'TTD',
'TWD',
'TZS',
'UAH',
'UGX',
'USD',
'UYU',
'UZS',
'VEF',
'VES',
'VND',
'VUV',
'WST',
'XAF',
'XAG',
'XAU',
'XCD',
'XDR',
'XOF',
'XPD',
'XPF',
'XPT',
'YER',
'ZAR',
'ZMW',
'ZWL'
],
invoicesTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'status', align: 'left', label: 'Status', field: 'status'},
{name: 'time', align: 'left', label: 'Created', field: 'time'},
{name: 'wallet', align: 'left', label: 'Wallet', field: 'wallet'},
{
name: 'currency',
align: 'left',
label: 'Currency',
field: 'currency'
},
{
name: 'company_name',
align: 'left',
label: 'Company Name',
field: 'company_name'
},
{
name: 'first_name',
align: 'left',
label: 'First Name',
field: 'first_name'
},
{
name: 'last_name',
align: 'left',
label: 'Last Name',
field: 'last_name'
},
{name: 'email', align: 'left', label: 'Email', field: 'email'},
{name: 'phone', align: 'left', label: 'Phone', field: 'phone'},
{name: 'address', align: 'left', label: 'Address', field: 'address'}
],
pagination: {
rowsPerPage: 10
}
},
formDialog: {
show: false,
data: {},
invoiceItems: []
}
}
},
methods: {
closeFormDialog: function () {
this.formDialog.data = {}
this.formDialog.invoiceItems = []
},
showEditModal: function (obj) {
this.formDialog.data = obj
this.formDialog.show = true
this.getInvoice(obj.id)
},
getInvoice: function (invoice_id) {
var self = this
LNbits.api
.request('GET', '/invoices/api/v1/invoice/' + invoice_id)
.then(function (response) {
self.formDialog.invoiceItems = response.data.items.map(function (
obj
) {
return mapInvoiceItems(obj)
})
})
},
getInvoices: function () {
var self = this
LNbits.api
.request(
'GET',
'/invoices/api/v1/invoices?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.invoices = response.data.map(function (obj) {
return mapInvoice(obj)
})
})
},
saveInvoice: function () {
var data = this.formDialog.data
data.items = this.formDialog.invoiceItems
var self = this
LNbits.api
.request(
'POST',
'/invoices/api/v1/invoice' + (data.id ? '/' + data.id : ''),
_.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet})
.inkey,
data
)
.then(function (response) {
if (!data.id) {
self.invoices.push(mapInvoice(response.data))
} else {
self.getInvoices()
}
self.formDialog.invoiceItems = []
self.formDialog.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteTPoS: function (tposId) {
var self = this
var tpos = _.findWhere(this.tposs, {id: tposId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this TPoS?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/tpos/api/v1/tposs/' + tposId,
_.findWhere(self.g.user.wallets, {id: tpos.wallet}).adminkey
)
.then(function (response) {
self.tposs = _.reject(self.tposs, function (obj) {
return obj.id == tposId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportCSV: function () {
LNbits.utils.exportCSV(this.invoicesTable.columns, this.invoices)
}
},
created: function () {
if (this.g.user.wallets.length) {
this.getInvoices()
}
}
})
</script>
{% endblock %}
@@ -0,0 +1,430 @@
{% extends "public.html" %} {% block toolbar_title %} Invoice
<q-btn
flat
dense
size="md"
@click.prevent="urlDialog.show = true"
icon="share"
color="white"
></q-btn>
<q-btn
flat
dense
size="md"
@click.prevent="printInvoice()"
icon="print"
color="white"
></q-btn>
{% endblock %} {% from "macros.jinja" import window_vars with context %} {%
block page %}
<link rel="stylesheet" href="/invoices/static/css/pay.css" />
<div id="invoicePage">
<div class="row q-gutter-y-md">
<div class="col-md-6 col-sm-12 col-xs-12">
<q-card>
<q-card-section>
<p>
<b>Invoice</b>
</p>
<q-list bordered separator>
<q-item clickable v-ripple>
<q-item-section><b>ID</b></q-item-section>
<q-item-section style="word-break: break-all"
>{{ invoice_id }}</q-item-section
>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Created At</b></q-item-section>
<q-item-section
>{{ datetime.utcfromtimestamp(invoice.time).strftime('%Y-%m-%d
%H:%M') }}</q-item-section
>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Status</b></q-item-section>
<q-item-section>
<span>
<q-badge color=""> {{ invoice.status }} </q-badge>
</span>
</q-item-section>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Total</b></q-item-section>
<q-item-section>
{{ "{:0,.2f}".format(invoice_total / 100) }} {{ invoice.currency
}}
</q-item-section>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Paid</b></q-item-section>
<q-item-section>
<div class="row" style="align-items: center">
<div class="col-sm-6">
{{ "{:0,.2f}".format(payments_total / 100) }} {{
invoice.currency }}
</div>
<div class="col-sm-6" id="payButtonContainer">
{% if payments_total < invoice_total %}
<q-btn
unelevated
color="primary"
@click="formDialog.show = true"
v-if="status == 'open'"
>
Pay Invoice
</q-btn>
{% endif %}
</div>
</div>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
</div>
<div class="col-md-6 col-sm-12 col-xs-12">
<q-card>
<q-card-section>
<p>
<b>Bill To</b>
</p>
<q-list bordered separator>
<q-item clickable v-ripple>
<q-item-section><b>Company Name</b></q-item-section>
<q-item-section>{{ invoice.company_name }}</q-item-section>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Name</b></q-item-section>
<q-item-section
>{{ invoice.first_name }} {{ invoice.last_name
}}</q-item-section
>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Address</b></q-item-section>
<q-item-section>{{ invoice.address }}</q-item-section>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Email</b></q-item-section>
<q-item-section>{{ invoice.email }}</q-item-section>
</q-item>
<q-item clickable v-ripple>
<q-item-section><b>Phone</b></q-item-section>
<q-item-section>{{ invoice.phone }}</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
</div>
</div>
<div class="clear"></div>
<div class="row">
<div class="col-12 col-md">
<q-card>
<q-card-section>
<p>
<b>Items</b>
</p>
<q-list bordered separator>
{% if invoice_items %}
<q-item clickable v-ripple>
<q-item-section><b>Item</b></q-item-section>
<q-item-section side><b>Amount</b></q-item-section>
</q-item>
{% endif %} {% for item in invoice_items %}
<q-item clickable v-ripple>
<q-item-section><b>{{item.description}}</b></q-item-section>
<q-item-section side>
{{ "{:0,.2f}".format(item.amount / 100) }} {{ invoice.currency
}}
</q-item-section>
</q-item>
{% endfor %} {% if not invoice_items %} No Invoice Items {% endif %}
</q-list>
</q-card-section>
</q-card>
</div>
</div>
<div class="clear"></div>
<div class="row">
<div class="col-12 col-md">
<q-card>
<q-card-section>
<p>
<b>Payments</b>
</p>
<q-list bordered separator>
{% if invoice_payments %}
<q-item clickable v-ripple>
<q-item-section><b>Date</b></q-item-section>
<q-item-section side><b>Amount</b></q-item-section>
</q-item>
{% endif %} {% for item in invoice_payments %}
<q-item clickable v-ripple>
<q-item-section
><b
>{{ datetime.utcfromtimestamp(item.time).strftime('%Y-%m-%d
%H:%M') }}</b
></q-item-section
>
<q-item-section side>
{{ "{:0,.2f}".format(item.amount / 100) }} {{ invoice.currency
}}
</q-item-section>
</q-item>
{% endfor %} {% if not invoice_payments %} No Invoice Payments {%
endif %}
</q-list>
</q-card-section>
</q-card>
</div>
</div>
<div class="clear"></div>
<div class="row q-gutter-y-md q-gutter-md" id="printQrCode">
<div class="col-12 col-md">
<div class="text-center">
<p><b>Scan to View & Pay Online!</b></p>
<qrcode
value="{{ request.url }}"
:options="{width: 200}"
class="rounded-borders"
></qrcode>
</div>
</div>
</div>
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="createPayment" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="formDialog.data.payment_amount"
:rules="[val => val >= 0.01 || 'Minimum amount is 0.01']"
min="0.01"
label="Payment Amount"
placeholder="4.20"
>
<template v-slot:append>
<span style="font-size: 12px"> {{ invoice.currency }} </span>
</template>
</q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="formDialog.data.payment_amount == null"
type="submit"
>Create Payment</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog
v-model="qrCodeDialog.show"
position="top"
@hide="closeQrCodeDialog"
>
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card text-center">
<a :href="'lightning:' + qrCodeDialog.data.payment_request">
<q-responsive :ratio="1" class="q-mx-xs">
<qrcode
:value="qrCodeDialog.data.payment_request"
:options="{width: 400}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
<br />
<q-btn
outline
color="grey"
@click="copyText('lightning:' + qrCodeDialog.data.payment_request, 'Invoice copied to clipboard!')"
>Copy Invoice</q-btn
>
</q-card>
</q-dialog>
<q-dialog v-model="urlDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode
value="{{ request.url }}"
:options="{width: 400}"
class="rounded-borders"
></qrcode>
</q-responsive>
<div class="text-center q-mb-xl">
<p style="word-break: break-all">{{ request.url }}</p>
</div>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText('{{ request.url }}', 'Invoice Pay URL copied to clipboard!')"
>Copy URL</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %}
<script>
var mapInvoice = function (obj) {
obj.time = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
return obj
}
var mapInvoiceItems = function (obj) {
obj.amount = parseFloat(obj.amount / 100).toFixed(2)
return obj
}
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
invoice_id: '{{ invoice.id }}',
wallet: '{{ invoice.wallet }}',
currency: '{{ invoice.currency }}',
status: '{{ invoice.status }}',
qrCodeDialog: {
data: {
payment_request: null,
},
show: false,
},
formDialog: {
data: {
payment_amount: parseFloat({{invoice_total - payments_total}} / 100).toFixed(2)
},
show: false,
},
urlDialog: {
show: false,
},
}
},
methods: {
printInvoice: function() {
window.print()
},
closeFormDialog: function() {
this.formDialog.show = false
},
closeQrCodeDialog: function() {
this.qrCodeDialog.show = false
},
createPayment: function () {
var self = this
var qrCodeDialog = this.qrCodeDialog
var formDialog = this.formDialog
var famount = parseInt(formDialog.data.payment_amount * 100)
axios
.post('/invoices/api/v1/invoice/' + this.invoice_id + '/payments', null, {
params: {
famount: famount,
}
})
.then(function (response) {
formDialog.show = false
formDialog.data = {}
qrCodeDialog.data = response.data
qrCodeDialog.show = true
console.log(qrCodeDialog.data)
qrCodeDialog.dismissMsg = self.$q.notify({
timeout: 0,
message: 'Waiting for payment...'
})
qrCodeDialog.paymentChecker = setInterval(function () {
axios
.get(
'/invoices/api/v1/invoice/' +
self.invoice_id +
'/payments/' +
response.data.payment_hash
)
.then(function (res) {
if (res.data.paid) {
clearInterval(qrCodeDialog.paymentChecker)
qrCodeDialog.dismissMsg()
qrCodeDialog.show = false
setTimeout(function () {
window.location.reload()
}, 500)
}
})
}, 3000)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
},
computed: {
statusBadgeColor: function() {
switch(this.status) {
case 'draft':
return 'gray'
break
case 'open':
return 'blue'
break
case 'paid':
return 'green'
break
case 'canceled':
return 'red'
break
}
},
},
created: function () {
}
})
</script>
{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
from datetime import datetime
from http import HTTPStatus
from fastapi import FastAPI, Request
from fastapi.params import Depends
from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import invoices_ext, invoices_renderer
from .crud import (
get_invoice,
get_invoice_items,
get_invoice_payments,
get_invoice_total,
get_payments_total,
)
templates = Jinja2Templates(directory="templates")
@invoices_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return invoices_renderer().TemplateResponse(
"invoices/index.html", {"request": request, "user": user.dict()}
)
@invoices_ext.get("/pay/{invoice_id}", response_class=HTMLResponse)
async def index(request: Request, invoice_id: str):
invoice = await get_invoice(invoice_id)
if not invoice:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist."
)
invoice_items = await get_invoice_items(invoice_id)
invoice_total = await get_invoice_total(invoice_items)
invoice_payments = await get_invoice_payments(invoice_id)
payments_total = await get_payments_total(invoice_payments)
return invoices_renderer().TemplateResponse(
"invoices/pay.html",
{
"request": request,
"invoice_id": invoice_id,
"invoice": invoice.dict(),
"invoice_items": invoice_items,
"invoice_total": invoice_total,
"invoice_payments": invoice_payments,
"payments_total": payments_total,
"datetime": datetime,
},
)
+136
View File
@@ -0,0 +1,136 @@
from http import HTTPStatus
from fastapi import Query
from fastapi.params import Depends
from loguru import logger
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user
from lnbits.core.services import create_invoice
from lnbits.core.views.api import api_payment
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
from . import invoices_ext
from .crud import (
create_invoice_internal,
create_invoice_items,
get_invoice,
get_invoice_items,
get_invoice_payments,
get_invoice_total,
get_invoices,
get_payments_total,
update_invoice_internal,
update_invoice_items,
)
from .models import CreateInvoiceData, UpdateInvoiceData
@invoices_ext.get("/api/v1/invoices", status_code=HTTPStatus.OK)
async def api_invoices(
all_wallets: bool = Query(None), wallet: WalletTypeInfo = Depends(get_key_type)
):
wallet_ids = [wallet.wallet.id]
if all_wallets:
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
return [invoice.dict() for invoice in await get_invoices(wallet_ids)]
@invoices_ext.get("/api/v1/invoice/{invoice_id}", status_code=HTTPStatus.OK)
async def api_invoice(invoice_id: str):
invoice = await get_invoice(invoice_id)
if not invoice:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist."
)
invoice_items = await get_invoice_items(invoice_id)
invoice_payments = await get_invoice_payments(invoice_id)
payments_total = await get_payments_total(invoice_payments)
invoice_dict = invoice.dict()
invoice_dict["items"] = invoice_items
invoice_dict["payments"] = payments_total
return invoice_dict
@invoices_ext.post("/api/v1/invoice", status_code=HTTPStatus.CREATED)
async def api_invoice_create(
data: CreateInvoiceData, wallet: WalletTypeInfo = Depends(get_key_type)
):
invoice = await create_invoice_internal(wallet_id=wallet.wallet.id, data=data)
items = await create_invoice_items(invoice_id=invoice.id, data=data.items)
invoice_dict = invoice.dict()
invoice_dict["items"] = items
return invoice_dict
@invoices_ext.post("/api/v1/invoice/{invoice_id}", status_code=HTTPStatus.OK)
async def api_invoice_update(
data: UpdateInvoiceData,
invoice_id: str,
wallet: WalletTypeInfo = Depends(get_key_type),
):
invoice = await update_invoice_internal(wallet_id=wallet.wallet.id, data=data)
items = await update_invoice_items(invoice_id=invoice.id, data=data.items)
invoice_dict = invoice.dict()
invoice_dict["items"] = items
return invoice_dict
@invoices_ext.post(
"/api/v1/invoice/{invoice_id}/payments", status_code=HTTPStatus.CREATED
)
async def api_invoices_create_payment(
famount: int = Query(..., ge=1), invoice_id: str = None
):
invoice = await get_invoice(invoice_id)
invoice_items = await get_invoice_items(invoice_id)
invoice_total = await get_invoice_total(invoice_items)
invoice_payments = await get_invoice_payments(invoice_id)
payments_total = await get_payments_total(invoice_payments)
if payments_total + famount > invoice_total:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Amount exceeds invoice due."
)
if not invoice:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist."
)
price_in_sats = await fiat_amount_as_satoshis(famount / 100, invoice.currency)
try:
payment_hash, payment_request = await create_invoice(
wallet_id=invoice.wallet,
amount=price_in_sats,
memo=f"Payment for invoice {invoice_id}",
extra={"tag": "invoices", "invoice_id": invoice_id, "famount": famount},
)
except Exception as e:
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
return {"payment_hash": payment_hash, "payment_request": payment_request}
@invoices_ext.get(
"/api/v1/invoice/{invoice_id}/payments/{payment_hash}", status_code=HTTPStatus.OK
)
async def api_invoices_check_payment(invoice_id: str, payment_hash: str):
invoice = await get_invoice(invoice_id)
if not invoice:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist."
)
try:
status = await api_payment(payment_hash)
except Exception as exc:
logger.error(exc)
return {"paid": False}
return status
+1 -3
View File
@@ -90,9 +90,7 @@ async def lnurl_callback(
wallet_id=ls.wallet,
amount=int(amount_received / 1000),
memo=await track.fullname(),
description_hash=hashlib.sha256(
(await track.lnurlpay_metadata()).encode("utf-8")
).digest(),
unhashed_description=(await track.lnurlpay_metadata()).encode("utf-8"),
extra={"tag": "livestream", "track": track.id, "comment": comment},
)
+3 -5
View File
@@ -70,11 +70,9 @@ async def lnurl_callback(address_id, amount: int = Query(...)):
json={
"out": False,
"amount": int(amount_received / 1000),
"description_hash": hashlib.sha256(
(await address.lnurlpay_metadata(domain=domain.domain)).encode(
"utf-8"
)
).hexdigest(),
"description_hash": (
await address.lnurlpay_metadata(domain=domain.domain)
).encode("utf-8"),
"extra": {"tag": f"Payment to {address.username}@{domain.domain}"},
},
timeout=40,
@@ -13,7 +13,7 @@
Charge people for using your domain name...<br />
<a
href="https://github.com/lnbits/lnbits/tree/master/lnbits/extensions/lnaddress"
href="https://github.com/lnbits/lnbits-legend/tree/main/lnbits/extensions/lnaddress"
>More details</a
>
<br />
+2 -2
View File
@@ -6,7 +6,7 @@ from fastapi.params import Depends, Query
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user
from lnbits.core.services import check_invoice_status, create_invoice
from lnbits.core.services import check_transaction_status, create_invoice
from lnbits.decorators import WalletTypeInfo, get_key_type
from lnbits.extensions.lnaddress.models import CreateAddress, CreateDomain
@@ -229,7 +229,7 @@ async def api_address_send_address(payment_hash):
address = await get_address(payment_hash)
domain = await get_domain(address.domain)
try:
status = await check_invoice_status(domain.wallet, payment_hash)
status = await check_transaction_status(domain.wallet, payment_hash)
is_paid = not status.pending
except Exception as e:
return {"paid": False, "error": str(e)}
+1 -5
View File
@@ -130,10 +130,7 @@ async def lndhub_gettxs(
offset=offset,
exclude_uncheckable=True,
):
await payment.set_pending(
(await WALLET.get_payment_status(payment.checking_id)).pending
)
await asyncio.sleep(0.1)
await payment.check_status()
return [
{
@@ -181,7 +178,6 @@ async def lndhub_getuserinvoices(
await invoice.set_pending(
(await WALLET.get_invoice_status(invoice.checking_id)).pending
)
await asyncio.sleep(0.1)
return [
{
@@ -281,7 +281,13 @@
</q-card-section>
{% endraw %}
<q-card-actions align="right">
<q-btn flat label="CLOSE" color="primary" v-close-popup />
<q-btn
flat
label="CLOSE"
color="primary"
v-close-popup
@click="resetForm"
/>
</q-card-actions>
</q-card>
</q-dialog>
@@ -371,6 +377,9 @@
}
},
methods: {
resetForm() {
this.formDialog.data = {flatrate: false}
},
getTickets: function () {
var self = this
@@ -463,7 +472,7 @@
.then(function (response) {
self.forms.push(mapLNTicket(response.data))
self.formDialog.show = false
self.formDialog.data = {}
self.resetForm()
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
@@ -497,7 +506,7 @@
})
self.forms.push(mapLNTicket(response.data))
self.formDialog.show = false
self.formDialog.data = {}
self.resetForm()
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
+1 -3
View File
@@ -205,9 +205,7 @@ async def lnurl_callback(
wallet_id=device.wallet,
amount=lnurldevicepayment.sats / 1000,
memo=device.title,
description_hash=hashlib.sha256(
(await device.lnurlpay_metadata()).encode("utf-8")
).digest(),
unhashed_description=(await device.lnurlpay_metadata()).encode("utf-8"),
extra={"tag": "PoS"},
)
lnurldevicepayment = await update_lnurldevicepayment(
+1 -3
View File
@@ -87,9 +87,7 @@ async def api_lnurl_callback(request: Request, link_id):
wallet_id=link.wallet,
amount=int(amount_received / 1000),
memo=link.description,
description_hash=hashlib.sha256(
link.lnurlpay_metadata.encode("utf-8")
).digest(),
unhashed_description=link.lnurlpay_metadata.encode("utf-8"),
extra={
"tag": "lnurlp",
"link": link.id,
@@ -296,16 +296,17 @@
<q-btn
outline
color="grey"
icon="link"
@click="copyText(qrCodeDialog.data.pay_url, 'Link copied to clipboard!')"
>Shareable link</q-btn
>
><q-tooltip>Copy sharable link</q-tooltip>
</q-btn>
<q-btn
outline
color="grey"
icon="nfc"
@click="writeNfcTag(qrCodeDialog.data.lnurl)"
:disable="nfcTagWriting"
>
><q-tooltip>Write to NFC</q-tooltip>
</q-btn>
<q-btn
outline
@@ -314,7 +315,8 @@
type="a"
:href="qrCodeDialog.data.print_url"
target="_blank"
></q-btn>
><q-tooltip>Print</q-tooltip></q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
+3 -3
View File
@@ -96,7 +96,7 @@ async def api_link_create_or_update(
data.min *= data.fiat_base_multiplier
data.max *= data.fiat_base_multiplier
if "success_url" in data and data.success_url[:8] != "https://":
if data.success_url is not None and not data.success_url.startswith("https://"):
raise HTTPException(
detail="Success URL must be secure https://...",
status_code=HTTPStatus.BAD_REQUEST,
@@ -121,7 +121,7 @@ async def api_link_create_or_update(
return {**link.dict(), "lnurl": link.lnurl(request)}
@lnurlp_ext.delete("/api/v1/links/{link_id}")
@lnurlp_ext.delete("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(get_key_type)):
link = await get_pay_link(link_id)
@@ -136,7 +136,7 @@ async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(get_key_type
)
await delete_pay_link(link_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
return {"success": True}
@lnurlp_ext.get("/api/v1/rate/{currency}", status_code=HTTPStatus.OK)
+1 -3
View File
@@ -73,9 +73,7 @@ async def lnurl_callback(request: Request, item_id: int):
wallet_id=shop.wallet,
amount=int(amount_received / 1000),
memo=item.name,
description_hash=hashlib.sha256(
(await item.lnurlpay_metadata()).encode("utf-8")
).digest(),
unhashed_description=(await item.lnurlpay_metadata()).encode("utf-8"),
extra={"tag": "offlineshop", "item": item.id},
)
except Exception as exc:
+2 -2
View File
@@ -4,7 +4,7 @@ from fastapi import Depends, Query
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user, get_wallet
from lnbits.core.services import check_invoice_status, create_invoice
from lnbits.core.services import check_transaction_status, create_invoice
from lnbits.decorators import WalletTypeInfo, get_key_type
from . import paywall_ext
@@ -87,7 +87,7 @@ async def api_paywal_check_invoice(
status_code=HTTPStatus.NOT_FOUND, detail="Paywall does not exist."
)
try:
status = await check_invoice_status(paywall.wallet, payment_hash)
status = await check_transaction_status(paywall.wallet, payment_hash)
is_paid = not status.pending
except Exception:
return {"paid": False}
+1 -3
View File
@@ -77,9 +77,7 @@ async def api_lnurlp_callback(
wallet_id=link.wallet,
amount=int(amount_received / 1000),
memo="Satsdice bet",
description_hash=hashlib.sha256(
link.lnurlpay_metadata.encode("utf-8")
).digest(),
unhashed_description=link.lnurlpay_metadata.encode("utf-8"),
extra={"tag": "satsdice", "link": link.id, "comment": "comment"},
)
@@ -232,7 +232,7 @@
<q-btn
outline
color="grey"
icon="share"
icon="link"
@click="copyText(qrCodeDialog.data.pay_url, 'Link copied to clipboard!')"
><q-tooltip>Copy shareable link</q-tooltip></q-btn
>
@@ -421,7 +421,13 @@
this.formDialog = {
show: false,
fixedAmount: true,
data: {}
data: {
haircut: 0,
min_bet: 10,
max_bet: 1000,
currency: 'satoshis',
comment_chars: 0
}
}
},
updatePayLink(wallet, data) {
+4 -1
View File
@@ -34,12 +34,15 @@ async def display(request: Request, charge_id: str):
wallet = await get_wallet(charge.lnbitswallet)
onchainwallet_config = await get_config(charge.user)
inkey = wallet.inkey if wallet else None
mempool_endpoint = (
onchainwallet_config.mempool_endpoint if onchainwallet_config else None
)
return satspay_renderer().TemplateResponse(
"satspay/display.html",
{
"request": request,
"charge_data": charge.dict(),
"wallet_inkey": inkey,
"mempool_endpoint": onchainwallet_config.mempool_endpoint,
"mempool_endpoint": mempool_endpoint,
},
)
@@ -13,7 +13,7 @@
Charge people for using your subdomain name...<br />
<a
href="https://github.com/lnbits/lnbits/tree/master/lnbits/extensions/subdomains"
href="https://github.com/lnbits/lnbits-legend/tree/main/lnbits/extensions/subdomains"
>More details</a
>
<br />
+2 -2
View File
@@ -5,7 +5,7 @@ from fastapi.params import Depends
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user
from lnbits.core.services import check_invoice_status, create_invoice
from lnbits.core.services import check_transaction_status, create_invoice
from lnbits.decorators import WalletTypeInfo, get_key_type
from lnbits.extensions.subdomains.models import CreateDomain, CreateSubdomain
@@ -161,7 +161,7 @@ async def api_subdomain_make_subdomain(domain_id, data: CreateSubdomain):
async def api_subdomain_send_subdomain(payment_hash):
subdomain = await get_subdomain(payment_hash)
try:
status = await check_invoice_status(subdomain.wallet, payment_hash)
status = await check_transaction_status(subdomain.wallet, payment_hash)
is_paid = not status.pending
except Exception:
return {"paid": False}
+1 -1
View File
@@ -76,10 +76,10 @@ async def get_tipjars(wallet_id: str) -> Optional[list]:
async def delete_tipjar(tipjar_id: int) -> None:
"""Delete a TipJar and all corresponding Tips"""
await db.execute("DELETE FROM tipjar.TipJars WHERE id = ?", (tipjar_id,))
rows = await db.fetchall("SELECT * FROM tipjar.Tips WHERE tipjar = ?", (tipjar_id,))
for row in rows:
await delete_tip(row["id"])
await db.execute("DELETE FROM tipjar.TipJars WHERE id = ?", (tipjar_id,))
async def get_tip(tip_id: str) -> Optional[Tip]:
+1 -1
View File
@@ -57,7 +57,7 @@ async def api_create_tip(data: createTips):
name = name.replace('"', "''")
if not name:
name = "Anonymous"
description = f'"{name}": {message}'
description = f"{name}: {message}"
charge = await create_charge(
user=charge_details["user"],
data=CreateCharge(
+4
View File
@@ -23,3 +23,7 @@ class TPoS(BaseModel):
@classmethod
def from_row(cls, row: Row) -> "TPoS":
return cls(**dict(row))
class PayLnurlWData(BaseModel):
lnurl: str
@@ -138,8 +138,9 @@
hide-dropdown-icon
input-debounce="0"
new-value-mode="add-unique"
label="Tip % Options"
></q-select>
label="Tip % Options (hit enter to add values)"
><q-tooltip>Hit enter to add values</q-tooltip></q-select
>
<div class="row q-mt-lg">
<q-btn
unelevated
+110 -6
View File
@@ -14,7 +14,7 @@
<div class="row justify-center full-width">
<div class="col-12 col-sm-8 col-md-6 col-lg-4 text-center">
<h3 class="q-mb-md">{% raw %}{{ famount }}{% endraw %}</h3>
<h5 class="q-mt-none">
<h5 class="q-mt-none q-mb-sm">
{% raw %}{{ fsat }}{% endraw %} <small>sat</small>
</h5>
</div>
@@ -174,8 +174,21 @@
>
{% endraw %}
</h5>
<q-btn
outline
color="grey"
icon="nfc"
@click="readNfcTag()"
:disable="nfcTagReading"
></q-btn>
</div>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText(invoiceDialog.data.payment_request)"
>Copy invoice</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
@@ -240,7 +253,7 @@
name="check"
transition-show="fade"
class="text-light-green"
style="font-size: 40em"
style="font-size: min(90vw, 40em)"
></q-icon>
</q-dialog>
</q-page>
@@ -281,6 +294,8 @@
exchangeRate: null,
stack: [],
tipAmount: 0.0,
hasNFC: false,
nfcTagReading: false,
invoiceDialog: {
show: false,
data: null,
@@ -356,7 +371,7 @@
this.showInvoice()
},
submitForm: function () {
if (this.tip_options) {
if (this.tip_options && this.tip_options.length) {
this.showTipModal()
} else {
this.showInvoice()
@@ -399,9 +414,6 @@
dialog.show = false
self.complete.show = true
setTimeout(function () {
self.complete.show = false
}, 5000)
}
})
}, 3000)
@@ -410,6 +422,98 @@
LNbits.utils.notifyApiError(error)
})
},
readNfcTag: function () {
try {
const self = this
if (typeof NDEFReader == 'undefined') {
throw {
toString: function () {
return 'NFC not supported on this device or browser.'
}
}
}
const ndef = new NDEFReader()
const readerAbortController = new AbortController()
readerAbortController.signal.onabort = event => {
console.log('All NFC Read operations have been aborted.')
}
this.nfcTagReading = true
this.$q.notify({
message: 'Tap your NFC tag to pay this invoice with LNURLw.'
})
return ndef.scan({signal: readerAbortController.signal}).then(() => {
ndef.onreadingerror = () => {
self.nfcTagReading = false
this.$q.notify({
type: 'negative',
message: 'There was an error reading this NFC tag.'
})
readerAbortController.abort()
}
ndef.onreading = ({message}) => {
//Decode NDEF data from tag
const textDecoder = new TextDecoder('utf-8')
const record = message.records.find(el => {
const payload = textDecoder.decode(el.data)
return payload.toUpperCase().indexOf('LNURL') !== -1
})
const lnurl = textDecoder.decode(record.data)
//User feedback, show loader icon
self.nfcTagReading = false
self.payInvoice(lnurl, readerAbortController)
this.$q.notify({
type: 'positive',
message: 'NFC tag read successfully.'
})
}
})
} catch (error) {
this.nfcTagReading = false
this.$q.notify({
type: 'negative',
message: error
? error.toString()
: 'An unexpected error has occurred.'
})
}
},
payInvoice: function (lnurl, readerAbortController) {
const self = this
return axios
.post(
'/tpos/api/v1/tposs/' +
self.tposId +
'/invoices/' +
self.invoiceDialog.data.payment_request +
'/pay',
{
lnurl: lnurl
}
)
.then(response => {
if (!response.data.success) {
this.$q.notify({
type: 'negative',
message: response.data.detail
})
}
readerAbortController.abort()
})
},
getRates: function () {
var self = this
axios.get('https://api.opennode.co/v1/rates').then(function (response) {
+63 -1
View File
@@ -1,7 +1,9 @@
from http import HTTPStatus
import httpx
from fastapi import Query
from fastapi.params import Depends
from lnurl import decode as decode_lnurl
from loguru import logger
from starlette.exceptions import HTTPException
@@ -12,7 +14,7 @@ from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from . import tpos_ext
from .crud import create_tpos, delete_tpos, get_tpos, get_tposs
from .models import CreateTposData
from .models import CreateTposData, PayLnurlWData
@tpos_ext.get("/api/v1/tposs", status_code=HTTPStatus.OK)
@@ -79,6 +81,66 @@ async def api_tpos_create_invoice(
return {"payment_hash": payment_hash, "payment_request": payment_request}
@tpos_ext.post(
"/api/v1/tposs/{tpos_id}/invoices/{payment_request}/pay", status_code=HTTPStatus.OK
)
async def api_tpos_pay_invoice(
lnurl_data: PayLnurlWData, payment_request: str = None, tpos_id: str = None
):
tpos = await get_tpos(tpos_id)
if not tpos:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
)
lnurl = (
lnurl_data.lnurl.replace("lnurlw://", "")
.replace("lightning://", "")
.replace("LIGHTNING://", "")
.replace("lightning:", "")
.replace("LIGHTNING:", "")
)
if lnurl.lower().startswith("lnurl"):
lnurl = decode_lnurl(lnurl)
else:
lnurl = "https://" + lnurl
async with httpx.AsyncClient() as client:
try:
r = await client.get(lnurl, follow_redirects=True)
if r.is_error:
lnurl_response = {"success": False, "detail": "Error loading"}
else:
resp = r.json()
if resp["tag"] != "withdrawRequest":
lnurl_response = {"success": False, "detail": "Wrong tag type"}
else:
r2 = await client.get(
resp["callback"],
follow_redirects=True,
params={
"k1": resp["k1"],
"pr": payment_request,
},
)
resp2 = r2.json()
if r2.is_error:
lnurl_response = {
"success": False,
"detail": "Error loading callback",
}
elif resp2["status"] == "ERROR":
lnurl_response = {"success": False, "detail": resp2["reason"]}
else:
lnurl_response = {"success": True, "detail": resp2}
except (httpx.ConnectError, httpx.RequestError):
lnurl_response = {"success": False, "detail": "Unexpected error occurred"}
return lnurl_response
@tpos_ext.get(
"/api/v1/tposs/{tpos_id}/invoices/{payment_hash}", status_code=HTTPStatus.OK
)
+19 -17
View File
@@ -6,17 +6,20 @@ Monitor an extended public key and generate deterministic fresh public keys with
You can now use this wallet on the LNBits [SatsPayServer](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/satspay/README.md) extension
<a href="https://www.youtube.com/watch?v=rQMHzQEPwZY">Video demo</a>
### Wallet Account
- a user can add one or more `xPubs` or `descriptors`
- the `xPub` fingerprint must be unique per user
- the `xPub` 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`)
- 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`)
- an account can be added `From Hardware Device`
### Scan Blockchain
- when the user clicks `Scan Blockchain`, the wallet will loop over the all addresses (for each account)
@@ -48,33 +51,32 @@ You can now use this wallet on the LNBits [SatsPayServer](https://github.com/lnb
- shows the UTXOs for all wallets
- there can be multiple UTXOs for the same address
### Make Payment
### New 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
- `Show Change` allows to select from which account the change address will be selected (defaults to the first one)
- `Show Custom Fee` allows to manually select the fee
- it defaults to the `Medium` value at the moment the `New 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`)
### Check & Send
- creates the PSBT and sends it to the Hardware Wallet
- a confirmation will be shown for each Output and for the Fee
- after the user confirms the addresses and amounts, the transaction will be signed on the Hardware Device
### Share PSBT
- Show the PSBT without sending it to the Hardware Wallet
## 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)
![image](https://user-images.githubusercontent.com/2951406/183087898-b91f5243-8ed9-4a14-9e57-7bb4f1fd43ef.png)
- screenshot 3:
![image](https://user-images.githubusercontent.com/2951406/177333755-4a9118fb-3eaf-43d6-bc7e-c3d8c80bc61e.png)

Some files were not shown because too many files have changed in this diff Show More