Compare commits

...
89 Commits
Author SHA1 Message Date
Tiago VasconcelosandGitHub 8aa1716e32 Add detailed CSV export option (#2618) 2024-09-04 13:04:55 +03:00
fbd22c1a22 fix: QR request case (#2668)
* fix the hotfix to fix a fuckup
* fixup!

---------

Co-authored-by: dni  <office@dnilabs.com>
2024-09-03 16:05:55 +02:00
dni ⚡andGitHub e8a6870d7a fix: update lnurl for backwards compatibility (#2666) 2024-09-03 12:03:26 +02:00
Tiago VasconcelosandGitHub 937936ff33 fix fuck up on refactor (#2665)
Bug introduced in #2656
2024-09-03 12:03:09 +02:00
Pavol RusnakandGitHub ab3eb967b4 chore: update pyproject dependencies (#2619) 2024-09-03 11:09:36 +02:00
dni ⚡andGitHub 6341e1edaf feat: add baseurl to admin ui settings (#2644)
* feat: add baseurl to admin ui settings

server tab with a hint that it is currently not used.

i ran into an issue developing an extension where i needed to know the
url inside a task, where i cannot pass the `Request` object.
so i depend on `settings.lnbits_baseurl` there
2024-09-03 07:08:56 +02:00
69d518bac0 Uncomment LNBITS_ALLOWED_FUNDING_SOURCES (#2663)
* Uncomment LNBITS_ALLOWED_FUNDING_SOURCES

I don't see why this exists

---------

Co-authored-by: dni  <office@dnilabs.com>
2024-08-30 18:51:28 +02:00
dni ⚡andGitHub 9db2429a45 refactor: move migrate_databases to core helpers (#2636)
commands.py should be used for cli commands
2024-08-30 18:48:50 +02:00
dd90dec768 feat: frontend gradient option (#2561)
---------

Co-authored-by: dni  <office@dnilabs.com>
2024-08-30 18:16:24 +02:00
dni ⚡andGitHub eb37a064ad feat: vue components lnbits-dynamic-fields validation (#2645)
* feat: vue components lnbits-dynamic-fields validation

- add validation to fields if require = true
- add type hidden field (can be useful for create/update dialog with
passing item_id into update hidden field)
2024-08-30 18:06:55 +02:00
Tiago VasconcelosandGitHub 28df100d9a Fix lnurl fallback (#2656)
* Fix lnurl fallback

Wrong string case search

Closes #1599

* optimize the code

* one more clean up
2024-08-30 12:48:24 +01:00
6e6b387b7d feat: log with console.error on notifyApiError (#2646)
* feat: log with `console.error` on `notifyApiError`

usually why have code like that
```
    getAudit() {
      LNbits.api
        .request('GET', '/admin/api/v1/audit',
this.g.user.wallets[0].adminkey)
        .then(response => {
          this.auditData = response.data
          xonst myerror = isSwallowed()
        })
        .catch(function (error) {
          LNbits.utils.notifyApiError(error)
        })
    },

```
which if you make a mistake hides your error. logging console.error here
again makes it easier to see what is happening while developing and
doesnt hurt for the user aswell

* chore: bundle

* fixup!

---------

Co-authored-by: Arc <33088785+arcbtc@users.noreply.github.com>
2024-08-30 12:23:11 +01:00
Tiago VasconcelosandGitHub 209cf7fbe0 fix chips on server page (#2654)
Fix chips not having the remove functionality

Closes #2471
2024-08-30 12:19:22 +01:00
Pavol RusnakandGitHub 596167f443 chore: update install instructions for developers (#2652) 2024-08-30 12:18:42 +01:00
Gonçalo ValérioandGitHub 4732c4b296 add proper validation for the unit field when creating an invoice (#2647) 2024-08-30 12:17:52 +01:00
Tiago VasconcelosandGitHub 405a2f0776 fix decode info (#2655)
Closes #1837
2024-08-30 12:15:29 +01:00
dni ⚡andGitHub 304ad3035b feat: add generic lnurl error response handler (#2638)
* feat: add generic lnurl error response handler

this is used multiple times in extensions to safeguard `views_lnurl.py`
endpoint to not return a wrong lnurl error response.

you use it by just setting following on your lnurl router/view

```
withdraw_ext_lnurl = APIRouter(prefix="/api/v1/lnurl")
withdraw_ext_lnurl.route_class = LNURLErrorResponseHandler
```
2024-08-30 13:12:55 +02:00
Pavol RusnakandGitHub 8cffda5a55 chore: update package.json (#2635)
* chore: update package.json

* chore: make bundle
2024-08-29 22:51:41 +02:00
Tiago VasconcelosandGitHub cbe858b385 show wallet names in dropdown (#2653)
Extensions will need it also

Fix #2517
2024-08-29 21:43:00 +01:00
dni ⚡andGitHub 65ecca2507 feat: add rate endpoint per currency (#2641)
this is already used and implemented by tpos, lnurlp and i probably need
it for satspay aswell
2024-08-20 10:52:39 +01:00
1900cf9aa4 feat: add boltz client fundingsource (#2358)
* feat: add boltz client standalone fundingsource
WIP.
https://docs.boltz.exchange/v/boltz-client

this fundingsource utilizing the boltz client standalone function: https://github.com/BoltzExchange/boltz-client/pull/123
this makes him act like a lightning node while submarine swapping everything on liquid network. like aqua does in its wallet.

* feat: paid_invoices_stream

* feat: proper invoice and payment status check

* feat: authenticate over insecure channel aswell

* chore: lint

* docs: add more setup instructions

* chore: add `boltz_client_cert` in frontend

* feat: populate fee_msat in get_payment_status and get_invoice_status

* fixup!

* chore: bundle

* added boltz logo

* add BoltzWallet to __all__

* chore: bump grpcio and protobuf deps and add grpcio-tools as dev dependency

* chore: update protos

* feat: pass description when creating swap

* fixup!

* chore: bundle

---------

Co-authored-by: jackstar12 <jkranawetter05@gmail.com>
Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-08-20 10:38:30 +01:00
Tiago VasconcelosandGitHub 296b1dfa9a add copy buttons (#2643) 2024-08-19 15:23:03 +02:00
dni ⚡andGitHub b14e0e4cc6 feat: add label to lnbits-dynamic-fields (#2637)
* feat: add label to `lnbits-dynamic-fields`

* chore: bundle
2024-08-14 16:52:19 +02:00
Ross SavageandGitHub fb585402dd update breez-sdk to 0.5.2 (#2639) 2024-08-14 15:42:12 +01:00
dni ⚡andGitHub 63f246908e fix: add back lnurl wallet (#2625)
* fix: add back lnurl wallet
* add comment from withdraw ext
* fixup, when you use unique links
2024-08-08 11:09:01 +02:00
dni ⚡andGitHub 8ac827f5a4 fix: export nwc wallet (#2632)
quick fix, #2630
2024-08-08 07:34:31 +02:00
dni ⚡andGitHub 74d4ddd312 feat: use __all__ to export deps from __init__.py (#2630)
* feat: export wallets
* remove linting exception
2024-08-08 07:29:21 +02:00
dni ⚡andGitHub 40ffa7dea0 test: refactor to not use paid_invoices stream for real invoice tests (#2628) 2024-08-07 16:19:53 +02:00
dni ⚡andGitHub ddb8fcb986 feat: add typing for tasks (#2629)
* feat: add typing for tasks

* fixup!
2024-08-07 09:57:15 +02:00
27b9e8254c feat: NWC Funding source #2579 (#2631)
* feat: nwc funding source

* implement paid_invoices_stream, fix for unsettled invoices where settled_at is present but None

* cancel pending_payments_lookup_task on cleanup

* Rename subscription_timeout_task to timeout_task

* ensure preimage is not None

* Improve readability, return failed status on expiration in get_payment_status, ensure result_type is checked after error (some implementations might not set a result_type on error)

* fetch account info when possible

* workaround possible race condition on some nwc service providers, improve performance of fallback by using payment_hash from bolt11 invoice

* fundle

* make format

* fix formatting

* fix C901 `_on_message` is too complex (21 > 16)

* format

* fix lint

* format

* fix tests/wallets/test_nwc_wallets.py:80:11: C901 `run` is too complex (17 > 16)

* fix padding

* fix documentation for _verify_event method

* refactoring and fixes

* Split NWCWallet - NWCConnection

* refactor class methods into helpers

* update bundle

* format

* catch NWCError failure codes

* format and fix

* chore: bundle

* add example

* typos

---------

Co-authored-by: Riccardo Balbo <riccardo0blb@gmail.com>
Co-authored-by: benarc <ben@arc.wales>
Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-08-07 09:56:53 +02:00
daa4b92331 fix: db helpers to be used with timestamps (#2627)
* fix: db helpers to be used with timestamps

those helpers are used in boltz extension and they did not take dates
into consideration yet

* vlad picks
* refactor get_placeholder

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-08-06 12:43:44 +02:00
0015314e11 feat: add Breez SDK wallet (#1897)
* add Breez SDK wallet
* use more description status classes
* fix: add try-except

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2024-08-06 10:06:21 +02:00
blackcoffeexbtandGitHub 235f8a6c19 Invoice creation UI: Replace input mask with pattern and inputmode (#2623)
* Invoice creation UI. Replace input mask with pattern and inputmode
2024-08-02 09:26:10 +03:00
Pavol RusnakandGitHub bab399f825 fix: run cachix nix action for all branches (#2624) 2024-08-01 21:54:15 +02:00
dni ⚡andGitHub 646a604221 feat: add release flow for release candidates (#2620)
- pushes docker tag
- pushes to pypi for extensions to update
- generates a prerelease on github
2024-08-01 13:05:02 +02:00
7d8fad267a fix: set a maximium sleep time when retrying to connect to the funding source (#2622)
---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-08-01 13:02:55 +02:00
dni ⚡andGitHub ce0aff206d fix: skip pending payment check on void (#2610)
skips payment check if voidwallet is active
2024-07-31 15:06:31 +03:00
80e7b9639d feat: filter response fields for /api/v1/payments/decode (#2612)
* feat: filter response fields

* chore: `make format`

* chore: comment

* Update lnbits/helpers.py

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>

* Update lnbits/helpers.py

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>

* chore: code format

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-07-31 14:36:42 +03:00
dni ⚡andGitHub 94caa2e1ba fix: still flaky regtest (#2617)
increase wait
2024-07-31 12:05:07 +02:00
dni ⚡andGitHub b0a66e8cf5 fix: random exceptions inside logs in middleware (#2608)
closes #2599

special thanks to bitkarrot for figuring that out, it was actually
initialized twice, the decorator alone is enough!

also was issue for first_install middleware
2024-07-31 12:00:40 +02:00
dni ⚡andGitHub ffba71c0ce test: fix flaky regtest (#2616)
* add log for invoice success
* add internal flag
* sleeping inside the tasks to not block
* sleep was wrong decrease wait time
2024-07-31 11:41:19 +02:00
b41705167f feat: extra log for tests phases (#2604)
* fix: set `corelightning_rest_cert`
* feat: extra log for tests phases

---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2024-07-31 11:40:25 +02:00
blackcoffeexbtandGitHub 0387db3b55 Update installation.md (#2615)
mkdir data no longer needed for standard installation
2024-07-31 10:40:17 +03:00
Tiago VasconcelosandGitHub 38ef1b0061 feat: add a copy wallet button (#2613)
* feat: add a copy wallet button
* fix: make button consistent
* feat: hide API keys
Closes #2462
* fix: consistency in icons
* fix: missing end tag
2024-07-31 08:51:17 +02:00
Pavol RusnakandGitHub 19c231a2f4 chore: update pyln-client to 24.5 (#2614) 2024-07-30 18:04:28 +02:00
BitkarrotandGitHub 026c9b5155 Fix for LNURLp with ZBD wallet (#2609) 2024-07-30 18:04:07 +02:00
Pavol RusnakandGitHub c834929f8b add bitpay and yadio fiat rate providers + increase precision of blockchain.info fiat rate provider (#2605)
* feat: increase precision of blockchain.info fiat rate provider
* feat: add bitpay and yadio fiat rate providers
2024-07-30 13:44:08 +02:00
dni ⚡andGitHub 97de4eda18 feat: add exclude_to list for btcprice providers (#2602) 2024-07-26 11:31:24 +02:00
dni ⚡andGitHub a844711530 bug: removing admin user was not persistent (#2603)
thanks for reporting, that was a serious bug. the indentation was wrong
2024-07-26 11:15:34 +02:00
dni ⚡andGitHub e2522b358a chore: bump version to 0.12.11 (#2601)
this is needed for testing the new lndhub version
2024-07-25 10:19:18 +02:00
dni ⚡andGitHub 8f761dfd0f refactor: add status column to apipayments (#2537)
* refactor: add status column to apipayments

keep track of the payment status with an enum and persist it as string
to db. `pending`, `success`, `failed`.

- database migration
- remove deleting of payments, failed payments stay
2024-07-24 16:47:26 +03:00
Pavol RusnakandGitHub b14d36a0aa chore(deps): replace python-jose with pyjwt (#2591)
python-jose had no release since 3 years
2024-07-24 10:42:47 +02:00
dni ⚡andGitHub dbb689c5c5 chore: update version to 0.12.10 (#2597) 2024-07-23 14:06:55 +02:00
dni ⚡andGitHub 2167aa398f fix: annotations for models.py (#2595) 2024-07-23 14:03:27 +02:00
dni ⚡andGitHub eb8d2f312f fix: install extensions async (#2596)
so it does not block webserver start on saas instances and comes up
faster if extensions are reinstalled
2024-07-23 14:01:34 +02:00
jackstar12andGitHub f9133760fc fix: proper status check in invoice paid callback (#2592)
status fields like preimage and fee_msat are never updated otherwise
2024-07-22 16:59:26 +02:00
7298c4664b feat: Blink funding source (#2477)
* feat: Blink funding source

* chore: make bundle

* Blink review 01 (#2575)

* refactor: mark `graphql_query` as private (`_` prefix)

* feat: set default value for `blink_api_endpoint`

* fix: raise if HTTP call failed

* refactor: move private method to the bottom

* refactor: make `wallet_id` a property

* fix: key mapping for attribute

* chore: fix `mypy`

* chore: fix `make check`

* refactor: extract query strings

* refactor: extract `BlinkGrafqlQueries` class

* chore: code clean-up

* chore: add `try-catch`

* refactor: extract `tx_query`

* chore: format grapfhql queries

* fix: set funding source class

* chore: `make format`

* fix: test by following the other patterns

* Update docs/guide/wallets.md

Co-authored-by: openoms <43343391+openoms@users.noreply.github.com>

* feat: add websocket connection to blink (#2577)

* feat: add websocket connection to blink

* feat: close websocket on shutdown

* feat: add `blink_ws_endpoint` to the UI

* fix: use `SEND` tx for `settlementFee`

* refactor: remove `else` when `if` has `return`

* fix: remove test env file

---------

Co-authored-by: bitkarrot <73979971+bitkarrot@users.noreply.github.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
Co-authored-by: openoms <43343391+openoms@users.noreply.github.com>
2024-07-19 21:32:02 +01:00
Pavol RusnakandGitHub eda7e35c61 chore: update python deps (#2586) 2024-07-16 07:59:04 +02:00
7d1e22c7de fix: always create default wallet for user (#2580)
* fix: always create default wallet for user

* no assert in api

---------

Co-authored-by: dni  <office@dnilabs.com>
2024-07-15 13:34:26 +03:00
michael1011andGitHub 1bee84d419 chore: optimize Dockerfile (#2576)
By doing a multi-stage Docker build, the size
of the final image is reduced by ~45%.
2024-07-10 14:22:44 +02:00
a00292544f fix: lib secp256k fails building (#2572)
Co-authored-by: michael1011 <me@michael1011.at>
2024-07-09 15:51:40 +02:00
ArcandGitHub fe14c2cd83 Fixed ugly adv description card (#2570) 2024-07-09 14:03:11 +01:00
schneimiandGitHub 760f11f1ce Update wallet.js (#2569)
enable 'Read' button after 'Paste from clipboard' click
2024-07-09 13:57:03 +01:00
0e1090b717 docs: nginx websocket support for reverse proxy (#2564)
* docs: nginx websocket support for reverse proxy

websocket weren't working with that conf

* cleanup

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-07-09 13:56:31 +01:00
Vlad StanandGitHub b2564154cd [feat] add default_user_extensions setting (#2571)
* feat: add `lnbits_user_default_extensions` to `settings`

* refactor: extract `create_user_account` in services

* feat: auto enable user extensions
2024-07-09 13:55:05 +01:00
Vlad StanandGitHub fb17611207 [feat] add authenticated_user_id decorator (#2566)
* feat: add `optional_user_id` decorator
2024-07-04 17:24:20 +03:00
ArcandGitHub 7f628948c9 bug: carousel and adv desc fix (#2562)
* Carousel image fix
* pushed carousel nav to top as well so yt controls are accessible
* Added some extra breathing room for the adv description
2024-06-26 13:21:34 +02:00
dni ⚡andGitHub cbe0861439 feat: improve on extension page layout (#2558)
* feat: improve on extension page layout
give it more luft and simplify it a bit. also improved responsiveness
* show description now, ben
2024-06-26 13:07:13 +02:00
dni ⚡andGitHub e9d6160f4d chore: prepare for 0.12.9 (#2555) 2024-06-19 12:56:34 +02:00
Vlad StanandGitHub eacdd432b2 [feat] Extension details page (#2544)
* feat: add empty dialog

* feat: add `details_link` field for extension

* feat: show info icon if `details_link` present

* feat: add extension details endpoint

* feat: first details page

* feat: carousel working

* feat: full screen

* fix: layout

* fix: repo site

* fix: release icon

* fix: repo link

* feat: terms and conditions partial

* chore: fix typing

* fix: info icon layout

* chore: add try-catch

* feat: layout improvements

* feat: add video link

* fix: show terms and conditions

* chore: code format

* feat: add `details_link`

* fix: github release details

* feat: add close button

* chore: code clean-up

* chore: revert some changes

* feat: i18n

* chore: `make bundle`

* chore: make bundle

* feat: terms and conditions is a link now
2024-06-19 11:52:18 +01:00
Pavol RusnakandGitHub 76e8d72d0d LNBits -> LNbits typo (#2552)
* LNBits -> LNbits
2024-06-19 09:27:26 +03:00
dbacf7e8c1 "Failed to connect to https://ws:" crash (#2548)
* Update base.py

fixing bug in normalize_enpoints

* Update lnbits/wallets/base.py

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>

---------

Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
2024-06-17 17:36:15 +02:00
dni ⚡andGitHub b6d99b09cf fix: npm packages with high severity issue (#2551)
updating `braces` npm package
2024-06-17 17:17:53 +02:00
5c21e7f9ed Update installation.md (#2549)
Minor rewording

---------

Co-authored-by: dni  <office@dnilabs.com>
2024-06-17 11:24:31 +02:00
14e9c7d9dc Fix typo of "LNbits" in list of funding sources. (#2546)
* Fix typo of "LNbits" in list of funding sources.


---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2024-06-17 09:08:22 +02:00
blackcoffeexbtandGitHub b3368d89f4 Nice formatting of funding source titles in Server admin (#2543)
* Display friendly funding source titles in funding sources list

* Sort funding options select alphabetically

* Run make bundle
2024-06-10 23:37:09 +02:00
83b89851a5 fix: phoenixd wallet description field supports lnurlp (#2514)
* Fix for phoenixd and lnurlp nostr usage

- Support description, restricted to 128 characters
- Support descriptionHash

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
Co-authored-by: Vic <vic@example.com>
2024-05-29 13:47:10 +03:00
dni ⚡andGitHub 2db5a83f4e chore: update LNbits to 0.12.8 (#2539) 2024-05-28 13:59:13 +02:00
d72cf40439 [feat] Pay to enable extension (#2516)
* feat: add payment tab

* feat: add buttons

* feat: persist `pay to enable` changes

* fix: do not disable extension on upgrade

* fix: show releases tab first

* feat: extract `enableExtension` logic

* refactor: rename routes

* feat: show dialog for paying extension

* feat: create invoice to enable

* refactor: extract enable/disable extension logic

* feat: add extra info to UserExtensions

* feat: check payment for extension enable

* fix: parsing

* feat: admins must not pay

* fix: code checks

* fix: test

* refactor: extract extension activate/deactivate to the `api` side

* feat: add `get_user_extensions `

* feat: return explicit `requiresPayment`

* feat: add `isPaymentRequired` to extension list

* fix: `paid_to_enable` status

* fix: ui layout

* feat: show QR Code

* feat: wait for invoice to be paid

* test: removed deprecated test and dead code

* feat: add re-check button

* refactor: rename paths for endpoints

* feat: i18n

* feat: add `{"success": True}`

* test: fix listener

* fix: rebase errors

* chore: update bundle

* fix: return error status code for the HTML error pages

* fix: active extension loading from file system

* chore: temp commit

* fix: premature optimisation

* chore: make check

* refactor: remove extracted logic

* chore: code format

* fix: enable by default after install

* fix: use `discard` instead of `remove` for `set`

* chore: code format

* fix: better error code

* fix: check for stop function before invoking

* feat: check if the wallet belongs to the admin user

* refactor: return 402 Requires Payment

* chore: more typing

* chore: temp checkout different branch for tests

* fix: too much typing

* fix: remove try-except

* fix: typo

* fix: manual format

* fix: merge issue

* remove this line

---------

Co-authored-by: dni  <office@dnilabs.com>
2024-05-28 12:07:33 +01:00
Vlad StanandGitHub 7c68a02eee [feat] Check payment tag (#2522)
* feat: check if the payment is made for an extension that the user disabed
2024-05-24 17:24:59 +03:00
Vlad StanandGitHub 93965bc5b6 [test] webpush_api endpoints (#2534)
* test: webpush_api endpoints

* fix: SQL quote for `user`
2024-05-23 23:23:32 +02:00
Vlad StanandGitHub ae60b4517c [fix] SQL error for create webpush notification (#2533)
* fix: replace all SQL `user = ?` with `"user"" = ?`
* fix: surround with try-catch
* fix: bad double quote
2024-05-23 10:16:00 +02:00
dni ⚡andGitHub b15596d045 fix-fiat-balance (#2515) 2024-05-23 07:47:56 +02:00
Vlad StanandGitHub 07f0dc80f8 [fix] editable fields with default=None must be Optional (#2530)
* fix: optional fields

* fix: bad uppercase field
2024-05-22 14:03:52 +01:00
Pavol RusnakandGitHub 5f64c298c9 chore(i18n-ai-tool): use gpt-4o model + chore(i18n): update strings using the AI tool (#2511)
* chore(i18n-ai-tool): use gpt-4o model

* chore(i18n): update strings using the AI tool
2024-05-22 14:18:23 +03:00
dni ⚡andGitHub 5b056ce07e feat: update latest docker tag on release (#2528) 2024-05-22 13:47:41 +03:00
Vlad Stananddni ⚡ 44b458ebb8 [fix] check user extension access (#2519)
* feat: check user extension access
* fix: handle upgraded extensions
2024-05-22 11:10:35 +02:00
Vlad Stan d4da96597e chore: project version bump 2024-05-22 12:04:56 +03:00
Vlad StanandGitHub 6a0b645316 hotfix: check for lnbits_site_description setting (#2527)
* fix: make `lnbits_site_description` optional again
2024-05-22 10:53:34 +02:00
124 changed files with 13630 additions and 3041 deletions
+1
View File
@@ -3,6 +3,7 @@ data
docker
docs
tests
node_modules
lnbits/static/css/*
lnbits/static/bundle.js
+25 -1
View File
@@ -28,7 +28,7 @@ PORT=5000
######################################
# which fundingsources are allowed in the admin ui
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet"
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet"
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
@@ -87,10 +87,21 @@ LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
ALBY_API_ENDPOINT=https://api.getalby.com/
ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
# BoltzWallet
BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroon" # or HEXSTRING
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" # or HEXSTRING
BOLTZ_CLIENT_WALLET="lnbits"
# ZBDWallet
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
ZBD_API_KEY=ZBD_ACCESS_TOKEN
# BlinkWallet
BLINK_API_ENDPOINT=https://api.blink.sv/graphql
BLINK_WS_ENDPOINT=wss://ws.blink.sv/graphql
BLINK_TOKEN=BLINK_TOKEN
# PhoenixdWallet
PHOENIXD_API_ENDPOINT=http://localhost:9740/
PHOENIXD_API_PASSWORD=PHOENIXD_KEY
@@ -107,11 +118,22 @@ LNBITS_DENOMINATION=sats
ECLAIR_URL=http://127.0.0.1:8283
ECLAIR_PASS=eclairpw
# NWCWalllet
NWC_PAIRING_URL="nostr+walletconnect://000...000?relay=example.com&secret=123"
# LnTipsWallet
# Enter /api in LightningTipBot to get your key
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
LNTIPS_API_ENDPOINT=https://ln.tips
# BreezSdkWallet
BREEZ_API_KEY=KEY
BREEZ_GREENLIGHT_SEED=SEED
# A Greenlight invite code or Greenlight partner certificate/key can be used
BREEZ_GREENLIGHT_INVITE_CODE=CODE
BREEZ_GREENLIGHT_DEVICE_KEY="/path/to/breezsdk/device.pem" # or BASE64/HEXSTRING
BREEZ_GREENLIGHT_DEVICE_CERT="/path/to/breezsdk/device.crt" # or BASE64/HEXSTRING
######################################
####### Auth Configurations ##########
######################################
@@ -162,6 +184,8 @@ LNBITS_ADMIN_USERS=""
# Extensions only admin can access
LNBITS_ADMIN_EXTENSIONS="ngrok, admin"
# Extensions enabled by default when a user is created
LNBITS_USER_DEFAULT_EXTENSIONS="lnurlp"
# Start LNbits core only. The extensions are not loaded.
# LNBITS_EXTENSIONS_DEACTIVATE_ALL=true
+3 -4
View File
@@ -28,11 +28,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v24
- uses: cachix/install-nix-action@v27
with:
nix_path: nixpkgs=channel:nixos-23.11
- uses: cachix/cachix-action@v13
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/cachix'
nix_path: nixpkgs=channel:nixos-24.05
- uses: cachix/cachix-action@v15
with:
name: lnbits
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
+44
View File
@@ -0,0 +1,44 @@
name: release-rc
on:
push:
tags:
- "*-rc[0-9]"
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Create github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
gh release create "$tag" --generate-notes --prerelease
docker:
needs: [ release ]
uses: ./.github/workflows/docker.yml
with:
tag: ${{ github.ref_name }}
secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
pypi:
runs-on: ubuntu-latest
steps:
- name: Install dependencies for building secp256k1
run: |
sudo apt-get update
sudo apt-get install -y build-essential automake libtool libffi-dev libgmp-dev
- uses: actions/checkout@v4
- name: Build and publish to pypi
uses: JRubics/poetry-publish@v1.15
with:
pypi_token: ${{ secrets.PYPI_API_KEY }}
+13
View File
@@ -31,9 +31,22 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
docker-latest:
needs: [ release ]
uses: ./.github/workflows/docker.yml
with:
tag: latest
secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
pypi:
runs-on: ubuntu-latest
steps:
- name: Install dependencies for building secp256k1
run: |
sudo apt-get update
sudo apt-get install -y build-essential automake libtool libffi-dev libgmp-dev
- uses: actions/checkout@v4
- name: Build and publish to pypi
uses: JRubics/poetry-publish@v1.15
+34 -9
View File
@@ -1,4 +1,4 @@
FROM python:3.10-slim-bookworm
FROM python:3.10-slim-bookworm AS builder
RUN apt-get clean
RUN apt-get update
@@ -7,19 +7,44 @@ RUN apt-get install -y curl pkg-config build-essential libnss-myhostname
RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH"
WORKDIR /app
# Only copy the files required to install the dependencies
COPY pyproject.toml poetry.lock ./
RUN mkdir data
ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache
RUN poetry install --only main
FROM python:3.10-slim-bookworm
# needed for backups postgresql-client version 14 (pg_dump)
RUN apt install -y postgresql-common ca-certificates
RUN install -d /usr/share/postgresql-common/pgdg
RUN curl -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc --fail https://www.postgresql.org/media/keys/ACCC4CF8.asc
RUN sh -c 'echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
RUN apt-get update
RUN apt-get install -y postgresql-client-14
RUN apt-get update && apt-get -y upgrade && \
apt-get -y install gnupg2 curl lsb-release && \
sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' && \
curl -s https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \
apt-get update && \
apt-get -y install postgresql-client-14 postgresql-client-common && \
apt-get clean all && rm -rf /var/lib/apt/lists/*
RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH"
ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
VIRTUAL_ENV=/app/.venv \
PATH="/app/.venv/bin:$PATH"
WORKDIR /app
COPY . .
RUN mkdir data
COPY --from=builder /app/.venv .venv
RUN poetry install --only main
+4
View File
@@ -39,24 +39,28 @@ dev:
poetry run lnbits --reload
test-wallets:
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest tests/wallets
test-unit:
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest tests/unit
test-api:
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest tests/api
test-regtest:
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest tests/regtest
+14 -2
View File
@@ -11,13 +11,16 @@ Thanks for contributing :)
# Run
This starts the lnbits uvicorn server
Follow the [Basic installation: Option 1 (recommended): poetry](https://docs.lnbits.org/guide/installation.html#option-1-recommended-poetry)
guide to install poetry and other dependencies.
Then you can start LNbits uvicorn server with:
```bash
poetry run lnbits
```
This starts the lnbits uvicorn with hot reloading.
Or you can use the following to start uvicorn with hot reloading enabled:
```bash
make dev
@@ -25,6 +28,15 @@ make dev
poetry run lnbits --reload
```
You might need the following extra dependencies on clean installation of Debian:
```
sudo apt install nodejs
sudo apt install npm
npm install
sudo apt-get install autoconf libtool libpg-dev
```
# Precommit hooks
This ensures that all commits adhere to the formatting and linting rules.
+11 -3
View File
@@ -6,9 +6,9 @@ nav_order: 2
# Basic installation
You can choose between four package managers, `poetry` and `nix`
The following sections explain how to install LNbits using varions package managers: `poetry`, `nix`, `Docker` and `Fly.io`.
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).
Note that by default LNbits uses SQLite as its database, which is simple and effective but you can configure it to use PostgreSQL instead which is also described in a section below.
## Option 1 (recommended): poetry
@@ -44,7 +44,6 @@ git checkout main
poetry env use python3.9
poetry install --only main
mkdir data
cp .env.example .env
# set funding source amongst other options
nano .env
@@ -444,6 +443,15 @@ server {
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass_request_headers on;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
listen [::]:443 ssl;
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/lnbits.org/fullchain.pem;
+41
View File
@@ -76,6 +76,15 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
- `OPENNODE_API_ENDPOINT`: https://api.opennode.com/
- `OPENNODE_KEY`: opennodeAdminApiKey
### Blink
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate a Blink API key after logging in or creating a new Blink account at: https://dashboard.blink.sv. For more info visit: https://dev.blink.sv/api/auth#create-an-api-key```
- `LNBITS_BACKEND_WALLET_CLASS`: **BlinkWallet**
- `BLINK_API_ENDPOINT`: https://api.blink.sv/graphql
- `BLINK_WS_ENDPOINT`: wss://ws.blink.sv/graphql
- `BLINK_TOKEN`: BlinkToken
### Alby
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an alby access token here: https://getalby.com/developer/access_tokens/new
@@ -84,6 +93,18 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
- `ALBY_API_ENDPOINT`: https://api.getalby.com/
- `ALBY_ACCESS_TOKEN`: AlbyAccessToken
### Boltz
This funding source connects to a running [boltz-client](https://docs.boltz.exchange/v/boltz-client) and handles all lightning payments through submarine swaps on the liquid network.
You can configure the daemon to run in standalone mode by `standalone = True` in the config file or using the cli flag (`boltzd --standalone`).
Once running, you can create a liquid wallet using `boltzcli wallet create lnbits lbtc`.
- `LNBITS_BACKEND_WALLET_CLASS`: **BoltzWallet**
- `BOLTZ_CLIENT_ENDPOINT`: 127.0.0.1:9002
- `BOLTZ_CLIENT_MACAROON`: /home/bob/.boltz/macaroons/admin.macaroon or Base64/Hex
- `BOLTZ_CLIENT_CERT`: /home/bob/.boltz/tls.cert or Base64/Hex
- `BOLTZ_CLIENT_WALLET`: lnbits
### ZBD
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an ZBD API Key here: https://zbd.dev/docs/dashboard/projects/api
@@ -101,6 +122,26 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
- `PHOENIXD_API_ENDPOINT`: http://localhost:9740/
- `PHOENIXD_API_PASSWORD`: PhoenixdApiPassword
### Breez SDK
A Greenlight invite code or Greenlight partner certificate/key can be used to register a new node with Greenlight. If the Greenlight node already exists, neither are required.
- `LNBITS_BACKEND_WALLET_CLASS`: **BreezSdkWallet**
- `BREEZ_API_KEY`: ...
- `BREEZ_GREENLIGHT_SEED`: ...
- `BREEZ_GREENLIGHT_INVITE_CODE`: ...
- `BREEZ_GREENLIGHT_DEVICE_KEY`: /path/to/breezsdk/device.pem or Base64/Hex
- `BREEZ_GREENLIGHT_DEVICE_CERT`: /path/to/breezsdk/device.crt or Base64/Hex
### Cliche Wallet
- `CLICHE_ENDPOINT`: ws://127.0.0.1:12000
### Nostr Wallet Connect (NWC)
To use NWC as funding source in LNbits you'll need a pairing URL (also known as pairing secret) from a NWC service provider. You can find a list of providers [here](https://github.com/getAlby/awesome-nwc?tab=readme-ov-file#nwc-wallets).
You can configure Nostr Wallet Connect in the admin ui or using the following environment variables:
- `LNBITS_BACKEND_WALLET_CLASS`: **NWCWallet**
- `NWC_PAIRING_URL`: **nostr+walletconnect://...your...pairing...secret...**
Generated
+16 -16
View File
@@ -5,11 +5,11 @@
"systems": "systems"
},
"locked": {
"lastModified": 1694529238,
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github"
},
"original": {
@@ -26,11 +26,11 @@
]
},
"locked": {
"lastModified": 1698974481,
"narHash": "sha256-yPncV9Ohdz1zPZxYHQf47S8S0VrnhV7nNhCawY46hDA=",
"lastModified": 1703863825,
"narHash": "sha256-rXwqjtwiGKJheXB43ybM8NwWB8rO2dSRrEqes0S7F5Y=",
"owner": "nix-community",
"repo": "nix-github-actions",
"rev": "4bb5e752616262457bc7ca5882192a564c0472d2",
"rev": "5163432afc817cf8bd1f031418d1869e4c9d5547",
"type": "github"
},
"original": {
@@ -41,16 +41,16 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1702233072,
"narHash": "sha256-H5G2wgbim2Ku6G6w+NSaQaauv6B6DlPhY9fMvArKqRo=",
"lastModified": 1723938990,
"narHash": "sha256-9tUadhnZQbWIiYVXH8ncfGXGvkNq3Hag4RCBEMUk7MI=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "781e2a9797ecf0f146e81425c822dca69fe4a348",
"rev": "c42fcfbdfeae23e68fc520f9182dde9f38ad1890",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-23.11",
"ref": "nixos-24.05",
"repo": "nixpkgs",
"type": "github"
}
@@ -66,11 +66,11 @@
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1702334837,
"narHash": "sha256-QZG6+zFshyY+L8m2tlOTm75U5m9y7z01g0josVK+8Os=",
"lastModified": 1724134185,
"narHash": "sha256-nDqpGjz7cq3ThdC98BPe1ANCNlsJds/LLZ3/MdIXjA0=",
"owner": "nix-community",
"repo": "poetry2nix",
"rev": "1f4bcbf1be73abc232a972a77102a3e820485a99",
"rev": "5ee730a8752264e463c0eaf06cc060fd07f6dae9",
"type": "github"
},
"original": {
@@ -122,11 +122,11 @@
]
},
"locked": {
"lastModified": 1699786194,
"narHash": "sha256-3h3EH1FXQkIeAuzaWB+nK0XK54uSD46pp+dMD3gAcB4=",
"lastModified": 1719749022,
"narHash": "sha256-ddPKHcqaKCIFSFc/cvxS14goUhCOAwsM1PbMr0ZtHMg=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "e82f32aa7f06bbbd56d7b12186d555223dc399d1",
"rev": "8df5ff62195d4e67e2264df0b7f5e8c9995fd0bd",
"type": "github"
},
"original": {
+1 -11
View File
@@ -2,7 +2,7 @@
description = "LNbits, free and open-source Lightning wallet and accounts system";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-23.11";
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.05";
poetry2nix = {
url = "github:nix-community/poetry2nix";
inputs.nixpkgs.follows = "nixpkgs";
@@ -33,16 +33,6 @@
protobuf = prev.protobuf.override { preferWheel = true; };
ruff = prev.ruff.override { preferWheel = true; };
wallycore = prev.wallycore.override { preferWheel = true; };
# remove the following override when https://github.com/nix-community/poetry2nix/pull/1563 is merged
asgi-lifespan = prev.asgi-lifespan.overridePythonAttrs (
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
);
pytest-md = prev.pytest-md.overridePythonAttrs (
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
);
types-mock = prev.pytest-md.overridePythonAttrs (
old: { buildInputs = (old.buildInputs or []) ++ [ prev.setuptools ]; }
);
});
};
});
+22 -18
View File
@@ -16,7 +16,11 @@ from slowapi import Limiter
from slowapi.util import get_remote_address
from starlette.middleware.sessions import SessionMiddleware
from lnbits.core.crud import get_dbversions, get_installed_extensions
from lnbits.core.crud import (
get_dbversions,
get_installed_extensions,
update_installed_extension_state,
)
from lnbits.core.helpers import migrate_extension_database
from lnbits.core.tasks import ( # watchdog_task
killswitch_task,
@@ -42,7 +46,6 @@ from .core import init_core_routers
from .core.db import core_app_extra
from .core.services import check_admin_settings, check_webpush_settings
from .core.views.extension_api import add_installed_extension
from .core.views.generic import update_installed_extension_state
from .extension_manager import (
Extension,
InstallableExtension,
@@ -60,6 +63,7 @@ from .middleware import (
from .requestvars import g
from .tasks import (
check_pending_payments,
create_task,
internal_invoice_listener,
invoice_listener,
)
@@ -90,13 +94,8 @@ async def startup(app: FastAPI):
# register core routes
init_core_routers(app)
# check extensions after restart
if not settings.lnbits_extensions_deactivate_all:
await check_installed_extensions(app)
register_all_ext_routes(app)
# initialize tasks
register_async_tasks()
register_async_tasks(app)
async def shutdown():
@@ -207,7 +206,7 @@ async def check_funding_source() -> None:
break
retry_counter += 1
sleep_time = 0.25 * (2**retry_counter)
sleep_time = min(0.25 * (2**retry_counter), 60)
logger.warning(
f"Retrying connection to backend in {sleep_time} seconds... "
f"({retry_counter}/{max_retries})"
@@ -261,10 +260,10 @@ async def build_all_installed_extensions_list(
MUST be installed by default (see LNBITS_EXTENSIONS_DEFAULT_INSTALL).
"""
installed_extensions = await get_installed_extensions()
settings.lnbits_all_extensions_ids = {e.id for e in installed_extensions}
installed_extensions_ids = [e.id for e in installed_extensions]
for ext_id in settings.lnbits_extensions_default_install:
if ext_id in installed_extensions_ids:
if ext_id in settings.lnbits_all_extensions_ids:
continue
ext_releases = await InstallableExtension.get_extension_releases(ext_id)
@@ -318,8 +317,7 @@ async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
# mount routes for the new version
core_app_extra.register_new_ext_routes(extension)
if extension.upgrade_hash:
ext.notify_upgrade()
ext.notify_upgrade(extension.upgrade_hash)
def register_custom_extensions_path():
@@ -397,22 +395,28 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
app.include_router(router=ext_route, prefix=prefix)
def register_all_ext_routes(app: FastAPI):
async def check_and_register_extensions(app: FastAPI):
await check_installed_extensions(app)
for ext in get_valid_extensions(False):
try:
register_ext_routes(app, ext)
except Exception as e:
logger.error(f"Could not load extension `{ext.code}`: {e!s}")
except Exception as exc:
logger.error(f"Could not load extension `{ext.code}`: {exc!s}")
def register_async_tasks():
def register_async_tasks(app: FastAPI):
# check extensions after restart
if not settings.lnbits_extensions_deactivate_all:
create_task(check_and_register_extensions(app))
create_permanent_task(check_pending_payments)
create_permanent_task(invoice_listener)
create_permanent_task(internal_invoice_listener)
create_permanent_task(cache.invalidate_forever)
# core invoice listener
invoice_queue = asyncio.Queue(5)
invoice_queue: asyncio.Queue = asyncio.Queue(5)
register_invoice_listener(invoice_queue, "core")
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue))
+18 -68
View File
@@ -12,38 +12,33 @@ from fastapi.exceptions import HTTPException
from loguru import logger
from packaging import version
from lnbits.core.models import Payment, User
from lnbits.core.services import check_admin_settings
from lnbits.core.views.extension_api import (
api_install_extension,
api_uninstall_extension,
)
from lnbits.settings import settings
from lnbits.wallets.base import Wallet
from .core import db as core_db
from .core import migrations as core_migrations
from .core.crud import (
from lnbits.core import db as core_db
from lnbits.core.crud import (
delete_accounts_no_wallets,
delete_unused_wallets,
delete_wallet_by_id,
delete_wallet_payment,
get_dbversions,
get_inactive_extensions,
get_installed_extension,
get_installed_extensions,
get_payments,
remove_deleted_wallets,
update_payment_status,
)
from .core.helpers import migrate_extension_database, run_migration
from .db import COCKROACH, POSTGRES, SQLITE
from .extension_manager import (
from lnbits.core.helpers import migrate_databases
from lnbits.core.models import Payment, PaymentState, User
from lnbits.core.services import check_admin_settings
from lnbits.core.views.extension_api import (
api_install_extension,
api_uninstall_extension,
)
from lnbits.extension_manager import (
CreateExtension,
ExtensionRelease,
InstallableExtension,
get_valid_extensions,
)
from lnbits.settings import settings
from lnbits.wallets.base import Wallet
def coro(f):
@@ -123,47 +118,6 @@ def database_migrate():
loop.run_until_complete(migrate_databases())
async def db_migrate():
task = asyncio.create_task(migrate_databases())
await task
async def migrate_databases():
"""Creates the necessary databases if they don't exist already; or migrates them."""
async with core_db.connect() as conn:
exists = False
if conn.type == SQLITE:
exists = await conn.fetchone(
"SELECT * FROM sqlite_master WHERE type='table' AND name='dbversions'"
)
elif conn.type in {POSTGRES, COCKROACH}:
exists = await conn.fetchone(
"SELECT * FROM information_schema.tables WHERE table_schema = 'public'"
" AND table_name = 'dbversions'"
)
if not exists:
await core_migrations.m000_create_migrations_table(conn)
current_versions = await get_dbversions(conn)
core_version = current_versions.get("core", 0)
await run_migration(conn, core_migrations, "core", core_version)
# here is the first place we can be sure that the
# `installed_extensions` table has been created
await load_disabled_extension_list()
for ext in get_valid_extensions(False):
current_version = current_versions.get(ext.code, 0)
try:
await migrate_extension_database(ext, current_version)
except Exception as e:
logger.exception(f"Error migrating extension {ext.code}: {e}")
logger.info("✔️ All migrations done.")
@db.command("versions")
@coro
async def db_versions():
@@ -216,10 +170,12 @@ async def database_delete_wallet_payment(wallet: str, checking_id: str):
@db.command("mark-payment-pending")
@click.option("-c", "--checking-id", required=True, help="Payment checking Id.")
@coro
async def database_revert_payment(checking_id: str, pending: bool = True):
"""Mark wallet as deleted"""
async def database_revert_payment(checking_id: str):
"""Mark payment as pending"""
async with core_db.connect() as conn:
await update_payment_status(pending=pending, checking_id=checking_id, conn=conn)
await update_payment_status(
status=PaymentState.PENDING, checking_id=checking_id, conn=conn
)
@db.command("cleanup-accounts")
@@ -313,12 +269,6 @@ async def check_invalid_payments(
click.echo(" ".join([w, str(data[0]), str(data[1] / 1000).ljust(10)]))
async def load_disabled_extension_list() -> None:
"""Update list of extensions that have been explicitly disabled"""
inactive_extensions = await get_inactive_extensions()
settings.lnbits_deactivated_extensions += inactive_extensions
@extensions.command("list")
@coro
async def extensions_list():
@@ -694,7 +644,7 @@ async def _can_run_operation(url) -> bool:
elif url:
click.echo(
"The option '--url' has been provided,"
+ f" but no server found runnint at '{url}'"
f" but no server found running at '{url}'"
)
return False
+4 -1
View File
@@ -7,7 +7,7 @@ from .views.auth_api import auth_router
from .views.extension_api import extension_router
# this compat is needed for usermanager extension
from .views.generic import generic_router, update_user_extension
from .views.generic import generic_router
from .views.node_api import node_router, public_node_router, super_node_router
from .views.payment_api import payment_router
from .views.public_api import public_router
@@ -38,3 +38,6 @@ def init_core_routers(app: FastAPI):
app.include_router(tinyurl_router)
app.include_router(webpush_router)
app.include_router(users_router)
__all__ = ["core_app", "core_app_extra", "db"]
+122 -105
View File
@@ -2,14 +2,20 @@ import datetime
import json
from time import time
from typing import Any, Dict, List, Literal, Optional
from uuid import UUID, uuid4
from uuid import uuid4
import shortuuid
from passlib.context import CryptContext
from lnbits.core.db import db
from lnbits.core.models import PaymentState
from lnbits.db import DB_TYPE, SQLITE, Connection, Database, Filters, Page
from lnbits.extension_manager import InstallableExtension
from lnbits.extension_manager import (
InstallableExtension,
PayToEnableInfo,
UserExtension,
UserExtensionInfo,
)
from lnbits.settings import (
AdminSettings,
EditableSettings,
@@ -21,7 +27,6 @@ from lnbits.settings import (
from .models import (
Account,
AccountFilters,
CreateUser,
Payment,
PaymentFilters,
PaymentHistoryPoint,
@@ -37,63 +42,23 @@ from .models import (
# --------
async def create_user(
data: CreateUser, user_config: Optional[UserConfig] = None
) -> User:
if not settings.new_accounts_allowed:
raise ValueError("Account creation is disabled.")
if await get_account_by_username(data.username):
raise ValueError("Username already exists.")
if data.email and await get_account_by_email(data.email):
raise ValueError("Email already exists.")
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
user_id = uuid4().hex
tsph = db.timestamp_placeholder
now = int(time())
await db.execute(
f"""
INSERT INTO accounts
(id, email, username, pass, extra, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, {tsph}, {tsph})
""",
(
user_id,
data.email,
data.username,
pwd_context.hash(data.password),
json.dumps(dict(user_config)) if user_config else "{}",
now,
now,
),
)
new_account = await get_account(user_id=user_id)
assert new_account, "Newly created account couldn't be retrieved"
return new_account
async def create_account(
conn: Optional[Connection] = None,
user_id: Optional[str] = None,
username: Optional[str] = None,
email: Optional[str] = None,
password: Optional[str] = None,
user_config: Optional[UserConfig] = None,
conn: Optional[Connection] = None,
) -> User:
if user_id:
user_uuid4 = UUID(hex=user_id, version=4)
assert user_uuid4.hex == user_id, "User ID is not valid UUID4 hex string"
else:
user_id = uuid4().hex
user_id = user_id or uuid4().hex
extra = json.dumps(dict(user_config)) if user_config else "{}"
now = int(time())
await (conn or db).execute(
f"""
INSERT INTO accounts (id, email, extra, created_at, updated_at)
VALUES (?, ?, ?, {db.timestamp_placeholder}, {db.timestamp_placeholder})
INSERT INTO accounts (id, username, pass, email, extra, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, {db.timestamp_placeholder}, {db.timestamp_placeholder})
""",
(user_id, email, extra, now, now),
(user_id, username, password, email, extra, now, now),
)
new_account = await get_account(user_id=user_id, conn=conn)
@@ -322,10 +287,7 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
)
if user:
extensions = await (conn or db).fetchall(
"""SELECT extension FROM extensions WHERE "user" = ? AND active""",
(user_id,),
)
extensions = await get_user_active_extensions_ids(user_id, conn)
wallets = await (conn or db).fetchall(
"""
SELECT *, COALESCE((
@@ -344,7 +306,7 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
email=user["email"],
username=user["username"],
extensions=[
e[0] for e in extensions if User.is_extension_for_user(e[0], user["id"])
e for e in extensions if User.is_extension_for_user(e[0], user["id"])
],
wallets=[Wallet(**w) for w in wallets],
admin=user["id"] == settings.super_user
@@ -367,6 +329,7 @@ async def add_installed_extension(
"installed_release": (
dict(ext.installed_release) if ext.installed_release else None
),
"pay_to_enable": (dict(ext.pay_to_enable) if ext.pay_to_enable else None),
"dependencies": ext.dependencies,
"payments": [dict(p) for p in ext.payments] if ext.payments else None,
}
@@ -376,8 +339,8 @@ async def add_installed_extension(
await (conn or db).execute(
"""
INSERT INTO installed_extensions
(id, version, name, short_description, icon, stars, meta)
VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET
(id, version, name, active, short_description, icon, stars, meta)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET
(version, name, active, short_description, icon, stars, meta) =
(?, ?, ?, ?, ?, ?, ?)
""",
@@ -385,13 +348,14 @@ async def add_installed_extension(
ext.id,
version,
ext.name,
ext.active,
ext.short_description,
ext.icon,
ext.stars,
json.dumps(meta),
version,
ext.name,
False,
ext.active,
ext.short_description,
ext.icon,
ext.stars,
@@ -411,6 +375,17 @@ async def update_installed_extension_state(
)
async def update_extension_pay_to_enable(
ext_id: str, payment_info: PayToEnableInfo, conn: Optional[Connection] = None
) -> None:
ext = await get_installed_extension(ext_id, conn)
if not ext:
return
ext.pay_to_enable = payment_info
await add_installed_extension(ext, conn)
async def delete_installed_extension(
*, ext_id: str, conn: Optional[Connection] = None
) -> None:
@@ -453,21 +428,44 @@ async def get_installed_extension(
async def get_installed_extensions(
active: Optional[bool] = None,
conn: Optional[Connection] = None,
) -> List["InstallableExtension"]:
rows = await (conn or db).fetchall(
"SELECT * FROM installed_extensions",
(),
)
return [InstallableExtension.from_row(row) for row in rows]
all_extensions = [InstallableExtension.from_row(row) for row in rows]
if active is None:
return all_extensions
return [e for e in all_extensions if e.active == active]
async def get_inactive_extensions(*, conn: Optional[Connection] = None) -> List[str]:
inactive_extensions = await (conn or db).fetchall(
"""SELECT id FROM installed_extensions WHERE NOT active""",
(),
async def get_user_extension(
user_id: str, extension: str, conn: Optional[Connection] = None
) -> Optional[UserExtension]:
row = await (conn or db).fetchone(
"""
SELECT extension, active, extra as _extra FROM extensions
WHERE "user" = ? AND extension = ?
""",
(user_id, extension),
)
return [ext[0] for ext in inactive_extensions]
return UserExtension.from_row(row) if row else None
async def get_user_extensions(
user_id: str, conn: Optional[Connection] = None
) -> List[UserExtension]:
rows = await (conn or db).fetchall(
"""
SELECT extension, active, extra as _extra FROM extensions
WHERE "user" = ?
""",
(user_id,),
)
return [UserExtension.from_row(row) for row in rows]
async def update_user_extension(
@@ -482,6 +480,32 @@ async def update_user_extension(
)
async def get_user_active_extensions_ids(
user_id: str, conn: Optional[Connection] = None
) -> List[str]:
rows = await (conn or db).fetchall(
"""SELECT extension FROM extensions WHERE "user" = ? AND active""",
(user_id,),
)
return [e[0] for e in rows]
async def update_user_extension_extra(
user_id: str,
extension: str,
extra: UserExtensionInfo,
conn: Optional[Connection] = None,
) -> None:
extra_json = json.dumps(dict(extra))
await (conn or db).execute(
"""
INSERT INTO extensions ("user", extension, extra) VALUES (?, ?, ?)
ON CONFLICT ("user", extension) DO UPDATE SET extra = ?
""",
(user_id, extension, extra_json, extra_json),
)
# wallets
# -------
@@ -715,7 +739,7 @@ async def get_latest_payments_by_extension(ext_name: str, ext_id: str, limit: in
rows = await db.fetchall(
f"""
SELECT * FROM apipayments
WHERE pending = false
WHERE status = '{PaymentState.SUCCESS}'
AND extra LIKE ?
AND extra LIKE ?
ORDER BY time DESC LIMIT {limit}
@@ -759,9 +783,11 @@ async def get_payments_paginated(
if complete and pending:
pass
elif complete:
clause.append("((amount > 0 AND pending = false) OR amount < 0)")
clause.append(
f"((amount > 0 AND status = '{PaymentState.SUCCESS}') OR amount < 0)"
)
elif pending:
clause.append("pending = true")
clause.append(f"status = '{PaymentState.PENDING}'")
else:
pass
@@ -834,7 +860,7 @@ async def delete_expired_invoices(
await (conn or db).execute(
f"""
DELETE FROM apipayments
WHERE pending = true AND amount > 0
WHERE status = '{PaymentState.PENDING}' AND amount > 0
AND time < {db.timestamp_now} - {db.interval_seconds(2592000)}
"""
)
@@ -842,7 +868,7 @@ async def delete_expired_invoices(
await (conn or db).execute(
f"""
DELETE FROM apipayments
WHERE pending = true AND amount > 0
WHERE status = '{PaymentState.PENDING}' AND amount > 0
AND expiry < {db.timestamp_now}
"""
)
@@ -861,9 +887,9 @@ async def create_payment(
amount: int,
memo: str,
fee: int = 0,
status: PaymentState = PaymentState.PENDING,
preimage: Optional[str] = None,
expiry: Optional[datetime.datetime] = None,
pending: bool = True,
extra: Optional[Dict] = None,
webhook: Optional[str] = None,
conn: Optional[Connection] = None,
@@ -877,8 +903,8 @@ async def create_payment(
"""
INSERT INTO apipayments
(wallet, checking_id, bolt11, hash, preimage,
amount, pending, memo, fee, extra, webhook, expiry)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
amount, status, memo, fee, extra, webhook, expiry, pending)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
wallet_id,
@@ -887,7 +913,7 @@ async def create_payment(
payment_hash,
preimage,
amount,
pending,
status.value,
memo,
fee,
(
@@ -897,6 +923,7 @@ async def create_payment(
),
webhook,
db.datetime_to_timestamp(expiry) if expiry else None,
False, # TODO: remove this in next release
),
)
@@ -907,17 +934,17 @@ async def create_payment(
async def update_payment_status(
checking_id: str, pending: bool, conn: Optional[Connection] = None
checking_id: str, status: PaymentState, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"UPDATE apipayments SET pending = ? WHERE checking_id = ?",
(pending, checking_id),
"UPDATE apipayments SET status = ? WHERE checking_id = ?",
(status.value, checking_id),
)
async def update_payment_details(
checking_id: str,
pending: Optional[bool] = None,
status: Optional[PaymentState] = None,
fee: Optional[int] = None,
preimage: Optional[str] = None,
new_checking_id: Optional[str] = None,
@@ -929,9 +956,9 @@ async def update_payment_details(
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 status is not None:
set_clause.append("status = ?")
set_variables.append(status.value)
if fee is not None:
set_clause.append("fee = ?")
set_variables.append(fee)
@@ -945,7 +972,6 @@ async def update_payment_details(
f"UPDATE apipayments SET {', '.join(set_clause)} WHERE checking_id = ?",
tuple(set_variables),
)
return
async def update_payment_extra(
@@ -977,16 +1003,6 @@ async def update_payment_extra(
)
async def update_pending_payments(wallet_id: str):
pending_payments = await get_payments(
wallet_id=wallet_id,
pending=True,
exclude_uncheckable=True,
)
for payment in pending_payments:
await payment.check_status()
DateTrunc = Literal["hour", "day", "month"]
sqlite_formats = {
"hour": "%Y-%m-%d %H:00:00",
@@ -1002,7 +1018,7 @@ async def get_payments_history(
) -> List[PaymentHistoryPoint]:
if not filters:
filters = Filters()
where = ["(pending = False OR amount < 0)"]
where = [f"(status = '{PaymentState.SUCCESS}' OR amount < 0)"]
values = []
if wallet_id:
where.append("wallet = ?")
@@ -1067,9 +1083,9 @@ async def check_internal(
otherwise None
"""
row = await (conn or db).fetchone(
"""
f"""
SELECT checking_id FROM apipayments
WHERE hash = ? AND pending AND amount > 0
WHERE hash = ? AND status = '{PaymentState.PENDING}' AND amount > 0
""",
(payment_hash,),
)
@@ -1088,15 +1104,14 @@ async def check_internal_pending(
"""
row = await (conn or db).fetchone(
"""
SELECT pending FROM apipayments
SELECT status FROM apipayments
WHERE hash = ? AND amount > 0
""",
(payment_hash,),
)
if not row:
return True
else:
return row["pending"]
return row["status"] == PaymentState.PENDING.value
async def mark_webhook_sent(payment_hash: str, status: int) -> None:
@@ -1256,7 +1271,7 @@ async def get_webpush_subscription(
endpoint: str, user: str
) -> Optional[WebPushSubscription]:
row = await db.fetchone(
"SELECT * FROM webpush_subscriptions WHERE endpoint = ? AND user = ?",
"""SELECT * FROM webpush_subscriptions WHERE endpoint = ? AND "user" = ?""",
(
endpoint,
user,
@@ -1269,7 +1284,7 @@ async def get_webpush_subscriptions_for_user(
user: str,
) -> List[WebPushSubscription]:
rows = await db.fetchall(
"SELECT * FROM webpush_subscriptions WHERE user = ?",
"""SELECT * FROM webpush_subscriptions WHERE "user" = ?""",
(user,),
)
return [WebPushSubscription(**dict(row)) for row in rows]
@@ -1280,7 +1295,7 @@ async def create_webpush_subscription(
) -> WebPushSubscription:
await db.execute(
"""
INSERT INTO webpush_subscriptions (endpoint, user, data, host)
INSERT INTO webpush_subscriptions (endpoint, "user", data, host)
VALUES (?, ?, ?, ?)
""",
(
@@ -1295,17 +1310,19 @@ async def create_webpush_subscription(
return subscription
async def delete_webpush_subscription(endpoint: str, user: str) -> None:
await db.execute(
"DELETE FROM webpush_subscriptions WHERE endpoint = ? AND user = ?",
async def delete_webpush_subscription(endpoint: str, user: str) -> int:
resp = await db.execute(
"""DELETE FROM webpush_subscriptions WHERE endpoint = ? AND "user" = ?""",
(
endpoint,
user,
),
)
return resp.rowcount
async def delete_webpush_subscriptions(endpoint: str) -> None:
await db.execute(
async def delete_webpush_subscriptions(endpoint: str) -> int:
resp = await db.execute(
"DELETE FROM webpush_subscriptions WHERE endpoint = ?", (endpoint,)
)
return resp.rowcount
+57 -5
View File
@@ -6,13 +6,20 @@ from uuid import UUID
import httpx
from loguru import logger
from lnbits.core import migrations as core_migrations
from lnbits.core.crud import (
get_dbversions,
get_installed_extensions,
update_migration_version,
)
from lnbits.core.db import db as core_db
from lnbits.db import Connection
from lnbits.extension_manager import Extension
from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection
from lnbits.extension_manager import (
Extension,
get_valid_extensions,
)
from lnbits.settings import settings
from .crud import update_migration_version
async def migrate_extension_database(ext: Extension, current_version):
try:
@@ -77,7 +84,9 @@ async def _stop_extension_background_work(ext_id) -> bool:
stop_fn_name = next((fn for fn in stop_fns if hasattr(old_module, fn)), None)
assert stop_fn_name, "No stop function found for '{ext.module_name}'"
await getattr(old_module, stop_fn_name)()
stop_fn = getattr(old_module, stop_fn_name)
if stop_fn:
await stop_fn()
logger.info(f"Stopped background work for extension '{ext.module_name}'.")
except Exception as ex:
@@ -117,3 +126,46 @@ def to_valid_user_id(user_id: str) -> UUID:
raise ValueError("Invalid hex string for User ID.") from exc
return UUID(hex=user_id[:32], version=4)
async def load_disabled_extension_list() -> None:
"""Update list of extensions that have been explicitly disabled"""
inactive_extensions = await get_installed_extensions(active=False)
settings.lnbits_deactivated_extensions.update([e.id for e in inactive_extensions])
async def migrate_databases():
"""Creates the necessary databases if they don't exist already; or migrates them."""
async with core_db.connect() as conn:
exists = False
if conn.type == SQLITE:
exists = await conn.fetchone(
"SELECT * FROM sqlite_master WHERE type='table' AND name='dbversions'"
)
elif conn.type in {POSTGRES, COCKROACH}:
exists = await conn.fetchone(
"SELECT * FROM information_schema.tables WHERE table_schema = 'public'"
" AND table_name = 'dbversions'"
)
if not exists:
await core_migrations.m000_create_migrations_table(conn)
current_versions = await get_dbversions(conn)
core_version = current_versions.get("core", 0)
await run_migration(conn, core_migrations, "core", core_version)
# here is the first place we can be sure that the
# `installed_extensions` table has been created
await load_disabled_extension_list()
# todo: revisit, use installed extensions
for ext in get_valid_extensions(False):
current_version = current_versions.get(ext.code, 0)
try:
await migrate_extension_database(ext, current_version)
except Exception as e:
logger.exception(f"Error migrating extension {ext.code}: {e}")
logger.info("✔️ All migrations done.")
+37 -1
View File
@@ -366,7 +366,8 @@ async def m014_set_deleted_wallets(db):
inkey = row[4].split(":")[1]
await db.execute(
"""
UPDATE wallets SET user = ?, adminkey = ?, inkey = ?, deleted = true
UPDATE wallets SET
"user" = ?, adminkey = ?, inkey = ?, deleted = true
WHERE id = ?
""",
(user, adminkey, inkey, row[0]),
@@ -512,3 +513,38 @@ async def m019_balances_view_based_on_wallets(db):
GROUP BY apipayments.wallet
"""
)
async def m020_add_column_column_to_user_extensions(db):
"""
Adds extra column to user extensions.
"""
await db.execute("ALTER TABLE extensions ADD COLUMN extra TEXT")
async def m021_add_success_failed_to_apipayments(db):
"""
Adds success and failed columns to apipayments.
"""
await db.execute("ALTER TABLE apipayments ADD COLUMN status TEXT DEFAULT 'pending'")
# set all not pending to success true, failed payments were deleted until now
await db.execute("UPDATE apipayments SET status = 'success' WHERE NOT pending")
await db.execute("DROP VIEW balances")
await db.execute(
"""
CREATE VIEW balances AS
SELECT apipayments.wallet,
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
FROM wallets
LEFT JOIN apipayments ON apipayments.wallet = wallets.id
WHERE (wallets.deleted = false OR wallets.deleted is NULL)
AND (
(apipayments.status = 'success' AND apipayments.amount > 0)
OR (apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)
)
GROUP BY apipayments.wallet
"""
)
# TODO: drop column in next release
# await db.execute("ALTER TABLE apipayments DROP COLUMN pending")
+55 -79
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import datetime
import hashlib
import hmac
@@ -6,19 +8,22 @@ import time
from dataclasses import dataclass
from enum import Enum
from sqlite3 import Row
from typing import Callable, Dict, List, Optional
from typing import Callable, Optional
from ecdsa import SECP256k1, SigningKey
from fastapi import Query
from loguru import logger
from pydantic import BaseModel
from pydantic import BaseModel, validator
from lnbits.db import Connection, FilterModel, FromRowModel
from lnbits.db import FilterModel, FromRowModel
from lnbits.helpers import url_for
from lnbits.lnurl import encode as lnurl_encode
from lnbits.settings import settings
from lnbits.utils.exchange_rates import allowed_currencies
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import PaymentPendingStatus, PaymentStatus
from lnbits.wallets.base import (
PaymentPendingStatus,
PaymentStatus,
)
class BaseWallet(BaseModel):
@@ -62,7 +67,7 @@ class Wallet(BaseWallet):
linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
)
async def get_payment(self, payment_hash: str) -> Optional["Payment"]:
async def get_payment(self, payment_hash: str) -> Optional[Payment]:
from .crud import get_standalone_payment
return await get_standalone_payment(payment_hash)
@@ -132,8 +137,8 @@ class User(BaseModel):
id: str
email: Optional[str] = None
username: Optional[str] = None
extensions: List[str] = []
wallets: List[Wallet] = []
extensions: list[str] = []
wallets: list[Wallet] = []
admin: bool = False
super_user: bool = False
has_password: bool = False
@@ -142,10 +147,10 @@ class User(BaseModel):
updated_at: Optional[int] = None
@property
def wallet_ids(self) -> List[str]:
def wallet_ids(self) -> list[str]:
return [wallet.id for wallet in self.wallets]
def get_wallet(self, wallet_id: str) -> Optional["Wallet"]:
def get_wallet(self, wallet_id: str) -> Optional[Wallet]:
w = [wallet for wallet in self.wallets if wallet.id == wallet_id]
return w[0] if w else None
@@ -197,9 +202,20 @@ class LoginUsernamePassword(BaseModel):
password: str
class PaymentState(str, Enum):
PENDING = "pending"
SUCCESS = "success"
FAILED = "failed"
def __str__(self) -> str:
return self.value
class Payment(FromRowModel):
checking_id: str
status: str
# TODO should be removed in the future, backward compatibility
pending: bool
checking_id: str
amount: int
fee: int
memo: Optional[str]
@@ -208,11 +224,19 @@ class Payment(FromRowModel):
preimage: str
payment_hash: str
expiry: Optional[float]
extra: Dict = {}
extra: dict = {}
wallet_id: str
webhook: Optional[str]
webhook_status: Optional[int]
@property
def success(self) -> bool:
return self.status == PaymentState.SUCCESS.value
@property
def failed(self) -> bool:
return self.status == PaymentState.FAILED.value
@classmethod
def from_row(cls, row: Row):
return cls(
@@ -221,7 +245,9 @@ class Payment(FromRowModel):
bolt11=row["bolt11"] or "",
preimage=row["preimage"] or "0" * 64,
extra=json.loads(row["extra"] or "{}"),
pending=row["pending"],
status=row["status"],
# TODO should be removed in the future, backward compatibility
pending=row["status"] == PaymentState.PENDING.value,
amount=row["amount"],
fee=row["fee"],
memo=row["memo"],
@@ -262,80 +288,16 @@ class Payment(FromRowModel):
def is_uncheckable(self) -> bool:
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:
from .crud import update_payment_status
self.pending = pending
await update_payment_status(self.checking_id, pending)
async def check_status(
self,
conn: Optional[Connection] = None,
) -> PaymentStatus:
async def check_status(self) -> PaymentStatus:
if self.is_uncheckable:
return PaymentPendingStatus()
logger.debug(
f"Checking {'outgoing' if self.is_out else 'incoming'} "
f"pending payment {self.checking_id}"
)
funding_source = get_funding_source()
if self.is_out:
status = await funding_source.get_payment_status(self.checking_id)
else:
status = await funding_source.get_invoice_status(self.checking_id)
logger.debug(f"Status: {status}")
if self.is_in and status.pending and self.is_expired and self.expiry:
expiration_date = datetime.datetime.fromtimestamp(self.expiry)
logger.debug(
f"Deleting expired incoming pending payment {self.checking_id}: "
f"expired {expiration_date}"
)
await self.delete(conn)
# wait at least 15 minutes before deleting failed outgoing payments
elif self.is_out and status.failed:
if self.time + 900 < int(time.time()):
logger.warning(
f"Deleting outgoing failed payment {self.checking_id}: {status}"
)
await self.delete(conn)
else:
logger.warning(
f"Tried to delete outgoing payment {self.checking_id}: "
"skipping because it's not old enough"
)
elif not status.pending:
logger.info(
f"Marking '{'in' if self.is_in else 'out'}' "
f"{self.checking_id} as not pending anymore: {status}"
)
await self.update_status(status, conn=conn)
return status
async def delete(self, conn: Optional[Connection] = None) -> None:
from .crud import delete_wallet_payment
await delete_wallet_payment(self.checking_id, self.wallet_id, conn=conn)
class PaymentFilters(FilterModel):
__search_fields__ = ["memo", "amount"]
@@ -349,7 +311,7 @@ class PaymentFilters(FilterModel):
preimage: str
payment_hash: str
expiry: Optional[datetime.datetime]
extra: Dict = {}
extra: dict = {}
wallet_id: str
webhook: Optional[str]
webhook_status: Optional[int]
@@ -395,6 +357,7 @@ class Callback(BaseModel):
class DecodePayment(BaseModel):
data: str
filter_fields: Optional[list[str]] = []
class CreateLnurl(BaseModel):
@@ -420,6 +383,14 @@ class CreateInvoice(BaseModel):
bolt11: Optional[str] = None
lnurl_callback: Optional[str] = None
@validator("unit")
@classmethod
def unit_is_from_allowed_currencies(cls, v):
if v != "sat" and v not in allowed_currencies():
raise ValueError("The provided unit is not supported")
return v
class CreateTopup(BaseModel):
id: str
@@ -453,3 +424,8 @@ class BalanceDelta(BaseModel):
@property
def delta_msats(self):
return self.node_balance_msats - self.lnbits_balance_msats
class SimpleStatus(BaseModel):
success: bool
message: str
+174 -59
View File
@@ -6,18 +6,26 @@ from io import BytesIO
from pathlib import Path
from typing import Dict, List, Optional, Tuple, TypedDict
from urllib.parse import parse_qs, urlparse
from uuid import UUID, uuid4
import httpx
from bolt11 import MilliSatoshi
from bolt11 import decode as bolt11_decode
from cryptography.hazmat.primitives import serialization
from fastapi import Depends, WebSocket
from loguru import logger
from passlib.context import CryptContext
from py_vapid import Vapid
from py_vapid.utils import b64urlencode
from lnbits.core.db import db
from lnbits.db import Connection
from lnbits.decorators import WalletTypeInfo, require_admin_key
from lnbits.decorators import (
WalletTypeInfo,
check_user_extension_access,
require_admin_key,
)
from lnbits.exceptions import InvoiceError, PaymentError
from lnbits.helpers import url_for
from lnbits.lnurl import LnurlErrorResponse
from lnbits.lnurl import decode as decode_lnurl
@@ -44,8 +52,9 @@ from .crud import (
create_admin_settings,
create_payment,
create_wallet,
delete_wallet_payment,
get_account,
get_account_by_email,
get_account_by_username,
get_payments,
get_standalone_payment,
get_super_settings,
@@ -56,21 +65,10 @@ from .crud import (
update_payment_details,
update_payment_status,
update_super_user,
update_user_extension,
)
from .helpers import to_valid_user_id
from .models import BalanceDelta, Payment, UserConfig, Wallet
class PaymentError(Exception):
def __init__(self, message: str, status: str = "pending"):
self.message = message
self.status = status
class InvoiceError(Exception):
def __init__(self, message: str, status: str = "pending"):
self.message = message
self.status = status
from .models import BalanceDelta, Payment, PaymentState, User, UserConfig, Wallet
async def calculate_fiat_amounts(
@@ -274,44 +272,26 @@ async def pay_invoice(
new_payment = await create_payment(
checking_id=internal_id,
fee=0 + abs(fee_reserve_total_msat),
pending=False,
status=PaymentState.SUCCESS,
conn=conn,
**payment_kwargs,
)
else:
fee_reserve_total_msat = fee_reserve_total(
invoice.amount_msat, internal=False
new_payment = await _create_external_payment(
temp_id, invoice.amount_msat, conn=conn, **payment_kwargs
)
logger.debug(f"creating temporary payment with id {temp_id}")
# create a temporary payment here so we can check if
# the balance is enough in the next step
try:
new_payment = await create_payment(
checking_id=temp_id,
fee=-abs(fee_reserve_total_msat),
conn=conn,
**payment_kwargs,
)
except Exception as exc:
logger.error(f"could not create temporary payment: {exc}")
# happens if the same wallet tries to pay an invoice twice
raise PaymentError("Could not make payment.", status="failed") from exc
# do the balance check
wallet = await get_wallet(wallet_id, conn=conn)
assert wallet, "Wallet for balancecheck could not be fetched"
if wallet.balance_msat < 0:
logger.debug("balance is too low, deleting temporary payment")
if (
not internal_checking_id
and wallet.balance_msat > -fee_reserve_total_msat
):
raise PaymentError(
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
" sat) to cover potential routing fees.",
status="failed",
)
raise PaymentError("Insufficient balance.", status="failed")
fee_reserve_total_msat = fee_reserve_total(invoice.amount_msat, internal=False)
_check_wallet_balance(wallet, fee_reserve_total_msat, internal_checking_id)
if extra and "tag" in extra:
# check if the payment is made for an extension that the user disabled
status = await check_user_extension_access(wallet.user, extra["tag"])
if not status.success:
raise PaymentError(status.message)
if internal_checking_id:
service_fee_msat = service_fee(invoice.amount_msat, internal=True)
@@ -321,7 +301,9 @@ async def pay_invoice(
# the payer has enough to deduct from
async with db.connect() as conn:
await update_payment_status(
checking_id=internal_checking_id, pending=False, conn=conn
checking_id=internal_checking_id,
status=PaymentState.SUCCESS,
conn=conn,
)
await send_payment_notification(wallet, new_payment)
@@ -346,15 +328,18 @@ async def pay_invoice(
f" {payment.checking_id})"
)
logger.debug(f"backend: pay_invoice finished {temp_id}")
logger.debug(f"backend: pay_invoice response {payment}")
logger.debug(f"backend: pay_invoice finished {temp_id}, {payment}")
if payment.checking_id and payment.ok is not False:
# payment.ok can be True (paid) or None (pending)!
logger.debug(f"updating payment {temp_id}")
async with db.connect() as conn:
await update_payment_details(
checking_id=temp_id,
pending=payment.ok is not True,
status=(
PaymentState.SUCCESS
if payment.ok is True
else PaymentState.PENDING
),
fee=-(
abs(payment.fee_msat if payment.fee_msat else 0)
+ abs(service_fee_msat)
@@ -369,13 +354,16 @@ async def pay_invoice(
)
if wallet and updated:
await send_payment_notification(wallet, updated)
logger.debug(f"payment successful {payment.checking_id}")
logger.success(f"payment successful {payment.checking_id}")
elif payment.checking_id is None and payment.ok is False:
# payment failed
logger.warning("backend sent payment failure")
logger.debug(f"payment failed {temp_id}, {payment.error_message}")
async with db.connect() as conn:
logger.debug(f"deleting temporary payment {temp_id}")
await delete_wallet_payment(temp_id, wallet_id, conn=conn)
await update_payment_status(
checking_id=temp_id,
status=PaymentState.FAILED,
conn=conn,
)
raise PaymentError(
f"Payment failed: {payment.error_message}"
or "Payment failed, but backend didn't give us an error message.",
@@ -397,11 +385,78 @@ async def pay_invoice(
checking_id="service_fee" + temp_id,
payment_request=payment_request,
payment_hash=invoice.payment_hash,
pending=False,
status=PaymentState.SUCCESS,
)
return invoice.payment_hash
async def _create_external_payment(
temp_id: str,
amount_msat: MilliSatoshi,
conn: Optional[Connection],
**payment_kwargs,
) -> Payment:
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=False)
# check if there is already a payment with the same checking_id
old_payment = await get_standalone_payment(temp_id, conn=conn)
if old_payment:
# fail on pending payments
if old_payment.pending:
raise PaymentError("Payment is still pending.", status="pending")
if old_payment.success:
raise PaymentError("Payment already paid.", status="success")
if old_payment.failed:
status = await old_payment.check_status()
if status.success:
# payment was successful on the fundingsource
await update_payment_status(
checking_id=temp_id, status=PaymentState.SUCCESS, conn=conn
)
raise PaymentError(
"Failed payment was already paid on the fundingsource.",
status="success",
)
if status.failed:
raise PaymentError(
"Payment is failed node, retrying is not possible.", status="failed"
)
# status.pending fall through and try again
return old_payment
logger.debug(f"creating temporary payment with id {temp_id}")
# create a temporary payment here so we can check if
# the balance is enough in the next step
try:
new_payment = await create_payment(
checking_id=temp_id,
fee=-abs(fee_reserve_total_msat),
conn=conn,
**payment_kwargs,
)
return new_payment
except Exception as exc:
logger.error(f"could not create temporary payment: {exc}")
# happens if the same wallet tries to pay an invoice twice
raise PaymentError("Could not make payment", status="failed") from exc
def _check_wallet_balance(
wallet: Wallet,
fee_reserve_total_msat: int,
internal_checking_id: Optional[str] = None,
):
if wallet.balance_msat < 0:
logger.debug("balance is too low, deleting temporary payment")
if not internal_checking_id and wallet.balance_msat > -fee_reserve_total_msat:
raise PaymentError(
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
" sat) to cover potential routing fees.",
status="failed",
)
raise PaymentError("Insufficient balance.", status="failed")
async def check_wallet_limits(wallet_id, conn, amount_msat):
await check_time_limit_between_transactions(conn, wallet_id)
await check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat)
@@ -597,12 +652,11 @@ async def check_transaction_status(
)
if not payment:
return PaymentPendingStatus()
if not payment.pending:
# note: before, we still checked the status of the payment again
if payment.status == PaymentState.SUCCESS.value:
return PaymentSuccessStatus(fee_msat=payment.fee)
status: PaymentStatus = await payment.check_status()
return status
return await payment.check_status()
# WARN: this same value must be used for balance check and passed to
@@ -636,7 +690,7 @@ def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
async def send_payment_notification(wallet: Wallet, payment: Payment):
await websocket_updater(
wallet.id,
wallet.inkey,
json.dumps(
{
"wallet_balance": wallet.balance,
@@ -645,6 +699,10 @@ async def send_payment_notification(wallet: Wallet, payment: Payment):
),
)
await websocket_updater(
payment.payment_hash, json.dumps({"pending": payment.pending})
)
async def update_wallet_balance(wallet_id: str, amount: int):
payment_hash, _ = await create_invoice(
@@ -656,7 +714,9 @@ async def update_wallet_balance(wallet_id: str, amount: int):
async with db.connect() as conn:
checking_id = await check_internal(payment_hash, conn=conn)
assert checking_id, "newly created checking_id cannot be retrieved"
await update_payment_status(checking_id=checking_id, pending=False, conn=conn)
await update_payment_status(
checking_id=checking_id, status=PaymentState.SUCCESS, conn=conn
)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
@@ -756,6 +816,41 @@ async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings
return await create_admin_settings(account.id, editable_settings.dict())
async def create_user_account(
user_id: Optional[str] = None,
email: Optional[str] = None,
username: Optional[str] = None,
password: Optional[str] = None,
wallet_name: Optional[str] = None,
user_config: Optional[UserConfig] = None,
) -> User:
if not settings.new_accounts_allowed:
raise ValueError("Account creation is disabled.")
if username and await get_account_by_username(username):
raise ValueError("Username already exists.")
if email and await get_account_by_email(email):
raise ValueError("Email already exists.")
if user_id:
user_uuid4 = UUID(hex=user_id, version=4)
assert user_uuid4.hex == user_id, "User ID is not valid UUID4 hex string"
else:
user_id = uuid4().hex
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
password = pwd_context.hash(password) if password else None
account = await create_account(user_id, username, email, password, user_config)
wallet = await create_wallet(user_id=account.id, wallet_name=wallet_name)
account.wallets = [wallet]
for ext_id in settings.lnbits_user_default_extensions:
await update_user_extension(user_id=account.id, extension=ext_id, active=True)
return account
class WebsocketConnectionManager:
def __init__(self) -> None:
self.active_connections: List[WebSocket] = []
@@ -797,3 +892,23 @@ async def get_balance_delta() -> BalanceDelta:
lnbits_balance_msats=lnbits_balance,
node_balance_msats=status.balance_msat,
)
async def update_pending_payments(wallet_id: str):
pending_payments = await get_payments(
wallet_id=wallet_id,
pending=True,
exclude_uncheckable=True,
)
for payment in pending_payments:
status = await payment.check_status()
if status.failed:
await update_payment_status(
checking_id=payment.checking_id,
status=PaymentState.FAILED,
)
elif status.success:
await update_payment_status(
checking_id=payment.checking_id,
status=PaymentState.SUCCESS,
)
@@ -173,7 +173,7 @@
@remove="removeBlockedIPs(blocked_ip)"
color="primary"
text-color="white"
v-text="blocked_ip"
:label="blocked_ip"
></q-chip>
</div>
<br />
@@ -202,7 +202,7 @@
@remove="removeAllowedIPs(allowed_ip)"
color="primary"
text-color="white"
v-text="allowed_ip"
:label="allowed_ip"
></q-chip>
</div>
<br />
+95 -87
View File
@@ -1,24 +1,19 @@
<q-tab-panel name="server">
<q-card-section class="q-pa-none">
<h6 class="q-my-none">Server Management</h6>
<br />
<div>
<div class="row">
<div class="col">
<p>Server Info</p>
<ul>
<li
v-if="settings.lnbits_data_folder"
v-text="'SQlite: ' + settings.lnbits_data_folder"
></li>
<li
v-if="settings.lnbits_database_url"
v-text="'Postgres: ' + settings.lnbits_database_url"
></li>
</ul>
<div class="col-md-6">
<p>Base URL</p>
<q-input
filled
v-model.number="formData.lnbits_baseurl"
label="Static/Base url for the server"
></q-input>
<br />
</div>
</div>
<h6 class="q-my-none">Currency Settings</h6>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p>Allowed currencies</p>
@@ -45,56 +40,7 @@
<br />
</div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p>Admin Extensions</p>
<q-select
filled
v-model="formData.lnbits_admin_extensions"
multiple
hint="Extensions only user with admin privileges can use"
label="Admin extensions"
:options="g.extensions.map(e => e.code)"
></q-select>
<br />
</div>
<div class="col-12 col-md-6">
<p>Miscellaneous</p>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Disable Extensions</q-item-label>
<q-item-label caption>Disables all extensions</q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_extensions_deactivate_all"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Hide API</q-item-label>
<q-item-label caption
>Hides wallet api, extensions can choose to honor</q-item-label
>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_hide_api"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<br />
</div>
</div>
<br />
<h6 class="q-my-none">Service Fee</h6>
<div class="row q-col-gutter-md">
@@ -154,32 +100,94 @@
<br />
</div>
</div>
<q-separator></q-separator>
<h6 class="q-my-none">Extensions</h6>
<div>
<p>Extension Sources</p>
<q-input
filled
v-model="formAddExtensionsManifest"
@keydown.enter="addExtensionsManifest"
type="text"
label="Source URL (only use the official LNbits extension source, and sources you can trust)"
hint="Repositories from where the extensions can be downloaded"
>
<q-btn @click="addExtensionsManifest" dense flat icon="add"></q-btn>
</q-input>
<div>
<q-chip
v-for="manifestUrl in formData.lnbits_extensions_manifests"
:key="manifestUrl"
removable
@remove="removeExtensionsManifest(manifestUrl)"
color="primary"
text-color="white"
><span v-text="manifestUrl"></span
></q-chip>
<div class="row q-col-gutter-md">
<div class="col-12">
<p>Extension Sources</p>
<q-input
filled
v-model="formAddExtensionsManifest"
@keydown.enter="addExtensionsManifest"
type="text"
label="Source URL (only use the official LNbits extension source, and sources you can trust)"
hint="Repositories from where the extensions can be downloaded"
>
<q-btn @click="addExtensionsManifest" dense flat icon="add"></q-btn>
</q-input>
<div>
<q-chip
v-for="manifestUrl in formData.lnbits_extensions_manifests"
:key="manifestUrl"
removable
@remove="removeExtensionsManifest(manifestUrl)"
color="primary"
text-color="white"
><span v-text="manifestUrl"></span
></q-chip>
</div>
</div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p>Admin Extensions</p>
<q-select
filled
v-model="formData.lnbits_admin_extensions"
multiple
hint="Extensions only user with admin privileges can use"
label="Admin extensions"
:options="g.extensions.map(e => e.code)"
></q-select>
</div>
<div class="col-12 col-md-6">
<p>User Default Extensions</p>
<q-select
filled
v-model="formData.lnbits_user_default_extensions"
multiple
hint="Extensions that will be enabled by default for the users."
label="User extensions"
:options="g.extensions.map(e => e.code)"
></q-select>
</div>
<div class="col-12 col-md-6">
<p>Miscellaneous</p>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Disable Extensions</q-item-label>
<q-item-label caption>Disables all extensions</q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_extensions_deactivate_all"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Hide API</q-item-label>
<q-item-label caption
>Hides wallet api, extensions can choose to honor</q-item-label
>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_hide_api"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<br />
</div>
<br />
</div>
</div>
</q-card-section>
+2 -2
View File
@@ -27,8 +27,8 @@
<div class="col-12 col-md-2 q-mt-xl">
<q-toggle
tip="Remove homepage elements like 'runs on' etc"
v-model="formData.LNBITS_SHOW_HOME_PAGE_ELEMENTS"
:label="formData.LNBITS_SHOW_HOME_PAGE_ELEMENTS ? 'Enable elements on homepage' : 'Disable elements on homepage'"
v-model="formData.lnbits_show_home_page_elements"
:label="formData.lnbits_show_home_page_elements ? 'Enable elements on homepage' : 'Disable elements on homepage'"
></q-toggle>
</div>
</div>
+2 -2
View File
@@ -23,8 +23,8 @@
@remove="removeAdminUser(user)"
color="primary"
text-color="white"
:label="user"
>
<span v-text="user"></span>
</q-chip>
</div>
<br />
@@ -49,8 +49,8 @@
@remove="removeAllowedUser(user)"
color="primary"
text-color="white"
:label="user"
>
<span v-text="user" />
</q-chip>
</div>
<br />
+74 -9
View File
@@ -5,10 +5,77 @@
:content-inset-level="0.5"
>
<q-card-section>
<strong>Node URL: </strong><em v-text="origin"></em><br />
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br />
<strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br />
<strong>Invoice/read key: </strong><em>{{ wallet.inkey }}</em>
<q-list>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Node URL: </strong><em v-text="origin"></em>
</q-item-label>
</q-item-section>
</q-item>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em>
</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon
name="content_copy"
class="cursor-pointer"
@click="copyText('{{ wallet.id }}')"
></q-icon>
</q-item-section>
</q-item>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Admin key: </strong
><em
v-text="adminkeyHidden ? '****************' : `{{ wallet.adminkey }}`"
></em>
</q-item-label>
</q-item-section>
<q-item-section side>
<div>
<q-icon
:name="adminkeyHidden ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="adminkeyHidden = !adminkeyHidden"
></q-icon>
<q-icon
name="content_copy"
class="cursor-pointer q-ml-sm"
@click="copyText('{{ wallet.adminkey }}')"
></q-icon>
</div>
</q-item-section>
</q-item>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Invoice/read key: </strong
><em
v-text="inkeyHidden ? '****************' : `{{ wallet.inkey }}`"
></em>
</q-item-label>
</q-item-section>
<q-item-section side>
<div>
<q-icon
:name="inkeyHidden ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="inkeyHidden = !inkeyHidden"
></q-icon>
<q-icon
name="content_copy"
class="cursor-pointer q-ml-sm"
@click="copyText('{{ wallet.inkey }}')"
></q-icon>
</div>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
<q-expansion-item
group="api"
@@ -109,18 +176,16 @@
><span class="text-light-green">POST</span>
/api/v1/payments/decode</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"invoice": &lt;string&gt;}</code>
<code>{"data": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}api/v1/payments/decode -d
'{"data": &lt;bolt11/lnurl, string&gt;}' -H "X-Api-Key:
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code
'{"data": &lt;bolt11/lnurl, string&gt;}' -H "Content-type:
application/json"</code
>
</q-card-section>
</q-card>
+21
View File
@@ -373,6 +373,27 @@
</q-btn>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('gradient_background')"></span>
</div>
<div class="col-8">
<q-btn
dense
flat
round
@click="toggleGradient"
icon="gradient"
size="sm"
v-model="gradientChoice"
>
<q-tooltip
><span v-text="$t('toggle_gradient')"></span
></q-tooltip>
</q-btn>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('toggle_darkmode')"></span>
File diff suppressed because it is too large Load Diff
+39 -2
View File
@@ -33,7 +33,7 @@
color="primary"
@click="processing"
type="a"
href="{{ url_for('core.lnurlwallet') }}?lightning={{ lnurl }}"
href="/lnurlwallet?lightning={{ lnurl }}"
v-text="$t('press_to_claim')"
class="full-width"
></q-btn>
@@ -484,6 +484,32 @@
</a>
</div>
</div>
<div class="row">
<div class="col">
<a
href="https://breez.technology/sdk/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/breez.png') }}' : '{{ static_url_for('static', 'images/breezl.png') }}'"
></q-img>
</a>
</div>
<div class="col q-pl-md">
<a
href="https://blockstream.com/lightning/greenlight/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/greenlight.png') }}' : '{{ static_url_for('static', 'images/greenlightl.png') }}'"
></q-img>
</a>
</div>
</div>
<div class="row">
<div class="col">
<a
@@ -519,7 +545,18 @@
></q-img>
</a>
</div>
<div class="col q-pl-md"></div>
<div class="col">
<a
href="https://boltz.exchange/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/boltz.svg') }}' : '{{ static_url_for('static', 'images/boltz.svg') }}'"
></q-img>
</a>
</div>
</div>
</div>
</div>
+11 -5
View File
@@ -156,9 +156,17 @@
<p v-text="$t('export_to_phone_desc')"></p>
<qrcode
:value="'{{request.base_url}}wallet?usr={{user.id}}&wal={{wallet.id}}'"
:options="{width:240}"
:options="{ width: 256 }"
></qrcode>
</q-card-section>
<q-card-actions class="flex-center q-pb-md">
<q-btn
outline
color="grey"
:label="$t('copy_wallet_url')"
@click="copyText('{{request.base_url}}wallet?usr={{user.id}}&wal={{wallet.id}}')"
></q-btn>
</q-card-actions>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
@@ -311,13 +319,11 @@
<q-input
ref="setAmount"
filled
:pattern="receive.unit === 'sat' ? '\\d*' : '\\d*\\.?\\d*'"
inputmode="numeric"
dense
v-model.number="receive.data.amount"
:label="$t('amount') + ' (' + receive.unit + ') *'"
:mask="receive.unit != 'sat' ? '#.##' : '#'"
fill-mask="0"
reverse-fill-mask
:step="receive.unit != 'sat' ? '0.01' : '1'"
:min="receive.minMax[0]"
:max="receive.minMax[1]"
:readonly="receive.lnurl && receive.lnurl.fixed"
+10 -7
View File
@@ -33,14 +33,11 @@ from lnbits.settings import settings
from lnbits.utils.exchange_rates import (
allowed_currencies,
fiat_amount_as_satoshis,
get_fiat_rate_satoshis,
satoshis_amount_as_fiat,
)
from ..crud import (
create_account,
create_wallet,
)
from ..services import perform_lnurlauth
from ..services import create_user_account, perform_lnurlauth
# backwards compatibility for extension
# TODO: remove api_payment and pay_invoice imports from extensions
@@ -70,8 +67,8 @@ async def api_create_account(data: CreateWallet) -> Wallet:
status_code=HTTPStatus.FORBIDDEN,
detail="Account creation is disabled.",
)
account = await create_account()
return await create_wallet(user_id=account.id, wallet_name=data.name)
account = await create_user_account(wallet_name=data.name)
return account.wallets[0]
@api_router.get("/api/v1/lnurlscan/{code}")
@@ -204,6 +201,12 @@ async def api_perform_lnurlauth(
return ""
@api_router.get("/api/v1/rate/{currency}")
async def api_check_fiat_rate(currency: str) -> Dict[str, float]:
rate = await get_fiat_rate_satoshis(currency)
return {"rate": rate}
@api_router.get("/api/v1/currencies")
async def api_list_currencies_available() -> List[str]:
return allowed_currencies()
+5 -4
View File
@@ -12,6 +12,7 @@ from starlette.status import (
HTTP_500_INTERNAL_SERVER_ERROR,
)
from lnbits.core.services import create_user_account
from lnbits.decorators import check_user_exists
from lnbits.helpers import (
create_access_token,
@@ -23,8 +24,6 @@ from lnbits.helpers import (
from lnbits.settings import AuthMethods, settings
from ..crud import (
create_account,
create_user,
get_account,
get_account_by_email,
get_account_by_username_or_email,
@@ -166,7 +165,9 @@ async def register(data: CreateUser) -> JSONResponse:
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid email.")
try:
user = await create_user(data)
user = await create_user_account(
email=data.email, username=data.username, password=data.password
)
return _auth_success_response(user.username)
except ValueError as exc:
@@ -274,7 +275,7 @@ async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] =
else:
if not settings.new_accounts_allowed:
raise HTTPException(HTTP_400_BAD_REQUEST, "Account creation is disabled.")
user = await create_account(email=email, user_config=user_config)
user = await create_user_account(email=email, user_config=user_config)
if not user:
raise HTTPException(HTTP_401_UNAUTHORIZED, "User not found.")
+264 -18
View File
@@ -1,3 +1,4 @@
import sys
from http import HTTPStatus
from typing import (
List,
@@ -18,18 +19,25 @@ from lnbits.core.helpers import (
stop_extension_background_work,
)
from lnbits.core.models import (
SimpleStatus,
User,
)
from lnbits.core.services import check_transaction_status, create_invoice
from lnbits.decorators import (
check_access_token,
check_admin,
check_user_exists,
)
from lnbits.extension_manager import (
CreateExtension,
Extension,
ExtensionRelease,
InstallableExtension,
PayToEnableInfo,
ReleasePaymentInfo,
UserExtensionInfo,
fetch_github_release_config,
fetch_release_details,
fetch_release_payment_info,
get_valid_extensions,
)
@@ -43,6 +51,11 @@ from ..crud import (
get_dbversions,
get_installed_extension,
get_installed_extensions,
get_user_extension,
update_extension_pay_to_enable,
update_installed_extension_state,
update_user_extension,
update_user_extension_extra,
)
extension_router = APIRouter(
@@ -88,20 +101,18 @@ async def api_install_extension(
db_version = (await get_dbversions()).get(data.ext_id, 0)
await migrate_extension_database(extension, db_version)
ext_info.active = True
await add_installed_extension(ext_info)
if extension.is_upgrade_extension:
# call stop while the old routes are still active
await stop_extension_background_work(data.ext_id, user.id, access_token)
if data.ext_id not in settings.lnbits_deactivated_extensions:
settings.lnbits_deactivated_extensions += [data.ext_id]
# mount routes for the new version
core_app_extra.register_new_ext_routes(extension)
if extension.upgrade_hash:
ext_info.notify_upgrade()
ext_info.notify_upgrade(extension.upgrade_hash)
settings.lnbits_deactivated_extensions.discard(data.ext_id)
return extension
except AssertionError as exc:
@@ -118,18 +129,200 @@ async def api_install_extension(
) from exc
@extension_router.get("/{ext_id}/details", dependencies=[Depends(check_user_exists)])
async def api_extension_details(
ext_id: str,
details_link: str,
):
try:
all_releases = await InstallableExtension.get_extension_releases(ext_id)
release = next(
(r for r in all_releases if r.details_link == details_link), None
)
assert release, "Details not found for release"
release_details = await fetch_release_details(details_link)
assert release_details, "Cannot fetch details for release"
release_details["icon"] = release.icon
release_details["repo"] = release.repo
return release_details
except AssertionError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
except Exception as exc:
logger.warning(exc)
raise HTTPException(
HTTPStatus.INTERNAL_SERVER_ERROR,
f"Failed to get details for extension {ext_id}.",
) from exc
@extension_router.put("/{ext_id}/sell")
async def api_update_pay_to_enable(
ext_id: str,
data: PayToEnableInfo,
user: User = Depends(check_admin),
) -> SimpleStatus:
try:
assert (
data.wallet in user.wallet_ids
), "Wallet does not belong to this admin user."
await update_extension_pay_to_enable(ext_id, data)
return SimpleStatus(
success=True, message=f"Payment info updated for '{ext_id}' extension."
)
except AssertionError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
except Exception as exc:
logger.warning(exc)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=(f"Failed to update pay to install data for extension '{ext_id}' "),
) from exc
@extension_router.put("/{ext_id}/enable")
async def api_enable_extension(
ext_id: str, user: User = Depends(check_user_exists)
) -> SimpleStatus:
if ext_id not in [e.code for e in get_valid_extensions()]:
raise HTTPException(
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist."
)
try:
logger.info(f"Enabling extension: {ext_id}.")
ext = await get_installed_extension(ext_id)
assert ext, f"Extension '{ext_id}' is not installed."
assert ext.active, f"Extension '{ext_id}' is not activated."
if user.admin or not ext.requires_payment:
await update_user_extension(user_id=user.id, extension=ext_id, active=True)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
user_ext = await get_user_extension(user.id, ext_id)
if not (user_ext and user_ext.extra and user_ext.extra.payment_hash_to_enable):
raise HTTPException(
HTTPStatus.PAYMENT_REQUIRED, f"Extension '{ext_id}' requires payment."
)
if user_ext.is_paid:
await update_user_extension(user_id=user.id, extension=ext_id, active=True)
return SimpleStatus(
success=True, message=f"Paid extension '{ext_id}' enabled."
)
assert (
ext.pay_to_enable and ext.pay_to_enable.wallet
), f"Extension '{ext_id}' is missing payment wallet."
payment_status = await check_transaction_status(
wallet_id=ext.pay_to_enable.wallet,
payment_hash=user_ext.extra.payment_hash_to_enable,
)
if not payment_status.paid:
raise HTTPException(
HTTPStatus.PAYMENT_REQUIRED,
f"Invoice generated but not paid for enabeling extension '{ext_id}'.",
)
user_ext.extra.paid_to_enable = True
await update_user_extension_extra(user.id, ext_id, user_ext.extra)
await update_user_extension(user_id=user.id, extension=ext_id, active=True)
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
except AssertionError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
except HTTPException as exc:
raise exc from exc
except Exception as exc:
logger.warning(exc)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=(f"Failed to enable '{ext_id}' "),
) from exc
@extension_router.put("/{ext_id}/disable")
async def api_disable_extension(
ext_id: str, user: User = Depends(check_user_exists)
) -> SimpleStatus:
if ext_id not in [e.code for e in get_valid_extensions()]:
raise HTTPException(
HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' doesn't exist."
)
try:
logger.info(f"Disabeling extension: {ext_id}.")
await update_user_extension(user_id=user.id, extension=ext_id, active=False)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
except Exception as exc:
logger.warning(exc)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=(f"Failed to disable '{ext_id}'."),
) from exc
@extension_router.put("/{ext_id}/activate", dependencies=[Depends(check_admin)])
async def api_activate_extension(ext_id: str) -> SimpleStatus:
try:
logger.info(f"Activating extension: '{ext_id}'.")
all_extensions = get_valid_extensions()
ext = next((e for e in all_extensions if e.code == ext_id), None)
assert ext, f"Extension '{ext_id}' doesn't exist."
# if extension never loaded (was deactivated on server startup)
if ext_id not in sys.modules.keys():
# run extension start-up routine
core_app_extra.register_new_ext_routes(ext)
settings.lnbits_deactivated_extensions.discard(ext_id)
await update_installed_extension_state(ext_id=ext_id, active=True)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' activated.")
except Exception as exc:
logger.warning(exc)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=(f"Failed to activate '{ext_id}'."),
) from exc
@extension_router.put("/{ext_id}/deactivate", dependencies=[Depends(check_admin)])
async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
try:
logger.info(f"Deactivating extension: '{ext_id}'.")
all_extensions = get_valid_extensions()
ext = next((e for e in all_extensions if e.code == ext_id), None)
assert ext, f"Extension '{ext_id}' doesn't exist."
settings.lnbits_deactivated_extensions.add(ext_id)
await update_installed_extension_state(ext_id=ext_id, active=False)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' deactivated.")
except Exception as exc:
logger.warning(exc)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=(f"Failed to deactivate '{ext_id}'."),
) from exc
@extension_router.delete("/{ext_id}")
async def api_uninstall_extension(
ext_id: str,
user: User = Depends(check_admin),
access_token: Optional[str] = Depends(check_access_token),
):
) -> SimpleStatus:
installed_extensions = await get_installed_extensions()
extensions = [e for e in installed_extensions if e.id == ext_id]
if len(extensions) == 0:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
status_code=HTTPStatus.NOT_FOUND,
detail=f"Unknown extension id: {ext_id}",
)
@@ -151,14 +344,14 @@ async def api_uninstall_extension(
# call stop while the old routes are still active
await stop_extension_background_work(ext_id, user.id, access_token)
if ext_id not in settings.lnbits_deactivated_extensions:
settings.lnbits_deactivated_extensions += [ext_id]
settings.lnbits_deactivated_extensions.add(ext_id)
for ext_info in extensions:
ext_info.clean_extension_files()
await delete_installed_extension(ext_id=ext_info.id)
logger.success(f"Extension '{ext_id}' uninstalled.")
return SimpleStatus(success=True, message=f"Extension '{ext_id}' uninstalled.")
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
@@ -166,7 +359,7 @@ async def api_uninstall_extension(
@extension_router.get("/{ext_id}/releases", dependencies=[Depends(check_admin)])
async def get_extension_releases(ext_id: str):
async def get_extension_releases(ext_id: str) -> List[ExtensionRelease]:
try:
extension_releases: List[ExtensionRelease] = (
await InstallableExtension.get_extension_releases(ext_id)
@@ -189,30 +382,35 @@ async def get_extension_releases(ext_id: str):
) from exc
@extension_router.put("/invoice", dependencies=[Depends(check_admin)])
async def get_extension_invoice(data: CreateExtension):
@extension_router.put("/{ext_id}/invoice/install", dependencies=[Depends(check_admin)])
async def get_pay_to_install_invoice(
ext_id: str, data: CreateExtension
) -> ReleasePaymentInfo:
try:
assert data.cost_sats, "A non-zero amount must be specified"
assert (
ext_id == data.ext_id
), f"Wrong extension id. Expected {ext_id}, but got {data.ext_id}"
assert data.cost_sats, "A non-zero amount must be specified."
release = await InstallableExtension.get_extension_release(
data.ext_id, data.source_repo, data.archive, data.version
)
assert release, "Release not found"
assert release.pay_link, "Pay link not found for release"
assert release, "Release not found."
assert release.pay_link, "Pay link not found for release."
payment_info = await fetch_release_payment_info(
release.pay_link, data.cost_sats
)
assert payment_info and payment_info.payment_request, "Cannot request invoice"
assert payment_info and payment_info.payment_request, "Cannot request invoice."
invoice = bolt11_decode(payment_info.payment_request)
assert invoice.amount_msat is not None, "Invoic amount is missing"
assert invoice.amount_msat is not None, "Invoic amount is missing."
invoice_amount = int(invoice.amount_msat / 1000)
assert (
invoice_amount == data.cost_sats
), f"Wrong invoice amount: {invoice_amount}."
assert (
payment_info.payment_hash == invoice.payment_hash
), "Wroong invoice payment hash"
), "Wrong invoice payment hash."
return payment_info
@@ -225,6 +423,51 @@ async def get_extension_invoice(data: CreateExtension):
) from exc
@extension_router.put("/{ext_id}/invoice/enable")
async def get_pay_to_enable_invoice(
ext_id: str, data: PayToEnableInfo, user: User = Depends(check_user_exists)
):
try:
assert data.amount and data.amount > 0, "A non-zero amount must be specified."
ext = await get_installed_extension(ext_id)
assert ext, f"Extension '{ext_id}' not found."
assert ext.pay_to_enable, f"Payment Info not found for extension '{ext_id}'."
assert (
ext.pay_to_enable.required
), f"Payment not required for extension '{ext_id}'."
assert ext.pay_to_enable.wallet and ext.pay_to_enable.amount, (
f"Payment wallet or amount missing for extension '{ext_id}'."
"Please contact the administrator."
)
assert (
data.amount >= ext.pay_to_enable.amount
), f"Minimum amount is {ext.pay_to_enable.amount} sats."
payment_hash, payment_request = await create_invoice(
wallet_id=ext.pay_to_enable.wallet,
amount=data.amount,
memo=f"Enable '{ext.name}' extension.",
)
user_ext = await get_user_extension(user.id, ext_id)
user_ext_info = (
user_ext.extra if user_ext and user_ext.extra else UserExtensionInfo()
)
user_ext_info.payment_hash_to_enable = payment_hash
await update_user_extension_extra(user.id, ext_id, user_ext_info)
return {"payment_hash": payment_hash, "payment_request": payment_request}
except AssertionError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
except Exception as exc:
logger.warning(exc)
raise HTTPException(
HTTPStatus.INTERNAL_SERVER_ERROR, "Cannot request invoice."
) from exc
@extension_router.get(
"/release/{org}/{repo}/{tag_name}",
dependencies=[Depends(check_admin)],
@@ -261,6 +504,9 @@ async def delete_extension_db(ext_id: str):
await drop_extension_db(ext_id=ext_id)
await delete_dbversion(ext_id=ext_id)
logger.success(f"Database removed for extension '{ext_id}'")
return SimpleStatus(
success=True, message=f"DB deleted for '{ext_id}' extension."
)
except HTTPException as ex:
logger.error(ex)
raise ex
+64 -65
View File
@@ -1,19 +1,20 @@
import sys
from http import HTTPStatus
from pathlib import Path
from typing import Annotated, List, Optional, Union
from urllib.parse import urlparse
from urllib.parse import urlencode, urlparse
import httpx
from fastapi import Cookie, Depends, Query, Request
from fastapi.exceptions import HTTPException
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.routing import APIRouter
from lnurl import decode as lnurl_decode
from loguru import logger
from pydantic.types import UUID4
from lnbits.core.db import core_app_extra
from lnbits.core.helpers import to_valid_user_id
from lnbits.core.models import User
from lnbits.core.services import create_invoice
from lnbits.decorators import check_admin, check_user_exists
from lnbits.helpers import template_renderer
from lnbits.settings import settings
@@ -22,13 +23,11 @@ from lnbits.wallets import get_funding_source
from ...extension_manager import InstallableExtension, get_valid_extensions
from ...utils.exchange_rates import allowed_currencies, currencies
from ..crud import (
create_account,
create_wallet,
get_dbversions,
get_inactive_extensions,
get_installed_extensions,
get_user,
update_installed_extension_state,
update_user_extension,
)
generic_router = APIRouter(
@@ -73,21 +72,10 @@ async def robots():
return HTMLResponse(content=data, media_type="text/plain")
@generic_router.get(
"/extensions", name="install.extensions", response_class=HTMLResponse
)
async def extensions_install(
request: Request,
user: User = Depends(check_user_exists),
activate: str = Query(None),
deactivate: str = Query(None),
enable: str = Query(None),
disable: str = Query(None),
):
await toggle_extension(enable, disable, user.id)
@generic_router.get("/extensions", name="extensions", response_class=HTMLResponse)
async def extensions(request: Request, user: User = Depends(check_user_exists)):
try:
installed_exts: List["InstallableExtension"] = await get_installed_extensions()
installed_exts: List[InstallableExtension] = await get_installed_extensions()
installed_exts_ids = [e.id for e in installed_exts]
installable_exts = await InstallableExtension.get_installable_extensions()
@@ -100,6 +88,11 @@ async def extensions_install(
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
if installed_ext:
e.installed_release = installed_ext.installed_release
if installed_ext.pay_to_enable and not user.admin:
# not a security leak, but better not to share the wallet id
installed_ext.pay_to_enable.wallet = None
e.pay_to_enable = installed_ext.pay_to_enable
# use the installed extension values
e.name = installed_ext.name
e.short_description = installed_ext.short_description
@@ -111,30 +104,10 @@ async def extensions_install(
installed_exts_ids = []
try:
ext_id = activate or deactivate
all_extensions = get_valid_extensions()
ext = next((e for e in all_extensions if e.code == ext_id), None)
if ext_id and user.admin:
if deactivate and deactivate not in settings.lnbits_deactivated_extensions:
settings.lnbits_deactivated_extensions += [deactivate]
elif activate:
# if extension never loaded (was deactivated on server startup)
if ext_id not in sys.modules.keys():
# run extension start-up routine
core_app_extra.register_new_ext_routes(ext)
settings.lnbits_deactivated_extensions = list(
filter(
lambda e: e != activate, settings.lnbits_deactivated_extensions
)
)
await update_installed_extension_state(
ext_id=ext_id, active=activate is not None
)
all_ext_ids = [ext.code for ext in all_extensions]
inactive_extensions = await get_inactive_extensions()
all_ext_ids = [ext.code for ext in get_valid_extensions()]
inactive_extensions = [
e.id for e in await get_installed_extensions(active=False)
]
db_version = await get_dbversions()
extensions = [
{
@@ -156,6 +129,8 @@ async def extensions_install(
"installedRelease": (
dict(ext.installed_release) if ext.installed_release else None
),
"payToEnable": (dict(ext.pay_to_enable) if ext.pay_to_enable else {}),
"isPaymentRequired": ext.requires_payment,
}
for ext in installable_exts
]
@@ -203,7 +178,7 @@ async def wallet(
user_wallet = user.get_wallet(wallet_id)
if not user_wallet or user_wallet.deleted:
return template_renderer().TemplateResponse(
request, "error.html", {"err": "Wallet not found"}
request, "error.html", {"err": "Wallet not found"}, HTTPStatus.NOT_FOUND
)
resp = template_renderer().TemplateResponse(
@@ -420,27 +395,51 @@ async def hex_to_uuid4(hex_value: str):
) from exc
async def toggle_extension(extension_to_enable, extension_to_disable, user_id):
if extension_to_enable and extension_to_disable:
raise HTTPException(
HTTPStatus.BAD_REQUEST, "You can either `enable` or `disable` an extension."
)
@generic_router.get("/lnurlwallet", response_class=RedirectResponse)
async def lnurlwallet(request: Request):
"""
If a user doesn't have a Lightning Network wallet and scans the LNURLw QR code with
their smartphone camera, or a QR scanner app, they can follow the link provided to
claim their satoshis and get an instant LNbits wallet! lnbits/withdraw docs
"""
# check if extension exists
if extension_to_enable or extension_to_disable:
ext = extension_to_enable or extension_to_disable
if ext not in [e.code for e in get_valid_extensions()]:
lightning_param = request.query_params.get("lightning")
if not lightning_param:
return {"status": "ERROR", "reason": "lightning parameter not provided."}
if not settings.lnbits_allow_new_accounts:
return {"status": "ERROR", "reason": "New accounts are not allowed."}
lnurl = lnurl_decode(lightning_param)
async with httpx.AsyncClient() as client:
res1 = await client.get(lnurl, timeout=2)
res1.raise_for_status()
data1 = res1.json()
if data1.get("tag") != "withdrawRequest":
raise HTTPException(
HTTPStatus.BAD_REQUEST, f"Extension '{ext}' doesn't exist."
status_code=HTTPStatus.BAD_REQUEST,
detail="Invalid lnurl. Expected tag=withdrawRequest",
)
if not data1.get("maxWithdrawable"):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Invalid lnurl. Expected maxWithdrawable",
)
account = await create_account()
wallet = await create_wallet(user_id=account.id)
_, payment_request = await create_invoice(
wallet_id=wallet.id,
amount=data1.get("maxWithdrawable") / 1000,
memo=data1.get("defaultDescription", "lnurl wallet withdraw"),
)
url = data1.get("callback")
params = {"k1": data1.get("k1"), "pr": payment_request}
callback = url + ("&" if urlparse(url).query else "?") + urlencode(params)
if extension_to_enable:
logger.info(f"Enabling extension: {extension_to_enable} for user {user_id}")
await update_user_extension(
user_id=user_id, extension=extension_to_enable, active=True
)
elif extension_to_disable:
logger.info(f"Disabling extension: {extension_to_disable} for user {user_id}")
await update_user_extension(
user_id=user_id, extension=extension_to_disable, active=False
)
res2 = await client.get(callback, timeout=2)
res2.raise_for_status()
return RedirectResponse(
f"/wallet?usr={account.id}&wal={wallet.id}",
)
+8 -15
View File
@@ -40,7 +40,7 @@ from lnbits.decorators import (
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import generate_filter_params_openapi
from lnbits.helpers import filter_dict_keys, generate_filter_params_openapi
from lnbits.lnurl import decode as lnurl_decode
from lnbits.settings import settings
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
@@ -52,13 +52,12 @@ from ..crud import (
get_payments_paginated,
get_standalone_payment,
get_wallet_for_key,
update_pending_payments,
)
from ..services import (
check_transaction_status,
create_invoice,
fee_reserve_total,
pay_invoice,
update_pending_payments,
)
from ..tasks import api_invoice_listeners
@@ -402,15 +401,8 @@ 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_transaction_status(payment.wallet_id, payment_hash)
payment = await get_standalone_payment(
payment_hash, wallet_id=wallet.id if wallet else None
)
if not payment:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
)
elif not payment.pending:
if payment.success:
if wallet and wallet.id == payment.wallet_id:
return {"paid": True, "preimage": payment.preimage, "details": payment}
return {"paid": True, "preimage": payment.preimage}
@@ -424,12 +416,12 @@ async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
if wallet and wallet.id == payment.wallet_id:
return {
"paid": not payment.pending,
"paid": payment.success,
"status": f"{status!s}",
"preimage": payment.preimage,
"details": payment,
}
return {"paid": not payment.pending, "preimage": payment.preimage}
return {"paid": payment.success, "preimage": payment.preimage}
@payment_router.post("/decode", status_code=HTTPStatus.OK)
@@ -441,7 +433,8 @@ async def api_payments_decode(data: DecodePayment) -> JSONResponse:
return JSONResponse({"domain": url})
else:
invoice = bolt11.decode(payment_str)
return JSONResponse(invoice.data)
filtered_data = filter_dict_keys(invoice.data, data.filter_fields)
return JSONResponse(filtered_data)
except Exception as exc:
return JSONResponse(
{"message": f"Failed to decode: {exc!s}"},
+2 -1
View File
@@ -20,7 +20,8 @@ async def api_public_payment_longpolling(payment_hash):
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
)
elif not payment.pending:
# TODO: refactor to use PaymentState
if payment.success:
return {"status": "paid"}
try:
+4 -4
View File
@@ -84,10 +84,10 @@ async def api_users_toggle_admin(user_id: str) -> None:
settings.lnbits_admin_users.remove(user_id)
else:
settings.lnbits_admin_users.append(user_id)
update_settings = EditableSettings(
lnbits_admin_users=settings.lnbits_admin_users
)
await update_admin_settings(update_settings)
update_settings = EditableSettings(
lnbits_admin_users=settings.lnbits_admin_users
)
await update_admin_settings(update_settings)
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
+34 -17
View File
@@ -6,8 +6,10 @@ from urllib.parse import unquote, urlparse
from fastapi import (
APIRouter,
Depends,
HTTPException,
Request,
)
from loguru import logger
from lnbits.core.models import (
CreateWebPushSubscription,
@@ -33,20 +35,27 @@ async def api_create_webpush_subscription(
data: CreateWebPushSubscription,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> WebPushSubscription:
subscription = json.loads(data.subscription)
endpoint = subscription["endpoint"]
host = urlparse(str(request.url)).netloc
try:
subscription = json.loads(data.subscription)
endpoint = subscription["endpoint"]
host = urlparse(str(request.url)).netloc
subscription = await get_webpush_subscription(endpoint, wallet.wallet.user)
if subscription:
return subscription
else:
return await create_webpush_subscription(
endpoint,
wallet.wallet.user,
data.subscription,
host,
)
subscription = await get_webpush_subscription(endpoint, wallet.wallet.user)
if subscription:
return subscription
else:
return await create_webpush_subscription(
endpoint,
wallet.wallet.user,
data.subscription,
host,
)
except Exception as exc:
logger.debug(exc)
raise HTTPException(
HTTPStatus.INTERNAL_SERVER_ERROR,
"Cannot create webpush notification",
) from exc
@webpush_router.delete("", status_code=HTTPStatus.OK)
@@ -54,7 +63,15 @@ async def api_delete_webpush_subscription(
request: Request,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
endpoint = unquote(
base64.b64decode(str(request.query_params.get("endpoint"))).decode("utf-8")
)
await delete_webpush_subscription(endpoint, wallet.wallet.user)
try:
endpoint = unquote(
base64.b64decode(str(request.query_params.get("endpoint"))).decode("utf-8")
)
count = await delete_webpush_subscription(endpoint, wallet.wallet.user)
return {"count": count}
except Exception as exc:
logger.debug(exc)
raise HTTPException(
HTTPStatus.INTERNAL_SERVER_ERROR,
"Cannot delete webpush notification",
) from exc
+10 -4
View File
@@ -65,6 +65,14 @@ def compat_timestamp_placeholder():
return "?"
def get_placeholder(model: Any, field: str) -> str:
type_ = model.__fields__[field].type_
if type_ == datetime.datetime:
return compat_timestamp_placeholder()
else:
return "?"
class Compat:
type: Optional[str] = "<inherited>"
schema: Optional[str] = "<inherited>"
@@ -422,10 +430,8 @@ class Filter(BaseModel, Generic[TFilterModel]):
@property
def statement(self):
if self.model and self.model.__fields__[self.field].type_ == datetime.datetime:
placeholder = compat_timestamp_placeholder()
else:
placeholder = "?"
assert self.model, "Model is required for statement generation"
placeholder = get_placeholder(self.model, self.field)
if self.op in (Operator.INCLUDE, Operator.EXCLUDE):
placeholders = ", ".join([placeholder] * len(self.values))
stmt = [f"{self.field} {self.op.as_sql} ({placeholders})"]
+52 -24
View File
@@ -1,12 +1,12 @@
from http import HTTPStatus
from typing import Annotated, Literal, Optional, Type, Union
import jwt
from fastapi import Cookie, Depends, Query, Request, Security
from fastapi.exceptions import HTTPException
from fastapi.openapi.models import APIKey, APIKeyIn, SecuritySchemeType
from fastapi.security import APIKeyHeader, APIKeyQuery, OAuth2PasswordBearer
from fastapi.security.base import SecurityBase
from jose import ExpiredSignatureError, JWTError, jwt
from loguru import logger
from pydantic.types import UUID4
@@ -15,9 +15,10 @@ from lnbits.core.crud import (
get_account_by_email,
get_account_by_username,
get_user,
get_user_active_extensions_ids,
get_wallet_for_key,
)
from lnbits.core.models import KeyType, User, WalletTypeInfo
from lnbits.core.models import KeyType, SimpleStatus, User, WalletTypeInfo
from lnbits.db import Filter, Filters, TFilterModel
from lnbits.settings import AuthMethods, settings
@@ -88,16 +89,7 @@ class KeyChecker(SecurityBase):
detail="Invalid adminkey.",
)
if (
wallet.user != settings.super_user
and wallet.user not in settings.lnbits_admin_users
and settings.lnbits_admin_extensions
and request["path"].split("/")[1] in settings.lnbits_admin_extensions
):
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="User not authorized for this extension.",
)
await _check_user_extension_access(wallet.user, request["path"])
key_type = KeyType.admin if wallet.adminkey == key_value else KeyType.invoice
return WalletTypeInfo(key_type, wallet)
@@ -161,19 +153,24 @@ async def check_user_exists(
user = await get_user(account.id)
assert user, "User not found for account."
if (
user.id != settings.super_user
and user.id not in settings.lnbits_admin_users
and settings.lnbits_admin_extensions
and r["path"].split("/")[1] in settings.lnbits_admin_extensions
):
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "User not authorized for extension."
)
await _check_user_extension_access(user.id, r["path"])
return user
async def optional_user_id(
access_token: Annotated[Optional[str], Depends(check_access_token)],
usr: Optional[UUID4] = None,
) -> Optional[str]:
if usr and settings.is_auth_method_allowed(AuthMethods.user_id_only):
return usr.hex
if access_token:
account = await _get_account_from_token(access_token)
return account.id if account else None
return None
async def check_admin(user: Annotated[User, Depends(check_user_exists)]) -> User:
if user.id != settings.super_user and user.id not in settings.lnbits_admin_users:
raise HTTPException(
@@ -226,9 +223,40 @@ def parse_filters(model: Type[TFilterModel]):
return dependency
async def check_user_extension_access(user_id: str, ext_id: str) -> SimpleStatus:
"""
Check if the user has access to a particular extension.
Raises HTTP Forbidden if the user is not allowed.
"""
if settings.is_admin_extension(ext_id) and not settings.is_admin_user(user_id):
return SimpleStatus(
success=False, message=f"User not authorized for extension '{ext_id}'."
)
if settings.is_extension_id(ext_id):
ext_ids = await get_user_active_extensions_ids(user_id)
if ext_id not in ext_ids:
return SimpleStatus(
success=False, message=f"User extension '{ext_id}' not enabled."
)
return SimpleStatus(success=True, message="OK")
async def _check_user_extension_access(user_id: str, current_path: str):
path = current_path.split("/")
ext_id = path[3] if path[1] == "upgrades" else path[1]
status = await check_user_extension_access(user_id, ext_id)
if not status.success:
raise HTTPException(
HTTPStatus.FORBIDDEN,
status.message,
)
async def _get_account_from_token(access_token):
try:
payload = jwt.decode(access_token, settings.auth_secret_key, "HS256")
payload = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
if "sub" in payload and payload.get("sub"):
return await get_account_by_username(str(payload.get("sub")))
if "usr" in payload and payload.get("usr"):
@@ -237,10 +265,10 @@ async def _get_account_from_token(access_token):
return await get_account_by_email(str(payload.get("email")))
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Data missing for access token.")
except ExpiredSignatureError as exc:
except jwt.ExpiredSignatureError as exc:
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Session expired.", {"token-expired": "true"}
) from exc
except JWTError as exc:
except jwt.PyJWTError as exc:
logger.debug(exc)
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid access token.") from exc
+21 -5
View File
@@ -8,11 +8,21 @@ from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, RedirectResponse, Response
from loguru import logger
from lnbits.core.services import InvoiceError, PaymentError
from .helpers import template_renderer
class PaymentError(Exception):
def __init__(self, message: str, status: str = "pending"):
self.message = message
self.status = status
class InvoiceError(Exception):
def __init__(self, message: str, status: str = "pending"):
self.message = message
self.status = status
def register_exception_handlers(app: FastAPI):
register_exception_handler(app)
register_request_validation_exception_handler(app)
@@ -40,8 +50,14 @@ def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
response.set_cookie("is_access_token_expired", "true")
return response
status_code: int = (
exc.status_code
if isinstance(exc, HTTPException)
else HTTPStatus.INTERNAL_SERVER_ERROR
)
return template_renderer().TemplateResponse(
request, "error.html", {"err": f"Error: {exc!s}"}
request, "error.html", {"err": f"Error: {exc!s}"}, status_code
)
return None
@@ -84,7 +100,7 @@ def register_http_exception_handler(app: FastAPI):
def register_payment_error_handler(app: FastAPI):
@app.exception_handler(PaymentError)
async def payment_error_handler(request: Request, exc: PaymentError):
logger.error(f"PaymentError: {exc.message}, {exc.status}")
logger.error(f"{exc.message}, {exc.status}")
return JSONResponse(
status_code=520,
content={"detail": exc.message, "status": exc.status},
@@ -94,7 +110,7 @@ def register_payment_error_handler(app: FastAPI):
def register_invoice_error_handler(app: FastAPI):
@app.exception_handler(InvoiceError)
async def invoice_error_handler(request: Request, exc: InvoiceError):
logger.error(f"InvoiceError: {exc.message}, Status: {exc.status}")
logger.error(f"{exc.message}, Status: {exc.status}")
return JSONResponse(
status_code=520,
content={"detail": exc.message, "status": exc.status},
+83 -14
View File
@@ -32,6 +32,7 @@ class ExplicitRelease(BaseModel):
warning: Optional[str]
info_notification: Optional[str]
critical_notification: Optional[str]
details_link: Optional[str]
pay_link: Optional[str]
def is_version_compatible(self):
@@ -58,6 +59,9 @@ class GitHubRepoRelease(BaseModel):
zipball_url: str
html_url: str
def details_link(self, source_repo: str) -> str:
return f"https://raw.githubusercontent.com/{source_repo}/{self.tag_name}/config.json"
class GitHubRepo(BaseModel):
stargazers_count: str
@@ -85,6 +89,39 @@ class ReleasePaymentInfo(BaseModel):
payment_request: Optional[str] = None
class PayToEnableInfo(BaseModel):
required: Optional[bool] = False
amount: Optional[int] = None
wallet: Optional[str] = None
class UserExtensionInfo(BaseModel):
paid_to_enable: Optional[bool] = False
payment_hash_to_enable: Optional[str] = None
class UserExtension(BaseModel):
extension: str
active: bool
extra: Optional[UserExtensionInfo] = None
@property
def is_paid(self) -> bool:
if not self.extra:
return False
return self.extra.paid_to_enable is True
@classmethod
def from_row(cls, data: dict) -> "UserExtension":
ext = UserExtension(**data)
ext.extra = (
UserExtensionInfo(**json.loads(data["_extra"] or "{}"))
if "_extra" in data
else None
)
return ext
def download_url(url, save_path):
with request.urlopen(url, timeout=60) as dl_file:
with open(save_path, "wb") as out_file:
@@ -177,6 +214,24 @@ async def fetch_release_payment_info(
return None
async def fetch_release_details(details_link: str) -> Optional[dict]:
try:
async with httpx.AsyncClient() as client:
resp = await client.get(details_link)
resp.raise_for_status()
data = resp.json()
if "description_md" in data:
resp = await client.get(data["description_md"])
if not resp.is_error:
data["description_md"] = resp.text
return data
except Exception as e:
logger.warning(e)
return None
def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
if not path:
return ""
@@ -235,6 +290,7 @@ class ExtensionManager:
@property
def extensions(self) -> List[Extension]:
# todo: remove this property somehow, it is too expensive
output: List[Extension] = []
for extension_folder in self._extension_folders:
@@ -281,6 +337,7 @@ class ExtensionRelease(BaseModel):
warning: Optional[str] = None
repo: Optional[str] = None
icon: Optional[str] = None
details_link: Optional[str] = None
pay_link: Optional[str] = None
cost_sats: Optional[int] = None
@@ -313,6 +370,7 @@ class ExtensionRelease(BaseModel):
archive=r.zipball_url,
source_repo=source_repo,
is_github_release=True,
details_link=r.details_link(source_repo),
repo=f"https://github.com/{source_repo}",
html_url=r.html_url,
)
@@ -332,6 +390,7 @@ class ExtensionRelease(BaseModel):
is_version_compatible=e.is_version_compatible(),
warning=e.warning,
html_url=e.html_url,
details_link=e.details_link,
pay_link=e.pay_link,
repo=e.repo,
icon=e.icon,
@@ -353,6 +412,7 @@ class ExtensionRelease(BaseModel):
class InstallableExtension(BaseModel):
id: str
name: str
active: Optional[bool] = False
short_description: Optional[str] = None
icon: Optional[str] = None
dependencies: List[str] = []
@@ -362,6 +422,7 @@ class InstallableExtension(BaseModel):
latest_release: Optional[ExtensionRelease] = None
installed_release: Optional[ExtensionRelease] = None
payments: List[ReleasePaymentInfo] = []
pay_to_enable: Optional[PayToEnableInfo] = None
archive: Optional[str] = None
@property
@@ -412,6 +473,12 @@ class InstallableExtension(BaseModel):
return self.installed_release.version
return ""
@property
def requires_payment(self) -> bool:
if not self.pay_to_enable:
return False
return self.pay_to_enable.required is True
async def download_archive(self):
logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
ext_zip_file = self.zip_path
@@ -479,22 +546,15 @@ class InstallableExtension(BaseModel):
shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir))
logger.success(f"Extension {self.name} ({self.installed_version}) installed.")
def notify_upgrade(self) -> None:
def notify_upgrade(self, upgrade_hash: Optional[str]) -> None:
"""
Update the list of upgraded extensions. The middleware will perform
redirects based on this
"""
if upgrade_hash:
settings.lnbits_upgraded_extensions.add(f"{self.hash}/{self.id}")
clean_upgraded_exts = list(
filter(
lambda old_ext: not old_ext.endswith(f"/{self.id}"),
settings.lnbits_upgraded_extensions,
)
)
settings.lnbits_upgraded_extensions = [
*clean_upgraded_exts,
f"{self.hash}/{self.id}",
]
settings.lnbits_all_extensions_ids.add(self.id)
def clean_extension_files(self):
# remove downloaded archive
@@ -555,8 +615,11 @@ class InstallableExtension(BaseModel):
ext = InstallableExtension(**data)
if "installed_release" in meta:
ext.installed_release = ExtensionRelease(**meta["installed_release"])
if meta.get("pay_to_enable"):
ext.pay_to_enable = PayToEnableInfo(**meta["pay_to_enable"])
if meta.get("payments"):
ext.payments = [ReleasePaymentInfo(**p) for p in meta["payments"]]
return ext
@classmethod
@@ -575,18 +638,18 @@ class InstallableExtension(BaseModel):
repo, latest_release, config = await fetch_github_repo_info(
github_release.organisation, github_release.repository
)
source_repo = f"{github_release.organisation}/{github_release.repository}"
return InstallableExtension(
id=github_release.id,
name=config.name,
short_description=config.short_description,
stars=int(repo.stargazers_count),
icon=icon_to_github_url(
f"{github_release.organisation}/{github_release.repository}",
source_repo,
config.tile,
),
latest_release=ExtensionRelease.from_github_release(
repo.html_url, latest_release
source_repo, latest_release
),
)
except Exception as e:
@@ -702,6 +765,12 @@ class CreateExtension(BaseModel):
payment_hash: Optional[str] = None
class ExtensionDetailsRequest(BaseModel):
ext_id: str
source_repo: str
version: str
def get_valid_extensions(include_deactivated: Optional[bool] = True) -> List[Extension]:
valid_extensions = [
extension for extension in ExtensionManager().extensions if extension.is_valid
+20 -5
View File
@@ -5,11 +5,12 @@ from pathlib import Path
from typing import Any, List, Optional, Type
import jinja2
import jwt
import shortuuid
from jose import jwt
from pydantic import BaseModel
from pydantic.schema import field_schema
from lnbits.db import get_placeholder
from lnbits.jinja2_templating import Jinja2Templates
from lnbits.nodes import get_node_class
from lnbits.requestvars import g
@@ -72,7 +73,7 @@ def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templa
t.env.globals["SITE_TAGLINE"] = settings.lnbits_site_tagline
t.env.globals["SITE_DESCRIPTION"] = settings.lnbits_site_description
t.env.globals["LNBITS_SHOW_HOME_PAGE_ELEMENTS"] = (
settings.LNBITS_SHOW_HOME_PAGE_ELEMENTS
settings.lnbits_show_home_page_elements
)
t.env.globals["LNBITS_CUSTOM_BADGE"] = settings.lnbits_custom_badge
t.env.globals["LNBITS_CUSTOM_BADGE_COLOR"] = settings.lnbits_custom_badge_color
@@ -178,9 +179,12 @@ def insert_query(table_name: str, model: BaseModel) -> str:
:param table_name: Name of the table
:param model: Pydantic model
"""
placeholders = ", ".join(["?"] * len(model.dict().keys()))
placeholders = []
for field in model.dict().keys():
placeholders.append(get_placeholder(model, field))
fields = ", ".join(model.dict().keys())
return f"INSERT INTO {table_name} ({fields}) VALUES ({placeholders})"
values = ", ".join(placeholders)
return f"INSERT INTO {table_name} ({fields}) VALUES ({values})"
def update_query(table_name: str, model: BaseModel, where: str = "WHERE id = ?") -> str:
@@ -190,7 +194,11 @@ def update_query(table_name: str, model: BaseModel, where: str = "WHERE id = ?")
:param model: Pydantic model
:param where: Where string, default to `WHERE id = ?`
"""
query = ", ".join([f"{field} = ?" for field in model.dict().keys()])
fields = []
for field in model.dict().keys():
placeholder = get_placeholder(model, field)
fields.append(f"{field} = {placeholder}")
query = ", ".join(fields)
return f"UPDATE {table_name} SET {query} {where}"
@@ -223,3 +231,10 @@ def decrypt_internal_message(m: Optional[str] = None) -> Optional[str]:
if not m:
return None
return AESCipher(key=settings.auth_secret_key).decrypt(m)
def filter_dict_keys(data: dict, filter_keys: Optional[list[str]]) -> dict:
if not filter_keys:
# return shallow clone of the dict even if there are no filters
return {**data}
return {key: data[key] for key in filter_keys if key in data}
+65 -1
View File
@@ -1 +1,65 @@
from lnurl import LnurlErrorResponse, decode, encode, handle # noqa: F401
from typing import Callable
from fastapi import HTTPException, Request, Response
from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute
from lnurl import LnurlErrorResponse, decode, encode, handle
from loguru import logger
from lnbits.exceptions import InvoiceError, PaymentError
class LnurlErrorResponseHandler(APIRoute):
"""
Custom APIRoute class to handle LNURL errors.
LNURL errors always return with status 200 and
a JSON response with `status="ERROR"` and a `reason` key.
Helps to catch HTTPException and return a valid lnurl error response
Example:
withdraw_lnurl_router = APIRouter(prefix="/api/v1/lnurl")
withdraw_lnurl_router.route_class = LnurlErrorResponseHandler
"""
def get_route_handler(self) -> Callable:
original_route_handler = super().get_route_handler()
async def lnurl_route_handler(request: Request) -> Response:
try:
response = await original_route_handler(request)
return response
except (InvoiceError, PaymentError) as exc:
logger.debug(f"Wallet Error: {exc}")
response = JSONResponse(
status_code=200,
content={"status": "ERROR", "reason": f"{exc.message}"},
)
return response
except HTTPException as exc:
logger.debug(f"HTTPException: {exc}")
response = JSONResponse(
status_code=200,
content={"status": "ERROR", "reason": f"{exc.detail}"},
)
return response
except Exception as exc:
logger.error("Unknown Error:", exc)
response = JSONResponse(
status_code=200,
content={
"status": "ERROR",
"reason": f"UNKNOWN ERROR: {exc!s}",
},
)
return response
return lnurl_route_handler
__all__ = [
"decode",
"encode",
"handle",
"LnurlErrorResponse",
"LnurlErrorResponseHandler",
]
-4
View File
@@ -214,8 +214,6 @@ def add_ip_block_middleware(app: FastAPI):
)
return await call_next(request)
app.middleware("http")(block_allow_ip_middleware)
def add_first_install_middleware(app: FastAPI):
@app.middleware("http")
@@ -228,5 +226,3 @@ def add_first_install_middleware(app: FastAPI):
):
return RedirectResponse("/first_install")
return await call_next(request)
app.middleware("http")(first_install_middleware)
+1 -1
View File
@@ -16,7 +16,7 @@ from lnbits.settings import set_cli_settings, settings
}
)
@click.option("--port", default=settings.port, help="Port to listen on")
@click.option("--host", default=settings.host, help="Host to run LNBits on")
@click.option("--host", default=settings.host, help="Host to run LNbits on")
@click.option(
"--forwarded-allow-ips",
default=settings.forwarded_allow_ips,
+59 -13
View File
@@ -47,6 +47,7 @@ class UsersSettings(LNbitsSettings):
class ExtensionsSettings(LNbitsSettings):
lnbits_admin_extensions: list[str] = Field(default=[])
lnbits_user_default_extensions: list[str] = Field(default=[])
lnbits_extensions_deactivate_all: bool = Field(default=False)
lnbits_extensions_manifests: list[str] = Field(
default=[
@@ -63,12 +64,15 @@ class ExtensionsInstallSettings(LNbitsSettings):
class InstalledExtensionsSettings(LNbitsSettings):
# installed extensions that have been deactivated
lnbits_deactivated_extensions: list[str] = Field(default=[])
lnbits_deactivated_extensions: set[str] = Field(default=[])
# upgraded extensions that require API redirects
lnbits_upgraded_extensions: list[str] = Field(default=[])
lnbits_upgraded_extensions: set[str] = Field(default=[])
# list of redirects that extensions want to perform
lnbits_extensions_redirects: list[Any] = Field(default=[])
# list of all extension ids
lnbits_all_extensions_ids: set[Any] = Field(default=[])
def extension_upgrade_path(self, ext_id: str) -> Optional[str]:
return next(
(e for e in self.lnbits_upgraded_extensions if e.endswith(f"/{ext_id}")),
@@ -83,12 +87,12 @@ class InstalledExtensionsSettings(LNbitsSettings):
class ThemesSettings(LNbitsSettings):
lnbits_site_title: str = Field(default="LNbits")
lnbits_site_tagline: str = Field(default="free and open-source lightning wallet")
lnbits_site_description: str = Field(
lnbits_site_description: Optional[str] = Field(
default="The world's most powerful suite of bitcoin tools."
)
LNBITS_SHOW_HOME_PAGE_ELEMENTS: bool = Field(default=True)
lnbits_show_home_page_elements: bool = Field(default=True)
lnbits_default_wallet_name: str = Field(default="LNbits wallet")
lnbits_custom_badge: str = Field(default=None)
lnbits_custom_badge: Optional[str] = Field(default=None)
lnbits_custom_badge_color: str = Field(default="warning")
lnbits_theme_options: list[str] = Field(
default=[
@@ -101,7 +105,7 @@ class ThemesSettings(LNbitsSettings):
"cyber",
]
)
lnbits_custom_logo: str = Field(default=None)
lnbits_custom_logo: Optional[str] = Field(default=None)
lnbits_ad_space_title: str = Field(default="Supported by")
lnbits_ad_space: str = Field(
default="https://shop.lnbits.com/;/static/images/bitcoin-shop-banner.png;/static/images/bitcoin-shop-banner.png,https://affil.trezor.io/aff_c?offer_id=169&aff_id=33845;/static/images/bitcoin-hardware-wallet.png;/static/images/bitcoin-hardware-wallet.png,https://opensats.org/;/static/images/open-sats.png;/static/images/open-sats.png"
@@ -119,7 +123,7 @@ class OpsSettings(LNbitsSettings):
lnbits_service_fee: float = Field(default=0)
lnbits_service_fee_ignore_internal: bool = Field(default=True)
lnbits_service_fee_max: int = Field(default=0)
lnbits_service_fee_wallet: str = Field(default=None)
lnbits_service_fee_wallet: Optional[str] = Field(default=None)
lnbits_hide_api: bool = Field(default=False)
lnbits_denomination: str = Field(default="sats")
@@ -214,6 +218,12 @@ class LnPayFundingSource(LNbitsSettings):
lnpay_admin_key: Optional[str] = Field(default=None)
class BlinkFundingSource(LNbitsSettings):
blink_api_endpoint: Optional[str] = Field(default="https://api.blink.sv/graphql")
blink_ws_endpoint: Optional[str] = Field(default="wss://ws.blink.sv/graphql")
blink_token: Optional[str] = Field(default=None)
class ZBDFundingSource(LNbitsSettings):
zbd_api_endpoint: Optional[str] = Field(default="https://api.zebedee.io/v0/")
zbd_api_key: Optional[str] = Field(default=None)
@@ -248,6 +258,25 @@ class LnTipsFundingSource(LNbitsSettings):
lntips_invoice_key: Optional[str] = Field(default=None)
class NWCFundingSource(LNbitsSettings):
nwc_pairing_url: Optional[str] = Field(default=None)
class BreezSdkFundingSource(LNbitsSettings):
breez_api_key: Optional[str] = Field(default=None)
breez_greenlight_seed: Optional[str] = Field(default=None)
breez_greenlight_invite_code: Optional[str] = Field(default=None)
breez_greenlight_device_key: Optional[str] = Field(default=None)
breez_greenlight_device_cert: Optional[str] = Field(default=None)
class BoltzFundingSource(LNbitsSettings):
boltz_client_endpoint: Optional[str] = Field(default="127.0.0.1:9002")
boltz_client_macaroon: Optional[str] = Field(default=None)
boltz_client_wallet: Optional[str] = Field(default="lnbits")
boltz_client_cert: Optional[str] = Field(default=None)
class LightningSettings(LNbitsSettings):
lightning_invoice_expiry: int = Field(default=3600)
@@ -262,19 +291,23 @@ class FundingSourcesSettings(
LndRestFundingSource,
LndGrpcFundingSource,
LnPayFundingSource,
BlinkFundingSource,
AlbyFundingSource,
BoltzFundingSource,
ZBDFundingSource,
PhoenixdFundingSource,
OpenNodeFundingSource,
SparkFundingSource,
LnTipsFundingSource,
NWCFundingSource,
BreezSdkFundingSource,
):
lnbits_backend_wallet_class: str = Field(default="VoidWallet")
class WebPushSettings(LNbitsSettings):
lnbits_webpush_pubkey: str = Field(default=None)
lnbits_webpush_privkey: str = Field(default=None)
lnbits_webpush_pubkey: Optional[str] = Field(default=None)
lnbits_webpush_privkey: Optional[str] = Field(default=None)
class NodeUISettings(LNbitsSettings):
@@ -411,19 +444,23 @@ class SuperUserSettings(LNbitsSettings):
lnbits_allowed_funding_sources: list[str] = Field(
default=[
"AlbyWallet",
"FakeWallet",
"BoltzWallet",
"BlinkWallet",
"BreezSdkWallet",
"CoreLightningRestWallet",
"CoreLightningWallet",
"EclairWallet",
"LNbitsWallet",
"LndRestWallet",
"FakeWallet",
"LNPayWallet",
"LNbitsWallet",
"LnTipsWallet",
"LndRestWallet",
"LndWallet",
"OpenNodeWallet",
"PhoenixdWallet",
"VoidWallet",
"ZBDWallet",
"NWCWallet",
]
)
@@ -481,7 +518,7 @@ class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettin
case_sensitive = False
json_loads = list_parse_fallback
def is_user_allowed(self, user_id: str):
def is_user_allowed(self, user_id: str) -> bool:
return (
len(self.lnbits_allowed_users) == 0
or user_id in self.lnbits_allowed_users
@@ -489,6 +526,15 @@ class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettin
or user_id == self.super_user
)
def is_admin_user(self, user_id: str) -> bool:
return user_id in self.lnbits_admin_users or user_id == self.super_user
def is_admin_extension(self, ext_id: str) -> bool:
return ext_id in self.lnbits_admin_extensions
def is_extension_id(self, ext_id: str) -> bool:
return ext_id in self.lnbits_all_extensions_ids
class SuperSettings(EditableSettings):
super_user: str
+1 -1
View File
File diff suppressed because one or more lines are too long
+11 -11
View File
File diff suppressed because one or more lines are too long
+9
View File
@@ -554,3 +554,12 @@ video {
.whitespace-pre-line {
white-space: pre-line;
}
.q-carousel__slide {
background-size: contain;
background-repeat: no-repeat;
}
.q-dialog__inner--minimized {
padding: 12px;
}
+7 -2
View File
@@ -9,6 +9,8 @@ window.localisation.br = {
transactions: 'Transações',
dashboard: 'Painel de Controle',
node: 'Nó',
export_users: 'Exportar Usuários',
no_users: 'Nenhum usuário encontrado',
total_capacity: 'Capacidade Total',
avg_channel_size: 'Tamanho médio do canal',
biggest_channel_size: 'Maior Tamanho de Canal',
@@ -34,6 +36,8 @@ window.localisation.br = {
'Apagar todas as configurações e redefinir para os padrões.',
download_backup: 'Fazer backup do banco de dados',
name_your_wallet: 'Nomeie sua carteira %{name}',
wallet_topup_ok:
'Sucesso ao criar fundos virtuais (%{amount} sats). Pagamentos dependem dos fundos reais na fonte de financiamento.',
paste_invoice_label: 'Cole uma fatura, pedido de pagamento ou código lnurl *',
lnbits_description:
'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da Lightning Network e até mesmo o LNbits em si! Você pode executar o LNbits para si mesmo ou oferecer facilmente uma solução de custódia para outros. Cada carteira tem suas próprias chaves de API e não há limite para o número de carteiras que você pode criar. Ser capaz de particionar fundos torna o LNbits uma ferramenta útil para gerenciamento de dinheiro e como uma ferramenta de desenvolvimento. As extensões adicionam funcionalidades extras ao LNbits para que você possa experimentar uma série de tecnologias de ponta na rede lightning. Nós tornamos o desenvolvimento de extensões o mais fácil possível e, como um projeto gratuito e de código aberto, incentivamos as pessoas a desenvolver e enviar as suas próprias.',
@@ -165,7 +169,7 @@ window.localisation.br = {
'Se ativado, mudará sua fonte de fundos para VoidWallet automaticamente se o LNbits enviar um sinal de desativação. Você precisará ativar manualmente após uma atualização.',
killswitch_interval: 'Intervalo do Killswitch',
killswitch_interval_desc:
'Com que frequência a tarefa de fundo deve verificar o sinal de desativação do LNBits proveniente da fonte de status (em minutos).',
'Com que frequência a tarefa de fundo deve verificar o sinal de desativação do LNbits proveniente da fonte de status (em minutos).',
enable_watchdog: 'Ativar Watchdog',
enable_watchdog_desc:
'Se ativado, ele mudará automaticamente sua fonte de financiamento para VoidWallet se o seu saldo for inferior ao saldo do LNbits. Você precisará ativar manualmente após uma atualização.',
@@ -249,5 +253,6 @@ window.localisation.br = {
pay_from_wallet: 'Pagar com a Carteira',
show_qr: 'Exibir QR',
retry_install: 'Repetir Instalação',
new_payment: 'Efetuar Novo Pagamento'
new_payment: 'Efetuar Novo Pagamento',
hide_empty_wallets: 'Ocultar carteiras vazias'
}
+7 -2
View File
@@ -9,6 +9,8 @@ window.localisation.cn = {
transactions: '交易记录',
dashboard: '控制面板',
node: '节点',
export_users: '导出用户',
no_users: '未找到用户',
total_capacity: '总容量',
avg_channel_size: '平均频道大小',
biggest_channel_size: '最大通道大小',
@@ -33,6 +35,8 @@ window.localisation.cn = {
reset_defaults_tooltip: '删除所有设置并重置为默认设置',
download_backup: '下载数据库备份',
name_your_wallet: '给你的 %{name}钱包起个名字',
wallet_topup_ok:
'成功创建虚拟资金(%{amount} sats)。付款取决于资金来源的实际资金。',
paste_invoice_label: '粘贴发票,付款请求或lnurl*',
lnbits_description:
'LNbits 设置简单、轻量级,可以在任何闪电网络的资金来源上运行,甚至可以在LNbits自身上运行!您可以为自己运行LNbits,或者轻松为他人提供托管解决方案。每个钱包都有自己的 API 密钥,你可以创建的钱包数量没有限制。能够把资金分开管理使 LNbits 成为一款有用的资金管理和开发工具。扩展程序增加了 LNbits 的额外功能,所以你可以在闪电网络上尝试各种尖端技术。我们已经尽可能简化了开发扩展程序的过程,作为一个免费和开源的项目,我们鼓励人们开发并提交自己的扩展程序。',
@@ -155,7 +159,7 @@ window.localisation.cn = {
'如果启用,当LNbits发送终止信号时,系统将自动将您的资金来源更改为VoidWallet。更新后,您将需要手动启用。',
killswitch_interval: 'Killswitch 间隔',
killswitch_interval_desc:
'后台任务应该多久检查一次来自状态源的LNBits断路信号(以分钟为单位)。',
'后台任务应该多久检查一次来自状态源的LNbits断路信号(以分钟为单位)。',
enable_watchdog: '启用看门狗',
enable_watchdog_desc:
'如果启用,当您的余额低于LNbits余额时,系统将自动将您的资金来源更改为VoidWallet。更新后您将需要手动启用。',
@@ -237,5 +241,6 @@ window.localisation.cn = {
pay_from_wallet: '从钱包支付',
show_qr: '显示QR码',
retry_install: '重试安装',
new_payment: '创建新支付'
new_payment: '创建新支付',
hide_empty_wallets: '隐藏空钱包'
}
+7 -2
View File
@@ -9,6 +9,8 @@ window.localisation.cs = {
transactions: 'Transakce',
dashboard: 'Přehled',
node: 'Uzel',
export_users: 'Exportovat uživatele',
no_users: 'Nebyli nalezeni žádní uživatelé',
total_capacity: 'Celková kapacita',
avg_channel_size: 'Průmerná velikost kanálu',
biggest_channel_size: 'Největší velikost kanálu',
@@ -33,6 +35,8 @@ window.localisation.cs = {
reset_defaults_tooltip: 'Smazat všechna nastavení a obnovit výchozí.',
download_backup: 'Stáhnout zálohu databáze',
name_your_wallet: 'Pojmenujte svou %{name} peněženku',
wallet_topup_ok:
'Úspěšně vytvořeny virtuální prostředky (%{amount} sats). Platby závisí na skutečných prostředcích na zdrojovém účtu.',
paste_invoice_label: 'Vložte fakturu, platební požadavek nebo lnurl kód *',
lnbits_description:
'Snadno nastavitelný a lehkotonážní, LNbits může běžet na jakémkoliv zdroji financování Lightning Network a dokonce LNbits samotné! LNbits můžete provozovat pro sebe, nebo snadno nabízet správu peněženek pro ostatní. Každá peněženka má své vlastní API klíče a není omezen počet peněženek, které můžete vytvořit. Možnost rozdělení prostředků dělá z LNbits užitečný nástroj pro správu peněz a jako vývojový nástroj. Rozšíření přidávají extra funkčnost k LNbits, takže můžete experimentovat s řadou špičkových technologií na lightning network. Vývoj rozšíření jsme učinili co nejjednodušší a jako svobodný a open-source projekt podporujeme lidi ve vývoji a zasílání vlastních rozšíření.',
@@ -162,7 +166,7 @@ window.localisation.cs = {
'Pokud je povoleno, automaticky změní zdroj financování na VoidWallet pokud LNbits odešle signál killswitch. Po aktualizaci budete muset povolit ručně.',
killswitch_interval: 'Interval Killswitch',
killswitch_interval_desc:
'Jak často by měl úkol na pozadí kontrolovat signál killswitch od LNBits ze zdroje stavu (v minutách).',
'Jak často by měl úkol na pozadí kontrolovat signál killswitch od LNbits ze zdroje stavu (v minutách).',
enable_watchdog: 'Povolit Watchdog',
enable_watchdog_desc:
'Pokud je povoleno, automaticky změní zdroj financování na VoidWallet pokud je váš zůstatek nižší než zůstatek LNbits. Po aktualizaci budete muset povolit ručně.',
@@ -246,5 +250,6 @@ window.localisation.cs = {
pay_from_wallet: 'Platit z peněženky',
show_qr: 'Zobrazit QR',
retry_install: 'Zkusit znovu nainstalovat',
new_payment: 'Vytvořit novou platbu'
new_payment: 'Vytvořit novou platbu',
hide_empty_wallets: 'Skrýt prázdné peněženky'
}
+7 -2
View File
@@ -9,6 +9,8 @@ window.localisation.de = {
transactions: 'Transaktionen',
dashboard: 'Armaturenbrett',
node: 'Knoten',
export_users: 'Benutzer exportieren',
no_users: 'Keine Benutzer gefunden',
total_capacity: 'Gesamtkapazität',
avg_channel_size: 'Durchschn. Kanalgröße',
biggest_channel_size: 'Größte Kanalgröße',
@@ -34,6 +36,8 @@ window.localisation.de = {
'Alle Einstellungen auf die Standardeinstellungen zurücksetzen.',
download_backup: 'Datenbank-Backup herunterladen',
name_your_wallet: 'Vergib deiner %{name} Wallet einen Namen',
wallet_topup_ok:
'Erfolg beim Erstellen von virtuellen Mitteln (%{amount} Satoshis). Zahlungen hängen von den tatsächlichen Mitteln der Finanzierungsquelle ab.',
paste_invoice_label:
'Füge eine Rechnung, Zahlungsanforderung oder LNURL ein *',
lnbits_description:
@@ -167,7 +171,7 @@ window.localisation.de = {
'Falls aktiviert, wird Ihre Zahlungsquelle automatisch auf VoidWallet umgestellt, wenn LNbits ein Killswitch-Signal sendet. Nach einem Update müssen Sie dies manuell wieder aktivieren.',
killswitch_interval: 'Intervall für den Notausschalter',
killswitch_interval_desc:
'Wie oft die Hintergrundaufgabe nach dem LNBits-Killswitch-Signal aus der Statusquelle suchen soll (in Minuten).',
'Wie oft die Hintergrundaufgabe nach dem LNbits-Killswitch-Signal aus der Statusquelle suchen soll (in Minuten).',
enable_watchdog: 'Aktiviere Watchdog',
enable_watchdog_desc:
'Wenn aktiviert, wird Ihre Zahlungsquelle automatisch auf VoidWallet umgestellt, wenn Ihr Guthaben niedriger als das LNbits-Guthaben ist. Nach einem Update müssen Sie dies manuell aktivieren.',
@@ -254,5 +258,6 @@ window.localisation.de = {
pay_from_wallet: 'Zahlen aus dem Geldbeutel',
show_qr: 'QR anzeigen',
retry_install: 'Installieren erneut versuchen',
new_payment: 'Neue Zahlung vornehmen'
new_payment: 'Neue Zahlung vornehmen',
hide_empty_wallets: 'Leere Geldbörsen verbergen'
}
+16 -2
View File
@@ -118,6 +118,7 @@ window.localisation.en = {
uninstall: 'Uninstall',
drop_db: 'Remove Data',
enable: 'Enable',
pay_to_enable: 'Pay To Enable',
enable_extension_details: 'Enable extension for current user',
disable: 'Disable',
installed: 'Installed',
@@ -144,6 +145,7 @@ window.localisation.en = {
payment_hash: 'Payment Hash',
fee: 'Fee',
amount: 'Amount',
amount_sats: 'Amount (sats)',
tag: 'Tag',
unit: 'Unit',
description: 'Description',
@@ -163,7 +165,7 @@ window.localisation.en = {
'If enabled it will change your funding source to VoidWallet automatically if LNbits sends out a killswitch signal. You will need to enable manually after an update.',
killswitch_interval: 'Killswitch Interval',
killswitch_interval_desc:
'How often the background task should check for the LNBits killswitch signal from the status source (in minutes).',
'How often the background task should check for the LNbits killswitch signal from the status source (in minutes).',
enable_watchdog: 'Enable Watchdog',
enable_watchdog_desc:
'If enabled it will change your funding source to VoidWallet automatically if your balance is lower than the LNbits balance. You will need to enable manually after an update.',
@@ -239,14 +241,26 @@ window.localisation.en = {
back: 'Back',
logout: 'Logout',
look_and_feel: 'Look and Feel',
toggle_gradient: 'Toggle Gradient',
gradient_background: 'Gradient Background',
language: 'Language',
color_scheme: 'Color Scheme',
extension_cost: 'This release requires a payment of minimum %{cost} sats.',
extension_paid_sats: 'You have already paid %{paid_sats} sats.',
release_details_error: 'Cannot get the release details.',
pay_from_wallet: 'Pay from Wallet',
wallet_required: 'Wallet *',
show_qr: 'Show QR',
retry_install: 'Retry Install',
new_payment: 'Make New Payment',
hide_empty_wallets: 'Hide empty wallets'
update_payment: 'Update Payment',
already_paid_question: 'Have you already paid?',
sell: 'Sell',
sell_require: 'Ask payment to enable extension',
sell_info:
'The %{name} extension requires a payment of minimum %{amount} sats to enable.',
hide_empty_wallets: 'Hide empty wallets',
recheck: 'Recheck',
contributors: 'Contributors',
license: 'License'
}
+7 -2
View File
@@ -9,6 +9,8 @@ window.localisation.es = {
transactions: 'Transacciones',
dashboard: 'Tablero de instrumentos',
node: 'Nodo',
export_users: 'Exportar Usuarios',
no_users: 'No se encontraron usuarios',
total_capacity: 'Capacidad Total',
avg_channel_size: 'Tamaño Medio del Canal',
biggest_channel_size: 'Tamaño del Canal Más Grande',
@@ -34,6 +36,8 @@ window.localisation.es = {
'Borrar todas las configuraciones y restablecer a los valores predeterminados.',
download_backup: 'Descargar copia de seguridad de la base de datos',
name_your_wallet: 'Nombre de su billetera %{name}',
wallet_topup_ok:
'Éxito creando fondos virtuales (%{amount} sats). Los pagos dependen de los fondos reales en la fuente de financiación.',
paste_invoice_label: 'Pegue la factura aquí',
lnbits_description:
'Fácil de instalar y liviano, LNbits puede ejecutarse en cualquier fuente de financiación de la red Lightning y hasta LNbits mismo! Puede ejecutar LNbits para usted mismo o ofrecer una solución competente a otros. Cada billetera tiene su propia clave API y no hay límite para la cantidad de billeteras que puede crear. La capacidad de particionar fondos hace de LNbits una herramienta útil para la administración de fondos y como herramienta de desarrollo. Las extensiones agregan funcionalidad adicional a LNbits, por lo que puede experimentar con una variedad de tecnologías de vanguardia en la red Lightning. Lo hemos hecho lo más simple posible para desarrollar extensiones y, como un proyecto gratuito y de código abierto, animamos a las personas a que se desarrollen a sí mismas y envíen sus propios contribuciones.',
@@ -165,7 +169,7 @@ window.localisation.es = {
'Si está activado, cambiará automáticamente su fuente de financiamiento a VoidWallet si LNbits envía una señal de parada de emergencia. Necesitará activarlo manualmente después de una actualización.',
killswitch_interval: 'Intervalo de Killswitch',
killswitch_interval_desc:
'Con qué frecuencia la tarea en segundo plano debe verificar la señal de interruptor de emergencia de LNBits desde la fuente de estado (en minutos).',
'Con qué frecuencia la tarea en segundo plano debe verificar la señal de interruptor de emergencia de LNbits desde la fuente de estado (en minutos).',
enable_watchdog: 'Activar Watchdog',
enable_watchdog_desc:
'Si está activado, cambiará automáticamente su fuente de financiamiento a VoidWallet si su saldo es inferior al saldo de LNbits. Tendrá que activarlo manualmente después de una actualización.',
@@ -251,5 +255,6 @@ window.localisation.es = {
pay_from_wallet: 'Pagar desde la billetera',
show_qr: 'Mostrar QR',
retry_install: 'Reintentar Instalación',
new_payment: 'Realizar nuevo pago'
new_payment: 'Realizar nuevo pago',
hide_empty_wallets: 'Ocultar billeteras vacías'
}
+7 -2
View File
@@ -9,6 +9,8 @@ window.localisation.fi = {
transactions: 'Tapahtumat',
dashboard: 'Ohjauspaneeli',
node: 'Solmu',
export_users: 'Vie käyttäjät',
no_users: 'Käyttäjiä ei löytynyt',
total_capacity: 'Kokonaiskapasiteetti',
avg_channel_size: 'Keskimääräisen kanavan kapasiteetti',
biggest_channel_size: 'Suurimman kanavan kapasiteetti',
@@ -34,6 +36,8 @@ window.localisation.fi = {
'Poista kaikki asetusten muutokset ja palauta järjestelmän oletusasetukset.',
download_backup: 'Lataa tietokannan varmuuskopio',
name_your_wallet: 'Anna %{name}-lompakollesi nimi',
wallet_topup_ok:
'Virtuaalisten varojen luominen onnistui (%{amount} sats). Maksut riippuvat rahoituslähteen todellisista varoista.',
paste_invoice_label:
'Liitä lasku, maksupyyntö, lnurl-koodi tai Lightning Address *',
lnbits_description:
@@ -62,7 +66,7 @@ window.localisation.fi = {
service_fee_max:
'Palvelumaksu: %{amount} % tapahtumasta (enintään %{max} sat)',
service_fee_tooltip:
'LNBits palvelimen ylläpitäjä veloittaa lähtevästä maksusta palvelumaksun.',
'LNbits palvelimen ylläpitäjä veloittaa lähtevästä maksusta palvelumaksun.',
toggle_darkmode: 'Tumma näkymä',
payment_reactions: 'Maksureaktiot',
view_swagger_docs: 'Näytä LNbits Swagger API-dokumentit',
@@ -249,5 +253,6 @@ window.localisation.fi = {
pay_from_wallet: 'Maksa lompakosta',
show_qr: 'Näytä QR',
retry_install: 'Yritä asennusta uudelleen',
new_payment: 'Tee uusi maksu'
new_payment: 'Tee uusi maksu',
hide_empty_wallets: 'Piilota tyhjät lompakot'
}
+7 -2
View File
@@ -9,6 +9,8 @@ window.localisation.fr = {
transactions: 'Transactions',
dashboard: 'Tableau de bord',
node: 'Noeud',
export_users: 'Exporter les utilisateurs',
no_users: 'Aucun utilisateur trouvé',
total_capacity: 'Capacité totale',
avg_channel_size: 'Taille moyenne du canal',
biggest_channel_size: 'Taille de canal maximale',
@@ -36,6 +38,8 @@ window.localisation.fr = {
'Supprimer tous les paramètres et les réinitialiser aux valeurs par défaut.',
download_backup: 'Télécharger la sauvegarde de la base de données',
name_your_wallet: 'Nommez votre portefeuille %{name}',
wallet_topup_ok:
'Succès de la création de fonds virtuels (%{amount} sats). Les paiements dépendent des fonds réels sur la source de financement.',
paste_invoice_label:
'Coller une facture, une demande de paiement ou un code lnurl *',
lnbits_description:
@@ -169,7 +173,7 @@ window.localisation.fr = {
'Si activé, il changera automatiquement votre source de financement en VoidWallet si LNbits envoie un signal de coupure. Vous devrez activer manuellement après une mise à jour.',
killswitch_interval: 'Intervalle du Killswitch',
killswitch_interval_desc:
"À quelle fréquence la tâche de fond doit-elle vérifier le signal d'arrêt d'urgence LNBits provenant de la source de statut (en minutes).",
"À quelle fréquence la tâche de fond doit-elle vérifier le signal d'arrêt d'urgence LNbits provenant de la source de statut (en minutes).",
enable_watchdog: 'Activer le Watchdog',
enable_watchdog_desc:
'Si elle est activée, elle changera automatiquement votre source de financement en VoidWallet si votre solde est inférieur au solde LNbits. Vous devrez activer manuellement après une mise à jour.',
@@ -256,5 +260,6 @@ window.localisation.fr = {
pay_from_wallet: 'Payer depuis le portefeuille',
show_qr: 'Afficher le QR',
retry_install: "Réessayer l'installation",
new_payment: 'Effectuer un nouveau paiement'
new_payment: 'Effectuer un nouveau paiement',
hide_empty_wallets: 'Masquer les portefeuilles vides'
}
+7 -2
View File
@@ -9,6 +9,8 @@ window.localisation.it = {
transactions: 'Transazioni',
dashboard: 'Pannello di controllo',
node: 'Interruttore',
export_users: 'Esporta utenti',
no_users: 'Nessun utente trovato',
total_capacity: 'Capacità Totale',
avg_channel_size: 'Dimensione media del canale',
biggest_channel_size: 'Dimensione del canale più grande',
@@ -34,6 +36,8 @@ window.localisation.it = {
'Cancella tutte le impostazioni e ripristina i valori predefiniti',
download_backup: 'Scarica il backup del database',
name_your_wallet: 'Dai un nome al tuo portafoglio %{name}',
wallet_topup_ok:
'Operazione riuscita nella creazione di fondi virtuali (%{amount} sats). I pagamenti dipendono dai fondi effettivi sulla fonte di finanziamento.',
paste_invoice_label:
'Incolla una fattura, una richiesta di pagamento o un codice lnurl *',
lnbits_description:
@@ -165,7 +169,7 @@ window.localisation.it = {
'Se attivato, cambierà automaticamente la tua fonte di finanziamento in VoidWallet se LNbits invia un segnale di killswitch. Dovrai attivare manualmente dopo un aggiornamento.',
killswitch_interval: 'Intervallo Killswitch',
killswitch_interval_desc:
'Quanto spesso il compito in background dovrebbe controllare il segnale di killswitch LNBits dalla fonte di stato (in minuti).',
'Quanto spesso il compito in background dovrebbe controllare il segnale di killswitch LNbits dalla fonte di stato (in minuti).',
enable_watchdog: 'Attiva Watchdog',
enable_watchdog_desc:
'Se abilitato, cambierà automaticamente la tua fonte di finanziamento in VoidWallet se il tuo saldo è inferiore al saldo LNbits. Dovrai abilitarlo manualmente dopo un aggiornamento.',
@@ -253,5 +257,6 @@ window.localisation.it = {
pay_from_wallet: 'Paga dal Portafoglio',
show_qr: 'Mostra QR',
retry_install: 'Riprova Installazione',
new_payment: 'Effettua Nuovo Pagamento'
new_payment: 'Effettua Nuovo Pagamento',
hide_empty_wallets: 'Nascondi portafogli vuoti'
}
+7 -2
View File
@@ -9,6 +9,8 @@ window.localisation.jp = {
transactions: 'トランザクション',
dashboard: 'ダッシュボード',
node: 'ノード',
export_users: 'ユーザーのエクスポート',
no_users: 'ユーザーが見つかりません',
total_capacity: '合計容量',
avg_channel_size: '平均チャンネルサイズ',
biggest_channel_size: '最大チャネルサイズ',
@@ -33,6 +35,8 @@ window.localisation.jp = {
reset_defaults_tooltip: 'すべての設定を削除してデフォルトに戻します。',
download_backup: 'データベースのバックアップをダウンロードする',
name_your_wallet: 'あなたのウォレットの名前 %{name}',
wallet_topup_ok:
'仮想資金の作成に成功しました(%{amount} sats)。支払いは資金ソースの実際の資金に依存します。',
paste_invoice_label: '請求書を貼り付けてください',
lnbits_description:
'簡単にインストールでき、軽量なLNbitsは、あらゆるライトニングネットワークの資金源と、LNbits自身でさえも実行できます!LNbitsを個人で実行することも、他人に対してカストディアンソリューションをで実行できます! LNbitsを自分で実行することも、他の人に優れたソリューションを提供することもできます。各ウォレットには独自のAPIキーがあり、作成できるウォレットの数に制限はありません。資金を分割する機能は、LNbitsを資金管理ツールとして使用したり、開発ツールとして使用したりするための便利なツールです。拡張機能は、LNbitsに追加の機能を追加します。そのため、LNbitsは最先端の技術をネットワークLightningで試すことができます。拡張機能を開発するのは簡単で、無料でオープンソースのプロジェクトであるため、人々が自分で開発し、自分の貢献を送信することを奨励しています。',
@@ -162,7 +166,7 @@ window.localisation.jp = {
'有効にすると、LNbitsからキルスイッチ信号が送信された場合に自動的に資金源をVoidWalletに切り替えます。更新後には手動で有効にする必要があります。',
killswitch_interval: 'キルスイッチ間隔',
killswitch_interval_desc:
'バックグラウンドタスクがステータスソースからLNBitsキルスイッチ信号を確認する頻度(分単位)。',
'バックグラウンドタスクがステータスソースからLNbitsキルスイッチ信号を確認する頻度(分単位)。',
enable_watchdog: 'ウォッチドッグを有効にする',
enable_watchdog_desc:
'有効にすると、残高がLNbitsの残高より少ない場合に、資金源を自動的にVoidWalletに変更します。アップデート後は手動で有効にする必要があります。',
@@ -248,5 +252,6 @@ window.localisation.jp = {
pay_from_wallet: 'ウォレットから支払う',
show_qr: 'QRを表示',
retry_install: '再試行インストール',
new_payment: '新しい支払いを作成する'
new_payment: '新しい支払いを作成する',
hide_empty_wallets: '空のウォレットを非表示にする'
}
+8 -3
View File
@@ -9,6 +9,8 @@ window.localisation.kr = {
transactions: '거래 내역',
dashboard: '현황판',
node: '노드',
export_users: '사용자 내보내기',
no_users: '사용자가 없습니다',
total_capacity: '총 용량',
avg_channel_size: '평균 채널 용량',
biggest_channel_size: '가장 큰 채널 용량',
@@ -34,9 +36,11 @@ window.localisation.kr = {
'설정했던 내용들을 모두 지우고, 기본 설정으로 돌아갑니다.',
download_backup: '데이터베이스 백업 다운로드',
name_your_wallet: '사용할 %{name}지갑의 이름을 정하세요',
wallet_topup_ok:
'성공적으로 가상 자금을 생성했습니다 (%{amount} sats). 지급은 자금 원천의 실제 자금에 따라 달라집니다.',
paste_invoice_label: '인보이스, 결제 요청, 혹은 lnurl 코드를 붙여넣으세요 *',
lnbits_description:
'설정이 쉽고 가벼운 LNBits는 어떤 라이트닝 네트워크의 예산 자원 위에서든 돌아갈 수 있습니다, 그리고 다른 LNBits 지갑들입니다. 스스로 사용하기 위해, 또는 다른 사람들에게 수탁형 솔루션을 제공하기 위해 LNBits를 운영할 수 있습니다. 각 지갑들은 자신만의 API key를 가지며, 생성 가능한 지갑의 수에는 제한이 없습니다. 자금을 분할할 수 있는 기능으로 인해, LNBits는 자금 운영 도구로써뿐만 아니라 개발 도구로써도 유용합니다. 확장 기능들은 LNBits에 여러분들이 라이트닝 네트워크의 다양한 최신 기술들을 수행해볼 수 있게 하는 추가 기능을 제공합니다. LNBits 개발진들은 확장 기능들의 개발 또한 가능한 쉽게 만들었으며, 무료 오픈 소스 프로젝트답게 사람들이 자신만의 확장 기능들을 개발하고 제출하기를 응원합니다.',
'설정이 쉽고 가벼운 LNbits는 어떤 라이트닝 네트워크의 예산 자원 위에서든 돌아갈 수 있습니다, 그리고 다른 LNbits 지갑들입니다. 스스로 사용하기 위해, 또는 다른 사람들에게 수탁형 솔루션을 제공하기 위해 LNbits를 운영할 수 있습니다. 각 지갑들은 자신만의 API key를 가지며, 생성 가능한 지갑의 수에는 제한이 없습니다. 자금을 분할할 수 있는 기능으로 인해, LNbits는 자금 운영 도구로써뿐만 아니라 개발 도구로써도 유용합니다. 확장 기능들은 LNbits에 여러분들이 라이트닝 네트워크의 다양한 최신 기술들을 수행해볼 수 있게 하는 추가 기능을 제공합니다. LNbits 개발진들은 확장 기능들의 개발 또한 가능한 쉽게 만들었으며, 무료 오픈 소스 프로젝트답게 사람들이 자신만의 확장 기능들을 개발하고 제출하기를 응원합니다.',
export_to_phone: 'QR 코드를 이용해 모바일 기기로 내보내기',
export_to_phone_desc:
'이 QR 코드는 선택된 지갑의 최대 접근 권한을 가진 전체 URL을 담고 있습니다. 스캔 후, 모바일 기기에서 지갑을 열 수 있습니다.',
@@ -58,7 +62,7 @@ window.localisation.kr = {
service_fee: '서비스 수수료: 거래액의 %{amount} %',
service_fee_max: '서비스 수수료: 거래액의 %{amount} % (최대 %{max} sats)',
service_fee_tooltip:
'지불 결제 시마다 LNBits 서버 관리자에게 납부되는 서비스 수수료',
'지불 결제 시마다 LNbits 서버 관리자에게 납부되는 서비스 수수료',
toggle_darkmode: '다크 모드 전환',
payment_reactions: '결제 반응',
view_swagger_docs: 'LNbits Swagger API 문서를 봅니다',
@@ -245,5 +249,6 @@ window.localisation.kr = {
pay_from_wallet: '지갑에서 결제하다',
show_qr: 'QR 보기',
retry_install: '다시 설치하세요',
new_payment: '새로운 결제하기'
new_payment: '새로운 결제하기',
hide_empty_wallets: '빈 지갑 숨기기'
}
+7 -2
View File
@@ -9,6 +9,8 @@ window.localisation.nl = {
transactions: 'Transacties',
dashboard: 'Dashboard',
node: 'Knooppunt',
export_users: 'Gebruikers exporteren',
no_users: 'Geen gebruikers gevonden',
total_capacity: 'Totale capaciteit',
avg_channel_size: 'Gem. Kanaalgrootte',
biggest_channel_size: 'Grootste Kanaalgrootte',
@@ -35,6 +37,8 @@ window.localisation.nl = {
'Wis alle instellingen en herstel de standaardinstellingen.',
download_backup: 'Databaseback-up downloaden',
name_your_wallet: 'Geef je %{name} portemonnee een naam',
wallet_topup_ok:
'Succes met het aanmaken van virtuele fondsen (%{amount} sats). Betalingen zijn afhankelijk van de werkelijke fondsen op de financieringsbron.',
paste_invoice_label: 'Plak een factuur, betalingsverzoek of lnurl-code*',
lnbits_description:
'Gemakkelijk in te stellen en lichtgewicht, LNbits kan op elke lightning-netwerkfinancieringsbron draaien en zelfs LNbits zelf! U kunt LNbits voor uzelf laten draaien of gemakkelijk een bewaardersoplossing voor anderen bieden. Elke portemonnee heeft zijn eigen API-sleutels en er is geen limiet aan het aantal portemonnees dat u kunt maken. Het kunnen partitioneren van fondsen maakt LNbits een nuttige tool voor geldbeheer en als ontwikkelingstool. Extensies voegen extra functionaliteit toe aan LNbits, zodat u kunt experimenteren met een reeks toonaangevende technologieën op het bliksemschichtnetwerk. We hebben het ontwikkelen van extensies zo eenvoudig mogelijk gemaakt en als een gratis en opensource-project moedigen we mensen aan om hun eigen ontwikkelingen in te dienen.',
@@ -165,7 +169,7 @@ window.localisation.nl = {
'Indien ingeschakeld, zal het uw financieringsbron automatisch wijzigen naar VoidWallet als LNbits een killswitch-signaal verzendt. U zult het na een update handmatig moeten inschakelen.',
killswitch_interval: 'Uitschakelschakelaar-interval',
killswitch_interval_desc:
'Hoe vaak de achtergrondtaak moet controleren op het LNBits killswitch signaal van de statusbron (in minuten).',
'Hoe vaak de achtergrondtaak moet controleren op het LNbits killswitch signaal van de statusbron (in minuten).',
enable_watchdog: 'Inschakelen Watchdog',
enable_watchdog_desc:
'Indien ingeschakeld, wordt uw betaalbron automatisch gewijzigd naar VoidWallet als uw saldo lager is dan het saldo van LNbits. U zult dit na een update handmatig moeten inschakelen.',
@@ -252,5 +256,6 @@ window.localisation.nl = {
pay_from_wallet: 'Betalen vanuit Portemonnee',
show_qr: 'Toon QR',
retry_install: 'Opnieuw installeren',
new_payment: 'Nieuwe betaling maken'
new_payment: 'Nieuwe betaling maken',
hide_empty_wallets: 'Verberg lege portemonnees'
}
+7 -2
View File
@@ -9,6 +9,8 @@ window.localisation.pi = {
transactions: 'Pirate Transactions and loot',
dashboard: 'Arrr-board',
node: 'Node',
export_users: 'Export Mateys',
no_users: 'No swabbies found',
total_capacity: 'Total Capacity',
avg_channel_size: 'Avg. Channel Size',
biggest_channel_size: 'Largest Bilge Size',
@@ -34,6 +36,8 @@ window.localisation.pi = {
'Scuttle all settings and reset to Davy Jones Locker. Aye, start anew!',
download_backup: 'Download database booty',
name_your_wallet: 'Name yer %{name} treasure chest',
wallet_topup_ok:
"Success creatin' virtual funds (%{amount} sats). Payments depend on actual funds on funding source.",
paste_invoice_label: 'Paste a booty, payment request or lnurl code, matey!',
lnbits_description:
'Arr, easy to set up and lightweight, LNbits can run on any Lightning Network funding source and even LNbits itself! Ye can run LNbits for yourself, or easily offer a custodian solution for others. Each chest has its own API keys and there be no limit to the number of chests ye can make. Being able to partition booty makes LNbits a useful tool for money management and as a development tool. Arr, extensions add extra functionality to LNbits so ye can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage scallywags to develop and submit their own.',
@@ -164,7 +168,7 @@ window.localisation.pi = {
"If enabled it'll be changin' yer fundin' source to VoidWallet automatically if LNbits sends out a killswitch signal, ye will. Ye'll be needin' t' enable manually after an update, arr.",
killswitch_interval: 'Killswitch Interval',
killswitch_interval_desc:
"How oft th' background task should be checkin' fer th' LNBits killswitch signal from th' status source (in minutes).",
"How oft th' background task should be checkin' fer th' LNbits killswitch signal from th' status source (in minutes).",
enable_watchdog: 'Enable Seadog',
enable_watchdog_desc:
"If enabled, it will swap yer treasure source t' VoidWallet on its own if yer balance be lower than th' LNbits balance. Ye'll need t' enable by hand after an update.",
@@ -249,5 +253,6 @@ window.localisation.pi = {
pay_from_wallet: 'Pay from ye Wallet',
show_qr: 'Show QR',
retry_install: "Try 'nstallin' Again",
new_payment: 'Make New Payment'
new_payment: 'Make New Payment',
hide_empty_wallets: 'Stow empty wallets'
}
+7 -2
View File
@@ -9,6 +9,8 @@ window.localisation.pl = {
transactions: 'Transakcje',
dashboard: 'Panel kontrolny',
node: 'Węzeł',
export_users: 'Eksportuj użytkowników',
no_users: 'Nie znaleziono użytkowników',
total_capacity: 'Całkowita Pojemność',
avg_channel_size: 'Średni rozmiar kanału',
biggest_channel_size: 'Największy Rozmiar Kanału',
@@ -33,6 +35,8 @@ window.localisation.pl = {
reset_defaults_tooltip: 'Wymaż wszystkie ustawienia i ustaw domyślne.',
download_backup: 'Pobierz kopię zapasową bazy danych',
name_your_wallet: 'Nazwij swój portfel %{name}',
wallet_topup_ok:
'Sukces w tworzeniu wirtualnych środków (%{amount} sats). Płatności zależą od rzeczywistych środków na źródle finansowania.',
paste_invoice_label: 'Wklej fakturę, żądanie zapłaty lub kod lnurl *',
lnbits_description:
'Łatwy i lekki w konfiguracji, LNbits może działać w oparciu o dowolne źródło finansowania w sieci lightning czy nawet inną instancję LNbits! Możesz uruchomić instancję LNbits dla siebie lub dla innych. Każdy portfel ma swoje klucze API i nie ma ograniczeń jeśli chodzi o ilość portfeli. LNbits umożliwia dzielenie środków w celu zarządzania nimi, jest również dobrym narzędziem deweloperskim. Rozszerzenia zwiększają funkcjonalność LNbits co umożliwia eksperymentowanie z nowym technologiami w sieci lightning. Tworzenie rozszerzeń jest proste dlatego zachęcamy innych deweloperów do tworzenia dodatkowych funkcjonalności i wysyłanie do nas PR',
@@ -162,7 +166,7 @@ window.localisation.pl = {
'Jeśli zostanie włączone, automatycznie zmieni źródło finansowania na VoidWallet, jeśli LNbits wyśle sygnał wyłączający. Po aktualizacji będziesz musiał włączyć to ręcznie.',
killswitch_interval: 'Interwał wyłącznika awaryjnego',
killswitch_interval_desc:
'Jak często zadanie w tle powinno sprawdzać sygnał wyłącznika awaryjnego LNBits ze źródła statusu (w minutach).',
'Jak często zadanie w tle powinno sprawdzać sygnał wyłącznika awaryjnego LNbits ze źródła statusu (w minutach).',
enable_watchdog: 'Włącz Watchdog',
enable_watchdog_desc:
'Jeśli zostanie włączone, automatycznie zmieni źródło finansowania na VoidWallet, jeśli saldo jest niższe niż saldo LNbits. Po aktualizacji trzeba będzie włączyć ręcznie.',
@@ -248,5 +252,6 @@ window.localisation.pl = {
pay_from_wallet: 'Zapłać z portfela',
show_qr: 'Pokaż kod QR',
retry_install: 'Ponów instalację',
new_payment: 'Dokonaj nowej płatności'
new_payment: 'Dokonaj nowej płatności',
hide_empty_wallets: 'Ukryj puste portfele'
}
+7 -2
View File
@@ -9,6 +9,8 @@ window.localisation.pt = {
transactions: 'Transações',
dashboard: 'Painel de Controle',
node: 'Nó',
export_users: 'Exportar Usuários',
no_users: 'Nenhum usuário encontrado',
total_capacity: 'Capacidade Total',
avg_channel_size: 'Tamanho Médio do Canal',
biggest_channel_size: 'Maior Tamanho do Canal',
@@ -34,6 +36,8 @@ window.localisation.pt = {
'Apagar todas as configurações e redefinir para os padrões.',
download_backup: 'Fazer backup da base de dados',
name_your_wallet: 'Nomeie sua carteira %{name}',
wallet_topup_ok:
'Sucesso ao criar fundos virtuais (%{amount} sats). Os pagamentos dependem dos fundos reais na fonte de financiamento.',
paste_invoice_label: 'Cole uma fatura, pedido de pagamento ou código lnurl *',
lnbits_description:
'Fácil de configurar e leve, o LNbits pode ser executado em qualquer fonte de financiamento da Lightning Network e até mesmo o LNbits em si! Você pode executar o LNbits para si mesmo ou oferecer facilmente uma solução de custódia para outros. Cada carteira tem suas próprias chaves de API e não há limite para o número de carteiras que você pode criar. Ser capaz de particionar fundos torna o LNbits uma ferramenta útil para gerenciamento de dinheiro e como uma ferramenta de desenvolvimento. As extensões adicionam funcionalidades extras ao LNbits para que você possa experimentar uma série de tecnologias de ponta na rede lightning. Nós tornamos o desenvolvimento de extensões o mais fácil possível e, como um projeto gratuito e de código aberto, incentivamos as pessoas a desenvolver e enviar as suas próprias.',
@@ -164,7 +168,7 @@ window.localisation.pt = {
'Se ativado, ele mudará sua fonte de financiamento para VoidWallet automaticamente se o LNbits enviar um sinal de desativação. Você precisará ativar manualmente após uma atualização.',
killswitch_interval: 'Intervalo do Killswitch',
killswitch_interval_desc:
'Com que frequência a tarefa de fundo deve verificar o sinal de desativação do LNBits proveniente da fonte de status (em minutos).',
'Com que frequência a tarefa de fundo deve verificar o sinal de desativação do LNbits proveniente da fonte de status (em minutos).',
enable_watchdog: 'Ativar Watchdog',
enable_watchdog_desc:
'Se ativado, mudará automaticamente a sua fonte de financiamento para VoidWallet caso o seu saldo seja inferior ao saldo LNbits. Você precisará ativar manualmente após uma atualização.',
@@ -248,5 +252,6 @@ window.localisation.pt = {
pay_from_wallet: 'Pague da Carteira',
show_qr: 'Exibir QR',
retry_install: 'Reinstalar Tente Novamente',
new_payment: 'Realizar Novo Pagamento'
new_payment: 'Realizar Novo Pagamento',
hide_empty_wallets: 'Ocultar carteiras vazias'
}
+6 -1
View File
@@ -9,6 +9,8 @@ window.localisation.sk = {
transactions: 'Transakcie',
dashboard: 'Prehľad',
node: 'Uzol',
export_users: 'Exportovať používateľov',
no_users: 'Nenašli sa žiadni používatelia',
total_capacity: 'Celková kapacita',
avg_channel_size: 'Priemerná veľkosť kanálu',
biggest_channel_size: 'Najväčší kanál',
@@ -33,6 +35,8 @@ window.localisation.sk = {
reset_defaults_tooltip: 'Odstrániť všetky nastavenia a obnoviť predvolené.',
download_backup: 'Stiahnuť zálohu databázy',
name_your_wallet: 'Pomenujte vašu %{name} peňaženku',
wallet_topup_ok:
'Úspešne vytvorené virtuálne prostriedky (%{amount} sats). Platby závisia od skutočných prostriedkov v zdroji financovania.',
paste_invoice_label: 'Vložte faktúru, platobnú požiadavku alebo lnurl kód *',
lnbits_description:
'Ľahko nastaviteľný a ľahkotonážny, LNbits môže bežať na akomkoľvek zdroji financovania Lightning Network a dokonca LNbits samotný! LNbits môžete používať pre seba, alebo ľahko ponúknuť správcovské riešenie pre iných. Každá peňaženka má svoje vlastné API kľúče a nie je limit na počet peňaženiek, ktoré môžete vytvoriť. Schopnosť rozdeľovať finančné prostriedky robí z LNbits užitočný nástroj pre správu peňazí a ako vývojový nástroj. Rozšírenia pridávajú extra funkčnosť do LNbits, takže môžete experimentovať s radou najnovších technológií na lightning sieti. Vývoj rozšírení sme urobili čo najjednoduchší a ako voľný a open-source projekt, podporujeme ľudí vývoj a odovzdávanie vlastných rozšírení.',
@@ -247,5 +251,6 @@ window.localisation.sk = {
pay_from_wallet: 'Zaplatiť z peňaženky',
show_qr: 'Zobraziť QR',
retry_install: 'Skúste inštaláciu znova',
new_payment: 'Vytvoriť novú platbu'
new_payment: 'Vytvoriť novú platbu',
hide_empty_wallets: 'Skryť prázdne peňaženky'
}
+7 -2
View File
@@ -9,6 +9,8 @@ window.localisation.we = {
transactions: 'Trafodion',
dashboard: 'Panel Gweinyddol',
node: 'Nod',
export_users: 'Allfor Defnyddwyr',
no_users: 'Heb ganfod defnyddwyr',
total_capacity: 'Capasiti Cyfanswm',
avg_channel_size: 'Maint Sianel Cyf.',
biggest_channel_size: 'Maint Sianel Fwyaf',
@@ -33,6 +35,8 @@ window.localisation.we = {
reset_defaults_tooltip: 'Dileu pob gosodiad ac ailosod i`r rhagosodiadau.',
download_backup: 'Lawrlwytho copi wrth gefn cronfa ddata',
name_your_wallet: 'Enwch eich waled %{name}',
wallet_topup_ok:
"Llwyddiant wrth greu cronfeydd rhithwir (%{amount} sats). Mae taliadau'n dibynnu ar gronfeydd gwirioneddol ar y ffynhonnell cyllido.",
paste_invoice_label: 'Gludwch anfoneb, cais am daliad neu god lnurl *',
lnbits_description:
'Yn hawdd iw sefydlu ac yn ysgafn, gall LNbits redeg ar unrhyw ffynhonnell ariannu rhwydwaith mellt a hyd yn oed LNbits ei hun! Gallwch redeg LNbits i chi`ch hun, neu gynnig datrysiad ceidwad i eraill yn hawdd. Mae gan bob waled ei allweddi API ei hun ac nid oes cyfyngiad ar nifer y waledi y gallwch eu gwneud. Mae gallu rhannu cronfeydd yn gwneud LNbits yn arf defnyddiol ar gyfer rheoli arian ac fel offeryn datblygu. Mae estyniadau yn ychwanegu ymarferoldeb ychwanegol at LNbits fel y gallwch arbrofi gydag ystod o dechnolegau blaengar ar y rhwydwaith mellt. Rydym wedi gwneud datblygu estyniadau mor hawdd â phosibl, ac fel prosiect ffynhonnell agored am ddim, rydym yn annog pobl i ddatblygu a chyflwyno eu rhai eu hunain.',
@@ -162,7 +166,7 @@ window.localisation.we = {
'Os bydd yn galluogi, bydd yn newid eich ffynhonnell arian i VoidWallet yn awtomatig os bydd LNbits yn anfon arwydd killswitch. Bydd angen i chi alluogi â llaw ar ôl diweddariad.',
killswitch_interval: 'Amlder Cyllell Dorri',
killswitch_interval_desc:
"Pa mor aml y dylai'r dasg gefndir wirio am signal killswitch LNBits o'r ffynhonnell statws (mewn munudau).",
"Pa mor aml y dylai'r dasg gefndir wirio am signal killswitch LNbits o'r ffynhonnell statws (mewn munudau).",
enable_watchdog: 'Galluogi Watchdog',
enable_watchdog_desc:
'Os bydd yn cael ei alluogi bydd yn newid eich ffynhonnell ariannu i VoidWallet yn awtomatig os bydd eich balans yn is na balans LNbits. Bydd angen i chi alluogi â llaw ar ôl diweddariad.',
@@ -247,5 +251,6 @@ window.localisation.we = {
pay_from_wallet: "Talu o'r Waled",
show_qr: 'Dangos QR',
retry_install: 'Ailgeisio Gosod',
new_payment: 'Gwneud Taliad Newydd'
new_payment: 'Gwneud Taliad Newydd',
hide_empty_wallets: 'Cuddio waledau gwag'
}
+10
View File
@@ -0,0 +1,10 @@
<svg width="46.626mm" height="12.965mm" version="1.1" viewBox="0 0 46.626 12.965" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(.85851 0 0 .85851 -56.357 -42.14)">
<g transform="matrix(.26458 0 0 .26458 69.488 50.153)" style="fill:none">
<path d="m14.462 28.768-.0035.0072h-12.787c-.4776 0-.8756-.1467-1.194-.4401s-.4776-.6619-.4776-1.1055c0-.4723.20262-.9589.60786-1.4598l20.342-24.837c.3763-.48659.7924-.77997 1.2482-.88015.4559-.10018.8648-.057246 1.2266.1288.3618.18605.6151.49016.7598.91235.1448.42218.1086.92666-.1085 1.5134l-6.643 17.625h1.1057l.0035-.0072h12.787c.4776 0 .8756.1467 1.194.4401s.4776.6619.4776 1.1055c0 .4723-.2026.9589-.6079 1.4598l-20.342 24.837c-.3763.4865-.7924.7799-1.2482.8801-.4559.1002-.86479.0573-1.2266-.1288-.36182-.186-.61509-.4902-.75982-.9123-.14473-.4222-.10855-.9267.10854-1.5135l6.643-17.624z" clip-rule="evenodd" fill="#fff" fill-rule="evenodd" style="fill:#fff"/>
</g>
<g transform="matrix(.26458 0 0 .26458 81.643 50.938)" style="fill:none">
<path d="m.92 42h19.488c7.672 0 12.488-4.144 12.488-11.312 0-5.152-2.072-7.952-5.264-9.464v-.224c1.96-1.4 3.808-3.752 3.808-8.008 0-6.44-4.76-10.192-11.76-10.192h-18.76zm8.736-24.08v-7.392h8.736c2.968 0 4.144 1.568 4.144 3.696s-1.176 3.696-4.144 3.696zm0 16.296v-8.68h9.296c3.808 0 4.984 2.352 4.984 4.312 0 2.352-1.232 4.368-4.984 4.368zm40.657 8.288c9.52 0 14.616-6.384 14.616-15.12 0-8.792-5.208-15.176-14.616-15.176s-14.616 6.384-14.616 15.176 5.096 15.12 14.616 15.12zm0-7.56c-3.528 0-5.824-2.24-5.824-7.56 0-5.264 2.24-7.616 5.824-7.616s5.824 2.296 5.824 7.616-2.24 7.56-5.824 7.56zm18.831 7.056h8.624v-41.44h-8.624zm26.552.504c2.744 0 5.0404-.504 5.9364-.84v-7.168h-.56c-.896.336-1.8484.504-2.8004.504-2.576 0-3.752-1.176-3.752-3.92v-11.032h7.0004v-7.336h-7.0004v-7.672h-.56l-7.672 1.568v6.104h-5.152v7.336h4.76v13.104c0 5.32 3.024 9.352 9.8 9.352zm10.397-.504h24.192v-7.336h-13.552v-.224l13.44-14.448v-7.28h-24.024v7.336h13.44v.224l-13.496 14.504z" fill="#fff" style="fill:#fff"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg width="46.626mm" height="12.965mm" version="1.1" viewBox="0 0 46.626 12.965" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(.85851 0 0 .85851 -56.357 -42.14)">
<g transform="matrix(.26458 0 0 .26458 69.488 50.153)">
<path d="m14.462 28.768-.0035.0072h-12.787c-.4776 0-.8756-.1467-1.194-.4401s-.4776-.6619-.4776-1.1055c0-.4723.20262-.9589.60786-1.4598l20.342-24.837c.3763-.48659.7924-.77997 1.2482-.88015.4559-.10018.8648-.057246 1.2266.1288.3618.18605.6151.49016.7598.91235.1448.42218.1086.92666-.1085 1.5134l-6.643 17.625h1.1057l.0035-.0072h12.787c.4776 0 .8756.1467 1.194.4401s.4776.6619.4776 1.1055c0 .4723-.2026.9589-.6079 1.4598l-20.342 24.837c-.3763.4865-.7924.7799-1.2482.8801-.4559.1002-.86479.0573-1.2266-.1288-.36182-.186-.61509-.4902-.75982-.9123-.14473-.4222-.10855-.9267.10854-1.5135l6.643-17.624z" clip-rule="evenodd" fill="#fff" fill-rule="evenodd" style="fill:#000"/>
</g>
<g transform="matrix(.26458 0 0 .26458 81.643 50.938)">
<path d="m.92 42h19.488c7.672 0 12.488-4.144 12.488-11.312 0-5.152-2.072-7.952-5.264-9.464v-.224c1.96-1.4 3.808-3.752 3.808-8.008 0-6.44-4.76-10.192-11.76-10.192h-18.76zm8.736-24.08v-7.392h8.736c2.968 0 4.144 1.568 4.144 3.696s-1.176 3.696-4.144 3.696zm0 16.296v-8.68h9.296c3.808 0 4.984 2.352 4.984 4.312 0 2.352-1.232 4.368-4.984 4.368zm40.657 8.288c9.52 0 14.616-6.384 14.616-15.12 0-8.792-5.208-15.176-14.616-15.176s-14.616 6.384-14.616 15.176 5.096 15.12 14.616 15.12zm0-7.56c-3.528 0-5.824-2.24-5.824-7.56 0-5.264 2.24-7.616 5.824-7.616s5.824 2.296 5.824 7.616-2.24 7.56-5.824 7.56zm18.831 7.056h8.624v-41.44h-8.624zm26.552.504c2.744 0 5.0404-.504 5.9364-.84v-7.168h-.56c-.896.336-1.8484.504-2.8004.504-2.576 0-3.752-1.176-3.752-3.92v-11.032h7.0004v-7.336h-7.0004v-7.672h-.56l-7.672 1.568v6.104h-5.152v7.336h4.76v13.104c0 5.32 3.024 9.352 9.8 9.352zm10.397-.504h24.192v-7.336h-13.552v-.224l13.44-14.448v-7.28h-24.024v7.336h13.44v.224l-13.496 14.504z" fill="#fff" style="fill:#000"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

+57
View File
@@ -32,6 +32,42 @@ new Vue({
toggleDarkMode: function () {
this.$q.dark.toggle()
this.$q.localStorage.set('lnbits.darkMode', this.$q.dark.isActive)
if (!this.$q.dark.isActive && this.gradientChoice) {
this.toggleGradient()
}
},
applyGradient: function () {
darkBgColor = this.$q.localStorage.getItem('lnbits.darkBgColor')
primaryColor = this.$q.localStorage.getItem('lnbits.primaryColor')
if (this.gradientChoice) {
if (!this.$q.dark.isActive) {
this.toggleDarkMode()
}
const gradientStyle = `linear-gradient(to bottom right, ${LNbits.utils.hexDarken(String(primaryColor), -70)}, #0a0a0a)`
document.body.style.setProperty(
'background-image',
gradientStyle,
'important'
)
const gradientStyleCards = `background-color: ${LNbits.utils.hexAlpha(String(darkBgColor), 0.4)} !important`
const style = document.createElement('style')
style.innerHTML =
`body[data-theme="${this.$q.localStorage.getItem('lnbits.theme')}"] .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark), body.body${this.$q.dark.isActive ? '--dark' : ''} .q-header, body.body${this.$q.dark.isActive ? '--dark' : ''} .q-drawer { ${gradientStyleCards} }` +
`body[data-theme="${this.$q.localStorage.getItem('lnbits.theme')}"].body--dark{background: ${LNbits.utils.hexDarken(String(primaryColor), -88)} !important; }` +
`[data-theme="${this.$q.localStorage.getItem('lnbits.theme')}"] .q-card--dark{background: ${String(darkBgColor)} !important;} }`
document.head.appendChild(style)
this.$q.localStorage.set('lnbits.gradientBg', true)
} else {
this.$q.localStorage.set('lnbits.gradientBg', false)
}
},
toggleGradient: function () {
this.gradientChoice = !this.gradientChoice
this.applyGradient()
if (!this.gradientChoice) {
window.location.reload()
}
this.gradientChoice = this.$q.localStorage.getItem('lnbits.gradientBg')
},
reactionChoiceFunc: function () {
this.$q.localStorage.set('lnbits.reactions', this.reactionChoice)
@@ -39,6 +75,24 @@ new Vue({
changeColor: function (newValue) {
document.body.setAttribute('data-theme', newValue)
this.$q.localStorage.set('lnbits.theme', newValue)
this.setColors()
if (this.$q.localStorage.getItem('lnbits.gradientBg')) {
this.applyGradient()
}
},
setColors: function () {
this.$q.localStorage.set(
'lnbits.primaryColor',
LNbits.utils.getPaletteColor('primary')
)
this.$q.localStorage.set(
'lnbits.secondaryColor',
LNbits.utils.getPaletteColor('secondary')
)
this.$q.localStorage.set(
'lnbits.darkBgColor',
LNbits.utils.getPaletteColor('dark')
)
},
updateAccount: async function () {
try {
@@ -118,5 +172,8 @@ new Vue({
} catch (e) {
LNbits.utils.notifyApiError(e)
}
if (this.$q.localStorage.getItem('lnbits.gradientBg')) {
this.applyGradient()
}
}
})
+45 -2
View File
@@ -247,7 +247,7 @@ window.LNbits = {
payment: function (data) {
obj = {
checking_id: data.checking_id,
pending: data.pending,
status: data.status,
amount: data.amount,
fee: data.fee,
memo: data.memo,
@@ -280,8 +280,15 @@ window.LNbits = {
obj.fsat = new Intl.NumberFormat(window.LOCALE).format(obj.sat)
obj.isIn = obj.amount > 0
obj.isOut = obj.amount < 0
obj.isPaid = !obj.pending
obj.isPending = obj.status === 'pending'
obj.isPaid = obj.status === 'success'
obj.isFailed = obj.status === 'failed'
obj._q = [obj.memo, obj.sat].join(' ').toLowerCase()
try {
obj.details = JSON.parse(data.extra?.details || '{}')
} catch {
obj.details = {extraDetails: data.extra?.details}
}
return obj
}
},
@@ -321,6 +328,7 @@ window.LNbits = {
return this.formatSat(value / 1000)
},
notifyApiError: function (error) {
console.error(error)
var types = {
400: 'warning',
401: 'warning',
@@ -422,6 +430,18 @@ window.LNbits = {
converter.setFlavor('github')
converter.setOption('simpleLineBreaks', true)
return converter.makeHtml(text)
},
hexToRgb: function (hex) {
return Quasar.utils.colors.hexToRgb(hex)
},
hexDarken: function (hex, percent) {
return Quasar.utils.colors.lighten(hex, percent)
},
hexAlpha: function (hex, alpha) {
return Quasar.utils.colors.changeAlpha(hex, alpha)
},
getPaletteColor: function (color) {
return Quasar.utils.colors.getPaletteColor(color)
}
}
}
@@ -432,6 +452,8 @@ window.windowMixin = {
return {
toggleSubs: true,
reactionChoice: 'confettiBothSides',
gradientChoice:
this.$q.localStorage.getItem('lnbits.gradientBg') || false,
isUserAuthorized: false,
g: {
offline: !navigator.onLine,
@@ -451,6 +473,25 @@ window.windowMixin = {
document.body.setAttribute('data-theme', newValue)
this.$q.localStorage.set('lnbits.theme', newValue)
},
applyGradient: function () {
if (this.$q.localStorage.getItem('lnbits.gradientBg')) {
darkBgColor = this.$q.localStorage.getItem('lnbits.darkBgColor')
primaryColor = this.$q.localStorage.getItem('lnbits.primaryColor')
const gradientStyle = `linear-gradient(to bottom right, ${LNbits.utils.hexDarken(String(primaryColor), -70)}, #0a0a0a)`
document.body.style.setProperty(
'background-image',
gradientStyle,
'important'
)
const gradientStyleCards = `background-color: ${LNbits.utils.hexAlpha(String(darkBgColor), 0.4)} !important`
const style = document.createElement('style')
style.innerHTML =
`body[data-theme="${this.$q.localStorage.getItem('lnbits.theme')}"] .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark), body.body${this.$q.dark.isActive ? '--dark' : ''} .q-header, body.body${this.$q.dark.isActive ? '--dark' : ''} .q-drawer { ${gradientStyleCards} }` +
`body[data-theme="${this.$q.localStorage.getItem('lnbits.theme')}"].body--dark{background: ${LNbits.utils.hexDarken(String(primaryColor), -88)} !important; }` +
`[data-theme="${this.$q.localStorage.getItem('lnbits.theme')}"] .q-card--dark{background: ${String(darkBgColor)} !important;} }`
document.head.appendChild(style)
}
},
copyText: function (text, message, position) {
var notify = this.$q.notify
Quasar.utils.copyToClipboard(text).then(function () {
@@ -514,6 +555,8 @@ window.windowMixin = {
this.reactionChoice =
this.$q.localStorage.getItem('lnbits.reactions') || 'confettiBothSides'
this.applyGradient()
this.g.allowedThemes = window.allowedThemes ?? ['bitcoin']
let locale = this.$q.localStorage.getItem('lnbits.lang')
+84 -23
View File
@@ -610,7 +610,8 @@ Vue.component('lnbits-dynamic-fields', {
props: ['options', 'value'],
data() {
return {
formData: null
formData: null,
rules: [val => !!val || 'Field is required']
}
},
@@ -619,49 +620,109 @@ Vue.component('lnbits-dynamic-fields', {
<div class="row q-mb-lg" v-for="o in options">
<div class="col auto-width">
<p v-if=o.options?.length class="q-ml-xl">
<span v-text="o.name"></span> <small v-if="o.description"> (<span v-text="o.description"></span>)</small>
<span v-text="o.label || o.name"></span> <small v-if="o.description"> (<span v-text="o.description"></span>)</small>
</p>
<lnbits-dynamic-fields v-if="o.options?.length" :options="o.options" v-model="formData[o.name]"
@input="handleValueChanged" class="q-ml-xl">
</lnbits-dynamic-fields>
<div v-else>
<q-input v-if="o.type === 'number'" v-model="formData[o.name]" @input="handleValueChanged" type="number"
:label="o.name" :hint="o.description" filled dense>
</q-input>
<q-input v-else-if="o.type === 'text'" v-model="formData[o.name]" @input="handleValueChanged" type="textarea"
rows="5" :label="o.name" :hint="o.description" filled dense>
</q-input>
<q-input v-else-if="o.type === 'password'" v-model="formData[o.name]" @input="handleValueChanged" type="password"
:label="o.name" :hint="o.description" filled dense>
</q-input>
<q-input
v-if="o.type === 'number'"
type="number"
v-model="formData[o.name]"
@input="handleValueChanged"
:label="o.label || o.name"
:hint="o.description"
:rules="applyRules(o.required)"
filled
dense
></q-input>
<q-input
v-else-if="o.type === 'text'"
type="textarea"
rows="5"
v-model="formData[o.name]"
@input="handleValueChanged"
:label="o.label || o.name"
:hint="o.description"
:rules="applyRules(o.required)"
filled
dense
></q-input>
<q-input
v-else-if="o.type === 'password'"
v-model="formData[o.name]"
@input="handleValueChanged"
type="password"
:label="o.label || o.name"
:hint="o.description"
:rules="applyRules(o.required)"
filled
dense
></q-input>
<q-select
v-else-if="o.type === 'select'"
v-model="formData[o.name]"
@input="handleValueChanged"
:label="o.label || o.name"
:hint="o.description"
:options="o.values"
:rules="applyRules(o.required)"
></q-select>
<q-select
v-else-if="o.isList"
v-model.trim="formData[o.name]"
@input="handleValueChanged"
input-debounce="0"
new-value-mode="add-unique"
:label="o.label || o.name"
:hint="o.description"
:rules="applyRules(o.required)"
filled
multiple
dense
use-input
use-chips
multiple
hide-dropdown-icon
></q-select>
<div v-else-if="o.type === 'bool'">
<q-item tag="label" v-ripple>
<q-item-section avatar top>
<q-checkbox v-model="formData[o.name]" @input="handleValueChanged" />
</q-item-section>
<q-item-section>
<q-item-label><span v-text="o.name"></span></q-item-label>
<q-item-label><span v-text="o.label || o.name"></span></q-item-label>
<q-item-label caption> <span v-text="o.description"></span> </q-item-label>
</q-item-section>
</q-item>
</div>
<q-select v-else-if="o.type === 'select'" v-model="formData[o.name]" @input="handleValueChanged" :label="o.name"
:hint="o.description" :options="o.values"></q-select>
<q-select v-else-if="o.isList" filled multiple dense v-model.trim="formData[o.name]" use-input use-chips
@input="handleValueChanged" multiple hide-dropdown-icon input-debounce="0" new-value-mode="add-unique"
:label="o.name" :hint="o.description">
</q-select>
<q-input v-else v-model="formData[o.name]" @input="handleValueChanged" :label="o.name" :hint="o.description"
filled dense>
</q-input>
<q-input
v-else-if="o.type === 'hidden'"
v-model="formData[o.name]"
type="text"
style="display: none"
:rules="applyRules(o.required)"
></q-input>
<q-input
v-else
v-model="formData[o.name]"
@input="handleValueChanged"
:hint="o.description"
:label="o.label || o.name"
:rules="applyRules(o.required)"
filled
dense
></q-input>
</div>
</div>
</div>
</div>
`,
methods: {
applyRules(required) {
return required ? this.rules : []
},
buildData(options, data = {}) {
return options.reduce((d, option) => {
if (option.options?.length) {
@@ -0,0 +1,16 @@
Vue.component('lnbits-extension-rating', {
name: 'lnbits-extension-rating',
props: ['rating'],
template: `
<div style="margin-bottom: 3px">
<q-rating
v-model="rating"
size="1.5em"
:max="5"
color="primary"
><q-tooltip>
<span v-text="$t('extension_rating_soon')"></span> </q-tooltip
></q-rating>
</div>
`
})
@@ -1,6 +1,14 @@
Vue.component('lnbits-funding-sources', {
mixins: [windowMixin],
props: ['form-data', 'allowed-funding-sources'],
methods: {
getFundingSourceLabel(item) {
const fundingSource = this.rawFundingSources.find(
fundingSource => fundingSource[0] === item
)
return fundingSource ? fundingSource[1] : item
}
},
computed: {
fundingSources() {
let tmp = []
@@ -14,6 +22,9 @@ Vue.component('lnbits-funding-sources', {
tmp.push([key, tmpObj])
}
return new Map(tmp)
},
sortedAllowedFundingSources() {
return this.allowedFundingSources.sort()
}
},
data() {
@@ -93,12 +104,21 @@ Vue.component('lnbits-funding-sources', {
],
[
'LNbitsWallet',
'LNBits',
'LNbits',
{
lnbits_endpoint: 'Endpoint',
lnbits_key: 'Admin Key'
}
],
[
'BlinkWallet',
'Blink',
{
blink_api_endpoint: 'Endpoint',
blink_ws_endpoint: 'WebSocket',
blink_token: 'Key'
}
],
[
'AlbyWallet',
'Alby',
@@ -107,6 +127,16 @@ Vue.component('lnbits-funding-sources', {
alby_access_token: 'Key'
}
],
[
'BoltzWallet',
'Boltz',
{
boltz_client_endpoint: 'Endpoint',
boltz_client_macaroon: 'Admin Macaroon path or hex',
boltz_client_cert: 'Certificate path or hex',
boltz_client_wallet: 'Wallet Name'
}
],
[
'ZBDWallet',
'ZBD',
@@ -145,6 +175,24 @@ Vue.component('lnbits-funding-sources', {
spark_url: 'Endpoint',
spark_token: 'Token'
}
],
[
'NWCWallet',
'Nostr Wallet Connect',
{
nwc_pairing_url: 'Pairing URL'
}
],
[
'BreezSdkWallet',
'Breez SDK',
{
breez_api_key: 'Breez API Key',
breez_greenlight_seed: 'Greenlight Seed',
breez_greenlight_device_key: 'Greenlight Device Key',
breez_greenlight_device_cert: 'Greenlight Device Cert',
breez_greenlight_invite_code: 'Greenlight Invite Code'
}
]
]
}
@@ -159,7 +207,8 @@ Vue.component('lnbits-funding-sources', {
filled
v-model="formData.lnbits_backend_wallet_class"
hint="Select the active funding wallet"
:options="allowedFundingSources"
:options="sortedAllowedFundingSources"
:option-label="(item) => getFundingSourceLabel(item)"
></q-select>
</div>
</div>
+116 -8
View File
@@ -33,6 +33,8 @@ Vue.component('payment-list', {
search: null,
loading: false
},
exportTagName: '',
exportPaymentTagList: [],
paymentsCSV: {
columns: [
{
@@ -146,7 +148,7 @@ Vue.component('payment-list', {
paymentTableRowKey: function (row) {
return row.payment_hash + row.amount
},
exportCSV: function () {
exportCSV(detailed = false) {
// 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
@@ -157,14 +159,51 @@ Vue.component('payment-list', {
}
const params = new URLSearchParams(query)
LNbits.api.getPayments(this.wallet, params).then(response => {
const payments = response.data.data.map(LNbits.map.payment)
let payments = response.data.data.map(LNbits.map.payment)
let columns = this.paymentsCSV.columns
if (detailed) {
if (this.exportPaymentTagList.length) {
payments = payments.filter(p =>
this.exportPaymentTagList.includes(p.tag)
)
}
const extraColumns = Object.keys(
payments.reduce((e, p) => ({...e, ...p.details}), {})
).map(col => ({
name: col,
align: 'right',
label:
col.charAt(0).toUpperCase() +
col.slice(1).replace(/([A-Z])/g, ' $1'),
field: row => row.details[col],
format: data =>
typeof data === 'object' ? JSON.stringify(data) : data
}))
columns = this.paymentsCSV.columns.concat(extraColumns)
}
LNbits.utils.exportCSV(
this.paymentsCSV.columns,
columns,
payments,
this.wallet.name + '-payments'
)
})
},
addFilterTag: function () {
if (!this.exportTagName) return
const value = this.exportTagName.trim()
this.exportPaymentTagList = this.exportPaymentTagList.filter(
v => v !== value
)
this.exportPaymentTagList.push(value)
this.exportTagName = ''
},
removeExportTag: function (value) {
this.exportPaymentTagList = this.exportPaymentTagList.filter(
v => v !== value
)
},
formatCurrency: function (amount, currency) {
try {
return LNbits.utils.formatCurrency(amount, currency)
@@ -202,7 +241,59 @@ Vue.component('payment-list', {
></h5>
</div>
<div class="gt-sm col-auto">
<q-btn flat color="grey" @click="exportCSV" :label="$t('export_csv')" ></q-btn>
<q-btn-dropdown
outline
persistent
class="q-mr-sm"
color="grey"
:label="$t('export_csv')"
split
@click="exportCSV(false)"
>
<q-list>
<q-item>
<q-item-section>
<q-input
@keydown.enter="addFilterTag"
filled
dense
v-model="exportTagName"
type="text"
label="Payment Tags"
class="q-pa-sm"
>
<q-btn
@click="addFilterTag"
dense
flat
icon="add"
></q-btn>
</q-input>
</q-item-section>
</q-item>
<q-item v-if="exportPaymentTagList.length">
<q-item-section>
<div>
<q-chip
v-for="tag in exportPaymentTagList"
:key="tag"
removable
@remove="removeExportTag(tag)"
color="primary"
text-color="white"
:label="tag"
></q-chip>
</div>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-btn v-close-popup outline color="grey" @click="exportCSV(true)" label="Export to CSV with details" ></q-btn>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<payment-chart :wallet="wallet" />
</div>
</div>
@@ -254,6 +345,16 @@ Vue.component('payment-list', {
:color="props.row.isOut ? 'pink' : 'green'"
@click="props.expand = !props.expand"
></q-icon>
<q-icon
v-else-if="props.row.isFailed"
name="warning"
color="yellow"
@click="props.expand = !props.expand"
>
<q-tooltip
><span>failed</span
></q-tooltip>
</q-icon>
<q-icon
v-else
name="settings_ethernet"
@@ -319,7 +420,7 @@ Vue.component('payment-list', {
<q-dialog v-model="props.expand" :props="props" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<div v-if="props.row.isIn && props.row.pending">
<div v-if="props.row.isIn && props.row.isPending">
<q-icon name="settings_ethernet" color="grey"></q-icon>
<span v-text="$t('invoice_waiting')"></span>
<lnbits-payment-details
@@ -353,6 +454,13 @@ Vue.component('payment-list', {
></q-btn>
</div>
</div>
<div v-else-if="props.row.isOut && props.row.isPending">
<q-icon name="settings_ethernet" color="grey"></q-icon>
<span v-text="$t('outgoing_payment_pending')"></span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isPaid && props.row.isIn">
<q-icon
size="18px"
@@ -375,9 +483,9 @@ Vue.component('payment-list', {
:payment="props.row"
></lnbits-payment-details>
</div>
<div v-else-if="props.row.isOut && props.row.pending">
<q-icon name="settings_ethernet" color="grey"></q-icon>
<span v-text="$t('outgoing_payment_pending')"></span>
<div v-else-if="props.row.isFailed">
<q-icon name="warning" color="yellow"></q-icon>
<span>Payment failed</span>
<lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
+64 -62
View File
@@ -56,7 +56,9 @@ new Vue({
update: {
name: null,
currency: null
}
},
inkeyHidden: true,
adminkeyHidden: true
}
},
computed: {
@@ -209,6 +211,54 @@ new Vue({
})
}
},
lnurlScan() {
LNbits.api
.request(
'GET',
'/api/v1/lnurlscan/' + this.parse.data.request,
this.g.wallet.adminkey
)
.catch(err => {
LNbits.utils.notifyApiError(err)
})
.then(response => {
let data = response.data
if (data.status === 'ERROR') {
this.$q.notify({
timeout: 5000,
type: 'warning',
message: `${data.domain} lnurl call failed.`,
caption: data.reason
})
return
}
if (data.kind === 'pay') {
this.parse.lnurlpay = Object.freeze(data)
this.parse.data.amount = data.minSendable / 1000
} else if (data.kind === 'auth') {
this.parse.lnurlauth = Object.freeze(data)
} else if (data.kind === 'withdraw') {
this.parse.show = false
this.receive.show = true
this.receive.status = 'pending'
this.receive.paymentReq = null
this.receive.paymentHash = null
this.receive.data.amount = data.maxWithdrawable / 1000
this.receive.data.memo = data.defaultDescription
this.receive.minMax = [
data.minWithdrawable / 1000,
data.maxWithdrawable / 1000
]
this.receive.lnurl = {
domain: data.domain,
callback: data.callback,
fixed: data.fixed
}
}
})
},
decodeQR: function (res) {
this.parse.data.request = res
this.decodeRequest()
@@ -216,67 +266,18 @@ new Vue({
},
decodeRequest: function () {
this.parse.show = true
let req = this.parse.data.request.toLowerCase()
if (this.parse.data.request.toLowerCase().startsWith('lightning:')) {
this.parse.data.request = this.parse.data.request.slice(10)
} else if (this.parse.data.request.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
.split('lightning=')[1]
.split('&')[0]
this.parse.data.request = this.parse.data.request.trim().toLowerCase()
let req = this.parse.data.request
if (req.startsWith('lightning:')) {
this.parse.data.request = req.slice(10)
} else if (req.startsWith('lnurl:')) {
this.parse.data.request = req.slice(6)
} else if (req.includes('lightning=lnurl1')) {
this.parse.data.request = req.split('lightning=')[1].split('&')[0]
}
if (
this.parse.data.request.toLowerCase().startsWith('lnurl1') ||
this.parse.data.request.match(/[\w.+-~_]+@[\w.+-~_]/)
) {
LNbits.api
.request(
'GET',
'/api/v1/lnurlscan/' + this.parse.data.request,
this.g.wallet.adminkey
)
.catch(err => {
LNbits.utils.notifyApiError(err)
})
.then(response => {
let data = response.data
if (data.status === 'ERROR') {
this.$q.notify({
timeout: 5000,
type: 'warning',
message: `${data.domain} lnurl call failed.`,
caption: data.reason
})
return
}
if (data.kind === 'pay') {
this.parse.lnurlpay = Object.freeze(data)
this.parse.data.amount = data.minSendable / 1000
} else if (data.kind === 'auth') {
this.parse.lnurlauth = Object.freeze(data)
} else if (data.kind === 'withdraw') {
this.parse.show = false
this.receive.show = true
this.receive.status = 'pending'
this.receive.paymentReq = null
this.receive.paymentHash = null
this.receive.data.amount = data.maxWithdrawable / 1000
this.receive.data.memo = data.defaultDescription
this.receive.minMax = [
data.minWithdrawable / 1000,
data.maxWithdrawable / 1000
]
this.receive.lnurl = {
domain: data.domain,
callback: data.callback,
fixed: data.fixed
}
}
})
req = this.parse.data.request
if (req.startsWith('lnurl1') || req.match(/[\w.+-~_]+@[\w.+-~_]/)) {
this.lnurlScan()
return
}
@@ -542,7 +543,7 @@ new Vue({
pasteToTextArea: function () {
this.$refs.textArea.focus() // Set cursor to textarea
navigator.clipboard.readText().then(text => {
this.$refs.textArea.value = text
this.parse.data.request = text.trim()
})
}
},
@@ -560,6 +561,7 @@ new Vue({
this.update.name = this.g.wallet.name
this.update.currency = this.g.wallet.currency
this.receive.units = ['sat', ...window.currencies]
this.updateFiatBalance()
},
watch: {
updatePayments: function () {
+7
View File
@@ -231,3 +231,10 @@ video {
.whitespace-pre-line {
white-space: pre-line;
}
.q-carousel__slide {
background-size: contain;
background-repeat: no-repeat;
}
.q-dialog__inner--minimized {
padding: 12px;
}
+1
View File
@@ -37,6 +37,7 @@
"js/components.js",
"js/components/lnbits-funding-sources.js",
"js/components/extension-settings.js",
"js/components/extension-rating.js",
"js/components/payment-list.js",
"js/components/payment-chart.js",
"js/event-reactions.js",
+1711 -453
View File
File diff suppressed because it is too large Load Diff
+368 -365
View File
@@ -1,5 +1,5 @@
//! moment.js
//! version : 2.29.4
//! version : 2.30.1
//! authors : Tim Wood, Iskren Chernev, Moment.js contributors
//! license : MIT
//! momentjs.com
@@ -155,24 +155,25 @@
}
function isValid(m) {
if (m._isValid == null) {
var flags = getParsingFlags(m),
parsedParts = some.call(flags.parsedDateParts, function (i) {
return i != null;
}),
isNowValid =
!isNaN(m._d.getTime()) &&
flags.overflow < 0 &&
!flags.empty &&
!flags.invalidEra &&
!flags.invalidMonth &&
!flags.invalidWeekday &&
!flags.weekdayMismatch &&
!flags.nullInput &&
!flags.invalidFormat &&
!flags.userInvalidated &&
(!flags.meridiem || (flags.meridiem && parsedParts));
var flags = null,
parsedParts = false,
isNowValid = m._d && !isNaN(m._d.getTime());
if (isNowValid) {
flags = getParsingFlags(m);
parsedParts = some.call(flags.parsedDateParts, function (i) {
return i != null;
});
isNowValid =
flags.overflow < 0 &&
!flags.empty &&
!flags.invalidEra &&
!flags.invalidMonth &&
!flags.invalidWeekday &&
!flags.weekdayMismatch &&
!flags.nullInput &&
!flags.invalidFormat &&
!flags.userInvalidated &&
(!flags.meridiem || (flags.meridiem && parsedParts));
if (m._strict) {
isNowValid =
isNowValid &&
@@ -180,12 +181,11 @@
flags.unusedTokens.length === 0 &&
flags.bigHour === undefined;
}
if (Object.isFrozen == null || !Object.isFrozen(m)) {
m._isValid = isNowValid;
} else {
return isNowValid;
}
}
if (Object.isFrozen == null || !Object.isFrozen(m)) {
m._isValid = isNowValid;
} else {
return isNowValid;
}
return m._isValid;
}
@@ -630,12 +630,56 @@
return isFunction(format) ? format(output) : format.replace(/%s/i, output);
}
var aliases = {};
function addUnitAlias(unit, shorthand) {
var lowerCase = unit.toLowerCase();
aliases[lowerCase] = aliases[lowerCase + 's'] = aliases[shorthand] = unit;
}
var aliases = {
D: 'date',
dates: 'date',
date: 'date',
d: 'day',
days: 'day',
day: 'day',
e: 'weekday',
weekdays: 'weekday',
weekday: 'weekday',
E: 'isoWeekday',
isoweekdays: 'isoWeekday',
isoweekday: 'isoWeekday',
DDD: 'dayOfYear',
dayofyears: 'dayOfYear',
dayofyear: 'dayOfYear',
h: 'hour',
hours: 'hour',
hour: 'hour',
ms: 'millisecond',
milliseconds: 'millisecond',
millisecond: 'millisecond',
m: 'minute',
minutes: 'minute',
minute: 'minute',
M: 'month',
months: 'month',
month: 'month',
Q: 'quarter',
quarters: 'quarter',
quarter: 'quarter',
s: 'second',
seconds: 'second',
second: 'second',
gg: 'weekYear',
weekyears: 'weekYear',
weekyear: 'weekYear',
GG: 'isoWeekYear',
isoweekyears: 'isoWeekYear',
isoweekyear: 'isoWeekYear',
w: 'week',
weeks: 'week',
week: 'week',
W: 'isoWeek',
isoweeks: 'isoWeek',
isoweek: 'isoWeek',
y: 'year',
years: 'year',
year: 'year',
};
function normalizeUnits(units) {
return typeof units === 'string'
@@ -660,11 +704,24 @@
return normalizedInput;
}
var priorities = {};
function addUnitPriority(unit, priority) {
priorities[unit] = priority;
}
var priorities = {
date: 9,
day: 11,
weekday: 11,
isoWeekday: 11,
dayOfYear: 4,
hour: 13,
millisecond: 16,
minute: 14,
month: 8,
quarter: 7,
second: 15,
weekYear: 1,
isoWeekYear: 1,
week: 5,
isoWeek: 5,
year: 1,
};
function getPrioritizedUnits(unitsObj) {
var units = [],
@@ -680,96 +737,6 @@
return units;
}
function isLeapYear(year) {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
function absFloor(number) {
if (number < 0) {
// -0 -> 0
return Math.ceil(number) || 0;
} else {
return Math.floor(number);
}
}
function toInt(argumentForCoercion) {
var coercedNumber = +argumentForCoercion,
value = 0;
if (coercedNumber !== 0 && isFinite(coercedNumber)) {
value = absFloor(coercedNumber);
}
return value;
}
function makeGetSet(unit, keepTime) {
return function (value) {
if (value != null) {
set$1(this, unit, value);
hooks.updateOffset(this, keepTime);
return this;
} else {
return get(this, unit);
}
};
}
function get(mom, unit) {
return mom.isValid()
? mom._d['get' + (mom._isUTC ? 'UTC' : '') + unit]()
: NaN;
}
function set$1(mom, unit, value) {
if (mom.isValid() && !isNaN(value)) {
if (
unit === 'FullYear' &&
isLeapYear(mom.year()) &&
mom.month() === 1 &&
mom.date() === 29
) {
value = toInt(value);
mom._d['set' + (mom._isUTC ? 'UTC' : '') + unit](
value,
mom.month(),
daysInMonth(value, mom.month())
);
} else {
mom._d['set' + (mom._isUTC ? 'UTC' : '') + unit](value);
}
}
}
// MOMENTS
function stringGet(units) {
units = normalizeUnits(units);
if (isFunction(this[units])) {
return this[units]();
}
return this;
}
function stringSet(units, value) {
if (typeof units === 'object') {
units = normalizeObjectUnits(units);
var prioritized = getPrioritizedUnits(units),
i,
prioritizedLen = prioritized.length;
for (i = 0; i < prioritizedLen; i++) {
this[prioritized[i].unit](units[prioritized[i].unit]);
}
} else {
units = normalizeUnits(units);
if (isFunction(this[units])) {
return this[units](value);
}
}
return this;
}
var match1 = /\d/, // 0 - 9
match2 = /\d\d/, // 00 - 99
match3 = /\d{3}/, // 000 - 999
@@ -790,6 +757,8 @@
// includes scottish gaelic two word and hyphenated months
matchWord =
/[0-9]{0,256}['a-z\u00A0-\u05FF\u0700-\uD7FF\uF900-\uFDCF\uFDF0-\uFF07\uFF10-\uFFEF]{1,256}|[\u0600-\u06FF\/]{1,256}(\s*?[\u0600-\u06FF]{1,256}){1,2}/i,
match1to2NoLeadingZero = /^[1-9]\d?/, // 1-99
match1to2HasZero = /^([1-9]\d|\d)/, // 0-99
regexes;
regexes = {};
@@ -828,6 +797,26 @@
return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
}
function absFloor(number) {
if (number < 0) {
// -0 -> 0
return Math.ceil(number) || 0;
} else {
return Math.floor(number);
}
}
function toInt(argumentForCoercion) {
var coercedNumber = +argumentForCoercion,
value = 0;
if (coercedNumber !== 0 && isFinite(coercedNumber)) {
value = absFloor(coercedNumber);
}
return value;
}
var tokens = {};
function addParseToken(token, callback) {
@@ -861,6 +850,10 @@
}
}
function isLeapYear(year) {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
var YEAR = 0,
MONTH = 1,
DATE = 2,
@@ -871,6 +864,173 @@
WEEK = 7,
WEEKDAY = 8;
// FORMATTING
addFormatToken('Y', 0, 0, function () {
var y = this.year();
return y <= 9999 ? zeroFill(y, 4) : '+' + y;
});
addFormatToken(0, ['YY', 2], 0, function () {
return this.year() % 100;
});
addFormatToken(0, ['YYYY', 4], 0, 'year');
addFormatToken(0, ['YYYYY', 5], 0, 'year');
addFormatToken(0, ['YYYYYY', 6, true], 0, 'year');
// PARSING
addRegexToken('Y', matchSigned);
addRegexToken('YY', match1to2, match2);
addRegexToken('YYYY', match1to4, match4);
addRegexToken('YYYYY', match1to6, match6);
addRegexToken('YYYYYY', match1to6, match6);
addParseToken(['YYYYY', 'YYYYYY'], YEAR);
addParseToken('YYYY', function (input, array) {
array[YEAR] =
input.length === 2 ? hooks.parseTwoDigitYear(input) : toInt(input);
});
addParseToken('YY', function (input, array) {
array[YEAR] = hooks.parseTwoDigitYear(input);
});
addParseToken('Y', function (input, array) {
array[YEAR] = parseInt(input, 10);
});
// HELPERS
function daysInYear(year) {
return isLeapYear(year) ? 366 : 365;
}
// HOOKS
hooks.parseTwoDigitYear = function (input) {
return toInt(input) + (toInt(input) > 68 ? 1900 : 2000);
};
// MOMENTS
var getSetYear = makeGetSet('FullYear', true);
function getIsLeapYear() {
return isLeapYear(this.year());
}
function makeGetSet(unit, keepTime) {
return function (value) {
if (value != null) {
set$1(this, unit, value);
hooks.updateOffset(this, keepTime);
return this;
} else {
return get(this, unit);
}
};
}
function get(mom, unit) {
if (!mom.isValid()) {
return NaN;
}
var d = mom._d,
isUTC = mom._isUTC;
switch (unit) {
case 'Milliseconds':
return isUTC ? d.getUTCMilliseconds() : d.getMilliseconds();
case 'Seconds':
return isUTC ? d.getUTCSeconds() : d.getSeconds();
case 'Minutes':
return isUTC ? d.getUTCMinutes() : d.getMinutes();
case 'Hours':
return isUTC ? d.getUTCHours() : d.getHours();
case 'Date':
return isUTC ? d.getUTCDate() : d.getDate();
case 'Day':
return isUTC ? d.getUTCDay() : d.getDay();
case 'Month':
return isUTC ? d.getUTCMonth() : d.getMonth();
case 'FullYear':
return isUTC ? d.getUTCFullYear() : d.getFullYear();
default:
return NaN; // Just in case
}
}
function set$1(mom, unit, value) {
var d, isUTC, year, month, date;
if (!mom.isValid() || isNaN(value)) {
return;
}
d = mom._d;
isUTC = mom._isUTC;
switch (unit) {
case 'Milliseconds':
return void (isUTC
? d.setUTCMilliseconds(value)
: d.setMilliseconds(value));
case 'Seconds':
return void (isUTC ? d.setUTCSeconds(value) : d.setSeconds(value));
case 'Minutes':
return void (isUTC ? d.setUTCMinutes(value) : d.setMinutes(value));
case 'Hours':
return void (isUTC ? d.setUTCHours(value) : d.setHours(value));
case 'Date':
return void (isUTC ? d.setUTCDate(value) : d.setDate(value));
// case 'Day': // Not real
// return void (isUTC ? d.setUTCDay(value) : d.setDay(value));
// case 'Month': // Not used because we need to pass two variables
// return void (isUTC ? d.setUTCMonth(value) : d.setMonth(value));
case 'FullYear':
break; // See below ...
default:
return; // Just in case
}
year = value;
month = mom.month();
date = mom.date();
date = date === 29 && month === 1 && !isLeapYear(year) ? 28 : date;
void (isUTC
? d.setUTCFullYear(year, month, date)
: d.setFullYear(year, month, date));
}
// MOMENTS
function stringGet(units) {
units = normalizeUnits(units);
if (isFunction(this[units])) {
return this[units]();
}
return this;
}
function stringSet(units, value) {
if (typeof units === 'object') {
units = normalizeObjectUnits(units);
var prioritized = getPrioritizedUnits(units),
i,
prioritizedLen = prioritized.length;
for (i = 0; i < prioritizedLen; i++) {
this[prioritized[i].unit](units[prioritized[i].unit]);
}
} else {
units = normalizeUnits(units);
if (isFunction(this[units])) {
return this[units](value);
}
}
return this;
}
function mod(n, x) {
return ((n % x) + x) % x;
}
@@ -919,17 +1079,9 @@
return this.localeData().months(this, format);
});
// ALIASES
addUnitAlias('month', 'M');
// PRIORITY
addUnitPriority('month', 8);
// PARSING
addRegexToken('M', match1to2);
addRegexToken('M', match1to2, match1to2NoLeadingZero);
addRegexToken('MM', match1to2, match2);
addRegexToken('MMM', function (isStrict, locale) {
return locale.monthsShortRegex(isStrict);
@@ -1095,8 +1247,6 @@
// MOMENTS
function setMonth(mom, value) {
var dayOfMonth;
if (!mom.isValid()) {
// No op
return mom;
@@ -1114,8 +1264,13 @@
}
}
dayOfMonth = Math.min(mom.date(), daysInMonth(mom.year(), value));
mom._d['set' + (mom._isUTC ? 'UTC' : '') + 'Month'](value, dayOfMonth);
var month = value,
date = mom.date();
date = date < 29 ? date : Math.min(date, daysInMonth(mom.year(), month));
void (mom._isUTC
? mom._d.setUTCMonth(month, date)
: mom._d.setMonth(month, date));
return mom;
}
@@ -1182,27 +1337,24 @@
longPieces = [],
mixedPieces = [],
i,
mom;
mom,
shortP,
longP;
for (i = 0; i < 12; i++) {
// make the regex if we don't have it already
mom = createUTC([2000, i]);
shortPieces.push(this.monthsShort(mom, ''));
longPieces.push(this.months(mom, ''));
mixedPieces.push(this.months(mom, ''));
mixedPieces.push(this.monthsShort(mom, ''));
shortP = regexEscape(this.monthsShort(mom, ''));
longP = regexEscape(this.months(mom, ''));
shortPieces.push(shortP);
longPieces.push(longP);
mixedPieces.push(longP);
mixedPieces.push(shortP);
}
// Sorting makes sure if one month (or abbr) is a prefix of another it
// will match the longer piece.
shortPieces.sort(cmpLenRev);
longPieces.sort(cmpLenRev);
mixedPieces.sort(cmpLenRev);
for (i = 0; i < 12; i++) {
shortPieces[i] = regexEscape(shortPieces[i]);
longPieces[i] = regexEscape(longPieces[i]);
}
for (i = 0; i < 24; i++) {
mixedPieces[i] = regexEscape(mixedPieces[i]);
}
this._monthsRegex = new RegExp('^(' + mixedPieces.join('|') + ')', 'i');
this._monthsShortRegex = this._monthsRegex;
@@ -1216,69 +1368,6 @@
);
}
// FORMATTING
addFormatToken('Y', 0, 0, function () {
var y = this.year();
return y <= 9999 ? zeroFill(y, 4) : '+' + y;
});
addFormatToken(0, ['YY', 2], 0, function () {
return this.year() % 100;
});
addFormatToken(0, ['YYYY', 4], 0, 'year');
addFormatToken(0, ['YYYYY', 5], 0, 'year');
addFormatToken(0, ['YYYYYY', 6, true], 0, 'year');
// ALIASES
addUnitAlias('year', 'y');
// PRIORITIES
addUnitPriority('year', 1);
// PARSING
addRegexToken('Y', matchSigned);
addRegexToken('YY', match1to2, match2);
addRegexToken('YYYY', match1to4, match4);
addRegexToken('YYYYY', match1to6, match6);
addRegexToken('YYYYYY', match1to6, match6);
addParseToken(['YYYYY', 'YYYYYY'], YEAR);
addParseToken('YYYY', function (input, array) {
array[YEAR] =
input.length === 2 ? hooks.parseTwoDigitYear(input) : toInt(input);
});
addParseToken('YY', function (input, array) {
array[YEAR] = hooks.parseTwoDigitYear(input);
});
addParseToken('Y', function (input, array) {
array[YEAR] = parseInt(input, 10);
});
// HELPERS
function daysInYear(year) {
return isLeapYear(year) ? 366 : 365;
}
// HOOKS
hooks.parseTwoDigitYear = function (input) {
return toInt(input) + (toInt(input) > 68 ? 1900 : 2000);
};
// MOMENTS
var getSetYear = makeGetSet('FullYear', true);
function getIsLeapYear() {
return isLeapYear(this.year());
}
function createDate(y, m, d, h, M, s, ms) {
// can't just apply() to create a date:
// https://stackoverflow.com/q/181348
@@ -1384,21 +1473,11 @@
addFormatToken('w', ['ww', 2], 'wo', 'week');
addFormatToken('W', ['WW', 2], 'Wo', 'isoWeek');
// ALIASES
addUnitAlias('week', 'w');
addUnitAlias('isoWeek', 'W');
// PRIORITIES
addUnitPriority('week', 5);
addUnitPriority('isoWeek', 5);
// PARSING
addRegexToken('w', match1to2);
addRegexToken('w', match1to2, match1to2NoLeadingZero);
addRegexToken('ww', match1to2, match2);
addRegexToken('W', match1to2);
addRegexToken('W', match1to2, match1to2NoLeadingZero);
addRegexToken('WW', match1to2, match2);
addWeekParseToken(
@@ -1460,17 +1539,6 @@
addFormatToken('e', 0, 0, 'weekday');
addFormatToken('E', 0, 0, 'isoWeekday');
// ALIASES
addUnitAlias('day', 'd');
addUnitAlias('weekday', 'e');
addUnitAlias('isoWeekday', 'E');
// PRIORITY
addUnitPriority('day', 11);
addUnitPriority('weekday', 11);
addUnitPriority('isoWeekday', 11);
// PARSING
addRegexToken('d', match1to2);
@@ -1550,24 +1618,24 @@
return m === true
? shiftWeekdays(weekdays, this._week.dow)
: m
? weekdays[m.day()]
: weekdays;
? weekdays[m.day()]
: weekdays;
}
function localeWeekdaysShort(m) {
return m === true
? shiftWeekdays(this._weekdaysShort, this._week.dow)
: m
? this._weekdaysShort[m.day()]
: this._weekdaysShort;
? this._weekdaysShort[m.day()]
: this._weekdaysShort;
}
function localeWeekdaysMin(m) {
return m === true
? shiftWeekdays(this._weekdaysMin, this._week.dow)
: m
? this._weekdaysMin[m.day()]
: this._weekdaysMin;
? this._weekdaysMin[m.day()]
: this._weekdaysMin;
}
function handleStrictParse$1(weekdayName, format, strict) {
@@ -1716,7 +1784,8 @@
if (!this.isValid()) {
return input != null ? this : NaN;
}
var day = this._isUTC ? this._d.getUTCDay() : this._d.getDay();
var day = get(this, 'Day');
if (input != null) {
input = parseWeekday(input, this.localeData());
return this.add(input - day, 'd');
@@ -1915,13 +1984,6 @@
meridiem('a', true);
meridiem('A', false);
// ALIASES
addUnitAlias('hour', 'h');
// PRIORITY
addUnitPriority('hour', 13);
// PARSING
function matchMeridiem(isStrict, locale) {
@@ -1930,9 +1992,9 @@
addRegexToken('a', matchMeridiem);
addRegexToken('A', matchMeridiem);
addRegexToken('H', match1to2);
addRegexToken('h', match1to2);
addRegexToken('k', match1to2);
addRegexToken('H', match1to2, match1to2HasZero);
addRegexToken('h', match1to2, match1to2NoLeadingZero);
addRegexToken('k', match1to2, match1to2NoLeadingZero);
addRegexToken('HH', match1to2, match2);
addRegexToken('hh', match1to2, match2);
addRegexToken('kk', match1to2, match2);
@@ -2082,7 +2144,8 @@
function isLocaleNameSane(name) {
// Prevent names that look like filesystem paths, i.e contain '/' or '\'
return name.match('^[^/\\\\]*$') != null;
// Ensure name is available and function returns boolean
return !!(name && name.match('^[^/\\\\]*$'));
}
function loadLocale(name) {
@@ -2274,21 +2337,21 @@
a[MONTH] < 0 || a[MONTH] > 11
? MONTH
: a[DATE] < 1 || a[DATE] > daysInMonth(a[YEAR], a[MONTH])
? DATE
: a[HOUR] < 0 ||
a[HOUR] > 24 ||
(a[HOUR] === 24 &&
(a[MINUTE] !== 0 ||
a[SECOND] !== 0 ||
a[MILLISECOND] !== 0))
? HOUR
: a[MINUTE] < 0 || a[MINUTE] > 59
? MINUTE
: a[SECOND] < 0 || a[SECOND] > 59
? SECOND
: a[MILLISECOND] < 0 || a[MILLISECOND] > 999
? MILLISECOND
: -1;
? DATE
: a[HOUR] < 0 ||
a[HOUR] > 24 ||
(a[HOUR] === 24 &&
(a[MINUTE] !== 0 ||
a[SECOND] !== 0 ||
a[MILLISECOND] !== 0))
? HOUR
: a[MINUTE] < 0 || a[MINUTE] > 59
? MINUTE
: a[SECOND] < 0 || a[SECOND] > 59
? SECOND
: a[MILLISECOND] < 0 || a[MILLISECOND] > 999
? MILLISECOND
: -1;
if (
getParsingFlags(m)._overflowDayOfYear &&
@@ -3729,16 +3792,16 @@
return diff < -6
? 'sameElse'
: diff < -1
? 'lastWeek'
: diff < 0
? 'lastDay'
: diff < 1
? 'sameDay'
: diff < 2
? 'nextDay'
: diff < 7
? 'nextWeek'
: 'sameElse';
? 'lastWeek'
: diff < 0
? 'lastDay'
: diff < 1
? 'sameDay'
: diff < 2
? 'nextDay'
: diff < 7
? 'nextWeek'
: 'sameElse';
}
function calendar$1(time, formats) {
@@ -4546,16 +4609,22 @@
mixedPieces = [],
i,
l,
erasName,
erasAbbr,
erasNarrow,
eras = this.eras();
for (i = 0, l = eras.length; i < l; ++i) {
namePieces.push(regexEscape(eras[i].name));
abbrPieces.push(regexEscape(eras[i].abbr));
narrowPieces.push(regexEscape(eras[i].narrow));
erasName = regexEscape(eras[i].name);
erasAbbr = regexEscape(eras[i].abbr);
erasNarrow = regexEscape(eras[i].narrow);
mixedPieces.push(regexEscape(eras[i].name));
mixedPieces.push(regexEscape(eras[i].abbr));
mixedPieces.push(regexEscape(eras[i].narrow));
namePieces.push(erasName);
abbrPieces.push(erasAbbr);
narrowPieces.push(erasNarrow);
mixedPieces.push(erasName);
mixedPieces.push(erasAbbr);
mixedPieces.push(erasNarrow);
}
this._erasRegex = new RegExp('^(' + mixedPieces.join('|') + ')', 'i');
@@ -4588,14 +4657,6 @@
// ALIASES
addUnitAlias('weekYear', 'gg');
addUnitAlias('isoWeekYear', 'GG');
// PRIORITY
addUnitPriority('weekYear', 1);
addUnitPriority('isoWeekYear', 1);
// PARSING
addRegexToken('G', matchSigned);
@@ -4625,7 +4686,7 @@
this,
input,
this.week(),
this.weekday(),
this.weekday() + this.localeData()._week.dow,
this.localeData()._week.dow,
this.localeData()._week.doy
);
@@ -4687,14 +4748,6 @@
addFormatToken('Q', 0, 'Qo', 'quarter');
// ALIASES
addUnitAlias('quarter', 'Q');
// PRIORITY
addUnitPriority('quarter', 7);
// PARSING
addRegexToken('Q', match1);
@@ -4714,16 +4767,9 @@
addFormatToken('D', ['DD', 2], 'Do', 'date');
// ALIASES
addUnitAlias('date', 'D');
// PRIORITY
addUnitPriority('date', 9);
// PARSING
addRegexToken('D', match1to2);
addRegexToken('D', match1to2, match1to2NoLeadingZero);
addRegexToken('DD', match1to2, match2);
addRegexToken('Do', function (isStrict, locale) {
// TODO: Remove "ordinalParse" fallback in next major release.
@@ -4745,13 +4791,6 @@
addFormatToken('DDD', ['DDDD', 3], 'DDDo', 'dayOfYear');
// ALIASES
addUnitAlias('dayOfYear', 'DDD');
// PRIORITY
addUnitPriority('dayOfYear', 4);
// PARSING
addRegexToken('DDD', match1to3);
@@ -4776,17 +4815,9 @@
addFormatToken('m', ['mm', 2], 0, 'minute');
// ALIASES
addUnitAlias('minute', 'm');
// PRIORITY
addUnitPriority('minute', 14);
// PARSING
addRegexToken('m', match1to2);
addRegexToken('m', match1to2, match1to2HasZero);
addRegexToken('mm', match1to2, match2);
addParseToken(['m', 'mm'], MINUTE);
@@ -4798,17 +4829,9 @@
addFormatToken('s', ['ss', 2], 0, 'second');
// ALIASES
addUnitAlias('second', 's');
// PRIORITY
addUnitPriority('second', 15);
// PARSING
addRegexToken('s', match1to2);
addRegexToken('s', match1to2, match1to2HasZero);
addRegexToken('ss', match1to2, match2);
addParseToken(['s', 'ss'], SECOND);
@@ -4846,14 +4869,6 @@
return this.millisecond() * 1000000;
});
// ALIASES
addUnitAlias('millisecond', 'ms');
// PRIORITY
addUnitPriority('millisecond', 16);
// PARSING
addRegexToken('S', match1to3, match1);
@@ -5161,12 +5176,12 @@
toInt((number % 100) / 10) === 1
? 'th'
: b === 1
? 'st'
: b === 2
? 'nd'
: b === 3
? 'rd'
: 'th';
? 'st'
: b === 2
? 'nd'
: b === 3
? 'rd'
: 'th';
return number + output;
},
});
@@ -5339,19 +5354,6 @@
}
}
// TODO: Use this.as('ms')?
function valueOf$1() {
if (!this.isValid()) {
return NaN;
}
return (
this._milliseconds +
this._days * 864e5 +
(this._months % 12) * 2592e6 +
toInt(this._months / 12) * 31536e6
);
}
function makeAs(alias) {
return function () {
return this.as(alias);
@@ -5366,7 +5368,8 @@
asWeeks = makeAs('w'),
asMonths = makeAs('M'),
asQuarters = makeAs('Q'),
asYears = makeAs('y');
asYears = makeAs('y'),
valueOf$1 = asMilliseconds;
function clone$1() {
return createDuration(this);
@@ -5635,7 +5638,7 @@
//! moment.js
hooks.version = '2.29.4';
hooks.version = '2.30.1';
setHookCallback(createLocal);
+100 -56
View File
@@ -4,18 +4,26 @@ import time
import traceback
import uuid
from http import HTTPStatus
from typing import Dict, List, Optional
from typing import (
Callable,
Coroutine,
Dict,
List,
Optional,
)
from loguru import logger
from py_vapid import Vapid
from pywebpush import WebPushException, webpush
from lnbits.core.crud import (
delete_expired_invoices,
delete_webpush_subscriptions,
get_payments,
get_standalone_payment,
update_payment_details,
update_payment_status,
)
from lnbits.core.models import Payment, PaymentState
from lnbits.settings import settings
from lnbits.wallets import get_funding_source
@@ -23,17 +31,13 @@ tasks: List[asyncio.Task] = []
unique_tasks: Dict[str, asyncio.Task] = {}
def create_task(coro):
def create_task(coro: Coroutine) -> asyncio.Task:
task = asyncio.create_task(coro)
tasks.append(task)
return task
def create_permanent_task(func):
return create_task(catch_everything_and_restart(func))
def create_unique_task(name: str, coro):
def create_unique_task(name: str, coro: Coroutine) -> asyncio.Task:
if unique_tasks.get(name):
logger.warning(f"task `{name}` already exists, cancelling it")
try:
@@ -45,11 +49,17 @@ def create_unique_task(name: str, coro):
return task
def create_permanent_unique_task(name: str, coro):
def create_permanent_task(func: Callable[[], Coroutine]) -> asyncio.Task:
return create_task(catch_everything_and_restart(func))
def create_permanent_unique_task(
name: str, coro: Callable[[], Coroutine]
) -> asyncio.Task:
return create_unique_task(name, catch_everything_and_restart(coro, name))
def cancel_all_tasks():
def cancel_all_tasks() -> None:
for task in tasks:
try:
task.cancel()
@@ -62,9 +72,12 @@ def cancel_all_tasks():
logger.warning(f"error while cancelling task `{name}`: {exc!s}")
async def catch_everything_and_restart(func, name: str = "unnamed"):
async def catch_everything_and_restart(
func: Callable[[], Coroutine],
name: str = "unnamed",
) -> Coroutine:
try:
await func()
return await func()
except asyncio.CancelledError:
raise # because we must pass this up
except Exception as exc:
@@ -72,7 +85,7 @@ async def catch_everything_and_restart(func, name: str = "unnamed"):
logger.error(traceback.format_exc())
logger.error("will restart the task in 5 seconds.")
await asyncio.sleep(5)
await catch_everything_and_restart(func, name)
return catch_everything_and_restart(func, name)
invoice_listeners: Dict[str, asyncio.Queue] = {}
@@ -99,7 +112,7 @@ def register_invoice_listener(send_chan: asyncio.Queue, name: Optional[str] = No
internal_invoice_queue: asyncio.Queue = asyncio.Queue(0)
async def internal_invoice_listener():
async def internal_invoice_listener() -> None:
"""
internal_invoice_queue will be filled directly in core/services.py
after the payment was deemed to be settled internally.
@@ -108,11 +121,11 @@ async def internal_invoice_listener():
"""
while settings.lnbits_running:
checking_id = await internal_invoice_queue.get()
logger.info("> got internal payment notification", checking_id)
create_task(invoice_callback_dispatcher(checking_id))
logger.info(f"got an internal payment notification {checking_id}")
await invoice_callback_dispatcher(checking_id, is_internal=True)
async def invoice_listener():
async def invoice_listener() -> None:
"""
invoice_listener will collect all invoices that come directly
from the backend wallet.
@@ -121,8 +134,23 @@ async def invoice_listener():
"""
funding_source = get_funding_source()
async for checking_id in funding_source.paid_invoices_stream():
logger.info("> got a payment notification", checking_id)
create_task(invoice_callback_dispatcher(checking_id))
logger.info(f"got a payment notification {checking_id}")
await invoice_callback_dispatcher(checking_id)
def wait_for_paid_invoices(
invoice_listener_name: str,
func: Callable[[Payment], Coroutine],
) -> Callable[[], Coroutine]:
async def wrapper() -> None:
invoice_queue: asyncio.Queue = asyncio.Queue()
register_invoice_listener(invoice_queue, invoice_listener_name)
while settings.lnbits_running:
payment = await invoice_queue.get()
await func(payment)
return wrapper
async def check_pending_payments():
@@ -131,59 +159,68 @@ async def check_pending_payments():
the backend and also to delete expired invoices. Incoming payments will be
checked only once, outgoing pending payments will be checked regularly.
"""
outgoing = True
incoming = True
sleep_time = 60 * 30 # 30 minutes
while settings.lnbits_running:
logger.info(
f"Task: checking all pending payments (incoming={incoming},"
f" outgoing={outgoing}) of last 15 days"
)
funding_source = get_funding_source()
if funding_source.__class__.__name__ == "VoidWallet":
logger.warning("Task: skipping pending check for VoidWallet")
await asyncio.sleep(sleep_time)
continue
start_time = time.time()
pending_payments = await get_payments(
since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago
complete=False,
pending=True,
outgoing=outgoing,
incoming=incoming,
exclude_uncheckable=True,
)
for payment in pending_payments:
await payment.check_status()
await asyncio.sleep(0.01) # to avoid complete blocking
logger.info(
f"Task: pending check finished for {len(pending_payments)} payments"
f" (took {time.time() - start_time:0.3f} s)"
)
# we delete expired invoices once upon the first pending check
if incoming:
logger.debug("Task: deleting all expired invoices")
start_time = time.time()
await delete_expired_invoices()
count = len(pending_payments)
if count > 0:
logger.info(f"Task: checking {count} pending payments of last 15 days...")
for i, payment in enumerate(pending_payments):
status = await payment.check_status()
prefix = f"payment ({i+1} / {count})"
if status.failed:
await update_payment_status(
payment.checking_id, status=PaymentState.FAILED
)
logger.debug(f"{prefix} failed {payment.checking_id}")
elif status.success:
await update_payment_details(
checking_id=payment.checking_id,
fee=status.fee_msat,
preimage=status.preimage,
status=PaymentState.SUCCESS,
)
logger.debug(f"{prefix} success {payment.checking_id}")
else:
logger.debug(f"{prefix} pending {payment.checking_id}")
await asyncio.sleep(0.01) # to avoid complete blocking
logger.info(
"Task: expired invoice deletion finished (took"
f" {time.time() - start_time:0.3f} s)"
f"Task: pending check finished for {count} payments"
f" (took {time.time() - start_time:0.3f} s)"
)
# after the first check we will only check outgoing, not incoming
# that will be handled by the global invoice listeners, hopefully
incoming = False
await asyncio.sleep(60 * 30) # every 30 minutes
await asyncio.sleep(sleep_time)
async def invoice_callback_dispatcher(checking_id: str):
async def invoice_callback_dispatcher(checking_id: str, is_internal: bool = False):
"""
Takes incoming payments, sets pending=False, and dispatches them to
Takes an incoming payment, checks its status, and dispatches it to
invoice_listeners from core and extensions.
"""
payment = await get_standalone_payment(checking_id, incoming=True)
if payment and payment.is_in:
logger.trace(
f"invoice listeners: sending invoice callback for payment {checking_id}"
status = await payment.check_status()
await update_payment_details(
checking_id=payment.checking_id,
fee=status.fee_msat,
preimage=status.preimage,
status=PaymentState.SUCCESS,
)
await payment.set_pending(False)
payment = await get_standalone_payment(checking_id, incoming=True)
assert payment, "updated payment not found"
internal = "internal" if is_internal else ""
logger.success(f"{internal} invoice {checking_id} settled")
for name, send_chan in invoice_listeners.items():
logger.trace(f"invoice listeners: sending to `{name}`")
await send_chan.put(payment)
@@ -196,12 +233,19 @@ async def send_push_notification(subscription, title, body, url=""):
webpush(
json.loads(subscription.data),
json.dumps({"title": title, "body": body, "url": url}),
vapid.from_pem(bytes(settings.lnbits_webpush_privkey, "utf-8")),
(
vapid.from_pem(bytes(settings.lnbits_webpush_privkey, "utf-8"))
if settings.lnbits_webpush_privkey
else None
),
{"aud": "", "sub": "mailto:alan@lnbits.com"},
)
except WebPushException as e:
if e.response.status_code == HTTPStatus.GONE:
if e.response and e.response.status_code == HTTPStatus.GONE:
# cleanup unsubscribed or expired push subscriptions
await delete_webpush_subscriptions(subscription.endpoint)
else:
logger.error(f"failed sending push notification: {e.response.text}")
logger.error(
f"failed sending push notification: "
f"{e.response.text if e.response else e}"
)
+25 -2
View File
@@ -191,6 +191,7 @@ class Provider(NamedTuple):
domain: str
api_url: str
getter: Callable
exclude_to: list = []
exchange_rate_providers = {
@@ -200,30 +201,34 @@ exchange_rate_providers = {
"binance.com",
"https://api.binance.com/api/v3/ticker/price?symbol={FROM}{TO}",
lambda data, replacements: data["price"],
["czk"],
),
"blockchain": Provider(
"Blockchain",
"blockchain.com",
"https://blockchain.info/tobtc?currency={TO}&value=1",
lambda data, replacements: 1 / data,
"https://blockchain.info/tobtc?currency={TO}&value=1000000",
lambda data, replacements: 1000000 / data,
),
"exir": Provider(
"Exir",
"exir.io",
"https://api.exir.io/v1/ticker?symbol={from}-{to}",
lambda data, replacements: data["last"],
["czk", "eur"],
),
"bitfinex": Provider(
"Bitfinex",
"bitfinex.com",
"https://api.bitfinex.com/v1/pubticker/{from}{to}",
lambda data, replacements: data["last_price"],
["czk"],
),
"bitstamp": Provider(
"Bitstamp",
"bitstamp.net",
"https://www.bitstamp.net/api/v2/ticker/{from}{to}/",
lambda data, replacements: data["last"],
["czk"],
),
"coinbase": Provider(
"Coinbase",
@@ -242,6 +247,21 @@ exchange_rate_providers = {
"kraken.com",
"https://api.kraken.com/0/public/Ticker?pair=XBT{TO}",
lambda data, replacements: data["result"]["XXBTZ" + replacements["TO"]]["c"][0],
["czk"],
),
"bitpay": Provider(
"BitPay",
"bitpay.com",
"https://bitpay.com/rates",
lambda data, replacements: next(
i["rate"] for i in data["data"] if i["code"] == replacements["TO"]
),
),
"yadio": Provider(
"yadio",
"yadio.io",
"https://api.yadio.io/exrates/{FROM}",
lambda data, replacements: data[replacements["FROM"]][replacements["TO"]],
),
}
@@ -255,6 +275,9 @@ async def btc_price(currency: str) -> float:
}
async def fetch_price(provider: Provider):
if currency.lower() in provider.exclude_to:
raise Exception(f"Provider {provider.name} does not support {currency}.")
url = provider.api_url.format(**replacements)
try:
headers = {"User-Agent": settings.user_agent}
+29
View File
@@ -8,6 +8,9 @@ from lnbits.settings import settings
from lnbits.wallets.base import Wallet
from .alby import AlbyWallet
from .blink import BlinkWallet
from .boltz import BoltzWallet
from .breez import BreezSdkWallet
from .cliche import ClicheWallet
from .corelightning import CoreLightningWallet
@@ -22,6 +25,7 @@ from .lndgrpc import LndWallet
from .lndrest import LndRestWallet
from .lnpay import LNPayWallet
from .lntips import LnTipsWallet
from .nwc import NWCWallet
from .opennode import OpenNodeWallet
from .phoenixd import PhoenixdWallet
from .spark import SparkWallet
@@ -47,3 +51,28 @@ fake_wallet = FakeWallet()
# initialize as fake wallet
funding_source: Wallet = fake_wallet
__all__ = [
"AlbyWallet",
"BlinkWallet",
"BoltzWallet",
"BreezSdkWallet",
"ClicheWallet",
"CoreLightningWallet",
"CLightningWallet",
"CoreLightningRestWallet",
"EclairWallet",
"FakeWallet",
"LNbitsWallet",
"LndWallet",
"LndRestWallet",
"LNPayWallet",
"LnTipsWallet",
"NWCWallet",
"OpenNodeWallet",
"PhoenixdWallet",
"SparkWallet",
"VoidWallet",
"ZBDWallet",
]
+2
View File
@@ -137,6 +137,8 @@ class Wallet(ABC):
def normalize_endpoint(self, endpoint: str, add_proto=True) -> str:
endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
if add_proto:
if endpoint.startswith("ws://") or endpoint.startswith("wss://"):
return endpoint
endpoint = (
f"https://{endpoint}" if not endpoint.startswith("http") else endpoint
)
+483
View File
@@ -0,0 +1,483 @@
import asyncio
import hashlib
import json
from typing import AsyncGenerator, Optional
import httpx
from loguru import logger
from pydantic import BaseModel
from websockets.client import WebSocketClientProtocol, connect
from websockets.typing import Subprotocol
from lnbits import bolt11
from lnbits.settings import settings
from .base import (
InvoiceResponse,
PaymentResponse,
PaymentStatus,
StatusResponse,
Wallet,
)
class BlinkWallet(Wallet):
"""https://dev.blink.sv/"""
def __init__(self):
if not settings.blink_api_endpoint:
raise ValueError(
"cannot initialize BlinkWallet: missing blink_api_endpoint"
)
if not settings.blink_ws_endpoint:
raise ValueError("cannot initialize BlinkWallet: missing blink_ws_endpoint")
if not settings.blink_token:
raise ValueError("cannot initialize BlinkWallet: missing blink_token")
self.endpoint = self.normalize_endpoint(settings.blink_api_endpoint)
self.auth = {
"X-API-KEY": settings.blink_token,
"User-Agent": settings.user_agent,
}
self.ws_endpoint = self.normalize_endpoint(settings.blink_ws_endpoint)
self.ws_auth = {
"type": "connection_init",
"payload": {"X-API-KEY": settings.blink_token},
}
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.auth)
self.ws: Optional[WebSocketClientProtocol] = None
self._wallet_id = None
@property
def wallet_id(self):
if self._wallet_id:
return self._wallet_id
raise ValueError("Wallet id not initialized.")
async def cleanup(self):
try:
await self.client.aclose()
except RuntimeError as e:
logger.warning(f"Error closing wallet connection: {e}")
try:
if self.ws:
await self.ws.close(reason="Shutting down.")
except RuntimeError as e:
logger.warning(f"Error closing websocket connection: {e}")
async def status(self) -> StatusResponse:
try:
await self._init_wallet_id()
payload = {"query": q.balance_query, "variables": {}}
response = await self._graphql_query(payload)
wallets = (
response.get("data", {})
.get("me", {})
.get("defaultAccount", {})
.get("wallets", [])
)
btc_balance = next(
(
wallet["balance"]
for wallet in wallets
if wallet["walletCurrency"] == "BTC"
),
None,
)
if btc_balance is None:
return StatusResponse("No BTC balance", 0)
# multiply balance by 1000 to get msats balance
return StatusResponse(None, btc_balance * 1000)
except ValueError as exc:
return StatusResponse(str(exc), 0)
except Exception as exc:
logger.warning(exc)
return StatusResponse(f"Unable to connect, got: '{exc}'", 0)
async def create_invoice(
self,
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse:
# https://dev.blink.sv/api/btc-ln-receive
invoice_variables = {
"input": {
"amount": amount,
"recipientWalletId": self.wallet_id,
}
}
if description_hash:
invoice_variables["input"]["descriptionHash"] = description_hash.hex()
elif unhashed_description:
invoice_variables["input"]["descriptionHash"] = hashlib.sha256(
unhashed_description
).hexdigest()
else:
invoice_variables["input"]["memo"] = memo or ""
data = {"query": q.invoice_query, "variables": invoice_variables}
try:
response = await self._graphql_query(data)
errors = (
response.get("data", {})
.get("lnInvoiceCreateOnBehalfOfRecipient", {})
.get("errors", {})
)
if len(errors) > 0:
error_message = errors[0].get("message")
return InvoiceResponse(False, None, None, error_message)
payment_request = (
response.get("data", {})
.get("lnInvoiceCreateOnBehalfOfRecipient", {})
.get("invoice", {})
.get("paymentRequest", None)
)
checking_id = (
response.get("data", {})
.get("lnInvoiceCreateOnBehalfOfRecipient", {})
.get("invoice", {})
.get("paymentHash", None)
)
return InvoiceResponse(True, checking_id, payment_request, None)
except json.JSONDecodeError:
return InvoiceResponse(
False, None, None, "Server error: 'invalid json response'"
)
except Exception as exc:
logger.warning(exc)
return InvoiceResponse(
False, None, None, f"Unable to connect to {self.endpoint}."
)
async def pay_invoice(
self, bolt11_invoice: str, fee_limit_msat: int
) -> PaymentResponse:
# https://dev.blink.sv/api/btc-ln-send
# Future: add check fee estimate is < fee_limit_msat before paying invoice
payment_variables = {
"input": {
"paymentRequest": bolt11_invoice,
"walletId": self.wallet_id,
"memo": "Payment memo",
}
}
data = {"query": q.payment_query, "variables": payment_variables}
try:
response = await self._graphql_query(data)
errors = (
response.get("data", {})
.get("lnInvoicePaymentSend", {})
.get("errors", {})
)
if len(errors) > 0:
error_message = errors[0].get("message")
return PaymentResponse(False, None, None, None, error_message)
checking_id = bolt11.decode(bolt11_invoice).payment_hash
payment_status = await self.get_payment_status(checking_id)
fee_msat = payment_status.fee_msat
preimage = payment_status.preimage
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
except Exception as exc:
logger.info(f"Failed to pay invoice {bolt11_invoice}")
logger.warning(exc)
return PaymentResponse(
None, None, None, None, f"Unable to connect to {self.endpoint}."
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
statuses = {
"EXPIRED": False,
"PENDING": None,
"PAID": True,
}
variables = {"paymentHash": checking_id, "walletId": self.wallet_id}
data = {"query": q.status_query, "variables": variables}
try:
response = await self._graphql_query(data)
if response.get("errors") is not None:
logger.trace(response.get("errors"))
return PaymentStatus(None)
status = response["data"]["me"]["defaultAccount"]["walletById"][
"invoiceByPaymentHash"
]["paymentStatus"]
return PaymentStatus(statuses[status])
except Exception as e:
logger.warning(f"Error getting invoice status: {e}")
return PaymentStatus(None)
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
variables = {
"walletId": self.wallet_id,
"transactionsByPaymentHash": checking_id,
}
data = {"query": q.tx_query, "variables": variables}
statuses = {
"FAILURE": False,
"EXPIRED": False,
"PENDING": None,
"PAID": True,
"SUCCESS": True,
}
try:
response = await self._graphql_query(data)
response_data = response.get("data")
assert response_data is not None
txs_data = (
response_data.get("me", {})
.get("defaultAccount", {})
.get("walletById", {})
.get("transactionsByPaymentHash", [])
)
tx_data = next((t for t in txs_data if t.get("direction") == "SEND"), None)
assert tx_data, "No SEND data found."
fee = tx_data.get("settlementFee")
preimage = tx_data.get("settlementVia", {}).get("preImage")
status = tx_data.get("status")
return PaymentStatus(
paid=statuses[status], fee_msat=fee * 1000, preimage=preimage
)
except Exception as e:
logger.error(f"Error getting payment status: {e}")
return PaymentStatus(None)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
subscription_id = "blink_payment_stream"
while settings.lnbits_running:
try:
async with connect(
self.ws_endpoint, subprotocols=[Subprotocol("graphql-transport-ws")]
) as ws:
logger.info("Connected to blink invoices stream.")
self.ws = ws
await ws.send(json.dumps(self.ws_auth))
confirmation = await ws.recv()
ack = json.loads(confirmation)
assert (
ack.get("type") == "connection_ack"
), "Websocket connection not acknowledged."
logger.info("Websocket connection acknowledged.")
subscription_req = {
"id": subscription_id,
"type": "subscribe",
"payload": {"query": q.my_updates_query, "variables": {}},
}
await ws.send(json.dumps(subscription_req))
while settings.lnbits_running:
message = await ws.recv()
resp = json.loads(message)
if resp.get("id") != subscription_id:
continue
tx = (
resp.get("payload", {})
.get("data", {})
.get("myUpdates", {})
.get("update", {})
.get("transaction", {})
)
if tx.get("direction") != "RECEIVE":
continue
if not tx.get("initiationVia"):
continue
payment_hash = tx.get("initiationVia").get("paymentHash")
if payment_hash:
yield payment_hash
except Exception as exc:
logger.error(
f"lost connection to blink invoices stream: '{exc}'"
"retrying in 5 seconds"
)
await asyncio.sleep(5)
async def _graphql_query(self, payload) -> dict:
response = await self.client.post(self.endpoint, json=payload, timeout=10)
response.raise_for_status()
return response.json()
async def _init_wallet_id(self) -> str:
"""
Get the defaultAccount wallet id, required for payments.
"""
if self._wallet_id:
return self._wallet_id
try:
payload = {
"query": q.wallet_query,
"variables": {},
}
response = await self._graphql_query(payload)
wallets = (
response.get("data", {})
.get("me", {})
.get("defaultAccount", {})
.get("wallets", [])
)
btc_wallet_ids = [
wallet["id"] for wallet in wallets if wallet["walletCurrency"] == "BTC"
]
if not btc_wallet_ids:
raise ValueError("BTC Wallet not found")
self._wallet_id = btc_wallet_ids[0]
return self._wallet_id
except Exception as exc:
logger.warning(exc)
raise ValueError(f"Unable to connect to '{self.endpoint}'") from exc
class BlinkGrafqlQueries(BaseModel):
balance_query: str
invoice_query: str
payment_query: str
status_query: str
wallet_query: str
tx_query: str
my_updates_query: str
q = BlinkGrafqlQueries(
balance_query="""
query Me {
me {
defaultAccount {
wallets {
walletCurrency
balance
}
}
}
}
""",
invoice_query="""
mutation LnInvoiceCreateOnBehalfOfRecipient(
$input: LnInvoiceCreateOnBehalfOfRecipientInput!
) {
lnInvoiceCreateOnBehalfOfRecipient(input: $input) {
invoice {
paymentRequest
paymentHash
paymentSecret
satoshis
}
errors {
message
}
}
}
""",
payment_query="""
mutation LnInvoicePaymentSend($input: LnInvoicePaymentInput!) {
lnInvoicePaymentSend(input: $input) {
status
errors {
message
path
code
}
}
}
""",
status_query="""
query InvoiceByPaymentHash($walletId: WalletId!, $paymentHash: PaymentHash!) {
me {
defaultAccount {
walletById(walletId: $walletId) {
invoiceByPaymentHash(paymentHash: $paymentHash) {
... on LnInvoice {
paymentStatus
}
}
}
}
}
}
""",
wallet_query="""
query me {
me {
defaultAccount {
wallets {
id
walletCurrency
}
}
}
}
""",
tx_query="""
query TransactionsByPaymentHash(
$walletId: WalletId!
$transactionsByPaymentHash: PaymentHash!
) {
me {
defaultAccount {
walletById(walletId: $walletId) {
walletCurrency
... on BTCWallet {
transactionsByPaymentHash(paymentHash: $transactionsByPaymentHash) {
settlementFee
status
direction
settlementVia {
... on SettlementViaLn {
preImage
}
}
}
}
}
}
}
}
""",
my_updates_query="""
subscription {
myUpdates {
update {
... on LnUpdate {
transaction {
initiationVia {
... on InitiationViaLn {
paymentHash
}
}
direction
}
}
}
}
}
""",
)
+214
View File
@@ -0,0 +1,214 @@
import asyncio
from typing import AsyncGenerator, Optional
from bolt11.decode import decode
from grpc.aio import AioRpcError
from loguru import logger
from lnbits.settings import settings
from lnbits.wallets.boltz_grpc_files import boltzrpc_pb2, boltzrpc_pb2_grpc
from lnbits.wallets.lnd_grpc_files.lightning_pb2_grpc import grpc
from lnbits.wallets.macaroon.macaroon import load_macaroon
from .base import (
InvoiceResponse,
PaymentFailedStatus,
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
PaymentSuccessStatus,
StatusResponse,
Wallet,
)
class BoltzWallet(Wallet):
"""
Utilizing Boltz Client gRPC interface
gRPC Bindings can be updated by running lnbits/wallets/boltz_grpc_files/update.sh
"""
async def cleanup(self):
logger.warning("Cleaning up BoltzWallet...")
def __init__(self):
if not settings.boltz_client_endpoint:
raise ValueError(
"cannot initialize BoltzWallet: missing boltz_client_endpoint"
)
if not settings.boltz_client_wallet:
raise ValueError(
"cannot initialize BoltzWallet: missing boltz_client_wallet"
)
self.endpoint = self.normalize_endpoint(
settings.boltz_client_endpoint, add_proto=True
)
if settings.boltz_client_macaroon:
self.metadata = [
("macaroon", load_macaroon(settings.boltz_client_macaroon))
]
else:
self.metadata = None
if settings.boltz_client_cert:
cert = open(settings.boltz_client_cert, "rb").read()
creds = grpc.ssl_channel_credentials(cert)
channel = grpc.aio.secure_channel(settings.boltz_client_endpoint, creds)
else:
channel = grpc.aio.insecure_channel(settings.boltz_client_endpoint)
self.rpc = boltzrpc_pb2_grpc.BoltzStub(channel)
self.wallet_id: int = 0
async def status(self) -> StatusResponse:
try:
request = boltzrpc_pb2.GetWalletRequest(name=settings.boltz_client_wallet)
response: boltzrpc_pb2.Wallet = await self.rpc.GetWallet(
request, metadata=self.metadata
)
except AioRpcError as exc:
return StatusResponse(
exc.details()
+ " make sure you have macaroon and certificate configured, unless your client runs without", # noqa: E501
0,
)
self.wallet_id = response.id
return StatusResponse(None, response.balance.total * 1000)
async def create_invoice(
self,
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
**_,
) -> InvoiceResponse:
pair = boltzrpc_pb2.Pair(to=boltzrpc_pb2.LBTC)
request = boltzrpc_pb2.CreateReverseSwapRequest(
amount=amount,
pair=pair,
wallet_id=self.wallet_id,
accept_zero_conf=True,
external_pay=True,
description=memo,
)
response: boltzrpc_pb2.CreateReverseSwapResponse
try:
response = await self.rpc.CreateReverseSwap(request, metadata=self.metadata)
except AioRpcError as exc:
return InvoiceResponse(ok=False, error_message=exc.details())
return InvoiceResponse(
ok=True, checking_id=response.id, payment_request=response.invoice
)
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
pair = boltzrpc_pb2.Pair(**{"from": boltzrpc_pb2.LBTC})
try:
pair_info: boltzrpc_pb2.PairInfo
pair_request = boltzrpc_pb2.GetPairInfoRequest(
type=boltzrpc_pb2.SUBMARINE, pair=pair
)
pair_info = await self.rpc.GetPairInfo(pair_request, metadata=self.metadata)
invoice = decode(bolt11)
assert invoice.amount_msat, "amountless invoice"
service_fee: float = invoice.amount_msat * pair_info.fees.percentage / 100
estimate = service_fee + pair_info.fees.miner_fees * 1000
if estimate > fee_limit_msat:
error = f"fee of {estimate} msat exceeds limit of {fee_limit_msat} msat"
return PaymentResponse(ok=False, error_message=error)
request = boltzrpc_pb2.CreateSwapRequest(
invoice=bolt11,
pair=pair,
wallet_id=self.wallet_id,
zero_conf=True,
send_from_internal=True,
)
response: boltzrpc_pb2.CreateSwapResponse
response = await self.rpc.CreateSwap(request, metadata=self.metadata)
except AioRpcError as exc:
return PaymentResponse(ok=False, error_message=exc.details())
try:
info_request = boltzrpc_pb2.GetSwapInfoRequest(id=response.id)
info: boltzrpc_pb2.GetSwapInfoResponse
async for info in self.rpc.GetSwapInfoStream(
info_request, metadata=self.metadata
):
if info.swap.state == boltzrpc_pb2.SUCCESSFUL:
return PaymentResponse(
ok=True,
checking_id=response.id,
fee_msat=(info.swap.onchain_fee + info.swap.service_fee) * 1000,
preimage=info.swap.preimage,
)
elif info.swap.error != "":
return PaymentResponse(ok=False, error_message=info.swap.error)
return PaymentResponse(
ok=False, error_message="stream stopped unexpectedly"
)
except AioRpcError as exc:
return PaymentResponse(ok=False, error_message=exc.details())
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try:
response: boltzrpc_pb2.GetSwapInfoResponse = await self.rpc.GetSwapInfo(
boltzrpc_pb2.GetSwapInfoRequest(id=checking_id), metadata=self.metadata
)
swap = response.reverse_swap
except AioRpcError:
return PaymentPendingStatus()
if swap.state == boltzrpc_pb2.SwapState.SUCCESSFUL:
return PaymentSuccessStatus(
fee_msat=(
(swap.service_fee + swap.onchain_fee) * 1000 + swap.routing_fee_msat
),
preimage=swap.preimage,
)
elif swap.state == boltzrpc_pb2.SwapState.PENDING:
return PaymentPendingStatus()
return PaymentFailedStatus()
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
try:
response: boltzrpc_pb2.GetSwapInfoResponse = await self.rpc.GetSwapInfo(
boltzrpc_pb2.GetSwapInfoRequest(id=checking_id), metadata=self.metadata
)
swap = response.swap
except AioRpcError:
return PaymentPendingStatus()
if swap.state == boltzrpc_pb2.SwapState.SUCCESSFUL:
return PaymentSuccessStatus(
fee_msat=(swap.service_fee + swap.onchain_fee) * 1000,
preimage=swap.preimage,
)
elif swap.state == boltzrpc_pb2.SwapState.PENDING:
return PaymentPendingStatus()
return PaymentFailedStatus()
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while True:
try:
request = boltzrpc_pb2.GetSwapInfoRequest()
info: boltzrpc_pb2.GetSwapInfoResponse
async for info in self.rpc.GetSwapInfoStream(
request, metadata=self.metadata
):
reverse = info.reverse_swap
if reverse and reverse.state == boltzrpc_pb2.SUCCESSFUL:
yield reverse.id
except Exception as exc:
logger.error(
f"lost connection to boltz client swap stream: '{exc}', retrying in"
" 5 seconds"
)
await asyncio.sleep(5)
@@ -0,0 +1,742 @@
syntax = "proto3";
package boltzrpc;
option go_package = "github.com/BoltzExchange/boltz-client/boltzrpc";
import "google/protobuf/empty.proto";
service Boltz {
/*
Gets general information about the daemon like the chain of the lightning node it is connected to
and the IDs of pending swaps.
*/
rpc GetInfo (GetInfoRequest) returns (GetInfoResponse);
/*
Fetches the latest limits and fees from the Boltz backend API it is connected to.
*/
rpc GetServiceInfo (GetServiceInfoRequest) returns (GetServiceInfoResponse) {
option deprecated = true;
};
/*
Fetches information about a specific pair for a chain swap.
*/
rpc GetPairInfo (GetPairInfoRequest) returns (PairInfo);
/*
Fetches all available pairs for submarine and reverse swaps.
*/
rpc GetPairs (google.protobuf.Empty) returns (GetPairsResponse);
/*
Returns a list of all swaps, reverse swaps, and chain swaps in the database.
*/
rpc ListSwaps (ListSwapsRequest) returns (ListSwapsResponse);
/*
Returns stats of all swaps, reverse swaps, and chain swaps in the database.
*/
rpc GetStats (GetStatsRequest) returns (GetStatsResponse);
/*
Refund a failed swap manually.
This is only required when no refund address has been set and the swap does not have an associated wallet.
*/
rpc RefundSwap (RefundSwapRequest) returns (GetSwapInfoResponse);
/*
Claim swaps manually.
This is only required when no claim address has been set and the swap does not have an associated wallet.
*/
rpc ClaimSwaps (ClaimSwapsRequest) returns (ClaimSwapsResponse);
/*
Gets all available information about a swap from the database.
*/
rpc GetSwapInfo (GetSwapInfoRequest) returns (GetSwapInfoResponse);
/*
Returns the entire history of the swap if is still pending and streams updates in real time.
If the swap id is empty or "*" updates for all swaps will be streamed.
*/
rpc GetSwapInfoStream (GetSwapInfoRequest) returns (stream GetSwapInfoResponse);
/*
This is a wrapper for channel creation swaps. The daemon only returns the ID, timeout block height and lockup address.
The Boltz backend takes care of the rest. When an amount of onchain coins that is in the limits is sent to the address
before the timeout block height, the daemon creates a new lightning invoice, sends it to the Boltz backend which
will try to pay it and if that is not possible, create a new channel to make the swap succeed.
*/
rpc Deposit (DepositRequest) returns (DepositResponse) { option deprecated = true; }
/*
Creates a new swap from onchain to lightning.
*/
rpc CreateSwap (CreateSwapRequest) returns (CreateSwapResponse);
/*
Create a new swap from onchain to a new lightning channel. The daemon will only accept the invoice payment if the HTLCs
is coming trough a new channel channel opened by Boltz.
*/
rpc CreateChannel (CreateChannelRequest) returns (CreateSwapResponse) { option deprecated = true; };
/*
Creates a new reverse swap from lightning to onchain. If `accept_zero_conf` is set to true in the request, the daemon
will not wait until the lockup transaction from Boltz is confirmed in a block, but will claim it instantly.
*/
rpc CreateReverseSwap (CreateReverseSwapRequest) returns (CreateReverseSwapResponse);
/*
Creates a new chain swap from one chain to another. If `accept_zero_conf` is set to true in the request, the daemon
will not wait until the lockup transaction from Boltz is confirmed in a block, but will claim it instantly.
*/
rpc CreateChainSwap (CreateChainSwapRequest) returns (ChainSwapInfo);
/*
Creates a new liquid wallet and returns the mnemonic.
*/
rpc CreateWallet (CreateWalletRequest) returns (CreateWalletResponse);
/*
Imports an existing wallet.
*/
rpc ImportWallet (ImportWalletRequest) returns (Wallet);
/*
Sets the subaccount of a wallet. Not supported for readonly wallets.
*/
rpc SetSubaccount (SetSubaccountRequest) returns (Subaccount);
/*
Returns all subaccounts for a given wallet. Not supported for readonly wallets.
*/
rpc GetSubaccounts (GetSubaccountsRequest) returns (GetSubaccountsResponse);
/*
Returns all available wallets.
*/
rpc GetWallets (GetWalletsRequest) returns (Wallets);
/*
Returns the current balance and subaccount of a wallet.
*/
rpc GetWallet (GetWalletRequest) returns (Wallet);
/*
Returns the credentials of a wallet. The password will be required if the wallet is encrypted.
*/
rpc GetWalletCredentials (GetWalletCredentialsRequest) returns (WalletCredentials);
/*
Removes a wallet.
*/
rpc RemoveWallet (RemoveWalletRequest) returns (RemoveWalletResponse);
/*
Gracefully stops the daemon.
*/
rpc Stop(google.protobuf.Empty) returns (google.protobuf.Empty);
/*
Unlocks the server. This will be required on startup if there are any encrypted wallets.
*/
rpc Unlock(UnlockRequest) returns (google.protobuf.Empty);
/*
Check if the password is correct.
*/
rpc VerifyWalletPassword(VerifyWalletPasswordRequest) returns (VerifyWalletPasswordResponse);
/*
Changes the password for wallet encryption.
*/
rpc ChangeWalletPassword(ChangeWalletPasswordRequest) returns (google.protobuf.Empty);
/*
Creates a new tenant which can be used to bake restricted macaroons.
*/
rpc CreateTenant(CreateTenantRequest) returns (Tenant);
/*
Returns all tenants.
*/
rpc ListTenants(ListTenantsRequest) returns (ListTenantsResponse);
/*
Get a specifiy tenant.
*/
rpc GetTenant(GetTenantRequest) returns (Tenant);
/*
Bakes a new macaroon with the specified permissions.
The macaroon can also be restricted to a specific tenant. In this case,
- any swap or wallet created with the returned macaroon will belong to this tenant and can not be accessed by other tenants.
- the lightning node connected to the daemon can not be used to pay or create invoices for swaps.
*/
rpc BakeMacaroon(BakeMacaroonRequest) returns (BakeMacaroonResponse);
}
message CreateTenantRequest {
string name = 1;
}
message ListTenantsRequest {}
message ListTenantsResponse {
repeated Tenant tenants = 1;
}
message GetTenantRequest {
string name = 1;
}
message Tenant {
uint64 id = 1;
string name = 2;
}
enum MacaroonAction {
READ = 0;
WRITE = 1;
}
message MacaroonPermissions {
MacaroonAction action = 2;
}
message BakeMacaroonRequest {
optional uint64 tenant_id = 1;
repeated MacaroonPermissions permissions = 2;
}
message BakeMacaroonResponse {
string macaroon = 1;
}
enum SwapState {
PENDING = 0;
SUCCESSFUL= 1;
// Unknown client error. Check the error field of the message for more information
ERROR = 2;
// Unknown server error. Check the status field of the message for more information
SERVER_ERROR = 3;
// Client refunded locked coins after the HTLC timed out
REFUNDED = 4;
// Client noticed that the HTLC timed out but didn't find any outputs to refund
ABANDONED = 5;
}
enum Currency {
BTC = 0;
LBTC = 1;
}
message Pair {
Currency from = 1;
Currency to = 2;
}
message SwapInfo {
string id = 1;
Pair pair = 22;
SwapState state = 2;
string error = 3;
// Latest status message of the Boltz backend
string status = 4;
string private_key = 5;
string preimage = 6;
string redeem_script = 7;
string invoice = 8;
string lockup_address = 9;
uint64 expected_amount = 10;
uint32 timeout_block_height = 11;
string lockup_transaction_id = 12;
/*
If the swap times out or fails for some other reason, the damon will automatically refund the coins sent to the
`lockup_address` back to the configured wallet or the address specified in the `refund_address` field.
*/
string refund_transaction_id = 13;
optional string refund_address = 19;
repeated ChannelId chan_ids = 14;
optional string blinding_key = 15;
int64 created_at = 16;
optional uint64 service_fee = 17;
optional uint64 onchain_fee = 18;
// internal wallet which was used to pay the swap
optional uint64 wallet_id = 20;
uint64 tenant_id = 21;
}
enum SwapType {
SUBMARINE = 0;
REVERSE = 1;
CHAIN = 2;
}
message GetPairInfoRequest {
SwapType type = 1;
Pair pair = 2;
}
message PairInfo {
Pair pair = 1;
SwapFees fees = 2;
Limits limits = 3;
string hash = 4;
}
/*
Channel creations are an optional extension to a submarine swap in the data types of boltz-client.
*/
message ChannelCreationInfo {
option deprecated = true;
// ID of the swap to which this channel channel belongs
string swap_id = 1;
string status = 2;
uint32 inbound_liquidity = 3;
bool private = 4;
string funding_transaction_id = 5;
uint32 funding_transaction_vout = 6;
}
message CombinedChannelSwapInfo {
option deprecated = true;
SwapInfo swap = 1;
ChannelCreationInfo channel_creation = 2;
}
message ReverseSwapInfo {
string id = 1;
SwapState state = 2;
string error = 3;
// Latest status message of the Boltz backend
string status = 4;
string private_key = 5;
string preimage = 6;
string redeem_script = 7;
string invoice = 8;
string claim_address = 9;
int64 onchain_amount = 10;
uint32 timeout_block_height = 11;
string lockup_transaction_id = 12;
string claim_transaction_id = 13;
Pair pair = 14;
repeated ChannelId chan_ids = 15;
optional string blinding_key = 16;
int64 created_at = 17;
optional int64 paid_at = 23; // the time when the invoice was paid
optional uint64 service_fee = 18;
optional uint64 onchain_fee = 19;
optional uint64 routing_fee_msat = 20;
bool external_pay = 21;
uint64 tenant_id = 22;
}
message BlockHeights {
uint32 btc = 1;
optional uint32 liquid = 2;
}
message GetInfoRequest {}
message GetInfoResponse {
string version = 9;
string node = 10;
string network = 2;
string node_pubkey = 7;
// one of: running, disabled, error
string auto_swap_status = 11;
// mapping of the currency to the latest block height.
BlockHeights block_heights = 8;
// swaps that need a manual interaction to refund
repeated string refundable_swaps = 12;
// the currently authenticated tenant
optional Tenant tenant = 13;
// swaps that need a manual interaction to claim
repeated string claimable_swaps = 14;
string symbol = 1 [deprecated = true];
string lnd_pubkey = 3 [deprecated = true];
uint32 block_height = 4 [deprecated = true];
repeated string pending_swaps = 5 [deprecated = true];
repeated string pending_reverse_swaps = 6 [deprecated = true];
}
message Limits {
uint64 minimal = 1;
uint64 maximal = 2;
uint64 maximal_zero_conf_amount = 3;
}
message SwapFees {
double percentage = 1;
uint64 miner_fees = 2;
}
message GetPairsResponse {
repeated PairInfo submarine = 1;
repeated PairInfo reverse = 2;
repeated PairInfo chain = 3;
}
message MinerFees {
uint32 normal = 1;
uint32 reverse = 2;
}
message Fees {
float percentage = 1;
MinerFees miner = 2;
}
message GetServiceInfoRequest {}
message GetServiceInfoResponse {
Fees fees = 1;
Limits limits = 2;
}
enum IncludeSwaps {
ALL = 0;
MANUAL = 1;
AUTO = 2;
}
message ListSwapsRequest {
optional Currency from = 1;
optional Currency to = 2;
optional SwapState state = 4;
IncludeSwaps include = 5;
}
message ListSwapsResponse {
repeated SwapInfo swaps = 1;
repeated CombinedChannelSwapInfo channel_creations = 2;
repeated ReverseSwapInfo reverse_swaps = 3;
repeated ChainSwapInfo chain_swaps = 4;
}
message GetStatsRequest {
IncludeSwaps include = 1;
}
message GetStatsResponse {
SwapStats stats = 1;
}
message RefundSwapRequest {
string id = 1;
oneof destination {
string address = 2;
uint64 wallet_id = 3;
}
}
message ClaimSwapsRequest {
repeated string swap_ids = 1;
oneof destination {
string address = 2;
uint64 wallet_id = 3;
}
}
message ClaimSwapsResponse {
string transaction_id = 1;
}
message GetSwapInfoRequest {
string id = 1;
}
message GetSwapInfoResponse {
SwapInfo swap = 1;
ChannelCreationInfo channel_creation = 2;
ReverseSwapInfo reverse_swap = 3;
ChainSwapInfo chain_swap = 4;
}
message DepositRequest {
/*
Percentage of inbound liquidity the channel that is opened in case the invoice cannot be paid should have.
25 by default.
*/
uint32 inbound_liquidity = 1;
}
message DepositResponse {
string id = 1;
string address = 2;
uint32 timeout_block_height = 3;
}
message CreateSwapRequest {
uint64 amount = 1;
Pair pair = 2;
// not yet supported
// repeated string chan_ids = 3;
// the daemon will pay the swap using the onchain wallet specified in the `wallet` field or any wallet otherwise.
bool send_from_internal = 4;
// address where the coins should go if the swap fails. Refunds will go to any of the daemons wallets otherwise.
optional string refund_address = 5;
// wallet to pay swap from. only used if `send_from_internal` is set to true
optional uint64 wallet_id = 6;
// invoice to use for the swap. if not set, the daemon will get a new invoice from the lightning node
optional string invoice = 7;
// Boltz does not accept 0-conf for Liquid transactions with a fee of 0.01 sat/vByte;
// when `zero_conf` is enabled, a fee of 0.1 sat/vByte will be used for Liquid lockup transactions
optional bool zero_conf = 8;
}
message CreateSwapResponse {
string id = 1;
string address = 2;
uint64 expected_amount = 3;
string bip21 = 4;
// lockup transaction id. Only populated when `send_from_internal` was specified in the request
string tx_id = 5;
uint32 timeout_block_height = 6;
float timeout_hours = 7;
}
message CreateChannelRequest {
int64 amount = 1;
/*
Percentage of inbound liquidity the channel that is opened should have.
25 by default.
*/
uint32 inbound_liquidity = 2;
bool private = 3;
};
message CreateReverseSwapRequest {
// amount of satoshis to swap
uint64 amount = 1;
// If no value is set, the daemon will query a new address from the lightning node
string address = 2;
// Whether the daemon should broadcast the claim transaction immediately after the lockup transaction is in the mempool.
// Should only be used for smaller amounts as it involves trust in boltz.
bool accept_zero_conf = 3;
Pair pair = 4;
// a list of channel ids which are allowed for paying the invoice. can be in either cln or lnd style.
repeated string chan_ids = 5;
// wallet from which the onchain address should be generated - only considered if `address` is not set
optional uint64 wallet_id = 6;
// Whether the daemon should return immediately after creating the swap or wait until the swap is successful or failed.
// It will always return immediately if `accept_zero_conf` is not set.
optional bool return_immediately = 7;
// If set, the daemon will not pay the invoice of the swap and return the invoice to be paid. This implicitly sets `return_immediately` to true.
optional bool external_pay = 8;
// Description of the invoice which will be created for the swap
optional string description = 9;
}
message CreateReverseSwapResponse {
string id = 1;
string lockup_address = 2;
// Only populated when zero-conf is accepted and return_immediately is set to false
optional uint64 routing_fee_milli_sat = 3;
// Only populated when zero-conf is accepted and return_immediately is set to false
optional string claim_transaction_id = 4;
// Invoice to be paid. Only populated when `external_pay` is set to true
optional string invoice = 5;
}
message CreateChainSwapRequest {
// Amount of satoshis to swap. It is the amount expected to be sent to the lockup address.
uint64 amount = 1;
Pair pair = 2;
// Address where funds will be swept to if the swap succeeds
optional string to_address = 3;
// Address where the coins should be refunded to if the swap fails.
optional string refund_address = 4;
// Wallet from which the swap should be paid from. Ignored if `external_pay` is set to true.
// If the swap fails, funds will be refunded to this wallet as well.
optional uint64 from_wallet_id = 5;
// Wallet where the the funds will go if the swap succeeds.
optional uint64 to_wallet_id = 6;
// Whether the daemon should broadcast the claim transaction immediately after the lockup transaction is in the mempool.
// Should only be used for smaller amounts as it involves trust in Boltz.
optional bool accept_zero_conf = 7;
// If set, the daemon will not pay the swap from an internal wallet.
optional bool external_pay = 8;
// Boltz does not accept 0-conf for Liquid transactions with a fee of 0.01 sat/vByte;
// when `lockup_zero_conf` is enabled, a fee of 0.1 sat/vByte will be used for Liquid lockup transactions
optional bool lockup_zero_conf = 9;
}
message ChainSwapInfo {
string id = 1;
Pair pair = 2;
SwapState state = 3;
string error = 4;
string status = 5;
string preimage = 6;
bool is_auto = 8;
optional uint64 service_fee = 9;
double service_fee_percent = 10;
optional uint64 onchain_fee = 11;
int64 created_at = 12;
uint64 tenant_id = 13;
ChainSwapData from_data = 14;
ChainSwapData to_data = 15;
}
message ChainSwapData {
string id = 1;
Currency currency = 2;
string private_key = 3;
string their_public_key = 4;
uint64 amount = 6;
uint32 timeout_block_height = 7;
optional string lockup_transaction_id = 8;
optional string transaction_id = 9;
optional uint64 wallet_id = 20;
optional string address = 12;
optional string blinding_key = 13;
string lockup_address = 14;
}
message ChannelId {
// cln style: 832347x2473x1
string cln = 1;
// lnd style: 915175205006540801
uint64 lnd = 2;
}
message LightningChannel {
ChannelId id = 1;
uint64 capacity = 2;
uint64 outbound_sat = 3;
uint64 inbound_sat = 4;
string peer_id = 5;
}
message SwapStats {
uint64 total_fees = 1;
uint64 total_amount = 2;
uint64 avg_fees = 3;
uint64 avg_amount = 4;
uint64 count = 5;
uint64 success_count = 6;
}
message Budget {
uint64 total = 1;
int64 remaining = 2;
int64 start_date = 3;
int64 end_date = 4;
}
message WalletCredentials {
// only one of these is allowed to be present
optional string mnemonic = 1;
optional string xpub = 2;
optional string core_descriptor = 3;
// only used in combination with mnemonic
optional uint64 subaccount = 4;
}
message WalletParams {
string name = 1;
Currency currency = 2;
// the password to encrypt the wallet with. If there are existing encrypted wallets, the same password has to be used.
optional string password = 3;
}
message ImportWalletRequest {
WalletCredentials credentials = 1;
WalletParams params = 2;
}
message CreateWalletRequest {
WalletParams params = 2;
}
message CreateWalletResponse {
string mnemonic = 1;
Wallet wallet = 2;
}
message SetSubaccountRequest {
uint64 wallet_id = 1;
// The subaccount to use. If not set, a new one will be created.
optional uint64 subaccount = 2;
}
message GetSubaccountsRequest {
uint64 wallet_id = 1;
}
message GetSubaccountsResponse {
optional uint64 current = 1;
repeated Subaccount subaccounts = 2;
}
message ImportWalletResponse {}
message GetWalletsRequest {
optional Currency currency = 1;
optional bool include_readonly = 2;
}
message GetWalletRequest {
optional string name = 1;
optional uint64 id = 2;
}
message GetWalletCredentialsRequest {
uint64 id = 1;
optional string password = 2;
}
message RemoveWalletRequest {
uint64 id = 1;
}
message Wallet {
uint64 id = 1;
string name = 2;
Currency currency = 3;
bool readonly = 4;
Balance balance = 5;
uint64 tenant_id = 6;
}
message Wallets {
repeated Wallet wallets = 1;
}
message Balance {
uint64 total = 1;
uint64 confirmed = 2;
uint64 unconfirmed = 3;
}
message Subaccount {
Balance balance = 1;
uint64 pointer = 2;
string type = 3;
}
message RemoveWalletResponse {}
message UnlockRequest {
string password = 1;
}
message VerifyWalletPasswordRequest {
string password = 1;
}
message VerifyWalletPasswordResponse {
bool correct = 1;
}
message ChangeWalletPasswordRequest {
string old = 1;
string new = 2;
}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
wget https://raw.githubusercontent.com/BoltzExchange/boltz-client/master/boltzrpc/boltzrpc.proto -O lnbits/wallets/boltz_grpc_files/boltzrpc.proto
poetry run python -m grpc_tools.protoc -I . --python_out=. --grpc_python_out=. --pyi_out=. lnbits/wallets/boltz_grpc_files/boltzrpc.proto
+281
View File
@@ -0,0 +1,281 @@
import base64
try:
import breez_sdk # type: ignore
BREEZ_SDK_INSTALLED = True
except ImportError:
BREEZ_SDK_INSTALLED = False
if not BREEZ_SDK_INSTALLED:
class BreezSdkWallet: # pyright: ignore
def __init__(self):
raise RuntimeError(
"Breez SDK is not installed. "
"Ask admin to run `poetry install -E breez` to install it."
)
else:
import asyncio
from pathlib import Path
from typing import AsyncGenerator, Optional
from loguru import logger
from lnbits import bolt11 as lnbits_bolt11
from lnbits.settings import settings
from .base import (
InvoiceResponse,
PaymentFailedStatus,
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
PaymentSuccessStatus,
StatusResponse,
UnsupportedError,
Wallet,
)
breez_event_queue: asyncio.Queue = asyncio.Queue()
def load_bytes(source: str, extension: str) -> Optional[bytes]:
# first check if it can be read from a file
if source.split(".")[-1] == extension:
with open(source, "rb") as f:
source_bytes = f.read()
return source_bytes
else:
# else check the source string can be converted from hex
try:
return bytes.fromhex(source)
except ValueError:
pass
# else convert from base64
try:
return base64.b64decode(source)
except Exception:
pass
return None
def load_greenlight_credentials() -> (
Optional[
breez_sdk.GreenlightCredentials # pyright: ignore[reportUnboundVariable]
]
):
if (
settings.breez_greenlight_device_key
and settings.breez_greenlight_device_cert
):
device_key_bytes = load_bytes(settings.breez_greenlight_device_key, "pem")
device_cert_bytes = load_bytes(settings.breez_greenlight_device_cert, "crt")
if not device_key_bytes or not device_cert_bytes:
raise ValueError(
"cannot initialize BreezSdkWallet: "
"cannot decode breez_greenlight_device_key "
"or breez_greenlight_device_cert"
)
return breez_sdk.GreenlightCredentials( # pyright: ignore[reportUnboundVariable]
developer_key=list(device_key_bytes),
developer_cert=list(device_cert_bytes),
)
return None
class SDKListener(
breez_sdk.EventListener # pyright: ignore[reportUnboundVariable]
):
def on_event(self, event):
logger.debug(event)
breez_event_queue.put_nowait(event)
class BreezSdkWallet(Wallet): # type: ignore[no-redef]
def __init__(self):
if not settings.breez_greenlight_seed:
raise ValueError(
"cannot initialize BreezSdkWallet: missing breez_greenlight_seed"
)
if not settings.breez_api_key:
raise ValueError(
"cannot initialize BreezSdkWallet: missing breez_api_key"
)
if (
settings.breez_greenlight_device_key
and not settings.breez_greenlight_device_cert
):
raise ValueError(
"cannot initialize BreezSdkWallet: "
"missing breez_greenlight_device_cert"
)
if (
settings.breez_greenlight_device_cert
and not settings.breez_greenlight_device_key
):
raise ValueError(
"cannot initialize BreezSdkWallet: "
"missing breez_greenlight_device_key"
)
self.config = breez_sdk.default_config(
breez_sdk.EnvironmentType.PRODUCTION,
settings.breez_api_key,
breez_sdk.NodeConfig.GREENLIGHT(
config=breez_sdk.GreenlightNodeConfig(
partner_credentials=load_greenlight_credentials(),
invite_code=settings.breez_greenlight_invite_code,
)
),
)
breez_sdk_working_dir = Path(settings.lnbits_data_folder, "breez-sdk")
breez_sdk_working_dir.mkdir(parents=True, exist_ok=True)
self.config.working_dir = breez_sdk_working_dir.absolute().as_posix()
try:
seed = breez_sdk.mnemonic_to_seed(settings.breez_greenlight_seed)
connect_request = breez_sdk.ConnectRequest(self.config, seed)
self.sdk_services = breez_sdk.connect(connect_request, SDKListener())
except Exception as exc:
logger.warning(exc)
raise ValueError(f"cannot initialize BreezSdkWallet: {exc!s}") from exc
async def cleanup(self):
self.sdk_services.disconnect()
async def status(self) -> StatusResponse:
try:
node_info: breez_sdk.NodeState = self.sdk_services.node_info()
except Exception as exc:
return StatusResponse(f"Failed to connect to breez, got: '{exc}...'", 0)
return StatusResponse(None, int(node_info.channels_balance_msat))
async def create_invoice(
self,
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse:
# if description_hash or unhashed_description:
# raise UnsupportedError("description_hash and unhashed_description")
try:
if description_hash and not unhashed_description:
raise UnsupportedError(
"'description_hash' unsupported by Greenlight, provide"
" 'unhashed_description'"
)
breez_invoice: breez_sdk.ReceivePaymentResponse = (
self.sdk_services.receive_payment(
breez_sdk.ReceivePaymentRequest(
amount * 1000, # breez uses msat
(
unhashed_description.decode()
if unhashed_description
else memo
),
preimage=kwargs.get("preimage"),
opening_fee_params=None,
use_description_hash=True if unhashed_description else None,
)
)
)
return InvoiceResponse(
True,
breez_invoice.ln_invoice.payment_hash,
breez_invoice.ln_invoice.bolt11,
None,
)
except Exception as e:
logger.warning(e)
return InvoiceResponse(False, None, None, str(e))
async def pay_invoice(
self, bolt11: str, fee_limit_msat: int
) -> PaymentResponse:
invoice = lnbits_bolt11.decode(bolt11)
try:
send_payment_request = breez_sdk.SendPaymentRequest(bolt11=bolt11)
send_payment_response: breez_sdk.SendPaymentResponse = (
self.sdk_services.send_payment(send_payment_request)
)
payment: breez_sdk.Payment = send_payment_response.payment
except Exception as exc:
logger.warning(exc)
try:
# try to report issue to Breez to improve LSP routing
self.sdk_services.report_issue(
breez_sdk.ReportIssueRequest.PAYMENT_FAILURE(
breez_sdk.ReportPaymentFailureDetails(invoice.payment_hash)
)
)
except Exception as ex:
logger.info(ex)
# assume that payment failed?
return PaymentResponse(
False, None, None, None, f"payment failed: {exc}"
)
if payment.status != breez_sdk.PaymentStatus.COMPLETE:
return PaymentResponse(False, None, None, None, "payment is pending")
# let's use the payment_hash as the checking_id
checking_id = invoice.payment_hash
return PaymentResponse(
True,
checking_id,
payment.fee_msat,
payment.details.data.payment_preimage,
None,
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try:
payment: breez_sdk.Payment = self.sdk_services.payment_by_hash(
checking_id
)
if payment is None:
return PaymentPendingStatus()
if payment.payment_type != breez_sdk.PaymentType.RECEIVED:
logger.warning(f"unexpected payment type: {payment.status}")
return PaymentPendingStatus()
if payment.status == breez_sdk.PaymentStatus.FAILED:
return PaymentFailedStatus()
if payment.status == breez_sdk.PaymentStatus.COMPLETE:
return PaymentSuccessStatus()
return PaymentPendingStatus()
except Exception as exc:
logger.warning(exc)
return PaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
try:
payment: breez_sdk.Payment = self.sdk_services.payment_by_hash(
checking_id
)
if payment is None:
return PaymentPendingStatus()
if payment.payment_type != breez_sdk.PaymentType.SENT:
logger.warning(f"unexpected payment type: {payment.status}")
return PaymentPendingStatus()
if payment.status == breez_sdk.PaymentStatus.COMPLETE:
return PaymentSuccessStatus(
fee_msat=payment.fee_msat,
preimage=payment.details.data.payment_preimage,
)
if payment.status == breez_sdk.PaymentStatus.FAILED:
return PaymentFailedStatus()
return PaymentPendingStatus()
except Exception as exc:
logger.warning(exc)
return PaymentPendingStatus()
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while True:
event = await breez_event_queue.get()
if event.is_invoice_paid():
yield event.details.payment_hash
+1 -1
View File
@@ -101,7 +101,7 @@ class CoreLightningWallet(Wallet):
if unhashed_description and not self.supports_description_hash:
raise UnsupportedError("unhashed_description")
r: dict = self.ln.invoice( # type: ignore
msatoshi=msat,
amount_msat=msat,
label=label,
description=(
unhashed_description.decode() if unhashed_description else memo
+2
View File
@@ -1 +1,3 @@
from .macaroon import load_macaroon
__all__ = ["load_macaroon"]
+953
View File
@@ -0,0 +1,953 @@
import asyncio
import base64
import hashlib
import json
import random
import time
from typing import AsyncGenerator, Dict, List, Optional, Union, cast
from urllib.parse import parse_qs, unquote, urlparse
import secp256k1
from bolt11 import decode as bolt11_decode
from Cryptodome import Random
from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import pad, unpad
from loguru import logger
from websockets.client import connect as ws_connect
from lnbits.settings import settings
from .base import (
InvoiceResponse,
PaymentResponse,
PaymentStatus,
StatusResponse,
Wallet,
)
class NWCError(Exception):
"""
An exception from NWC
"""
def __init__(self, code: str, message: str):
self.code = code
self.message = message
super().__init__(self.__str__())
def __str__(self):
return f"{self.code} {self.message}"
class NWCWallet(Wallet):
"""
A funding source that connects to a Nostr Wallet Connect (NWC) service provider.
https://nwc.dev/
"""
def __init__(self):
self.shutdown = False
nwc_data = parse_nwc(settings.nwc_pairing_url)
self.conn = NWCConnection(
nwc_data["pubkey"], nwc_data["secret"], nwc_data["relay"]
)
# pending payments for paid_invoices_stream.
# They are tracked until they expire or are settled
self.pending_payments = []
# interval in seconds between checks for pending payments
self.pending_payments_lookup_interval = 10
# track paid invoices for paid_invoices_stream
self.paid_invoices_queue: asyncio.Queue = asyncio.Queue(0)
# This task periodically checks if pending payments have been settled
self.pending_payments_lookup_task = asyncio.create_task(
self._handle_pending_payments()
)
def _is_shutting_down(self) -> bool:
"""
Returns True if the wallet is shutting down.
"""
return self.shutdown or not settings.lnbits_running
async def _handle_pending_payments(self):
"""
Periodically checks if any pending payments have been settled.
"""
while not self._is_shutting_down():
await asyncio.sleep(self.pending_payments_lookup_interval)
# Check if any pending payments have been settled or timed out
now = time.time()
for payment in self.pending_payments:
try:
if not payment["settled"]:
payment_data = await self.conn.call(
"lookup_invoice", {"payment_hash": payment["checking_id"]}
)
settled = (
"settled_at" in payment_data
and payment_data["settled_at"]
and int(payment_data["settled_at"]) > 0
and "preimage" in payment_data
and payment_data["preimage"]
)
if settled:
logger.debug(
"Pending payment " + payment["checking_id"] + " settled"
)
payment["settled"] = True
self.paid_invoices_queue.put_nowait(payment["checking_id"])
except Exception as e:
logger.error("Error handling pending payment: " + str(e))
try:
if now > payment["expires_at"]:
logger.warning(
"Pending payment " + payment["checking_id"] + " timed out"
)
payment["expired"] = True
except Exception as e:
logger.error("Error handling pending payment: " + str(e))
# Remove all settled or expired payments
self.pending_payments = [
payment
for payment in self.pending_payments
if not payment["settled"] and not payment["expired"]
]
async def cleanup(self):
self.shutdown = True
try:
self.pending_payments_lookup_task.cancel()
except Exception as e:
logger.warning("Error cancelling pending payments lookup task: " + str(e))
await self.conn.close()
async def create_invoice(
self,
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse:
desc = ""
desc_hash = None
if description_hash:
desc_hash = description_hash.hex()
desc = (unhashed_description or b"").decode()
elif unhashed_description:
desc = unhashed_description.decode()
desc_hash = hashlib.sha256(desc.encode()).hexdigest()
else:
desc = memo or ""
try:
info = await self.conn.get_info()
if "make_invoice" not in info["supported_methods"]:
return InvoiceResponse(
False,
None,
None,
"make_invoice is not supported by this NWC service.",
)
resp = await self.conn.call(
"make_invoice",
{
"amount": int(amount * 1000), # nwc uses msats denominations
"description_hash": desc_hash,
"description": desc,
},
)
checking_id = str(resp["payment_hash"])
payment_request = resp.get("invoice", None)
# if lookup_invoice is not supported, we can't track the payment
if "lookup_invoice" in info["supported_methods"]:
created_at = int(resp.get("created_at", time.time()))
expires_at = int(resp.get("expires_at", created_at + 3600))
self.pending_payments.append(
{ # Start tracking
"checking_id": checking_id,
"expires_at": expires_at,
"settled": False,
"expired": False,
}
)
return InvoiceResponse(True, checking_id, payment_request, None)
except Exception as e:
return InvoiceResponse(ok=False, error_message=str(e))
async def status(self) -> StatusResponse:
try:
info = await self.conn.get_info()
if "get_balance" not in info["supported_methods"]:
logger.debug("get_balance is not supported by this NWC service.")
return StatusResponse(None, 0)
resp = await self.conn.call("get_balance", {})
balance = int(resp["balance"])
return StatusResponse(None, balance)
except Exception as e:
return StatusResponse(str(e), 0)
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
try:
resp = await self.conn.call("pay_invoice", {"invoice": bolt11})
preimage = resp.get("preimage", None)
invoice_data = bolt11_decode(bolt11)
payment_hash = invoice_data.payment_hash
# pay_invoice doesn't return payment data, so we need
# to call lookup_invoice too (if supported)
info = await self.conn.get_info()
if "lookup_invoice" not in info["supported_methods"]:
# if not supported, we assume it succeeded
return PaymentResponse(True, payment_hash, None, preimage, None)
try:
payment_data = await self.conn.call(
"lookup_invoice", {"invoice": bolt11}
)
settled = payment_data.get("settled_at", None) and payment_data.get(
"preimage", None
)
if not settled:
return PaymentResponse(None, payment_hash, None, None, None)
else:
fee_msat = payment_data.get("fees_paid", None)
return PaymentResponse(True, payment_hash, fee_msat, preimage, None)
except Exception:
# Workaround: some nwc service providers might not store the invoice
# right away, so this call may raise an exception.
# We will assume the payment is pending anyway
return PaymentResponse(None, payment_hash, None, None, None)
except NWCError as e:
logger.error("Error paying invoice: " + str(e))
failure_codes = [
"RATE_LIMITED",
"NOT_IMPLEMENTED",
"INSUFFICIENT_BALANCE",
"QUOTA_EXCEEDED",
"RESTRICTED",
"UNAUTHORIZED",
"INTERNAL",
"OTHER",
"PAYMENT_FAILED",
]
failed = e.code in failure_codes
return PaymentResponse(
None if not failed else False,
error_message=e.message if failed else None,
)
except Exception as e:
logger.error("Error paying invoice: " + str(e))
# assume pending
return PaymentResponse(None)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
return await self.get_payment_status(checking_id)
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
try:
info = await self.conn.get_info()
if "lookup_invoice" in info["supported_methods"]:
payment_data = await self.conn.call(
"lookup_invoice", {"payment_hash": checking_id}
)
settled = payment_data.get("settled_at", None) and payment_data.get(
"preimage", None
)
fee_msat = payment_data.get("fees_paid", None)
preimage = payment_data.get("preimage", None)
created_at = int(payment_data.get("created_at", time.time()))
expires_at = int(payment_data.get("expires_at", created_at + 3600))
expired = expires_at and time.time() > expires_at
if expired and not settled:
return PaymentStatus(False, fee_msat=fee_msat, preimage=preimage)
else:
return PaymentStatus(
True if settled else None, fee_msat=fee_msat, preimage=preimage
)
else:
return PaymentStatus(None, fee_msat=None, preimage=None)
except NWCError as e:
logger.error("Error getting payment status: " + str(e))
failed = e.code == "NOT_FOUND"
return PaymentStatus(
None if not failed else False, fee_msat=None, preimage=None
)
except Exception as e:
logger.error("Error getting payment status: " + str(e))
# assume pending (eg. exception due to network error)
return PaymentStatus(None, fee_msat=None, preimage=None)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while not self._is_shutting_down():
value = await self.paid_invoices_queue.get()
yield value
class NWCConnection:
"""
A connection to a Nostr Wallet Connect (NWC) service provider.
"""
def __init__(self, pubkey, secret, relay):
# Parse pairing url (if invalid an exception is raised)
# Extract keys (used to sign nwc events+identify NWC user)
self.account_private_key = secp256k1.PrivateKey(bytes.fromhex(secret))
self.account_private_key_hex = secret
self.account_public_key = self.account_private_key.pubkey
self.account_public_key_hex = self.account_public_key.serialize().hex()[2:]
# Extract service key (used for encryption to identify the nwc service provider)
self.service_pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey), True)
self.service_pubkey_hex = pubkey
# Extract relay url
self.relay = relay
# Create temporary subscriptions, stored until the response is received/expires
self.subscriptions = {}
# Timeout in seconds after which a subscription is closed
# if no response is received
self.subscription_timeout = 10
# Incremental counter to generate unique subscription ids for the connection
self.subscriptions_count = 0
# websocket connection
self.ws = None
# if True the websocket is connected
self.connected = False
# if True the connection is shutting down
self.shutdown = False
# cached info about the service provider
self.info = None
# This task handles connection and reconnection to the relay
self.connection_task = asyncio.create_task(self._connect_to_relay())
# This task periodically checks and removes subscriptions
# and pending payments that have timed out
self.timeout_task = asyncio.create_task(self._handle_timeouts())
logger.info(
"NWCConnection is ready. relay: "
+ self.relay
+ " account: "
+ self.account_public_key_hex
+ " service: "
+ self.service_pubkey_hex
)
def _is_shutting_down(self) -> bool:
"""
Returns True if the connection is shutting down.
"""
return self.shutdown or not settings.lnbits_running
async def _send(self, data: List[Union[str, Dict]]):
"""
Sends data to the NWC relay.
Args:
data (Dict): The data to be sent.
"""
if self._is_shutting_down():
logger.warning("Trying to send data while shutting down")
return
if not self.ws:
logger.warning("Trying to send data without a connection")
return
await self._wait_for_connection() # ensure the connection is established
tx = json_dumps(data)
await self.ws.send(tx)
def _get_new_subid(self) -> str:
"""
Generates a unique subscription id.
Returns:
str: The generated 64 characters long subscription id (eg. lnbits0abc...)
"""
subid = "lnbits" + str(self.subscriptions_count)
self.subscriptions_count += 1
max_length = 64
chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
n = max_length - len(subid)
if n > 0:
for _ in range(n):
subid += chars[random.randint(0, len(chars) - 1)]
return subid
async def _close_subscription_by_subid(
self, sub_id: str, send_event: bool = True
) -> Optional[Dict]:
"""
Closes a subscription by its sub_id.
Args:
sub_id (str): The subscription id.
sendEvent (bool): If True, sends a CLOSE event to the relay.
Returns:
Dict: The subscription that was closed.
"""
logger.debug("Closing subscription " + sub_id)
sub_to_close = None
for subscription in self.subscriptions.values():
if subscription["sub_id"] == sub_id:
sub_to_close = subscription
# send CLOSE event to the relay if the subscription
# is not already closed and sendEvent is True
if not subscription["closed"] and send_event:
await self._send(["CLOSE", sub_id])
# mark as closed
subscription["closed"] = True
break
# remove the subscription from the list
if sub_to_close:
self.subscriptions.pop(sub_to_close["event_id"], None)
return sub_to_close
async def _close_subscription_by_eventid(
self, event_id, send_event=True
) -> Optional[Dict]:
"""
Closes a subscription associated to an event_id.
Args:
event_id (str): The event id associated to the subscription.
sendEvent (bool): If True, sends a CLOSE event to the relay.
Returns:
Dict: The subscription that was closed.
"""
logger.debug("Closing subscription for event " + event_id)
# find and remove the subscription
subscription = self.subscriptions.pop(event_id, None)
if subscription:
# send CLOSE event to the relay if the subscription
# is not already closed and sendEvent is True
if not subscription["closed"] and send_event:
await self._send(["CLOSE", subscription["sub_id"]])
# mark as closed
subscription["closed"] = True
return subscription
async def _wait_for_connection(self):
"""
Waits until the connection is ready
"""
while not self.connected:
if self._is_shutting_down():
raise Exception("Connection is closing")
logger.debug("Waiting for connection...")
await asyncio.sleep(1)
async def _handle_timeouts(self):
"""
Periodically checks if any subscriptions and pending
payments have timed out, and removes them.
"""
try:
while not self._is_shutting_down():
try:
await asyncio.sleep(int(self.subscription_timeout * 0.5))
# skip if connection is not established
if not self.connected:
continue
# Find all subscriptions that have timed out
now = time.time()
subscriptions_to_close = []
for subscription in self.subscriptions.values():
t = now - subscription["timestamp"]
if t > self.subscription_timeout:
logger.warning(
"Subscription " + subscription["sub_id"] + " timed out"
)
subscriptions_to_close.append(subscription["sub_id"])
# if not already closed, pass the "time out"
# exception to the future
if not subscription["closed"]:
subscription["future"].set_exception(
Exception("timed out")
)
# Close all timed out subscriptions
for sub_id in subscriptions_to_close:
await self._close_subscription_by_subid(sub_id)
except Exception as e:
logger.error("Error handling subscription timeout: " + str(e))
except Exception as e:
logger.error("Error handling subscription timeout: " + str(e))
async def _on_ok_message(self, msg: List[str]):
"""
Handles OK messages from the relay.
"""
event_id = msg[1]
status = msg[2]
info = (msg[3] or "") if len(msg) > 3 else ""
if not status:
# close subscription and pass an exception
# if the event was rejected by the relay
subscription = await self._close_subscription_by_eventid(event_id)
if subscription: # Check if the subscription exists first
subscription["future"].set_exception(Exception(info))
async def _on_event_message(self, msg: List[Union[str, Dict]]):
"""
Handles EVENT messages from the relay.
"""
sub_id = cast(str, msg[1])
event = cast(Dict, msg[2])
if not verify_event(event): # Ensure the event is valid (do not trust relays)
raise Exception("Invalid event signature")
tags = event["tags"]
if event["kind"] == 13194: # An info event
# info events are handled specially,
# they are stored in the subscriptions list
# using the subscription id for both sub_id and event_id
subscription = await self._close_subscription_by_eventid(
sub_id
) # sub_id is the event_id for info events
if subscription: # Check if the subscription exists first
if (
subscription["method"] != "info_sub"
): # Ensure the subscription is for an info event
raise Exception("Unexpected info event")
# create an info dictionary with the supported
# methods that is passed to the future
content = event["content"]
subscription["future"].set_result(
{"supported_methods": content.split(" ")}
)
else: # A response event
subscription = None
# find the first "e" tag that is handled by
# a registered subscription
# Note: usually we expect only one "e" tag, but we are
# handling multiple "e" tags just in case
for tag in tags:
if tag[0] == "e":
subscription = await self._close_subscription_by_eventid(tag[1])
if subscription:
break
# if a subscription was found, pass the result to the future
if subscription:
content = decrypt_content(
event["content"], self.service_pubkey, self.account_private_key_hex
)
content = json.loads(content)
result_type = content.get("result_type", "")
error = content.get("error", None)
result = content.get("result", None)
if error: # if an error occurred, pass the error to the future
nwc_exception = NWCError(error["code"], error["message"])
subscription["future"].set_exception(nwc_exception)
else:
# ensure the result is for the expected method
if result_type != subscription["method"]:
raise Exception("Unexpected result type")
if not result:
raise Exception("Malformed response")
else:
subscription["future"].set_result(result)
async def _on_closed_message(self, msg: List[str]):
"""
Handles CLOSED messages from the relay.
"""
# The change is reflected in the subscriptions list.
sub_id = msg[1]
info = msg[2] or ""
if info:
logger.warning("Subscription " + sub_id + " closed remotely: " + info)
# Note: sendEvent=false because the action was initiated by the relay
await self._close_subscription_by_subid(sub_id, send_event=False)
async def _on_message(self, ws, message: str):
"""
Handle incoming messages from the relay.
"""
try:
msg = json.loads(message)
if msg[0] == "OK": # Event status message
await self._on_ok_message(msg)
elif msg[0] == "EVENT": # Event message
await self._on_event_message(msg)
elif msg[0] == "EOSE":
# Do nothing. No need to handle this message type for NWC
pass
elif msg[0] == "CLOSED":
# Subscription was closed remotely.
await self._on_closed_message(msg)
elif msg[0] == "NOTICE":
# A message from the relay, mostly useless, but we log it anyway
logger.info("Notice from relay " + self.relay + ": " + str(msg[1]))
else:
raise Exception("Unknown message type")
except Exception as e:
logger.error("Error parsing event: " + str(e))
async def _connect_to_relay(self):
"""
Initiate websocket connection to the relay.
"""
logger.debug("Connecting to NWC relay " + self.relay)
while (
not self._is_shutting_down()
): # Reconnect until the connection is shutting down
logger.debug("Creating new connection...")
try:
async with ws_connect(self.relay) as ws:
self.ws = ws
self.connected = True
while (
not self._is_shutting_down()
): # receive messages until the connection is shutting down
try:
reply = await ws.recv()
reply_str = ""
if isinstance(reply, bytes):
reply_str = reply.decode("utf-8")
else:
reply_str = reply
await self._on_message(ws, reply_str)
except Exception as e:
logger.debug("Error receiving message: " + str(e))
break
logger.debug("Connection to NWC relay closed")
except Exception as e:
logger.error("Error connecting to NWC relay: " + str(e))
# the connection was closed, so we set the connected flag to False
# this will make the methods calling _wait_for_connection()
# to wait until the connection is re-established
self.connected = False
if not self._is_shutting_down():
# Wait some time before reconnecting
logger.debug("Reconnecting to NWC relay in 5 seconds...")
await asyncio.sleep(5)
async def call(self, method: str, params: Dict) -> Dict:
"""
Call a NWC method.
Args:
method (str): The method name.
params (Dict): The method parameters.
Returns:
Dict: The result of the method call.
"""
await self._wait_for_connection()
logger.debug("Calling " + method + " with params: " + str(params))
# Prepare the content
content = json_dumps(
{
"method": method,
"params": params,
}
)
# Encrypt
content = encrypt_content(
content, self.service_pubkey, self.account_private_key_hex
)
# Prepare the NWC event
event = {
"kind": 23194,
"content": content,
"created_at": int(time.time()),
"tags": [["p", self.service_pubkey_hex]],
}
# Sign
sign_event(event, self.account_public_key_hex, self.account_private_key)
# Subscribe for a response to this event
sub_filter = {
"kinds": [23195],
"#p": [self.account_public_key_hex],
"#e": [event["id"]],
"since": event["created_at"],
}
sub_id = self._get_new_subid()
# register a future to receive the response asynchronously
future = asyncio.get_event_loop().create_future()
# Check if the subscription already exists
# (this means there is a bug somewhere, should not happen)
if event["id"] in self.subscriptions:
raise Exception("Subscription for this event id already exists?")
# Store the subscription in the list
self.subscriptions[event["id"]] = {
"method": method,
"future": future,
"sub_id": sub_id,
"event_id": event["id"],
"timestamp": time.time(),
"closed": False,
}
# Send the events
await self._send(["REQ", sub_id, sub_filter])
await self._send(["EVENT", event])
# Wait for the response
return await future
async def get_info(self) -> Dict:
"""
Get the info about the service provider and cache it.
Returns:
Dict: The info about the service provider.
"""
if not self.info: # if not cached
try:
await self._wait_for_connection()
# Prepare filter to request the info note
sub_filter = {"kinds": [13194], "authors": [self.service_pubkey_hex]}
# We register a special subscription using the sub_id as the event_id
sub_id = self._get_new_subid()
future = asyncio.get_event_loop().create_future()
self.subscriptions[sub_id] = {
"method": "info_sub",
"future": future,
"sub_id": sub_id,
"event_id": sub_id,
"timestamp": time.time(),
"closed": False,
}
# Send the request
await self._send(["REQ", sub_id, sub_filter])
# Wait for the response
service_info = await future
# Get account info when possible
if "get_info" in service_info["supported_methods"]:
try:
account_info = await self.call("get_info", {})
# cache
self.info = service_info
self.info["alias"] = account_info.get("alias", "")
self.info["color"] = account_info.get("color", "")
self.info["pubkey"] = account_info.get("pubkey", "")
self.info["network"] = account_info.get("network", "")
self.info["block_height"] = account_info.get("block_height", 0)
self.info["block_hash"] = account_info.get("block_hash", "")
self.info["supported_methods"] = account_info.get(
"methods",
service_info.get("supported_methods", ["pay_invoice"]),
)
except Exception as e:
# If there is an error, fallback to using service info
logger.error(
"Error getting account info: "
+ str(e)
+ " Using service info only"
)
self.info = service_info
else:
# get_info is not supported,
# so we will make do with the service info
self.info = service_info # cache
except Exception as e:
logger.error("Error getting info: " + str(e))
# The error could mean that the service provider does
# not provide an info note
# So we just assume it supports the bare minimum to be Nip47 compliant
self.info = {
"supported_methods": ["pay_invoice"],
}
return self.info
async def close(self):
logger.debug("Closing NWCConnection")
self.shutdown = True # Mark for shutdown
# cancel all tasks
try:
self.timeout_task.cancel()
except Exception as e:
logger.warning("Error cancelling subscription timeout task: " + str(e))
try:
self.connection_task.cancel()
except Exception as e:
logger.warning("Error cancelling connection task: " + str(e))
# close the websocket
try:
if self.ws:
await self.ws.close()
except Exception as e:
logger.warning("Error closing connection: " + str(e))
def parse_nwc(nwc) -> Dict:
"""
Parses a NWC URL (nostr+walletconnect://...) and extracts relevant information.
Args:
nwc (str): The Nostr Wallet Connect URL to be parsed.
Returns:
Dict[str, str]: A dict containing:'pubkey', 'relay', and 'secret'.
If the URL is invalid, an exception is raised.
Example:
>>> parse_nwc("nostr+walletconnect://000000...000000?relay=example.com&secret=123")
{'pubkey': '000000...000000', 'relay': 'example.com', 'secret': '123'}
"""
data = {}
prefix = "nostr+walletconnect://"
if nwc and nwc.startswith(prefix):
nwc = nwc[len(prefix) :]
parsed_url = urlparse(nwc)
data["pubkey"] = parsed_url.path
query_params = parse_qs(parsed_url.query)
for key, value in query_params.items():
if key in ["relay", "secret"] and value:
data[key] = unquote(value[0])
if "pubkey" not in data or "relay" not in data or "secret" not in data:
raise ValueError("Invalid NWC pairing url")
else:
raise ValueError("Invalid NWC pairing url")
return data
def json_dumps(data: Union[Dict, list]) -> str:
"""
Converts a Python dictionary to a JSON string with compact encoding.
Args:
data (Dict): The dictionary to be converted.
Returns:
str: The compact JSON string.
"""
if isinstance(data, Dict):
data = {k: v for k, v in data.items() if v is not None}
return json.dumps(data, separators=(",", ":"), ensure_ascii=False)
def encrypt_content(
content: str, service_pubkey: secp256k1.PublicKey, account_private_key_hex: str
) -> str:
"""
Encrypts the content to be sent to the service.
Args:
content (str): The content to be encrypted.
service_pubkey (secp256k1.PublicKey): The service provider's public key.
account_private_key_hex (str): The account private key in hex format.
Returns:
str: The encrypted content.
"""
shared = service_pubkey.tweak_mul(
bytes.fromhex(account_private_key_hex)
).serialize()[1:]
# random iv (16B)
iv = Random.new().read(AES.block_size)
aes = AES.new(shared, AES.MODE_CBC, iv)
content_bytes = content.encode("utf-8")
# padding
content_bytes = pad(content_bytes, AES.block_size)
# Encrypt
encrypted_b64 = base64.b64encode(aes.encrypt(content_bytes)).decode("ascii")
iv_b64 = base64.b64encode(iv).decode("ascii")
encrypted_content = encrypted_b64 + "?iv=" + iv_b64
return encrypted_content
def decrypt_content(
content: str, service_pubkey: secp256k1.PublicKey, account_private_key_hex: str
) -> str:
"""
Decrypts the content coming from the service.
Args:
content (str): The encrypted content.
service_pubkey (secp256k1.PublicKey): The service provider's public key.
account_private_key_hex (str): The account private key in hex format.
Returns:
str: The decrypted content.
"""
shared = service_pubkey.tweak_mul(
bytes.fromhex(account_private_key_hex)
).serialize()[1:]
# extract iv and content
(encrypted_content_b64, iv_b64) = content.split("?iv=")
encrypted_content = base64.b64decode(encrypted_content_b64.encode("ascii"))
iv = base64.b64decode(iv_b64.encode("ascii"))
# Decrypt
aes = AES.new(shared, AES.MODE_CBC, iv)
decrypted_bytes = aes.decrypt(encrypted_content)
decrypted_bytes = unpad(decrypted_bytes, AES.block_size)
decrypted = decrypted_bytes.decode("utf-8")
return decrypted
def verify_event(event: Dict) -> bool:
"""
Verify the event signature
Args:
event (Dict): The event to verify.
Returns:
bool: True if the event signature is valid, False otherwise.
"""
signature_data = json_dumps(
[
0,
event["pubkey"],
event["created_at"],
event["kind"],
event["tags"],
event["content"],
]
)
event_id = hashlib.sha256(signature_data.encode()).hexdigest()
if event_id != event["id"]: # Invalid event id
return False
pubkey_hex = event["pubkey"]
pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkey_hex), True)
if not pubkey.schnorr_verify(
bytes.fromhex(event_id), bytes.fromhex(event["sig"]), None, raw=True
):
return False
return True
def sign_event(
event: Dict, account_public_key_hex: str, account_private_key: secp256k1.PrivateKey
) -> Dict:
"""
Signs the event (in place) with the service secret
Args:
event (Dict): The event to be signed.
account_public_key_hex (str): The account public key in hex format.
account_private_key (secp256k1.PrivateKey): The account private key.
Returns:
Dict: The input event with the signature added.
"""
signature_data = json_dumps(
[
0,
account_public_key_hex,
event["created_at"],
event["kind"],
event["tags"],
event["content"],
]
)
event_id = hashlib.sha256(signature_data.encode()).hexdigest()
event["id"] = event_id
event["pubkey"] = account_public_key_hex
signature = (
account_private_key.schnorr_sign(bytes.fromhex(event_id), None, raw=True)
).hex()
event["sig"] = signature
return event
+15 -4
View File
@@ -1,5 +1,6 @@
import asyncio
import base64
import hashlib
import json
import urllib.parse
from typing import AsyncGenerator, Dict, Optional
@@ -17,7 +18,6 @@ from .base import (
PaymentStatus,
PaymentSuccessStatus,
StatusResponse,
UnsupportedError,
Wallet,
)
@@ -87,17 +87,28 @@ class PhoenixdWallet(Wallet):
unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse:
if description_hash or unhashed_description:
raise UnsupportedError("description_hash")
try:
msats_amount = amount
data: Dict = {
"amountSat": f"{msats_amount}",
"description": memo,
"externalId": "",
}
# Either 'description' (string) or 'descriptionHash' must be supplied
# PhoenixD description limited to 128 characters
if description_hash:
data["descriptionHash"] = description_hash.hex()
else:
desc = memo
if desc is None and unhashed_description:
desc = unhashed_description.decode()
desc = desc or ""
if len(desc) > 128:
data["descriptionHash"] = hashlib.sha256(desc.encode()).hexdigest()
else:
data["description"] = desc
r = await self.client.post(
"/createinvoice",
data=data,

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