Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
769d3a07e8 | ||
|
|
b3dfb0384e | ||
|
|
c054e47913 | ||
|
|
92d3269a85 | ||
|
|
183b84c167 | ||
|
|
60f50a71a2 | ||
|
|
c3252ce4dc | ||
|
|
c312d70e63 | ||
|
|
36fc911b88 | ||
|
|
672a5b3a4d | ||
|
|
c0b33560bb | ||
|
|
15b6b1d512 | ||
|
|
67c92a79cf | ||
|
|
0d4751d6e0 |
@@ -1,4 +1,4 @@
|
|||||||
name: Build LNbits AppImage DMG
|
name: Build LNbits AppImage
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
@@ -11,56 +11,89 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# Step 1: Checkout the repository
|
# Step 1: Checkout the repository
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
# Step 2: Install Dependencies
|
# Step 2: Set up Python (uv will still use this toolchain)
|
||||||
- name: Install Dependencies
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
# Step 3: Install system deps (fuse) + uv
|
||||||
|
- name: Install system deps and uv
|
||||||
run: |
|
run: |
|
||||||
curl -sSL https://install.python-poetry.org | python3 -
|
|
||||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libfuse2
|
sudo apt-get install -y libfuse2
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
# Step 3: Clone LNbits Repository
|
# Optional: Cache uv + venv to speed up CI
|
||||||
- name: Clone LNbits
|
- name: Cache uv and venv
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/uv
|
||||||
|
.venv
|
||||||
|
key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock', 'pyproject.toml') }}
|
||||||
|
|
||||||
|
# Step 4: Prepare packaging tree and clone LNbits
|
||||||
|
- name: Prepare packaging & clone LNbits
|
||||||
run: |
|
run: |
|
||||||
mv .github/packaging packaging
|
mv .github/packaging packaging
|
||||||
mkdir -p packaging/linux/AppDir/usr
|
mkdir -p packaging/linux/AppDir/usr
|
||||||
git clone https://github.com/lnbits/lnbits.git packaging/linux/AppDir/usr/lnbits
|
git clone https://github.com/lnbits/lnbits.git packaging/linux/AppDir/usr/lnbits
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
# Step 4: Make the AppImage Asset
|
# Step 5: Build the LNbits binary with uv + PyInstaller
|
||||||
- name: Make Asset
|
- name: Build LNbits binary (uv + PyInstaller)
|
||||||
run: |
|
run: |
|
||||||
cd packaging/linux/AppDir/usr/lnbits
|
cd packaging/linux/AppDir/usr/lnbits
|
||||||
poetry install
|
|
||||||
poetry run pip install pyinstaller
|
# Install project deps into .venv using uv
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# Install PyInstaller into the same environment
|
||||||
|
uv pip install pyinstaller
|
||||||
|
|
||||||
# Build the LNbits binary
|
# Build the LNbits binary
|
||||||
poetry run pyinstaller --onefile --name lnbits --hidden-import=embit --collect-all embit --collect-all lnbits --collect-all sqlalchemy --collect-all aiosqlite --hidden-import=passlib.handlers.bcrypt $(poetry run which lnbits)
|
uv run pyinstaller \
|
||||||
|
--onefile \
|
||||||
|
--name lnbits \
|
||||||
|
--hidden-import=embit \
|
||||||
|
--collect-all embit \
|
||||||
|
--collect-all lnbits \
|
||||||
|
--collect-all sqlalchemy \
|
||||||
|
--collect-all aiosqlite \
|
||||||
|
--hidden-import=passlib.handlers.bcrypt \
|
||||||
|
"$(uv run which lnbits)"
|
||||||
|
|
||||||
cd ../../../../..
|
cd ../../../../..
|
||||||
chmod +x packaging/linux/AppDir/AppRun
|
chmod +x packaging/linux/AppDir/AppRun
|
||||||
chmod +x packaging/linux/AppDir/lnbits.desktop
|
chmod +x packaging/linux/AppDir/lnbits.desktop
|
||||||
chmod +x packaging/linux/AppDir/usr/lnbits/dist/lnbits
|
chmod +x packaging/linux/AppDir/usr/lnbits/dist/lnbits
|
||||||
|
|
||||||
|
# Clean out non-dist content from the app dir to keep AppImage slim
|
||||||
find packaging/linux/AppDir/usr/lnbits -mindepth 1 -maxdepth 1 \
|
find packaging/linux/AppDir/usr/lnbits -mindepth 1 -maxdepth 1 \
|
||||||
! -name 'dist' \
|
! -name 'dist' \
|
||||||
! -name 'lnbits' \
|
! -name 'lnbits' \
|
||||||
-exec rm -rf {} +
|
-exec rm -rf {} +
|
||||||
|
|
||||||
|
# Build AppImage
|
||||||
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
|
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||||
chmod +x appimagetool-x86_64.AppImage
|
chmod +x appimagetool-x86_64.AppImage
|
||||||
TAG_NAME=${{ github.event.release.tag_name }}
|
TAG_NAME=${{ github.event.release.tag_name }}
|
||||||
APPIMAGE_NAME="LNbits-${TAG_NAME}.AppImage"
|
APPIMAGE_NAME="LNbits-${TAG_NAME}.AppImage"
|
||||||
./appimagetool-x86_64.AppImage --updateinformation "gh-releases-zsync|lnbits|lnbits|latest|*.AppImage.zsync" packaging/linux/AppDir "$APPIMAGE_NAME"
|
./appimagetool-x86_64.AppImage \
|
||||||
|
--updateinformation "gh-releases-zsync|lnbits|lnbits|latest|*.AppImage.zsync" \
|
||||||
|
packaging/linux/AppDir "$APPIMAGE_NAME"
|
||||||
chmod +x "$APPIMAGE_NAME"
|
chmod +x "$APPIMAGE_NAME"
|
||||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
# Step 5: Upload Linux Release Asset
|
# Step 6: Upload Linux Release Asset
|
||||||
- name: Upload Linux Release Asset
|
- name: Upload Linux Release Asset
|
||||||
uses: actions/upload-release-asset@v1
|
uses: actions/upload-release-asset@v1
|
||||||
with:
|
with:
|
||||||
@@ -69,4 +102,4 @@ jobs:
|
|||||||
asset_name: ${{ env.APPIMAGE_NAME }}
|
asset_name: ${{ env.APPIMAGE_NAME }}
|
||||||
asset_content_type: application/octet-stream
|
asset_content_type: application/octet-stream
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from .funding_source import (
|
|||||||
get_balance_delta,
|
get_balance_delta,
|
||||||
switch_to_voidwallet,
|
switch_to_voidwallet,
|
||||||
)
|
)
|
||||||
from .lnurl import fetch_lnurl_pay_request, get_pr_from_lnurl
|
from .lnurl import fetch_lnurl_pay_request, get_pr_from_lnurl, perform_withdraw
|
||||||
from .notifications import enqueue_admin_notification, send_payment_notification
|
from .notifications import enqueue_admin_notification, send_payment_notification
|
||||||
from .payments import (
|
from .payments import (
|
||||||
calculate_fiat_amounts,
|
calculate_fiat_amounts,
|
||||||
@@ -57,6 +57,7 @@ __all__ = [
|
|||||||
"get_payments_daily_stats",
|
"get_payments_daily_stats",
|
||||||
"get_pr_from_lnurl",
|
"get_pr_from_lnurl",
|
||||||
"pay_invoice",
|
"pay_invoice",
|
||||||
|
"perform_withdraw",
|
||||||
"send_payment_notification",
|
"send_payment_notification",
|
||||||
"service_fee",
|
"service_fee",
|
||||||
"settle_hold_invoice",
|
"settle_hold_invoice",
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ from lnurl import (
|
|||||||
LnurlPayActionResponse,
|
LnurlPayActionResponse,
|
||||||
LnurlPayResponse,
|
LnurlPayResponse,
|
||||||
LnurlResponseException,
|
LnurlResponseException,
|
||||||
|
LnurlSuccessResponse,
|
||||||
|
LnurlWithdrawResponse,
|
||||||
execute_pay_request,
|
execute_pay_request,
|
||||||
|
execute_withdraw,
|
||||||
handle,
|
handle,
|
||||||
)
|
)
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -15,10 +18,36 @@ from loguru import logger
|
|||||||
from lnbits.core.crud import update_wallet
|
from lnbits.core.crud import update_wallet
|
||||||
from lnbits.core.models import CreateLnurlPayment, Wallet
|
from lnbits.core.models import CreateLnurlPayment, Wallet
|
||||||
from lnbits.core.models.lnurl import StoredPayLink
|
from lnbits.core.models.lnurl import StoredPayLink
|
||||||
|
from lnbits.helpers import check_callback_url
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||||||
|
|
||||||
|
|
||||||
|
async def perform_withdraw(lnurl: str, payment_request: str) -> None:
|
||||||
|
"""
|
||||||
|
Perform an LNURL withdraw to the given LNURL-withdraw link.
|
||||||
|
:param lnurl: The LNURL-withdraw link. bech32 or lud17 format.
|
||||||
|
:param payment_request: The BOLT11 payment request to pay.
|
||||||
|
:raises LnurlResponseException: If the LNURL-withdraw process fails.
|
||||||
|
"""
|
||||||
|
res = await handle(lnurl, user_agent=settings.user_agent, timeout=10)
|
||||||
|
if isinstance(res, LnurlErrorResponse):
|
||||||
|
raise LnurlResponseException(res.reason)
|
||||||
|
if not isinstance(res, LnurlWithdrawResponse):
|
||||||
|
raise LnurlResponseException("Invalid LNURL-withdraw response.")
|
||||||
|
try:
|
||||||
|
check_callback_url(res.callback)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise LnurlResponseException(f"Invalid callback URL: {exc!s}") from exc
|
||||||
|
res2 = await execute_withdraw(
|
||||||
|
res, payment_request, user_agent=settings.user_agent, timeout=10
|
||||||
|
)
|
||||||
|
if isinstance(res2, LnurlErrorResponse):
|
||||||
|
raise LnurlResponseException(res2.reason)
|
||||||
|
if not isinstance(res2, LnurlSuccessResponse):
|
||||||
|
raise LnurlResponseException("Invalid LNURL-withdraw success response.")
|
||||||
|
|
||||||
|
|
||||||
async def get_pr_from_lnurl(
|
async def get_pr_from_lnurl(
|
||||||
lnurl: str, amount_msat: int, comment: str | None = None
|
lnurl: str, amount_msat: int, comment: str | None = None
|
||||||
) -> str:
|
) -> str:
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ from lnurl import LnurlErrorResponse, LnurlSuccessResponse
|
|||||||
from lnurl import execute_withdraw as lnurl_withdraw
|
from lnurl import execute_withdraw as lnurl_withdraw
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from bolt11.exceptions import Bolt11Bech32InvalidException
|
||||||
|
import base64, json, time
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from lnbits.core.crud.payments import get_daily_stats
|
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
|
||||||
@@ -52,6 +56,62 @@ from .notifications import send_payment_notification
|
|||||||
payment_lock = asyncio.Lock()
|
payment_lock = asyncio.Lock()
|
||||||
wallets_payments_lock: dict[str, asyncio.Lock] = {}
|
wallets_payments_lock: dict[str, asyncio.Lock] = {}
|
||||||
|
|
||||||
|
def _parse_ark_ticket(ticket: str) -> SimpleNamespace:
|
||||||
|
"""
|
||||||
|
Accepts 'ark1' + base64url(JSON) and returns an object that mimics the
|
||||||
|
attributes LNbits reads from a decoded BOLT11 invoice.
|
||||||
|
Required JSON fields we honor:
|
||||||
|
- amt_sat: int
|
||||||
|
- hash: str (payment hash / checking_id)
|
||||||
|
- ts: int (unix timestamp when created)
|
||||||
|
- exp: int (seconds until expiry)
|
||||||
|
- memo: str (optional)
|
||||||
|
"""
|
||||||
|
if not isinstance(ticket, str) or not ticket.startswith("ark1"):
|
||||||
|
raise ValueError("Not an ARK ticket")
|
||||||
|
|
||||||
|
b64u = ticket[4:]
|
||||||
|
b64u += "=" * (-len(b64u) % 4) # restore padding
|
||||||
|
payload = json.loads(base64.urlsafe_b64decode(b64u).decode())
|
||||||
|
|
||||||
|
amt_sat = int(payload.get("amt_sat", 0))
|
||||||
|
ts = int(payload.get("ts", int(time.time())))
|
||||||
|
exp = int(payload.get("exp", 3600))
|
||||||
|
memo = payload.get("memo") or ""
|
||||||
|
p_hash = payload.get("hash") or payload.get("payment_hash")
|
||||||
|
if not isinstance(p_hash, str):
|
||||||
|
raise ValueError("ARK ticket missing 'hash'")
|
||||||
|
|
||||||
|
# Match attributes accessed later in this module:
|
||||||
|
# - amount_msat (int)
|
||||||
|
# - payment_hash (str)
|
||||||
|
# - description (str)
|
||||||
|
# - expiry_date (int unix ts expected by CreatePayment)
|
||||||
|
# - tags.get(...) is sometimes called; return a harmless stub
|
||||||
|
return SimpleNamespace(
|
||||||
|
amount_msat=amt_sat * 1000,
|
||||||
|
payment_hash=p_hash,
|
||||||
|
description=memo,
|
||||||
|
expiry_date=ts + exp, # unix timestamp
|
||||||
|
timestamp=ts, # not strictly required here but nice to have
|
||||||
|
tags=SimpleNamespace(get=lambda *a, **k: None),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Keep a reference to the real decoder, then override the name used below
|
||||||
|
_real_bolt11_decode = bolt11_decode
|
||||||
|
|
||||||
|
def _safe_decode_payment_request(pr: str):
|
||||||
|
try:
|
||||||
|
return _real_bolt11_decode(pr)
|
||||||
|
except Bolt11Bech32InvalidException:
|
||||||
|
# Fallback: accept ARK tickets
|
||||||
|
if isinstance(pr, str) and pr.startswith("ark1"):
|
||||||
|
return _parse_ark_ticket(pr)
|
||||||
|
# Not ARK? re-raise to keep existing behavior
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Override the imported name used throughout this file:
|
||||||
|
bolt11_decode = _safe_decode_payment_request
|
||||||
|
|
||||||
async def pay_invoice(
|
async def pay_invoice(
|
||||||
*,
|
*,
|
||||||
@@ -139,7 +199,9 @@ async def create_fiat_invoice(
|
|||||||
payment_hash=internal_payment.payment_hash,
|
payment_hash=internal_payment.payment_hash,
|
||||||
currency=invoice_data.unit,
|
currency=invoice_data.unit,
|
||||||
memo=invoice_data.memo,
|
memo=invoice_data.memo,
|
||||||
|
extra=invoice_data.extra or {},
|
||||||
)
|
)
|
||||||
|
|
||||||
if fiat_invoice.failed:
|
if fiat_invoice.failed:
|
||||||
logger.warning(fiat_invoice.error_message)
|
logger.warning(fiat_invoice.error_message)
|
||||||
internal_payment.status = PaymentState.FAILED
|
internal_payment.status = PaymentState.FAILED
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from lnbits.core.models.misc import SimpleStatus
|
from lnbits.core.models.misc import SimpleStatus
|
||||||
from lnbits.core.services.fiat_providers import test_connection
|
from lnbits.core.services.fiat_providers import test_connection
|
||||||
from lnbits.decorators import check_admin
|
from lnbits.decorators import check_admin
|
||||||
|
from lnbits.fiat import StripeWallet, get_fiat_provider
|
||||||
|
|
||||||
fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat")
|
fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat")
|
||||||
|
|
||||||
@@ -16,3 +17,29 @@ fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat")
|
|||||||
)
|
)
|
||||||
async def api_test_fiat_provider(provider: str) -> SimpleStatus:
|
async def api_test_fiat_provider(provider: str) -> SimpleStatus:
|
||||||
return await test_connection(provider)
|
return await test_connection(provider)
|
||||||
|
|
||||||
|
|
||||||
|
@fiat_router.post(
|
||||||
|
"/{provider}/connection_token",
|
||||||
|
status_code=HTTPStatus.OK,
|
||||||
|
dependencies=[Depends(check_admin)],
|
||||||
|
)
|
||||||
|
async def connection_token(provider: str):
|
||||||
|
provider_wallet = await get_fiat_provider(provider)
|
||||||
|
if provider == "stripe":
|
||||||
|
if not isinstance(provider_wallet, StripeWallet):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail="Stripe wallet/provider not configured"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
tok = await provider_wallet.create_terminal_connection_token()
|
||||||
|
secret = tok.get("secret")
|
||||||
|
if not secret:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502, detail="Stripe returned no connection token"
|
||||||
|
)
|
||||||
|
return {"secret": secret}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail="Failed to create connection token"
|
||||||
|
) from e
|
||||||
|
|||||||
@@ -6,12 +6,8 @@ from fastapi import (
|
|||||||
Depends,
|
Depends,
|
||||||
HTTPException,
|
HTTPException,
|
||||||
)
|
)
|
||||||
from lnurl import (
|
from lnurl import LnurlResponseException
|
||||||
LnurlResponseException,
|
|
||||||
LnurlSuccessResponse,
|
|
||||||
)
|
|
||||||
from lnurl import execute_login as lnurlauth
|
from lnurl import execute_login as lnurlauth
|
||||||
from lnurl import execute_withdraw as lnurl_withdraw
|
|
||||||
from lnurl import handle as lnurl_handle
|
from lnurl import handle as lnurl_handle
|
||||||
from lnurl.models import (
|
from lnurl.models import (
|
||||||
LnurlAuthResponse,
|
LnurlAuthResponse,
|
||||||
@@ -22,7 +18,7 @@ from lnurl.models import (
|
|||||||
)
|
)
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits.core.models import CreateLnurlWithdraw, Payment
|
from lnbits.core.models import Payment
|
||||||
from lnbits.core.models.lnurl import CreateLnurlPayment, LnurlScan
|
from lnbits.core.models.lnurl import CreateLnurlPayment, LnurlScan
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
WalletTypeInfo,
|
WalletTypeInfo,
|
||||||
@@ -138,38 +134,3 @@ async def api_payments_pay_lnurl(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return payment
|
return payment
|
||||||
|
|
||||||
|
|
||||||
@lnurl_router.post(
|
|
||||||
"/api/v1/payments/{payment_request}/pay-with-nfc", status_code=HTTPStatus.OK
|
|
||||||
)
|
|
||||||
async def api_payment_pay_with_nfc(
|
|
||||||
payment_request: str,
|
|
||||||
lnurl_data: CreateLnurlWithdraw,
|
|
||||||
) -> LnurlErrorResponse | LnurlSuccessResponse:
|
|
||||||
if not lnurl_data.lnurl_w.lud17:
|
|
||||||
return LnurlErrorResponse(reason="LNURL-withdraw lud17 not provided.")
|
|
||||||
try:
|
|
||||||
url = lnurl_data.lnurl_w.lud17
|
|
||||||
res = await lnurl_handle(url, user_agent=settings.user_agent, timeout=10)
|
|
||||||
except (LnurlResponseException, Exception) as exc:
|
|
||||||
return LnurlErrorResponse(reason=str(exc))
|
|
||||||
|
|
||||||
if not isinstance(res, LnurlWithdrawResponse):
|
|
||||||
return LnurlErrorResponse(reason="Invalid LNURL-withdraw response.")
|
|
||||||
try:
|
|
||||||
check_callback_url(res.callback)
|
|
||||||
except ValueError as exc:
|
|
||||||
return LnurlErrorResponse(reason=f"Invalid callback URL: {exc!s}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
res2 = await lnurl_withdraw(
|
|
||||||
res, payment_request, user_agent=settings.user_agent, timeout=10
|
|
||||||
)
|
|
||||||
except (LnurlResponseException, Exception) as exc:
|
|
||||||
logger.warning(exc)
|
|
||||||
return LnurlErrorResponse(reason=str(exc))
|
|
||||||
if not isinstance(res2, LnurlSuccessResponse | LnurlErrorResponse):
|
|
||||||
return LnurlErrorResponse(reason="Invalid LNURL-withdraw response.")
|
|
||||||
|
|
||||||
return res2
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from lnbits.core.crud.payments import (
|
|||||||
from lnbits.core.models import (
|
from lnbits.core.models import (
|
||||||
CancelInvoice,
|
CancelInvoice,
|
||||||
CreateInvoice,
|
CreateInvoice,
|
||||||
|
CreateLnurlWithdraw,
|
||||||
DecodePayment,
|
DecodePayment,
|
||||||
KeyType,
|
KeyType,
|
||||||
Payment,
|
Payment,
|
||||||
@@ -29,6 +30,7 @@ from lnbits.core.models import (
|
|||||||
PaymentHistoryPoint,
|
PaymentHistoryPoint,
|
||||||
PaymentWalletStats,
|
PaymentWalletStats,
|
||||||
SettleInvoice,
|
SettleInvoice,
|
||||||
|
SimpleStatus,
|
||||||
)
|
)
|
||||||
from lnbits.core.models.users import User
|
from lnbits.core.models.users import User
|
||||||
from lnbits.db import Filters, Page
|
from lnbits.db import Filters, Page
|
||||||
@@ -59,6 +61,7 @@ from ..services import (
|
|||||||
fee_reserve_total,
|
fee_reserve_total,
|
||||||
get_payments_daily_stats,
|
get_payments_daily_stats,
|
||||||
pay_invoice,
|
pay_invoice,
|
||||||
|
perform_withdraw,
|
||||||
settle_hold_invoice,
|
settle_hold_invoice,
|
||||||
update_pending_payment,
|
update_pending_payment,
|
||||||
update_pending_payments,
|
update_pending_payments,
|
||||||
@@ -359,3 +362,23 @@ async def api_payments_cancel(
|
|||||||
detail="Payment does not exist or does not belong to this wallet.",
|
detail="Payment does not exist or does not belong to this wallet.",
|
||||||
)
|
)
|
||||||
return await cancel_hold_invoice(payment)
|
return await cancel_hold_invoice(payment)
|
||||||
|
|
||||||
|
|
||||||
|
@payment_router.post("/{payment_request}/pay-with-nfc")
|
||||||
|
async def api_payment_pay_with_nfc(
|
||||||
|
payment_request: str,
|
||||||
|
lnurl_data: CreateLnurlWithdraw,
|
||||||
|
) -> SimpleStatus:
|
||||||
|
if not lnurl_data.lnurl_w.lud17:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail="LNURL-withdraw lud17 not provided.",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await perform_withdraw(lnurl_data.lnurl_w.lud17, payment_request)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return SimpleStatus(success=True, message="Payment sent with NFC.")
|
||||||
|
|||||||
+2
-1
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from collections.abc import AsyncGenerator, Coroutine
|
from collections.abc import AsyncGenerator, Coroutine
|
||||||
from typing import TYPE_CHECKING, NamedTuple
|
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
pass
|
pass
|
||||||
@@ -106,6 +106,7 @@ class FiatProvider(ABC):
|
|||||||
payment_hash: str,
|
payment_hash: str,
|
||||||
currency: str,
|
currency: str,
|
||||||
memo: str | None = None,
|
memo: str | None = None,
|
||||||
|
extra: dict[str, Any] | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> Coroutine[None, None, FiatInvoiceResponse]:
|
) -> Coroutine[None, None, FiatInvoiceResponse]:
|
||||||
pass
|
pass
|
||||||
|
|||||||
+223
-62
@@ -2,10 +2,12 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Literal
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
from pydantic import BaseModel, Field, ValidationError
|
||||||
|
|
||||||
from lnbits.helpers import normalize_endpoint
|
from lnbits.helpers import normalize_endpoint
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
@@ -21,6 +23,34 @@ from .base import (
|
|||||||
FiatStatusResponse,
|
FiatStatusResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
FiatMethod = Literal["checkout", "terminal"]
|
||||||
|
|
||||||
|
|
||||||
|
class StripeTerminalOptions(BaseModel):
|
||||||
|
class Config:
|
||||||
|
extra = "ignore"
|
||||||
|
|
||||||
|
capture_method: Literal["automatic", "manual"] = "automatic"
|
||||||
|
metadata: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class StripeCheckoutOptions(BaseModel):
|
||||||
|
class Config:
|
||||||
|
extra = "ignore"
|
||||||
|
|
||||||
|
success_url: str | None = None
|
||||||
|
metadata: dict[str, str] = Field(default_factory=dict)
|
||||||
|
line_item_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class StripeCreateInvoiceOptions(BaseModel):
|
||||||
|
class Config:
|
||||||
|
extra = "ignore"
|
||||||
|
|
||||||
|
fiat_method: FiatMethod = "checkout"
|
||||||
|
terminal: StripeTerminalOptions | None = None
|
||||||
|
checkout: StripeCheckoutOptions | None = None
|
||||||
|
|
||||||
|
|
||||||
class StripeWallet(FiatProvider):
|
class StripeWallet(FiatProvider):
|
||||||
"""https://docs.stripe.com/api"""
|
"""https://docs.stripe.com/api"""
|
||||||
@@ -30,9 +60,9 @@ class StripeWallet(FiatProvider):
|
|||||||
self._settings_fields = self._settings_connection_fields()
|
self._settings_fields = self._settings_connection_fields()
|
||||||
if not settings.stripe_api_endpoint:
|
if not settings.stripe_api_endpoint:
|
||||||
raise ValueError("Cannot initialize StripeWallet: missing endpoint.")
|
raise ValueError("Cannot initialize StripeWallet: missing endpoint.")
|
||||||
|
|
||||||
if not settings.stripe_api_secret_key:
|
if not settings.stripe_api_secret_key:
|
||||||
raise ValueError("Cannot initialize StripeWallet: missing API secret key.")
|
raise ValueError("Cannot initialize StripeWallet: missing API secret key.")
|
||||||
|
|
||||||
self.endpoint = normalize_endpoint(settings.stripe_api_endpoint)
|
self.endpoint = normalize_endpoint(settings.stripe_api_endpoint)
|
||||||
self.headers = {
|
self.headers = {
|
||||||
"Authorization": f"Bearer {settings.stripe_api_secret_key}",
|
"Authorization": f"Bearer {settings.stripe_api_secret_key}",
|
||||||
@@ -60,8 +90,10 @@ class StripeWallet(FiatProvider):
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
|
|
||||||
available_balance = data.get("available", [{}])[0].get("amount", 0)
|
available = data.get("available") or []
|
||||||
# pending_balance = data.get("pending", {}).get("amount", 0)
|
available_balance = 0
|
||||||
|
if available and isinstance(available, list):
|
||||||
|
available_balance = int(available[0].get("amount", 0))
|
||||||
|
|
||||||
return FiatStatusResponse(balance=available_balance)
|
return FiatStatusResponse(balance=available_balance)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
@@ -76,81 +108,47 @@ class StripeWallet(FiatProvider):
|
|||||||
payment_hash: str,
|
payment_hash: str,
|
||||||
currency: str,
|
currency: str,
|
||||||
memo: str | None = None,
|
memo: str | None = None,
|
||||||
|
extra: dict[str, Any] | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> FiatInvoiceResponse:
|
) -> FiatInvoiceResponse:
|
||||||
amount_cents = int(amount * 100)
|
amount_cents = int(amount * 100)
|
||||||
form_data = [
|
opts = self._parse_create_opts(extra or {})
|
||||||
("mode", "payment"),
|
if not opts:
|
||||||
(
|
return FiatInvoiceResponse(ok=False, error_message="Invalid Stripe options")
|
||||||
"success_url",
|
|
||||||
settings.stripe_payment_success_url or "https://lnbits.com",
|
|
||||||
),
|
|
||||||
("metadata[payment_hash]", payment_hash),
|
|
||||||
("line_items[0][price_data][currency]", currency.lower()),
|
|
||||||
("line_items[0][price_data][product_data][name]", memo or "LNbits Invoice"),
|
|
||||||
("line_items[0][price_data][unit_amount]", amount_cents),
|
|
||||||
("line_items[0][quantity]", "1"),
|
|
||||||
]
|
|
||||||
encoded_data = urlencode(form_data)
|
|
||||||
|
|
||||||
try:
|
if opts.fiat_method == "checkout":
|
||||||
headers = self.headers.copy()
|
return await self._create_checkout_invoice(
|
||||||
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
amount_cents, currency, payment_hash, memo, opts
|
||||||
r = await self.client.post(
|
)
|
||||||
url="/v1/checkout/sessions", headers=headers, content=encoded_data
|
if opts.fiat_method == "terminal":
|
||||||
|
return await self._create_terminal_invoice(
|
||||||
|
amount_cents, currency, payment_hash, opts
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
|
||||||
data = r.json()
|
|
||||||
|
|
||||||
session_id = data.get("id")
|
return FiatInvoiceResponse(
|
||||||
if not session_id:
|
ok=False, error_message=f"Unsupported fiat_method: {opts.fiat_method}"
|
||||||
return FiatInvoiceResponse(
|
)
|
||||||
ok=False, error_message="Server error: 'missing session id'"
|
|
||||||
)
|
|
||||||
payment_request = data.get("url")
|
|
||||||
if not payment_request:
|
|
||||||
return FiatInvoiceResponse(
|
|
||||||
ok=False, error_message="Server error: 'missing payment URL'"
|
|
||||||
)
|
|
||||||
|
|
||||||
return FiatInvoiceResponse(
|
|
||||||
ok=True, checking_id=session_id, payment_request=payment_request
|
|
||||||
)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return FiatInvoiceResponse(
|
|
||||||
ok=False, error_message="Server error: 'invalid json response'"
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(exc)
|
|
||||||
return FiatInvoiceResponse(
|
|
||||||
ok=False, error_message=f"Unable to connect to {self.endpoint}."
|
|
||||||
)
|
|
||||||
|
|
||||||
async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse:
|
async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse:
|
||||||
raise NotImplementedError("Stripe does not support paying invoices directly.")
|
raise NotImplementedError("Stripe does not support paying invoices directly.")
|
||||||
|
|
||||||
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
|
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
|
||||||
try:
|
try:
|
||||||
r = await self.client.get(
|
stripe_id = self._normalize_stripe_id(checking_id)
|
||||||
url=f"/v1/checkout/sessions/{checking_id}",
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
|
|
||||||
data = r.json()
|
if stripe_id.startswith("cs_"):
|
||||||
payment_status = data.get("payment_status")
|
r = await self.client.get(f"/v1/checkout/sessions/{stripe_id}")
|
||||||
if not payment_status:
|
r.raise_for_status()
|
||||||
return FiatPaymentPendingStatus()
|
return self._status_from_checkout_session(r.json())
|
||||||
if payment_status == "paid":
|
|
||||||
# todo: handle fee
|
|
||||||
return FiatPaymentSuccessStatus()
|
|
||||||
|
|
||||||
expires_at = data.get("expires_at")
|
if stripe_id.startswith("pi_"):
|
||||||
_24_hours_ago = datetime.now(timezone.utc) - timedelta(hours=24)
|
r = await self.client.get(f"/v1/payment_intents/{stripe_id}")
|
||||||
if expires_at and expires_at < _24_hours_ago.timestamp():
|
r.raise_for_status()
|
||||||
# be defensive: add a 24 hour buffer
|
return self._status_from_payment_intent(r.json())
|
||||||
return FiatPaymentFailedStatus()
|
|
||||||
|
|
||||||
|
logger.debug(f"Unknown Stripe id prefix: {checking_id}")
|
||||||
return FiatPaymentPendingStatus()
|
return FiatPaymentPendingStatus()
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug(f"Error getting invoice status: {exc}")
|
logger.debug(f"Error getting invoice status: {exc}")
|
||||||
return FiatPaymentPendingStatus()
|
return FiatPaymentPendingStatus()
|
||||||
@@ -167,6 +165,169 @@ class StripeWallet(FiatProvider):
|
|||||||
value = await mock_queue.get()
|
value = await mock_queue.get()
|
||||||
yield value
|
yield value
|
||||||
|
|
||||||
|
async def create_terminal_connection_token(self) -> dict:
|
||||||
|
r = await self.client.post("/v1/terminal/connection_tokens")
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
async def _create_checkout_invoice(
|
||||||
|
self,
|
||||||
|
amount_cents: int,
|
||||||
|
currency: str,
|
||||||
|
payment_hash: str,
|
||||||
|
memo: str | None,
|
||||||
|
opts: StripeCreateInvoiceOptions,
|
||||||
|
) -> FiatInvoiceResponse:
|
||||||
|
co = opts.checkout or StripeCheckoutOptions()
|
||||||
|
success_url = (
|
||||||
|
co.success_url
|
||||||
|
or settings.stripe_payment_success_url
|
||||||
|
or "https://lnbits.com"
|
||||||
|
)
|
||||||
|
line_item_name = co.line_item_name or memo or "LNbits Invoice"
|
||||||
|
|
||||||
|
form_data: list[tuple[str, str]] = [
|
||||||
|
("mode", "payment"),
|
||||||
|
("success_url", success_url),
|
||||||
|
("metadata[payment_hash]", payment_hash),
|
||||||
|
("line_items[0][price_data][currency]", currency.lower()),
|
||||||
|
("line_items[0][price_data][product_data][name]", line_item_name),
|
||||||
|
("line_items[0][price_data][unit_amount]", str(amount_cents)),
|
||||||
|
("line_items[0][quantity]", "1"),
|
||||||
|
]
|
||||||
|
form_data += self._encode_metadata("metadata", co.metadata)
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = await self.client.post(
|
||||||
|
"/v1/checkout/sessions",
|
||||||
|
headers=self._build_headers_form(),
|
||||||
|
content=urlencode(form_data),
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
session_id, url = data.get("id"), data.get("url")
|
||||||
|
if not session_id or not url:
|
||||||
|
return FiatInvoiceResponse(
|
||||||
|
ok=False, error_message="Server error: missing id or url"
|
||||||
|
)
|
||||||
|
return FiatInvoiceResponse(
|
||||||
|
ok=True, checking_id=session_id, payment_request=url
|
||||||
|
)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return FiatInvoiceResponse(
|
||||||
|
ok=False, error_message="Server error: invalid json response"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(exc)
|
||||||
|
return FiatInvoiceResponse(
|
||||||
|
ok=False, error_message=f"Unable to connect to {self.endpoint}."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _create_terminal_invoice(
|
||||||
|
self,
|
||||||
|
amount_cents: int,
|
||||||
|
currency: str,
|
||||||
|
payment_hash: str,
|
||||||
|
opts: StripeCreateInvoiceOptions,
|
||||||
|
) -> FiatInvoiceResponse:
|
||||||
|
term = opts.terminal or StripeTerminalOptions()
|
||||||
|
data: dict[str, str] = {
|
||||||
|
"amount": str(amount_cents),
|
||||||
|
"currency": currency.lower(),
|
||||||
|
"payment_method_types[]": "card_present",
|
||||||
|
"capture_method": term.capture_method,
|
||||||
|
"metadata[payment_hash]": payment_hash,
|
||||||
|
"metadata[source]": "lnbits",
|
||||||
|
}
|
||||||
|
for k, v in (term.metadata or {}).items():
|
||||||
|
data[f"metadata[{k}]"] = str(v)
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = await self.client.post("/v1/payment_intents", data=data)
|
||||||
|
r.raise_for_status()
|
||||||
|
pi = r.json()
|
||||||
|
pi_id, client_secret = pi.get("id"), pi.get("client_secret")
|
||||||
|
if not pi_id or not client_secret:
|
||||||
|
return FiatInvoiceResponse(
|
||||||
|
ok=False,
|
||||||
|
error_message="Error: missing PaymentIntent or client_secret",
|
||||||
|
)
|
||||||
|
return FiatInvoiceResponse(
|
||||||
|
ok=True, checking_id=pi_id, payment_request=client_secret
|
||||||
|
)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return FiatInvoiceResponse(
|
||||||
|
ok=False, error_message="Error: invalid json response"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(exc)
|
||||||
|
return FiatInvoiceResponse(
|
||||||
|
ok=False, error_message=f"Unable to connect to {self.endpoint}."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _normalize_stripe_id(self, checking_id: str) -> str:
|
||||||
|
"""Remove our internal prefix so Stripe sees a real id."""
|
||||||
|
return (
|
||||||
|
checking_id.replace("fiat_stripe_", "", 1)
|
||||||
|
if checking_id.startswith("fiat_stripe_")
|
||||||
|
else checking_id
|
||||||
|
)
|
||||||
|
|
||||||
|
def _status_from_checkout_session(self, data: dict) -> FiatPaymentStatus:
|
||||||
|
"""Map a Checkout Session to LNbits fiat status."""
|
||||||
|
if data.get("payment_status") == "paid":
|
||||||
|
return FiatPaymentSuccessStatus()
|
||||||
|
|
||||||
|
# Consider an expired session a fail (existing 24h rule).
|
||||||
|
expires_at = data.get("expires_at")
|
||||||
|
_24h_ago = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||||
|
if expires_at and float(expires_at) < _24h_ago.timestamp():
|
||||||
|
return FiatPaymentFailedStatus()
|
||||||
|
|
||||||
|
return FiatPaymentPendingStatus()
|
||||||
|
|
||||||
|
def _status_from_payment_intent(self, pi: dict) -> FiatPaymentStatus:
|
||||||
|
"""Map a PaymentIntent to LNbits fiat status (card_present friendly)."""
|
||||||
|
status = pi.get("status")
|
||||||
|
|
||||||
|
if status == "succeeded":
|
||||||
|
return FiatPaymentSuccessStatus()
|
||||||
|
|
||||||
|
if status in ("canceled", "payment_failed"):
|
||||||
|
return FiatPaymentFailedStatus()
|
||||||
|
|
||||||
|
if status == "requires_payment_method":
|
||||||
|
if pi.get("last_payment_error"):
|
||||||
|
return FiatPaymentFailedStatus()
|
||||||
|
|
||||||
|
now_ts = datetime.now(timezone.utc).timestamp()
|
||||||
|
created_ts = float(pi.get("created") or now_ts)
|
||||||
|
is_stale = (now_ts - created_ts) > 300
|
||||||
|
if is_stale:
|
||||||
|
return FiatPaymentFailedStatus()
|
||||||
|
|
||||||
|
return FiatPaymentPendingStatus()
|
||||||
|
|
||||||
|
def _build_headers_form(self) -> dict[str, str]:
|
||||||
|
return {**self.headers, "Content-Type": "application/x-www-form-urlencoded"}
|
||||||
|
|
||||||
|
def _encode_metadata(
|
||||||
|
self, prefix: str, md: dict[str, Any]
|
||||||
|
) -> list[tuple[str, str]]:
|
||||||
|
out: list[tuple[str, str]] = []
|
||||||
|
for k, v in (md or {}).items():
|
||||||
|
out.append((f"{prefix}[{k}]", str(v)))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _parse_create_opts(
|
||||||
|
self, raw_opts: dict[str, Any]
|
||||||
|
) -> StripeCreateInvoiceOptions | None:
|
||||||
|
try:
|
||||||
|
return StripeCreateInvoiceOptions.parse_obj(raw_opts)
|
||||||
|
except ValidationError as e:
|
||||||
|
logger.warning(f"Invalid Stripe options: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
def _settings_connection_fields(self) -> str:
|
def _settings_connection_fields(self) -> str:
|
||||||
return "-".join(
|
return "-".join(
|
||||||
[str(settings.stripe_api_endpoint), str(settings.stripe_api_secret_key)]
|
[str(settings.stripe_api_endpoint), str(settings.stripe_api_secret_key)]
|
||||||
|
|||||||
@@ -974,6 +974,7 @@ class SuperUserSettings(LNbitsSettings):
|
|||||||
"ZBDWallet",
|
"ZBDWallet",
|
||||||
"NWCWallet",
|
"NWCWallet",
|
||||||
"StrikeWallet",
|
"StrikeWallet",
|
||||||
|
"ArkFakeWallet",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+3
-3
File diff suppressed because one or more lines are too long
@@ -115,6 +115,7 @@ window.localisation.en = {
|
|||||||
payment_check: 'Check payment',
|
payment_check: 'Check payment',
|
||||||
not_enough_funds: 'Not enough funds!',
|
not_enough_funds: 'Not enough funds!',
|
||||||
search_by_tag_memo_amount: 'Search by tag, memo, amount',
|
search_by_tag_memo_amount: 'Search by tag, memo, amount',
|
||||||
|
search: 'Search',
|
||||||
invoice_waiting: 'Invoice waiting to be paid',
|
invoice_waiting: 'Invoice waiting to be paid',
|
||||||
payment_received: 'Payment Received',
|
payment_received: 'Payment Received',
|
||||||
payment_sent: 'Payment Sent',
|
payment_sent: 'Payment Sent',
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ window.app.component('lnbits-qrcode-lnurl', {
|
|||||||
const bech32 = NostrTools.nip19.encodeBytes('lnurl', bytes)
|
const bech32 = NostrTools.nip19.encodeBytes('lnurl', bytes)
|
||||||
this.lnurl = `lightning:${bech32.toUpperCase()}`
|
this.lnurl = `lightning:${bech32.toUpperCase()}`
|
||||||
} else if (this.tab == 'lud17') {
|
} else if (this.tab == 'lud17') {
|
||||||
this.lnurl = this.url.replace('https://', this.prefix + '://')
|
if (this.url.startsWith('http://')) {
|
||||||
|
this.lnurl = this.url.replace('http://', this.prefix + '://')
|
||||||
|
} else {
|
||||||
|
this.lnurl = this.url.replace('https://', this.prefix + '://')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.$emit('update:lnurl', this.lnurl)
|
this.$emit('update:lnurl', this.lnurl)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,6 @@ window.app.component('lnbits-qrcode', {
|
|||||||
mounted() {
|
mounted() {
|
||||||
this.$refs.qrCode.$el.style.maxWidth = this.maxWidth + 'px'
|
this.$refs.qrCode.$el.style.maxWidth = this.maxWidth + 'px'
|
||||||
this.$refs.qrCode.$el.setAttribute('width', '100%')
|
this.$refs.qrCode.$el.setAttribute('width', '100%')
|
||||||
this.$refs.qrCode.$el.setAttribute('height', null)
|
this.$refs.qrCode.$el.removeAttribute('height')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -575,6 +575,7 @@ window.WalletPageLogic = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.catch(LNbits.utils.notifyApiError)
|
||||||
},
|
},
|
||||||
authLnurl() {
|
authLnurl() {
|
||||||
const dismissAuthMsg = Quasar.Notify.create({
|
const dismissAuthMsg = Quasar.Notify.create({
|
||||||
|
|||||||
Vendored
+427
-305
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,7 @@ from .spark import SparkWallet
|
|||||||
from .strike import StrikeWallet
|
from .strike import StrikeWallet
|
||||||
from .void import VoidWallet
|
from .void import VoidWallet
|
||||||
from .zbd import ZBDWallet
|
from .zbd import ZBDWallet
|
||||||
|
from .fakeark import ArkFakeWallet
|
||||||
|
|
||||||
|
|
||||||
def set_funding_source(class_name: str | None = None) -> None:
|
def set_funding_source(class_name: str | None = None) -> None:
|
||||||
@@ -79,4 +80,5 @@ __all__ = [
|
|||||||
"StrikeWallet",
|
"StrikeWallet",
|
||||||
"VoidWallet",
|
"VoidWallet",
|
||||||
"ZBDWallet",
|
"ZBDWallet",
|
||||||
|
"ArkFakeWallet",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# lnbits/wallets/ark_fake.py
|
||||||
|
import asyncio, json, base64
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from datetime import datetime
|
||||||
|
from hashlib import sha256
|
||||||
|
from os import urandom
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
from lnbits.settings import settings
|
||||||
|
from lnbits.utils.crypto import fake_privkey # unused but kept for parity/logs
|
||||||
|
|
||||||
|
from .base import (
|
||||||
|
InvoiceResponse,
|
||||||
|
PaymentFailedStatus,
|
||||||
|
PaymentPendingStatus,
|
||||||
|
PaymentResponse,
|
||||||
|
PaymentStatus,
|
||||||
|
PaymentSuccessStatus,
|
||||||
|
StatusResponse,
|
||||||
|
Wallet,
|
||||||
|
)
|
||||||
|
|
||||||
|
def ark_encode(payload: dict[str, Any]) -> str:
|
||||||
|
raw = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode()
|
||||||
|
return "ark1" + base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||||
|
|
||||||
|
class ArkFakeWallet(Wallet):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.queue: asyncio.Queue[str] = asyncio.Queue(0)
|
||||||
|
self.payment_secrets: dict[str, str] = {}
|
||||||
|
self.paid_invoices: set[str] = set()
|
||||||
|
self.secret = settings.fake_wallet_secret
|
||||||
|
self.privkey = fake_privkey(self.secret)
|
||||||
|
|
||||||
|
async def cleanup(self): pass
|
||||||
|
|
||||||
|
async def status(self) -> StatusResponse:
|
||||||
|
logger.info("ArkFakeWallet: ARK-only test funding source (no bolt11).")
|
||||||
|
return StatusResponse(None, 1_000_000_000)
|
||||||
|
|
||||||
|
async def create_invoice(
|
||||||
|
self, amount: int, memo: str | None = None,
|
||||||
|
description_hash: bytes | None = None,
|
||||||
|
unhashed_description: bytes | None = None,
|
||||||
|
expiry: int | None = None,
|
||||||
|
payment_secret: bytes | None = None,
|
||||||
|
**_,
|
||||||
|
) -> InvoiceResponse:
|
||||||
|
preimage = urandom(32)
|
||||||
|
p_hash = sha256(preimage).hexdigest()
|
||||||
|
now = int(datetime.now().timestamp())
|
||||||
|
ticket = ark_encode({
|
||||||
|
"v": 1,
|
||||||
|
"ts": now,
|
||||||
|
"amt_sat": int(amount),
|
||||||
|
"memo": memo or "",
|
||||||
|
"hash": p_hash,
|
||||||
|
"exp": int(expiry or 3600),
|
||||||
|
"secret": (payment_secret.hex() if payment_secret else urandom(32).hex()),
|
||||||
|
})
|
||||||
|
self.payment_secrets[p_hash] = preimage.hex()
|
||||||
|
return InvoiceResponse(
|
||||||
|
ok=True,
|
||||||
|
checking_id=p_hash,
|
||||||
|
payment_request=ticket, # <-- non-bolt11, starts with ark1
|
||||||
|
preimage=preimage.hex(),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def pay_invoice(self, bolt11: str, _: int) -> PaymentResponse:
|
||||||
|
# Only allow paying our own tickets (like FakeWallet behavior)
|
||||||
|
# We don’t parse here because we only care about internal payments.
|
||||||
|
# If you want to parse/validate, you can mirror the core’s ark parser.
|
||||||
|
for p_hash in list(self.payment_secrets.keys()):
|
||||||
|
if p_hash in bolt11: # cheap check; or properly decode ark1 and compare its "hash"
|
||||||
|
await self.queue.put(p_hash)
|
||||||
|
self.paid_invoices.add(p_hash)
|
||||||
|
return PaymentResponse(
|
||||||
|
ok=True,
|
||||||
|
checking_id=p_hash,
|
||||||
|
fee_msat=0,
|
||||||
|
preimage=self.payment_secrets[p_hash],
|
||||||
|
)
|
||||||
|
return PaymentResponse(ok=False, error_message="Only internal ARK tickets can be used!")
|
||||||
|
|
||||||
|
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||||
|
if checking_id in self.paid_invoices:
|
||||||
|
return PaymentSuccessStatus()
|
||||||
|
if checking_id in self.payment_secrets:
|
||||||
|
return PaymentPendingStatus()
|
||||||
|
return PaymentFailedStatus()
|
||||||
|
|
||||||
|
async def get_payment_status(self, _: str) -> PaymentStatus:
|
||||||
|
return PaymentPendingStatus()
|
||||||
|
|
||||||
|
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||||
|
while settings.lnbits_running:
|
||||||
|
checking_id = await self.queue.get()
|
||||||
|
yield checking_id
|
||||||
@@ -226,9 +226,7 @@ class PhoenixdWallet(Wallet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||||
r = await self.client.get(
|
r = await self.client.get(f"/payments/incoming/{checking_id}")
|
||||||
f"/payments/incoming/{checking_id}?all=true&limit=1000"
|
|
||||||
)
|
|
||||||
if r.is_error:
|
if r.is_error:
|
||||||
if r.status_code == 404:
|
if r.status_code == 404:
|
||||||
# invoice does not exist in phoenixd, so it was never paid
|
# invoice does not exist in phoenixd, so it was never paid
|
||||||
@@ -260,9 +258,7 @@ class PhoenixdWallet(Wallet):
|
|||||||
return PaymentPendingStatus()
|
return PaymentPendingStatus()
|
||||||
|
|
||||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||||
r = await self.client.get(
|
r = await self.client.get(f"/payments/outgoingbyhash/{checking_id}")
|
||||||
f"/payments/outgoing/{checking_id}?all=true&limit=1000"
|
|
||||||
)
|
|
||||||
if r.is_error:
|
if r.is_error:
|
||||||
if r.status_code == 404:
|
if r.status_code == 404:
|
||||||
# payment does not exist in phoenixd, so it was never paid
|
# payment does not exist in phoenixd, so it was never paid
|
||||||
|
|||||||
Generated
+5
-5
@@ -6,7 +6,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "lnbits",
|
"name": "lnbits",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.8.2",
|
"axios": "^1.12.0",
|
||||||
"chart.js": "^4.4.4",
|
"chart.js": "^4.4.4",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"nostr-tools": "^2.7.2",
|
"nostr-tools": "^2.7.2",
|
||||||
@@ -402,13 +402,13 @@
|
|||||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.8.2",
|
"version": "1.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz",
|
||||||
"integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==",
|
"integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.6",
|
"follow-redirects": "^1.15.6",
|
||||||
"form-data": "^4.0.0",
|
"form-data": "^4.0.4",
|
||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@
|
|||||||
"sass": "^1.78.0"
|
"sass": "^1.78.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.8.2",
|
"axios": "^1.12.0",
|
||||||
"chart.js": "^4.4.4",
|
"chart.js": "^4.4.4",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"qrcode.vue": "^3.4.1",
|
"qrcode.vue": "^3.4.1",
|
||||||
|
|||||||
Generated
+4
-4
@@ -2134,14 +2134,14 @@ valkey = ["valkey (>=6)"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lnurl"
|
name = "lnurl"
|
||||||
version = "0.8.1"
|
version = "0.8.3"
|
||||||
description = "LNURL implementation for Python."
|
description = "LNURL implementation for Python."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.10"
|
python-versions = ">=3.10"
|
||||||
groups = ["main"]
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "lnurl-0.8.1-py3-none-any.whl", hash = "sha256:a451f1b78c62146fda7a17954e2eaa1de994092a19266b5c64c25577ecd66eae"},
|
{file = "lnurl-0.8.3-py3-none-any.whl", hash = "sha256:670cdeaef2c55de986dad89126ab58275d5199ba6554a93d9965d1e162080c2a"},
|
||||||
{file = "lnurl-0.8.1.tar.gz", hash = "sha256:7d2412c309227a3148510cbed92c3500c9366c624354eef2fc205bdf30d4655c"},
|
{file = "lnurl-0.8.3.tar.gz", hash = "sha256:8ca73af84fb9ee36a184d731d165f289ba7bc6260d4dadb2b6cf24f381c3afba"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
@@ -4592,4 +4592,4 @@ 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 = "d124502a4452455773117a06f08fec01bf4ac4980b617ed26b49f070a0b89cb8"
|
content-hash = "6b3b2f3c3163bc7a7bc2029ff6dd67f67c37057d9efbdf866b9d744f9e4ee9a5"
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "lnbits"
|
name = "lnbits"
|
||||||
version = "1.3.0-rc6"
|
version = "1.3.0-rc8"
|
||||||
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" }]
|
||||||
@@ -14,7 +14,7 @@ dependencies = [
|
|||||||
"starlette==0.47.1",
|
"starlette==0.47.1",
|
||||||
"httpx==0.27.0",
|
"httpx==0.27.0",
|
||||||
"jinja2==3.1.6",
|
"jinja2==3.1.6",
|
||||||
"lnurl==0.8.1",
|
"lnurl==0.8.3",
|
||||||
"pydantic==1.10.22",
|
"pydantic==1.10.22",
|
||||||
"pyqrcode==1.2.1",
|
"pyqrcode==1.2.1",
|
||||||
"shortuuid==1.0.13",
|
"shortuuid==1.0.13",
|
||||||
|
|||||||
+9
-20
@@ -1,5 +1,4 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
from http import HTTPStatus
|
|
||||||
from json import JSONDecodeError
|
from json import JSONDecodeError
|
||||||
from unittest.mock import AsyncMock, Mock
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
@@ -591,31 +590,26 @@ async def test_fiat_tracking(client, adminkey_headers_from, settings: Settings):
|
|||||||
"minWithdrawable": 1000,
|
"minWithdrawable": 1000,
|
||||||
"maxWithdrawable": 1_500_000,
|
"maxWithdrawable": 1_500_000,
|
||||||
},
|
},
|
||||||
{
|
{"status": "OK"},
|
||||||
"status": "OK",
|
{"success": True, "message": "Payment sent with NFC."},
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": "OK",
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
# Error loading LNURL request
|
# Error loading LNURL request
|
||||||
(
|
(
|
||||||
"error_loading_lnurl",
|
"error_loading_lnurl",
|
||||||
None,
|
None,
|
||||||
{
|
{
|
||||||
"status": "ERROR",
|
"detail": "Error loading callback request",
|
||||||
"reason": "Error loading callback request",
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
# LNURL response with error status
|
# LNURL response with error status
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
"status": "ERROR",
|
"status": "ERROR",
|
||||||
|
"reason": "Invalid LNURL-withdraw response.",
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
{
|
{
|
||||||
"status": "ERROR",
|
"detail": "Invalid LNURL-withdraw response.",
|
||||||
"reason": "Invalid LNURL-withdraw response.",
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
# Invalid LNURL-withdraw pay request
|
# Invalid LNURL-withdraw pay request
|
||||||
@@ -629,8 +623,7 @@ async def test_fiat_tracking(client, adminkey_headers_from, settings: Settings):
|
|||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
{
|
{
|
||||||
"status": "ERROR",
|
"detail": "Invalid LNURL-withdraw response.",
|
||||||
"reason": "Invalid LNURL-withdraw response.",
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
# Error loading callback request
|
# Error loading callback request
|
||||||
@@ -644,8 +637,7 @@ async def test_fiat_tracking(client, adminkey_headers_from, settings: Settings):
|
|||||||
},
|
},
|
||||||
"error_loading_callback",
|
"error_loading_callback",
|
||||||
{
|
{
|
||||||
"status": "ERROR",
|
"detail": "Error loading callback request",
|
||||||
"reason": "Error loading callback request",
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
# Callback response with error status
|
# Callback response with error status
|
||||||
@@ -662,8 +654,7 @@ async def test_fiat_tracking(client, adminkey_headers_from, settings: Settings):
|
|||||||
"reason": "Callback failed",
|
"reason": "Callback failed",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"status": "ERROR",
|
"detail": "Callback failed",
|
||||||
"reason": "Callback failed",
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
# Unexpected exception during LNURL response JSON parsing
|
# Unexpected exception during LNURL response JSON parsing
|
||||||
@@ -671,8 +662,7 @@ async def test_fiat_tracking(client, adminkey_headers_from, settings: Settings):
|
|||||||
"exception_in_lnurl_response_json",
|
"exception_in_lnurl_response_json",
|
||||||
None,
|
None,
|
||||||
{
|
{
|
||||||
"status": "ERROR",
|
"detail": "Invalid JSON response from https://example.com/lnurl",
|
||||||
"reason": "Invalid JSON response from https://example.com/lnurl",
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -768,7 +758,6 @@ async def test_api_payment_pay_with_nfc(
|
|||||||
json={"lnurl_w": lnurl},
|
json={"lnurl_w": lnurl},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == HTTPStatus.OK
|
|
||||||
assert response.json() == expected_response
|
assert response.json() == expected_response
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2474,7 +2474,7 @@
|
|||||||
},
|
},
|
||||||
"phoenixd": {
|
"phoenixd": {
|
||||||
"get_payment_status_endpoint": {
|
"get_payment_status_endpoint": {
|
||||||
"uri": "/payments/outgoing/e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
|
"uri": "/payments/outgoingbyhash/e35526a43d04e985594c0dfab848814f524b1c786598ec9a63beddb2d726ac96",
|
||||||
"headers": {
|
"headers": {
|
||||||
"Authorization": "Basic OmYxNzFiYTAyMmE3NjRlNjc5ZWVmOTUwYjIxZmIxYzA0ZjE3MWJhMDIyYTc2NGU2NzllZWY5NTBiMjFmYjFjMDQ=",
|
"Authorization": "Basic OmYxNzFiYTAyMmE3NjRlNjc5ZWVmOTUwYjIxZmIxYzA0ZjE3MWJhMDIyYTc2NGU2NzllZWY5NTBiMjFmYjFjMDQ=",
|
||||||
"User-Agent": "LNbits/Tests"
|
"User-Agent": "LNbits/Tests"
|
||||||
|
|||||||
@@ -1260,7 +1260,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lnbits"
|
name = "lnbits"
|
||||||
version = "1.3.0rc6"
|
version = "1.3.0rc8"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
@@ -1363,7 +1363,7 @@ requires-dist = [
|
|||||||
{ name = "itsdangerous", specifier = "==2.2.0" },
|
{ name = "itsdangerous", specifier = "==2.2.0" },
|
||||||
{ name = "jinja2", specifier = "==3.1.6" },
|
{ name = "jinja2", specifier = "==3.1.6" },
|
||||||
{ name = "jsonpath-ng", specifier = "==1.7.0" },
|
{ name = "jsonpath-ng", specifier = "==1.7.0" },
|
||||||
{ name = "lnurl", specifier = "==0.8.1" },
|
{ name = "lnurl", specifier = "==0.8.3" },
|
||||||
{ name = "loguru", specifier = "==0.7.3" },
|
{ name = "loguru", specifier = "==0.7.3" },
|
||||||
{ name = "nostr-sdk", specifier = "==0.42.1" },
|
{ name = "nostr-sdk", specifier = "==0.42.1" },
|
||||||
{ name = "packaging", specifier = "==25.0" },
|
{ name = "packaging", specifier = "==25.0" },
|
||||||
@@ -1418,7 +1418,7 @@ dev = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lnurl"
|
name = "lnurl"
|
||||||
version = "0.8.1"
|
version = "0.8.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "bech32" },
|
{ name = "bech32" },
|
||||||
@@ -1429,9 +1429,9 @@ dependencies = [
|
|||||||
{ name = "pycryptodomex" },
|
{ name = "pycryptodomex" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/32/b611804e96fd1e76e9bdf306395e4a9e52f9343994b65fd9646e48318107/lnurl-0.8.1.tar.gz", hash = "sha256:7d2412c309227a3148510cbed92c3500c9366c624354eef2fc205bdf30d4655c", size = 17096, upload-time = "2025-08-27T05:50:19.948Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/9b/dc/29cce6bb8688622c7f5f6db4355c54b5c87bb05be58e49374fd2c6f4b0f2/lnurl-0.8.3.tar.gz", hash = "sha256:8ca73af84fb9ee36a184d731d165f289ba7bc6260d4dadb2b6cf24f381c3afba", size = 17171, upload-time = "2025-09-08T13:30:56.636Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/bf/6061b01bfc62ad3d1f82d8546dc543fc2ccc391f0911d5718abe578b0661/lnurl-0.8.1-py3-none-any.whl", hash = "sha256:a451f1b78c62146fda7a17954e2eaa1de994092a19266b5c64c25577ecd66eae", size = 17382, upload-time = "2025-08-27T05:50:19.013Z" },
|
{ url = "https://files.pythonhosted.org/packages/fd/99/e400734afb7469a0cfa661259de2bb66417d6c3f14361444a010e9d186ee/lnurl-0.8.3-py3-none-any.whl", hash = "sha256:670cdeaef2c55de986dad89126ab58275d5199ba6554a93d9965d1e162080c2a", size = 17459, upload-time = "2025-09-08T13:30:55.691Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Reference in New Issue
Block a user