Compare commits

..
13 Commits
Author SHA1 Message Date
alan 1282322d42 chore: make bundle [skip ci] 2026-05-20 01:06:34 +00:00
Arc ebb5e0af2a Payments working 2026-05-20 01:56:16 +01:00
Arc db89a8f608 make 2026-05-15 12:09:03 +01:00
Arc 29b1ace1e0 Adds checkout and subscriptions 2026-05-15 12:00:56 +01:00
Vlad Stan c77207405b feat: introduce external_id 2026-05-13 15:23:30 +03:00
Vlad Stan 739c13df4e refactor: move private method to the bottom 2026-05-13 15:23:30 +03:00
Vlad Stan 531ac7ebd2 refactor: extract method 2026-05-13 15:23:30 +03:00
Vlad Stan 28af192e34 fix: simplify expected event types 2026-05-13 15:23:29 +03:00
Vlad Stan 272cb9bcc9 fix: plan 2026-05-13 15:23:29 +03:00
Vlad Stan b8055b4f51 feat: first subscription 2026-05-13 15:23:29 +03:00
Vlad Stan f7e21b225c fix: webhook url for signature check 2026-05-13 15:23:29 +03:00
Vlad Stan 0e8072a36e fix: lint 2026-05-13 15:23:29 +03:00
Vlad Stan 3850f561d6 feat: basic square integration 2026-05-13 15:23:29 +03:00
113 changed files with 26826 additions and 9677 deletions
-2
View File
@@ -119,8 +119,6 @@ LNBITS_SITE_TAGLINE="Open Source Lightning Payments Platform"
LNBITS_SITE_DESCRIPTION="The world's most powerful suite of bitcoin tools. Run for yourself, for others, or as part of a stack." LNBITS_SITE_DESCRIPTION="The world's most powerful suite of bitcoin tools. Run for yourself, for others, or as part of a stack."
# Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic, cyber # Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic, cyber
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber" LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
# Toggle the background styling on burger menus / drawers
# LNBITS_DEFAULT_BURGER_MENU_BACKGROUND=true
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg" # LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
###################################### ######################################
+14 -1
View File
@@ -7,6 +7,10 @@ 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:
@@ -14,6 +18,10 @@ 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:
@@ -105,6 +113,11 @@ 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
+3 -7
View File
@@ -10,16 +10,15 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
ref: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.head_ref || github.event.pull_request.head.sha }} ref: ${{ github.head_ref }}
- 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
- name: Build and commit bundle (same-repo PR) - run: make bundle
if: github.event.pull_request.head.repo.full_name == github.repository - name: Commit and push bundle changes
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
@@ -28,6 +27,3 @@ 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,7 +66,6 @@ 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,6 +12,8 @@ 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
@@ -20,6 +22,14 @@ 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'
@@ -64,3 +74,4 @@ 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,6 +13,8 @@ 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
@@ -21,6 +23,14 @@ 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'
@@ -75,3 +85,4 @@ 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 }}
-1
View File
@@ -1 +0,0 @@
min-release-age=7
+5 -2
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 AUTH_HTTPS_ONLY=false ./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 ./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,7 +285,10 @@ 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 AUTH_HTTPS_ONLY=false ./result/bin/lnbits --port 9000 --host 0.0.0.0 LNBITS_ADMIN_UI=true ./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)
-4
View File
@@ -40,7 +40,6 @@ from lnbits.core.tasks import (
) )
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 ( from lnbits.tasks import (
cancel_all_tasks, cancel_all_tasks,
@@ -103,9 +102,6 @@ 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()
+5 -5
View File
@@ -290,7 +290,6 @@ 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,
@@ -307,7 +306,7 @@ async def update_payment_checking_id(
await (conn or db).execute( await (conn or db).execute(
f""" f"""
UPDATE apipayments UPDATE apipayments
SET checking_id = :new_id, updated_at = {db.timestamp_placeholder("now")} SET checking_id = :new_id, updated_at = {db.timestamp_placeholder('now')}
WHERE checking_id = :old_id WHERE checking_id = :old_id
""", # noqa: S608 """, # noqa: S608
{ {
@@ -322,15 +321,13 @@ async def update_payment(
payment: Payment, payment: Payment,
new_checking_id: str | None = None, new_checking_id: str | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> Payment: ) -> None:
payment.updated_at = datetime.now(timezone.utc) payment.updated_at = datetime.now(timezone.utc)
await (conn or db).update( await (conn or db).update(
"apipayments", payment, "WHERE checking_id = :checking_id" "apipayments", payment, "WHERE checking_id = :checking_id"
) )
if new_checking_id and new_checking_id != payment.checking_id: if new_checking_id and new_checking_id != payment.checking_id:
await update_payment_checking_id(payment.checking_id, new_checking_id, conn) await update_payment_checking_id(payment.checking_id, new_checking_id, conn)
payment.checking_id = new_checking_id
return payment
async def get_payments_history( async def get_payments_history(
@@ -402,6 +399,7 @@ async def get_payment_count_stats(
user_id: str | None = None, user_id: str | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> list[PaymentCountStat]: ) -> list[PaymentCountStat]:
if not filters: if not filters:
filters = Filters() filters = Filters()
extra_stmts = [] extra_stmts = []
@@ -434,6 +432,7 @@ async def get_daily_stats(
user_id: str | None = None, user_id: str | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]: ) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
if not filters: if not filters:
filters = Filters() filters = Filters()
@@ -483,6 +482,7 @@ async def get_wallets_stats(
user_id: str | None = None, user_id: str | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> list[PaymentWalletStats]: ) -> list[PaymentWalletStats]:
if not filters: if not filters:
filters = Filters() filters = Filters()
+10 -15
View File
@@ -8,18 +8,11 @@ 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")
@@ -76,14 +69,16 @@ async def delete_admin_settings(tag: str | None = "core") -> None:
async def reset_core_settings() -> None: async def reset_core_settings() -> None:
core_settings = await get_settings_by_tag("core") or {} await db.execute(
super_user = await get_settings_field("super_user") """
await delete_admin_settings() DELETE FROM system_settings WHERE tag = 'core'
if super_user: AND id NOT IN (
await set_settings_field("super_user", super_user.value) 'super_user',
for field in RESET_PRESERVED_SETTINGS: 'lnbits_webpush_pubkey',
if field in core_settings: 'lnbits_webpush_privkey'
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:
-2
View File
@@ -25,7 +25,6 @@ from .payments import (
PaymentState, PaymentState,
PaymentWalletStats, PaymentWalletStats,
SettleInvoice, SettleInvoice,
UpdatePaymentExtra,
) )
from .tinyurl import TinyURL from .tinyurl import TinyURL
from .users import ( from .users import (
@@ -91,7 +90,6 @@ __all__ = [
"SimpleStatus", "SimpleStatus",
"TinyURL", "TinyURL",
"UpdateBalance", "UpdateBalance",
"UpdatePaymentExtra",
"UpdateSuperuserPassword", "UpdateSuperuserPassword",
"UpdateUser", "UpdateUser",
"UpdateUserPassword", "UpdateUserPassword",
+2 -13
View File
@@ -55,10 +55,9 @@ 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):
@@ -309,6 +308,7 @@ 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,7 +333,6 @@ 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
@@ -681,11 +680,6 @@ 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]
@@ -701,11 +695,6 @@ 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:
+8 -11
View File
@@ -35,11 +35,6 @@ class PaymentExtra(BaseModel):
lnurl_response: str | None = None lnurl_response: str | None = None
class UpdatePaymentExtra(BaseModel):
payment_hash: str
extra: dict = Field(default_factory=dict)
class PayInvoice(BaseModel): class PayInvoice(BaseModel):
payment_request: str payment_request: str
description: str | None = None description: str | None = None
@@ -54,7 +49,6 @@ 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
@@ -141,18 +135,22 @@ 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(self) -> PaymentStatus: async def check_status(
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) return await check_payment_status(self, skip_internal_payment_notifications)
# 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(self) -> FiatPaymentStatus: async def check_fiat_status(
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) return await check_fiat_status(self, skip_internal_payment_notifications)
class PaymentFilters(FilterModel): class PaymentFilters(FilterModel):
@@ -260,7 +258,6 @@ 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
+3 -127
View File
@@ -1,9 +1,7 @@
import base64 import base64
import io import io
from urllib.parse import quote
from uuid import uuid4 from uuid import uuid4
import filetype
from fastapi import UploadFile from fastapi import UploadFile
from loguru import logger from loguru import logger
from PIL import Image from PIL import Image
@@ -12,48 +10,11 @@ from lnbits.core.crud.assets import create_asset, get_user_assets_count
from lnbits.core.models.assets import Asset from lnbits.core.models.assets import Asset
from lnbits.settings import settings from lnbits.settings import settings
IMAGE_MIME_TYPE_ALIASES = {
"heic": "image/heic",
"heics": "image/heics",
"heif": "image/heif",
"image/jpg": "image/jpeg",
"jpeg": "image/jpeg",
"jpg": "image/jpeg",
"png": "image/png",
}
PIL_IMAGE_FORMAT_MIME_TYPES = {
"JPEG": "image/jpeg",
"PNG": "image/png",
}
INLINE_ASSET_MIME_TYPES = {
"image/heic",
"image/heics",
"image/heif",
"image/jpeg",
"image/png",
}
ASSET_SECURITY_HEADERS = {
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": (
"sandbox; default-src 'none'; script-src 'none'; "
"object-src 'none'; base-uri 'none'"
),
}
THUMBNAIL_FORMAT_MIME_TYPES = {
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"png": "image/png",
}
async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) -> Asset: async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) -> Asset:
if not file.content_type: if not file.content_type:
raise ValueError("File must have a content type.") raise ValueError("File must have a content type.")
if file.content_type.lower() not in settings.lnbits_assets_allowed_mime_types:
content_type = normalize_asset_mime_type(file.content_type)
filename = file.filename or "unnamed"
if content_type not in allowed_asset_mime_types():
raise ValueError(f"File type '{file.content_type}' not allowed.") raise ValueError(f"File type '{file.content_type}' not allowed.")
if not settings.is_unlimited_assets_user(user_id): if not settings.is_unlimited_assets_user(user_id):
@@ -69,26 +30,14 @@ async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) ->
f"File limit of {settings.lnbits_max_asset_size_mb}MB exceeded." f"File limit of {settings.lnbits_max_asset_size_mb}MB exceeded."
) )
stored_mime_type = detect_image_mime_type(contents)
if stored_mime_type != content_type:
logger.warning(
"Image MIME type mismatch: declared={}, detected={}",
content_type,
stored_mime_type,
)
raise ValueError(
"Image file content does not match declared file type. "
f"Declared: '{content_type}', detected: '{stored_mime_type}'."
)
thumb_buffer = thumbnail_from_bytes(contents) thumb_buffer = thumbnail_from_bytes(contents)
asset = Asset( asset = Asset(
id=uuid4().hex, id=uuid4().hex,
user_id=user_id, user_id=user_id,
mime_type=stored_mime_type, mime_type=file.content_type,
is_public=is_public, is_public=is_public,
name=filename, name=file.filename or "unnamed",
size_bytes=len(contents), size_bytes=len(contents),
thumbnail_base64=( thumbnail_base64=(
base64.b64encode(thumb_buffer.getvalue()).decode("utf-8") base64.b64encode(thumb_buffer.getvalue()).decode("utf-8")
@@ -102,79 +51,6 @@ async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) ->
return asset return asset
def normalize_asset_mime_type(content_type: str) -> str:
content_type = content_type.split(";", 1)[0].strip().lower()
return IMAGE_MIME_TYPE_ALIASES.get(content_type, content_type)
def normalize_media_type(media_type: str) -> str:
return media_type.split(";", 1)[0].strip().lower() or "application/octet-stream"
def thumbnail_media_type() -> str:
thumbnail_format = (settings.lnbits_asset_thumbnail_format or "png").strip().lower()
return THUMBNAIL_FORMAT_MIME_TYPES.get(thumbnail_format, "application/octet-stream")
def content_disposition(disposition: str, filename: str) -> str:
safe_filename = filename or "unnamed"
quoted_filename = quote(safe_filename, safe="")
if quoted_filename == safe_filename:
return f'{disposition}; filename="{safe_filename}"'
return f"{disposition}; filename*=utf-8''{quoted_filename}"
def allowed_asset_mime_types() -> set[str]:
return {
mime_type
for mime_type in (
normalize_asset_mime_type(mime_type)
for mime_type in settings.lnbits_assets_allowed_mime_types
)
if mime_type.startswith("image/")
}
def detect_image_mime_type(contents: bytes) -> str:
kind = filetype.guess(contents)
mime_type = normalize_asset_mime_type(kind.mime) if kind else None
if mime_type and mime_type in PIL_IMAGE_FORMAT_MIME_TYPES.values():
verify_pil_image(contents, mime_type)
return mime_type
if mime_type and mime_type.startswith("image/"):
return mime_type
try:
with Image.open(io.BytesIO(contents)) as image:
image.verify()
mime_type = PIL_IMAGE_FORMAT_MIME_TYPES.get(image.format or "")
except Exception as exc:
raise ValueError(
"Image file content does not match declared file type."
) from exc
if not mime_type:
raise ValueError("Image file content does not match declared file type.")
return mime_type
def verify_pil_image(contents: bytes, mime_type: str) -> None:
try:
with Image.open(io.BytesIO(contents)) as image:
image.verify()
detected_mime_type = PIL_IMAGE_FORMAT_MIME_TYPES.get(image.format or "")
except Exception as exc:
raise ValueError(
"Image file content does not match declared file type."
) from exc
if detected_mime_type != mime_type:
raise ValueError("Image file content does not match declared file type.")
def thumbnail_from_bytes(contents: bytes) -> io.BytesIO | None: def thumbnail_from_bytes(contents: bytes) -> io.BytesIO | None:
try: try:
image = Image.open(io.BytesIO(contents)) image = Image.open(io.BytesIO(contents))
+28 -18
View File
@@ -1,6 +1,7 @@
import hashlib import hashlib
import hmac import hmac
import json import json
import math
import time import time
from base64 import b64encode from base64 import b64encode
@@ -8,7 +9,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, update_payment from lnbits.core.crud.payments import create_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
@@ -36,7 +37,9 @@ async def handle_fiat_payment_confirmation(
logger.warning(e) logger.warning(e)
async def check_fiat_status(payment: Payment) -> FiatPaymentStatus: async def check_fiat_status(
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:
@@ -56,11 +59,10 @@ async def check_fiat_status(payment: Payment) -> FiatPaymentStatus:
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 fiat_status.success: if skip_internal_payment_notifications:
payment.status = PaymentState.SUCCESS.value return fiat_status
await update_payment(payment)
await handle_fiat_payment_confirmation(payment)
if fiat_status.success:
# notify receivers asynchronously # notify receivers asynchronously
from lnbits.tasks import internal_invoice_queue from lnbits.tasks import internal_invoice_queue
@@ -218,27 +220,35 @@ def check_revolut_signature(
logger.warning("Revolut webhook signing secret is not set.") logger.warning("Revolut webhook signing secret is not set.")
raise ValueError("Revolut webhook cannot be verified.") raise ValueError("Revolut webhook cannot be verified.")
try: timestamp = int(timestamp_header)
timestamp = int(timestamp_header)
except ValueError as exc:
logger.warning("Invalid Revolut timestamp.")
raise ValueError("Invalid Revolut timestamp.") from exc
timestamp_seconds = timestamp / 1000 if timestamp > 9999999999 else timestamp timestamp_seconds = timestamp / 1000 if timestamp > 9999999999 else timestamp
if not math.isfinite(timestamp_seconds):
logger.warning("Invalid Revolut timestamp.")
raise ValueError("Invalid Revolut timestamp.")
if abs(time.time() - timestamp_seconds) > tolerance_seconds: if abs(time.time() - timestamp_seconds) > tolerance_seconds:
logger.warning("Timestamp outside tolerance.") logger.warning("Timestamp outside tolerance.")
raise ValueError("Timestamp outside tolerance." f"Timestamp: {timestamp}") raise ValueError("Timestamp outside tolerance." f"Timestamp: {timestamp}")
signed_payload = b"v1." + timestamp_header.encode() + b"." + payload candidates = [
digest = hmac.new( b"v1." + timestamp_header.encode() + b"." + payload,
key=secret.encode(), msg=signed_payload, digestmod=hashlib.sha256 payload,
).hexdigest() f"{timestamp_header}.{payload.decode()}".encode(),
expected_signature = f"v1={digest}" timestamp_header.encode() + b"." + payload,
]
signatures = []
for candidate in candidates:
digest = hmac.new(
key=secret.encode(), msg=candidate, digestmod=hashlib.sha256
).digest()
signatures.extend(
[digest.hex(), f"v1={digest.hex()}", b64encode(digest).decode()]
)
provided_signatures = [sig.strip() for sig in sig_header.split(",") if sig.strip()] provided_signatures = [sig.strip() for sig in sig_header.split(",") if sig.strip()]
if not any( if not any(
hmac.compare_digest(expected_signature, provided) hmac.compare_digest(expected, provided)
for expected in signatures
for provided in provided_signatures for provided in provided_signatures
): ):
logger.warning("Revolut signature verification failed.") logger.warning("Revolut signature verification failed.")
+6 -35
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_in_background( return await send_notification(
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_in_background( return await send_notification(
user_notifications.telegram_chat_id, user_notifications.telegram_chat_id,
nostr_identifiers, nostr_identifiers,
email_address, email_address,
@@ -222,20 +222,12 @@ 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:
def _send() -> bool: smtp_server.starttls()
with smtplib.SMTP(server, port) as smtp_server: smtp_server.login(username, password)
smtp_server.starttls() smtp_server.sendmail(from_email, to_emails, msg.as_string())
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_webhook(payment: Payment): async def dispatch_webhook(payment: Payment):
""" """
@@ -302,27 +294,6 @@ 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())
+28 -32
View File
@@ -13,6 +13,7 @@ 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
@@ -170,15 +171,15 @@ async def create_fiat_invoice(
internal_payment.fiat_provider = fiat_provider_name internal_payment.fiat_provider = fiat_provider_name
internal_payment.extra["fiat_checking_id"] = fiat_invoice.checking_id internal_payment.extra["fiat_checking_id"] = fiat_invoice.checking_id
# TODO: move to payment # todo: move to payent
internal_payment.extra["fiat_payment_request"] = fiat_invoice.payment_request internal_payment.extra["fiat_payment_request"] = fiat_invoice.payment_request
new_checking_id = ( new_checking_id = (
f"fiat_{fiat_provider_name}_" f"fiat_{fiat_provider_name}_"
f"{fiat_invoice.checking_id or internal_payment.checking_id}" f"{fiat_invoice.checking_id or internal_payment.checking_id}"
) )
internal_payment = await update_payment( await update_payment(internal_payment, new_checking_id, conn=conn)
internal_payment, new_checking_id, conn=conn internal_payment.checking_id = new_checking_id
)
return internal_payment return internal_payment
@@ -214,7 +215,6 @@ 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,7 +260,6 @@ 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,
@@ -344,7 +343,6 @@ 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,
@@ -376,7 +374,7 @@ async def update_pending_payment(
status = await check_payment_status(payment) status = await check_payment_status(payment)
if status.failed: if status.failed:
payment.status = PaymentState.FAILED payment.status = PaymentState.FAILED
payment = await update_payment(payment, conn=conn) await update_payment(payment, conn=conn)
elif status.success: elif status.success:
payment = await update_payment_success_status(payment, status, conn=conn) payment = await update_payment_success_status(payment, status, conn=conn)
return payment return payment
@@ -632,14 +630,18 @@ async def check_transaction_status(
return await check_payment_status(payment) return await check_payment_status(payment)
async def check_payment_status(payment: Payment) -> PaymentStatus: async def check_payment_status(
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(payment) fiat_status = await check_fiat_status(
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()
@@ -781,14 +783,9 @@ 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( await _send_payment_notification_in_background(wallet.id, payment, conn=conn)
wallet.id, payment, conn=conn
) # notify the sender
await _send_payment_notification_in_background(
internal_payment.wallet_id, internal_payment, conn=conn
) # notify the receiver
# notify receiver asynchronously (extension listeners) # notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue from lnbits.tasks import internal_invoice_queue
logger.debug(f"enqueuing internal invoice {internal_payment.checking_id}") logger.debug(f"enqueuing internal invoice {internal_payment.checking_id}")
@@ -879,7 +876,7 @@ async def update_payment_success_status(
payment.status = PaymentState.SUCCESS payment.status = PaymentState.SUCCESS
payment.fee = -(abs(status.fee_msat or 0) + abs(service_fee_msat)) payment.fee = -(abs(status.fee_msat or 0) + abs(service_fee_msat))
payment.preimage = payment.preimage or status.preimage payment.preimage = payment.preimage or status.preimage
payment = await update_payment(payment, conn=conn) await update_payment(payment, conn=conn)
return payment return payment
@@ -1082,29 +1079,28 @@ 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_from_paid_invoices_stream(checking_id: str) -> Payment | None: async def update_invoice_callback(checking_id: str) -> Payment | None:
""" """
Takes a checking_id of an incoming payment from paid_invoices_stream() Takes a checking_id of an incoming payment, from either paid_invoices_stream()
Checks its status, updates its status and returns it. or internal_invoice_queue. Checks its status, updates and returns it.
returns None if no incoming payment was found or the status is not successful returns None if no payment was found or it not and incoming payment.
""" """
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 incoming payment found for '{checking_id}'.") logger.warning(f"No payment found for '{checking_id}'.")
return None return None
if not payment.is_in:
status = await check_payment_status(payment) logger.warning(f"Payment '{checking_id}' is not incoming, skipping.")
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) await update_payment(payment)
if payment.fiat_provider:
await handle_fiat_payment_confirmation(payment)
return payment return payment
+7 -30
View File
@@ -25,8 +25,7 @@ from lnbits.core.services.notifications import (
) )
from lnbits.db import Filters from lnbits.db import Filters
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.cache import cache from lnbits.utils.exchange_rates import btc_rates
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()
@@ -151,34 +150,12 @@ async def collect_exchange_rates_data() -> None:
if sleep_time > 0: if sleep_time > 0:
try: try:
if ( rates = await btc_rates(currency)
settings.lnbits_price_aggregator_enabled if rates:
and settings.lnbits_price_aggregator_url rates_values = [r[1] for r in rates]
): lnbits_rate = sum(rates_values) / len(rates_values)
price = await btc_price_from_aggregator(currency) rates.append(("LNbits", lnbits_rate))
if price: settings.append_exchange_rate_datapoint(dict(rates), max_history_size)
cache.set(
f"btc-price-{currency}",
price,
expiry=settings.lnbits_exchange_rate_cache_seconds,
)
settings.append_exchange_rate_datapoint(
{"Aggregator": price}, max_history_size
)
else:
rates = await btc_rates(currency)
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: except Exception as ex:
logger.warning(ex) logger.warning(ex)
else: else:
+9 -25
View File
@@ -16,14 +16,7 @@ from lnbits.core.crud.assets import (
from lnbits.core.models.assets import AssetFilters, AssetInfo, AssetUpdate from lnbits.core.models.assets import AssetFilters, AssetInfo, AssetUpdate
from lnbits.core.models.misc import SimpleStatus from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.users import AccountId from lnbits.core.models.users import AccountId
from lnbits.core.services.assets import ( from lnbits.core.services.assets import create_user_asset
ASSET_SECURITY_HEADERS,
INLINE_ASSET_MIME_TYPES,
content_disposition,
create_user_asset,
normalize_media_type,
thumbnail_media_type,
)
from lnbits.db import Filters, Page from lnbits.db import Filters, Page
from lnbits.decorators import ( from lnbits.decorators import (
check_account_id_exists, check_account_id_exists,
@@ -82,7 +75,11 @@ async def api_get_asset_data(
if not asset: if not asset:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.") raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
return asset_response(asset.data, asset.mime_type, asset.name) return Response(
content=asset.data,
media_type=asset.mime_type,
headers={"Content-Disposition": f'inline; filename="{asset.name}"'},
)
@asset_router.get( @asset_router.get(
@@ -104,14 +101,14 @@ async def api_get_asset_thumbnail(
if not asset_info: if not asset_info:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.") raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
return asset_response( return Response(
content=( content=(
base64.b64decode(asset_info.thumbnail_base64) base64.b64decode(asset_info.thumbnail_base64)
if asset_info.thumbnail_base64 if asset_info.thumbnail_base64
else b"" else b""
), ),
media_type=thumbnail_media_type(), media_type=asset_info.mime_type,
filename=asset_info.name, headers={"Content-Disposition": f'inline; filename="{asset_info.name}"'},
) )
@@ -175,16 +172,3 @@ async def api_delete_asset(
await delete_user_asset(account_id.id, asset_id) await delete_user_asset(account_id.id, asset_id)
return SimpleStatus(success=True, message="Asset deleted successfully.") return SimpleStatus(success=True, message="Asset deleted successfully.")
def asset_response(content: bytes, media_type: str, filename: str) -> Response:
media_type = normalize_media_type(media_type)
disposition = "inline" if media_type in INLINE_ASSET_MIME_TYPES else "attachment"
return Response(
content=content,
media_type=media_type,
headers={
**ASSET_SECURITY_HEADERS,
"Content-Disposition": content_disposition(disposition, filename),
},
)
+2 -12
View File
@@ -36,7 +36,6 @@ from lnbits.decorators import (
check_account_exists, check_account_exists,
check_admin, check_admin,
check_user_exists, check_user_exists,
optional_user_id,
) )
from lnbits.helpers import ( from lnbits.helpers import (
create_access_token, create_access_token,
@@ -321,10 +320,7 @@ async def api_delete_user_api_token(
@auth_router.get("/{provider}", description="SSO Provider") @auth_router.get("/{provider}", description="SSO Provider")
async def login_with_sso_provider( async def login_with_sso_provider(
request: Request, request: Request, provider: str, user_id: str | None = None
provider: str,
user_id: str | None,
auth_user_id: str | None = Depends(optional_user_id),
): ):
provider_sso = _new_sso(provider) provider_sso = _new_sso(provider)
if not provider_sso: if not provider_sso:
@@ -332,8 +328,6 @@ async def login_with_sso_provider(
HTTPStatus.FORBIDDEN, HTTPStatus.FORBIDDEN,
f"Login by '{provider}' not allowed.", f"Login by '{provider}' not allowed.",
) )
if user_id and user_id != auth_user_id:
raise HTTPException(HTTPStatus.FORBIDDEN, "User ID mismatch.")
provider_sso.redirect_uri = str(request.base_url) + f"api/v1/auth/{provider}/token" provider_sso.redirect_uri = str(request.base_url) + f"api/v1/auth/{provider}/token"
with provider_sso: with provider_sso:
@@ -354,11 +348,7 @@ async def handle_oauth_token(request: Request, provider: str) -> RedirectRespons
userinfo = await provider_sso.verify_and_process(request) userinfo = await provider_sso.verify_and_process(request)
if not userinfo: if not userinfo:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid user info.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid user info.")
if provider_sso.state is None or provider_sso.state == "null": user_id = decrypt_internal_message(provider_sso.state)
user_id = None
else:
user_id = decrypt_internal_message(provider_sso.state)
request.session.pop("user", None) request.session.pop("user", None)
return await _handle_sso_login(userinfo, user_id) return await _handle_sso_login(userinfo, user_id)
+29 -106
View File
@@ -18,11 +18,7 @@ from lnbits.core.services.fiat_providers import (
check_stripe_signature, check_stripe_signature,
verify_paypal_webhook, verify_paypal_webhook,
) )
from lnbits.core.services.payments import ( from lnbits.core.services.payments import create_fiat_invoice
create_fiat_invoice,
create_wallet_invoice,
service_fee_fiat,
)
from lnbits.db import Filter, Filters from lnbits.db import Filter, Filters
from lnbits.fiat import get_fiat_provider from lnbits.fiat import get_fiat_provider
from lnbits.fiat.base import FiatSubscriptionPaymentOptions from lnbits.fiat.base import FiatSubscriptionPaymentOptions
@@ -357,20 +353,15 @@ async def handle_revolut_event(event: dict):
return return
payment = await get_standalone_payment(f"fiat_revolut_order_{order_id}") payment = await get_standalone_payment(f"fiat_revolut_order_{order_id}")
if payment: if not payment:
await check_fiat_status(payment)
return
if event_type == "ORDER_COMPLETED":
logger.warning(f"No payment found for Revolut order: '{order_id}'.") logger.warning(f"No payment found for Revolut order: '{order_id}'.")
await _handle_revolut_subscription_order_paid(order_id)
return return
logger.info(f"Ignoring Revolut authorised order without payment: '{order_id}'.") await check_fiat_status(payment)
return return
if event_type == "SUBSCRIPTION_INITIATED": if event_type == "SUBSCRIPTION_INITIATED":
logger.info("Revolut subscription initiated event received.") await _handle_revolut_subscription_initiated(event)
return return
if event_type in [ if event_type in [
@@ -384,25 +375,18 @@ async def handle_revolut_event(event: dict):
logger.warning(f"Unhandled Revolut event type: '{event_type}'.") logger.warning(f"Unhandled Revolut event type: '{event_type}'.")
async def _get_revolut_provider() -> RevolutWallet | None: async def _handle_revolut_subscription_initiated(event: dict):
subscription_id = event.get("subscription_id")
if not subscription_id:
logger.warning("Revolut subscription event missing subscription_id.")
return
fiat_provider = await get_fiat_provider("revolut") fiat_provider = await get_fiat_provider("revolut")
if not isinstance(fiat_provider, RevolutWallet): if not isinstance(fiat_provider, RevolutWallet):
logger.warning("Revolut fiat provider is not configured.") logger.warning("Revolut fiat provider is not configured.")
return None
return fiat_provider
async def _handle_revolut_subscription(
subscription: dict,
fiat_provider: RevolutWallet,
order_id: str | None = None,
order: dict | None = None,
):
subscription_id = subscription.get("id")
if not subscription_id:
logger.warning("Revolut subscription missing id.")
return return
subscription = await fiat_provider.get_subscription(subscription_id)
reference = fiat_provider.deserialize_subscription_reference( reference = fiat_provider.deserialize_subscription_reference(
subscription.get("external_reference") subscription.get("external_reference")
) )
@@ -410,17 +394,16 @@ async def _handle_revolut_subscription(
logger.warning("Revolut subscription event missing LNbits metadata.") logger.warning("Revolut subscription event missing LNbits metadata.")
return return
if not order_id: cycle_id = subscription.get("current_cycle_id")
cycle_id = subscription.get("current_cycle_id") if not cycle_id:
if not cycle_id: logger.warning("Revolut subscription missing current_cycle_id.")
logger.warning("Revolut subscription missing current_cycle_id.") return
return
cycle = await fiat_provider.get_subscription_cycle(subscription_id, cycle_id) cycle = await fiat_provider.get_subscription_cycle(subscription_id, cycle_id)
order_id = cycle.get("order_id") order_id = cycle.get("order_id")
if not order_id: if not order_id:
logger.warning("Revolut subscription cycle missing order_id.") logger.warning("Revolut subscription cycle missing order_id.")
return return
existing_payment = await get_standalone_payment(f"fiat_revolut_order_{order_id}") existing_payment = await get_standalone_payment(f"fiat_revolut_order_{order_id}")
if existing_payment: if existing_payment:
@@ -430,8 +413,7 @@ async def _handle_revolut_subscription(
await check_fiat_status(existing_payment) await check_fiat_status(existing_payment)
return return
if not order: order = await fiat_provider.get_order(order_id)
order = await fiat_provider.get_order(order_id)
amount_minor = order.get("amount") amount_minor = order.get("amount")
currency = (order.get("currency") or "").upper() currency = (order.get("currency") or "").upper()
if amount_minor is None or not currency: if amount_minor is None or not currency:
@@ -439,7 +421,7 @@ async def _handle_revolut_subscription(
extra = { extra = {
**(reference.extra or {}), **(reference.extra or {}),
"subscription_request_id": subscription_id, "subscription_request_id": reference.subscription_request_id,
"fiat_method": "subscription", "fiat_method": "subscription",
"tag": reference.tag, "tag": reference.tag,
"subscription": { "subscription": {
@@ -447,78 +429,19 @@ async def _handle_revolut_subscription(
"payment_request": order.get("checkout_url") or "", "payment_request": order.get("checkout_url") or "",
}, },
} }
lnbits_payment = await _create_revolut_subscription_payment( lnbits_payment = await create_fiat_invoice(
wallet_id=reference.wallet_id, wallet_id=reference.wallet_id,
amount_minor=amount_minor, invoice_data=CreateInvoice(
currency=currency,
memo=reference.memo or "",
extra=extra,
order_id=order_id,
payment_request=order.get("checkout_url") or "",
subscription_id=subscription_id,
)
await check_fiat_status(lnbits_payment)
async def _handle_revolut_subscription_order_paid(order_id: str):
fiat_provider = await _get_revolut_provider()
if not fiat_provider:
return
order = await fiat_provider.get_order(order_id)
order_type = (order.get("type") or "").lower()
order_state = (order.get("state") or "").upper()
if order_type != "payment" or order_state != "COMPLETED":
logger.warning(f"Revolut order is not a completed payment: '{order_id}'.")
return
channel_data = order.get("channel_data") or {}
subscription_id = channel_data.get("subscription_id")
if not subscription_id:
logger.warning(f"Revolut order missing subscription_id: '{order_id}'.")
return
subscription = await fiat_provider.get_subscription(subscription_id)
if subscription.get("state") != "active":
logger.warning(f"Revolut subscription is not active: '{subscription_id}'.")
return
await _handle_revolut_subscription(
subscription, fiat_provider, order_id=order_id, order=order
)
async def _create_revolut_subscription_payment(
wallet_id: str,
amount_minor: int,
currency: str,
memo: str,
extra: dict,
order_id: str,
payment_request: str,
subscription_id: str,
) -> Payment:
amount = RevolutWallet.minor_units_to_amount(amount_minor, currency)
payment = await create_wallet_invoice(
wallet_id,
CreateInvoice(
unit=currency, unit=currency,
amount=amount, amount=amount_minor / 100,
memo=memo, memo=reference.memo or "",
extra=extra, extra=extra,
internal=True, fiat_provider="revolut",
external_id=subscription_id, external_id=subscription_id,
), ),
) )
payment.fee = -abs(service_fee_fiat(payment.msat, "revolut"))
payment.fiat_provider = "revolut" await check_fiat_status(lnbits_payment)
payment.extra["fiat_checking_id"] = f"order_{order_id}"
payment.extra["fiat_payment_request"] = payment_request
checking_id = f"fiat_revolut_order_{order_id}"
await update_payment(payment, checking_id)
payment.checking_id = checking_id
return payment
async def _handle_square_payment_event(event: dict): async def _handle_square_payment_event(event: dict):
+1 -1
View File
@@ -292,6 +292,7 @@ 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(
@@ -566,7 +567,6 @@ 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(
-33
View File
@@ -34,7 +34,6 @@ from lnbits.core.models import (
PaymentWalletStats, PaymentWalletStats,
SettleInvoice, SettleInvoice,
SimpleStatus, SimpleStatus,
UpdatePaymentExtra,
) )
from lnbits.core.models.payments import UpdatePaymentLabels from lnbits.core.models.payments import UpdatePaymentLabels
from lnbits.core.models.users import AccountId from lnbits.core.models.users import AccountId
@@ -298,38 +297,6 @@ async def api_update_payment_labels(
return SimpleStatus(success=True, message="Payment labels updated.") return SimpleStatus(success=True, message="Payment labels updated.")
@payment_router.patch(
"/extra",
name="Update payment extra",
description="Append new extra metadata to a payment.",
response_model=Payment,
)
async def api_update_payment_extra(
data: UpdatePaymentExtra,
key_type: WalletTypeInfo = Depends(require_admin_key),
) -> Payment:
payment = await get_standalone_payment(
data.payment_hash, wallet_id=key_type.wallet.id
)
if payment is None:
raise HTTPException(HTTPStatus.NOT_FOUND, "Payment does not exist.")
if not payment.success:
raise HTTPException(
HTTPStatus.BAD_REQUEST, "Payment extra can only be updated after success."
)
duplicate_keys = sorted(set(payment.extra).intersection(data.extra))
if duplicate_keys:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
f"Extra keys already exist: {', '.join(duplicate_keys)}.",
)
payment.extra.update(data.extra)
await update_payment(payment)
return payment
@payment_router.get("/fee-reserve") @payment_router.get("/fee-reserve")
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse: async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
invoice_obj = bolt11.decode(invoice) invoice_obj = bolt11.decode(invoice)
+3 -7
View File
@@ -95,10 +95,6 @@ class FiatSubscriptionPaymentOptions(BaseModel):
description="Unique ID that can be used to identify the subscription request." description="Unique ID that can be used to identify the subscription request."
"If not provided, one will be generated.", "If not provided, one will be generated.",
) )
customer_email: str | None = Field(
default=None,
description="The customer email to use for the subscription.",
)
tag: str | None = Field( tag: str | None = Field(
default=None, default=None,
description="Payments created by the recurring subscription" description="Payments created by the recurring subscription"
@@ -131,15 +127,15 @@ class FiatSubscriptionResponse(BaseModel):
class FiatPaymentSuccessStatus(FiatPaymentStatus): class FiatPaymentSuccessStatus(FiatPaymentStatus):
paid = True # type: ignore[reportIncompatibleVariableOverride] paid = True
class FiatPaymentFailedStatus(FiatPaymentStatus): class FiatPaymentFailedStatus(FiatPaymentStatus):
paid = False # type: ignore[reportIncompatibleVariableOverride] paid = False
class FiatPaymentPendingStatus(FiatPaymentStatus): class FiatPaymentPendingStatus(FiatPaymentStatus):
paid = None # type: ignore[reportIncompatibleVariableOverride] paid = None
class FiatProvider(ABC): class FiatProvider(ABC):
+19 -178
View File
@@ -2,7 +2,6 @@ import asyncio
import ipaddress import ipaddress
import json import json
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from decimal import ROUND_HALF_UP, Decimal
from typing import Any from typing import Any
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -57,37 +56,6 @@ REVOLUT_WEBHOOK_EVENTS = [
"SUBSCRIPTION_INITIATED", "SUBSCRIPTION_INITIATED",
] ]
ZERO_DECIMAL_CURRENCIES = {
"BIF",
"CLP",
"DJF",
"GNF",
"ISK",
"JPY",
"KMF",
"KRW",
"PYG",
"RWF",
"UGX",
"VND",
"VUV",
"XAF",
"XOF",
"XPF",
}
THREE_DECIMAL_CURRENCIES = {
"BHD",
"IQD",
"JOD",
"KWD",
"LYD",
"OMR",
"TND",
}
REVOLUT_CUSTOMER_LIST_LIMIT = 500
REVOLUT_CUSTOMER_LIST_MAX_PAGES = 20
REVOLUT_REQUEST_TIMEOUT = 30
class RevolutWallet(FiatProvider): class RevolutWallet(FiatProvider):
"""https://developer.revolut.com/docs/merchant""" """https://developer.revolut.com/docs/merchant"""
@@ -125,11 +93,7 @@ class RevolutWallet(FiatProvider):
return FiatStatusResponse(balance=0) return FiatStatusResponse(balance=0)
try: try:
r = await self.client.get( r = await self.client.get("/api/orders", params={"limit": 1}, timeout=15)
"/api/orders",
params={"limit": 1},
timeout=REVOLUT_REQUEST_TIMEOUT,
)
r.raise_for_status() r.raise_for_status()
_ = r.json() _ = r.json()
return FiatStatusResponse(balance=0) return FiatStatusResponse(balance=0)
@@ -154,7 +118,7 @@ class RevolutWallet(FiatProvider):
ok=False, error_message="Invalid Revolut options" ok=False, error_message="Invalid Revolut options"
) )
amount_minor = self.amount_to_minor_units(amount, currency) amount_minor = int(amount * 100)
checkout = opts.checkout or RevolutCheckoutOptions() checkout = opts.checkout or RevolutCheckoutOptions()
success_url = ( success_url = (
checkout.success_url checkout.success_url
@@ -175,9 +139,7 @@ class RevolutWallet(FiatProvider):
} }
try: try:
r = await self.client.post( r = await self.client.post("/api/orders", json=payload)
"/api/orders", json=payload, timeout=REVOLUT_REQUEST_TIMEOUT
)
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
order_id = data.get("id") order_id = data.get("id")
@@ -221,6 +183,13 @@ class RevolutWallet(FiatProvider):
) )
extra = payment_options.extra or {} extra = payment_options.extra or {}
customer_id = extra.get("customer_id")
if not customer_id:
return FiatSubscriptionResponse(
ok=False,
error_message="Revolut subscriptions require extra.customer_id.",
)
if not payment_options.subscription_request_id: if not payment_options.subscription_request_id:
payment_options.subscription_request_id = urlsafe_short_hash() payment_options.subscription_request_id = urlsafe_short_hash()
@@ -233,6 +202,7 @@ class RevolutWallet(FiatProvider):
) )
payload: dict[str, Any] = { payload: dict[str, Any] = {
"plan_variation_id": subscription_id, "plan_variation_id": subscription_id,
"customer_id": customer_id,
"external_reference": self._serialize_subscription_reference(reference), "external_reference": self._serialize_subscription_reference(reference),
"setup_order_redirect_url": ( "setup_order_redirect_url": (
payment_options.success_url payment_options.success_url
@@ -249,17 +219,8 @@ class RevolutWallet(FiatProvider):
} }
try: try:
customer_id, customer_error = await self._get_subscription_customer_id(
payment_options
)
if not customer_id:
return FiatSubscriptionResponse(ok=False, error_message=customer_error)
payload["customer_id"] = customer_id
r = await self.client.post( r = await self.client.post(
"/api/subscriptions", "/api/subscriptions", json=payload, headers=headers
json=payload,
headers=headers,
timeout=REVOLUT_REQUEST_TIMEOUT,
) )
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
@@ -302,19 +263,7 @@ class RevolutWallet(FiatProvider):
**kwargs, **kwargs,
) -> FiatSubscriptionResponse: ) -> FiatSubscriptionResponse:
try: try:
subscription = await self.get_subscription(subscription_id) r = await self.client.post(f"/api/subscriptions/{subscription_id}/cancel")
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(
f"/api/subscriptions/{subscription_id}/cancel",
timeout=REVOLUT_REQUEST_TIMEOUT,
)
r.raise_for_status() r.raise_for_status()
return FiatSubscriptionResponse(ok=True) return FiatSubscriptionResponse(ok=True)
except Exception as exc: except Exception as exc:
@@ -355,16 +304,12 @@ class RevolutWallet(FiatProvider):
return value.replace("order_", "", 1) if value.startswith("order_") else value return value.replace("order_", "", 1) if value.startswith("order_") else value
async def get_order(self, order_id: str) -> dict[str, Any]: async def get_order(self, order_id: str) -> dict[str, Any]:
r = await self.client.get( r = await self.client.get(f"/api/orders/{order_id}")
f"/api/orders/{order_id}", timeout=REVOLUT_REQUEST_TIMEOUT
)
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
async def get_subscription(self, subscription_id: str) -> dict[str, Any]: async def get_subscription(self, subscription_id: str) -> dict[str, Any]:
r = await self.client.get( r = await self.client.get(f"/api/subscriptions/{subscription_id}")
f"/api/subscriptions/{subscription_id}", timeout=REVOLUT_REQUEST_TIMEOUT
)
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
@@ -372,60 +317,7 @@ class RevolutWallet(FiatProvider):
self, subscription_id: str, cycle_id: str self, subscription_id: str, cycle_id: str
) -> dict[str, Any]: ) -> dict[str, Any]:
r = await self.client.get( r = await self.client.get(
f"/api/subscriptions/{subscription_id}/cycles/{cycle_id}", f"/api/subscriptions/{subscription_id}/cycles/{cycle_id}"
timeout=REVOLUT_REQUEST_TIMEOUT,
)
r.raise_for_status()
return r.json()
async def _get_subscription_customer_id(
self, payment_options: FiatSubscriptionPaymentOptions
) -> tuple[str | None, str | None]:
if not payment_options.customer_email:
return (
None,
"Revolut subscriptions require customer_email.",
)
customer = await self._get_customer_by_email(payment_options.customer_email)
customer_id = customer.get("id") if customer else None
if customer_id:
return customer_id, None
customer = await self._create_customer(payment_options.customer_email)
customer_id = customer.get("id")
if not customer_id:
return None, "Server error: missing customer id"
return customer_id, None
async def _get_customer_by_email(self, email: str) -> dict[str, Any] | None:
page_token = None
for _ in range(REVOLUT_CUSTOMER_LIST_MAX_PAGES):
customer_page = await self._list_customers(page_token=page_token)
customer = _find_customer_by_email(customer_page["customers"], email)
if customer:
return customer
page_token = customer_page.get("next_page_token")
if not page_token:
return None
return None
async def _list_customers(self, page_token: str | None = None) -> dict[str, Any]:
params: dict[str, Any] = {"limit": REVOLUT_CUSTOMER_LIST_LIMIT}
if page_token:
params["page_token"] = page_token
r = await self.client.get(
"/api/customers", params=params, timeout=REVOLUT_REQUEST_TIMEOUT
)
r.raise_for_status()
return _extract_customer_page(r.json())
async def _create_customer(self, email: str) -> dict[str, Any]:
r = await self.client.post(
"/api/customers",
json={"email": email},
timeout=REVOLUT_REQUEST_TIMEOUT,
) )
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
@@ -462,15 +354,13 @@ class RevolutWallet(FiatProvider):
existing["already_exists"] = True existing["already_exists"] = True
return existing return existing
response = await client.post( response = await client.post("/api/webhooks", json=payload, timeout=15)
"/api/webhooks", json=payload, timeout=REVOLUT_REQUEST_TIMEOUT
)
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
@classmethod @classmethod
async def _list_webhooks(cls, client: httpx.AsyncClient) -> list[dict[str, Any]]: async def _list_webhooks(cls, client: httpx.AsyncClient) -> list[dict[str, Any]]:
response = await client.get("/api/webhooks", timeout=REVOLUT_REQUEST_TIMEOUT) response = await client.get("/api/webhooks", timeout=15)
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
if isinstance(data, list): if isinstance(data, list):
@@ -495,9 +385,7 @@ class RevolutWallet(FiatProvider):
if webhook_id and ( if webhook_id and (
not webhook.get("events") or not webhook.get("signing_secret") not webhook.get("events") or not webhook.get("signing_secret")
): ):
response = await client.get( response = await client.get(f"/api/webhooks/{webhook_id}", timeout=15)
f"/api/webhooks/{webhook_id}", timeout=REVOLUT_REQUEST_TIMEOUT
)
response.raise_for_status() response.raise_for_status()
webhook = response.json() webhook = response.json()
@@ -557,25 +445,6 @@ class RevolutWallet(FiatProvider):
return FiatPaymentFailedStatus() return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus() return FiatPaymentPendingStatus()
@classmethod
def amount_to_minor_units(cls, amount: float | Decimal, currency: str) -> int:
scale = Decimal(10) ** cls.currency_exponent(currency)
return int((Decimal(str(amount)) * scale).quantize(Decimal("1"), ROUND_HALF_UP))
@classmethod
def minor_units_to_amount(cls, amount: int, currency: str) -> float:
scale = Decimal(10) ** cls.currency_exponent(currency)
return float(Decimal(amount) / scale)
@classmethod
def currency_exponent(cls, currency: str) -> int:
normalized = currency.upper()
if normalized in ZERO_DECIMAL_CURRENCIES:
return 0
if normalized in THREE_DECIMAL_CURRENCIES:
return 3
return 2
def _parse_create_opts( def _parse_create_opts(
self, raw_opts: dict[str, Any] self, raw_opts: dict[str, Any]
) -> RevolutCreateInvoiceOptions | None: ) -> RevolutCreateInvoiceOptions | None:
@@ -616,31 +485,3 @@ class RevolutWallet(FiatProvider):
str(settings.revolut_webhook_signing_secret), str(settings.revolut_webhook_signing_secret),
] ]
) )
def _extract_customer_page(data: Any) -> dict[str, Any]:
if isinstance(data, list):
return {"customers": _filter_customer_list(data)}
if isinstance(data, dict):
for field in ["customers", "data", "items"]:
customers = data.get(field)
if isinstance(customers, list):
return {
"customers": _filter_customer_list(customers),
"next_page_token": data.get("next_page_token"),
}
return {"customers": []}
def _filter_customer_list(customers: list[Any]) -> list[dict[str, Any]]:
return [customer for customer in customers if isinstance(customer, dict)]
def _find_customer_by_email(
customers: list[dict[str, Any]], email: str
) -> dict[str, Any] | None:
normalized_email = email.casefold()
for customer in customers:
if str(customer.get("email") or "").casefold() == normalized_email:
return customer
return None
-4
View File
@@ -137,10 +137,6 @@ class SquareWallet(FiatProvider):
payment_options: FiatSubscriptionPaymentOptions, payment_options: FiatSubscriptionPaymentOptions,
**kwargs, **kwargs,
) -> FiatSubscriptionResponse: ) -> FiatSubscriptionResponse:
if settings.lnbits_running:
return FiatSubscriptionResponse(
ok=False, error_message="Subscription not supported for Square."
)
success_url = ( success_url = (
payment_options.success_url payment_options.success_url
or settings.square_payment_success_url or settings.square_payment_success_url
-78
View File
@@ -1,78 +0,0 @@
"""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)
+9 -12
View File
@@ -300,7 +300,6 @@ class ThemesSettings(LNbitsSettings):
lnbits_default_card_rounded: bool = Field(default=True) lnbits_default_card_rounded: bool = Field(default=True)
lnbits_default_card_gradient: bool = Field(default=True) lnbits_default_card_gradient: bool = Field(default=True)
lnbits_default_card_shadow: bool = Field(default=False) lnbits_default_card_shadow: bool = Field(default=False)
lnbits_default_burger_menu_background: bool = Field(default=True)
class OpsSettings(LNbitsSettings): class OpsSettings(LNbitsSettings):
@@ -324,6 +323,11 @@ class AssetSettings(LNbitsSettings):
"heic", "heic",
"heif", "heif",
"heics", "heics",
"text/plain",
"text/json",
"text/xml",
"application/json",
"application/pdf",
] ]
) )
lnbits_asset_thumbnail_width: int = Field(default=128, ge=0) lnbits_asset_thumbnail_width: int = Field(default=128, ge=0)
@@ -359,11 +363,9 @@ class FeeSettings(LNbitsSettings):
class ExchangeProvidersSettings(LNbitsSettings): class ExchangeProvidersSettings(LNbitsSettings):
lnbits_exchange_rate_cache_seconds: int = Field(default=60, ge=0) lnbits_exchange_rate_cache_seconds: int = Field(default=30, 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=[
@@ -589,7 +591,6 @@ 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):
@@ -614,7 +615,6 @@ 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)
@@ -652,7 +652,6 @@ 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):
@@ -1052,7 +1051,7 @@ class EditableSettings(
class UpdateSettings(EditableSettings): class UpdateSettings(EditableSettings):
class Config(EditableSettings.Config): class Config:
extra = Extra.forbid extra = Extra.forbid
@@ -1203,11 +1202,11 @@ class ReadOnlySettings(
class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings): class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings):
class Config(EditableSettings.Config, BaseSettings.Config): # type: ignore[misc] class Config:
env_file = ".env" env_file = ".env"
env_file_encoding = "utf-8" env_file_encoding = "utf-8"
case_sensitive = False case_sensitive = False
json_loads = list_parse_fallback # type: ignore[assignment] json_loads = list_parse_fallback
def is_user_allowed(self, user_id: str) -> bool: def is_user_allowed(self, user_id: str) -> bool:
return ( return (
@@ -1287,7 +1286,6 @@ class PublicSettings(BaseModel):
default_card_rounded: bool = Field(alias="defaultCardRounded") default_card_rounded: bool = Field(alias="defaultCardRounded")
default_card_gradient: bool = Field(alias="defaultCardGradient") default_card_gradient: bool = Field(alias="defaultCardGradient")
default_card_shadow: bool = Field(alias="defaultCardShadow") default_card_shadow: bool = Field(alias="defaultCardShadow")
default_burger_menu_background: bool = Field(alias="defaultBurgerMenuBackground")
denomination: str | None = Field() denomination: str | None = Field()
extensions: list[str] = Field() extensions: list[str] = Field()
allowed_currencies: list[str] = Field(alias="allowedCurrencies") allowed_currencies: list[str] = Field(alias="allowedCurrencies")
@@ -1351,7 +1349,6 @@ class PublicSettings(BaseModel):
defaultCardRounded=settings.lnbits_default_card_rounded, defaultCardRounded=settings.lnbits_default_card_rounded,
defaultCardGradient=settings.lnbits_default_card_gradient, defaultCardGradient=settings.lnbits_default_card_gradient,
defaultCardShadow=settings.lnbits_default_card_shadow, defaultCardShadow=settings.lnbits_default_card_shadow,
defaultBurgerMenuBackground=settings.lnbits_default_burger_menu_background,
denomination=settings.lnbits_denomination, denomination=settings.lnbits_denomination,
extensions=list(settings.lnbits_installed_extensions_ids), extensions=list(settings.lnbits_installed_extensions_ids),
allowedCurrencies=settings.lnbits_allowed_currencies, allowedCurrencies=settings.lnbits_allowed_currencies,
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+14 -14
View File
File diff suppressed because one or more lines are too long
+4 -14
View File
@@ -212,15 +212,12 @@ body.bg-image .q-page-container {
backdrop-filter: none; /* Ensure the page content is not affected */ backdrop-filter: none; /* Ensure the page content is not affected */
} }
body.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) { body.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark),
--q-dark: rgba(29, 29, 29, 0.3);
background-color: var(--q-dark);
}
body.body--dark .q-header, body.body--dark .q-header,
body.body--dark .q-drawer { body.body--dark .q-drawer {
--q-dark: rgba(29, 29, 29, 0.3); --q-dark: rgba(29, 29, 29, 0.3);
background-color: var(--q-dark); background-color: var(--q-dark);
backdrop-filter: brightness(0.8); backdrop-filter: blur(6px) brightness(0.8);
} }
body.rounded-ui .q-card, body.rounded-ui .q-card,
@@ -391,18 +388,11 @@ body[data-theme=salvador].card-gradient.body--dark .q-drawer {
} }
body.card-shadow .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) { body.card-shadow .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18); filter: drop-shadow(0 10px 24px rgba(0, 0, 0, 0.18));
} }
body.card-shadow.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) { body.card-shadow.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45); filter: drop-shadow(0 12px 28px rgba(0, 0, 0, 0.45));
}
body.no-burger-background .q-drawer {
background-color: transparent !important;
background-image: none !important;
backdrop-filter: none !important;
box-shadow: none !important;
} }
:root { :root {
-3
View File
@@ -184,7 +184,6 @@ 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:
@@ -491,8 +490,6 @@ window.localisation.en = {
toggle_card_gradient: 'Toggle gradient on cards', toggle_card_gradient: 'Toggle gradient on cards',
card_shadow: 'Card Shadow', card_shadow: 'Card Shadow',
toggle_card_shadow: 'Toggle shadow on cards', toggle_card_shadow: 'Toggle shadow on cards',
burger_menu_background: 'Burger Menu Background',
toggle_burger_menu_background: 'Toggle burger menu background',
language: 'Language', language: 'Language',
assets: 'Assets', assets: 'Assets',
max_asset_size_mb: 'Max Asset Size (MB)', max_asset_size_mb: 'Max Asset Size (MB)',
+1 -3
View File
@@ -452,9 +452,7 @@ window.app.component('username-password', {
confirmationMethod: 'code', confirmationMethod: 'code',
confirmationEmail: '', confirmationEmail: '',
confirmationCode: this.invitationCode || '', confirmationCode: this.invitationCode || '',
showConfirmationCode: false, showConfirmationCode: false
showPwd: false,
showPwdRepeat: false
} }
}, },
methods: { methods: {
@@ -62,6 +62,12 @@ 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 => {
@@ -121,21 +127,18 @@ 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 = this.formData.lnbits_price_aggregator_enabled const exchanges = [
? [{name: 'Aggregator'}] ...this.formData.lnbits_exchange_rate_providers,
: [...this.formData.lnbits_exchange_rate_providers, {name: 'LNbits'}] {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 : 2, borderWidth: exchange.name === 'LNbits' ? 4 : 1,
tension: 0.4 tension: 0.4
})) }))
this.exchangeRatesChart = new Chart( this.exchangeRatesChart = new Chart(
@@ -145,11 +148,7 @@ window.app.component('lnbits-admin-exchange-providers', {
options: { options: {
plugins: { plugins: {
legend: { legend: {
display: true display: false
},
title: {
display: true,
text: 'Bitcoin Price History'
} }
} }
}, },
@@ -1,151 +0,0 @@
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: ['active', 'is-super-user', 'form-data', 'settings'], props: ['is-super-user', 'form-data', 'settings'],
template: '#lnbits-admin-funding', template: '#lnbits-admin-funding',
data() { data() {
return { return {
@@ -360,37 +360,18 @@ window.app.component('lnbits-payment-list', {
paymentTableRowKey(row) { paymentTableRowKey(row) {
return row.payment_hash + row.amount return row.payment_hash + row.amount
}, },
async exportCSV(detailed = false) { exportCSV(detailed = false) {
// status is important for export but it is not in paymentsTable // status is important for export but it is not in paymentsTable
// because it is manually added with payment detail link and icons // because it is manually added with payment detail link and icons
// and would cause duplication in the list // and would cause duplication in the list
const pagination = this.paymentsTable.pagination const pagination = this.paymentsTable.pagination
const maxPages = 100 const query = {
const limit = 1000 sortby: pagination.sortBy ?? 'time',
let payments = [] direction: pagination.descending ? 'desc' : 'asc'
}
this.paymentsCSV.loading = true const params = new URLSearchParams(query)
try { LNbits.api.getPayments(this.wallet, params).then(response => {
for (let page = 0; page < maxPages; page++) { let payments = response.data.data.map(this.mapPayment)
const query = {
sortby: pagination.sortBy ?? 'time',
direction: pagination.descending ? 'desc' : 'asc',
limit,
offset: page * limit
}
const params = new URLSearchParams(query)
const response = await LNbits.api.getPayments(this.wallet, params)
const pagePayments = response.data.data || []
payments = payments.concat(pagePayments.map(this.mapPayment))
if (
pagePayments.length < limit ||
payments.length >= response.data.total
) {
break
}
}
let columns = this.paymentsCSV.columns let columns = this.paymentsCSV.columns
if (detailed) { if (detailed) {
@@ -419,11 +400,7 @@ window.app.component('lnbits-payment-list', {
payments, payments,
this.wallet.name + '-payments' this.wallet.name + '-payments'
) )
} catch (err) { })
LNbits.utils.notifyApiError(err)
} finally {
this.paymentsCSV.loading = false
}
}, },
addFilterTag() { addFilterTag() {
if (!this.exportTagName) return if (!this.exportTagName) return
@@ -8,10 +8,6 @@ window.app.component('lnbits-qrcode-lnurl', {
prefix: { prefix: {
type: String, type: String,
default: 'lnurlp' default: 'lnurlp'
},
href: {
type: String,
default: ''
} }
}, },
data() { data() {
@@ -25,10 +21,7 @@ window.app.component('lnbits-qrcode-lnurl', {
if (this.tab == 'bech32') { if (this.tab == 'bech32') {
const bytes = new TextEncoder().encode(this.url) const bytes = new TextEncoder().encode(this.url)
const bech32 = NostrTools.nip19.encodeBytes('lnurl', bytes) const bech32 = NostrTools.nip19.encodeBytes('lnurl', bytes)
this.lnurl = this.lnurl = `lightning:${bech32.toUpperCase()}`
this.href && this.href.trim() !== ''
? `${this.href}?lightning=${bech32.toUpperCase()}`
: `lightning:${bech32.toUpperCase()}`
} else if (this.tab == 'lud17') { } else if (this.tab == 'lud17') {
if (this.url.startsWith('http://')) { if (this.url.startsWith('http://')) {
this.lnurl = this.url.replace('http://', this.prefix + '://') this.lnurl = this.url.replace('http://', this.prefix + '://')
@@ -85,9 +85,6 @@ window.app.component('lnbits-qrcode', {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
return false return false
} else if (this.href && this.href.startsWith('http')) {
window.open(this.href, '_blank')
event.preventDefault()
} }
}, },
async writeNfcTag() { async writeNfcTag() {
@@ -69,14 +69,6 @@ window.app.component('lnbits-theme', {
document.body.classList.remove('card-shadow') document.body.classList.remove('card-shadow')
} }
}, },
'g.burgerMenuChoice'(val) {
this.$q.localStorage.set('lnbits.burgerMenu', val)
if (val === true) {
document.body.classList.remove('no-burger-background')
} else {
document.body.classList.add('no-burger-background')
}
},
'g.mobileSimple'(val) { 'g.mobileSimple'(val) {
this.$q.localStorage.set('lnbits.mobileSimple', val) this.$q.localStorage.set('lnbits.mobileSimple', val)
if (val === true) { if (val === true) {
@@ -158,9 +150,6 @@ window.app.component('lnbits-theme', {
if (this.g.cardShadowChoice === true) { if (this.g.cardShadowChoice === true) {
document.body.classList.add('card-shadow') document.body.classList.add('card-shadow')
} }
if (this.g.burgerMenuChoice !== true) {
document.body.classList.add('no-burger-background')
}
if (this.g.bgimageChoice !== '') { if (this.g.bgimageChoice !== '') {
document.body.classList.add('bg-image') document.body.classList.add('bg-image')
document.body.style.setProperty( document.body.style.setProperty(
+5 -6
View File
@@ -1,17 +1,16 @@
function eventReaction(amount) { function eventReaction(amount) {
localUrl = '' localUrl = ''
const reaction = reaction = localStorage.getItem('lnbits.reactions')
Quasar.LocalStorage.getItem('lnbits.reactions') || SETTINGS.defaultReaction if (!reaction || reaction === 'None') {
if (!reaction || reaction.toLowerCase() === 'none') {
return return
} }
try { try {
if (amount < 0) { if (amount < 0) {
return return
} }
if (typeof window[reaction] === 'function') { reaction = localStorage.getItem('lnbits.reactions')
window[reaction]() if (reaction) {
window[reaction.split('|')[1]]()
} }
} catch (e) { } catch (e) {
console.log(e) console.log(e)
-4
View File
@@ -29,10 +29,6 @@ window.g = Vue.reactive({
SETTINGS.defaultCardGradient SETTINGS.defaultCardGradient
), ),
cardShadowChoice: localStore('lnbits.cardShadow', SETTINGS.defaultCardShadow), cardShadowChoice: localStore('lnbits.cardShadow', SETTINGS.defaultCardShadow),
burgerMenuChoice: localStore(
'lnbits.burgerMenu',
SETTINGS.defaultBurgerMenuBackground
),
reactionChoice: localStore('lnbits.reactions', SETTINGS.defaultReaction), reactionChoice: localStore('lnbits.reactions', SETTINGS.defaultReaction),
bgimageChoice: localStore( bgimageChoice: localStore(
'lnbits.backgroundImage', 'lnbits.backgroundImage',
-32
View File
@@ -27,14 +27,6 @@ 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]
} }
@@ -159,30 +151,6 @@ 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() {
+1 -2
View File
@@ -732,8 +732,7 @@ window.PageAccount = {
darkChoice: this.g.settings.defaultDark, darkChoice: this.g.settings.defaultDark,
cardRoundedChoice: this.g.settings.defaultCardRounded, cardRoundedChoice: this.g.settings.defaultCardRounded,
cardGradientChoice: this.g.settings.defaultCardGradient, cardGradientChoice: this.g.settings.defaultCardGradient,
cardShadowChoice: this.g.settings.defaultCardShadow, cardShadowChoice: this.g.settings.defaultCardShadow
burgerMenuChoice: this.g.settings.defaultBurgerMenuBackground
} }
this.siteCustomisationChanged(defaults) this.siteCustomisationChanged(defaults)
} }
-9
View File
@@ -10,7 +10,6 @@ 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,
@@ -107,10 +106,6 @@ 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))
@@ -118,7 +113,6 @@ 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,
@@ -838,9 +832,6 @@ 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)
-6
View File
@@ -223,12 +223,6 @@ 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') {
-14
View File
@@ -323,20 +323,6 @@ 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))
+2 -6
View File
@@ -58,15 +58,11 @@ body.bg-image {
} }
// transparent background for specific elements // transparent background for specific elements
body.body--dark { body.body--dark {
.q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) { .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark),
--q-dark: #{color.adjust(#1d1d1d, $alpha: -0.7)};
background-color: var(--q-dark);
}
.q-header, .q-header,
.q-drawer { .q-drawer {
--q-dark: #{color.adjust(#1d1d1d, $alpha: -0.7)}; --q-dark: #{color.adjust(#1d1d1d, $alpha: -0.7)};
background-color: var(--q-dark); background-color: var(--q-dark);
backdrop-filter: brightness(0.8); backdrop-filter: blur(6px) brightness(0.8);
} }
} }
+2 -11
View File
@@ -61,21 +61,12 @@ body.rounded-ui {
body.card-shadow { body.card-shadow {
.q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) { .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18); filter: drop-shadow(0 10px 24px rgba(0, 0, 0, 0.18));
} }
} }
body.card-shadow.body--dark { body.card-shadow.body--dark {
.q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) { .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45); filter: drop-shadow(0 12px 28px rgba(0, 0, 0, 0.45));
}
}
body.no-burger-background {
.q-drawer {
background-color: transparent !important;
background-image: none !important;
backdrop-filter: none !important;
box-shadow: none !important;
} }
} }
-1
View File
@@ -58,7 +58,6 @@
"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",
+20776
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
/*
* DOM element rendering detection
* https://davidwalsh.name/detect-node-insertion
*/
@keyframes chartjs-render-animation {
from { opacity: 0.99; }
to { opacity: 1; }
}
.chartjs-render-monitor {
animation: chartjs-render-animation 0.001s;
}
/*
* DOM element resizing detection
* https://github.com/marcj/css-element-queries
*/
.chartjs-size-monitor,
.chartjs-size-monitor-expand,
.chartjs-size-monitor-shrink {
position: absolute;
direction: ltr;
left: 0;
top: 0;
right: 0;
bottom: 0;
overflow: hidden;
pointer-events: none;
visibility: hidden;
z-index: -1;
}
.chartjs-size-monitor-expand > div {
position: absolute;
width: 1000000px;
height: 1000000px;
left: 0;
top: 0;
}
.chartjs-size-monitor-shrink > div {
position: absolute;
width: 200%;
height: 200%;
left: 0;
top: 0;
}
+495 -865
View File
File diff suppressed because it is too large Load Diff
+3116 -2768
View File
File diff suppressed because it is too large Load Diff
+131 -221
View File
@@ -1,5 +1,5 @@
/*! /*!
* qrcode.vue v3.9.0 * qrcode.vue v3.6.0
* A Vue.js component to generate QRCode. Both support Vue 2 and Vue 3 * A Vue.js component to generate QRCode. Both support Vue 2 and Vue 3
* © 2017-PRESENT @scopewu(https://github.com/scopewu) * © 2017-PRESENT @scopewu(https://github.com/scopewu)
* MIT License. * MIT License.
@@ -909,18 +909,7 @@ var qrcodegen;
})(qrcodegen || (qrcodegen = {})); })(qrcodegen || (qrcodegen = {}));
var QR = qrcodegen; var QR = qrcodegen;
var _uid = 0;
function getUid() {
if (typeof vue.useId === 'function') {
return "".concat(vue.useId(), "-").concat(_uid++);
}
return "vue-".concat(Math.random().toString(36).slice(2), "-").concat(_uid++);
}
var defaultErrorCorrectLevel = 'L'; var defaultErrorCorrectLevel = 'L';
var DEFAULT_QR_SIZE = 100;
var DEFAULT_MARGIN = 0;
var DEFAULT_IMAGE_SIZE_RATIO = 0.1;
var IMAGE_EXCAVATE_THICKNESS = 2;
var ErrorCorrectLevelMap = { var ErrorCorrectLevelMap = {
L: QR.QrCode.Ecc.LOW, L: QR.QrCode.Ecc.LOW,
M: QR.QrCode.Ecc.MEDIUM, M: QR.QrCode.Ecc.MEDIUM,
@@ -940,139 +929,74 @@ var SUPPORTS_PATH2D = (function () {
function validErrorCorrectLevel(level) { function validErrorCorrectLevel(level) {
return level in ErrorCorrectLevelMap; return level in ErrorCorrectLevelMap;
} }
function getNeighborFlags(modules, row, col) {
var north = row > 0 ? modules[row - 1][col] : false;
var south = row < modules.length - 1 ? modules[row + 1][col] : false;
var west = col > 0 ? modules[row][col - 1] : false;
var east = col < modules[row].length - 1 ? modules[row][col + 1] : false;
return {
nw: !north && !west,
ne: !north && !east,
se: !south && !east,
sw: !south && !west,
};
}
function generateRoundedPath(modules, margin, radius) {
if (margin === void 0) { margin = 0; }
if (radius === void 0) { radius = 0; }
var pathSegments = [];
var r = Math.min(radius, 0.5);
for (var row = 0; row < modules.length; row++) {
for (var col = 0; col < modules[row].length; col++) {
if (!modules[row][col])
continue;
var _a = getNeighborFlags(modules, row, col), nw = _a.nw, ne = _a.ne, se = _a.se, sw = _a.sw;
var x = col + margin;
var y = row + margin;
pathSegments.push("M".concat(x + (nw ? r : 0), " ").concat(y), "L".concat(x + 1 - (ne ? r : 0), " ").concat(y));
if (ne) {
pathSegments.push("A".concat(r, " ").concat(r, " 0 0 1 ").concat(x + 1, " ").concat(y + r));
}
pathSegments.push("L".concat(x + 1, " ").concat(y + 1 - (se ? r : 0)));
if (se) {
pathSegments.push("A".concat(r, " ").concat(r, " 0 0 1 ").concat(x + 1 - r, " ").concat(y + 1));
}
pathSegments.push("L".concat(x + (sw ? r : 0), " ").concat(y + 1));
if (sw) {
pathSegments.push("A".concat(r, " ").concat(r, " 0 0 1 ").concat(x, " ").concat(y + 1 - r));
}
pathSegments.push("L".concat(x, " ").concat(y + (nw ? r : 0)));
if (nw) {
pathSegments.push("A".concat(r, " ").concat(r, " 0 0 1 ").concat(x + r, " ").concat(y));
}
pathSegments.push('z');
}
}
return pathSegments.join('');
}
function generatePath(modules, margin) { function generatePath(modules, margin) {
if (margin === void 0) { margin = 0; } if (margin === void 0) { margin = 0; }
var pathSegments = []; var ops = [];
for (var y = 0; y < modules.length; y++) { modules.forEach(function (row, y) {
var row = modules[y];
var start = null; var start = null;
for (var x = 0; x < row.length; x++) { row.forEach(function (cell, x) {
var cell = row[x];
if (!cell && start !== null) { if (!cell && start !== null) {
// M0 0h7v1H0z injects the space with the move and drops the comma, // M0 0h7v1H0z injects the space with the move and drops the comma,
pathSegments.push("M".concat(start + margin, " ").concat(y + margin, "h").concat(x - start, "v1H").concat(start + margin, "z")); // saving a char per operation
ops.push("M".concat(start + margin, " ").concat(y + margin, "h").concat(x - start, "v1H").concat(start + margin, "z"));
start = null; start = null;
continue; return;
} }
// end of row, clean up or skip // end of row, clean up or skip
if (x === row.length - 1) { if (x === row.length - 1) {
if (!cell) { if (!cell) {
// We would have closed the op above already so this can only mean // We would have closed the op above already so this can only mean
// 2+ light modules in a row. // 2+ light modules in a row.
continue; return;
} }
if (start === null) { if (start === null) {
// Just a single dark module. // Just a single dark module.
pathSegments.push("M".concat(x + margin, ",").concat(y + margin, " h1v1H").concat(x + margin, "z")); ops.push("M".concat(x + margin, ",").concat(y + margin, " h1v1H").concat(x + margin, "z"));
} }
else { else {
// Otherwise finish the current line. // Otherwise finish the current line.
pathSegments.push("M".concat(start + margin, ",").concat(y + margin, " h").concat(x + 1 - start, "v1H").concat(start + margin, "z")); ops.push("M".concat(start + margin, ",").concat(y + margin, " h").concat(x + 1 - start, "v1H").concat(start + margin, "z"));
} }
continue; return;
} }
if (cell && start === null) { if (cell && start === null) {
start = x; start = x;
} }
} });
} });
return pathSegments.join(''); return ops.join('');
} }
function getImageSettings(cells, size, margin, imageSettings) { function getImageSettings(cells, size, margin, imageSettings) {
var width = imageSettings.width, height = imageSettings.height, imageX = imageSettings.x, imageY = imageSettings.y; var width = imageSettings.width, height = imageSettings.height, imageX = imageSettings.x, imageY = imageSettings.y;
var numCells = cells.length + margin * 2; var numCells = cells.length + margin * 2;
var defaultSize = Math.floor(size * DEFAULT_IMAGE_SIZE_RATIO); var defaultSize = Math.floor(size * 0.1);
var scale = numCells / size; var scale = numCells / size;
var w = (width || defaultSize) * scale; var w = (width || defaultSize) * scale;
var h = (height || defaultSize) * scale; var h = (height || defaultSize) * scale;
var x = imageX == null ? cells.length / 2 - w / 2 : imageX * scale; var x = imageX == null ? cells.length / 2 - w / 2 : imageX * scale;
var y = imageY == null ? cells.length / 2 - h / 2 : imageY * scale; var y = imageY == null ? cells.length / 2 - h / 2 : imageY * scale;
var borderRadius = (imageSettings.borderRadius || 0) * scale; var excavation = null;
return { x: x, y: y, h: h, w: w, borderRadius: borderRadius }; if (imageSettings.excavate) {
var floorX = Math.floor(x);
var floorY = Math.floor(y);
var ceilW = Math.ceil(w + x - floorX);
var ceilH = Math.ceil(h + y - floorY);
excavation = { x: floorX, y: floorY, w: ceilW, h: ceilH };
}
return { x: x, y: y, h: h, w: w, excavation: excavation };
} }
function useQRCode(props) { function excavateModules(modules, excavation) {
var margin = vue.computed(function () { var _a; return ((_a = props.margin) !== null && _a !== void 0 ? _a : DEFAULT_MARGIN) >>> 0; }); return modules.slice().map(function (row, y) {
var cells = vue.computed(function () { if (y < excavation.y || y >= excavation.y + excavation.h) {
var level = validErrorCorrectLevel(props.level) ? props.level : defaultErrorCorrectLevel; return row;
return QR.QrCode.encodeText(props.value, ErrorCorrectLevelMap[level]).getModules();
});
var numCells = vue.computed(function () { return cells.value.length + margin.value * 2; });
var fgPath = vue.computed(function () {
if (props.radius > 0) {
return generateRoundedPath(cells.value, margin.value, props.radius);
} }
return generatePath(cells.value, margin.value); return row.map(function (cell, x) {
if (x < excavation.x || x >= excavation.x + excavation.w) {
return cell;
}
return false;
});
}); });
var imageProps = vue.computed(function () {
if (!props.imageSettings.src)
return null;
var settings = getImageSettings(cells.value, props.size, margin.value, props.imageSettings);
return {
x: settings.x + margin.value,
y: settings.y + margin.value,
width: settings.w,
height: settings.h,
borderRadius: settings.borderRadius,
};
});
var imageBorderProps = vue.computed(function () {
if (!props.imageSettings.excavate || !imageProps.value)
return null;
var borderThickness = IMAGE_EXCAVATE_THICKNESS / (props.size / numCells.value);
return {
x: imageProps.value.x - borderThickness,
y: imageProps.value.y - borderThickness,
width: imageProps.value.width + borderThickness * 2,
height: imageProps.value.height + borderThickness * 2,
borderRadius: imageProps.value.borderRadius,
};
});
return { margin: margin, numCells: numCells, cells: cells, fgPath: fgPath, imageProps: imageProps, imageBorderProps: imageBorderProps };
} }
var QRCodeProps = { var QRCodeProps = {
value: { value: {
@@ -1082,7 +1006,7 @@ var QRCodeProps = {
}, },
size: { size: {
type: Number, type: Number,
default: DEFAULT_QR_SIZE, default: 100,
}, },
level: { level: {
type: String, type: String,
@@ -1100,7 +1024,7 @@ var QRCodeProps = {
margin: { margin: {
type: Number, type: Number,
required: false, required: false,
default: DEFAULT_MARGIN, default: 0,
}, },
imageSettings: { imageSettings: {
type: Object, type: Object,
@@ -1128,12 +1052,6 @@ var QRCodeProps = {
required: false, required: false,
default: '#fff', default: '#fff',
}, },
radius: {
type: Number,
required: false,
default: 0,
validator: function (r) { return !isNaN(r) && r >= 0 && r <= 0.5; },
},
}; };
var QRCodeVueProps = __assign(__assign({}, QRCodeProps), { renderAs: { var QRCodeVueProps = __assign(__assign({}, QRCodeProps), { renderAs: {
type: String, type: String,
@@ -1145,11 +1063,36 @@ var QrcodeSvg = vue.defineComponent({
name: 'QRCodeSvg', name: 'QRCodeSvg',
props: QRCodeProps, props: QRCodeProps,
setup: function (props) { setup: function (props) {
var _a = useQRCode(props), numCells = _a.numCells, fgPath = _a.fgPath, imageProps = _a.imageProps, imageBorderProps = _a.imageBorderProps; var numCells = vue.ref(0);
var uid = getUid(); var fgPath = vue.ref('');
var qrGradientId = "qrcode.vue-gradient-".concat(uid); var imageProps;
var qrLogoClipPathId = "qrcode.vue-logo-clip-path-".concat(uid); var generate = function () {
var gradientVNode = vue.computed(function () { var value = props.value, _level = props.level, _margin = props.margin;
var margin = _margin >>> 0;
var level = validErrorCorrectLevel(_level) ? _level : defaultErrorCorrectLevel;
var cells = QR.QrCode.encodeText(value, ErrorCorrectLevelMap[level]).getModules();
numCells.value = cells.length + margin * 2;
if (props.imageSettings.src) {
var imageSettings = getImageSettings(cells, props.size, margin, props.imageSettings);
imageProps = {
x: imageSettings.x + margin,
y: imageSettings.y + margin,
width: imageSettings.w,
height: imageSettings.h,
};
if (imageSettings.excavation) {
cells = excavateModules(cells, imageSettings.excavation);
}
}
// Drawing strategy: instead of a rect per module, we're going to create a
// single path for the dark modules and layer that on top of a light rect,
// for a total of 2 DOM nodes. We pay a bit more in string concat but that's
// way faster than DOM ops.
// For level 1, 441 nodes -> 2
// For level 40, 31329 -> 2
fgPath.value = generatePath(cells, margin);
};
var renderGradient = function () {
if (!props.gradient) if (!props.gradient)
return null; return null;
var gradientProps = props.gradientType === 'linear' var gradientProps = props.gradientType === 'linear'
@@ -1166,7 +1109,7 @@ var QrcodeSvg = vue.defineComponent({
fx: '50%', fx: '50%',
fy: '50%', fy: '50%',
}; };
return vue.h(props.gradientType === 'linear' ? 'linearGradient' : 'radialGradient', __assign({ id: qrGradientId }, gradientProps), [ return vue.h(props.gradientType === 'linear' ? 'linearGradient' : 'radialGradient', __assign({ id: 'qr-gradient' }, gradientProps), [
vue.h('stop', { vue.h('stop', {
offset: '0%', offset: '0%',
style: { stopColor: props.gradientStartColor }, style: { stopColor: props.gradientStartColor },
@@ -1176,52 +1119,27 @@ var QrcodeSvg = vue.defineComponent({
style: { stopColor: props.gradientEndColor }, style: { stopColor: props.gradientEndColor },
}), }),
]); ]);
}); };
var clipPathVNode = vue.computed(function () { generate();
if (!imageProps.value) vue.onUpdated(generate);
return null;
var borderRadius = imageProps.value.borderRadius;
if (borderRadius <= 0)
return null;
return vue.h('clipPath', { id: qrLogoClipPathId }, [
vue.h('rect', {
x: imageProps.value.x,
y: imageProps.value.y,
width: imageProps.value.width,
height: imageProps.value.height,
rx: borderRadius,
ry: borderRadius,
}),
]);
});
return function () { return vue.h('svg', { return function () { return vue.h('svg', {
width: props.size, width: props.size,
height: props.size, height: props.size,
'shape-rendering': "crispEdges",
xmlns: 'http://www.w3.org/2000/svg', xmlns: 'http://www.w3.org/2000/svg',
viewBox: "0 0 ".concat(numCells.value, " ").concat(numCells.value), viewBox: "0 0 ".concat(numCells.value, " ").concat(numCells.value),
role: 'img',
'aria-label': props.value,
}, [ }, [
vue.h('defs', {}, [gradientVNode.value, clipPathVNode.value]), vue.h('defs', {}, [renderGradient()]),
vue.h('rect', { vue.h('rect', {
width: '100%', width: '100%',
height: '100%', height: '100%',
fill: props.background, fill: props.background,
}), }),
vue.h('path', { vue.h('path', {
fill: props.gradient ? "url(#".concat(qrGradientId, ")") : props.foreground, fill: props.gradient ? 'url(#qr-gradient)' : props.foreground,
d: fgPath.value, d: fgPath.value,
}), }),
imageBorderProps.value && vue.h('rect', { props.imageSettings.src && vue.h('image', __assign({ href: props.imageSettings.src }, imageProps)),
x: imageBorderProps.value.x,
y: imageBorderProps.value.y,
width: imageBorderProps.value.width,
height: imageBorderProps.value.height,
fill: props.background,
rx: imageBorderProps.value.borderRadius,
ry: imageBorderProps.value.borderRadius,
}),
props.imageSettings.src && imageProps.value && vue.h('image', __assign(__assign({ href: props.imageSettings.src }, imageProps.value), (imageProps.value.borderRadius > 0 ? { 'clip-path': "url(#".concat(qrLogoClipPathId, ")") } : {}))),
]); }; ]); };
}, },
}); });
@@ -1229,89 +1147,81 @@ var QrcodeCanvas = vue.defineComponent({
name: 'QRCodeCanvas', name: 'QRCodeCanvas',
props: QRCodeProps, props: QRCodeProps,
setup: function (props, ctx) { setup: function (props, ctx) {
var _a = useQRCode(props), margin = _a.margin, cells = _a.cells, numCells = _a.numCells, fgPath = _a.fgPath, imageProps = _a.imageProps, imageBorderProps = _a.imageBorderProps;
var canvasEl = vue.ref(null); var canvasEl = vue.ref(null);
var imageEl = vue.ref(null); var imageRef = vue.ref(null);
var drawRoundedRect = function (ctx, x, y, width, height, radius) {
ctx.beginPath();
if (ctx.roundRect) {
ctx.roundRect(x, y, width, height, radius);
}
else {
ctx.rect(x, y, width, height);
}
};
var generate = function () { var generate = function () {
var size = props.size, background = props.background, foreground = props.foreground, gradient = props.gradient, gradientType = props.gradientType, gradientStartColor = props.gradientStartColor, gradientEndColor = props.gradientEndColor; var value = props.value, _level = props.level, size = props.size, _margin = props.margin, background = props.background, foreground = props.foreground, gradient = props.gradient, gradientType = props.gradientType, gradientStartColor = props.gradientStartColor, gradientEndColor = props.gradientEndColor;
var margin = _margin >>> 0;
var level = validErrorCorrectLevel(_level) ? _level : defaultErrorCorrectLevel;
var canvas = canvasEl.value; var canvas = canvasEl.value;
if (!canvas) { if (!canvas) {
return; return;
} }
var canvasCtx = canvas.getContext('2d'); var ctx = canvas.getContext('2d');
if (!canvasCtx) { if (!ctx) {
return; return;
} }
var image = imageEl.value; var cells = QR.QrCode.encodeText(value, ErrorCorrectLevelMap[level]).getModules();
var devicePixelRatio = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1; var numCells = cells.length + margin * 2;
var scale = (size / numCells.value) * devicePixelRatio; var image = imageRef.value;
var imageProps = { x: 0, y: 0, width: 0, height: 0 };
var showImage = props.imageSettings.src && image != null && image.naturalWidth !== 0 && image.naturalHeight !== 0;
if (showImage) {
var imageSettings = getImageSettings(cells, props.size, margin, props.imageSettings);
imageProps = {
x: imageSettings.x + margin,
y: imageSettings.y + margin,
width: imageSettings.w,
height: imageSettings.h,
};
if (imageSettings.excavation) {
cells = excavateModules(cells, imageSettings.excavation);
}
}
var devicePixelRatio = window.devicePixelRatio || 1;
var scale = (size / numCells) * devicePixelRatio;
canvas.height = canvas.width = size * devicePixelRatio; canvas.height = canvas.width = size * devicePixelRatio;
canvasCtx.setTransform(scale, 0, 0, scale, 0, 0); ctx.scale(scale, scale);
canvasCtx.fillStyle = background; ctx.fillStyle = background;
canvasCtx.fillRect(0, 0, numCells.value, numCells.value); ctx.fillRect(0, 0, numCells, numCells);
if (gradient) { if (gradient) {
var grad = void 0; var grad = void 0;
if (gradientType === 'linear') { if (gradientType === 'linear') {
grad = canvasCtx.createLinearGradient(0, 0, numCells.value, numCells.value); grad = ctx.createLinearGradient(0, 0, numCells, numCells);
} }
else { else {
grad = canvasCtx.createRadialGradient(numCells.value / 2, numCells.value / 2, 0, numCells.value / 2, numCells.value / 2, numCells.value / 2); grad = ctx.createRadialGradient(numCells / 2, numCells / 2, 0, numCells / 2, numCells / 2, numCells / 2);
} }
grad.addColorStop(0, gradientStartColor); grad.addColorStop(0, gradientStartColor);
grad.addColorStop(1, gradientEndColor); grad.addColorStop(1, gradientEndColor);
canvasCtx.fillStyle = grad; ctx.fillStyle = grad;
} }
else { else {
canvasCtx.fillStyle = foreground; ctx.fillStyle = foreground;
} }
if (SUPPORTS_PATH2D) { if (SUPPORTS_PATH2D) {
canvasCtx.fill(new Path2D(fgPath.value)); ctx.fill(new Path2D(generatePath(cells, margin)));
} }
else { else {
cells.value.forEach(function (row, rdx) { cells.forEach(function (row, rdx) {
row.forEach(function (cell, cdx) { row.forEach(function (cell, cdx) {
if (cell) { if (cell) {
canvasCtx.fillRect(cdx + margin.value, rdx + margin.value, 1, 1); ctx.fillRect(cdx + margin, rdx + margin, 1, 1);
} }
}); });
}); });
} }
var showImage = props.imageSettings.src && image && image.naturalWidth !== 0 && image.naturalHeight !== 0; if (showImage) {
if (showImage && imageProps.value) { ctx.drawImage(image, imageProps.x, imageProps.y, imageProps.width, imageProps.height);
if (imageBorderProps.value) {
var imageBorder = imageBorderProps.value;
canvasCtx.fillStyle = props.background;
drawRoundedRect(canvasCtx, imageBorder.x, imageBorder.y, imageBorder.width, imageBorder.height, imageBorder.borderRadius);
canvasCtx.fill();
}
var borderRadius = imageProps.value.borderRadius;
if (borderRadius > 0) {
canvasCtx.save();
drawRoundedRect(canvasCtx, imageProps.value.x, imageProps.value.y, imageProps.value.width, imageProps.value.height, borderRadius);
canvasCtx.clip();
canvasCtx.drawImage(image, imageProps.value.x, imageProps.value.y, imageProps.value.width, imageProps.value.height);
canvasCtx.restore();
}
else {
canvasCtx.drawImage(image, imageProps.value.x, imageProps.value.y, imageProps.value.width, imageProps.value.height);
}
} }
}; };
vue.onMounted(generate); vue.onMounted(generate);
vue.watchEffect(generate); vue.onUpdated(generate);
var style = ctx.attrs.style;
return function () { return vue.h(vue.Fragment, [ return function () { return vue.h(vue.Fragment, [
vue.h('canvas', __assign(__assign({}, ctx.attrs), { ref: canvasEl, role: 'img', 'aria-label': props.value, style: __assign(__assign({}, ctx.attrs.style), { width: "".concat(props.size, "px"), height: "".concat(props.size, "px") }) })), vue.h('canvas', __assign(__assign({}, ctx.attrs), { ref: canvasEl, style: __assign(__assign({}, style), { width: "".concat(props.size, "px"), height: "".concat(props.size, "px") }) })),
props.imageSettings.src && vue.h('img', { props.imageSettings.src && vue.h('img', {
ref: imageEl, ref: imageRef,
src: props.imageSettings.src, src: props.imageSettings.src,
style: { display: 'none' }, style: { display: 'none' },
onLoad: generate, onLoad: generate,
@@ -1321,23 +1231,23 @@ var QrcodeCanvas = vue.defineComponent({
}); });
var QrcodeVue = vue.defineComponent({ var QrcodeVue = vue.defineComponent({
name: 'Qrcode', name: 'Qrcode',
props: QRCodeVueProps, render: function () {
setup: function (props) { var _a = this.$props, renderAs = _a.renderAs, value = _a.value, size = _a.size, margin = _a.margin, level = _a.level, background = _a.background, foreground = _a.foreground, imageSettings = _a.imageSettings, gradient = _a.gradient, gradientType = _a.gradientType, gradientStartColor = _a.gradientStartColor, gradientEndColor = _a.gradientEndColor;
return function () { return vue.h(props.renderAs === 'svg' ? QrcodeSvg : QrcodeCanvas, { return vue.h(renderAs === 'svg' ? QrcodeSvg : QrcodeCanvas, {
value: props.value, value: value,
size: props.size, size: size,
margin: props.margin, margin: margin,
level: props.level, level: level,
background: props.background, background: background,
foreground: props.foreground, foreground: foreground,
imageSettings: props.imageSettings, imageSettings: imageSettings,
gradient: props.gradient, gradient: gradient,
gradientType: props.gradientType, gradientType: gradientType,
gradientStartColor: props.gradientStartColor, gradientStartColor: gradientStartColor,
gradientEndColor: props.gradientEndColor, gradientEndColor: gradientEndColor,
radius: props.radius, });
}); };
}, },
props: QRCodeVueProps,
}); });
exports.QrcodeCanvas = QrcodeCanvas; exports.QrcodeCanvas = QrcodeCanvas;
+28 -15
View File
@@ -43,7 +43,8 @@ summary {
abbr[title] { abbr[title] {
border-bottom: none; border-bottom: none;
text-decoration: underline; text-decoration: underline;
text-decoration: underline dotted; -webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
} }
/** /**
@@ -199,7 +200,8 @@ input[type=search]::-webkit-search-decoration {
.material-symbols-outlined, .material-symbols-outlined,
.material-symbols-rounded, .material-symbols-rounded,
.material-symbols-sharp { .material-symbols-sharp {
user-select: none; -webkit-user-select: none;
user-select: none;
cursor: inherit; cursor: inherit;
font-size: inherit; font-size: inherit;
display: inline-flex; display: inline-flex;
@@ -996,7 +998,8 @@ input[type=search]::-webkit-search-decoration {
height: 1px; height: 1px;
} }
.q-checkbox__bg, .q-checkbox__icon-container { .q-checkbox__bg, .q-checkbox__icon-container {
user-select: none; -webkit-user-select: none;
user-select: none;
} }
.q-checkbox__bg { .q-checkbox__bg {
top: 25%; top: 25%;
@@ -2209,7 +2212,8 @@ body.q-ios-padding .q-dialog__inner > div {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
outline: 0 !important; outline: 0 !important;
user-select: auto; -webkit-user-select: auto;
user-select: auto;
} }
.q-field__native:-webkit-autofill, .q-field__input:-webkit-autofill { .q-field__native:-webkit-autofill, .q-field__input:-webkit-autofill {
-webkit-animation-name: q-autofill; -webkit-animation-name: q-autofill;
@@ -3035,7 +3039,8 @@ body.body--dark .q-knob--editable:focus:before {
z-index: 2001; z-index: 2001;
height: 100%; height: 100%;
width: 15px; width: 15px;
user-select: none; -webkit-user-select: none;
user-select: none;
} }
.q-layout, .q-header, .q-footer, .q-page { .q-layout, .q-header, .q-footer, .q-page {
@@ -3292,7 +3297,8 @@ body.platform-ios .q-layout--containerized {
height: 1px; height: 1px;
} }
.q-radio__bg, .q-radio__icon-container { .q-radio__bg, .q-radio__icon-container {
user-select: none; -webkit-user-select: none;
user-select: none;
} }
.q-radio__bg { .q-radio__bg {
top: 25%; top: 25%;
@@ -3776,7 +3782,8 @@ body.platform-ios:not(.native-mobile) .q-dialog__inner--top .q-select__dialog--f
.q-slide-item__content { .q-slide-item__content {
background: inherit; background: inherit;
transition: transform 0.2s ease-in; transition: transform 0.2s ease-in;
user-select: none; -webkit-user-select: none;
user-select: none;
cursor: pointer; cursor: pointer;
} }
@@ -4149,7 +4156,8 @@ body.desktop .q-slider.q-slider--enabled .q-slider__track-container:hover .q-sli
} }
.q-splitter__separator { .q-splitter__separator {
background-color: rgba(0, 0, 0, 0.12); background-color: rgba(0, 0, 0, 0.12);
user-select: none; -webkit-user-select: none;
user-select: none;
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
@@ -4238,7 +4246,8 @@ body.desktop .q-slider.q-slider--enabled .q-slider__track-container:hover .q-sli
color: #000; color: #000;
} }
.q-stepper__tab--navigation { .q-stepper__tab--navigation {
user-select: none; -webkit-user-select: none;
user-select: none;
cursor: pointer; cursor: pointer;
} }
.q-stepper__tab--active, .q-stepper__tab--done { .q-stepper__tab--active, .q-stepper__tab--done {
@@ -4470,7 +4479,8 @@ body.desktop .q-slider.q-slider--enabled .q-slider__track-container:hover .q-sli
.q-table th { .q-table th {
font-weight: 500; font-weight: 500;
font-size: 12px; font-size: 12px;
user-select: none; -webkit-user-select: none;
user-select: none;
} }
.q-table th.sortable { .q-table th.sortable {
cursor: pointer; cursor: pointer;
@@ -5474,7 +5484,8 @@ body.desktop .q-table > tbody > tr:not(.q-tr--no-hover):hover > td:not(.q-td--no
width: 0.5em; width: 0.5em;
height: 0.5em; height: 0.5em;
transition: left 0.22s cubic-bezier(0.4, 0, 0.2, 1); transition: left 0.22s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none; -webkit-user-select: none;
user-select: none;
z-index: 0; z-index: 0;
} }
.q-toggle__thumb:after { .q-toggle__thumb:after {
@@ -10326,7 +10337,6 @@ body.body--dark .inset-shadow-down {
.glossy { .glossy {
background-image: linear-gradient(to bottom, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0) 50%, rgba(0, 0, 0, 0.12) 51%, rgba(0, 0, 0, 0.04)) !important; background-image: linear-gradient(to bottom, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0) 50%, rgba(0, 0, 0, 0.12) 51%, rgba(0, 0, 0, 0.04)) !important;
} }
.q-placeholder::placeholder { .q-placeholder::placeholder {
color: inherit; color: inherit;
opacity: 0.7; opacity: 0.7;
@@ -10358,7 +10368,8 @@ body.body--dark .inset-shadow-down {
text-decoration: none; text-decoration: none;
} }
.q-link--focusable:focus-visible { .q-link--focusable:focus-visible {
text-decoration: underline dashed currentColor 1px; -webkit-text-decoration: underline dashed currentColor 1px;
text-decoration: underline dashed currentColor 1px;
} }
body.electron .q-electron-drag { body.electron .q-electron-drag {
@@ -10375,7 +10386,8 @@ img.responsive {
} }
.non-selectable { .non-selectable {
user-select: none !important; -webkit-user-select: none !important;
user-select: none !important;
} }
.scroll, .scroll,
@@ -11033,7 +11045,8 @@ body.q-ios-padding .fullscreen {
} }
.q-touch { .q-touch {
user-select: none; -webkit-user-select: none;
user-select: none;
user-drag: none; user-drag: none;
-khtml-user-drag: none; -khtml-user-drag: none;
-webkit-user-drag: none; -webkit-user-drag: none;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+3 -6
View File
@@ -6,10 +6,7 @@ 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 ( from lnbits.core.services.payments import update_invoice_callback
get_standalone_payment,
update_invoice_from_paid_invoices_stream,
)
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.wallets import get_funding_source from lnbits.wallets import get_funding_source
@@ -115,7 +112,7 @@ async def internal_invoice_listener() -> None:
while settings.lnbits_running: while settings.lnbits_running:
checking_id = await internal_invoice_queue.get() checking_id = await internal_invoice_queue.get()
logger.info(f"got an internal payment notification {checking_id}") logger.info(f"got an internal payment notification {checking_id}")
payment = await get_standalone_payment(checking_id, incoming=True) payment = await update_invoice_callback(checking_id)
if payment: if payment:
logger.success(f"internal invoice {checking_id} settled") logger.success(f"internal invoice {checking_id} settled")
await invoice_callback_dispatcher(payment) await invoice_callback_dispatcher(payment)
@@ -131,7 +128,7 @@ async def invoice_listener() -> None:
funding_source = get_funding_source() funding_source = get_funding_source()
async for checking_id in funding_source.paid_invoices_stream(): async for checking_id in funding_source.paid_invoices_stream():
logger.info(f"got a payment notification {checking_id}") logger.info(f"got a payment notification {checking_id}")
payment = await update_invoice_from_paid_invoices_stream(checking_id) payment = await update_invoice_callback(checking_id)
if payment: if payment:
logger.success(f"fundingsource invoice {checking_id} settled") logger.success(f"fundingsource invoice {checking_id} settled")
await invoice_callback_dispatcher(payment) await invoice_callback_dispatcher(payment)
+6 -37
View File
@@ -1,5 +1,4 @@
{% include('components/admin/funding_seed_backup.vue') %} {% {% include('components/admin/funding.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') %} {%
@@ -775,13 +774,7 @@ include('components/lnbits-error.vue') %}
v-model="password" v-model="password"
name="password" name="password"
:label="$t('password') + ' *'" :label="$t('password') + ' *'"
:type="showPwd ? 'text' : 'password'" type="password"
><template v-slot:append>
<q-icon
:name="showPwd ? 'visibility' : 'visibility_off'"
class="cursor-pointer"
@click="showPwd = !showPwd"
/> </template
></q-input> ></q-input>
<div class="row justify-end"> <div class="row justify-end">
<q-btn <q-btn
@@ -810,28 +803,16 @@ include('components/lnbits-error.vue') %}
filled filled
v-model="password" v-model="password"
:label="$t('password') + ' *'" :label="$t('password') + ' *'"
:type="showPwd ? 'text' : 'password'" type="password"
:rules="[val => !val || val.length >= 8 || $t('invalid_password')]" :rules="[val => !val || val.length >= 8 || $t('invalid_password')]"
><template v-slot:append>
<q-icon
:name="showPwd ? 'visibility' : 'visibility_off'"
class="cursor-pointer"
@click="showPwd = !showPwd"
/> </template
></q-input> ></q-input>
<q-input <q-input
dense dense
filled filled
v-model="passwordRepeat" v-model="passwordRepeat"
:label="$t('password_repeat') + ' *'" :label="$t('password_repeat') + ' *'"
:type="showPwdRepeat ? 'text' : 'password'" type="password"
:rules="[val => !val || val.length >= 8 || $t('invalid_password')]" :rules="[val => !val || val.length >= 8 || $t('invalid_password')]"
><template v-slot:append>
<q-icon
:name="showPwdRepeat ? 'visibility' : 'visibility_off'"
class="cursor-pointer"
@click="showPwdRepeat = !showPwdRepeat"
/> </template
></q-input> ></q-input>
<div <div
v-if="confirmationMethodsCount > 1" v-if="confirmationMethodsCount > 1"
@@ -944,28 +925,16 @@ include('components/lnbits-error.vue') %}
filled filled
v-model="password" v-model="password"
:label="$t('password') + ' *'" :label="$t('password') + ' *'"
:type="showPwd ? 'text' : 'password'" type="password"
:rules="[val => !val || val.length >= 8 || $t('invalid_password')]" :rules="[val => !val || val.length >= 8 || $t('invalid_password')]"
><template v-slot:append>
<q-icon
:name="showPwd ? 'visibility' : 'visibility_off'"
class="cursor-pointer"
@click="showPwd = !showPwd"
/> </template
></q-input> ></q-input>
<q-input <q-input
dense dense
filled filled
v-model="passwordRepeat" v-model="passwordRepeat"
:label="$t('password_repeat') + ' *'" :label="$t('password_repeat') + ' *'"
:type="showPwdRepeat ? 'text' : 'password'" type="password"
:rules="[val => !val || val.length >= 8 || $t('invalid_password')]" :rules="[val => !val || val.length >= 8 || $t('invalid_password')]"
><template v-slot:append>
<q-icon
:name="showPwdRepeat ? 'visibility' : 'visibility_off'"
class="cursor-pointer"
@click="showPwdRepeat = !showPwdRepeat"
/> </template
></q-input> ></q-input>
<div class="row justify-end"> <div class="row justify-end">
<q-btn <q-btn
@@ -1,46 +1,7 @@
<template id="lnbits-admin-exchange-providers"> <template id="lnbits-admin-exchange-providers">
<h6 class="q-my-none q-mb-xs">LNbits Price Aggregator</h6> <h6 class="q-my-none q-mb-sm">
<p class="q-mb-md text-caption text-grey"> <span v-text="$t('exchange_providers')"></span>
A privacy-friendly, open-source Bitcoin price aggregator maintained by the </h6>
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">
@@ -92,11 +53,6 @@
</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
@@ -104,7 +60,6 @@
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>
@@ -115,20 +70,12 @@
: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 <div class="overflow-auto">
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"
@@ -866,7 +866,6 @@
</q-expansion-item> </q-expansion-item>
</q-card> </q-card>
</q-expansion-item> </q-expansion-item>
<q-separator></q-separator>
<q-expansion-item header-class="text-primary text-bold"> <q-expansion-item header-class="text-primary text-bold">
<template v-slot:header> <template v-slot:header>
<q-item-section avatar> <q-item-section avatar>
@@ -1176,8 +1175,8 @@
<q-chip dense color="positive" text-color="white" icon="check" <q-chip dense color="positive" text-color="white" icon="check"
>Checkout</q-chip >Checkout</q-chip
> >
<q-chip dense color="warning" text-color="black" icon="schedule" <q-chip dense color="positive" text-color="white" icon="check"
>Subscriptions coming soon</q-chip >Subscriptions</q-chip
> >
<q-chip dense color="negative" text-color="white" icon="close" <q-chip dense color="negative" text-color="white" icon="close"
>Tap-to-pay</q-chip >Tap-to-pay</q-chip
@@ -301,11 +301,5 @@
</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>
@@ -1,136 +0,0 @@
<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>
@@ -320,15 +320,6 @@
> >
</q-toggle> </q-toggle>
</div> </div>
<div class="col-12 col-sm-6 col-lg-2">
<q-toggle
type="bool"
v-model="formData.lnbits_default_burger_menu_background"
color="primary"
:label="$t('burger_menu_background')"
>
</q-toggle>
</div>
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
@@ -6,9 +6,6 @@
: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>
-24
View File
@@ -601,30 +601,6 @@
</div> </div>
</div> </div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('burger_menu_background')"></span>
</div>
<div class="col-8">
<q-toggle
dense
flat
round
icon="menu_open"
v-model="g.burgerMenuChoice"
@update:model-value="
siteCustomisationChanged({burgerMenuChoice: $event})
"
>
<q-tooltip
><span
v-text="$t('toggle_burger_menu_background')"
></span
></q-tooltip>
</q-toggle>
</div>
</div>
<div class="row q-mb-md"> <div class="row q-mb-md">
<div class="col-4"> <div class="col-4">
<span v-text="$t('toggle_darkmode')"></span> <span v-text="$t('toggle_darkmode')"></span>
-1
View File
@@ -199,7 +199,6 @@
> >
<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"
+1 -23
View File
@@ -7,29 +7,7 @@
<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 <q-tab name="featured" :label="$t('featured')"></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')"
+1 -1
View File
@@ -136,7 +136,7 @@
</q-card> </q-card>
</div> </div>
<div <div
v-show="chartData.showPaymentTags" v-show="chartData.showPaymentStatus"
class="col-lg-3 col-md-6 col-sm-12 text-center" class="col-lg-3 col-md-6 col-sm-12 text-center"
> >
<q-card class="q-pt-sm"> <q-card class="q-pt-sm">
-439
View File
@@ -1,439 +0,0 @@
"""
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,32 +289,7 @@ 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.")
+3 -3
View File
@@ -94,15 +94,15 @@ class PaymentStatus(NamedTuple):
class PaymentSuccessStatus(PaymentStatus): class PaymentSuccessStatus(PaymentStatus):
paid = True # type: ignore[reportIncompatibleVariableOverride] paid = True
class PaymentFailedStatus(PaymentStatus): class PaymentFailedStatus(PaymentStatus):
paid = False # type: ignore[reportIncompatibleVariableOverride] paid = False
class PaymentPendingStatus(PaymentStatus): class PaymentPendingStatus(PaymentStatus):
paid = None # type: ignore[reportIncompatibleVariableOverride] paid = None
class Wallet(ABC): class Wallet(ABC):
+7 -5
View File
@@ -8,7 +8,7 @@ from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel
from websockets import Subprotocol, connect from websockets import Subprotocol, connect
from lnbits import bolt11 as bolt11_lib from lnbits import bolt11
from lnbits.helpers import normalize_endpoint from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings from lnbits.settings import settings
@@ -164,13 +164,15 @@ class BlinkWallet(Wallet):
ok=False, error_message=f"Unable to connect to {self.endpoint}." ok=False, error_message=f"Unable to connect to {self.endpoint}."
) )
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: async def pay_invoice(
self, bolt11_invoice: str, fee_limit_msat: int
) -> PaymentResponse:
# https://dev.blink.sv/api/btc-ln-send # https://dev.blink.sv/api/btc-ln-send
# Future: add check fee estimate is < fee_limit_msat before paying invoice # Future: add check fee estimate is < fee_limit_msat before paying invoice
payment_variables = { payment_variables = {
"input": { "input": {
"paymentRequest": bolt11, "paymentRequest": bolt11_invoice,
"walletId": self.wallet_id, "walletId": self.wallet_id,
"memo": "Payment memo", "memo": "Payment memo",
} }
@@ -188,7 +190,7 @@ class BlinkWallet(Wallet):
error_message = errors[0].get("message") error_message = errors[0].get("message")
return PaymentResponse(ok=False, error_message=error_message) return PaymentResponse(ok=False, error_message=error_message)
checking_id = bolt11_lib.decode(bolt11).payment_hash checking_id = bolt11.decode(bolt11_invoice).payment_hash
payment_status = await self.get_payment_status(checking_id) payment_status = await self.get_payment_status(checking_id)
fee_msat = payment_status.fee_msat fee_msat = payment_status.fee_msat
@@ -197,7 +199,7 @@ class BlinkWallet(Wallet):
ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage
) )
except Exception as exc: except Exception as exc:
logger.info(f"Failed to pay invoice {bolt11}") logger.info(f"Failed to pay invoice {bolt11_invoice}")
logger.warning(exc) logger.warning(exc)
return PaymentResponse( return PaymentResponse(
error_message=f"Unable to connect to {self.endpoint}." error_message=f"Unable to connect to {self.endpoint}."
+2 -4
View File
@@ -19,7 +19,7 @@ else:
from bolt11 import Bolt11Exception from bolt11 import Bolt11Exception
from bolt11 import decode as bolt11_decode from bolt11 import decode as bolt11_decode
from breez_sdk import ( # type: ignore[reportMissingImports] from breez_sdk import (
BreezEvent, BreezEvent,
ConnectRequest, ConnectRequest,
EnvironmentType, EnvironmentType,
@@ -39,9 +39,7 @@ else:
default_config, default_config,
mnemonic_to_seed, mnemonic_to_seed,
) )
from breez_sdk import ( from breez_sdk import PaymentStatus as BreezPaymentStatus
PaymentStatus as BreezPaymentStatus, # type: ignore[reportMissingImports]
)
from loguru import logger from loguru import logger
from lnbits.settings import settings from lnbits.settings import settings
+1 -1
View File
@@ -18,7 +18,7 @@ else:
from pathlib import Path from pathlib import Path
from bolt11 import decode as bolt11_decode from bolt11 import decode as bolt11_decode
from breez_sdk_liquid import ( # type: ignore[reportMissingImports] from breez_sdk_liquid import (
ConnectRequest, ConnectRequest,
EventListener, EventListener,
GetInfoResponse, GetInfoResponse,
+2 -2
View File
@@ -103,7 +103,7 @@ class FakeWallet(Wallet):
preimage=preimage.hex(), preimage=preimage.hex(),
) )
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: async def pay_invoice(self, bolt11: str, _: int) -> PaymentResponse:
try: try:
invoice = decode(bolt11) invoice = decode(bolt11)
except Bolt11Exception as exc: except Bolt11Exception as exc:
@@ -130,7 +130,7 @@ class FakeWallet(Wallet):
return PaymentPendingStatus() return PaymentPendingStatus()
return PaymentFailedStatus() return PaymentFailedStatus()
async def get_payment_status(self, checking_id: str) -> PaymentStatus: async def get_payment_status(self, _: str) -> PaymentStatus:
return PaymentPendingStatus() return PaymentPendingStatus()
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
+1 -3
View File
@@ -118,7 +118,7 @@ class LndWallet(Wallet):
cert = open(cert_path, "rb").read() cert = open(cert_path, "rb").read()
creds = grpc.ssl_channel_credentials(cert) creds = grpc.ssl_channel_credentials(cert)
auth_creds = grpc.metadata_call_credentials(self.metadata_callback) # type: ignore[reportArgumentType] auth_creds = grpc.metadata_call_credentials(self.metadata_callback)
composite_creds = grpc.composite_channel_credentials(creds, auth_creds) composite_creds = grpc.composite_channel_credentials(creds, auth_creds)
channel = grpc.aio.secure_channel( channel = grpc.aio.secure_channel(
f"{self.endpoint}:{self.port}", composite_creds f"{self.endpoint}:{self.port}", composite_creds
@@ -192,8 +192,6 @@ 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()
+40 -41
View File
@@ -3,7 +3,6 @@ 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
@@ -124,8 +123,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:
@@ -133,7 +132,34 @@ class LndRestWallet(Wallet):
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
return self._parse_create_invoice_response(r, data, preimage) if len(data) == 0:
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'"
@@ -216,13 +242,12 @@ 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("state") is None: if r.is_error or data.get("settled") 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 "state" attribute on the object # it will return an error and no "settled" attribute on the object
logger.warning(f"Error checking invoice from LND REST API: {r.text}")
return PaymentPendingStatus() return PaymentPendingStatus()
if data.get("state") == "SETTLED": if data.get("settled") is True:
return PaymentSuccessStatus() return PaymentSuccessStatus()
if data.get("state") == "CANCELED": if data.get("state") == "CANCELED":
@@ -239,11 +264,10 @@ 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=30) as r: async with self.client.stream("GET", url, timeout=None) 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)
@@ -274,7 +298,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}")
continue return PaymentPendingStatus()
logger.info(f"LNDRest Payment non-existent: {checking_id}") logger.info(f"LNDRest Payment non-existent: {checking_id}")
return PaymentPendingStatus() return PaymentPendingStatus()
@@ -287,12 +311,13 @@ 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.get("state") == "SETTLED": if not inv["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(
@@ -338,6 +363,8 @@ 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
) )
@@ -372,31 +399,3 @@ 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,
)
+132 -921
View File
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -117,12 +117,11 @@ 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 or "" desc = memo
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:
+198 -673
View File
File diff suppressed because it is too large Load Diff
+11 -12
View File
@@ -15,24 +15,24 @@
"devDependencies": { "devDependencies": {
"clean-css-cli": "^5.6.3", "clean-css-cli": "^5.6.3",
"concat": "^1.0.3", "concat": "^1.0.3",
"prettier": "^3.8.3", "prettier": "^3.7.4",
"pyright": "1.1.409", "pyright": "1.1.289",
"sass": "^1.99.0", "sass": "^1.94.2",
"terser": "^5.47.1" "terser": "^5.44.1"
}, },
"dependencies": { "dependencies": {
"axios": "^1.16.0", "axios": "^1.15.0",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"moment": "^2.30.1", "moment": "^2.30.1",
"nostr-tools": "^2.23.3", "nostr-tools": "^2.18.2",
"qrcode.vue": "^3.9.0", "qrcode.vue": "^3.6.0",
"quasar": "2.19.3", "quasar": "2.18.6",
"showdown": "^2.1.0", "showdown": "^2.1.0",
"underscore": "^1.13.8", "underscore": "^1.13.8",
"vue": "3.5.34", "vue": "3.5.25",
"vue-i18n": "^11.4.2", "vue-i18n": "^11.2.2",
"vue-qrcode-reader": "^5.7.3", "vue-qrcode-reader": "^5.7.3",
"vue-router": "5.0.6", "vue-router": "4.6.3",
"vuex": "4.1.0" "vuex": "4.1.0"
}, },
"vendor": [ "vendor": [
@@ -111,7 +111,6 @@
"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",
Generated
+10 -23
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. # This file is automatically @generated by Poetry 2.4.0 and should not be changed by hand.
[[package]] [[package]]
name = "aiohappyeyeballs" name = "aiohappyeyeballs"
@@ -665,19 +665,6 @@ bitstring = "*"
click = "*" click = "*"
coincurve = "*" coincurve = "*"
[[package]]
name = "boltz-client"
version = "0.4.0"
description = "Boltz Swap library"
optional = true
python-versions = ">=3.10"
groups = ["main"]
markers = "extra == \"liquid\""
files = [
{file = "boltz_client-0.4.0-py3-none-manylinux_2_34_x86_64.whl", hash = "sha256:ed0b520209cf1b05a8523002f5d8f26fa29c04d2faecc9cbde3621b4ddc417e6"},
{file = "boltz_client-0.4.0.tar.gz", hash = "sha256:a3f5a6b637350267856e3ab680cd92158de720fa2d5805fc075e4583d020cb2a"},
]
[[package]] [[package]]
name = "breez-sdk" name = "breez-sdk"
version = "0.8.0" version = "0.8.0"
@@ -2147,7 +2134,7 @@ files = [
[package.dependencies] [package.dependencies]
attrs = ">=22.2.0" attrs = ">=22.2.0"
jsonschema-specifications = ">=2023.03.6" jsonschema-specifications = ">=2023.3.6"
referencing = ">=0.28.4" referencing = ">=0.28.4"
rpds-py = ">=0.25.0" rpds-py = ">=0.25.0"
@@ -2308,7 +2295,7 @@ colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""}
win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""}
[package.extras] [package.extras]
dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==v0.910) ; python_version < \"3.6\"", "mypy (==v0.971) ; python_version == \"3.6\"", "mypy (==v1.13.0) ; python_version >= \"3.8\"", "mypy (==v1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""] dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==0.910) ; python_version < \"3.6\"", "mypy (==0.971) ; python_version == \"3.6\"", "mypy (==1.13.0) ; python_version >= \"3.8\"", "mypy (==1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""]
[[package]] [[package]]
name = "markdown-it-py" name = "markdown-it-py"
@@ -3407,7 +3394,7 @@ version = "5.1.2"
description = "Call stack profiler for Python. Shows you why your code is slow!" description = "Call stack profiler for Python. Shows you why your code is slow!"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "pyinstrument-5.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f224fe80ba288a00980af298d3808219f9d246fd95b4f91729c9c33a0dc54fe6"}, {file = "pyinstrument-5.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f224fe80ba288a00980af298d3808219f9d246fd95b4f91729c9c33a0dc54fe6"},
{file = "pyinstrument-5.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7df09fc0d5b72daf48b73cdf07738761bff7f656c81aff686b3ccdd7d2abe236"}, {file = "pyinstrument-5.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7df09fc0d5b72daf48b73cdf07738761bff7f656c81aff686b3ccdd7d2abe236"},
@@ -4573,14 +4560,14 @@ files = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.7.0" version = "2.6.3"
description = "HTTP library with thread-safe connection pooling, file post, and more." description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.9"
groups = ["main", "dev"] groups = ["main", "dev"]
files = [ files = [
{file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"},
{file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"},
] ]
[package.extras] [package.extras]
@@ -5117,10 +5104,10 @@ propcache = ">=0.2.1"
[extras] [extras]
breez = ["breez-sdk", "breez-sdk-liquid"] breez = ["breez-sdk", "breez-sdk-liquid"]
liquid = ["boltz-client", "wallycore"] liquid = ["wallycore"]
migration = ["psycopg2-binary"] migration = ["psycopg2-binary"]
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.10,<3.13" python-versions = ">=3.10,<3.13"
content-hash = "7c70bdad0089089d383cf8f54c37c4473180cea175689b91322beaed1ab42e94" content-hash = "0879a6230bbc0d0e1d8d7d090e1572ba863078b18e0d78478baf2100aa245e08"
+3 -7
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "lnbits" name = "lnbits"
version = "1.5.6" version = "1.5.4"
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" }]
@@ -51,8 +51,6 @@ dependencies = [
"pillow~=12.1.0", "pillow~=12.1.0",
"python-dotenv~=1.2.1", "python-dotenv~=1.2.1",
"greenlet~=3.3.0", "greenlet~=3.3.0",
"urllib3>=2.7.0",
"pyinstrument>=5.1.2",
] ]
[project.scripts] [project.scripts]
@@ -61,7 +59,7 @@ lnbits-cli = "lnbits.commands:main"
[project.optional-dependencies] [project.optional-dependencies]
breez = ["breez-sdk~=0.8.0", "breez-sdk-liquid~=0.11.11"] breez = ["breez-sdk~=0.8.0", "breez-sdk-liquid~=0.11.11"]
liquid = ["wallycore~=1.5.1", "boltz-client==0.4.0"] liquid = ["wallycore~=1.5.1"]
migration = ["psycopg2-binary~=2.9.11"] migration = ["psycopg2-binary~=2.9.11"]
[dependency-groups] [dependency-groups]
@@ -85,11 +83,9 @@ dev = [
"types-mock~=5.2.0.20250924", "types-mock~=5.2.0.20250924",
"mock~=5.2.0", "mock~=5.2.0",
"grpcio-tools~=1.76.0", "grpcio-tools~=1.76.0",
"pyinstrument>=5.1.2",
] ]
[tool.uv]
exclude-newer = "1 week"
[tool.poetry] [tool.poetry]
packages = [ packages = [
{include = "lnbits"}, {include = "lnbits"},
-27
View File
@@ -3,7 +3,6 @@ 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
@@ -151,15 +150,6 @@ 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",
@@ -167,21 +157,4 @@ 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()
+9 -60
View File
@@ -13,15 +13,14 @@ async def test_asset_api_upload_list_update_and_delete(
client: AsyncClient, client: AsyncClient,
user_headers_from: dict[str, str], user_headers_from: dict[str, str],
): ):
payload = get_png_bytes()
upload = await client.post( upload = await client.post(
"/api/v1/assets?public_asset=false", "/api/v1/assets?public_asset=false",
headers={"Authorization": user_headers_from["Authorization"]}, headers={"Authorization": user_headers_from["Authorization"]},
files={"file": ("note.png", payload, "image/png")}, files={"file": ("note.txt", b"hello world", "text/plain")},
) )
assert upload.status_code == 200 assert upload.status_code == 200
asset = upload.json() asset = upload.json()
assert asset["name"] == "note.png" assert asset["name"] == "note.txt"
assert asset["is_public"] is False assert asset["is_public"] is False
page = await client.get("/api/v1/assets/paginated", headers=user_headers_from) page = await client.get("/api/v1/assets/paginated", headers=user_headers_from)
@@ -30,30 +29,27 @@ async def test_asset_api_upload_list_update_and_delete(
info = await client.get(f"/api/v1/assets/{asset['id']}", headers=user_headers_from) info = await client.get(f"/api/v1/assets/{asset['id']}", headers=user_headers_from)
assert info.status_code == 200 assert info.status_code == 200
assert info.json()["name"] == "note.png" assert info.json()["name"] == "note.txt"
data = await client.get( data = await client.get(
f"/api/v1/assets/{asset['id']}/data", headers=user_headers_from f"/api/v1/assets/{asset['id']}/data", headers=user_headers_from
) )
assert data.status_code == 200 assert data.status_code == 200
assert data.content == payload assert data.content == b"hello world"
assert data.headers["content-type"] == "image/png" assert data.headers["content-disposition"] == 'inline; filename="note.txt"'
assert data.headers["content-disposition"] == 'inline; filename="note.png"'
assert data.headers["x-content-type-options"] == "nosniff"
assert data.headers["content-security-policy"].startswith("sandbox")
updated = await client.put( updated = await client.put(
f"/api/v1/assets/{asset['id']}", f"/api/v1/assets/{asset['id']}",
headers=user_headers_from, headers=user_headers_from,
json={"name": "renamed.png", "is_public": True}, json={"name": "renamed.txt", "is_public": True},
) )
assert updated.status_code == 200 assert updated.status_code == 200
assert updated.json()["name"] == "renamed.png" assert updated.json()["name"] == "renamed.txt"
assert updated.json()["is_public"] is True assert updated.json()["is_public"] is True
public_data = await client.get(f"/api/v1/assets/{asset['id']}/data") public_data = await client.get(f"/api/v1/assets/{asset['id']}/data")
assert public_data.status_code == 200 assert public_data.status_code == 200
assert public_data.content == payload assert public_data.content == b"hello world"
deleted = await client.delete( deleted = await client.delete(
f"/api/v1/assets/{asset['id']}", headers=user_headers_from f"/api/v1/assets/{asset['id']}", headers=user_headers_from
@@ -102,51 +98,15 @@ async def test_asset_api_enforces_visibility_and_supports_admin_updates(
assert admin_updated.json()["is_public"] is True assert admin_updated.json()["is_public"] is True
assert admin_updated.json()["name"] == "admin-visible.png" assert admin_updated.json()["name"] == "admin-visible.png"
image_data = await client.get(f"/api/v1/assets/{private_asset.id}/data")
assert image_data.status_code == 200
assert image_data.headers["content-type"] == "image/png"
assert image_data.headers["content-disposition"] == (
'inline; filename="admin-visible.png"'
)
assert image_data.headers["x-content-type-options"] == "nosniff"
thumbnail = await client.get(f"/api/v1/assets/{private_asset.id}/thumbnail") thumbnail = await client.get(f"/api/v1/assets/{private_asset.id}/thumbnail")
assert thumbnail.status_code == 200 assert thumbnail.status_code == 200
assert thumbnail.content assert thumbnail.content
assert thumbnail.headers["content-type"] == "image/png" assert thumbnail.headers["content-type"] == "image/png"
assert thumbnail.headers["content-disposition"] == (
'inline; filename="admin-visible.png"'
)
@pytest.mark.anyio
async def test_asset_api_blocks_non_image_uploads(
client: AsyncClient,
user_headers_from: dict[str, str],
):
payload = (
b'<?xml version="1.0"?>'
b'<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform">'
b'<xsl:template match="/">'
b"<script>alert(1)</script>"
b"</xsl:template>"
b"</xsl:stylesheet>"
)
blocked = await client.post(
"/api/v1/assets",
headers={"Authorization": user_headers_from["Authorization"]},
files={"file": ("payload.xsl", payload, "text/xml")},
)
assert blocked.status_code == 400
assert blocked.json()["detail"] == "File type 'text/xml' not allowed."
@pytest.mark.anyio @pytest.mark.anyio
async def test_asset_api_validates_uploads_and_missing_assets( async def test_asset_api_validates_uploads_and_missing_assets(
client: AsyncClient, client: AsyncClient,
to_user,
user_headers_from: dict[str, str], user_headers_from: dict[str, str],
): ):
invalid = await client.post( invalid = await client.post(
@@ -157,15 +117,6 @@ async def test_asset_api_validates_uploads_and_missing_assets(
assert invalid.status_code == 400 assert invalid.status_code == 400
assert "not allowed" in invalid.json()["detail"] assert "not allowed" in invalid.json()["detail"]
fake_image_headers = await get_user_token_headers(client, to_user.id)
fake_image = await client.post(
"/api/v1/assets",
headers={"Authorization": fake_image_headers["Authorization"]},
files={"file": ("fake.png", b"<root></root>", "image/png")},
)
assert fake_image.status_code == 400
assert "does not match declared file type" in fake_image.json()["detail"]
missing = await client.delete( missing = await client.delete(
f"/api/v1/assets/{uuid4().hex}", f"/api/v1/assets/{uuid4().hex}",
headers=user_headers_from, headers=user_headers_from,
@@ -177,9 +128,7 @@ async def test_asset_api_validates_uploads_and_missing_assets(
stored = await create_user_asset( stored = await create_user_asset(
"missing-user-check", "missing-user-check",
make_upload_file( make_upload_file(b"content", filename="content.txt", content_type="text/plain"),
get_png_bytes(), filename="content.png", content_type="image/png"
),
is_public=True, is_public=True,
) )
fetched = await get_user_asset("missing-user-check", stored.id) fetched = await get_user_asset("missing-user-check", stored.id)
+1 -35
View File
@@ -72,42 +72,8 @@ async def test_auth_api_sso_login_and_callback(http_client: AsyncClient, mocker)
login_sso = _FakeSSO() login_sso = _FakeSSO()
mocker.patch("lnbits.core.views.auth_api._new_sso", return_value=login_sso) mocker.patch("lnbits.core.views.auth_api._new_sso", return_value=login_sso)
unauthenticated = await http_client.get(
f"/api/v1/auth/{provider}", params={"user_id": user.id}
)
assert unauthenticated.status_code == 403
assert unauthenticated.json()["detail"] == "User ID mismatch."
other_user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
other_login = await http_client.post(
"/api/v1/auth/usr", json={"usr": other_user.id}
)
http_client.cookies.clear()
assert other_login.status_code == 200
other_headers = {
"Authorization": f"Bearer {other_login.json()['access_token']}",
}
wrong_user = await http_client.get(
f"/api/v1/auth/{provider}",
params={"user_id": user.id},
headers=other_headers,
)
assert wrong_user.status_code == 403
assert wrong_user.json()["detail"] == "User ID mismatch."
login = await http_client.post("/api/v1/auth/usr", json={"usr": user.id})
http_client.cookies.clear()
assert login.status_code == 200
headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
response = await http_client.get( response = await http_client.get(
f"/api/v1/auth/{provider}", params={"user_id": user.id}, headers=headers f"/api/v1/auth/{provider}", params={"user_id": user.id}
) )
assert response.status_code == 307 assert response.status_code == 307
assert response.headers["location"] == "https://example.com/sso/login" assert response.headers["location"] == "https://example.com/sso/login"
+20 -114
View File
@@ -185,16 +185,23 @@ async def test_callback_api_handles_revolut_paid_events(mocker):
async def test_callback_api_handles_revolut_subscription_event( async def test_callback_api_handles_revolut_subscription_event(
mocker, settings: Settings mocker, settings: Settings
): ):
wallet_id = "wallet_1" user = await create_user_account(
payment = mocker.Mock() Account(
payment.extra = {} id=uuid4().hex,
payment.msat = 925_000 username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
payment = await create_wallet_invoice(
wallet.id, CreateInvoice(out=False, amount=15, memo="subscription")
)
settings.revolut_api_endpoint = "https://sandbox-merchant.revolut.com" settings.revolut_api_endpoint = "https://sandbox-merchant.revolut.com"
settings.revolut_api_secret_key = "revolut-secret" settings.revolut_api_secret_key = "revolut-secret"
settings.revolut_api_version = "2026-04-20" settings.revolut_api_version = "2026-04-20"
revolut_provider = RevolutWallet() revolut_provider = RevolutWallet()
get_subscription_mock = mocker.patch.object( mocker.patch.object(
revolut_provider, revolut_provider,
"get_subscription", "get_subscription",
return_value={ return_value={
@@ -202,7 +209,7 @@ async def test_callback_api_handles_revolut_subscription_event(
"current_cycle_id": "CYCLE_1", "current_cycle_id": "CYCLE_1",
"external_reference": json.dumps( "external_reference": json.dumps(
{ {
"wallet_id": wallet_id, "wallet_id": wallet.id,
"tag": "members", "tag": "members",
"subscription_request_id": "request_1", "subscription_request_id": "request_1",
"extra": {"link": "link-1", "customer_id": "customer_1"}, "extra": {"link": "link-1", "customer_id": "customer_1"},
@@ -234,14 +241,10 @@ async def test_callback_api_handles_revolut_subscription_event(
"lnbits.core.views.callback_api.get_standalone_payment", "lnbits.core.views.callback_api.get_standalone_payment",
mocker.AsyncMock(side_effect=[None]), mocker.AsyncMock(side_effect=[None]),
) )
create_wallet_invoice_mock = mocker.patch( create_fiat_invoice_mock = mocker.patch(
"lnbits.core.views.callback_api.create_wallet_invoice", "lnbits.core.views.callback_api.create_fiat_invoice",
mocker.AsyncMock(return_value=payment), mocker.AsyncMock(return_value=payment),
) )
mocker.patch("lnbits.core.views.callback_api.service_fee_fiat", return_value=2)
update_payment_mock = mocker.patch(
"lnbits.core.views.callback_api.update_payment", mocker.AsyncMock()
)
fiat_status_mock = mocker.patch( fiat_status_mock = mocker.patch(
"lnbits.core.views.callback_api.check_fiat_status", mocker.AsyncMock() "lnbits.core.views.callback_api.check_fiat_status", mocker.AsyncMock()
) )
@@ -253,113 +256,16 @@ async def test_callback_api_handles_revolut_subscription_event(
} }
) )
get_subscription_mock.assert_not_awaited() assert create_fiat_invoice_mock.await_count == 1
create_wallet_invoice_mock.assert_not_awaited() revolut_call = create_fiat_invoice_mock.await_args.kwargs
update_payment_mock.assert_not_awaited() assert revolut_call["wallet_id"] == wallet.id
fiat_status_mock.assert_not_awaited() invoice = revolut_call["invoice_data"]
assert invoice.fiat_provider == "revolut"
@pytest.mark.anyio
async def test_callback_api_handles_revolut_subscription_order_event(
mocker, settings: Settings
):
wallet_id = "wallet_1"
payment = mocker.Mock()
payment.extra = {}
payment.msat = 925_000
settings.revolut_api_endpoint = "https://sandbox-merchant.revolut.com"
settings.revolut_api_secret_key = "revolut-secret"
settings.revolut_api_version = "2026-04-20"
revolut_provider = RevolutWallet()
subscription = {
"id": "SUBSCRIPTION_1",
"state": "active",
"current_cycle_id": "CYCLE_1",
"external_reference": json.dumps(
{
"wallet_id": wallet_id,
"tag": "members",
"subscription_request_id": "request_1",
"extra": {"link": "link-1"},
"memo": "Revolut Members",
}
),
}
order = {
"id": "ORDER_SUB_1",
"type": "payment",
"state": "completed",
"amount": 925,
"currency": "USD",
"checkout_url": "https://checkout.revolut.com/payment-link/sub_1",
"channel_data": {
"subscription_id": "SUBSCRIPTION_1",
"subscription_cycle_id": "CYCLE_1",
},
}
get_order_mock = mocker.patch.object(
revolut_provider, "get_order", side_effect=[order, order]
)
get_subscription_mock = mocker.patch.object(
revolut_provider,
"get_subscription",
return_value=subscription,
)
mocker.patch.object(
revolut_provider,
"get_subscription_cycle",
return_value={"id": "CYCLE_1", "order_id": "ORDER_SUB_1"},
)
mocker.patch(
"lnbits.core.views.callback_api.get_fiat_provider",
mocker.AsyncMock(return_value=revolut_provider),
)
get_payment_mock = mocker.patch(
"lnbits.core.views.callback_api.get_standalone_payment",
mocker.AsyncMock(side_effect=[None, None]),
)
create_wallet_invoice_mock = mocker.patch(
"lnbits.core.views.callback_api.create_wallet_invoice",
mocker.AsyncMock(return_value=payment),
)
mocker.patch("lnbits.core.views.callback_api.service_fee_fiat", return_value=2)
update_payment_mock = mocker.patch(
"lnbits.core.views.callback_api.update_payment", mocker.AsyncMock()
)
fiat_status_mock = mocker.patch(
"lnbits.core.views.callback_api.check_fiat_status", mocker.AsyncMock()
)
await handle_revolut_event(
{
"event": "ORDER_COMPLETED",
"order_id": "ORDER_SUB_1",
}
)
assert get_payment_mock.await_count == 2
get_payment_mock.assert_any_await("fiat_revolut_order_ORDER_SUB_1")
assert get_order_mock.await_count == 1
assert [call.args for call in get_subscription_mock.await_args_list] == [
("SUBSCRIPTION_1",),
]
assert create_wallet_invoice_mock.await_count == 1
called_wallet_id, invoice = create_wallet_invoice_mock.await_args.args
assert called_wallet_id == "wallet_1"
assert invoice.amount == 9.25 assert invoice.amount == 9.25
assert invoice.memo == "Revolut Members" assert invoice.memo == "Revolut Members"
assert invoice.external_id == "SUBSCRIPTION_1" assert invoice.external_id == "SUBSCRIPTION_1"
assert invoice.internal is True
assert invoice.extra["fiat_method"] == "subscription" assert invoice.extra["fiat_method"] == "subscription"
assert invoice.extra["subscription"]["checking_id"] == "order_ORDER_SUB_1" assert invoice.extra["subscription"]["checking_id"] == "order_ORDER_SUB_1"
assert payment.fiat_provider == "revolut"
assert payment.fee == -2
assert payment.extra["fiat_checking_id"] == "order_ORDER_SUB_1"
assert payment.checking_id == "fiat_revolut_order_ORDER_SUB_1"
update_payment_mock.assert_awaited_once_with(
payment, "fiat_revolut_order_ORDER_SUB_1"
)
fiat_status_mock.assert_awaited_once_with(payment) fiat_status_mock.assert_awaited_once_with(payment)
+1 -2
View File
@@ -1,4 +1,3 @@
from typing import cast
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
@@ -107,7 +106,7 @@ async def test_lnurl_api_auth_and_pay_flow(mocker):
await api_perform_lnurlauth(auth_response, wallet_info) await api_perform_lnurlauth(auth_response, wallet_info)
action_response = LnurlPayActionResponse( action_response = LnurlPayActionResponse(
pr=cast(LightningInvoice, LightningInvoice(TEST_BOLT11)), pr=LightningInvoice(TEST_BOLT11),
disposable=False, disposable=False,
successAction=parse_obj_as(MessageAction, {"message": "paid"}), successAction=parse_obj_as(MessageAction, {"message": "paid"}),
) )
+2 -160
View File
@@ -6,7 +6,7 @@ import pytest
from fastapi import HTTPException from fastapi import HTTPException
from pydantic import ValidationError from pydantic import ValidationError
from lnbits.core.crud.payments import create_payment, get_payment, get_payments from lnbits.core.crud.payments import create_payment, get_payments
from lnbits.core.models import Account, CreateInvoice, PaymentFilters, PaymentState from lnbits.core.models import Account, CreateInvoice, PaymentFilters, PaymentState
from lnbits.core.models.payments import CancelInvoice, CreatePayment, SettleInvoice from lnbits.core.models.payments import CancelInvoice, CreatePayment, SettleInvoice
from lnbits.core.models.users import AccountId from lnbits.core.models.users import AccountId
@@ -161,7 +161,7 @@ async def test_payment_api_fee_reserve_and_hold_invoice_actions(mocker):
wallet.id, CreateInvoice(out=False, amount=42, memo="reserve") wallet.id, CreateInvoice(out=False, amount=42, memo="reserve")
) )
reserve = await api_payments_fee_reserve(invoice.bolt11) reserve = await api_payments_fee_reserve(invoice.bolt11)
assert json.loads(bytes(reserve.body))["fee_reserve"] >= 0 assert json.loads(reserve.body)["fee_reserve"] >= 0
with pytest.raises(HTTPException, match="Invoice has no amount."): with pytest.raises(HTTPException, match="Invoice has no amount."):
await api_payments_fee_reserve(ZERO_AMOUNT_INVOICE) await api_payments_fee_reserve(ZERO_AMOUNT_INVOICE)
@@ -218,164 +218,6 @@ async def test_payment_api_fee_reserve_and_hold_invoice_actions(mocker):
cancel_mock.assert_awaited_once() cancel_mock.assert_awaited_once()
@pytest.mark.anyio
async def test_payment_extra_update_appends_new_keys(
client,
to_wallet,
adminkey_headers_to,
):
payment_hash = uuid4().hex
checking_id = await _create_payment(
to_wallet.id,
amount_msat=1_000,
payment_hash=payment_hash,
tag="splitpayments",
)
response = await client.patch(
"/api/v1/payments/extra",
headers=adminkey_headers_to,
json={
"payment_hash": payment_hash,
"extra": {"child": "daughter", "compliance_note": "reviewed"},
},
)
assert response.status_code == 200
extra = response.json()["extra"]
assert extra["tag"] == "splitpayments"
assert extra["child"] == "daughter"
assert extra["compliance_note"] == "reviewed"
payment = await get_payment(checking_id)
assert payment.extra == extra
@pytest.mark.anyio
async def test_payment_extra_update_creates_extra_when_missing(
client,
to_wallet,
adminkey_headers_to,
):
payment_hash = uuid4().hex
checking_id = await _create_payment(
to_wallet.id,
amount_msat=1_000,
payment_hash=payment_hash,
)
response = await client.patch(
"/api/v1/payments/extra",
headers=adminkey_headers_to,
json={"payment_hash": payment_hash, "extra": {"note": "reviewed"}},
)
assert response.status_code == 200
assert response.json()["extra"] == {"note": "reviewed"}
payment = await get_payment(checking_id)
assert payment.extra == {"note": "reviewed"}
@pytest.mark.anyio
async def test_payment_extra_update_rejects_existing_keys(
client,
to_wallet,
adminkey_headers_to,
):
payment_hash = uuid4().hex
checking_id = await _create_payment(
to_wallet.id,
amount_msat=1_000,
payment_hash=payment_hash,
tag="original",
)
response = await client.patch(
"/api/v1/payments/extra",
headers=adminkey_headers_to,
json={"payment_hash": payment_hash, "extra": {"tag": "overwritten"}},
)
assert response.status_code == 400
assert response.json()["detail"] == "Extra keys already exist: tag."
payment = await get_payment(checking_id)
assert payment.extra == {"tag": "original"}
@pytest.mark.anyio
async def test_payment_extra_update_requires_admin_key(
client,
to_wallet,
inkey_headers_to,
):
payment_hash = uuid4().hex
await _create_payment(
to_wallet.id,
amount_msat=1_000,
payment_hash=payment_hash,
)
response = await client.patch(
"/api/v1/payments/extra",
headers=inkey_headers_to,
json={"payment_hash": payment_hash, "extra": {"note": "invoice key"}},
)
assert response.status_code == 403
assert response.json()["detail"] == "Invalid adminkey."
@pytest.mark.anyio
async def test_payment_extra_update_is_wallet_scoped(
client,
from_wallet,
adminkey_headers_to,
):
payment_hash = uuid4().hex
await _create_payment(
from_wallet.id,
amount_msat=1_000,
payment_hash=payment_hash,
)
response = await client.patch(
"/api/v1/payments/extra",
headers=adminkey_headers_to,
json={"payment_hash": payment_hash, "extra": {"note": "wrong wallet"}},
)
assert response.status_code == 404
assert response.json()["detail"] == "Payment does not exist."
@pytest.mark.anyio
async def test_payment_extra_update_requires_successful_payment(
client,
to_wallet,
adminkey_headers_to,
):
payment_hash = uuid4().hex
await _create_payment(
to_wallet.id,
amount_msat=1_000,
payment_hash=payment_hash,
status=PaymentState.PENDING,
)
response = await client.patch(
"/api/v1/payments/extra",
headers=adminkey_headers_to,
json={"payment_hash": payment_hash, "extra": {"note": "too early"}},
)
assert response.status_code == 400
assert (
response.json()["detail"] == "Payment extra can only be updated after success."
)
async def _create_payment( async def _create_payment(
wallet_id: str, wallet_id: str,
*, *,
+1
View File
@@ -0,0 +1 @@
98ae578877a3479bb0424d2e418b427a
+1
View File
@@ -0,0 +1 @@
6355d3c6c5df49dbb562a74f9deb19ce
+2
View File
@@ -26,6 +26,8 @@ docker_bitcoin_cli = [
"exec", "exec",
"lnbits-bitcoind-1", "lnbits-bitcoind-1",
"bitcoin-cli", "bitcoin-cli",
"-rpcuser=lnbits",
"-rpcpassword=lnbits",
"-regtest", "-regtest",
] ]
-184
View File
@@ -1,184 +0,0 @@
"""
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
-4
View File
@@ -275,10 +275,6 @@ 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

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