Compare commits

..
Author SHA1 Message Date
dni 842fb53742 mock import 2026-07-09 08:19:25 +02:00
dni 2f9b3723ae black 2026-07-09 08:05:59 +02:00
dni 04397b85f0 also activate extension for start task in test 2026-07-09 08:05:59 +02:00
dni 8982852516 dont start tasks twice 2026-07-09 08:05:59 +02:00
dni e2bee23daf fix pydantic v1 errors 2026-07-09 08:05:59 +02:00
dni a822523460 clear cached modules 2026-07-09 08:05:59 +02:00
dni 4971f4c5c0 fix import 2026-07-09 08:05:59 +02:00
dni d157c43ebb remove old files 2026-07-09 08:05:59 +02:00
dni bb53fe41c8 remove uppgrade_hash 2026-07-09 08:05:59 +02:00
dni 5865a3f96a refactor: get rid of url rewrites for extensions /upgrades/
should open the way for custom root-path
2026-07-09 08:05:59 +02:00
Vlad StanandGitHub cabb58f8fe chore: make bundle (#4045) 2026-07-08 16:59:28 +03:00
aac04efd0e Merge commit from fork
* fix: don't show unsafe html in lnurlp notifications

* fix: lnurlp success aes decryption

* regenerate bundle

---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-07-08 16:49:43 +03:00
fd9009f760 feat: create TaskManager singleton class (#3775)
Co-authored-by: dadofsambonzuki <dadofsambonzuki@users.noreply.github.com>
2026-07-08 14:07:38 +02:00
dni ⚡andGitHub 6ab413775a chore: update to v1.5.6 (#4044) 2026-07-08 13:54:35 +02:00
7eb9965205 feat: keep funding source on default reset, force seed backup (#4040)
Co-authored-by: alan <alan@lnbits.com>
2026-07-08 13:54:11 +02:00
4634ad5a5a feat: use lnbits price aggregator to save loads of request (#4026)
Co-authored-by: alan <alan@lnbits.com>
2026-07-06 09:21:55 +02:00
dni ⚡andGitHub 648aaa17c5 CI: appimage upload-urls with gh upload command (#4036) 2026-07-03 12:35:14 +01:00
blackcoffeexbtandGitHub 7db5c986b3 NWC funding source: Reduce polling (#4008) 2026-07-02 13:00:49 +01:00
Vlad StanandGitHub 26c31b626d refactor: set extension field for payments (#4031) 2026-07-01 16:55:15 +03:00
Vlad StanandGitHub e39cb20525 chore: version bump 1.5.5 (#4029) 2026-07-01 12:06:55 +03:00
ArcandGitHub 3cea9a90d1 fix: nix install guide (#4032) 2026-06-30 14:08:49 +01:00
ArcandGitHub 3224e2e774 fix: appimage install (#4030) 2026-06-30 15:37:13 +03:00
dni ⚡andGitHub cfc9874517 chore: update to version 1.5.5-rc3 (#4024) 2026-06-24 12:24:41 +02:00
62cd151fd6 feat: add i18n for DynamicComponent (#4018)
Co-authored-by: alan <alan@lnbits.com>
2026-06-23 12:37:54 +02:00
a4d0aa5db8 fix(phoenixd): use descriptionHash when unhashed_description is provided (#3913)
Co-authored-by: Richard Taylor <RT@MacBookPro.lan>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-23 12:16:54 +03:00
dni ⚡andGitHub 77630781d7 fix: failing make bundle ci on forks. (#4019) 2026-06-23 08:15:13 +03:00
Vlad StanandGitHub 59da350cc9 fix: fiat payment status update (#4017) 2026-06-22 16:13:02 +03:00
29e980dd67 fix: non-blocking relay publishes in send_nostr_dm (#3925)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-06-18 15:28:28 +02:00
9d9ce63c82 feat: add llms.txt endpoint for AI agents (#3877)
Co-authored-by: Storm Knight <storm-knight@openclaw.ai>
Co-authored-by: ThomsenDrake <drake@verqor.me>
Co-authored-by: Arc <33088785+arcbtc@users.noreply.github.com>
Co-authored-by: dni  <office@dnilabs.com>
2026-06-18 15:15:01 +02:00
748f458b8b feat: add extension profiles (#3914)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-06-18 15:27:33 +03:00
Ben WeeksandGitHub ce177a73b1 fix: exponential backoff for IN_FLIGHT payment polling (#3918) 2026-06-18 14:47:03 +03:00
2885e71be2 fix: populate unit selector when opening send/pay dialog (#3953)
Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-06-18 11:50:27 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4b6f43d274 chore(deps): bump python-multipart from 0.0.29 to 0.0.31 (#4010)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-18 10:37:54 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
86b5cf9421 chore(deps): bump tornado from 6.5.6 to 6.5.7 (#4011)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-18 10:37:25 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8f5b7d85aa chore(deps): bump aiohttp from 3.13.5 to 3.14.1 (#4012)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-18 10:32:12 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4d51e63924 chore(deps): bump cryptography from 48.0.0 to 48.0.1 (#4013)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-18 10:30:50 +02:00
Nathan DayandGitHub 0ce2501e1a fix: run blocking SMTP calls in a thread to avoid blocking the event loop (#3988) 2026-06-17 17:59:21 +03:00
f2351145f0 fix: stop updating internal invoices inside the listener unlike external one (#3984)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-06-17 16:47:02 +03:00
2eb7d67b2a feat: use cached rate from exchange_rate_history_setting (#4001)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-06-17 15:16:38 +03:00
dni ⚡andGitHub a82093b7ec feat: electrum util for onchain data (#3999) 2026-06-17 13:16:52 +02:00
Vlad StanandGitHub ee595eede1 fix: revolut unsubscribe (#4009) 2026-06-16 11:48:32 +03:00
dfdce54e57 fix(lnd): enable MPP via max_parts=16 and time_pref in SendPaymentV2 (#3916)
Co-authored-by: dni  <office@dnilabs.com>
2026-06-08 15:33:48 +03:00
72 changed files with 3300 additions and 973 deletions
+1 -14
View File
@@ -7,10 +7,6 @@ on:
description: 'The tag name for the release' description: 'The tag name for the release'
required: true required: true
type: string type: string
upload_url:
description: 'The upload URL for the release'
required: true
type: string
workflow_dispatch: workflow_dispatch:
inputs: inputs:
@@ -18,10 +14,6 @@ on:
description: 'The tag name for the release' description: 'The tag name for the release'
required: true required: true
type: string type: string
upload_url:
description: 'The upload URL for the release'
required: true
type: string
jobs: jobs:
build-linux-package: build-linux-package:
@@ -113,11 +105,6 @@ jobs:
shell: bash shell: bash
- name: Upload Linux Release Asset - name: Upload Linux Release Asset
uses: actions/upload-release-asset@v1
with:
upload_url: ${{ inputs.upload_url }}
asset_path: ${{ env.APPIMAGE_NAME }}
asset_name: ${{ env.APPIMAGE_NAME }}
asset_content_type: application/octet-stream
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${{ inputs.tag_name }}" "${{ env.APPIMAGE_NAME }}" --clobber
+7 -3
View File
@@ -10,15 +10,16 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
ref: ${{ github.head_ref }} ref: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.head_ref || github.event.pull_request.head.sha }}
- uses: lnbits/lnbits/.github/actions/prepare@dev - uses: lnbits/lnbits/.github/actions/prepare@dev
with: with:
python-version: "3.10" python-version: "3.10"
node-version: "24.x" node-version: "24.x"
npm: true npm: true
- run: make bundle - name: Build and commit bundle (same-repo PR)
- name: Commit and push bundle changes if: github.event.pull_request.head.repo.full_name == github.repository
run: | run: |
make bundle
git config user.name "alan" git config user.name "alan"
git config user.email "alan@lnbits.com" git config user.email "alan@lnbits.com"
git add lnbits/static git add lnbits/static
@@ -27,3 +28,6 @@ jobs:
fi fi
git commit -m "chore: make bundle [skip ci]" git commit -m "chore: make bundle [skip ci]"
git push git push
- name: Check bundle is up-to-date (fork PR)
if: github.event.pull_request.head.repo.full_name != github.repository
run: make checkbundle
+1
View File
@@ -66,6 +66,7 @@ jobs:
BOLTZ_MNEMONIC: abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about BOLTZ_MNEMONIC: abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
LNBITS_MAX_OUTGOING_PAYMENT_AMOUNT_SATS: 1000000000 LNBITS_MAX_OUTGOING_PAYMENT_AMOUNT_SATS: 1000000000
LNBITS_MAX_INCOMING_PAYMENT_AMOUNT_SATS: 1000000000 LNBITS_MAX_INCOMING_PAYMENT_AMOUNT_SATS: 1000000000
LNBITS_FUNDING_SOURCE_PAY_INVOICE_WAIT_SECONDS: ${{ inputs.backend-wallet-class == 'CoreLightningRestWallet' && 60 || 5 }}
ECLAIR_PASS: lnbits ECLAIR_PASS: lnbits
PYTHONUNBUFFERED: 1 PYTHONUNBUFFERED: 1
DEBUG: true DEBUG: true
-11
View File
@@ -12,8 +12,6 @@ jobs:
release: release:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
outputs:
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Create github pre-release - name: Create github pre-release
@@ -22,14 +20,6 @@ jobs:
tag: ${{ github.ref_name }} tag: ${{ github.ref_name }}
run: | run: |
gh release create "$tag" --prerelease --generate-notes --draft gh release create "$tag" --prerelease --generate-notes --draft
- id: get_upload_url
name: Get upload url of Github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
upload_url=$(gh release view "$tag" --json uploadUrl -q ".uploadUrl")
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
docker: docker:
if: github.repository == 'lnbits/lnbits' if: github.repository == 'lnbits/lnbits'
@@ -74,4 +64,3 @@ jobs:
uses: ./.github/workflows/appimage.yml uses: ./.github/workflows/appimage.yml
with: with:
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
upload_url: ${{ needs.release.outputs.upload_url }}
-11
View File
@@ -13,8 +13,6 @@ jobs:
release: release:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
outputs:
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Create github release - name: Create github release
@@ -23,14 +21,6 @@ jobs:
tag: ${{ github.ref_name }} tag: ${{ github.ref_name }}
run: | run: |
gh release create "$tag" --generate-notes --draft gh release create "$tag" --generate-notes --draft
- id: get_upload_url
name: Get upload url of Github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
upload_url=$(gh release view "$tag" --json uploadUrl -q ".uploadUrl")
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
docker: docker:
if: github.repository == 'lnbits/lnbits' if: github.repository == 'lnbits/lnbits'
@@ -85,4 +75,3 @@ jobs:
uses: ./.github/workflows/appimage.yml uses: ./.github/workflows/appimage.yml
with: with:
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
upload_url: ${{ needs.release.outputs.upload_url }}
+2 -5
View File
@@ -51,7 +51,7 @@ nav_order: 1
sudo apt-get install jq libfuse2 sudo apt-get install jq libfuse2
wget $(curl -s https://api.github.com/repos/lnbits/lnbits/releases/latest | jq -r '.assets[] | select(.name | endswith(".AppImage")) | .browser_download_url') -O LNbits-latest.AppImage wget $(curl -s https://api.github.com/repos/lnbits/lnbits/releases/latest | jq -r '.assets[] | select(.name | endswith(".AppImage")) | .browser_download_url') -O LNbits-latest.AppImage
chmod +x LNbits-latest.AppImage chmod +x LNbits-latest.AppImage
LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 AUTH_HTTPS_ONLY=false ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here
``` ```
- LNbits will create a folder for DB and extension files **in the same directory** as the AppImage. - LNbits will create a folder for DB and extension files **in the same directory** as the AppImage.
@@ -285,10 +285,7 @@ but you can also set the env variables or pass command line arguments:
```sh ```sh
# .env variables are currently passed when running, but LNbits can be managed with the admin UI. # .env variables are currently passed when running, but LNbits can be managed with the admin UI.
LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000 --host 0.0.0.0 LNBITS_ADMIN_UI=true AUTH_HTTPS_ONLY=false ./result/bin/lnbits --port 9000 --host 0.0.0.0
# Once you have created a user, you can set as the super_user
SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
``` ```
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949) > ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949)
+101 -45
View File
@@ -23,29 +23,35 @@ from lnbits.core.crud import (
get_installed_extensions, get_installed_extensions,
update_installed_extension_state, update_installed_extension_state,
) )
from lnbits.core.crud.audit import delete_expired_audit_entries
from lnbits.core.crud.extensions import create_installed_extension from lnbits.core.crud.extensions import create_installed_extension
from lnbits.core.helpers import migrate_extension_database from lnbits.core.helpers import migrate_extension_database
from lnbits.core.models.notifications import NotificationType from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions
from lnbits.core.services.notifications import enqueue_admin_notification from lnbits.core.services.funding_source import (
from lnbits.core.services.payments import check_pending_payments check_balance_delta_changed,
check_server_balance_against_node,
)
from lnbits.core.services.notifications import (
dispatch_payment_notification,
enqueue_admin_notification,
process_next_notification,
)
from lnbits.core.services.payments import (
check_pending_payments,
fundingsource_invoice_producer,
)
from lnbits.core.tasks import ( from lnbits.core.tasks import (
audit_queue, audit_queue,
collect_exchange_rates_data, collect_exchange_rates_data,
purge_audit_data, notify_server_status,
run_by_the_minute_tasks, process_next_audit_entry,
wait_for_audit_data, refresh_extension_cache,
wait_for_paid_invoices,
wait_notification_messages,
) )
from lnbits.exceptions import register_exception_handlers from lnbits.exceptions import register_exception_handlers
from lnbits.helpers import version_parse from lnbits.helpers import version_parse
from lnbits.llms_txt import create_llms_txt_route
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.tasks import (
cancel_all_tasks,
create_permanent_task,
register_invoice_listener,
)
from lnbits.utils.cache import cache from lnbits.utils.cache import cache
from lnbits.utils.logger import ( from lnbits.utils.logger import (
configure_logger, configure_logger,
@@ -68,7 +74,7 @@ from .middleware import (
add_profiler_middleware, add_profiler_middleware,
add_ratelimit_middleware, add_ratelimit_middleware,
) )
from .tasks import internal_invoice_listener, invoice_listener, run_interval from .task_manager import task_manager
async def startup(app: FastAPI): async def startup(app: FastAPI):
@@ -102,6 +108,9 @@ async def startup(app: FastAPI):
# register core routes # register core routes
init_core_routers(app) init_core_routers(app)
# register llms.txt endpoint for AI agents
create_llms_txt_route(app)
# initialize tasks # initialize tasks
register_async_tasks() register_async_tasks()
@@ -129,7 +138,7 @@ async def shutdown():
settings.lnbits_running = False settings.lnbits_running = False
# shutdown event # shutdown event
cancel_all_tasks() task_manager.cancel_all_tasks()
# wait a bit to allow them to finish, so that cleanup can run without problems # wait a bit to allow them to finish, so that cleanup can run without problems
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
@@ -377,7 +386,6 @@ def register_custom_extensions_path():
upgrades_dir = settings.lnbits_extensions_upgrade_path upgrades_dir = settings.lnbits_extensions_upgrade_path
shutil.rmtree(upgrades_dir, True) shutil.rmtree(upgrades_dir, True)
Path(upgrades_dir).mkdir(parents=True, exist_ok=True) Path(upgrades_dir).mkdir(parents=True, exist_ok=True)
sys.path.append(str(upgrades_dir))
if settings.has_default_extension_path: if settings.has_default_extension_path:
return return
@@ -432,10 +440,54 @@ def register_ext_tasks(ext: Extension) -> None:
def register_ext_routes(app: FastAPI, ext: Extension) -> None: def register_ext_routes(app: FastAPI, ext: Extension) -> None:
"""Register FastAPI routes for extension.""" """Register FastAPI routes for extension."""
ext_module = importlib.import_module(ext.module_name) module_name = ext.module_name
# Clear all cached sub-modules so a fresh import picks up new files from ext_dir.
# A simple reload() would reuse cached sub-modules (e.g. views_api) and serve
# stale code even after the extension files have been replaced on disk.
stale = [
k for k in sys.modules if k == module_name or k.startswith(f"{module_name}.")
]
for k in stale:
del sys.modules[k]
if stale:
# Pydantic v1 keeps a global _FUNCS set of validator qualnames to detect
# duplicates. Clear the extension's entries so reimport doesn't raise
# "duplicate validator" errors for validators with the same qualname.
try:
import pydantic.class_validators as _pydantic_cv
_pydantic_cv._FUNCS = {
f for f in _pydantic_cv._FUNCS if not f.startswith(f"{module_name}.")
}
except (ImportError, AttributeError):
pass
ext_module = importlib.import_module(module_name)
ext_route = getattr(ext_module, f"{ext.code}_ext") ext_route = getattr(ext_module, f"{ext.code}_ext")
ext_redirects = (
getattr(ext_module, f"{ext.code}_redirect_paths")
if hasattr(ext_module, f"{ext.code}_redirect_paths")
else []
)
settings.activate_extension_paths(ext.code, ext_redirects)
# Remove existing routes for this extension before re-registering so that
# an upgraded extension replaces the old one at the same paths (no prefix).
ext_prefix = f"/{ext.code}"
app.router.routes = [
r
for r in app.router.routes
if not (
getattr(r, "path", "") == ext_prefix
or getattr(r, "path", "").startswith(f"{ext_prefix}/")
)
]
# Invalidate FastAPI's cached OpenAPI schema so the next /openapi.json
# request reflects the updated routes.
app.openapi_schema = None
if hasattr(ext_module, f"{ext.code}_static_files"): if hasattr(ext_module, f"{ext.code}_static_files"):
ext_statics = getattr(ext_module, f"{ext.code}_static_files") ext_statics = getattr(ext_module, f"{ext.code}_static_files")
for s in ext_statics: for s in ext_statics:
@@ -444,17 +496,8 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
) )
app.mount(s["path"], StaticFiles(directory=static_dir), s["name"]) app.mount(s["path"], StaticFiles(directory=static_dir), s["name"])
ext_redirects = (
getattr(ext_module, f"{ext.code}_redirect_paths")
if hasattr(ext_module, f"{ext.code}_redirect_paths")
else []
)
settings.activate_extension_paths(ext.code, ext.upgrade_hash, ext_redirects)
logger.trace(f"Adding route for extension {ext_module}.") logger.trace(f"Adding route for extension {ext_module}.")
prefix = f"/upgrades/{ext.upgrade_hash}" if ext.upgrade_hash != "" else "" app.include_router(router=ext_route)
app.include_router(router=ext_route, prefix=prefix)
async def check_and_register_extensions(app: FastAPI) -> None: async def check_and_register_extensions(app: FastAPI) -> None:
@@ -469,29 +512,42 @@ async def check_and_register_extensions(app: FastAPI) -> None:
def register_async_tasks() -> None: def register_async_tasks() -> None:
create_permanent_task(wait_for_audit_data) task_manager.init()
create_permanent_task(wait_notification_messages)
create_permanent_task( # listen to all incoming payments and dispatch payment notifications
run_interval( # note: should be the first in task list for a bit quicker notifications
settings.lnbits_funding_source_pending_interval_seconds, task_manager.register_invoice_listener(dispatch_payment_notification, "core")
check_pending_payments,
) # periodic tasks
task_manager.create_permanent_task(cache.invalidate_cache, interval=10)
task_manager.create_permanent_task(delete_expired_audit_entries, interval=60 * 60)
task_manager.create_permanent_task(
check_pending_payments,
interval=settings.lnbits_funding_source_pending_interval_seconds,
) )
create_permanent_task(invoice_listener) task_manager.create_permanent_task(
create_permanent_task(internal_invoice_listener) collect_exchange_rates_data,
create_permanent_task(cache.invalidate_forever) interval=max(60, settings.lnbits_exchange_history_refresh_interval_seconds),
)
task_manager.create_permanent_task(check_balance_delta_changed, interval=60)
task_manager.create_permanent_task(
check_server_balance_against_node,
interval=60 * settings.lnbits_watchdog_interval_minutes,
)
task_manager.create_permanent_task(
notify_server_status,
interval=60 * 60 * settings.lnbits_notification_server_status_hours,
)
task_manager.create_permanent_task(refresh_extension_cache, interval=60)
# core invoice listener # permanent tasks run in a loop, will be restarted if they fail
invoice_queue: asyncio.Queue = asyncio.Queue() task_manager.create_permanent_task(fundingsource_invoice_producer)
register_invoice_listener(invoice_queue, "core") task_manager.create_permanent_task(process_next_notification)
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue)) task_manager.create_permanent_task(process_next_audit_entry)
create_permanent_task(run_by_the_minute_tasks)
create_permanent_task(purge_audit_data)
create_permanent_task(collect_exchange_rates_data)
# server logs for websocket # server logs for websocket
if settings.lnbits_admin_ui: if settings.lnbits_admin_ui:
server_log_task = initialize_server_websocket_logger() server_log_task = initialize_server_websocket_logger()
create_permanent_task(server_log_task) task_manager.create_permanent_task(
server_log_task, name="server_websocket_logger"
)
-5
View File
@@ -1,6 +1,5 @@
import asyncio import asyncio
import importlib import importlib
import sys
import time import time
from functools import wraps from functools import wraps
from getpass import getpass from getpass import getpass
@@ -377,10 +376,6 @@ async def extensions_update( # noqa: C901
if not await _can_run_operation(url): if not await _can_run_operation(url):
return return
upgrades_dir = settings.lnbits_extensions_upgrade_path
Path(upgrades_dir).mkdir(parents=True, exist_ok=True)
sys.path.append(str(upgrades_dir))
if extension: if extension:
await update_extension(extension, repo_index, source_repo, url, admin_user) await update_extension(extension, repo_index, source_repo, url, admin_user)
return return
+1
View File
@@ -290,6 +290,7 @@ async def create_payment(
webhook=data.webhook, webhook=data.webhook,
fee=-abs(data.fee), fee=-abs(data.fee),
tag=extra.get("tag", None), tag=extra.get("tag", None),
extension=data.extension,
extra=extra, extra=extra,
labels=data.labels or [], labels=data.labels or [],
external_id=data.external_id, external_id=data.external_id,
+15 -10
View File
@@ -8,11 +8,18 @@ from lnbits.db import dict_to_model
from lnbits.settings import ( from lnbits.settings import (
AdminSettings, AdminSettings,
EditableSettings, EditableSettings,
FundingSourcesSettings,
SettingsField, SettingsField,
SuperSettings, SuperSettings,
settings, settings,
) )
RESET_PRESERVED_SETTINGS = (
"lnbits_webpush_pubkey",
"lnbits_webpush_privkey",
*FundingSourcesSettings.__fields__,
)
async def get_super_settings() -> SuperSettings | None: async def get_super_settings() -> SuperSettings | None:
data = await get_settings_by_tag("core") data = await get_settings_by_tag("core")
@@ -69,16 +76,14 @@ async def delete_admin_settings(tag: str | None = "core") -> None:
async def reset_core_settings() -> None: async def reset_core_settings() -> None:
await db.execute( core_settings = await get_settings_by_tag("core") or {}
""" super_user = await get_settings_field("super_user")
DELETE FROM system_settings WHERE tag = 'core' await delete_admin_settings()
AND id NOT IN ( if super_user:
'super_user', await set_settings_field("super_user", super_user.value)
'lnbits_webpush_pubkey', for field in RESET_PRESERVED_SETTINGS:
'lnbits_webpush_privkey' if field in core_settings:
) await set_settings_field(field, core_settings[field])
""",
)
async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSettings: async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSettings:
+19 -16
View File
@@ -6,7 +6,6 @@ import json
import os import os
import shutil import shutil
import zipfile import zipfile
from asyncio.tasks import create_task
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -21,6 +20,7 @@ from lnbits.helpers import (
version_parse, version_parse,
) )
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.task_manager import task_manager
from lnbits.utils.cache import cache from lnbits.utils.cache import cache
@@ -55,9 +55,10 @@ class GitHubRelease(BaseModel):
class Manifest(BaseModel): class Manifest(BaseModel):
featured: list[str] = []
extensions: list[ExplicitRelease] = [] extensions: list[ExplicitRelease] = []
repos: list[GitHubRelease] = [] repos: list[GitHubRelease] = []
featured: list[str] = []
categories: dict[str, list[str]] = {}
class GitHubRepoRelease(BaseModel): class GitHubRepoRelease(BaseModel):
@@ -146,21 +147,13 @@ class Extension(BaseModel):
name: str | None = None name: str | None = None
short_description: str | None = None short_description: str | None = None
tile: str | None = None tile: str | None = None
upgrade_hash: str | None = ""
@property @property
def module_name(self) -> str: def module_name(self) -> str:
if self.is_upgrade_extension:
return f"{self.code}-{self.upgrade_hash}"
if settings.has_default_extension_path: if settings.has_default_extension_path:
return f"lnbits.extensions.{self.code}" return f"lnbits.extensions.{self.code}"
return self.code return self.code
@property
def is_upgrade_extension(self) -> bool:
return self.upgrade_hash != ""
@classmethod @classmethod
def from_installable_ext(cls, ext_info: InstallableExtension) -> Extension: def from_installable_ext(cls, ext_info: InstallableExtension) -> Extension:
return Extension( return Extension(
@@ -169,7 +162,6 @@ class Extension(BaseModel):
name=ext_info.name, name=ext_info.name,
short_description=ext_info.short_description, short_description=ext_info.short_description,
tile=ext_info.icon, tile=ext_info.icon,
upgrade_hash=ext_info.hash if ext_info.ext_upgrade_dir.is_dir() else "",
) )
@@ -308,7 +300,6 @@ class ExtensionRelease(BaseModel):
@classmethod @classmethod
async def fetch_release_details(cls, details_link: str) -> dict | None: async def fetch_release_details(cls, details_link: str) -> dict | None:
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.get(details_link) resp = await client.get(details_link)
@@ -333,6 +324,7 @@ class ExtensionMeta(BaseModel):
dependencies: list[str] = [] dependencies: list[str] = []
archive: str | None = None archive: str | None = None
featured: bool = False featured: bool = False
categories: list[str] = []
paid_features: str | None = None paid_features: str | None = None
has_paid_release: bool = False has_paid_release: bool = False
has_free_release: bool = False has_free_release: bool = False
@@ -374,9 +366,6 @@ class InstallableExtension(BaseModel):
@property @property
def module_name(self) -> str: def module_name(self) -> str:
if self.ext_upgrade_dir.is_dir():
return f"{self.id}-{self.hash}"
if settings.has_default_extension_path: if settings.has_default_extension_path:
return f"lnbits.extensions.{self.id}" return f"lnbits.extensions.{self.id}"
return self.id return self.id
@@ -467,6 +456,7 @@ class InstallableExtension(BaseModel):
shutil.rmtree(self.ext_dir, True) shutil.rmtree(self.ext_dir, True)
shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir)) shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir))
shutil.rmtree(self.ext_upgrade_dir, True)
logger.info(f"Extension {self.name} ({self.installed_version}) extracted.") logger.info(f"Extension {self.name} ({self.installed_version}) extracted.")
def clean_extension_files(self): def clean_extension_files(self):
@@ -641,7 +631,10 @@ class InstallableExtension(BaseModel):
if cache_value.older_than(10 * 60) or post_refresh_cache: if cache_value.older_than(10 * 60) or post_refresh_cache:
# refresh cache in background if older than 10 minutes or requested # refresh cache in background if older than 10 minutes or requested
create_task(cls._refresh_installable_extensions_cache()) task_manager.create_task(
cls._refresh_installable_extensions_cache(),
"refresh_installable_extensions_cache",
)
extension_list = cache_value.value # type: ignore extension_list = cache_value.value # type: ignore
return extension_list return extension_list
@@ -680,6 +673,11 @@ class InstallableExtension(BaseModel):
meta = ext.meta or ExtensionMeta() meta = ext.meta or ExtensionMeta()
meta.featured = ext.id in manifest.featured meta.featured = ext.id in manifest.featured
meta.categories = [
category
for category, ext_ids in manifest.categories.items()
if ext.id in ext_ids
]
ext.meta = meta ext.meta = meta
extension_list += [ext] extension_list += [ext]
@@ -695,6 +693,11 @@ class InstallableExtension(BaseModel):
ext.check_release_updates(release) ext.check_release_updates(release)
meta = ext.meta or ExtensionMeta() meta = ext.meta or ExtensionMeta()
meta.featured = ext.id in manifest.featured meta.featured = ext.id in manifest.featured
meta.categories = [
category
for category, ext_ids in manifest.categories.items()
if ext.id in ext_ids
]
ext.meta = meta ext.meta = meta
extension_list += [ext] extension_list += [ext]
except Exception as e: except Exception as e:
+6 -8
View File
@@ -54,6 +54,7 @@ class CreatePayment(BaseModel):
amount_msat: int amount_msat: int
memo: str memo: str
extra: dict | None = {} extra: dict | None = {}
extension: str | None = None
preimage: str | None = None preimage: str | None = None
expiry: datetime | None = None expiry: datetime | None = None
webhook: str | None = None webhook: str | None = None
@@ -140,22 +141,18 @@ class Payment(BaseModel):
) )
# DEPRECATED: in v1.5.0, use service check_payment_status instead # DEPRECATED: in v1.5.0, use service check_payment_status instead
async def check_status( async def check_status(self) -> PaymentStatus:
self, skip_internal_payment_notifications: bool | None = False
) -> PaymentStatus:
logger.warning("payment.check_status() is deprecated.") logger.warning("payment.check_status() is deprecated.")
from lnbits.core.services.payments import check_payment_status from lnbits.core.services.payments import check_payment_status
return await check_payment_status(self, skip_internal_payment_notifications) return await check_payment_status(self)
# DEPRECATED: in v1.5.0, use service check_payment_status instead # DEPRECATED: in v1.5.0, use service check_payment_status instead
async def check_fiat_status( async def check_fiat_status(self) -> FiatPaymentStatus:
self, skip_internal_payment_notifications: bool | None = False
) -> FiatPaymentStatus:
logger.warning("payment.check_fiat_status() is deprecated.") logger.warning("payment.check_fiat_status() is deprecated.")
from lnbits.core.services.fiat_providers import check_fiat_status from lnbits.core.services.fiat_providers import check_fiat_status
return await check_fiat_status(self, skip_internal_payment_notifications) return await check_fiat_status(self)
class PaymentFilters(FilterModel): class PaymentFilters(FilterModel):
@@ -263,6 +260,7 @@ class CreateInvoice(BaseModel):
) )
expiry: int | None = None expiry: int | None = None
extra: dict | None = None extra: dict | None = None
extension: str | None = None
webhook: str | None = None webhook: str | None = None
bolt11: str | None = None bolt11: str | None = None
lnurl_withdraw: LnurlWithdrawResponse | None = None lnurl_withdraw: LnurlWithdrawResponse | None = None
+15 -19
View File
@@ -56,14 +56,10 @@ async def install_extension(
else: else:
await update_installed_extension(ext_info) await update_installed_extension(ext_info)
extension = Extension.from_installable_ext(ext_info) if installed_ext:
if extension.is_upgrade_extension:
# call stop while the old routes are still active
await stop_extension_background_work(ext_info.id) await stop_extension_background_work(ext_info.id)
await start_extension_background_work(ext_info.id) return Extension.from_installable_ext(ext_info)
return extension
async def check_extensions_limit(installed_ext: InstallableExtension | None = None): async def check_extensions_limit(installed_ext: InstallableExtension | None = None):
@@ -103,16 +99,16 @@ async def stop_extension_background_work(ext_id: str) -> bool:
Stop background work for extension (like asyncio.Tasks, WebSockets, etc). Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
Extension must expose a `myextension_stop()` function if it is starting tasks. Extension must expose a `myextension_stop()` function if it is starting tasks.
""" """
upgrade_hash = settings.extension_upgrade_hash(ext_id) ext = Extension(code=ext_id, is_valid=True)
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash) module_name = ext.module_name
try: try:
logger.info(f"Stopping background work for extension '{ext.module_name}'.") logger.info(f"Stopping background work for extension '{module_name}'.")
old_module = importlib.import_module(ext.module_name) old_module = importlib.import_module(module_name)
stop_fn_name = f"{ext_id}_stop" stop_fn_name = f"{ext_id}_stop"
if not hasattr(old_module, stop_fn_name): if not hasattr(old_module, stop_fn_name):
raise ValueError(f"No stop function found for '{ext.module_name}'.") raise ValueError(f"No stop function found for '{module_name}'.")
stop_fn = getattr(old_module, stop_fn_name) stop_fn = getattr(old_module, stop_fn_name)
if stop_fn: if stop_fn:
@@ -120,9 +116,9 @@ async def stop_extension_background_work(ext_id: str) -> bool:
await stop_fn() await stop_fn()
else: else:
stop_fn() stop_fn()
logger.info(f"Stopped background work for extension '{ext.module_name}'.") logger.info(f"Stopped background work for extension '{module_name}'.")
except Exception as ex: except Exception as ex:
logger.warning(f"Failed to stop background work for '{ext.module_name}'.") logger.warning(f"Failed to stop background work for '{module_name}'.")
logger.warning(ex) logger.warning(ex)
return False return False
@@ -135,12 +131,12 @@ async def start_extension_background_work(ext_id: str) -> bool:
Extension CAN expose a `myextension_start()` function if it is starting tasks. Extension CAN expose a `myextension_start()` function if it is starting tasks.
Extension MUST expose a `myextension_stop()` in that case. Extension MUST expose a `myextension_stop()` in that case.
""" """
upgrade_hash = settings.extension_upgrade_hash(ext_id) ext = Extension(code=ext_id, is_valid=True)
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash) module_name = ext.module_name
try: try:
logger.info(f"Starting background work for extension '{ext.module_name}'.") logger.info(f"Starting background work for extension '{module_name}'.")
new_module = importlib.import_module(ext.module_name) new_module = importlib.import_module(module_name)
start_fn_name = f"{ext_id}_start" start_fn_name = f"{ext_id}_start"
# start function is optional, return False if not found # start function is optional, return False if not found
@@ -153,10 +149,10 @@ async def start_extension_background_work(ext_id: str) -> bool:
await start_fn() await start_fn()
else: else:
start_fn() start_fn()
logger.info(f"Started background work for extension '{ext.module_name}'.") logger.info(f"Started background work for extension '{module_name}'.")
return True return True
except Exception as ex: except Exception as ex:
logger.warning(f"Failed to start background work for '{ext.module_name}'.") logger.warning(f"Failed to start background work for '{module_name}'.")
logger.warning(ex) logger.warning(ex)
return False return False
+7 -11
View File
@@ -8,7 +8,7 @@ import httpx
from loguru import logger from loguru import logger
from lnbits.core.crud import get_wallet from lnbits.core.crud import get_wallet
from lnbits.core.crud.payments import create_payment from lnbits.core.crud.payments import create_payment, update_payment
from lnbits.core.models import CreatePayment, Payment, PaymentState from lnbits.core.models import CreatePayment, Payment, PaymentState
from lnbits.core.models.misc import SimpleStatus from lnbits.core.models.misc import SimpleStatus
from lnbits.db import Connection from lnbits.db import Connection
@@ -20,6 +20,7 @@ from lnbits.fiat.base import (
FiatPaymentSuccessStatus, FiatPaymentSuccessStatus,
) )
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.task_manager import task_manager
async def handle_fiat_payment_confirmation( async def handle_fiat_payment_confirmation(
@@ -36,9 +37,7 @@ async def handle_fiat_payment_confirmation(
logger.warning(e) logger.warning(e)
async def check_fiat_status( async def check_fiat_status(payment: Payment) -> FiatPaymentStatus:
payment: Payment, skip_internal_payment_notifications: bool | None = False
) -> FiatPaymentStatus:
if not payment.is_internal: if not payment.is_internal:
return FiatPaymentPendingStatus() return FiatPaymentPendingStatus()
if payment.success: if payment.success:
@@ -58,14 +57,11 @@ async def check_fiat_status(
return FiatPaymentPendingStatus() return FiatPaymentPendingStatus()
fiat_status = await fiat_provider.get_invoice_status(checking_id) fiat_status = await fiat_provider.get_invoice_status(checking_id)
if skip_internal_payment_notifications:
return fiat_status
if fiat_status.success: if fiat_status.success:
# notify receivers asynchronously payment.status = PaymentState.SUCCESS.value
from lnbits.tasks import internal_invoice_queue await update_payment(payment)
await handle_fiat_payment_confirmation(payment)
await internal_invoice_queue.put(payment.checking_id) task_manager.internal_invoice_queue.put_nowait(payment)
return fiat_status return fiat_status
+2
View File
@@ -66,6 +66,8 @@ async def check_server_balance_against_node():
async def check_balance_delta_changed(): async def check_balance_delta_changed():
if settings.notification_balance_delta_threshold_sats <= 0:
return
status = await get_balance_delta() status = await get_balance_delta()
if settings.latest_balance_delta_sats is None: if settings.latest_balance_delta_sats is None:
settings.latest_balance_delta_sats = status.delta_sats settings.latest_balance_delta_sats = status.delta_sats
+44 -6
View File
@@ -74,7 +74,7 @@ async def send_admin_notification(
message: str, message: str,
message_type: str | None = None, message_type: str | None = None,
) -> None: ) -> None:
return await send_notification( return await send_notification_in_background(
settings.lnbits_telegram_notifications_chat_id, settings.lnbits_telegram_notifications_chat_id,
settings.lnbits_nostr_notifications_identifiers, settings.lnbits_nostr_notifications_identifiers,
settings.lnbits_email_notifications_to_emails, settings.lnbits_email_notifications_to_emails,
@@ -97,7 +97,7 @@ async def send_user_notification(
if user_notifications.nostr_identifier if user_notifications.nostr_identifier
else [] else []
) )
return await send_notification( return await send_notification_in_background(
user_notifications.telegram_chat_id, user_notifications.telegram_chat_id,
nostr_identifiers, nostr_identifiers,
email_address, email_address,
@@ -222,12 +222,29 @@ async def send_email(
msg["Subject"] = subject msg["Subject"] = subject
msg.attach(MIMEText(message, "plain")) msg.attach(MIMEText(message, "plain"))
username = username if len(username) > 0 else from_email username = username if len(username) > 0 else from_email
with smtplib.SMTP(server, port) as smtp_server:
smtp_server.starttls() def _send() -> bool:
smtp_server.login(username, password) with smtplib.SMTP(server, port) as smtp_server:
smtp_server.sendmail(from_email, to_emails, msg.as_string()) smtp_server.starttls()
smtp_server.login(username, password)
smtp_server.sendmail(from_email, to_emails, msg.as_string())
return True return True
try:
return await asyncio.to_thread(_send)
except Exception as e:
logger.warning(f"Sending Email failed. {e!s}")
return False
async def dispatch_payment_notification(payment: Payment) -> None:
"""
This worker dispatches the payment notifications.
"""
wallet = await get_wallet(payment.wallet_id)
if wallet:
await send_payment_notification(wallet, payment)
async def dispatch_webhook(payment: Payment): async def dispatch_webhook(payment: Payment):
""" """
@@ -294,6 +311,27 @@ def send_payment_notification_in_background(wallet: Wallet, payment: Payment):
logger.warning(f"Error sending payment notification: {e}") logger.warning(f"Error sending payment notification: {e}")
async def send_notification_in_background(
telegram_chat_id: str | None,
nostr_identifiers: list[str] | None,
email_addresses: list[str] | None,
message: str,
message_type: str | None = None,
):
try:
create_task(
send_notification(
telegram_chat_id,
nostr_identifiers,
email_addresses,
message,
message_type,
)
)
except Exception as e:
logger.warning(f"Error sending notification in background: {e}")
async def send_ws_payment_notification(wallet: Wallet, payment: Payment): async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
# TODO: websocket message should be a clean payment model # TODO: websocket message should be a clean payment model
# await websocket_manager.send(wallet.inkey, payment.json()) # await websocket_manager.send(wallet.inkey, payment.json())
+47 -33
View File
@@ -13,13 +13,13 @@ from lnbits.core.crud.payments import get_daily_stats
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.core.models import PaymentDailyStats, PaymentFilters from lnbits.core.models import PaymentDailyStats, PaymentFilters
from lnbits.core.models.payments import CreateInvoice from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services.fiat_providers import handle_fiat_payment_confirmation
from lnbits.db import Connection, Filters from lnbits.db import Connection, Filters
from lnbits.decorators import check_user_extension_access from lnbits.decorators import check_user_extension_access
from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError
from lnbits.fiat import get_fiat_provider from lnbits.fiat import get_fiat_provider
from lnbits.helpers import check_callback_url from lnbits.helpers import check_callback_url
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.task_manager import task_manager
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_preimage from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_preimage
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
from lnbits.wallets import fake_wallet, get_funding_source from lnbits.wallets import fake_wallet, get_funding_source
@@ -215,6 +215,7 @@ async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
unhashed_description=unhashed_description, unhashed_description=unhashed_description,
expiry=data.expiry, expiry=data.expiry,
extra=data.extra, extra=data.extra,
extension=data.extension,
webhook=data.webhook, webhook=data.webhook,
internal=data.internal, internal=data.internal,
payment_hash=data.payment_hash, payment_hash=data.payment_hash,
@@ -260,6 +261,7 @@ async def create_invoice(
webhook: str | None = None, webhook: str | None = None,
internal: bool | None = False, internal: bool | None = False,
payment_hash: str | None = None, payment_hash: str | None = None,
extension: str | None = None,
labels: list[str] | None = None, labels: list[str] | None = None,
external_id: str | None = None, external_id: str | None = None,
conn: Connection | None = None, conn: Connection | None = None,
@@ -343,6 +345,7 @@ async def create_invoice(
expiry=invoice.expiry_date, expiry=invoice.expiry_date,
memo=memo, memo=memo,
extra=extra, extra=extra,
extension=extension,
webhook=webhook, webhook=webhook,
fee=invoice_response.fee_msat or 0, fee=invoice_response.fee_msat or 0,
labels=labels, labels=labels,
@@ -514,9 +517,7 @@ async def update_wallet_balance(
) )
payment.status = PaymentState.SUCCESS payment.status = PaymentState.SUCCESS
await update_payment(payment, conn=conn) await update_payment(payment, conn=conn)
from lnbits.tasks import internal_invoice_queue_put task_manager.internal_invoice_queue.put_nowait(payment)
await internal_invoice_queue_put(payment.checking_id)
async def check_wallet_limits( async def check_wallet_limits(
@@ -630,18 +631,14 @@ async def check_transaction_status(
return await check_payment_status(payment) return await check_payment_status(payment)
async def check_payment_status( async def check_payment_status(payment: Payment) -> PaymentStatus:
payment: Payment, skip_internal_payment_notifications: bool | None = False
) -> PaymentStatus:
if payment.is_internal: if payment.is_internal:
if payment.success: if payment.success:
return PaymentSuccessStatus() return PaymentSuccessStatus()
if payment.failed: if payment.failed:
return PaymentFailedStatus() return PaymentFailedStatus()
if payment.is_in and payment.fiat_provider: if payment.is_in and payment.fiat_provider:
fiat_status = await check_fiat_status( fiat_status = await check_fiat_status(payment)
payment, skip_internal_payment_notifications
)
return PaymentStatus(paid=fiat_status.paid) return PaymentStatus(paid=fiat_status.paid)
return PaymentPendingStatus() return PaymentPendingStatus()
funding_source = get_funding_source() funding_source = get_funding_source()
@@ -783,13 +780,16 @@ async def _pay_internal_invoice(
await update_payment(internal_payment, conn=conn) await update_payment(internal_payment, conn=conn)
logger.success(f"internal payment successful {internal_payment.checking_id}") logger.success(f"internal payment successful {internal_payment.checking_id}")
await _send_payment_notification_in_background(wallet.id, payment, conn=conn) await _send_payment_notification_in_background(
wallet.id, payment, conn=conn
# notify receiver asynchronously ) # notify the sender
from lnbits.tasks import internal_invoice_queue await _send_payment_notification_in_background(
internal_payment.wallet_id, internal_payment, conn=conn
) # notify the receiver
# notify receiver asynchronously (extension listeners)
logger.debug(f"enqueuing internal invoice {internal_payment.checking_id}") logger.debug(f"enqueuing internal invoice {internal_payment.checking_id}")
await internal_invoice_queue.put(internal_payment.checking_id) task_manager.internal_invoice_queue.put_nowait(internal_payment)
return payment return payment
@@ -826,16 +826,15 @@ async def _pay_external_invoice(
fee_reserve_msat = fee_reserve(amount_msat, internal=False) fee_reserve_msat = fee_reserve(amount_msat, internal=False)
from lnbits.tasks import create_task task = task_manager.create_task(
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat),
task = create_task( f"fundingsource_pay_invoice_{checking_id}",
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
) )
# make sure a hold invoice or deferred payment is not blocking the server # make sure a hold invoice or deferred payment is not blocking the server
wait_time = max(1, settings.lnbits_funding_source_pay_invoice_wait_seconds) wait_time = max(1, settings.lnbits_funding_source_pay_invoice_wait_seconds)
try: try:
payment_response = await asyncio.wait_for(task, timeout=wait_time) payment_response = await asyncio.wait_for(task.task, timeout=wait_time)
except asyncio.TimeoutError: except asyncio.TimeoutError:
# return pending payment on timeout # return pending payment on timeout
logger.debug( logger.debug(
@@ -1079,29 +1078,44 @@ async def _send_payment_notification_in_background(
send_payment_notification_in_background(wallet, payment) send_payment_notification_in_background(wallet, payment)
async def update_invoice_callback(checking_id: str) -> Payment | None: async def update_invoice_from_paid_invoices_stream(checking_id: str) -> Payment | None:
""" """
Takes a checking_id of an incoming payment, from either paid_invoices_stream() Takes a checking_id of an incoming payment from paid_invoices_stream()
or internal_invoice_queue. Checks its status, updates and returns it. Checks its status, updates its status and returns it.
returns None if no payment was found or it not and incoming payment. returns None if no incoming payment was found or the status is not successful
""" """
payment = await get_standalone_payment(checking_id, incoming=True) payment = await get_standalone_payment(checking_id, incoming=True)
if not payment: if not payment:
logger.warning(f"No payment found for '{checking_id}'.") logger.warning(f"No incoming payment found for '{checking_id}'.")
return None return None
if not payment.is_in:
logger.warning(f"Payment '{checking_id}' is not incoming, skipping.") status = await check_payment_status(payment)
if not status.success:
logger.error(
"Unexpected status response from paid_invoices_stream. Skipping update."
)
return None return None
status = await check_payment_status(
payment, skip_internal_payment_notifications=True
)
payment.fee = status.fee_msat or payment.fee payment.fee = status.fee_msat or payment.fee
# only overwrite preimage if status.preimage provides it # only overwrite preimage if status.preimage provides it
payment.preimage = status.preimage or payment.preimage payment.preimage = status.preimage or payment.preimage
payment.status = PaymentState.SUCCESS payment.status = PaymentState.SUCCESS
payment = await update_payment(payment) payment = await update_payment(payment)
if payment.fiat_provider:
await handle_fiat_payment_confirmation(payment)
return payment return payment
async def fundingsource_invoice_producer() -> None:
"""
will collect all invoices that come directly from the backend wallet.
Called registered in the app startup sequence and run by taskmanager.
"""
funding_source = get_funding_source()
async for checking_id in funding_source.paid_invoices_stream():
logger.info(f"got a payment notification {checking_id}")
payment = await update_invoice_from_paid_invoices_stream(checking_id)
if payment:
logger.success(f"fundingsource invoice {checking_id} settled")
task_manager.invoice_queue.put_nowait(payment)
+47 -118
View File
@@ -2,76 +2,43 @@ import asyncio
from loguru import logger from loguru import logger
from lnbits.core.crud import ( from lnbits.core.crud import create_audit_entry
create_audit_entry,
get_wallet,
)
from lnbits.core.crud.audit import delete_expired_audit_entries
from lnbits.core.crud.payments import get_payments_status_count from lnbits.core.crud.payments import get_payments_status_count
from lnbits.core.crud.users import get_accounts from lnbits.core.crud.users import get_accounts
from lnbits.core.crud.wallets import get_wallets_count from lnbits.core.crud.wallets import get_wallets_count
from lnbits.core.models.audit import AuditEntry from lnbits.core.models.audit import AuditEntry
from lnbits.core.models.extensions import InstallableExtension from lnbits.core.models.extensions import InstallableExtension
from lnbits.core.models.notifications import NotificationType from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.funding_source import ( from lnbits.core.services.funding_source import get_balance_delta
check_balance_delta_changed,
check_server_balance_against_node,
get_balance_delta,
)
from lnbits.core.services.notifications import ( from lnbits.core.services.notifications import (
enqueue_admin_notification, enqueue_admin_notification,
process_next_notification,
send_payment_notification,
) )
from lnbits.db import Filters from lnbits.db import Filters
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.exchange_rates import btc_rates from lnbits.utils.cache import cache
from lnbits.utils.exchange_rates import btc_price_from_aggregator, btc_rates
audit_queue: asyncio.Queue[AuditEntry] = asyncio.Queue() audit_queue: asyncio.Queue[AuditEntry] = asyncio.Queue()
async def run_by_the_minute_tasks() -> None: async def process_next_audit_entry() -> None:
minute_counter = 0 """
while settings.lnbits_running: Waits for audit entries to be pushed to the queue.
status_minutes = settings.lnbits_notification_server_status_hours * 60 Then it inserts the entries into the DB.
"""
if settings.notification_balance_delta_threshold_sats > 0: data = await audit_queue.get()
try: await create_audit_entry(data)
# runs by default every minute, the delta should not change that often
await check_balance_delta_changed()
except Exception as ex:
logger.error(ex)
if minute_counter % settings.lnbits_watchdog_interval_minutes == 0:
try:
await check_server_balance_against_node()
except Exception as ex:
logger.error(ex)
if minute_counter % status_minutes == 0:
try:
await _notify_server_status()
except Exception as ex:
logger.error(ex)
if minute_counter % 60 == 0:
try:
# initialize the list of all extensions
await InstallableExtension.get_installable_extensions(
post_refresh_cache=True
)
except Exception as ex:
logger.error(ex)
minute_counter += 1
await asyncio.sleep(60)
async def _notify_server_status() -> None: async def refresh_extension_cache() -> None:
# only refreshes every 10 minutes
await InstallableExtension.get_installable_extensions()
async def notify_server_status() -> None:
accounts = await get_accounts(filters=Filters(limit=0)) accounts = await get_accounts(filters=Filters(limit=0))
wallets_count = await get_wallets_count() wallets_count = await get_wallets_count()
payments = await get_payments_status_count() payments = await get_payments_status_count()
status = await get_balance_delta() status = await get_balance_delta()
values = { values = {
"up_time": settings.lnbits_server_up_time, "up_time": settings.lnbits_server_up_time,
@@ -88,76 +55,38 @@ async def _notify_server_status() -> None:
enqueue_admin_notification(NotificationType.server_status, values) enqueue_admin_notification(NotificationType.server_status, values)
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue) -> None:
"""
This worker dispatches events to all extensions and dispatches webhooks.
"""
while settings.lnbits_running:
payment = await invoice_paid_queue.get()
logger.trace("received invoice paid event")
# payment notification
wallet = await get_wallet(payment.wallet_id)
if wallet:
await send_payment_notification(wallet, payment)
async def wait_for_audit_data() -> None:
"""
Waits for audit entries to be pushed to the queue.
Then it inserts the entries into the DB.
"""
while settings.lnbits_running:
data = await audit_queue.get()
try:
await create_audit_entry(data)
except Exception as ex:
logger.warning(ex)
await asyncio.sleep(3)
async def wait_notification_messages() -> None:
while settings.lnbits_running:
try:
await process_next_notification()
except Exception as ex:
logger.warning("Payment notification error", ex)
await asyncio.sleep(3)
async def purge_audit_data() -> None:
"""
Remove audit entries which have passed their retention period.
"""
while settings.lnbits_running:
try:
await delete_expired_audit_entries()
except Exception as ex:
logger.warning(ex)
# clean every hour
await asyncio.sleep(60 * 60)
async def collect_exchange_rates_data() -> None: async def collect_exchange_rates_data() -> None:
""" """
Collect exchange rates data. Used for monitoring only. Collect exchange rates data. Used for monitoring only.
""" """
while settings.lnbits_running: currency = settings.lnbits_default_accounting_currency or "USD"
currency = settings.lnbits_default_accounting_currency or "USD" max_history_size = settings.lnbits_exchange_history_size
max_history_size = settings.lnbits_exchange_history_size try:
sleep_time = settings.lnbits_exchange_history_refresh_interval_seconds if (
settings.lnbits_price_aggregator_enabled
if sleep_time > 0: and settings.lnbits_price_aggregator_url
try: ):
rates = await btc_rates(currency) price = await btc_price_from_aggregator(currency)
if rates: if price:
rates_values = [r[1] for r in rates] cache.set(
lnbits_rate = sum(rates_values) / len(rates_values) f"btc-price-{currency}",
rates.append(("LNbits", lnbits_rate)) price,
settings.append_exchange_rate_datapoint(dict(rates), max_history_size) expiry=settings.lnbits_exchange_rate_cache_seconds,
except Exception as ex: )
logger.warning(ex) settings.append_exchange_rate_datapoint(
{"Aggregator": price}, max_history_size
)
else: else:
sleep_time = 60 rates = await btc_rates(currency)
await asyncio.sleep(sleep_time) if rates:
rates_values = [r[1] for r in rates]
lnbits_rate = sum(rates_values) / len(rates_values)
rates.append(("LNbits", lnbits_rate))
cache.set(
f"btc-price-{currency}",
lnbits_rate,
expiry=settings.lnbits_exchange_rate_cache_seconds,
)
settings.append_exchange_rate_datapoint(dict(rates), max_history_size)
except Exception as ex:
logger.warning(ex)
+4 -5
View File
@@ -20,7 +20,7 @@ from lnbits.core.services.settings import dict_to_settings
from lnbits.decorators import check_admin, check_super_user from lnbits.decorators import check_admin, check_super_user
from lnbits.server import server_restart from lnbits.server import server_restart
from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings
from lnbits.tasks import invoice_listeners from lnbits.task_manager import PublicTask, task_manager
from .. import core_app_extra from .. import core_app_extra
from ..crud import get_admin_settings, reset_core_settings, update_admin_settings from ..crud import get_admin_settings, reset_core_settings, update_admin_settings
@@ -44,11 +44,10 @@ async def api_auditor():
name="Monitor", name="Monitor",
description="show the current listeners and other monitoring data", description="show the current listeners and other monitoring data",
dependencies=[Depends(check_admin)], dependencies=[Depends(check_admin)],
response_model=list[PublicTask],
) )
async def api_monitor(): async def api_monitor() -> list[PublicTask]:
return { return task_manager.get_public_tasks()
"invoice_listeners": list(invoice_listeners.keys()),
}
@admin_router.get( @admin_router.get(
+1 -1
View File
@@ -439,7 +439,7 @@ async def _handle_revolut_subscription(
extra = { extra = {
**(reference.extra or {}), **(reference.extra or {}),
"subscription_request_id": reference.subscription_request_id, "subscription_request_id": subscription_id,
"fiat_method": "subscription", "fiat_method": "subscription",
"tag": reference.tag, "tag": reference.tag,
"subscription": { "subscription": {
+1 -1
View File
@@ -292,7 +292,6 @@ async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
@extension_router.delete("/{ext_id}", dependencies=[Depends(check_admin)]) @extension_router.delete("/{ext_id}", dependencies=[Depends(check_admin)])
async def api_uninstall_extension(ext_id: str) -> SimpleStatus: async def api_uninstall_extension(ext_id: str) -> SimpleStatus:
extension = await get_installed_extension(ext_id) extension = await get_installed_extension(ext_id)
if not extension: if not extension:
raise HTTPException( raise HTTPException(
@@ -567,6 +566,7 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
"shortDescription": ext.short_description, "shortDescription": ext.short_description,
"stars": ext.stars, "stars": ext.stars,
"isFeatured": ext.meta.featured if ext.meta else False, "isFeatured": ext.meta.featured if ext.meta else False,
"categories": ext.meta.categories if ext.meta else [],
"dependencies": ext.meta.dependencies if ext.meta else "", "dependencies": ext.meta.dependencies if ext.meta else "",
"isInstalled": ext.id in installed_exts_ids, "isInstalled": ext.id in installed_exts_ids,
"hasDatabaseTables": next( "hasDatabaseTables": next(
+10 -1
View File
@@ -283,7 +283,7 @@ class RevolutWallet(FiatProvider):
return FiatSubscriptionResponse( return FiatSubscriptionResponse(
ok=True, ok=True,
checkout_session_url=checkout_url, checkout_session_url=checkout_url,
subscription_request_id=payment_options.subscription_request_id, subscription_request_id=revolut_subscription_id,
) )
except json.JSONDecodeError: except json.JSONDecodeError:
return FiatSubscriptionResponse( return FiatSubscriptionResponse(
@@ -302,6 +302,15 @@ class RevolutWallet(FiatProvider):
**kwargs, **kwargs,
) -> FiatSubscriptionResponse: ) -> FiatSubscriptionResponse:
try: try:
subscription = await self.get_subscription(subscription_id)
reference = self.deserialize_subscription_reference(
subscription.get("external_reference")
)
if not reference or reference.wallet_id != correlation_id:
return FiatSubscriptionResponse(
ok=False, error_message="Subscription not found."
)
r = await self.client.post( r = await self.client.post(
f"/api/subscriptions/{subscription_id}/cancel", f"/api/subscriptions/{subscription_id}/cancel",
timeout=REVOLUT_REQUEST_TIMEOUT, timeout=REVOLUT_REQUEST_TIMEOUT,
+1 -6
View File
@@ -310,12 +310,7 @@ def get_api_routes(routes: list) -> dict[str, str]:
def path_segments(path: str) -> list[str]: def path_segments(path: str) -> list[str]:
path = path.strip("/") path = path.strip("/")
segments = path.split("/") return path.split("/")
if len(segments) < 2:
return segments
if segments[0] == "upgrades":
return segments[2:]
return segments[0:]
def normalize_path(path: str | None) -> str: def normalize_path(path: str | None) -> str:
+78
View File
@@ -0,0 +1,78 @@
"""Generate llms.txt markdown from FastAPI OpenAPI schema for AI agents."""
from typing import Any
from fastapi import FastAPI
from fastapi.responses import PlainTextResponse
def generate_llms_txt(app: FastAPI) -> str:
"""Convert an OpenAPI schema to llms.txt markdown format."""
openapi_schema = app.openapi()
lines: list[str] = []
# H1: API Title
info = openapi_schema.get("info", {})
title = info.get("title", "API")
lines.append(f"# {title}")
lines.append("")
# Blockquote: Description
description = info.get("description")
if description:
for line in description.strip().split("\n"):
lines.append(f"> {line}")
lines.append("")
# Group endpoints by tag
paths = openapi_schema.get("paths", {})
endpoints_by_tag: dict[str, list[dict[str, Any]]] = {}
for path, path_item in paths.items():
for method in ["get", "post", "put", "patch", "delete", "head", "options"]:
if method not in path_item:
continue
operation = path_item[method]
tags = operation.get("tags", ["Endpoints"])
tag = tags[0] if tags else "Endpoints"
if tag not in endpoints_by_tag:
endpoints_by_tag[tag] = []
endpoints_by_tag[tag].append(
{
"path": path,
"method": method.upper(),
"operation": operation,
}
)
# Generate sections by tag
for tag, endpoints in endpoints_by_tag.items():
lines.append(f"## {tag}")
lines.append("")
for endpoint in endpoints:
method = endpoint["method"]
path = endpoint["path"]
operation = endpoint["operation"]
summary = operation.get("summary", "")
if summary:
lines.append(f"### `{method} {path}` - {summary}")
else:
lines.append(f"### `{method} {path}`")
lines.append("")
lines.append("")
return "\n".join(lines).strip() + "\n"
def create_llms_txt_route(app: FastAPI) -> None:
"""Add a /llms.txt endpoint to the app."""
@app.get(
"/llms.txt",
response_class=PlainTextResponse,
include_in_schema=False,
summary="Get LLM-friendly API documentation",
)
async def get_llms_txt() -> str:
"""Return the API documentation in llms.txt markdown format."""
return generate_llms_txt(app)
-8
View File
@@ -51,14 +51,6 @@ class InstalledExtensionMiddleware:
await self.app(scope, receive, send) await self.app(scope, receive, send)
return return
# re-route all trafic if the extension has been upgraded
if top_path in settings.lnbits_upgraded_extensions:
upgrade_path = (
f"""{settings.lnbits_upgraded_extensions[top_path]}/{top_path}"""
)
tail = "/".join(rest)
scope["path"] = f"/upgrades/{upgrade_path}/{tail}"
await self.app(scope, receive, send) await self.app(scope, receive, send)
def _response_by_accepted_type( def _response_by_accepted_type(
+8 -15
View File
@@ -166,8 +166,6 @@ class ExchangeRateProvider(BaseModel):
class InstalledExtensionsSettings(LNbitsSettings): class InstalledExtensionsSettings(LNbitsSettings):
# installed extensions that have been deactivated # installed extensions that have been deactivated
lnbits_deactivated_extensions: set[str] = Field(default=set()) lnbits_deactivated_extensions: set[str] = Field(default=set())
# upgraded extensions that require API redirects
lnbits_upgraded_extensions: dict[str, str] = Field(default={})
# list of redirects that extensions want to perform # list of redirects that extensions want to perform
lnbits_extensions_redirects: list[RedirectPath] = Field(default=[]) lnbits_extensions_redirects: list[RedirectPath] = Field(default=[])
@@ -190,18 +188,10 @@ class InstalledExtensionsSettings(LNbitsSettings):
def activate_extension_paths( def activate_extension_paths(
self, self,
ext_id: str, ext_id: str,
upgrade_hash: str | None = None,
ext_redirects: list[dict] | None = None, ext_redirects: list[dict] | None = None,
): ):
self.lnbits_deactivated_extensions.discard(ext_id) self.lnbits_deactivated_extensions.discard(ext_id)
"""
Update the list of upgraded extensions. The middleware will perform
redirects based on this
"""
if upgrade_hash:
self.lnbits_upgraded_extensions[ext_id] = upgrade_hash
if ext_redirects: if ext_redirects:
self._activate_extension_redirects(ext_id, ext_redirects) self._activate_extension_redirects(ext_id, ext_redirects)
@@ -211,9 +201,6 @@ class InstalledExtensionsSettings(LNbitsSettings):
self.lnbits_deactivated_extensions.add(ext_id) self.lnbits_deactivated_extensions.add(ext_id)
self._remove_extension_redirects(ext_id) self._remove_extension_redirects(ext_id)
def extension_upgrade_hash(self, ext_id: str) -> str:
return settings.lnbits_upgraded_extensions.get(ext_id, "")
def _activate_extension_redirects(self, ext_id: str, ext_redirects: list[dict]): def _activate_extension_redirects(self, ext_id: str, ext_redirects: list[dict]):
ext_redirect_paths = [ ext_redirect_paths = [
RedirectPath(**{"ext_id": ext_id, **er}) for er in ext_redirects RedirectPath(**{"ext_id": ext_id, **er}) for er in ext_redirects
@@ -359,9 +346,11 @@ class FeeSettings(LNbitsSettings):
class ExchangeProvidersSettings(LNbitsSettings): class ExchangeProvidersSettings(LNbitsSettings):
lnbits_exchange_rate_cache_seconds: int = Field(default=30, ge=0) lnbits_exchange_rate_cache_seconds: int = Field(default=60, ge=0)
lnbits_exchange_history_size: int = Field(default=60, ge=0) lnbits_exchange_history_size: int = Field(default=60, ge=0)
lnbits_exchange_history_refresh_interval_seconds: int = Field(default=300, ge=0) lnbits_exchange_history_refresh_interval_seconds: int = Field(default=300, ge=0)
lnbits_price_aggregator_enabled: bool = Field(default=True)
lnbits_price_aggregator_url: str = Field(default="https://price.lnbits.com")
lnbits_exchange_rate_providers: list[ExchangeRateProvider] = Field( lnbits_exchange_rate_providers: list[ExchangeRateProvider] = Field(
default=[ default=[
@@ -587,6 +576,7 @@ class PhoenixdFundingSource(LNbitsSettings):
phoenixd_api_password: str | None = Field(default=None) phoenixd_api_password: str | None = Field(default=None)
phoenixd_data_dir: str | None = Field(default=None) phoenixd_data_dir: str | None = Field(default=None)
phoenixd_mnemonic: str | None = Field(default=None) phoenixd_mnemonic: str | None = Field(default=None)
phoenixd_mnemonic_backup_confirmed: bool = Field(default=False)
class AlbyFundingSource(LNbitsSettings): class AlbyFundingSource(LNbitsSettings):
@@ -611,6 +601,7 @@ class SparkL2FundingSource(LNbitsSettings):
spark_l2_external_endpoint: str | None = Field(default="http://localhost:8765") spark_l2_external_endpoint: str | None = Field(default="http://localhost:8765")
spark_l2_external_api_key: str | None = Field(default=None) spark_l2_external_api_key: str | None = Field(default=None)
spark_l2_mnemonic: str | None = Field(default=None) spark_l2_mnemonic: str | None = Field(default=None)
spark_l2_mnemonic_backup_confirmed: bool = Field(default=False)
spark_l2_pay_wait_ms: int = Field(default=4000, ge=0) spark_l2_pay_wait_ms: int = Field(default=4000, ge=0)
spark_l2_pay_poll_ms: int = Field(default=500, ge=0) spark_l2_pay_poll_ms: int = Field(default=500, ge=0)
spark_l2_stream_keepalive_ms: int = Field(default=15000, ge=0) spark_l2_stream_keepalive_ms: int = Field(default=15000, ge=0)
@@ -648,6 +639,7 @@ class BoltzFundingSource(LNbitsSettings):
boltz_client_password: str = Field(default="") boltz_client_password: str = Field(default="")
boltz_client_cert: str | None = Field(default=None) boltz_client_cert: str | None = Field(default=None)
boltz_mnemonic: str | None = Field(default=None) boltz_mnemonic: str | None = Field(default=None)
boltz_mnemonic_backup_confirmed: bool = Field(default=False)
class StrikeFundingSource(LNbitsSettings): class StrikeFundingSource(LNbitsSettings):
@@ -1072,11 +1064,12 @@ class EnvSettings(LNbitsSettings):
log_rotation: str = Field(default="100 MB") log_rotation: str = Field(default="100 MB")
log_retention: str = Field(default="3 months") log_retention: str = Field(default="3 months")
first_install_token: str | None = Field(default=None) first_install_token: str | None = Field(default=None)
cleanup_wallets_days: int = Field(default=90, ge=0) cleanup_wallets_days: int = Field(default=90, ge=0)
funding_source_max_retries: int = Field(default=4, ge=0) funding_source_max_retries: int = Field(default=4, ge=0)
lnbits_max_users: int = Field(default=0, ge=0) lnbits_max_users: int = Field(default=0, ge=0)
lnbits_max_extensions: int = Field(default=0, ge=0) lnbits_max_extensions: int = Field(default=0, ge=0)
task_heart_beat_verbose: bool = Field(default=False)
task_heart_beat_interval: int = Field(default=30)
@property @property
def has_default_extension_path(self) -> bool: def has_default_extension_path(self) -> bool:
File diff suppressed because one or more lines are too long
+10 -10
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -184,6 +184,7 @@ window.localisation.en = {
release_notes: 'Release Notes', release_notes: 'Release Notes',
activate_extension_details: 'Make extension available/unavailable for users', activate_extension_details: 'Make extension available/unavailable for users',
featured: 'Featured', featured: 'Featured',
categories: 'Categories',
all: 'All', all: 'All',
only_admins_can_install: '(Only admin accounts can install extensions)', only_admins_can_install: '(Only admin accounts can install extensions)',
only_admins_can_create_extensions: only_admins_can_create_extensions:
@@ -62,12 +62,6 @@ window.app.component('lnbits-admin-exchange-providers', {
mounted() { mounted() {
this.getExchangeRateHistory() this.getExchangeRateHistory()
}, },
created() {
const hash = window.location.hash.replace('#', '')
if (hash === 'exchange_providers') {
this.showExchangeProvidersTab(hash)
}
},
methods: { methods: {
getDefaultSetting(fieldName) { getDefaultSetting(fieldName) {
LNbits.api.getDefaultSetting(fieldName).then(response => { LNbits.api.getDefaultSetting(fieldName).then(response => {
@@ -127,18 +121,21 @@ window.app.component('lnbits-admin-exchange-providers', {
this.exchangeData.showTickerConversion = true this.exchangeData.showTickerConversion = true
}, },
initExchangeChart(data) { initExchangeChart(data) {
if (this.exchangeRatesChart) {
this.exchangeRatesChart.destroy()
this.exchangeRatesChart = null
}
const xValues = data.map(d => const xValues = data.map(d =>
this.utils.formatTimestamp(d.timestamp, 'HH:mm') this.utils.formatTimestamp(d.timestamp, 'HH:mm')
) )
const exchanges = [ const exchanges = this.formData.lnbits_price_aggregator_enabled
...this.formData.lnbits_exchange_rate_providers, ? [{name: 'Aggregator'}]
{name: 'LNbits'} : [...this.formData.lnbits_exchange_rate_providers, {name: 'LNbits'}]
]
const datasets = exchanges.map(exchange => ({ const datasets = exchanges.map(exchange => ({
label: exchange.name, label: exchange.name,
data: data.map(d => d.rates[exchange.name]), data: data.map(d => d.rates[exchange.name]),
pointStyle: true, pointStyle: true,
borderWidth: exchange.name === 'LNbits' ? 4 : 1, borderWidth: exchange.name === 'LNbits' ? 4 : 2,
tension: 0.4 tension: 0.4
})) }))
this.exchangeRatesChart = new Chart( this.exchangeRatesChart = new Chart(
@@ -148,7 +145,11 @@ window.app.component('lnbits-admin-exchange-providers', {
options: { options: {
plugins: { plugins: {
legend: { legend: {
display: false display: true
},
title: {
display: true,
text: 'Bitcoin Price History'
} }
} }
}, },
@@ -0,0 +1,151 @@
window.app.component('lnbits-admin-funding-seed-backup', {
props: ['active', 'is-super-user', 'form-data', 'settings'],
template: '#lnbits-admin-funding-seed-backup',
data() {
return {
dialog: {
show: false,
step: 1,
seed: '',
visible: false,
challenge: [],
answers: {},
error: '',
confirmField: ''
}
}
},
watch: {
active(isActive) {
if (isActive) {
this.openIfRequired()
}
},
'formData.lnbits_backend_wallet_class'(walletClass, previousWalletClass) {
const source = this.seedBackupSource(walletClass)
if (previousWalletClass && source && this.formData[source.seedField]) {
this.formData[source.confirmField] = false
}
this.openIfRequired()
},
'formData.boltz_mnemonic'() {
this.formData.boltz_mnemonic_backup_confirmed =
this.formData.boltz_mnemonic === this.settings.boltz_mnemonic
? this.settings.boltz_mnemonic_backup_confirmed
: false
this.openIfRequired()
},
'formData.phoenixd_mnemonic'() {
this.formData.phoenixd_mnemonic_backup_confirmed =
this.formData.phoenixd_mnemonic === this.settings.phoenixd_mnemonic
? this.settings.phoenixd_mnemonic_backup_confirmed
: false
this.openIfRequired()
},
'formData.spark_l2_mnemonic'() {
this.formData.spark_l2_mnemonic_backup_confirmed =
this.formData.spark_l2_mnemonic === this.settings.spark_l2_mnemonic
? this.settings.spark_l2_mnemonic_backup_confirmed
: false
this.openIfRequired()
}
},
computed: {
seedWords() {
return this.dialog.seed
.split(/\s+/)
.filter(Boolean)
.map((word, index) => ({index, word}))
}
},
created() {
this.openIfRequired()
},
methods: {
seedBackupSource(walletClass = this.formData.lnbits_backend_wallet_class) {
if (walletClass === 'BoltzWallet') {
return {
seedField: 'boltz_mnemonic',
confirmField: 'boltz_mnemonic_backup_confirmed'
}
}
if (walletClass === 'PhoenixdWallet') {
return {
seedField: 'phoenixd_mnemonic',
confirmField: 'phoenixd_mnemonic_backup_confirmed'
}
}
if (walletClass === 'SparkL2Wallet') {
return {
seedField: 'spark_l2_mnemonic',
confirmField: 'spark_l2_mnemonic_backup_confirmed'
}
}
},
openIfRequired() {
if (!this.active || !this.isSuperUser) return
const source = this.seedBackupSource()
if (!source) return
const seed = (this.formData[source.seedField] || '').trim()
const confirmed = this.formData[source.confirmField]
if (!seed || confirmed || this.dialog.show) return
this.dialog = {
show: true,
step: 1,
seed,
visible: false,
challenge: [],
answers: {},
error: '',
confirmField: source.confirmField
}
},
prepareChallenge() {
const words = this.dialog.seed.split(/\s+/).filter(Boolean)
const count = Math.min(4, words.length)
const indexes = _.shuffle([...Array(words.length).keys()]).slice(0, count)
this.dialog.challenge = indexes
.sort((a, b) => a - b)
.map(index => ({index, word: words[index]}))
this.dialog.answers = {}
this.dialog.error = ''
this.dialog.step = 2
},
submitChallenge() {
const isValid = this.dialog.challenge.every(({index, word}) => {
const answer = this.dialog.answers[index] || ''
return answer.trim().toLowerCase() === word.toLowerCase()
})
if (!isValid) {
this.dialog.error =
'One or more words are incorrect. Check your backup and try again.'
return
}
const field = this.dialog.confirmField
LNbits.api
.request(
'PATCH',
'/admin/api/v1/settings',
this.g.user.wallets[0].adminkey,
{
[field]: true
}
)
.then(() => {
this.formData[field] = true
this.settings[field] = true
this.dialog.show = false
Quasar.Notify.create({
type: 'positive',
message: 'Seed backup confirmed',
icon: 'check'
})
})
.catch(LNbits.utils.notifyApiError)
}
}
})
@@ -1,5 +1,5 @@
window.app.component('lnbits-admin-funding', { window.app.component('lnbits-admin-funding', {
props: ['is-super-user', 'form-data', 'settings'], props: ['active', 'is-super-user', 'form-data', 'settings'],
template: '#lnbits-admin-funding', template: '#lnbits-admin-funding',
data() { data() {
return { return {
+32
View File
@@ -27,6 +27,14 @@ const DynamicComponent = {
name: r.name, name: r.name,
component: async () => { component: async () => {
await LNbits.utils.loadTemplate(r.template) await LNbits.utils.loadTemplate(r.template)
if (r.i18n) {
const locale =
window.i18n?.global?.locale?.value ??
window.i18n?.global?.locale ??
window.g.locale ??
'en'
await LNbits.utils.loadExtI18n(r.i18n, locale)
}
await LNbits.utils.loadScript(r.component) await LNbits.utils.loadScript(r.component)
return window[r.name] return window[r.name]
} }
@@ -151,6 +159,30 @@ window.i18n = new VueI18n.createI18n({
fallbackLocale: 'en', fallbackLocale: 'en',
messages: window.localisation messages: window.localisation
}) })
;(function () {
let _applying = false
let _target = null
Vue.watch(
() => window.i18n.global.locale,
async (locale, prevLocale) => {
if (_applying || !LNbits.utils._extI18nDirs.size) return
_target = locale
_applying = true
window.i18n.global.locale = prevLocale
_applying = false
await Promise.all(
[...LNbits.utils._extI18nDirs].map(dir =>
LNbits.utils.loadExtI18n(dir, locale)
)
)
if (_target !== locale) return
_applying = true
window.i18n.global.locale = locale
_applying = false
},
{flush: 'sync'}
)
})()
window.app.mixin({ window.app.mixin({
data() { data() {
+9
View File
@@ -10,6 +10,7 @@ window.PageExtensions = {
tab: 'installed', tab: 'installed',
manageExtensionTab: 'releases', manageExtensionTab: 'releases',
filteredExtensions: [], filteredExtensions: [],
categories: new Set(),
updatableExtensions: [], updatableExtensions: [],
showUninstallDialog: false, showUninstallDialog: false,
showManageExtensionDialog: false, showManageExtensionDialog: false,
@@ -106,6 +107,10 @@ window.PageExtensions = {
} }
} }
const isCategoryTab = !['installed', 'all', 'featured'].includes(tab)
const isInSelectedCategory = extension =>
extension.categories?.includes(tab) ?? false
this.filteredExtensions = this.extensions this.filteredExtensions = this.extensions
.filter(e => (tab === 'all' ? !e.isInstalled : true)) .filter(e => (tab === 'all' ? !e.isInstalled : true))
.filter(e => (tab === 'installed' ? e.isInstalled : true)) .filter(e => (tab === 'installed' ? e.isInstalled : true))
@@ -113,6 +118,7 @@ window.PageExtensions = {
tab === 'installed' ? (e.isActive ? true : !!this.g.user.admin) : true tab === 'installed' ? (e.isActive ? true : !!this.g.user.admin) : true
) )
.filter(e => (tab === 'featured' ? e.isFeatured : true)) .filter(e => (tab === 'featured' ? e.isFeatured : true))
.filter(e => (isCategoryTab ? isInSelectedCategory(e) : true))
.filter(extensionNameContains(term)) .filter(extensionNameContains(term))
.map(e => ({ .map(e => ({
...e, ...e,
@@ -832,6 +838,9 @@ window.PageExtensions = {
async fetchAllExtensions() { async fetchAllExtensions() {
try { try {
const {data} = await LNbits.api.request('GET', `/api/v1/extension/all`) const {data} = await LNbits.api.request('GET', `/api/v1/extension/all`)
data.forEach(ext => {
ext.categories?.forEach(category => this.categories.add(category))
})
return data return data
} catch (error) { } catch (error) {
console.warn(error) console.warn(error)
+39 -12
View File
@@ -223,6 +223,12 @@ window.PageWallet = {
if (data.tag === 'payRequest') { if (data.tag === 'payRequest') {
this.parse.lnurlpay = Object.freeze(data) this.parse.lnurlpay = Object.freeze(data)
this.parse.data.amount = data.minSendable / 1000 this.parse.data.amount = data.minSendable / 1000
this.receive.units = [
'sats',
...(this.g.allowedCurrencies.length > 0
? this.g.allowedCurrencies
: this.g.currencies)
]
} else if (data.tag === 'login') { } else if (data.tag === 'login') {
this.parse.lnurlauth = Object.freeze(data) this.parse.lnurlauth = Object.freeze(data)
} else if (data.tag === 'withdrawRequest') { } else if (data.tag === 'withdrawRequest') {
@@ -408,12 +414,19 @@ window.PageWallet = {
switch (action.tag) { switch (action.tag) {
case 'url': case 'url':
Quasar.Notify.create({ Quasar.Notify.create({
message: `<a target="_blank" style="color: inherit" href="${action.url}">${action.url}</a>`, message: action.url,
caption: action.description, caption: action.description,
html: true, html: false,
type: 'positive', type: 'positive',
timeout: 0, timeout: 0,
closeBtn: true closeBtn: true,
actions: [
{
label: 'Open link',
color: 'white',
handler: () => this.utils.openUrlInNewTab(action.url)
}
]
}) })
break break
case 'message': case 'message':
@@ -425,15 +438,29 @@ window.PageWallet = {
}) })
break break
case 'aes': case 'aes':
this.utils.decryptLnurlPayAES(action, response.data.preimage) this.utils
Quasar.Notify.create({ .decryptLnurlPayAES(action, response.data.preimage)
message: value, .then(value => {
caption: extra.success_action.description, Quasar.Notify.create({
html: true, message: value,
type: 'positive', caption: action.description,
timeout: 0, html: false,
closeBtn: true type: 'positive',
}) timeout: 0,
closeBtn: true
})
})
.catch(error => {
Quasar.Notify.create({
message: action.description || 'Payment successful.',
caption: 'Could not decrypt success action.',
html: false,
type: 'warning',
timeout: 0,
closeBtn: true
})
})
break
} }
} }
}) })
+36
View File
@@ -323,6 +323,20 @@ window._lnbitsUtils = {
converter.setOption('simpleLineBreaks', true) converter.setOption('simpleLineBreaks', true)
return converter.makeHtml(text) return converter.makeHtml(text)
}, },
_extI18nDirs: new Set(),
_extI18nLoaded: {},
loadExtI18n(dir, locale) {
this._extI18nDirs.add(dir)
const loaded = (this._extI18nLoaded[dir] ??= {})
if (loaded[locale]) return loaded[locale]
loaded[locale] = this.loadScript(`${dir}/${locale}.js`).catch(() => {
if (locale !== 'en') {
loaded['en'] ??= this.loadScript(`${dir}/en.js`).catch(() => {})
return loaded['en']
}
})
return loaded[locale]
},
async decryptLnurlPayAES(success_action, preimage) { async decryptLnurlPayAES(success_action, preimage) {
let keyb = new Uint8Array( let keyb = new Uint8Array(
preimage.match(/[\da-f]{2}/gi).map(h => parseInt(h, 16)) preimage.match(/[\da-f]{2}/gi).map(h => parseInt(h, 16))
@@ -351,5 +365,27 @@ window._lnbitsUtils = {
let decoder = new TextDecoder('utf-8') let decoder = new TextDecoder('utf-8')
return decoder.decode(valueb) return decoder.decode(valueb)
}) })
},
validateBrowsableUrl(urlString, allowLoopback = false) {
const url = new URL(urlString)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('Invalid protocol')
}
if (!allowLoopback) {
const host = url.hostname
if (
host === 'localhost' ||
host === '[::1]' ||
host === '::1' ||
host.startsWith('127.') ||
host.startsWith('::ffff:127.')
) {
throw new Error('Loopback addresses are not allowed')
}
}
},
openUrlInNewTab(urlString, allowLoopback = false) {
this.validateBrowsableUrl(urlString, allowLoopback)
window.open(urlString, '_blank', 'noopener,noreferrer')
} }
} }
+1
View File
@@ -58,6 +58,7 @@
"js/pages/users.js", "js/pages/users.js",
"js/pages/account.js", "js/pages/account.js",
"js/pages/admin.js", "js/pages/admin.js",
"js/components/admin/lnbits-admin-funding-seed-backup.js",
"js/components/admin/lnbits-admin-funding.js", "js/components/admin/lnbits-admin-funding.js",
"js/components/admin/lnbits-admin-funding-sources.js", "js/components/admin/lnbits-admin-funding-sources.js",
"js/components/admin/lnbits-admin-fiat-providers.js", "js/components/admin/lnbits-admin-fiat-providers.js",
+200
View File
@@ -0,0 +1,200 @@
import asyncio
import traceback
import uuid
from collections.abc import Callable, Coroutine
from datetime import datetime, timezone
from loguru import logger
from pydantic import BaseModel
from lnbits.core.models import Payment
from lnbits.settings import settings
class PublicTask(BaseModel):
"""Public model used to expose task information via the API."""
name: str
created_at: datetime
class Task:
"""Model used on the backend to keep track of background tasks."""
coro: Coroutine
name: str
created_at: datetime
task: asyncio.Task
invoice_queue: asyncio.Queue[Payment] | None = None
def __init__(
self,
coro: Coroutine,
name: str | None = None,
invoice_queue: asyncio.Queue | None = None,
) -> None:
self.coro = coro
self.name = name or f"task_{uuid.uuid4()}"
self.created_at = datetime.now(timezone.utc)
self.task = asyncio.create_task(self.coro, name=self.name)
self.invoice_queue = invoice_queue
class TaskManager:
"""Singleton class to manage background tasks."""
tasks: list[Task] = []
invoice_queue: asyncio.Queue[Payment] = asyncio.Queue()
internal_invoice_queue: asyncio.Queue[Payment] = asyncio.Queue()
def init(self) -> None:
self.create_permanent_task(
func=self._heart_beat,
interval=settings.task_heart_beat_interval,
)
self.create_permanent_task(self._invoice_listener_consumer)
self.create_permanent_task(self._internal_invoice_listener_consumer)
def get_task(self, name: str) -> Task | None:
"""Get a running task by name."""
for task in self.tasks:
if task.name == name:
return task
return None
def get_public_tasks(self) -> list[PublicTask]:
"""Get a list of public tasks."""
return [PublicTask(name=t.name, created_at=t.created_at) for t in self.tasks]
def cancel_task(self, task: Task) -> None:
"""Cancel a running task."""
self.tasks.remove(task)
try:
task.task.cancel()
except Exception as exc:
logger.warning(f"error while cancelling task `{task.name}`: {exc!s}")
def cancel_all_tasks(self) -> None:
"""Cancel all running tasks."""
for task in list(self.tasks):
self.cancel_task(task)
def create_task(
self,
coro: Coroutine,
name: str | None = None,
invoice_queue: asyncio.Queue | None = None,
) -> Task:
"""Create a task. If a task with the same name exists, it will be cancelled."""
if name:
task = self.get_task(name)
if task:
self.cancel_task(task)
task = Task(coro=coro, name=name, invoice_queue=invoice_queue)
self.tasks.append(task)
return task
def create_permanent_task(
self,
func: Callable[[], Coroutine],
invoice_queue: asyncio.Queue | None = None,
name: str | None = None,
interval: int = 0,
) -> Task:
"""Create a task that runs forever and restarts on failure."""
async def wrapper():
while settings.lnbits_running:
await self._catch_everything_and_restart(func)
if interval > 0:
await asyncio.sleep(interval)
return self.create_task(
coro=wrapper(), name=name or func.__name__, invoice_queue=invoice_queue
)
def register_invoice_listener(
self,
func: Callable[[Payment], Coroutine],
name: str | None = None,
) -> Task:
"""
A method intended for extensions to call when they want to be notified about
incoming payments. Will call provided Coroutine with the updated payment.
"""
name = f"{name or uuid.uuid4()}_invoice_listener"
queue: asyncio.Queue[Payment] = asyncio.Queue()
return self.create_permanent_task(
self._invoice_listener_worker(func, queue),
name=name,
invoice_queue=queue,
)
async def _heart_beat(self) -> None:
"""A heartbeat that removes done tasks logs the number of tasks."""
for task in self.tasks:
state = task.task._state if task.task else "NOT RUNNING"
if settings.task_heart_beat_verbose:
logger.debug(
f"Task Manager: `{task.name}` state: `{state}` "
f"created: {task.created_at.strftime('%Y-%m-%d %H:%M:%S')}`"
)
if task.task and task.task.done():
logger.debug(f"Task Manager: task `{task.name}` is done.")
self.cancel_task(task)
listeners_count = sum(1 for task in self.tasks if task.invoice_queue)
logger.debug(
f"Task Manager: {len(self.tasks) - listeners_count} tasks "
f"and {listeners_count} invoice listeners."
)
async def _catch_everything_and_restart(
self,
func: Callable[[], Coroutine],
restart_interval: int = 5,
) -> None:
"""Catches all exceptions from a function and restarts it after 5 seconds."""
while settings.lnbits_running:
try:
return await func()
except asyncio.CancelledError:
raise # because we must pass this up
except Exception as exc:
if not settings.lnbits_running:
return
logger.error(f"exception in background task `{func.__name__}`:", exc)
logger.error(traceback.format_exc())
logger.info(
f"`{func.__name__}` restarts in {restart_interval} seconds."
)
await asyncio.sleep(restart_interval)
def _invoice_listener_worker(
self, func: Callable[[Payment], Coroutine], queue: asyncio.Queue[Payment]
) -> Callable:
async def wrapper() -> None:
payment: Payment = await queue.get()
await func(payment)
return wrapper
def _invoice_dispatcher(self, payment: Payment) -> None:
"""Dispatches a payment to all registered invoice listeners."""
for task in self.tasks:
if not task.invoice_queue:
continue
logger.debug(f"Enqueing payment to task {task.name}")
task.invoice_queue.put_nowait(payment)
async def _invoice_listener_consumer(self) -> None:
payment = await self.invoice_queue.get()
logger.info(f"got a payment notification {payment.checking_id}")
self._invoice_dispatcher(payment)
async def _internal_invoice_listener_consumer(self) -> None:
payment = await self.internal_invoice_queue.get()
logger.info(f"got an internal payment notification {payment.checking_id}")
self._invoice_dispatcher(payment)
task_manager = TaskManager()
+33 -117
View File
@@ -1,143 +1,83 @@
import asyncio import asyncio
import traceback
import uuid import uuid
from collections.abc import Callable, Coroutine from collections.abc import Callable, Coroutine
from loguru import logger from loguru import logger
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.core.services.payments import update_invoice_callback from lnbits.core.services.payments import get_standalone_payment
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.wallets import get_funding_source from lnbits.task_manager import task_manager
tasks: list[asyncio.Task] = []
unique_tasks: dict[str, asyncio.Task] = {}
# DEPRECATED: use task_manager.create_task instead.
def create_task(coro: Coroutine) -> asyncio.Task: def create_task(coro: Coroutine) -> asyncio.Task:
task = asyncio.create_task(coro) logger.debug("DEPRECATED: use task_manager.create_task instead.")
tasks.append(task) return task_manager.create_task(coro).task
return task
# DEPRECATED: use task_manager.create_task with `name` kwarg.
def create_unique_task(name: str, coro: Coroutine) -> asyncio.Task: def create_unique_task(name: str, coro: Coroutine) -> asyncio.Task:
if unique_tasks.get(name): logger.debug("DEPRECATED: use task_manager.create_task instead.")
logger.warning(f"task `{name}` already exists, cancelling it") return task_manager.create_task(coro, name=name).task
try:
unique_tasks[name].cancel()
except Exception as exc:
logger.warning(f"error while cancelling task `{name}`: {exc!s}")
task = asyncio.create_task(coro)
unique_tasks[name] = task
return task
# DEPRECATED: use task_manager.create_permanent_task instead.
def create_permanent_task(func: Callable[[], Coroutine]) -> asyncio.Task: def create_permanent_task(func: Callable[[], Coroutine]) -> asyncio.Task:
return create_task(catch_everything_and_restart(func)) logger.debug("DEPRECATED: use task_manager.create_permanent_task instead.")
return task_manager.create_permanent_task(func).task
# DEPRECATED: use task_manager.create_permanent_task with `name` argument instead.
def create_permanent_unique_task( def create_permanent_unique_task(
name: str, coro: Callable[[], Coroutine] name: str, coro: Callable[[], Coroutine]
) -> asyncio.Task: ) -> asyncio.Task:
return create_unique_task(name, catch_everything_and_restart(coro, name)) return create_unique_task(name, catch_everything_and_restart(coro, name))
def cancel_all_tasks() -> None: # DEPRECATED don't use this, use task_manager.create_permanent_task instead.
for task in tasks:
try:
task.cancel()
except Exception as exc:
logger.warning(f"error while cancelling task: {exc!s}")
for name, task in unique_tasks.items():
try:
task.cancel()
except Exception as exc:
logger.warning(f"error while cancelling task `{name}`: {exc!s}")
async def catch_everything_and_restart( async def catch_everything_and_restart(
func: Callable[[], Coroutine], func: Callable[[], Coroutine],
name: str = "unnamed", name: str = "unnamed",
) -> Coroutine: ) -> None:
try: _ = name
return await func() return await task_manager._catch_everything_and_restart(func)
except asyncio.CancelledError:
raise # because we must pass this up
except Exception as exc:
logger.error(f"exception in background task `{name}`:", exc)
logger.error(traceback.format_exc())
logger.error("will restart the task in 5 seconds.")
await asyncio.sleep(5)
return await catch_everything_and_restart(func, name)
invoice_listeners: dict[str, asyncio.Queue] = {}
# TODO: name should not be optional
# some extensions still dont use a name, but they should
def register_invoice_listener(send_chan: asyncio.Queue, name: str | None = None): def register_invoice_listener(send_chan: asyncio.Queue, name: str | None = None):
""" """
A method intended for extensions (and core/tasks.py) to call when they want to be DEPRECATED: use task_manager.register_invoice_listener instead,
notified about new invoice payments incoming. Will emit all incoming payments. which also allows to pass a callback instead of a queue.
This method will still work but it is not recommended for new code.
""" """
if not name: logger.debug("DEPRECATED: use task_manager.register_invoice_listener instead.")
# fallback to a random name if extension didn't provide one name = f"forward_{name or str(uuid.uuid4())[:8]}"
name = f"no_name_{str(uuid.uuid4())[:8]}"
if invoice_listeners.get(name): # here we just forwarding the payments to the provided queue
logger.warning(f"invoice listener `{name}` already exists, replacing it") async def forward_queue(payment: Payment):
send_chan.put_nowait(payment)
logger.trace(f"registering invoice listener `{name}`") task_manager.register_invoice_listener(forward_queue, name=name)
invoice_listeners[name] = send_chan
internal_invoice_queue: asyncio.Queue = asyncio.Queue(0)
async def internal_invoice_queue_put(checking_id: str) -> None: async def internal_invoice_queue_put(checking_id: str) -> None:
""" """
DEPRECATED: use task_manager.internal_invoice_queue instead,
A method to call when it wants to notify about an internal invoice payment. A method to call when it wants to notify about an internal invoice payment.
""" """
await internal_invoice_queue.put(checking_id) payment = await get_standalone_payment(checking_id, incoming=True)
if not payment:
logger.warning(f"internal_invoice_queue_put: payment {checking_id} not found")
async def internal_invoice_listener() -> None: return
""" await task_manager.internal_invoice_queue.put(payment)
internal_invoice_queue will be filled directly in core/services.py
after the payment was deemed to be settled internally.
Called by the app startup sequence.
"""
while settings.lnbits_running:
checking_id = await internal_invoice_queue.get()
logger.info(f"got an internal payment notification {checking_id}")
payment = await update_invoice_callback(checking_id)
if payment:
logger.success(f"internal invoice {checking_id} settled")
await invoice_callback_dispatcher(payment)
async def invoice_listener() -> None:
"""
invoice_listener will collect all invoices that come directly
from the backend wallet.
Called by the app startup sequence.
"""
funding_source = get_funding_source()
async for checking_id in funding_source.paid_invoices_stream():
logger.info(f"got a payment notification {checking_id}")
payment = await update_invoice_callback(checking_id)
if payment:
logger.success(f"fundingsource invoice {checking_id} settled")
await invoice_callback_dispatcher(payment)
# DEPRECATED use task_manager.register_invoice_listener(coro, name="myext")
def wait_for_paid_invoices( def wait_for_paid_invoices(
invoice_listener_name: str, invoice_listener_name: str,
func: Callable[[Payment], Coroutine], func: Callable[[Payment], Coroutine],
) -> Callable[[], Coroutine]: ) -> Callable[[], Coroutine]:
logger.debug("DEPRECATED: use task_manager.register_invoice_listener instead.")
async def wrapper() -> None: async def wrapper() -> None:
invoice_queue: asyncio.Queue = asyncio.Queue() invoice_queue: asyncio.Queue = asyncio.Queue()
@@ -147,27 +87,3 @@ def wait_for_paid_invoices(
await func(payment) await func(payment)
return wrapper return wrapper
def run_interval(
interval_seconds: int,
func: Callable[[], Coroutine],
) -> Callable[[], Coroutine]:
"""Run a function at a specified interval in seconds, while the server is running"""
async def wrapper() -> None:
while settings.lnbits_running:
try:
await func()
except Exception as e:
logger.error(f"Error occurred in interval task: {e}")
logger.warning(traceback.format_exc())
await asyncio.sleep(interval_seconds)
return wrapper
async def invoice_callback_dispatcher(payment: Payment):
for name, send_chan in invoice_listeners.items():
logger.trace(f"invoice listeners: sending to `{name}`")
await send_chan.put(payment)
+2 -1
View File
@@ -1,4 +1,5 @@
{% include('components/admin/funding.vue') %} {% {% include('components/admin/funding_seed_backup.vue') %} {%
include('components/admin/funding.vue') %} {%
include('components/admin/funding_sources.vue') %} {% include('components/admin/funding_sources.vue') %} {%
include('components/admin/fiat_providers.vue') %} {% include('components/admin/fiat_providers.vue') %} {%
include('components/admin/exchange_providers.vue') %} {% include('components/admin/exchange_providers.vue') %} {%
@@ -1,7 +1,46 @@
<template id="lnbits-admin-exchange-providers"> <template id="lnbits-admin-exchange-providers">
<h6 class="q-my-none q-mb-sm"> <h6 class="q-my-none q-mb-xs">LNbits Price Aggregator</h6>
<span v-text="$t('exchange_providers')"></span> <p class="q-mb-md text-caption text-grey">
</h6> A privacy-friendly, open-source Bitcoin price aggregator maintained by the
LNbits team. Aggregates prices from multiple exchanges and returns a median,
no API keys required.
<a href="https://price.lnbits.com" target="_blank" rel="noopener"
>price.lnbits.com</a
>
&mdash;
<a
href="https://github.com/lnbits/lnbits-price-aggregator"
target="_blank"
rel="noopener"
>GitHub</a
>
</p>
<div class="row q-mb-md items-start">
<div class="col-auto q-mr-md q-mt-sm">
<q-toggle
v-model="formData.lnbits_price_aggregator_enabled"
@update:model-value="formData.touch = null"
label="Use Price Aggregator"
>
</q-toggle>
</div>
<div class="col-12 col-md-7">
<q-input
filled
v-model="formData.lnbits_price_aggregator_url"
type="text"
label="Price Aggregator URL"
hint="Fetch BTC price from this aggregator instead of individual providers below."
:disable="!formData.lnbits_price_aggregator_enabled"
@update:model-value="formData.touch = null"
>
</q-input>
</div>
</div>
<q-separator class="q-my-md"></q-separator>
<h6 class="q-my-none q-mb-sm">Bitcoin Price History</h6>
<div class="row"> <div class="row">
<div class="col-12 col-md-8"> <div class="col-12 col-md-8">
@@ -53,6 +92,11 @@
</div> </div>
</div> </div>
<q-separator class="q-my-md"></q-separator>
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('exchange_providers')"></span>
</h6>
<div class="row q-mt-md"> <div class="row q-mt-md">
<div class="col-6"> <div class="col-6">
<q-btn <q-btn
@@ -60,6 +104,7 @@
label="Add Exchange Provider" label="Add Exchange Provider"
color="primary" color="primary"
class="q-mb-md" class="q-mb-md"
:disable="formData.lnbits_price_aggregator_enabled"
> >
</q-btn> </q-btn>
</div> </div>
@@ -70,12 +115,20 @@
:label="$t('reset_defaults')" :label="$t('reset_defaults')"
color="primary" color="primary"
class="float-right" class="float-right"
:disable="formData.lnbits_price_aggregator_enabled"
> >
</q-btn> </q-btn>
</div> </div>
</div> </div>
<div class="overflow-auto"> <div
class="overflow-auto"
:style="
formData.lnbits_price_aggregator_enabled
? 'opacity:0.4;pointer-events:none'
: ''
"
>
<q-table <q-table
row-key="name" row-key="name"
:rows="formData.lnbits_exchange_rate_providers" :rows="formData.lnbits_exchange_rate_providers"
@@ -301,5 +301,11 @@
</div> </div>
</div> </div>
</div> </div>
<lnbits-admin-funding-seed-backup
:active="active"
:is-super-user="isSuperUser"
:form-data="formData"
:settings="settings"
></lnbits-admin-funding-seed-backup>
</q-card-section> </q-card-section>
</template> </template>
@@ -0,0 +1,136 @@
<template id="lnbits-admin-funding-seed-backup">
<q-dialog v-model="dialog.show">
<q-card style="width: 760px; max-width: 95vw; border-radius: 8px">
<q-card-section class="q-pb-md">
<div class="row q-col-gutter-sm">
<div class="col-6">
<q-chip
square
class="full-width"
icon="looks_one"
:color="dialog.step === 1 ? 'primary' : 'grey-9'"
text-color="white"
label="Backup"
></q-chip>
</div>
<div class="col-6">
<q-chip
square
class="full-width"
icon="looks_two"
:color="dialog.step === 2 ? 'primary' : 'grey-9'"
text-color="white"
label="Verify"
></q-chip>
</div>
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-section v-if="dialog.step === 1">
<div class="row items-center justify-between q-mb-md">
<div>
<div
class="text-subtitle1"
v-text="`${seedWords.length}-word recovery phrase`"
></div>
<div
class="text-caption text-grey-5"
v-text="'Write these words down in order.'"
></div>
</div>
<q-btn
outline
no-caps
color="primary"
:icon="dialog.visible ? 'visibility_off' : 'visibility'"
:label="dialog.visible ? 'Hide words' : 'Show words'"
@click="dialog.visible = !dialog.visible"
></q-btn>
</div>
<div class="row q-col-gutter-sm">
<div
class="col-4 col-md-3"
v-for="word in seedWords"
:key="word.index"
>
<div
class="row items-center no-wrap rounded-borders"
style="
min-height: 42px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.035);
"
>
<div
class="text-caption text-grey-5 text-center"
style="
width: 42px;
border-right: 1px solid rgba(255, 255, 255, 0.1);
"
v-text="word.index + 1"
></div>
<div
class="text-body2 text-weight-medium q-px-sm"
style="min-width: 0; overflow-wrap: anywhere"
v-text="dialog.visible ? word.word : '••••••'"
></div>
</div>
</div>
</div>
<div class="row justify-end q-mt-lg">
<q-btn
color="primary"
no-caps
label="I have written it down"
@click="prepareChallenge"
></q-btn>
</div>
</q-card-section>
<q-card-section v-if="dialog.step === 2">
<div class="q-mb-md">
<div class="text-subtitle1" v-text="'Confirm your backup'"></div>
<div
class="text-caption text-grey-5"
v-text="
'Enter the requested words from your written recovery phrase.'
"
></div>
</div>
<div class="row q-col-gutter-md">
<div
class="col-12 col-sm-6"
v-for="word in dialog.challenge"
:key="word.index"
>
<q-input
v-model.trim="dialog.answers[word.index]"
filled
:label="`Word ${word.index + 1}`"
></q-input>
</div>
</div>
<div
class="text-negative q-mt-sm"
v-if="dialog.error"
v-text="dialog.error"
></div>
<div class="row justify-between q-mt-lg">
<q-btn flat no-caps label="Back" @click="dialog.step = 1"></q-btn>
<q-btn
color="primary"
icon="check"
no-caps
label="Confirm backup"
@click="submitChallenge"
></q-btn>
</div>
</q-card-section>
</q-card>
</q-dialog>
</template>
@@ -6,6 +6,9 @@
:content-inset-level="0.5" :content-inset-level="0.5"
> >
<q-card-section> <q-card-section>
<q-banner dense rounded class="bg-warning text-black q-mb-md">
These keys should be kept safe, sharing them could risk losing funds.
</q-banner>
<q-list> <q-list>
<q-item dense class="q-pa-none"> <q-item dense class="q-pa-none">
<q-item-section> <q-item-section>
+1
View File
@@ -199,6 +199,7 @@
> >
<q-tab-panel name="funding"> <q-tab-panel name="funding">
<lnbits-admin-funding <lnbits-admin-funding
:active="tab === 'funding'"
:is-super-user="isSuperUser" :is-super-user="isSuperUser"
:settings="settings" :settings="settings"
:form-data="formData" :form-data="formData"
+23 -1
View File
@@ -7,7 +7,29 @@
<q-tabs v-model="tab" active-color="primary" align="left"> <q-tabs v-model="tab" active-color="primary" align="left">
<q-tab name="installed" :label="$t('installed')"></q-tab> <q-tab name="installed" :label="$t('installed')"></q-tab>
<q-tab name="all" :label="$t('all')"></q-tab> <q-tab name="all" :label="$t('all')"></q-tab>
<q-tab name="featured" :label="$t('featured')"></q-tab> <q-tab
v-show="$q.screen.gt.xs"
name="featured"
:label="$t('featured')"
></q-tab>
<q-btn-dropdown auto-close stretch flat :label="$t('categories')">
<q-list>
<q-item
clickable
v-close-popup
v-for="category in categories"
@click="tab = category"
:key="category"
>
<q-item-section>
<q-item-label
class="text-capitalize"
v-text="category"
></q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<i <i
v-if="!g.user.admin && tab != 'installed'" v-if="!g.user.admin && tab != 'installed'"
v-text="$t('only_admins_can_install')" v-text="$t('only_admins_can_install')"
+12 -45
View File
@@ -1,13 +1,8 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from time import time from time import time
from typing import Any, NamedTuple from typing import Any, NamedTuple
from loguru import logger
from lnbits.settings import settings
class Cached(NamedTuple): class Cached(NamedTuple):
value: Any value: Any
@@ -22,11 +17,8 @@ class Cache:
Small caching utility providing simple get/set interface (very much like redis) Small caching utility providing simple get/set interface (very much like redis)
""" """
def __init__(self, interval: float = 10) -> None: def __init__(self) -> None:
self.interval = interval
self._values: dict[Any, Cached] = {} self._values: dict[Any, Cached] = {}
self._refreshing: set[str] = set()
self._tasks: set[asyncio.Task] = set()
def value(self, key: str) -> Cached | None: def value(self, key: str) -> Cached | None:
return self._values.get(key) return self._values.get(key)
@@ -51,46 +43,21 @@ class Cache:
async def save_result(self, coro, key: str, expiry: float = 10): async def save_result(self, coro, key: str, expiry: float = 10):
""" """
Stale-while-revalidate: return stale value immediately and refresh in If `key` exists, return its value, otherwise call coro and cache its result
the background. Only blocks on a true cold start (no prior value).
""" """
cached = self._values.get(key) cached = self.get(key)
if cached is not None: if cached:
if cached.expiry > time(): return cached
return cached.value else:
# stale: serve old value and refresh in background (one task at a time)
if key not in self._refreshing:
self._refreshing.add(key)
# extend expiry now to prevent a stampede of background tasks
self._values[key] = Cached(cached.value, time() + expiry)
task = asyncio.create_task(self._refresh(coro, key, expiry))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
return cached.value
# cold start: must wait for the first value
value = await coro()
self.set(key, value, expiry=expiry)
return value
async def _refresh(self, coro, key: str, expiry: float):
try:
value = await coro() value = await coro()
self.set(key, value, expiry=expiry) self.set(key, value, expiry=expiry)
except Exception: return value
logger.error(f"Error refreshing cache key {key}")
finally:
self._refreshing.discard(key)
async def invalidate_forever(self): async def invalidate_cache(self):
while settings.lnbits_running: ts = time()
try: expired = [k for k, v in self._values.items() if v.expiry < ts]
await asyncio.sleep(self.interval) for k in expired:
ts = time() self._values.pop(k)
expired = [k for k, v in self._values.items() if v.expiry < ts]
for k in expired:
self._values.pop(k)
except Exception:
logger.error("Error invalidating cache")
cache = Cache() cache = Cache()
+439
View File
@@ -0,0 +1,439 @@
"""
Electrum protocol client (https://github.com/spesmilo/electrum-protocol).
JSON-RPC 2.0 over TCP / SSL (newline-delimited), with request/response
correlation, subscription dispatch, and automatic keepalive pings.
server.version is sent automatically on connect as required by the spec.
"""
import asyncio
import hashlib
import itertools
import json
import ssl
from collections.abc import Callable
from typing import Any
from urllib.parse import urlparse
from loguru import logger
from pydantic import BaseModel
class ElectrumError(Exception):
pass
def scripthash_from_scriptpubkey(scriptpubkey: bytes) -> str:
"""Electrum script hash: SHA-256 of scriptPubKey, byte-reversed to hex."""
return hashlib.sha256(scriptpubkey).digest()[::-1].hex()
# ---------------------------------------------------------------------------
# Response models
# ---------------------------------------------------------------------------
class Balance(BaseModel):
confirmed: int
unconfirmed: int
class HistoryEntry(BaseModel):
tx_hash: str
height: int
fee: int | None = None # present for mempool entries
class MempoolEntry(BaseModel):
tx_hash: str
height: int
fee: int
class UTXO(BaseModel):
tx_hash: str
tx_pos: int
height: int
value: int # satoshis
class BlockHeader(BaseModel):
height: int
hex: str
class BlockHeaderProof(BaseModel):
"""Returned by get_block_header when cp_height > 0."""
branch: list[str]
header: str
root: str
class BlockHeaders(BaseModel):
count: int
hex: str
max: int
class MerkleProof(BaseModel):
block_height: int
merkle: list[str]
pos: int
class TxIdWithMerkle(BaseModel):
tx_hash: str
merkle: list[str]
class FeeHistogramEntry(BaseModel):
fee_rate: float
vsize: float
class ServerFeatures(BaseModel):
class Config:
extra = "allow"
genesis_hash: str = ""
protocol_max: str = ""
protocol_min: str = ""
server_version: str = ""
pruning: int | None = None
hash_function: str = "sha256d"
hosts: dict[str, Any] = {}
# ---------------------------------------------------------------------------
# Client
# ---------------------------------------------------------------------------
class ElectrumClient:
"""
Async Electrum protocol client over plain TCP or SSL.
Messages are newline-terminated JSON-RPC 2.0, as required by the spec.
Handles request/response correlation by id, routes push notifications to
registered callbacks, and sends periodic pings to keep the connection alive.
Usage::
# Plain TCP
async with ElectrumClient("tcp://blockstream.info:110") as client:
height = await client.get_height()
# SSL
async with ElectrumClient("ssl://electrum.blockstream.info:50002") as c:
height = await c.get_height()
"""
def __init__(
self,
url: str,
client_name: str = "lnbits",
protocol_version: str = "1.4",
ping_interval: float = 60.0,
) -> None:
parsed = urlparse(url)
self.host = parsed.hostname or ""
self.port = parsed.port or (
50002 if parsed.scheme in ("ssl", "https") else 50001
)
self.use_ssl = parsed.scheme in ("ssl", "https")
self.client_name = client_name
self.protocol_version = protocol_version
self.ping_interval = ping_interval
self._counter = itertools.count(1)
self._pending: dict[int, asyncio.Future[Any]] = {}
self._subscriptions: dict[str, list[Callable[[list[Any]], Any]]] = {}
self._recv_task: asyncio.Task[None] | None = None
self._ping_task: asyncio.Task[None] | None = None
self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None
self.server_version: str = ""
self.negotiated_protocol: str = ""
async def connect(self, timeout: float = 10.0) -> None:
ssl_ctx: ssl.SSLContext | None = None
if self.use_ssl:
ssl_ctx = ssl.create_default_context()
self._reader, self._writer = await asyncio.wait_for(
asyncio.open_connection(
self.host, self.port, ssl=ssl_ctx, limit=4 * 1024 * 1024
),
timeout=timeout,
)
self._recv_task = asyncio.create_task(self._recv_loop())
result = await self._call(
"server.version", [self.client_name, self.protocol_version], timeout=timeout
)
self.server_version, self.negotiated_protocol = result[0], result[1]
logger.debug(
f"Electrum connected: server={self.server_version}"
f" protocol={self.negotiated_protocol}"
)
if self.ping_interval > 0:
self._ping_task = asyncio.create_task(self._ping_loop())
async def close(self) -> None:
for task in (self._ping_task, self._recv_task):
if task:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
except Exception:
logger.debug("Electrum: error while cancelling task")
self._ping_task = None
self._recv_task = None
if self._writer:
self._writer.close()
try:
await asyncio.wait_for(self._writer.wait_closed(), timeout=5.0)
except Exception:
logger.debug("Electrum: error while closing writer")
self._reader = None
self._writer = None
async def __aenter__(self) -> "ElectrumClient":
try:
await self.connect()
except BaseException:
await self.close()
raise
return self
async def __aexit__(self, *_: Any) -> None:
await self.close()
# ---- internal plumbing ----
async def _call(
self,
method: str,
params: list[Any] | dict[str, Any] | None = None,
timeout: float = 30.0,
) -> Any:
if not self._writer:
raise ElectrumError("Not connected")
req_id = next(self._counter)
fut: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
self._pending[req_id] = fut
self._writer.write(
json.dumps(
{
"jsonrpc": "2.0",
"id": req_id,
"method": method,
"params": params if params is not None else [],
}
).encode()
+ b"\n"
)
await self._writer.drain()
try:
return await asyncio.wait_for(asyncio.shield(fut), timeout=timeout)
except asyncio.TimeoutError as exc:
self._pending.pop(req_id, None)
raise ElectrumError(f"Timeout waiting for response to {method!r}") from exc
def _dispatch(self, msg: dict[str, Any]) -> None:
msg_id = msg.get("id")
if msg_id is not None:
fut = self._pending.pop(msg_id, None)
if fut and not fut.done():
err = msg.get("error")
if err:
fut.set_exception(ElectrumError(err))
else:
fut.set_result(msg.get("result"))
else:
method = msg.get("method", "")
params = msg.get("params", [])
for cb in list(self._subscriptions.get(method, [])):
try:
result = cb(params)
if asyncio.iscoroutine(result):
self._bg_tasks.add(asyncio.create_task(result))
except Exception:
logger.exception(f"Electrum: callback error for {method!r}")
async def _recv_loop(self) -> None:
assert self._reader
self._bg_tasks: set[asyncio.Task[Any]] = set()
buf = b""
try:
while True:
chunk = await self._reader.read(65536)
if not chunk:
break
buf += chunk
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
if not line.strip():
continue
try:
msg: dict[str, Any] = json.loads(line)
except json.JSONDecodeError:
logger.warning(f"Electrum: invalid JSON: {line!r}")
continue
self._dispatch(msg)
except asyncio.CancelledError:
pass
except Exception:
logger.exception("Electrum: recv loop error")
finally:
for fut in self._pending.values():
if not fut.done():
fut.set_exception(ElectrumError("Connection closed"))
self._pending.clear()
async def _ping_loop(self) -> None:
try:
while True:
await asyncio.sleep(self.ping_interval)
await self._call("server.ping")
except asyncio.CancelledError:
pass
except Exception:
logger.exception("Electrum: ping loop error")
# ---- subscription management ----
def on(self, method: str, callback: Callable[[list[Any]], Any]) -> None:
"""Register a notification callback for a subscription method."""
self._subscriptions.setdefault(method, []).append(callback)
def off(self, method: str, callback: Callable[[list[Any]], Any]) -> None:
"""Remove a previously registered notification callback."""
cbs = self._subscriptions.get(method)
if cbs and callback in cbs:
cbs.remove(callback)
# ---- server methods ----
async def server_ping(self) -> None:
await self._call("server.ping")
async def server_banner(self) -> str:
return await self._call("server.banner")
async def server_features(self) -> ServerFeatures:
data = await self._call("server.features")
return ServerFeatures.parse_obj(data)
async def server_peers(self) -> list[Any]:
return await self._call("server.peers.subscribe")
# ---- scripthash methods ----
async def get_balance(self, scripthash: str) -> Balance:
data = await self._call("blockchain.scripthash.get_balance", [scripthash])
return Balance.parse_obj(data)
async def get_history(self, scripthash: str) -> list[HistoryEntry]:
data = await self._call("blockchain.scripthash.get_history", [scripthash])
return [HistoryEntry.parse_obj(e) for e in data]
async def get_mempool(self, scripthash: str) -> list[MempoolEntry]:
data = await self._call("blockchain.scripthash.get_mempool", [scripthash])
return [MempoolEntry.parse_obj(e) for e in data]
async def listunspent(self, scripthash: str) -> list[UTXO]:
data = await self._call("blockchain.scripthash.listunspent", [scripthash])
return [UTXO.parse_obj(e) for e in data]
async def subscribe_scripthash(
self,
scripthash: str,
callback: Callable[[list[Any]], Any] | None = None,
) -> str | None:
"""Subscribe to status changes; returns current status hash or None."""
if callback:
self.on("blockchain.scripthash.subscribe", callback)
return await self._call("blockchain.scripthash.subscribe", [scripthash])
async def unsubscribe_scripthash(
self,
scripthash: str,
callback: Callable[[list[Any]], Any] | None = None,
) -> bool:
if callback:
self.off("blockchain.scripthash.subscribe", callback)
return await self._call("blockchain.scripthash.unsubscribe", [scripthash])
async def subscribe_headers(
self,
callback: Callable[[list[Any]], Any] | None = None,
) -> BlockHeader:
"""Subscribe to new block headers; returns current tip."""
if callback:
self.on("blockchain.headers.subscribe", callback)
data = await self._call("blockchain.headers.subscribe")
return BlockHeader.parse_obj(data)
# ---- transaction methods ----
async def broadcast(self, raw_tx: str) -> str:
"""Broadcast a raw transaction hex; returns txid on success."""
return await self._call("blockchain.transaction.broadcast", [raw_tx])
async def get_transaction(
self, txid: str, verbose: bool = False
) -> str | dict[str, Any]:
return await self._call("blockchain.transaction.get", [txid, verbose])
async def get_merkle(self, txid: str, height: int) -> MerkleProof:
data = await self._call("blockchain.transaction.get_merkle", [txid, height])
return MerkleProof.parse_obj(data)
async def get_tx_id_from_pos(
self, height: int, tx_pos: int, merkle: bool = False
) -> str | TxIdWithMerkle:
data = await self._call(
"blockchain.transaction.id_from_pos", [height, tx_pos, merkle]
)
if isinstance(data, dict):
return TxIdWithMerkle.parse_obj(data)
return data
# ---- block methods ----
async def get_tip(self) -> BlockHeader:
"""Returns current chain tip."""
data = await self._call("blockchain.headers.subscribe")
return BlockHeader.parse_obj(data)
async def get_height(self) -> int:
"""Returns the current best block height."""
return (await self.get_tip()).height
async def get_block_header(
self, height: int, cp_height: int = 0
) -> str | BlockHeaderProof:
data = await self._call("blockchain.block.header", [height, cp_height])
if isinstance(data, dict):
return BlockHeaderProof.parse_obj(data)
return data
async def get_block_headers(
self, start_height: int, count: int, cp_height: int = 0
) -> BlockHeaders:
data = await self._call(
"blockchain.block.headers", [start_height, count, cp_height]
)
return BlockHeaders.parse_obj(data)
# ---- fee methods ----
async def estimate_fee(self, num_blocks: int) -> float:
"""Returns estimated fee rate in BTC/kB for confirmation within num_blocks."""
return await self._call("blockchain.estimatefee", [num_blocks])
async def fee_histogram(self) -> list[FeeHistogramEntry]:
"""Returns mempool fee histogram as FeeHistogramEntry(fee_rate, vsize) list."""
data = await self._call("mempool.get_fee_histogram")
return [FeeHistogramEntry(fee_rate=r[0], vsize=r[1]) for r in data]
+25
View File
@@ -289,7 +289,32 @@ async def btc_rates(currency: str) -> list[tuple[str, float]]:
return apply_trimmed_mean_filter(all_rates) return apply_trimmed_mean_filter(all_rates)
async def btc_price_from_aggregator(currency: str) -> float | None:
url = settings.lnbits_price_aggregator_url.rstrip("/")
try:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
r = await client.get(f"{url}/rate/{currency.upper()}", timeout=3)
r.raise_for_status()
data = r.json()
median = data.get("rates", {}).get("median")
if median:
return float(median)
except Exception as e:
logger.warning(f"Failed to fetch price from aggregator {url}: {e}")
return None
async def btc_price(currency: str) -> float: async def btc_price(currency: str) -> float:
if (
settings.lnbits_price_aggregator_enabled
and settings.lnbits_price_aggregator_url
):
price = await btc_price_from_aggregator(currency)
if price:
return price
logger.warning("Price aggregator failed, falling back to exchange providers.")
rates = await btc_rates(currency) rates = await btc_rates(currency)
if not rates: if not rates:
logger.warning("Could not fetch any Bitcoin price.") logger.warning("Could not fetch any Bitcoin price.")
+4 -7
View File
@@ -41,19 +41,16 @@ def log_server_info():
def initialize_server_websocket_logger() -> Callable: def initialize_server_websocket_logger() -> Callable:
super_user_hash = sha256(settings.super_user.encode("utf-8")).hexdigest() super_user_hash = sha256(settings.super_user.encode("utf-8")).hexdigest()
serverlog_queue: asyncio.Queue = asyncio.Queue() serverlog_queue: asyncio.Queue = asyncio.Queue()
async def update_websocket_serverlog():
while settings.lnbits_running:
msg = await serverlog_queue.get()
await websocket_updater(super_user_hash, msg)
logger.add( logger.add(
lambda msg: serverlog_queue.put_nowait(msg), lambda msg: serverlog_queue.put_nowait(msg),
format=Formatter().format, format=Formatter().format,
) )
async def update_websocket_serverlog():
msg = await serverlog_queue.get()
await websocket_updater(super_user_hash, msg)
return update_websocket_serverlog return update_websocket_serverlog
+2
View File
@@ -192,6 +192,8 @@ class LndWallet(Wallet):
fee_limit_msat=fee_limit_msat, fee_limit_msat=fee_limit_msat,
timeout_seconds=30, timeout_seconds=30,
no_inflight_updates=True, no_inflight_updates=True,
max_parts=16,
time_pref=0.9,
) )
try: try:
res: Payment = await self.router_rpc.SendPaymentV2(req).read() res: Payment = await self.router_rpc.SendPaymentV2(req).read()
+41 -40
View File
@@ -3,6 +3,7 @@ import base64
import hashlib import hashlib
import json import json
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from typing import Any
import httpx import httpx
from loguru import logger from loguru import logger
@@ -123,8 +124,8 @@ class LndRestWallet(Wallet):
hashlib.sha256(unhashed_description).digest() hashlib.sha256(unhashed_description).digest()
).decode("ascii") ).decode("ascii")
preimage, _payment_hash = random_secret_and_hash() preimage, payment_hash = random_secret_and_hash()
_data["r_hash"] = base64.b64encode(bytes.fromhex(_payment_hash)).decode() _data["r_hash"] = base64.b64encode(bytes.fromhex(payment_hash)).decode()
_data["r_preimage"] = base64.b64encode(bytes.fromhex(preimage)).decode() _data["r_preimage"] = base64.b64encode(bytes.fromhex(preimage)).decode()
try: try:
@@ -132,34 +133,7 @@ class LndRestWallet(Wallet):
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
if len(data) == 0: return self._parse_create_invoice_response(r, data, preimage)
return InvoiceResponse(ok=False, error_message="no data")
if "error" in data:
return InvoiceResponse(
ok=False, error_message=f"""Server error: '{data["error"]}'"""
)
if r.is_error:
return InvoiceResponse(
ok=False, error_message=f"Server error: '{r.text}'"
)
if "payment_request" not in data or "r_hash" not in data:
return InvoiceResponse(
ok=False, error_message="Server error: 'missing required fields'"
)
payment_request = data["payment_request"]
payment_hash = base64.b64decode(data["r_hash"]).hex()
checking_id = payment_hash
return InvoiceResponse(
ok=True,
checking_id=checking_id,
payment_request=payment_request,
preimage=preimage,
)
except json.JSONDecodeError: except json.JSONDecodeError:
return InvoiceResponse( return InvoiceResponse(
ok=False, error_message="Server error: 'invalid json response'" ok=False, error_message="Server error: 'invalid json response'"
@@ -242,12 +216,13 @@ class LndRestWallet(Wallet):
logger.warning(f"Error getting invoice status: {e}") logger.warning(f"Error getting invoice status: {e}")
return PaymentPendingStatus() return PaymentPendingStatus()
if r.is_error or data.get("settled") is None: if r.is_error or data.get("state") is None:
# this must also work when checking_id is not a hex recognizable by lnd # this must also work when checking_id is not a hex recognizable by lnd
# it will return an error and no "settled" attribute on the object # it will return an error and no "state" attribute on the object
logger.warning(f"Error checking invoice from LND REST API: {r.text}")
return PaymentPendingStatus() return PaymentPendingStatus()
if data.get("settled") is True: if data.get("state") == "SETTLED":
return PaymentSuccessStatus() return PaymentSuccessStatus()
if data.get("state") == "CANCELED": if data.get("state") == "CANCELED":
@@ -264,10 +239,11 @@ class LndRestWallet(Wallet):
"ascii" "ascii"
) )
except ValueError: except ValueError:
logger.warning("Invalid checking_id format, must be hex: {checking_id}")
return PaymentPendingStatus() return PaymentPendingStatus()
url = f"/v2/router/track/{checking_id}" url = f"/v2/router/track/{checking_id}"
async with self.client.stream("GET", url, timeout=None) as r: async with self.client.stream("GET", url, timeout=30) as r:
async for json_line in r.aiter_lines(): async for json_line in r.aiter_lines():
try: try:
line = json.loads(json_line) line = json.loads(json_line)
@@ -298,7 +274,7 @@ class LndRestWallet(Wallet):
return PaymentFailedStatus() return PaymentFailedStatus()
elif status == "IN_FLIGHT": elif status == "IN_FLIGHT":
logger.info(f"LNDRest Payment in flight: {checking_id}") logger.info(f"LNDRest Payment in flight: {checking_id}")
return PaymentPendingStatus() continue
logger.info(f"LNDRest Payment non-existent: {checking_id}") logger.info(f"LNDRest Payment non-existent: {checking_id}")
return PaymentPendingStatus() return PaymentPendingStatus()
@@ -311,13 +287,12 @@ class LndRestWallet(Wallet):
async for line in r.aiter_lines(): async for line in r.aiter_lines():
try: try:
inv = json.loads(line)["result"] inv = json.loads(line)["result"]
if not inv["settled"]: if not inv.get("state") == "SETTLED":
continue continue
payment_hash = base64.b64decode(inv.get("r_hash")).hex()
except Exception as exc: except Exception as exc:
logger.debug(exc) logger.debug(exc)
continue continue
payment_hash = base64.b64decode(inv["r_hash"]).hex()
yield payment_hash yield payment_hash
except Exception as exc: except Exception as exc:
logger.warning( logger.warning(
@@ -363,8 +338,6 @@ class LndRestWallet(Wallet):
return InvoiceResponse(ok=False, error_message=str(exc)) return InvoiceResponse(ok=False, error_message=str(exc))
payment_request = data["payment_request"] payment_request = data["payment_request"]
payment_hash = base64.b64encode(bytes.fromhex(payment_hash)).decode("ascii")
return InvoiceResponse( return InvoiceResponse(
ok=True, checking_id=payment_hash, payment_request=payment_request ok=True, checking_id=payment_hash, payment_request=payment_request
) )
@@ -399,3 +372,31 @@ class LndRestWallet(Wallet):
except Exception as exc: except Exception as exc:
logger.warning(exc) logger.warning(exc)
return InvoiceResponse(ok=False, error_message=str(exc)) return InvoiceResponse(ok=False, error_message=str(exc))
def _parse_create_invoice_response(
self, r: Any, data: dict, preimage: str
) -> InvoiceResponse:
if not data:
return InvoiceResponse(ok=False, error_message="no data")
if "error" in data:
return InvoiceResponse(
ok=False, error_message=f"Server error: '{data['error']}'"
)
if r.is_error:
return InvoiceResponse(ok=False, error_message=f"Server error: '{r.text}'")
if "payment_request" not in data or "r_hash" not in data:
return InvoiceResponse(
ok=False, error_message="Server error: 'missing required fields'"
)
try:
payment_hash = base64.b64decode(data["r_hash"]).hex()
except Exception:
return InvoiceResponse(
ok=False, error_message=f"Unable to b64decode to {data['r_hash']}."
)
return InvoiceResponse(
ok=True,
checking_id=payment_hash,
payment_request=data["payment_request"],
preimage=preimage,
)
+921 -138
View File
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -117,11 +117,12 @@ class PhoenixdWallet(Wallet):
# PhoenixD description limited to 128 characters # PhoenixD description limited to 128 characters
if description_hash: if description_hash:
data["descriptionHash"] = description_hash.hex() data["descriptionHash"] = description_hash.hex()
elif unhashed_description:
data["descriptionHash"] = hashlib.sha256(
unhashed_description
).hexdigest()
else: else:
desc = memo desc = memo or ""
if desc is None and unhashed_description:
desc = unhashed_description.decode()
desc = desc or ""
if len(desc) > 128: if len(desc) > 128:
data["descriptionHash"] = hashlib.sha256(desc.encode()).hexdigest() data["descriptionHash"] = hashlib.sha256(desc.encode()).hexdigest()
else: else:
+1
View File
@@ -111,6 +111,7 @@
"js/pages/users.js", "js/pages/users.js",
"js/pages/account.js", "js/pages/account.js",
"js/pages/admin.js", "js/pages/admin.js",
"js/components/admin/lnbits-admin-funding-seed-backup.js",
"js/components/admin/lnbits-admin-funding.js", "js/components/admin/lnbits-admin-funding.js",
"js/components/admin/lnbits-admin-funding-sources.js", "js/components/admin/lnbits-admin-funding-sources.js",
"js/components/admin/lnbits-admin-fiat-providers.js", "js/components/admin/lnbits-admin-fiat-providers.js",
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "lnbits" name = "lnbits"
version = "1.5.5-rc2" version = "1.5.6"
requires-python = ">=3.10,<3.13" requires-python = ">=3.10,<3.13"
description = "LNbits, free and open-source Lightning wallet and accounts system." description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }] authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
+29 -1
View File
@@ -3,6 +3,7 @@ from pathlib import Path
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from lnbits.core.crud.settings import get_settings_field, set_settings_field
from lnbits.server import server_restart from lnbits.server import server_restart
from lnbits.settings import Settings from lnbits.settings import Settings
@@ -81,7 +82,8 @@ async def test_admin_audit_monitor_and_test_email(
headers={"Authorization": f"Bearer {superuser_token}"}, headers={"Authorization": f"Bearer {superuser_token}"},
) )
assert monitor.status_code == 200 assert monitor.status_code == 200
assert "invoice_listeners" in monitor.json() task_names = [t["name"] for t in monitor.json()]
assert any("invoice_listener" in name for name in task_names)
test_email = await client.get( test_email = await client.get(
"/admin/api/v1/testemail", "/admin/api/v1/testemail",
@@ -150,6 +152,15 @@ async def test_admin_partial_reset_restart_and_backup(
async def test_admin_delete_settings_requires_superuser( async def test_admin_delete_settings_requires_superuser(
client: AsyncClient, superuser_token: str client: AsyncClient, superuser_token: str
): ):
await set_settings_field("lnbits_site_title", "Reset me")
await set_settings_field("lnbits_backend_wallet_class", "BoltzWallet")
await set_settings_field("boltz_mnemonic", "keep boltz seed")
await set_settings_field("boltz_mnemonic_backup_confirmed", True)
await set_settings_field("phoenixd_mnemonic", "keep phoenixd seed")
await set_settings_field("phoenixd_mnemonic_backup_confirmed", True)
await set_settings_field("spark_l2_mnemonic", "keep spark seed")
await set_settings_field("spark_l2_mnemonic_backup_confirmed", True)
server_restart.clear() server_restart.clear()
response = await client.delete( response = await client.delete(
"/admin/api/v1/settings", "/admin/api/v1/settings",
@@ -157,4 +168,21 @@ async def test_admin_delete_settings_requires_superuser(
) )
assert response.status_code == 200 assert response.status_code == 200
assert server_restart.is_set() is True assert server_restart.is_set() is True
assert await get_settings_field("lnbits_site_title") is None
backend_wallet = await get_settings_field("lnbits_backend_wallet_class")
boltz_seed = await get_settings_field("boltz_mnemonic")
boltz_confirmed = await get_settings_field("boltz_mnemonic_backup_confirmed")
phoenixd_seed = await get_settings_field("phoenixd_mnemonic")
phoenixd_confirmed = await get_settings_field("phoenixd_mnemonic_backup_confirmed")
spark_l2_seed = await get_settings_field("spark_l2_mnemonic")
spark_l2_confirmed = await get_settings_field("spark_l2_mnemonic_backup_confirmed")
assert backend_wallet and backend_wallet.value == "BoltzWallet"
assert boltz_seed and boltz_seed.value == "keep boltz seed"
assert boltz_confirmed and boltz_confirmed.value is True
assert phoenixd_seed and phoenixd_seed.value == "keep phoenixd seed"
assert phoenixd_confirmed and phoenixd_confirmed.value is True
assert spark_l2_seed and spark_l2_seed.value == "keep spark seed"
assert spark_l2_confirmed and spark_l2_confirmed.value is True
server_restart.clear() server_restart.clear()
-2
View File
@@ -26,8 +26,6 @@ docker_bitcoin_cli = [
"exec", "exec",
"lnbits-bitcoind-1", "lnbits-bitcoind-1",
"bitcoin-cli", "bitcoin-cli",
"-rpcuser=lnbits",
"-rpcpassword=lnbits",
"-regtest", "-regtest",
] ]
+184
View File
@@ -0,0 +1,184 @@
"""
Electrum client integration tests against the regtest electrs container.
Requires the regtest docker-compose stack (docker/regtest/docker-compose.yml).
electrs is exposed on localhost:19001 (plain TCP) and localhost:3002 (HTTP).
"""
import asyncio
import httpx
import pytest
from loguru import logger
from lnbits.utils.electrum import ElectrumClient, scripthash_from_scriptpubkey
from .helpers import docker_bitcoin_cli, run_cmd, run_cmd_json
ELECTRS_HOST = "localhost"
ELECTRS_PORT = 19001
ELECTRS_HTTP = "http://localhost:3002"
def bitcoin_height() -> int:
return run_cmd_json([*docker_bitcoin_cli, "getblockchaininfo"])["blocks"]
def mine_blocks(n: int = 1) -> int:
"""Mine n blocks and return the new chain height."""
run_cmd([*docker_bitcoin_cli, "-generate", str(n)])
return bitcoin_height()
def new_address() -> str:
return run_cmd([*docker_bitcoin_cli, "getnewaddress", "bech32"])
def get_scriptpubkey(address: str) -> bytes:
info = run_cmd_json([*docker_bitcoin_cli, "getaddressinfo", address])
return bytes.fromhex(info["scriptPubKey"])
def send_to_address(address: str, sats: int) -> str:
btc = f"{sats * 1e-8:.8f}"
return run_cmd([*docker_bitcoin_cli, "sendtoaddress", address, btc])
async def wait_for_electrs(height: int, timeout: float = 15.0) -> None:
"""Poll electrs HTTP until it has indexed up to `height`."""
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
async with httpx.AsyncClient() as http:
while loop.time() < deadline:
try:
r = await http.get(f"{ELECTRS_HTTP}/blocks/tip/height", timeout=2)
if int(r.text) >= height:
return
except Exception:
logger.debug("electrs not ready yet")
await asyncio.sleep(0.25)
raise TimeoutError(f"electrs did not reach height {height} within {timeout}s")
@pytest.fixture(scope="module", autouse=True)
async def wait_after_electrum_tests():
yield
await asyncio.sleep(1)
@pytest.mark.anyio
async def test_connect_and_height():
async with ElectrumClient(f"tcp://{ELECTRS_HOST}:{ELECTRS_PORT}") as client:
before = await client.get_height()
target = mine_blocks(3)
await wait_for_electrs(target)
async with ElectrumClient(f"tcp://{ELECTRS_HOST}:{ELECTRS_PORT}") as client:
after = await client.get_height()
assert isinstance(before, int) and before >= 0
assert after == before + 3
@pytest.mark.anyio
async def test_get_tip():
async with ElectrumClient(f"tcp://{ELECTRS_HOST}:{ELECTRS_PORT}") as client:
tip = await client.get_tip()
assert isinstance(tip.height, int)
assert isinstance(tip.hex, str)
assert len(tip.hex) == 160 # 80-byte serialised header
@pytest.mark.anyio
async def test_server_banner():
async with ElectrumClient(f"tcp://{ELECTRS_HOST}:{ELECTRS_PORT}") as client:
banner = await client.server_banner()
assert isinstance(banner, str)
@pytest.mark.anyio
async def test_balance_after_payment():
address = new_address()
scripthash = scripthash_from_scriptpubkey(get_scriptpubkey(address))
async with ElectrumClient(f"tcp://{ELECTRS_HOST}:{ELECTRS_PORT}") as client:
empty = await client.get_balance(scripthash)
assert empty.confirmed == 0
assert empty.unconfirmed == 0
send_to_address(address, 500_000)
target = mine_blocks(1)
await wait_for_electrs(target)
async with ElectrumClient(f"tcp://{ELECTRS_HOST}:{ELECTRS_PORT}") as client:
confirmed = await client.get_balance(scripthash)
assert confirmed.confirmed == 500_000
assert confirmed.unconfirmed == 0
@pytest.mark.anyio
async def test_history_and_utxos():
address = new_address()
scripthash = scripthash_from_scriptpubkey(get_scriptpubkey(address))
send_to_address(address, 250_000)
target = mine_blocks(1)
await wait_for_electrs(target)
async with ElectrumClient(f"tcp://{ELECTRS_HOST}:{ELECTRS_PORT}") as client:
history = await client.get_history(scripthash)
assert len(history) >= 1
assert history[0].tx_hash
assert history[0].height > 0
utxos = await client.listunspent(scripthash)
assert len(utxos) == 1
assert utxos[0].value == 250_000
raw_tx = await client.get_transaction(utxos[0].tx_hash)
assert isinstance(raw_tx, str) and len(raw_tx) > 0
@pytest.mark.anyio
async def test_subscribe_scripthash_payment():
address = new_address()
scripthash = scripthash_from_scriptpubkey(get_scriptpubkey(address))
received: list = []
event = asyncio.Event()
def on_change(params: list) -> None:
received.append(params)
event.set()
async with ElectrumClient(f"tcp://{ELECTRS_HOST}:{ELECTRS_PORT}") as client:
initial_status = await client.subscribe_scripthash(
scripthash, callback=on_change
)
assert initial_status is None # fresh address has no history
send_to_address(address, 777_000)
target = mine_blocks(1)
await wait_for_electrs(target)
await asyncio.wait_for(event.wait(), timeout=10)
assert len(received) == 1
assert received[0][0] == scripthash # first param is the scripthash
assert received[0][1] is not None # second param is the new status hash
balance = await client.get_balance(scripthash)
assert balance.confirmed == 777_000
@pytest.mark.anyio
async def test_subscribe_headers():
async with ElectrumClient(f"tcp://{ELECTRS_HOST}:{ELECTRS_PORT}") as client:
notifications: list = []
tip = await client.subscribe_headers(callback=lambda p: notifications.append(p))
height_before = tip.height
target = mine_blocks(1)
await wait_for_electrs(target)
assert await client.get_height() == height_before + 1
+9 -6
View File
@@ -13,10 +13,13 @@ from lnbits.core.services import (
fee_reserve_total, fee_reserve_total,
get_balance_delta, get_balance_delta,
) )
from lnbits.core.services.payments import pay_invoice, update_wallet_balance from lnbits.core.services.payments import (
pay_invoice,
update_wallet_balance,
)
from lnbits.core.services.users import create_user_account from lnbits.core.services.users import create_user_account
from lnbits.exceptions import PaymentError from lnbits.exceptions import PaymentError
from lnbits.tasks import create_task, wait_for_paid_invoices from lnbits.task_manager import task_manager
from lnbits.wallets import get_funding_source from lnbits.wallets import get_funding_source
from ..helpers import is_fake, is_regtest from ..helpers import is_fake, is_regtest
@@ -160,12 +163,11 @@ async def test_create_real_invoice(
assert not payment_status["paid"] assert not payment_status["paid"]
on_paid_mock = mocker.AsyncMock() on_paid_mock = mocker.AsyncMock()
create_task(wait_for_paid_invoices("test_create_invoice", on_paid_mock)()) task_manager.register_invoice_listener(on_paid_mock, "test_create_invoice")
pay_real_invoice(invoice["bolt11"]) pay_real_invoice(invoice["bolt11"])
await asyncio.sleep(1) await asyncio.sleep(1)
assert on_paid_mock.call_count == 1 assert on_paid_mock.call_count == 1
payment = on_paid_mock.call_args_list[0][0][0] payment = on_paid_mock.call_args_list[0][0][0]
@@ -393,12 +395,11 @@ async def test_receive_real_invoice_set_pending_and_check_state(
assert not payment_status["paid"] assert not payment_status["paid"]
on_paid_mock = mocker.AsyncMock() on_paid_mock = mocker.AsyncMock()
create_task(wait_for_paid_invoices("test_create_invoice", on_paid_mock)()) task_manager.register_invoice_listener(on_paid_mock, "test_create_invoice")
pay_real_invoice(invoice["bolt11"]) pay_real_invoice(invoice["bolt11"])
await asyncio.sleep(1) await asyncio.sleep(1)
assert on_paid_mock.call_count == 1 assert on_paid_mock.call_count == 1
payment = on_paid_mock.call_args_list[0][0][0] payment = on_paid_mock.call_args_list[0][0][0]
@@ -412,6 +413,8 @@ async def test_receive_real_invoice_set_pending_and_check_state(
payment_status = response.json() payment_status = response.json()
assert payment_status["paid"] assert payment_status["paid"]
assert payment
# set the incoming invoice to pending # set the incoming invoice to pending
payment.status = PaymentState.PENDING payment.status = PaymentState.PENDING
await update_payment(payment) await update_payment(payment)
+24 -69
View File
@@ -5,6 +5,7 @@ import pytest
from pytest_mock.plugin import MockerFixture from pytest_mock.plugin import MockerFixture
from lnbits.settings import Settings from lnbits.settings import Settings
from lnbits.task_manager import task_manager
from lnbits.utils.cache import Cache, Cached from lnbits.utils.cache import Cache, Cached
key = "foo" key = "foo"
@@ -13,11 +14,10 @@ value = "bar"
@pytest.fixture @pytest.fixture
async def cache(): async def cache():
cache = Cache(interval=0.1) cache = Cache()
task = task_manager.create_permanent_task(cache.invalidate_cache, interval=1)
task = asyncio.create_task(cache.invalidate_forever())
yield cache yield cache
task.cancel() task_manager.cancel_task(task)
@pytest.mark.anyio @pytest.mark.anyio
@@ -31,13 +31,13 @@ async def test_cache_get_set(cache):
@pytest.mark.anyio @pytest.mark.anyio
async def test_cache_expiry(cache): async def test_cache_expiry(cache):
# gets expired by `get` call # gets expired by `get` call
cache.set(key, value, expiry=0.01) cache.set(key, value, expiry=1)
await asyncio.sleep(0.02) await asyncio.sleep(2)
assert not cache.get(key) assert not cache.get(key)
# gets expired by invalidation task # gets expired by invalidation task
cache.set(key, value, expiry=0.1) cache.set(key, value, expiry=1)
await asyncio.sleep(0.2) await asyncio.sleep(2)
assert key not in cache._values assert key not in cache._values
assert not cache.get(key) assert not cache.get(key)
@@ -90,82 +90,37 @@ async def test_cache_pop_expired_returns_default(cache):
assert cache.pop(key, default="fallback") == "fallback" assert cache.pop(key, default="fallback") == "fallback"
@pytest.mark.anyio
async def test_cache_coro_stale_returns_immediately(cache):
"""Stale entry is served immediately; background refresh updates the value."""
calls = 0
async def test():
nonlocal calls
calls += 1
return calls
# cold start
result = await cache.save_result(test, key="test", expiry=0.01)
assert result == 1
# let the entry expire
await asyncio.sleep(0.02)
# stale-while-revalidate: returns old value immediately
result = await cache.save_result(test, key="test", expiry=0.5)
assert result == 1 # stale value returned, not the new one
# allow background refresh to complete
await asyncio.sleep(0.05)
assert calls == 2
# now the cache has the fresh value
result = await cache.save_result(test, key="test", expiry=0.5)
assert result == 2
@pytest.mark.anyio
async def test_cache_coro_no_stampede(cache):
"""Multiple concurrent requests on a stale entry spawn only one refresh."""
calls = 0
async def slow_fetch():
nonlocal calls
calls += 1
await asyncio.sleep(0.05)
return calls
await cache.save_result(slow_fetch, key="test", expiry=0.01)
await asyncio.sleep(0.02)
# fire multiple concurrent requests while stale
results = await asyncio.gather(
cache.save_result(slow_fetch, key="test", expiry=0.5),
cache.save_result(slow_fetch, key="test", expiry=0.5),
cache.save_result(slow_fetch, key="test", expiry=0.5),
)
await asyncio.sleep(0.1) # let the single background task finish
assert all(r == 1 for r in results) # all got stale value
assert calls == 2 # cold start + exactly one background refresh
@pytest.mark.anyio @pytest.mark.anyio
async def test_invalidate_forever_logs_and_recovers_from_errors( async def test_invalidate_forever_logs_and_recovers_from_errors(
settings: Settings, mocker: MockerFixture settings: Settings, mocker: MockerFixture
): ):
test_cache = Cache(interval=0) test_cache = Cache()
logger_error = mocker.patch("lnbits.utils.cache.logger.error")
original_running = settings.lnbits_running original_running = settings.lnbits_running
calls = 0 calls = 0
async def fake_sleep(_interval): original_invalidate = test_cache.invalidate_cache
async def fake_invalidate():
nonlocal calls nonlocal calls
calls += 1 calls += 1
if calls == 1: if calls == 1:
raise RuntimeError("boom") raise RuntimeError("boom")
settings.lnbits_running = False settings.lnbits_running = False
await original_invalidate()
mocker.patch.object(test_cache, "invalidate_cache", side_effect=fake_invalidate)
mocker.patch("lnbits.task_manager.asyncio.sleep")
logger_error = mocker.patch("lnbits.task_manager.logger.error")
bg_task = None
try: try:
settings.lnbits_running = True settings.lnbits_running = True
mocker.patch("lnbits.utils.cache.asyncio.sleep", side_effect=fake_sleep) bg_task = task_manager.create_permanent_task(test_cache.invalidate_cache)
await test_cache.invalidate_forever() await bg_task.task
finally: finally:
settings.lnbits_running = original_running settings.lnbits_running = original_running
if bg_task:
task_manager.cancel_task(bg_task)
logger_error.assert_called_once_with("Error invalidating cache") assert logger_error.called
assert calls == 2
+4
View File
@@ -275,6 +275,10 @@ async def test_btc_rates_skips_unsupported_and_failing_providers(
@pytest.mark.anyio @pytest.mark.anyio
async def test_btc_price_handles_empty_single_and_multiple_rates(mocker: MockerFixture): async def test_btc_price_handles_empty_single_and_multiple_rates(mocker: MockerFixture):
mocker.patch(
"lnbits.utils.exchange_rates.btc_price_from_aggregator",
AsyncMock(return_value=None),
)
mocker.patch("lnbits.utils.exchange_rates.btc_rates", AsyncMock(return_value=[])) mocker.patch("lnbits.utils.exchange_rates.btc_rates", AsyncMock(return_value=[]))
assert await btc_price("usd") == 0.0 assert await btc_price("usd") == 0.0
+105 -14
View File
@@ -8,7 +8,7 @@ from unittest.mock import AsyncMock
import pytest import pytest
from pytest_mock.plugin import MockerFixture from pytest_mock.plugin import MockerFixture
from lnbits.core.crud.payments import get_payments from lnbits.core.crud.payments import get_payment, get_payments
from lnbits.core.crud.users import get_user from lnbits.core.crud.users import get_user
from lnbits.core.crud.wallets import create_wallet from lnbits.core.crud.wallets import create_wallet
from lnbits.core.models.payments import CreateInvoice, Payment, PaymentState from lnbits.core.models.payments import CreateInvoice, Payment, PaymentState
@@ -919,8 +919,10 @@ async def test_revolut_wallet_create_subscription(settings: Settings):
"PLAN_VARIATION_123", 1, payment_options "PLAN_VARIATION_123", 1, payment_options
) )
subscription_request_id = payment_options.subscription_request_id
assert response.ok is True assert response.ok is True
assert response.subscription_request_id is not None assert response.subscription_request_id == "SUBSCRIPTION123"
assert subscription_request_id is not None
assert ( assert (
response.checkout_session_url response.checkout_session_url
== "https://checkout.revolut.com/payment-link/sub_123" == "https://checkout.revolut.com/payment-link/sub_123"
@@ -933,16 +935,14 @@ async def test_revolut_wallet_create_subscription(settings: Settings):
assert payload["plan_variation_id"] == "PLAN_VARIATION_123" assert payload["plan_variation_id"] == "PLAN_VARIATION_123"
assert payload["customer_id"] == "CUSTOMER123" assert payload["customer_id"] == "CUSTOMER123"
assert client.calls[1][1]["timeout"] == 30 assert client.calls[1][1]["timeout"] == 30
assert client.calls[1][1]["headers"]["Idempotency-Key"] == ( assert client.calls[1][1]["headers"]["Idempotency-Key"] == (subscription_request_id)
response.subscription_request_id
)
assert payload["setup_order_redirect_url"] == ( assert payload["setup_order_redirect_url"] == (
"https://lnbits.example/subscription-success" "https://lnbits.example/subscription-success"
) )
reference = json.loads(payload["external_reference"]) reference = json.loads(payload["external_reference"])
assert reference["wallet_id"] == "wallet_1" assert reference["wallet_id"] == "wallet_1"
assert reference["tag"] == "gold" assert reference["tag"] == "gold"
assert reference["subscription_request_id"] == response.subscription_request_id assert reference["subscription_request_id"] == subscription_request_id
assert reference["memo"] == "Monthly Gold" assert reference["memo"] == "Monthly Gold"
assert reference["extra"]["link"] == "link-1" assert reference["extra"]["link"] == "link-1"
assert client.calls[2][0] == "/api/orders/ORDER123" assert client.calls[2][0] == "/api/orders/ORDER123"
@@ -1235,13 +1235,55 @@ async def test_revolut_wallet_cancel_subscription(settings: Settings):
settings.revolut_api_version = "2026-04-20" settings.revolut_api_version = "2026-04-20"
wallet = RevolutWallet() wallet = RevolutWallet()
client = MockHTTPClient([MockHTTPResponse(json_data={})]) client = MockHTTPClient(
[
MockHTTPResponse(
json_data={
"external_reference": json.dumps({"wallet_id": "wallet_1"}),
}
),
MockHTTPResponse(json_data={}),
]
)
wallet.client = client # type: ignore[assignment] wallet.client = client # type: ignore[assignment]
response = await wallet.cancel_subscription("SUBSCRIPTION123", "wallet_1") response = await wallet.cancel_subscription("SUBSCRIPTION123", "wallet_1")
assert response.ok is True assert response.ok is True
assert client.calls[0][0] == "/api/subscriptions/SUBSCRIPTION123/cancel" assert client.calls[0][0] == "/api/subscriptions/SUBSCRIPTION123"
assert client.calls[1][0] == "/api/subscriptions/SUBSCRIPTION123/cancel"
@pytest.mark.anyio
async def test_revolut_wallet_cancel_subscription_checks_wallet_id(
settings: Settings,
):
settings.revolut_api_endpoint = "https://sandbox-merchant.revolut.com"
settings.revolut_api_secret_key = "revolut-secret"
settings.revolut_api_version = "2026-04-20"
wallet = RevolutWallet()
client = MockHTTPClient(
[
MockHTTPResponse(
json_data={
"external_reference": json.dumps({"wallet_id": "wallet_2"}),
}
),
]
)
wallet.client = client # type: ignore[assignment]
response = await wallet.cancel_subscription("SUBSCRIPTION123", "wallet_1")
assert response.ok is False
assert response.error_message == "Subscription not found."
assert client.calls == [
(
"/api/subscriptions/SUBSCRIPTION123",
{"timeout": 30},
)
]
@pytest.mark.anyio @pytest.mark.anyio
@@ -1675,7 +1717,9 @@ async def test_check_fiat_status_handles_internal_states(mocker: MockerFixture):
"lnbits.core.services.fiat_providers.get_fiat_provider", "lnbits.core.services.fiat_providers.get_fiat_provider",
AsyncMock(return_value=provider), AsyncMock(return_value=provider),
) )
queue_put = mocker.patch("lnbits.tasks.internal_invoice_queue.put", AsyncMock()) queue_put = mocker.patch(
"lnbits.task_manager.task_manager.internal_invoice_queue.put_nowait"
)
success_status = await check_fiat_status( success_status = await check_fiat_status(
Payment( Payment(
@@ -1692,7 +1736,8 @@ async def test_check_fiat_status_handles_internal_states(mocker: MockerFixture):
) )
assert success_status.success is True assert success_status.success is True
queue_put.assert_awaited_once_with("fiat_pending") queue_put.assert_called_once()
assert queue_put.call_args[0][0].checking_id == "fiat_pending"
await check_fiat_status( await check_fiat_status(
Payment( Payment(
@@ -1702,13 +1747,59 @@ async def test_check_fiat_status_handles_internal_states(mocker: MockerFixture):
amount=1000, amount=1000,
fee=0, fee=0,
bolt11="bolt11", bolt11="bolt11",
status=PaymentState.PENDING, status=PaymentState.SUCCESS,
fiat_provider="stripe", fiat_provider="stripe",
extra={"fiat_checking_id": "stripe_checking_id"}, extra={"fiat_checking_id": "stripe_checking_id"},
), )
skip_internal_payment_notifications=True,
) )
assert queue_put.await_count == 1 assert queue_put.call_count == 1
@pytest.mark.anyio
async def test_check_fiat_status_persists_successful_payment(
to_wallet: Wallet, settings: Settings, mocker: MockerFixture
):
settings.stripe_enabled = True
settings.stripe_api_secret_key = "mock_sk_test_4eC39HqLyjWDarjtT1zdp7dc"
settings.stripe_limits.service_min_amount_sats = 0
settings.stripe_limits.service_max_amount_sats = 0
settings.stripe_limits.service_fee_wallet_id = None
settings.stripe_limits.service_faucet_wallet_id = None
invoice_data = CreateInvoice(
unit="USD", amount=1.0, memo="Test", fiat_provider="stripe"
)
fiat_mock_response = FiatInvoiceResponse(
ok=True,
checking_id=f"session_paid_{get_random_string(10)}",
payment_request="https://stripe.com/pay/session_paid",
)
mocker.patch(
"lnbits.fiat.StripeWallet.create_invoice",
AsyncMock(return_value=fiat_mock_response),
)
mocker.patch(
"lnbits.utils.exchange_rates.get_fiat_rate_satoshis",
AsyncMock(return_value=1000),
)
payment = await payments.create_fiat_invoice(to_wallet.id, invoice_data)
assert payment.status == PaymentState.PENDING
mocker.patch(
"lnbits.fiat.StripeWallet.get_invoice_status",
AsyncMock(return_value=FiatPaymentStatus(paid=True)),
)
queue_put = mocker.patch(
"lnbits.task_manager.task_manager.internal_invoice_queue.put_nowait"
)
status = await check_fiat_status(payment)
assert status.success is True
assert payment.status == PaymentState.SUCCESS
updated_payment = await get_payment(payment.checking_id)
assert updated_payment.status == PaymentState.SUCCESS
queue_put.assert_called_once_with(payment)
@pytest.mark.anyio @pytest.mark.anyio
-2
View File
@@ -259,9 +259,7 @@ def test_get_api_routes_extracts_v1_paths():
def test_path_and_case_helpers(): def test_path_and_case_helpers():
assert path_segments("/wallet/path") == ["wallet", "path"] assert path_segments("/wallet/path") == ["wallet", "path"]
assert path_segments("/upgrades/ext/assets/app.js") == ["assets", "app.js"]
assert normalize_path(None) == "/" assert normalize_path(None) == "/"
assert normalize_path("/upgrades/ext/assets/app.js") == "/assets/app.js"
assert normalize_endpoint("example.com/") == "https://example.com" assert normalize_endpoint("example.com/") == "https://example.com"
assert normalize_endpoint("ws://socket.example.com") == "ws://socket.example.com" assert normalize_endpoint("ws://socket.example.com") == "ws://socket.example.com"
assert ( assert (
+22 -4
View File
@@ -12,10 +12,12 @@ from lnbits.core.crud import create_wallet, get_standalone_payment, get_wallet
from lnbits.core.crud.payments import get_payment, get_payments_paginated from lnbits.core.crud.payments import get_payment, get_payments_paginated
from lnbits.core.models import PaymentState, Wallet from lnbits.core.models import PaymentState, Wallet
from lnbits.core.services import create_invoice, create_user_account, pay_invoice from lnbits.core.services import create_invoice, create_user_account, pay_invoice
from lnbits.core.services.payments import update_wallet_balance from lnbits.core.services.payments import (
update_wallet_balance,
)
from lnbits.exceptions import InvoiceError, PaymentError from lnbits.exceptions import InvoiceError, PaymentError
from lnbits.settings import Settings from lnbits.settings import Settings
from lnbits.tasks import create_task, wait_for_paid_invoices from lnbits.task_manager import task_manager
from lnbits.wallets.base import PaymentResponse from lnbits.wallets.base import PaymentResponse
from lnbits.wallets.fake import FakeWallet from lnbits.wallets.fake import FakeWallet
@@ -231,17 +233,31 @@ async def test_notification_for_internal_payment(
): ):
test_name = "test_notification_for_internal_payment" test_name = "test_notification_for_internal_payment"
# Drain stale items left by session-scoped fixtures (e.g. update_wallet_balance)
while not task_manager.internal_invoice_queue.empty():
try:
task_manager.internal_invoice_queue.get_nowait()
except asyncio.QueueEmpty:
break
on_paid_mock = mocker.AsyncMock() on_paid_mock = mocker.AsyncMock()
create_task(wait_for_paid_invoices(test_name, on_paid_mock)()) # create_task(internal_invoice_listener())
task_manager.register_invoice_listener(on_paid_mock, test_name)
payment = await create_invoice( payment = await create_invoice(
wallet_id=to_wallet.id, wallet_id=to_wallet.id,
amount=123, amount=123,
memo=test_name, memo=test_name,
webhook="http://test.404.lnbits.com", webhook="http://test.404.lnbits.com",
) )
await pay_invoice( paid_payment = await pay_invoice(
wallet_id=to_wallet.id, payment_request=payment.bolt11, extra={"tag": "lnurlp"} wallet_id=to_wallet.id, payment_request=payment.bolt11, extra={"tag": "lnurlp"}
) )
assert paid_payment.status == PaymentState.SUCCESS.value
assert paid_payment.bolt11 == payment.bolt11
assert paid_payment.amount == -123_000
await asyncio.sleep(1) await asyncio.sleep(1)
assert on_paid_mock.call_count == 1 assert on_paid_mock.call_count == 1
@@ -251,6 +267,8 @@ async def test_notification_for_internal_payment(
assert _payment.status == PaymentState.SUCCESS.value assert _payment.status == PaymentState.SUCCESS.value
assert _payment.bolt11 == payment.bolt11 assert _payment.bolt11 == payment.bolt11
assert _payment.amount == 123_000 assert _payment.amount == 123_000
assert _payment.checking_id == payment.checking_id
updated_payment = await get_payment(_payment.checking_id) updated_payment = await get_payment(_payment.checking_id)
assert ( assert (
updated_payment.webhook_status is not None updated_payment.webhook_status is not None
+8 -3
View File
@@ -62,6 +62,9 @@ async def test_install_extension_creates_new_extension_and_starts_background_wor
"lnbits.core.services.extensions.start_extension_background_work", "lnbits.core.services.extensions.start_extension_background_work",
mocker.AsyncMock(return_value=True), mocker.AsyncMock(return_value=True),
) )
mocker.patch(
"lnbits.core.services.extensions.core_app_extra.register_new_ext_routes"
)
mocker.patch( mocker.patch(
"lnbits.core.services.extensions.get_db_version", "lnbits.core.services.extensions.get_db_version",
mocker.AsyncMock(return_value=0), mocker.AsyncMock(return_value=0),
@@ -76,6 +79,7 @@ async def test_install_extension_creates_new_extension_and_starts_background_wor
settings.lnbits_extensions_path = str(tmp_path / "code") settings.lnbits_extensions_path = str(tmp_path / "code")
extension = await install_extension(ext_info) extension = await install_extension(ext_info)
await activate_extension(extension) # starts background task
stored = await get_installed_extension(ext_id) stored = await get_installed_extension(ext_id)
finally: finally:
await delete_installed_extension(ext_id=ext_id) await delete_installed_extension(ext_id=ext_id)
@@ -111,6 +115,9 @@ async def test_install_extension_updates_existing_upgrade_and_preserves_payments
"lnbits.core.services.extensions.stop_extension_background_work", "lnbits.core.services.extensions.stop_extension_background_work",
mocker.AsyncMock(return_value=True), mocker.AsyncMock(return_value=True),
) )
mocker.patch(
"lnbits.core.services.extensions.core_app_extra.register_new_ext_routes"
)
mocker.patch( mocker.patch(
"lnbits.core.services.extensions.get_db_version", "lnbits.core.services.extensions.get_db_version",
mocker.AsyncMock(return_value=1), mocker.AsyncMock(return_value=1),
@@ -124,9 +131,8 @@ async def test_install_extension_updates_existing_upgrade_and_preserves_payments
settings.lnbits_data_folder = str(tmp_path / "data") settings.lnbits_data_folder = str(tmp_path / "data")
settings.lnbits_extensions_path = str(tmp_path / "code") settings.lnbits_extensions_path = str(tmp_path / "code")
await create_installed_extension(existing_ext) await create_installed_extension(existing_ext)
updated_ext.ext_upgrade_dir.mkdir(parents=True, exist_ok=True)
extension = await install_extension(updated_ext, skip_download=True) extension = await install_extension(updated_ext, skip_download=True)
await activate_extension(extension) # starts background task
stored = await get_installed_extension(ext_id) stored = await get_installed_extension(ext_id)
finally: finally:
await delete_installed_extension(ext_id=ext_id) await delete_installed_extension(ext_id=ext_id)
@@ -134,7 +140,6 @@ async def test_install_extension_updates_existing_upgrade_and_preserves_payments
settings.lnbits_extensions_path = original_extensions_path settings.lnbits_extensions_path = original_extensions_path
assert extension.code == ext_id assert extension.code == ext_id
assert extension.is_upgrade_extension is True
assert stored is not None assert stored is not None
assert stored.meta is not None assert stored.meta is not None
assert stored.meta.payments == [existing_payment] assert stored.meta.payments == [existing_payment]
+41 -1
View File
@@ -39,6 +39,7 @@ from lnbits.core.services.notifications import (
send_nostr_notification, send_nostr_notification,
send_nostr_notifications, send_nostr_notifications,
send_notification, send_notification,
send_notification_in_background,
send_payment_notification, send_payment_notification,
send_payment_push_notification, send_payment_push_notification,
send_push_notification, send_push_notification,
@@ -117,7 +118,7 @@ async def test_send_admin_and_user_notification_use_expected_targets(
settings: Settings, mocker: MockerFixture settings: Settings, mocker: MockerFixture
): ):
send_mock = mocker.patch( send_mock = mocker.patch(
"lnbits.core.services.notifications.send_notification", "lnbits.core.services.notifications.send_notification_in_background",
mocker.AsyncMock(), mocker.AsyncMock(),
) )
original_chat_id = settings.lnbits_telegram_notifications_chat_id original_chat_id = settings.lnbits_telegram_notifications_chat_id
@@ -159,6 +160,45 @@ async def test_send_admin_and_user_notification_use_expected_targets(
) )
@pytest.mark.anyio
async def test_send_notification_in_background_schedules_notification(
mocker: MockerFixture,
):
scheduled = []
def create_task(coro):
scheduled.append(coro)
coro.close()
return mocker.Mock()
create_task_mock = mocker.patch(
"lnbits.core.services.notifications.create_task",
side_effect=create_task,
)
send_mock = mocker.patch(
"lnbits.core.services.notifications.send_notification",
mocker.AsyncMock(),
)
await send_notification_in_background(
"chat-id",
["alice@example.com"],
["admin@example.com"],
"hello",
"settings_update",
)
create_task_mock.assert_called_once()
send_mock.assert_called_once_with(
"chat-id",
["alice@example.com"],
["admin@example.com"],
"hello",
"settings_update",
)
assert len(scheduled) == 1
@pytest.mark.anyio @pytest.mark.anyio
async def test_send_notification_uses_available_channels_and_swallows_exceptions( async def test_send_notification_uses_available_channels_and_swallows_exceptions(
settings: Settings, mocker: MockerFixture settings: Settings, mocker: MockerFixture
+3 -3
View File
@@ -197,8 +197,7 @@ async def test_update_wallet_balance_validates_credit_and_debit(
settings.lnbits_wallet_limit_max_balance = 0 settings.lnbits_wallet_limit_max_balance = 0
queue_mock = mocker.patch( queue_mock = mocker.patch(
"lnbits.tasks.internal_invoice_queue_put", "lnbits.task_manager.task_manager.internal_invoice_queue.put_nowait",
mocker.AsyncMock(),
) )
await update_wallet_balance(wallet, 5) await update_wallet_balance(wallet, 5)
@@ -212,7 +211,8 @@ async def test_update_wallet_balance_validates_credit_and_debit(
] ]
assert credit_payments assert credit_payments
assert credit_payments[0].status == PaymentState.SUCCESS assert credit_payments[0].status == PaymentState.SUCCESS
queue_mock.assert_awaited_once_with(credit_payments[0].checking_id) queue_mock.assert_called_once()
assert queue_mock.call_args[0][0].checking_id == credit_payments[0].checking_id
@pytest.mark.anyio @pytest.mark.anyio
+1 -6
View File
@@ -216,16 +216,11 @@ def test_installed_extensions_settings_activate_and_deactivate_paths():
} }
] ]
installed.activate_extension_paths( installed.activate_extension_paths("lnurlp", ext_redirects=redirects)
"lnurlp",
upgrade_hash="hash123",
ext_redirects=redirects,
)
redirect = installed.find_extension_redirect("/.well-known/lnurlp", []) redirect = installed.find_extension_redirect("/.well-known/lnurlp", [])
assert redirect is not None assert redirect is not None
assert redirect.ext_id == "lnurlp" assert redirect.ext_id == "lnurlp"
assert installed.lnbits_upgraded_extensions["lnurlp"] == "hash123"
assert "lnurlp" in installed.lnbits_installed_extensions_ids assert "lnurlp" in installed.lnbits_installed_extensions_ids
installed.deactivate_extension_paths("lnurlp") installed.deactivate_extension_paths("lnurlp")
+10 -10
View File
@@ -2073,7 +2073,7 @@
{ {
"response_type": "json", "response_type": "json",
"response": { "response": {
"settled": true "state": "SETTLED"
} }
} }
] ]
@@ -2155,8 +2155,15 @@
] ]
}, },
"lndrest": { "lndrest": {
"description": "lndrest.py doesn't handle the 'failed' status for `get_invoice_status`", "get_invoice_status_endpoint": [
"get_invoice_status_endpoint": [] {
"description": "error status",
"response_type": "json",
"response": {
"state": "CANCELED"
}
}
]
}, },
"alby": { "alby": {
"description": "alby.py doesn't handle the 'failed' status for `get_invoice_status`", "description": "alby.py doesn't handle the 'failed' status for `get_invoice_status`",
@@ -2243,13 +2250,6 @@
"response_type": "json", "response_type": "json",
"response": {} "response": {}
}, },
{
"description": "error status",
"response_type": "json",
"response": {
"seetled": false
}
},
{ {
"description": "bad json", "description": "bad json",
"response_type": "data", "response_type": "data",
+127 -1
View File
@@ -1,3 +1,4 @@
import asyncio
import base64 import base64
import hashlib import hashlib
import json import json
@@ -12,7 +13,7 @@ from Cryptodome.Util.Padding import pad, unpad
from websockets import ServerConnection from websockets import ServerConnection
from websockets import serve as ws_serve from websockets import serve as ws_serve
from lnbits.wallets.nwc import NWCWallet from lnbits.wallets.nwc import NWCConnection, NWCWallet
from tests.wallets.helpers import ( from tests.wallets.helpers import (
WalletTest, WalletTest,
build_test_id, build_test_id,
@@ -99,6 +100,8 @@ async def handle( # noqa: C901
event, event,
) )
await websocket.send(json.dumps(["EVENT", sub_id, event])) await websocket.send(json.dumps(["EVENT", sub_id, event]))
elif 23195 in kinds:
assert sub_filter["authors"] == [mock_settings["service_public_key"]]
elif msg[0] == "EVENT": elif msg[0] == "EVENT":
event = msg[1] event = msg[1]
decrypted_content = decrypt_content( decrypted_content = decrypt_content(
@@ -177,6 +180,129 @@ async def run(data: WalletTest):
await nwcwallet.cleanup() await nwcwallet.cleanup()
@pytest.mark.anyio
async def test_nwc_rejects_event_from_unexpected_pubkey(mocker):
async def _noop(*args, **kwargs):
return None
mocker.patch("lnbits.wallets.nwc.NWCConnection._connect_to_relay", new=_noop)
mocker.patch("lnbits.wallets.nwc.NWCConnection._handle_timeouts", new=_noop)
service_private_key = PrivateKey()
service_public_key = service_private_key.public_key.format().hex()[2:]
attacker_private_key = PrivateKey()
attacker_public_key = attacker_private_key.public_key.format().hex()[2:]
account_private_key = PrivateKey()
conn = NWCConnection(
service_public_key,
account_private_key.secret.hex(),
"ws://127.0.0.1:8555",
)
try:
event = {
"kind": 23195,
"content": "{}",
"created_at": int(time.time()),
"tags": [["e", "request-event-id"]],
}
sign_event(attacker_public_key, attacker_private_key.secret.hex(), event)
with pytest.raises(Exception, match="Invalid event signature"):
await conn._on_event_message(["EVENT", "subid", event])
finally:
await conn.close()
@pytest.mark.anyio
async def test_nwc_marks_pending_invoice_settled_only_once():
wallet = NWCWallet.__new__(NWCWallet)
wallet.pending_invoice_details = {"checking-id": {"checking_id": "checking-id"}}
wallet.pending_invoices = ["checking-id"]
wallet.paid_invoices_queue = asyncio.Queue(0)
wallet._mark_invoice_settled("checking-id", source="notification")
wallet._mark_invoice_settled("checking-id", source="notification")
assert wallet.paid_invoices_queue.qsize() == 1
assert await wallet.paid_invoices_queue.get() == "checking-id"
@pytest.mark.anyio
async def test_nwc_registers_notification_subscriptions(mocker):
async def _noop(*args, **kwargs):
return None
mocker.patch("lnbits.wallets.nwc.NWCConnection._connect_to_relay", new=_noop)
mocker.patch("lnbits.wallets.nwc.NWCConnection._handle_timeouts", new=_noop)
service_private_key = PrivateKey()
service_public_key = service_private_key.public_key.format().hex()[2:]
account_private_key = PrivateKey()
conn = NWCConnection(
service_public_key,
account_private_key.secret.hex(),
"ws://127.0.0.1:8555",
)
send_mock = mocker.patch.object(conn, "_send", mocker.AsyncMock())
try:
await conn._subscribe_to_notifications()
assert len(conn.notification_subscription_ids) == 2
assert len(conn.subscriptions) == 2
assert set(conn.subscriptions.keys()) == conn.notification_subscription_ids
assert all(
subscription["method"] == "notification_sub"
and subscription["event_id"] == subscription["sub_id"]
for subscription in conn.subscriptions.values()
)
assert send_mock.await_count == 2
finally:
await conn.close()
@pytest.mark.anyio
async def test_nwc_spreads_fallback_lookups_with_cooldown(mocker):
def _schedule_next_lookup(
invoice: dict[str, object], now: float | None = None
) -> None:
assert now is not None
invoice["next_lookup_at"] = now + 1
wallet = NWCWallet.__new__(NWCWallet)
wallet.shutdown = False
wallet.pending_invoices = ["checking-1", "checking-2"]
wallet.pending_invoice_details = {
"checking-1": {
"checking_id": "checking-1",
"next_lookup_at": 0.0,
"lookup_attempts": 0,
},
"checking-2": {
"checking_id": "checking-2",
"next_lookup_at": 0.0,
"lookup_attempts": 0,
},
}
wallet.pending_invoices_lookup_cooldown = 1.0
wallet._is_shutting_down = lambda: False
wallet._payment_data_is_settled = lambda payment_data: False
wallet._cache_payment_data = lambda *args, **kwargs: None
wallet._schedule_next_lookup = _schedule_next_lookup
wallet.conn = mocker.Mock()
wallet.conn.get_info = mocker.AsyncMock()
wallet.conn.supports_method = mocker.Mock(return_value=True)
wallet.conn.call = mocker.AsyncMock(return_value={"settled_at": None})
sleep_mock = mocker.patch("lnbits.wallets.nwc.asyncio.sleep", mocker.AsyncMock())
await wallet._run_fallback_lookups(100.0)
assert wallet.conn.call.await_count == 2
sleep_mock.assert_awaited_once_with(1.0)
@pytest.mark.anyio @pytest.mark.anyio
@pytest.mark.parametrize( @pytest.mark.parametrize(
"test_data", "test_data",
Generated
+109 -105
View File
@@ -3,7 +3,7 @@ revision = 3
requires-python = ">=3.10, <3.13" requires-python = ">=3.10, <3.13"
[options] [options]
exclude-newer = "2026-05-27T19:33:23.836682466Z" exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
exclude-newer-span = "P1W" exclude-newer-span = "P1W"
[[package]] [[package]]
@@ -17,7 +17,7 @@ wheels = [
[[package]] [[package]]
name = "aiohttp" name = "aiohttp"
version = "3.13.5" version = "3.14.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "aiohappyeyeballs" }, { name = "aiohappyeyeballs" },
@@ -27,61 +27,65 @@ dependencies = [
{ name = "frozenlist" }, { name = "frozenlist" },
{ name = "multidict" }, { name = "multidict" },
{ name = "propcache" }, { name = "propcache" },
{ name = "typing-extensions" },
{ name = "yarl" }, { name = "yarl" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/85/cebc47ee74d8b408749073a1a46c6fcba13d170dc8af7e61996c6c9394ac/aiohttp-3.13.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:02222e7e233295f40e011c1b00e3b0bd451f22cf853a0304c3595633ee47da4b", size = 750547, upload-time = "2026-03-31T21:56:30.024Z" }, { url = "https://files.pythonhosted.org/packages/6d/67/58ded4b3f2e10f94972d8928050c85330e249a31dd45a0e5f3c0e9c3fa05/aiohttp-3.14.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8f6bb621e5863cfe8fe5ff5468002d200ec31f30f1280b259dc505b02595099e", size = 766140, upload-time = "2026-06-07T21:05:37.471Z" },
{ url = "https://files.pythonhosted.org/packages/05/98/afd308e35b9d3d8c9ec54c0918f1d722c86dc17ddfec272fcdbcce5a3124/aiohttp-3.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bace460460ed20614fa6bc8cb09966c0b8517b8c58ad8046828c6078d25333b5", size = 503535, upload-time = "2026-03-31T21:56:31.935Z" }, { url = "https://files.pythonhosted.org/packages/18/68/4ae5b4e08943f316594bb68da89957d3baf5760588fa09509594bd777e4b/aiohttp-3.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f7215cb3933784f79ed20e5f050e15984f390424339b22375d5a53c933a0491", size = 519430, upload-time = "2026-06-07T21:05:40.751Z" },
{ url = "https://files.pythonhosted.org/packages/6f/4d/926c183e06b09d5270a309eb50fbde7b09782bfd305dec1e800f329834fb/aiohttp-3.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f546a4dc1e6a5edbb9fd1fd6ad18134550e096a5a43f4ad74acfbd834fc6670", size = 497830, upload-time = "2026-03-31T21:56:33.654Z" }, { url = "https://files.pythonhosted.org/packages/cb/c1/316c8f3549dbe5245f92bfd523ec6f32dd4d98cafe21df3f6a19b1184c75/aiohttp-3.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9d4e294455b23a68c9b8f042d0e8e377a265bcb15332753695f6e5b6819e0ce", size = 514406, upload-time = "2026-06-07T21:05:42.111Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d6/f47d1c690f115a5c2a5e8938cce4a232a5be9aac5c5fb2647efcbbbda333/aiohttp-3.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c86969d012e51b8e415a8c6ce96f7857d6a87d6207303ab02d5d11ef0cad2274", size = 1682474, upload-time = "2026-03-31T21:56:35.513Z" }, { url = "https://files.pythonhosted.org/packages/5a/ee/fb0ac28684e8d753b83c8a4eebc19a5846912aa0a4daaabb6a9936363840/aiohttp-3.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b238af795833d5731d049d82bc84b768ae6f8f97f0495963b3ed9935c5901cc3", size = 1703649, upload-time = "2026-06-07T21:05:43.427Z" },
{ url = "https://files.pythonhosted.org/packages/01/44/056fd37b1bb52eac760303e5196acc74d9d546631b035704ae5927f7b4ac/aiohttp-3.13.5-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b6f6cd1560c5fa427e3b6074bb24d2c64e225afbb7165008903bd42e4e33e28a", size = 1655259, upload-time = "2026-03-31T21:56:37.843Z" }, { url = "https://files.pythonhosted.org/packages/3b/57/aa2beab673331f111885db8a7b69dfe3ab0e53e446a0ace18ca694b4dc58/aiohttp-3.14.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4e5e0ae56914ecdbf446493addefc0159053dd53962cef37d7839f37f73d505", size = 1675126, upload-time = "2026-06-07T21:05:44.897Z" },
{ url = "https://files.pythonhosted.org/packages/91/9f/78eb1a20c1c28ae02f6a3c0f4d7b0dcc66abce5290cadd53d78ce3084175/aiohttp-3.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:636bc362f0c5bbc7372bc3ae49737f9e3030dbce469f0f422c8f38079780363d", size = 1736204, upload-time = "2026-03-31T21:56:39.822Z" }, { url = "https://files.pythonhosted.org/packages/47/ea/dad128abe365e79be03b16ed464198ac73e0d257e8260c6f7d6f31cbef26/aiohttp-3.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:092e4ce3619a7c6dee52a6bdabda973d9b34b66781f840ce93c7e0cec30cf521", size = 1771558, upload-time = "2026-06-07T21:05:46.405Z" },
{ url = "https://files.pythonhosted.org/packages/de/6c/d20d7de23f0b52b8c1d9e2033b2db1ac4dacbb470bb74c56de0f5f86bb4f/aiohttp-3.13.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6a7cbeb06d1070f1d14895eeeed4dac5913b22d7b456f2eb969f11f4b3993796", size = 1826198, upload-time = "2026-03-31T21:56:41.378Z" }, { url = "https://files.pythonhosted.org/packages/63/f3/b5b4e10327cb85d34d24232c6b71b64602f190b3ccb238a043ac6b187dac/aiohttp-3.14.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb33777ea21e8b7ecde0e6fc84f598be0a1192eab1a63bc746d75aa75d38e7bd", size = 1856631, upload-time = "2026-06-07T21:05:47.844Z" },
{ url = "https://files.pythonhosted.org/packages/2f/86/a6f3ff1fd795f49545a7c74b2c92f62729135d73e7e4055bf74da5a26c82/aiohttp-3.13.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca9ef7517fd7874a1a08970ae88f497bf5c984610caa0bf40bd7e8450852b95", size = 1681329, upload-time = "2026-03-31T21:56:43.374Z" }, { url = "https://files.pythonhosted.org/packages/2b/9d/93294c3045775c708ac8310eb3d3622a11d2951345ad590d532d62a1faa4/aiohttp-3.14.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23119f8fd4f5d16902ed459b63b100bcd269628075162bddac56cc7b5273b3fb", size = 1714139, upload-time = "2026-06-07T21:05:49.982Z" },
{ url = "https://files.pythonhosted.org/packages/fb/68/84cd3dab6b7b4f3e6fe9459a961acb142aaab846417f6e8905110d7027e5/aiohttp-3.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:019a67772e034a0e6b9b17c13d0a8fe56ad9fb150fc724b7f3ffd3724288d9e5", size = 1560023, upload-time = "2026-03-31T21:56:45.031Z" }, { url = "https://files.pythonhosted.org/packages/29/c4/93067c85a0373492ce8e577435203c5947c454af074ac48ed4f3a1b9dd4a/aiohttp-3.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:57fc6745a4b7d0f5a9eb4f40a69718be6c0bc1b8368cc9fe89e90118719f4f42", size = 1588321, upload-time = "2026-06-07T21:05:51.431Z" },
{ url = "https://files.pythonhosted.org/packages/41/2c/db61b64b0249e30f954a65ab4cb4970ced57544b1de2e3c98ee5dc24165f/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f34ecee82858e41dd217734f0c41a532bd066bcaab636ad830f03a30b2a96f2a", size = 1652372, upload-time = "2026-03-31T21:56:47.075Z" }, { url = "https://files.pythonhosted.org/packages/c4/39/9ff91aaf02af8b7b8222a987466da539f154c3e01732c22b5f5a20a8ee66/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6fd35beba67c4183b09375c5fff9accb47524191a244a99f95fd4472f5402c2b", size = 1670375, upload-time = "2026-06-07T21:05:53.109Z" },
{ url = "https://files.pythonhosted.org/packages/25/6f/e96988a6c982d047810c772e28c43c64c300c943b0ed5c1c0c4ce1e1027c/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4eac02d9af4813ee289cd63a361576da36dba57f5a1ab36377bc2600db0cbb73", size = 1662031, upload-time = "2026-03-31T21:56:48.835Z" }, { url = "https://files.pythonhosted.org/packages/aa/e4/77452a3676b8d99ac1375f77691d6bf65ea6e9f4b201b82ef77c916dc767/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:672b9d65f42eb877f5c3f234a4547e4e1a226ca8c2eed879bb34670a0ce51192", size = 1690933, upload-time = "2026-06-07T21:05:54.902Z" },
{ url = "https://files.pythonhosted.org/packages/b7/26/a56feace81f3d347b4052403a9d03754a0ab23f7940780dada0849a38c92/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4beac52e9fe46d6abf98b0176a88154b742e878fdf209d2248e99fcdf73cd297", size = 1708118, upload-time = "2026-03-31T21:56:50.833Z" }, { url = "https://files.pythonhosted.org/packages/7d/84/b0059a7c7fc05ea23f3bc1596ba91c12f79588b9450564a24cac37536d0a/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:24ba13339fed9251d9b1a1bec8c7ab84c0d1675d79d33501e11f94f8b9a84e05", size = 1740798, upload-time = "2026-06-07T21:05:56.458Z" },
{ url = "https://files.pythonhosted.org/packages/78/6e/b6173a8ff03d01d5e1a694bc06764b5dad1df2d4ed8f0ceec12bb3277936/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c180f480207a9b2475f2b8d8bd7204e47aec952d084b2a2be58a782ffcf96074", size = 1548667, upload-time = "2026-03-31T21:56:52.81Z" }, { url = "https://files.pythonhosted.org/packages/8f/3a/e2a513ecbfc362591caa51a7f7e011b3bfc8938b388ae44cd95560d36999/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:94da27378da0610e341c4d30de29a191672683cc82b8f9556e8f7c7212a020fe", size = 1576412, upload-time = "2026-06-07T21:05:57.953Z" },
{ url = "https://files.pythonhosted.org/packages/16/13/13296ffe2c132d888b3fe2c195c8b9c0c24c89c3fa5cc2c44464dc23b22e/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2837fb92951564d6339cedae4a7231692aa9f73cbc4fb2e04263b96844e03b4e", size = 1724490, upload-time = "2026-03-31T21:56:54.541Z" }, { url = "https://files.pythonhosted.org/packages/a1/10/08f1654f538f93d36dcac66310a06eefce4641cdafca83f9f0a5317be254/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:52cdac9432d8b4a719f35094a818d95adcae0f0b4fe9b9b921909e0c87de9e7d", size = 1750199, upload-time = "2026-06-07T21:05:59.488Z" },
{ url = "https://files.pythonhosted.org/packages/7a/b4/1f1c287f4a79782ef36e5a6e62954c85343bc30470d862d30bd5f26c9fa2/aiohttp-3.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d9010032a0b9710f58012a1e9c222528763d860ba2ee1422c03473eab47703e7", size = 1667109, upload-time = "2026-03-31T21:56:56.21Z" }, { url = "https://files.pythonhosted.org/packages/99/e4/d91b70c57d8b8e9611e4a2e52238ca3698d3dc1c2efe25b7a9bf594ac584/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:672ac254412a24d0d0cf00a9e6c238877e4be5e5fa2d188832c1244f45f31966", size = 1699356, upload-time = "2026-06-07T21:06:01.131Z" },
{ url = "https://files.pythonhosted.org/packages/ef/42/8461a2aaf60a8f4ea4549a4056be36b904b0eb03d97ca9a8a2604681a500/aiohttp-3.13.5-cp310-cp310-win32.whl", hash = "sha256:7c4b6668b2b2b9027f209ddf647f2a4407784b5d88b8be4efcc72036f365baf9", size = 439478, upload-time = "2026-03-31T21:56:58.292Z" }, { url = "https://files.pythonhosted.org/packages/3d/f1/15340176f35ff61b95dbe34020bcf43f9e624a2d7bbac934715ff97d2033/aiohttp-3.14.1-cp310-cp310-win32.whl", hash = "sha256:2fe3607e71acc6ebb0ec8e492a247bf7a291226192dc0084236dfc12478916f6", size = 458939, upload-time = "2026-06-07T21:06:02.86Z" },
{ url = "https://files.pythonhosted.org/packages/e5/71/06956304cb5ee439dfe8d86e1b2e70088bd88ed1ced1f42fb29e5d855f0e/aiohttp-3.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:cd3db5927bf9167d5a6157ddb2f036f6b6b0ad001ac82355d43e97a4bde76d76", size = 462047, upload-time = "2026-03-31T21:57:00.257Z" }, { url = "https://files.pythonhosted.org/packages/c3/c2/a2f1ec5b37f903109e43ae2862268cfe4a67a60c1b2cf43169fcdff5995f/aiohttp-3.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:30099eda75a53c32efb0920e9c33c195314d2cc1c680fbfd30894932ac5f27df", size = 482583, upload-time = "2026-06-07T21:06:04.666Z" },
{ url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" }, { url = "https://files.pythonhosted.org/packages/d0/7a/7b56f6732ef79530afaa72aa335d41b67c8d79b946995f0b11ad72985435/aiohttp-3.14.1-cp310-cp310-win_arm64.whl", hash = "sha256:5a837f49d901f9e368651b676912bff1104ed8c1a83b280bcd7b29adccef5c9c", size = 453470, upload-time = "2026-06-07T21:06:06.322Z" },
{ url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" }, { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" },
{ url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" }, { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" },
{ url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" }, { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" },
{ url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" }, { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" },
{ url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" }, { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" },
{ url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" }, { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" },
{ url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" }, { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" },
{ url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" }, { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" },
{ url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" }, { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" },
{ url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" }, { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" },
{ url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" }, { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" },
{ url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" }, { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" },
{ url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" }, { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" },
{ url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" }, { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" },
{ url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" }, { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" },
{ url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" }, { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" },
{ url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" },
{ url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" },
{ url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" },
{ url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" },
{ url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" },
{ url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" },
{ url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" },
{ url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" },
{ url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" },
{ url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" },
{ url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" },
{ url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" },
{ url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" },
{ url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" },
{ url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" },
{ url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" },
{ url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" },
{ url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" },
] ]
[[package]] [[package]]
@@ -684,48 +688,48 @@ toml = [
[[package]] [[package]]
name = "cryptography" name = "cryptography"
version = "48.0.0" version = "48.0.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" },
{ url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" },
{ url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" },
{ url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" },
{ url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" },
{ url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" },
{ url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" },
{ url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" },
{ url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" },
{ url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" },
{ url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" },
{ url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" },
{ url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" },
{ url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" },
{ url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" },
{ url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" },
{ url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" },
{ url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" },
{ url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" },
{ url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" },
{ url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" },
{ url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" },
{ url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" },
{ url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" },
{ url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" },
{ url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" },
{ url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" },
{ url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" },
{ url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" }, { url = "https://files.pythonhosted.org/packages/a9/d3/eb4e394e587341fdad09a09101fa76478ead3a78b0ad63e55c22f0d75c02/cryptography-48.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a", size = 3951747, upload-time = "2026-06-09T22:31:23.871Z" },
{ url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" }, { url = "https://files.pythonhosted.org/packages/e0/4a/3f43451b4f858bfceaaaffc649e6e787e8d4fb332a1d443af39ab02cc8f1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd", size = 4641226, upload-time = "2026-06-09T22:31:02.532Z" },
{ url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" }, { url = "https://files.pythonhosted.org/packages/73/4e/855584c2c23b09e4ce2d3b9c30e983e679cd60b068c513c6bbdb91e11782/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c", size = 4668958, upload-time = "2026-06-09T22:32:06.213Z" },
{ url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" }, { url = "https://files.pythonhosted.org/packages/42/3b/d35750e41d803d1e516fd6d6011f065424924da7af1748cef4cc9cb3ede1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9", size = 4640793, upload-time = "2026-06-09T22:32:26.331Z" },
{ url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" }, { url = "https://files.pythonhosted.org/packages/ca/aa/cdb7181fe865285e87e96825aaab239400f1de0c3bfba9bd9769b79f1a92/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92", size = 4668505, upload-time = "2026-06-09T22:31:27.534Z" },
{ url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" }, { url = "https://files.pythonhosted.org/packages/5d/8c/ce3823c06c2804f194f9e64f0d67fa3f4094a39f2bb1a990cd03603af8fc/cryptography-48.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a", size = 3742204, upload-time = "2026-06-09T22:31:34.773Z" },
] ]
[[package]] [[package]]
@@ -1284,7 +1288,7 @@ wheels = [
[[package]] [[package]]
name = "lnbits" name = "lnbits"
version = "1.5.5rc2" version = "1.5.6"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiosqlite" }, { name = "aiosqlite" },
@@ -2278,11 +2282,11 @@ wheels = [
[[package]] [[package]]
name = "python-multipart" name = "python-multipart"
version = "0.0.29" version = "0.0.31"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/fe/70bd71a6738b09a0bdf6480ca6436b167469ca4578b2a0efbe390b4b0e70/python_multipart-0.0.29.tar.gz", hash = "sha256:643e93849196645e2dbdd81a0f8829a23123ad7f797a84a364c6fb3563f18904", size = 45678, upload-time = "2026-05-17T17:29:47.654Z" } sdist = { url = "https://files.pythonhosted.org/packages/64/7e/9b35ad8f3d9ca680f7c87a88f19612fdd8da9796c4d3b46e560ac79dcc4a/python_multipart-0.0.31.tar.gz", hash = "sha256:fc631183bb13e56db3158a4909908dfb2e23565286744e798241e63750e5d680", size = 46689, upload-time = "2026-06-04T08:27:49.014Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/cb/769cfc37177252872a45a71f3fbdde9d51b471a3f3c14bfe95dde3407386/python_multipart-0.0.29-py3-none-any.whl", hash = "sha256:2ddcc971cef266225f54f552d8fa10bcfbb1f14446caec199060daac59ff2d69", size = 29640, upload-time = "2026-05-17T17:29:45.69Z" }, { url = "https://files.pythonhosted.org/packages/5e/1e/7f7f299527a5a8ad90acd5f2f78dfa6c8495c6301a3205106ea68a84de96/python_multipart-0.0.31-py3-none-any.whl", hash = "sha256:8408153d68a9773291fc1da39a8b85a50044bddbabd2dd72e9229776b7b15e28", size = 29996, upload-time = "2026-06-04T08:27:47.804Z" },
] ]
[[package]] [[package]]
@@ -2682,19 +2686,19 @@ wheels = [
[[package]] [[package]]
name = "tornado" name = "tornado"
version = "6.5.6" version = "6.5.7"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/50/57/6d7303a77ae439d9189108f76c0c4fd89ee5e2cc8387bffb55232565c4ed/tornado-6.5.6.tar.gz", hash = "sha256:9a365179fe8ff6b8766f602c0f67c185d778193e9bdd828b19f0b6ed7764177d", size = 518139, upload-time = "2026-05-27T15:35:54.646Z" } sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/0d/b4f481e18c5a51864e6d12b9a05ecf72919696680b747c958c3fc1f4fbae/tornado-6.5.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:65fcfaafb079435c2c19dc9e07c0f1cf0fa9051759ed0a7d0a3ba7ea7f64919c", size = 447737, upload-time = "2026-05-27T15:35:38.122Z" }, { url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" },
{ url = "https://files.pythonhosted.org/packages/9e/9c/5430c39fcab1144d35860f457b15e9c08b4bc7ac86764354204e983d6183/tornado-6.5.6-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:38bc01b4acacded2de63ae78023548e41ebe6fbed3ec05a796d7ae3ad893887e", size = 445899, upload-time = "2026-05-27T15:35:40.519Z" }, { url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" },
{ url = "https://files.pythonhosted.org/packages/8b/79/fa7e14a2f939c807a8d30619b4eb604eab219601b78792516ebe22d40cf9/tornado-6.5.6-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b942e6a137fda31ff54bf8e6e2c8d1c37f1f50583f3ed53fb840b53b9601d104", size = 448964, upload-time = "2026-05-27T15:35:42.106Z" }, { url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" },
{ url = "https://files.pythonhosted.org/packages/a7/71/bd67d5f5199f937dafe03a49a37989f60f600ff6fef34c79412a829d97bd/tornado-6.5.6-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8666946e70171b8c3f1fc9b7876fac492e84822c4c7f3746f4e8f8bc9ac92a79", size = 449935, upload-time = "2026-05-27T15:35:43.906Z" }, { url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" },
{ url = "https://files.pythonhosted.org/packages/cc/a4/c24388c9cf5b3c3a513b56a158af9f23092c9a2810d789e294310797df21/tornado-6.5.6-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1c34cfab7ad6d104f052f55de06d39bbafc5885cfeb4da688803308dbcfa90b7", size = 449767, upload-time = "2026-05-27T15:35:45.793Z" }, { url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" },
{ url = "https://files.pythonhosted.org/packages/a5/eb/6a07ad550c3f7b37244bd0becdf293ec3d3e961783d8b720a97df50de1b2/tornado-6.5.6-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:385f35e4e22fb52551dfcda4cdc8c30c61c2c001aef5ddad99cdfe116952efd3", size = 449174, upload-time = "2026-05-27T15:35:47.485Z" }, { url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" },
{ url = "https://files.pythonhosted.org/packages/bb/84/3469e098dccdb6763130e06aacd786bb4363fca7b590a55c101ddf34ed30/tornado-6.5.6-cp39-abi3-win32.whl", hash = "sha256:db475f1b67b2809b10bb16264829087724ca8d24fe4ed47f7b8675cae453ef86", size = 450230, upload-time = "2026-05-27T15:35:49.322Z" }, { url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3c/273a04e0b9dd9016f1685cca0c1c8795a71ac88a34a8c889a0b443483226/tornado-6.5.6-cp39-abi3-win_amd64.whl", hash = "sha256:6739bf1e8eb09230f1280ddbd3236f0309db70f2c551a8dbc40f62babdf82f79", size = 450667, upload-time = "2026-05-27T15:35:51.194Z" }, { url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" },
{ url = "https://files.pythonhosted.org/packages/02/98/0cffe22a224f60c5fb1e3aa0b76f9da2e1ca78b0e9545e3d077c68ce60a7/tornado-6.5.6-cp39-abi3-win_arm64.whl", hash = "sha256:2543597b24a695d72338a9a77818362d72387c03ae173f1f169eadc5c91466ac", size = 449690, upload-time = "2026-05-27T15:35:52.902Z" }, { url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" },
] ]
[[package]] [[package]]