Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55484b9cb4 | ||
|
|
4db7d20e60 | ||
|
|
02fec90a29 | ||
|
|
4efe1a9fca | ||
|
|
cba8c02d68 | ||
|
|
5c9d375713 | ||
|
|
264d1fa07c | ||
|
|
fc3e2b2b4d | ||
|
|
64edeebaaf | ||
|
|
c2739188d0 | ||
|
|
3f4d778c95 | ||
|
|
c1788c7219 | ||
|
|
07c62285c3 | ||
|
|
841c8ce3a9 | ||
|
|
cc6430221c | ||
|
|
4a34548864 | ||
|
|
4a3a3a0425 | ||
|
|
bf8b33a7b2 | ||
|
|
7e08211887 | ||
|
|
0bbb0aca2d | ||
|
|
9b1418b1f4 | ||
|
|
5f0a8d78e0 | ||
|
|
2fe8ae1cb1 | ||
|
|
1a3d23bbee | ||
|
|
ae45c1b958 | ||
|
|
8337351d6a | ||
|
|
f4dbd369be | ||
|
|
974ee26224 | ||
|
|
e4fb4453c3 | ||
|
|
aa7129a351 | ||
|
|
aba6e4cd87 | ||
|
|
f4e7d0c70c | ||
|
|
4247b9d9a3 |
+21
-1
@@ -40,7 +40,7 @@ PORT=5000
|
||||
######################################
|
||||
|
||||
# which fundingsources are allowed in the admin ui
|
||||
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet"
|
||||
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet"
|
||||
|
||||
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
||||
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
||||
@@ -61,6 +61,20 @@ CLICHE_ENDPOINT=ws://127.0.0.1:12000
|
||||
SPARK_URL=http://localhost:9737/rpc
|
||||
SPARK_TOKEN=myaccesstoken
|
||||
|
||||
#CLNRest (using runes)
|
||||
CLNREST_URL=https://127.0.0.1:3010
|
||||
CLNREST_CA=/home/lightningd/.lightning/bitcoin/ca.pem
|
||||
CLNREST_CERT=/home/lightningd/.lightning/bitcoin/server.pem
|
||||
# CLNREST_CA = cat ca.pem | awk '{printf "%s\\n", $0} END {printf "\n"}'
|
||||
# CLNREST_CERT = cat server.pem | awk '{printf "%s\\n", $0} END {printf "\n"}'
|
||||
|
||||
CLNREST_READONLY_RUNE=lightning-cli createrune restrictions='[["method=listfunds", "method=listpays", "method=listinvoices", "method=getinfo", "method=summary", "method=waitanyinvoice"]]' | jq -r .rune
|
||||
CLNREST_INVOICE_RUNE=lightning-cli createrune restrictions='[["method=invoice"], ["pnameamount_msat<1000001"], ["pnamelabel^LNbits"], ["rate=60"]]' | jq -r .rune
|
||||
CLNREST_PAY_RUNE=lightning-cli createrune restrictions='[["method=pay"], ["pinvbolt11_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune
|
||||
#CLNREST_RENEPAY_RUNE=lightning-cli createrune restrictions='[["method=renepay"], ["pinvinvstring_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune
|
||||
#CLNREST_LAST_PAY_INDEX='lightning-cli listinvoices | jq -r '.invoices | map(.created_index) | max'
|
||||
#CLNREST_NODEID=lightning-cli getinfo | jq -r .id # only required for v23.08
|
||||
|
||||
# CoreLightningWallet
|
||||
CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
|
||||
|
||||
@@ -153,6 +167,12 @@ BREEZ_GREENLIGHT_DEVICE_KEY="/path/to/breezsdk/device.pem" # or BASE64/HEXSTRIN
|
||||
BREEZ_GREENLIGHT_DEVICE_CERT="/path/to/breezsdk/device.crt" # or BASE64/HEXSTRING
|
||||
# BREEZ_USE_TRAMPOLINE=true
|
||||
|
||||
# BreezLiquidSdkWallet
|
||||
# get your own api key here https://breez.technology/request-api-key/#contact-us-form-sdk
|
||||
# or keep the api key empty to use the LNbits key for referrals (API key is not a secret)
|
||||
# BREEZ_LIQUID_API_KEY=""
|
||||
BREEZ_LIQUID_SEED="MNEMONIC SEED PHRASE"
|
||||
# BREEZ_LIQUID_FEE_OFFSET_SAT=50
|
||||
|
||||
# Google OAuth Config
|
||||
# Make sure that the authorized redirect URIs contain https://{domain}/api/v1/auth/google/token
|
||||
|
||||
@@ -43,9 +43,7 @@ runs:
|
||||
shell: bash
|
||||
run: |
|
||||
poetry env use python${{ inputs.python-version }}
|
||||
poetry install
|
||||
# needed for conv tests
|
||||
poetry add psycopg2-binary
|
||||
poetry install --all-extras
|
||||
|
||||
- name: Use Node.js ${{ inputs.node-version }}
|
||||
if: ${{ (inputs.npm == 'true') }}
|
||||
|
||||
@@ -12,6 +12,23 @@ A backend wallet can be configured using the following LNbits environment variab
|
||||
|
||||
You can [compare the LNbits compatible Lightning Network funding sources here](wallets.md).
|
||||
|
||||
### CLNRest (using [runes](https://docs.corelightning.org/reference/lightning-createrune))
|
||||
|
||||
[Core lightning Rest API docs](https://docs.corelightning.org/docs/rest)
|
||||
|
||||
Should also work with the [Rust version of CLNRest](https://github.com/daywalker90/clnrest-rs)
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **CLNRestWallet**
|
||||
- `CLNREST_URL`: `https://127.0.0.1:3010`
|
||||
- `CLNREST_CA`: `/home/lightningd/.lightning/bitcoin/ca.pem` (or the content of the `ca.pem` file)
|
||||
- `CLNREST_CERT`: `/home/lightningd/.lightning/bitcoin/server.pem` (or the content of the `server.pem` file)
|
||||
- `CLNREST_READONLY_RUNE`: `lightning-cli createrune restrictions='[["method=listfunds", "method=listpays", "method=listinvoices", "method=getinfo", "method=summary", "method=waitanyinvoice"]]' | jq -r .rune`
|
||||
- `CLNREST_INVOICE_RUNE`: `lightning-cli createrune restrictions='[["method=invoice"], ["pnameamount_msat<1000001"], ["pnamelabel^LNbits"], ["rate=60"]]' | jq -r .rune`
|
||||
- `CLNREST_PAY_RUNE`: `lightning-cli createrune restrictions='[["method=pay"], ["pinvbolt11_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune`
|
||||
- `CLNREST_RENEPAY_RUNE`: `lightning-cli createrune restrictions='[["method=renepay"], ["pinvinvstring_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune`
|
||||
- `CLNREST_LAST_PAY_INDEX`: `lightning-cli listinvoices | jq -r '.invoices | map(.created_index) | max' `
|
||||
- `CLNREST_NODEID`: `lightning-cli getinfo | jq -r .id` (only required for v23.08)
|
||||
|
||||
### CoreLightning
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
|
||||
@@ -19,6 +36,8 @@ You can [compare the LNbits compatible Lightning Network funding sources here](w
|
||||
|
||||
### CoreLightning REST
|
||||
|
||||
This is the old REST interface that uses [Ride The Lightning/c-lightning-REST](https://github.com/Ride-The-Lightning/c-lightning-REST)
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningRestWallet**
|
||||
- `CORELIGHTNING_REST_URL`: http://127.0.0.1:8185/
|
||||
- `CORELIGHTNING_REST_MACAROON`: /file/path/admin.macaroon or Base64/Hex
|
||||
@@ -135,6 +154,18 @@ A Greenlight invite code or Greenlight partner certificate/key can be used to re
|
||||
- `BREEZ_GREENLIGHT_DEVICE_KEY`: /path/to/breezsdk/device.pem or Base64/Hex
|
||||
- `BREEZ_GREENLIGHT_DEVICE_CERT`: /path/to/breezsdk/device.crt or Base64/Hex
|
||||
|
||||
### Breez Liquid SDK
|
||||
|
||||
This funding source leverages the [Breez SDK - Liquid](https://sdk-doc-liquid.breez.technology/) to manage all Lightning payments via submarine swaps on the Liquid network. To get started, simply provide a mnemonic seed phrase. The easiest way to generate one is by using a liquid wallet, such as [Blockstream Green](https://blockstream.com/green/). Once generated, you can copy the seed to your environment variable or enter it in the admin UI.
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **BreezLiquidSdkWallet**
|
||||
- `BREEZ_LIQUID_SEED`: ...
|
||||
|
||||
Each submarine swap incurs service and on-chain fees. To account for these, you may need to increase the reserve fee in the admin UI by navigating to **Settings -> Funding**, or by setting the following environment variables:
|
||||
|
||||
- `LNBITS_RESERVE_FEE_MIN`: ...
|
||||
- `LNBITS_RESERVE_FEE_PERCENT`: ...
|
||||
|
||||
### Cliche Wallet
|
||||
|
||||
- `CLICHE_ENDPOINT`: ws://127.0.0.1:12000
|
||||
|
||||
+6
-9
@@ -27,6 +27,7 @@ from lnbits.core.helpers import migrate_extension_database
|
||||
from lnbits.core.models.notifications import NotificationType
|
||||
from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions
|
||||
from lnbits.core.services.notifications import enqueue_notification
|
||||
from lnbits.core.services.payments import check_pending_payments
|
||||
from lnbits.core.tasks import (
|
||||
audit_queue,
|
||||
collect_exchange_rates_data,
|
||||
@@ -65,11 +66,7 @@ from .middleware import (
|
||||
add_ip_block_middleware,
|
||||
add_ratelimit_middleware,
|
||||
)
|
||||
from .tasks import (
|
||||
check_pending_payments,
|
||||
internal_invoice_listener,
|
||||
invoice_listener,
|
||||
)
|
||||
from .tasks import internal_invoice_listener, invoice_listener, run_interval
|
||||
|
||||
|
||||
async def startup(app: FastAPI):
|
||||
@@ -277,7 +274,7 @@ async def check_installed_extensions(app: FastAPI):
|
||||
logger.info(f"{ext.id} ({ext.installed_version})")
|
||||
|
||||
|
||||
async def build_all_installed_extensions_list(
|
||||
async def build_all_installed_extensions_list( # noqa: C901
|
||||
include_deactivated: Optional[bool] = True,
|
||||
) -> list[InstallableExtension]:
|
||||
"""
|
||||
@@ -451,7 +448,7 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
||||
app.include_router(router=ext_route, prefix=prefix)
|
||||
|
||||
|
||||
async def check_and_register_extensions(app: FastAPI):
|
||||
async def check_and_register_extensions(app: FastAPI) -> None:
|
||||
await check_installed_extensions(app)
|
||||
for ext in await get_valid_extensions(False):
|
||||
try:
|
||||
@@ -461,12 +458,12 @@ async def check_and_register_extensions(app: FastAPI):
|
||||
logger.error(f"Could not load extension `{ext.code}`: {exc!s}")
|
||||
|
||||
|
||||
def register_async_tasks():
|
||||
def register_async_tasks() -> None:
|
||||
|
||||
create_permanent_task(wait_for_audit_data)
|
||||
create_permanent_task(wait_notification_messages)
|
||||
|
||||
create_permanent_task(check_pending_payments)
|
||||
create_permanent_task(run_interval(30 * 60, check_pending_payments))
|
||||
create_permanent_task(invoice_listener)
|
||||
create_permanent_task(internal_invoice_listener)
|
||||
create_permanent_task(cache.invalidate_forever)
|
||||
|
||||
+7
-7
@@ -320,9 +320,8 @@ async def extensions_list():
|
||||
from lnbits.app import build_all_installed_extensions_list
|
||||
|
||||
for ext in await build_all_installed_extensions_list():
|
||||
assert (
|
||||
ext.meta and ext.meta.installed_release
|
||||
), f"Extension {ext.id} has no installed_release"
|
||||
if not ext.meta or not ext.meta.installed_release:
|
||||
raise ValueError(f"Extension {ext.id} has no installed_release")
|
||||
click.echo(f" - {ext.id} ({ext.meta.installed_release.version})")
|
||||
|
||||
|
||||
@@ -353,7 +352,7 @@ async def extensions_list():
|
||||
help="Admin user ID (must have permissions to install extensions).",
|
||||
)
|
||||
@coro
|
||||
async def extensions_update(
|
||||
async def extensions_update( # noqa: C901
|
||||
extension: Optional[str] = None,
|
||||
all_extensions: Optional[bool] = False,
|
||||
repo_index: Optional[str] = None,
|
||||
@@ -608,9 +607,10 @@ async def update_extension(
|
||||
|
||||
click.echo(f"Current '{extension}' version: {installed_ext.installed_version}.")
|
||||
|
||||
assert (
|
||||
installed_ext.meta and installed_ext.meta.installed_release
|
||||
), "Cannot find previously installed release. Please uninstall first."
|
||||
if not installed_ext.meta or not installed_ext.meta.installed_release:
|
||||
raise ValueError(
|
||||
"Cannot find previously installed release. Please uninstall first."
|
||||
)
|
||||
|
||||
release = await _select_release(extension, repo_index, source_repo)
|
||||
if not release:
|
||||
|
||||
@@ -30,10 +30,11 @@ async def delete_expired_audit_entries(
|
||||
conn: Optional[Connection] = None,
|
||||
):
|
||||
await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
DELETE from audit
|
||||
WHERE delete_at < {db.timestamp_now}
|
||||
""",
|
||||
""", # noqa: S608
|
||||
)
|
||||
|
||||
|
||||
@@ -48,13 +49,16 @@ async def get_count_stats(
|
||||
filters = Filters()
|
||||
clause = filters.where()
|
||||
data = await (conn or db).fetchall(
|
||||
# SQL injection vectors safety:
|
||||
# - `field` is a static string, not user input
|
||||
# - `clause` is generated from filters, which are validated and sanitized
|
||||
query=f"""
|
||||
SELECT {field} as field, count({field}) as total
|
||||
FROM audit
|
||||
{clause}
|
||||
GROUP BY {field}
|
||||
ORDER BY {field}
|
||||
""",
|
||||
""", # noqa: S608
|
||||
values=filters.values(),
|
||||
model=AuditCountStat,
|
||||
)
|
||||
@@ -70,13 +74,15 @@ async def get_long_duration_stats(
|
||||
filters = Filters()
|
||||
clause = filters.where()
|
||||
long_duration_paths = await (conn or db).fetchall(
|
||||
# This query is safe from SQL injection
|
||||
# The `clause` is constructed from sanitized inputs
|
||||
query=f"""
|
||||
SELECT path as field, max(duration) as total FROM audit
|
||||
{clause}
|
||||
GROUP BY path
|
||||
ORDER BY total DESC
|
||||
LIMIT 5
|
||||
""",
|
||||
""", # noqa: S608
|
||||
values=filters.values(),
|
||||
model=AuditCountStat,
|
||||
)
|
||||
|
||||
@@ -50,7 +50,8 @@ async def drop_extension_db(ext_id: str, conn: Optional[Connection] = None) -> N
|
||||
{"id": ext_id},
|
||||
)
|
||||
# Check that 'ext_id' is a valid extension id and not a malicious string
|
||||
assert row, f"Extension '{ext_id}' db version cannot be found"
|
||||
if not row:
|
||||
raise Exception(f"Extension '{ext_id}' db version cannot be found")
|
||||
|
||||
is_file_based_db = await Database.clean_ext_db_files(ext_id)
|
||||
if is_file_based_db:
|
||||
@@ -78,10 +79,13 @@ async def get_installed_extensions(
|
||||
active: Optional[bool] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> list[InstallableExtension]:
|
||||
where = "WHERE active = :active" if active is not None else ""
|
||||
query = "SELECT * FROM installed_extensions"
|
||||
if active is not None:
|
||||
query += " WHERE active = :active"
|
||||
|
||||
values = {"active": active} if active is not None else {}
|
||||
all_extensions = await (conn or db).fetchall(
|
||||
f"SELECT * FROM installed_extensions {where}",
|
||||
query,
|
||||
values,
|
||||
model=InstallableExtension,
|
||||
)
|
||||
|
||||
@@ -19,8 +19,13 @@ from ..models import (
|
||||
)
|
||||
|
||||
|
||||
def update_payment_extra():
|
||||
pass
|
||||
async def update_payment_extra(
|
||||
payment: Payment, conn: Optional[Connection] = None
|
||||
) -> Payment:
|
||||
return await (conn or db).execute(
|
||||
"UPDATE apipayments SET extra = :extra WHERE checking_id = :checking_id",
|
||||
{"extra": payment.extra, "checking_id": payment.checking_id},
|
||||
)
|
||||
|
||||
|
||||
async def get_payment(checking_id: str, conn: Optional[Connection] = None) -> Payment:
|
||||
@@ -50,11 +55,13 @@ async def get_standalone_payment(
|
||||
clause = f"({clause}) AND wallet_id = :wallet_id"
|
||||
|
||||
row = await (conn or db).fetchone(
|
||||
# This query is safe from SQL injection
|
||||
# The `clause` is constructed from sanitized inputs
|
||||
f"""
|
||||
SELECT * FROM apipayments
|
||||
WHERE {clause}
|
||||
ORDER BY amount LIMIT 1
|
||||
""",
|
||||
""", # noqa: S608
|
||||
values,
|
||||
Payment,
|
||||
)
|
||||
@@ -80,19 +87,25 @@ async def get_latest_payments_by_extension(
|
||||
ext_name: str, ext_id: str, limit: int = 5
|
||||
) -> list[Payment]:
|
||||
return await db.fetchall(
|
||||
# This query is safe from SQL injection
|
||||
# The limtit is an integer and not user input
|
||||
f"""
|
||||
SELECT * FROM apipayments
|
||||
WHERE status = '{PaymentState.SUCCESS}'
|
||||
WHERE status = :status
|
||||
AND extra LIKE :ext_name
|
||||
AND extra LIKE :ext_id
|
||||
ORDER BY time DESC LIMIT {int(limit)}
|
||||
""",
|
||||
{"ext_name": f"%{ext_name}%", "ext_id": f"%{ext_id}%"},
|
||||
""", # noqa: S608
|
||||
{
|
||||
"status": f"{PaymentState.SUCCESS}",
|
||||
"ext_name": f"%{ext_name}%",
|
||||
"ext_id": f"%{ext_id}%",
|
||||
},
|
||||
Payment,
|
||||
)
|
||||
|
||||
|
||||
async def get_payments_paginated(
|
||||
async def get_payments_paginated( # noqa: C901
|
||||
*,
|
||||
wallet_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
@@ -227,21 +240,23 @@ async def delete_expired_invoices(
|
||||
# first we delete all invoices older than one month
|
||||
|
||||
await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
DELETE FROM apipayments
|
||||
WHERE status = '{PaymentState.PENDING}' AND amount > 0
|
||||
WHERE status = :status AND amount > 0
|
||||
AND time < {db.timestamp_placeholder("delta")}
|
||||
""",
|
||||
{"delta": int(time() - 2592000)},
|
||||
""", # noqa: S608
|
||||
{"status": f"{PaymentState.PENDING}", "delta": int(time() - 2592000)},
|
||||
)
|
||||
# then we delete all invoices whose expiry date is in the past
|
||||
await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
DELETE FROM apipayments
|
||||
WHERE status = '{PaymentState.PENDING}' AND amount > 0
|
||||
WHERE status = :status AND amount > 0
|
||||
AND expiry < {db.timestamp_placeholder("now")}
|
||||
""",
|
||||
{"now": int(time())},
|
||||
""", # noqa: S608
|
||||
{"status": f"{PaymentState.PENDING}", "now": int(time())},
|
||||
)
|
||||
|
||||
|
||||
@@ -254,7 +269,8 @@ async def create_payment(
|
||||
# we don't allow the creation of the same invoice twice
|
||||
# note: this can be removed if the db uniqueness constraints are set appropriately
|
||||
previous_payment = await get_standalone_payment(checking_id, conn=conn)
|
||||
assert previous_payment is None, "Payment already exists"
|
||||
if previous_payment is not None:
|
||||
raise ValueError("Payment already exists")
|
||||
extra = data.extra or {}
|
||||
|
||||
payment = Payment(
|
||||
@@ -268,7 +284,7 @@ async def create_payment(
|
||||
preimage=data.preimage,
|
||||
expiry=data.expiry,
|
||||
webhook=data.webhook,
|
||||
fee=data.fee,
|
||||
fee=-abs(data.fee),
|
||||
tag=extra.get("tag", None),
|
||||
extra=extra,
|
||||
)
|
||||
@@ -321,16 +337,20 @@ async def get_payments_history(
|
||||
)
|
||||
"""
|
||||
]
|
||||
clause = filters.where(where)
|
||||
transactions: list[dict] = await db.fetchall(
|
||||
# This query is safe from SQL injection:
|
||||
# - `date_trunc` is a static string, not user input
|
||||
# - `clause` is generated from filters, which are validated and sanitized
|
||||
f"""
|
||||
SELECT {date_trunc} date,
|
||||
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) income,
|
||||
SUM(CASE WHEN amount < 0 THEN abs(amount) + abs(fee) ELSE 0 END) spending
|
||||
FROM apipayments
|
||||
{filters.where(where)}
|
||||
{clause}
|
||||
GROUP BY date
|
||||
ORDER BY date DESC
|
||||
""",
|
||||
""", # noqa: S608
|
||||
filters.values(values),
|
||||
)
|
||||
if wallet_id:
|
||||
@@ -365,7 +385,6 @@ async def get_payment_count_stats(
|
||||
user_id: Optional[str] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> list[PaymentCountStat]:
|
||||
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
extra_stmts = []
|
||||
@@ -376,13 +395,16 @@ async def get_payment_count_stats(
|
||||
|
||||
clause = filters.where(extra_stmts)
|
||||
data = await (conn or db).fetchall(
|
||||
# SQL injection vectors safety:
|
||||
# - `field` is a static string, not user input
|
||||
# - `clause` is generated from filters, which are validated and sanitized
|
||||
query=f"""
|
||||
SELECT {field} as field, count(*) as total
|
||||
FROM apipayments
|
||||
{clause}
|
||||
GROUP BY {field}
|
||||
ORDER BY {field}
|
||||
""",
|
||||
""", # noqa: S608
|
||||
values=filters.values(),
|
||||
model=PaymentCountStat,
|
||||
)
|
||||
@@ -395,7 +417,6 @@ async def get_daily_stats(
|
||||
user_id: Optional[str] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
|
||||
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
|
||||
@@ -445,7 +466,6 @@ async def get_wallets_stats(
|
||||
user_id: Optional[str] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> list[PaymentWalletStats]:
|
||||
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
|
||||
@@ -468,6 +488,8 @@ async def get_wallets_stats(
|
||||
clauses = filters.where(where_stmts)
|
||||
|
||||
data = await (conn or db).fetchall(
|
||||
# This query is safe from SQL injection
|
||||
# The `clauses` is constructed from sanitized inputs
|
||||
query=f"""
|
||||
SELECT apipayments.wallet_id,
|
||||
MAX(wallets.name) AS wallet_name,
|
||||
@@ -479,7 +501,7 @@ async def get_wallets_stats(
|
||||
{clauses}
|
||||
GROUP BY apipayments.wallet_id
|
||||
ORDER BY payments_count
|
||||
""",
|
||||
""", # noqa: S608
|
||||
values=filters.values(),
|
||||
model=PaymentWalletStats,
|
||||
)
|
||||
@@ -504,11 +526,11 @@ async def check_internal(
|
||||
otherwise None
|
||||
"""
|
||||
return await (conn or db).fetchone(
|
||||
f"""
|
||||
"""
|
||||
SELECT * FROM apipayments
|
||||
WHERE payment_hash = :hash AND status = '{PaymentState.PENDING}' AND amount > 0
|
||||
WHERE payment_hash = :hash AND status = :status AND amount > 0
|
||||
""",
|
||||
{"hash": payment_hash},
|
||||
{"status": f"{PaymentState.PENDING}", "hash": payment_hash},
|
||||
Payment,
|
||||
)
|
||||
|
||||
|
||||
@@ -56,7 +56,8 @@ async def update_admin_settings(
|
||||
async def update_super_user(super_user: str) -> SuperSettings:
|
||||
await set_settings_field("super_user", super_user)
|
||||
settings = await get_super_settings()
|
||||
assert settings, "updated super_user settings could not be retrieved"
|
||||
if not settings:
|
||||
raise ValueError("updated super_user settings could not be retrieved")
|
||||
return settings
|
||||
|
||||
|
||||
@@ -86,7 +87,8 @@ async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSet
|
||||
await set_settings_field(key, value)
|
||||
|
||||
settings = await get_super_settings()
|
||||
assert settings, "created admin settings could not be retrieved"
|
||||
if not settings:
|
||||
raise ValueError("created admin settings could not be retrieved")
|
||||
return settings
|
||||
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ async def delete_accounts_no_wallets(
|
||||
) -> None:
|
||||
delta = int(time()) - time_delta
|
||||
await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
DELETE FROM accounts
|
||||
WHERE NOT EXISTS (
|
||||
@@ -118,7 +119,7 @@ async def delete_accounts_no_wallets(
|
||||
(updated_at is null AND created_at < :delta)
|
||||
OR updated_at < {db.timestamp_placeholder("delta")}
|
||||
)
|
||||
""",
|
||||
""", # noqa: S608
|
||||
{"delta": delta},
|
||||
)
|
||||
|
||||
|
||||
+26
-21
@@ -48,11 +48,12 @@ async def delete_wallet(
|
||||
) -> None:
|
||||
now = int(time())
|
||||
await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
UPDATE wallets
|
||||
SET deleted = :deleted, updated_at = {db.timestamp_placeholder('now')}
|
||||
WHERE id = :wallet AND "user" = :user
|
||||
""",
|
||||
""", # noqa: S608
|
||||
{"wallet": wallet_id, "user": user_id, "deleted": deleted, "now": now},
|
||||
)
|
||||
|
||||
@@ -71,11 +72,12 @@ async def delete_wallet_by_id(
|
||||
) -> Optional[int]:
|
||||
now = int(time())
|
||||
result = await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
UPDATE wallets
|
||||
SET deleted = true, updated_at = {db.timestamp_placeholder('now')}
|
||||
WHERE id = :wallet
|
||||
""",
|
||||
""", # noqa: S608
|
||||
{"wallet": wallet_id, "now": now},
|
||||
)
|
||||
return result.rowcount
|
||||
@@ -107,14 +109,16 @@ async def delete_unused_wallets(
|
||||
async def get_wallet(
|
||||
wallet_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
|
||||
) -> Optional[Wallet]:
|
||||
where = "AND deleted = :deleted" if deleted is not None else ""
|
||||
query = """
|
||||
SELECT *, COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
), 0) AS balance_msat FROM wallets
|
||||
WHERE id = :wallet
|
||||
"""
|
||||
if deleted is not None:
|
||||
query += " AND deleted = :deleted "
|
||||
return await (conn or db).fetchone(
|
||||
f"""
|
||||
SELECT *, COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
), 0) AS balance_msat FROM wallets
|
||||
WHERE id = :wallet {where}
|
||||
""",
|
||||
query,
|
||||
{"wallet": wallet_id, "deleted": deleted},
|
||||
Wallet,
|
||||
)
|
||||
@@ -123,14 +127,16 @@ async def get_wallet(
|
||||
async def get_wallets(
|
||||
user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
|
||||
) -> list[Wallet]:
|
||||
where = "AND deleted = :deleted" if deleted is not None else ""
|
||||
query = """
|
||||
SELECT *, COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
), 0) AS balance_msat FROM wallets
|
||||
WHERE "user" = :user
|
||||
"""
|
||||
if deleted is not None:
|
||||
query += " AND deleted = :deleted "
|
||||
return await (conn or db).fetchall(
|
||||
f"""
|
||||
SELECT *, COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
), 0) AS balance_msat FROM wallets
|
||||
WHERE "user" = :user {where}
|
||||
""",
|
||||
query,
|
||||
{"user": user_id, "deleted": deleted},
|
||||
Wallet,
|
||||
)
|
||||
@@ -162,12 +168,11 @@ async def get_wallets_paginated(
|
||||
async def get_wallets_ids(
|
||||
user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
|
||||
) -> list[str]:
|
||||
where = "AND deleted = :deleted" if deleted is not None else ""
|
||||
query = """SELECT id FROM wallets WHERE "user" = :user"""
|
||||
if deleted is not None:
|
||||
query += "AND deleted = :deleted"
|
||||
result: list[dict] = await (conn or db).fetchall(
|
||||
f"""
|
||||
SELECT id FROM wallets
|
||||
WHERE "user" = :user {where}
|
||||
""",
|
||||
query,
|
||||
{"user": user_id, "deleted": deleted},
|
||||
)
|
||||
return [row["id"] for row in result]
|
||||
|
||||
@@ -37,7 +37,8 @@ async def create_webpush_subscription(
|
||||
{"endpoint": endpoint, "user": user, "data": data, "host": host},
|
||||
)
|
||||
subscription = await get_webpush_subscription(endpoint, user)
|
||||
assert subscription, "Newly created webpush subscription couldn't be retrieved"
|
||||
if not subscription:
|
||||
raise ValueError("Newly created webpush subscription couldn't be retrieved")
|
||||
return subscription
|
||||
|
||||
|
||||
|
||||
@@ -214,6 +214,7 @@ async def m007_set_invoice_expiries(db: Connection):
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
SELECT bolt11, checking_id
|
||||
FROM apipayments
|
||||
@@ -222,7 +223,7 @@ async def m007_set_invoice_expiries(db: Connection):
|
||||
AND bolt11 IS NOT NULL
|
||||
AND expiry IS NULL
|
||||
AND time < {db.timestamp_now}
|
||||
"""
|
||||
""" # noqa: S608
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
if len(rows):
|
||||
@@ -242,13 +243,15 @@ async def m007_set_invoice_expiries(db: Connection):
|
||||
f" {invoice.payment_hash} to {expiration_date}"
|
||||
)
|
||||
await db.execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
UPDATE apipayments SET expiry = {db.timestamp_placeholder('expiry')}
|
||||
WHERE checking_id = :checking_id AND amount > 0
|
||||
""",
|
||||
""", # noqa: S608
|
||||
{"expiry": expiration_date, "checking_id": checking_id},
|
||||
)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.debug(exc)
|
||||
continue
|
||||
except OperationalError:
|
||||
# this is necessary now because it may be the case that this migration will
|
||||
@@ -371,7 +374,8 @@ async def m014_set_deleted_wallets(db: Connection):
|
||||
"wallet": row.get("id"),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.debug(exc)
|
||||
continue
|
||||
except OperationalError:
|
||||
# this is necessary now because it may be the case that this migration will
|
||||
@@ -454,17 +458,19 @@ async def m017_add_timestamp_columns_to_accounts_and_wallets(db: Connection):
|
||||
# set all to now where they are null
|
||||
now = int(time())
|
||||
await db.execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
UPDATE wallets SET created_at = {db.timestamp_placeholder('now')}
|
||||
WHERE created_at IS NULL
|
||||
""",
|
||||
""", # noqa: S608
|
||||
{"now": now},
|
||||
)
|
||||
await db.execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
UPDATE accounts SET created_at = {db.timestamp_placeholder('now')}
|
||||
WHERE created_at IS NULL
|
||||
""",
|
||||
""", # noqa: S608
|
||||
{"now": now},
|
||||
)
|
||||
|
||||
@@ -616,7 +622,12 @@ async def m027_update_apipayments_data(db: Connection):
|
||||
logger.info(f"Updating {offset} to {offset+limit}")
|
||||
|
||||
result = await db.execute(
|
||||
f"SELECT * FROM apipayments ORDER BY time LIMIT {limit} OFFSET {offset}"
|
||||
# Limit and Offset safe from SQL injection
|
||||
# since they are integers and are not user input
|
||||
f"""
|
||||
SELECT * FROM apipayments
|
||||
ORDER BY time LIMIT {int(limit)} OFFSET {int(offset)}
|
||||
""" # noqa: S608
|
||||
)
|
||||
payments = result.mappings().all()
|
||||
logger.info(f"Payments count: {len(payments)}")
|
||||
@@ -629,11 +640,12 @@ async def m027_update_apipayments_data(db: Connection):
|
||||
tag = extra.get("tag")
|
||||
tsph = db.timestamp_placeholder("created_at")
|
||||
await db.execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
UPDATE apipayments
|
||||
SET tag = :tag, created_at = {tsph}, updated_at = {tsph}
|
||||
WHERE checking_id = :checking_id
|
||||
""",
|
||||
""", # noqa: S608
|
||||
{
|
||||
"tag": tag,
|
||||
"created_at": created_at,
|
||||
|
||||
@@ -211,7 +211,8 @@ class ExtensionRelease(BaseModel):
|
||||
self, amount: int | None = None
|
||||
) -> ReleasePaymentInfo | None:
|
||||
url = f"{self.pay_link}?amount={amount}" if amount else self.pay_link
|
||||
assert url, "Missing URL for payment info."
|
||||
if not url:
|
||||
raise ValueError("Missing URL for payment info.")
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url)
|
||||
@@ -376,9 +377,8 @@ class InstallableExtension(BaseModel):
|
||||
if ext_zip_file.is_file():
|
||||
os.remove(ext_zip_file)
|
||||
try:
|
||||
assert (
|
||||
self.meta and self.meta.installed_release
|
||||
), "installed_release is none."
|
||||
if not self.meta or not self.meta.installed_release:
|
||||
raise ValueError("No installed release.")
|
||||
|
||||
self._restore_payment_info()
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ class CreateLnurl(BaseModel):
|
||||
comment: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
unit: Optional[str] = None
|
||||
internal_memo: Optional[str] = None
|
||||
|
||||
|
||||
class CreateLnurlAuth(BaseModel):
|
||||
|
||||
@@ -10,6 +10,7 @@ from .payments import (
|
||||
check_wallet_limits,
|
||||
create_fiat_invoice,
|
||||
create_invoice,
|
||||
create_payment_request,
|
||||
create_wallet_invoice,
|
||||
fee_reserve,
|
||||
fee_reserve_total,
|
||||
@@ -41,6 +42,7 @@ __all__ = [
|
||||
"check_webpush_settings",
|
||||
"create_fiat_invoice",
|
||||
"create_invoice",
|
||||
"create_payment_request",
|
||||
"create_user_account",
|
||||
"create_user_account_no_ckeck",
|
||||
"create_wallet_invoice",
|
||||
|
||||
@@ -26,10 +26,11 @@ async def install_extension(ext_info: InstallableExtension) -> Extension:
|
||||
|
||||
ext_info.meta = ext_info.meta or ExtensionMeta()
|
||||
|
||||
if ext_info.meta.installed_release:
|
||||
assert (
|
||||
ext_info.meta.installed_release.is_version_compatible
|
||||
), "Incompatible extension version"
|
||||
if (
|
||||
ext_info.meta.installed_release
|
||||
and not ext_info.meta.installed_release.is_version_compatible
|
||||
):
|
||||
raise ValueError("Incompatible extension version")
|
||||
|
||||
installed_ext = await get_installed_extension(ext_info.id)
|
||||
if installed_ext and installed_ext.meta:
|
||||
@@ -93,9 +94,8 @@ async def stop_extension_background_work(ext_id: str) -> bool:
|
||||
old_module = importlib.import_module(ext.module_name)
|
||||
|
||||
stop_fn_name = f"{ext_id}_stop"
|
||||
assert hasattr(
|
||||
old_module, stop_fn_name
|
||||
), f"No stop function found for '{ext.module_name}'."
|
||||
if not hasattr(old_module, stop_fn_name):
|
||||
raise ValueError(f"No stop function found for '{ext.module_name}'.")
|
||||
|
||||
stop_fn = getattr(old_module, stop_fn_name)
|
||||
if stop_fn:
|
||||
|
||||
@@ -67,16 +67,16 @@ async def redeem_lnurl_withdraw(
|
||||
external=True,
|
||||
wal=wallet_id,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.debug(exc)
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
try:
|
||||
check_callback_url(res["callback"])
|
||||
await client.get(res["callback"], params=params)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.debug(exc)
|
||||
|
||||
|
||||
async def perform_lnurlauth(
|
||||
@@ -136,7 +136,8 @@ async def perform_lnurlauth(
|
||||
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
assert key.verifying_key, "LNURLauth verifying_key does not exist"
|
||||
if not key.verifying_key:
|
||||
raise ValueError("LNURLauth verifying_key does not exist")
|
||||
check_callback_url(callback)
|
||||
r = await client.get(
|
||||
callback,
|
||||
|
||||
@@ -28,7 +28,7 @@ from lnbits.helpers import check_callback_url, is_valid_email_address
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.nostr import normalize_private_key
|
||||
|
||||
notifications_queue: asyncio.Queue = asyncio.Queue()
|
||||
notifications_queue: asyncio.Queue[NotificationMessage] = asyncio.Queue()
|
||||
|
||||
|
||||
def enqueue_notification(message_type: NotificationType, values: dict) -> None:
|
||||
@@ -42,8 +42,8 @@ def enqueue_notification(message_type: NotificationType, values: dict) -> None:
|
||||
logger.error(f"Error enqueuing notification: {e}")
|
||||
|
||||
|
||||
async def process_next_notification():
|
||||
notification_message: NotificationMessage = await notifications_queue.get()
|
||||
async def process_next_notification() -> None:
|
||||
notification_message = await notifications_queue.get()
|
||||
message_type, text = _notification_message_to_text(notification_message)
|
||||
await send_notification(text, message_type)
|
||||
|
||||
|
||||
@@ -66,7 +66,8 @@ async def pay_invoice(
|
||||
if settings.lnbits_only_allow_incoming_payments:
|
||||
raise PaymentError("Only incoming payments allowed.", status="failed")
|
||||
invoice = _validate_payment_request(payment_request, max_sat)
|
||||
assert invoice.amount_msat
|
||||
if not invoice.amount_msat:
|
||||
raise ValueError("Missig invoice amount.")
|
||||
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
|
||||
amount_msat = invoice.amount_msat
|
||||
@@ -99,6 +100,18 @@ async def pay_invoice(
|
||||
return payment
|
||||
|
||||
|
||||
async def create_payment_request(
|
||||
wallet_id: str, invoice_data: CreateInvoice
|
||||
) -> Payment:
|
||||
"""
|
||||
Create a lightning invoice or a fiat payment request.
|
||||
"""
|
||||
if invoice_data.fiat_provider:
|
||||
return await create_fiat_invoice(wallet_id, invoice_data)
|
||||
|
||||
return await create_wallet_invoice(wallet_id, invoice_data)
|
||||
|
||||
|
||||
async def create_fiat_invoice(
|
||||
wallet_id: str, invoice_data: CreateInvoice, conn: Optional[Connection] = None
|
||||
):
|
||||
@@ -288,6 +301,7 @@ async def create_invoice(
|
||||
memo=memo,
|
||||
extra=extra,
|
||||
webhook=webhook,
|
||||
fee=payment_response.fee_msat or 0,
|
||||
)
|
||||
|
||||
payment = await create_payment(
|
||||
@@ -309,17 +323,45 @@ async def update_pending_payments(wallet_id: str):
|
||||
await update_pending_payment(payment)
|
||||
|
||||
|
||||
async def update_pending_payment(payment: Payment) -> bool:
|
||||
async def update_pending_payment(payment: Payment) -> Payment:
|
||||
status = await payment.check_status()
|
||||
if status.failed:
|
||||
payment.status = PaymentState.FAILED
|
||||
await update_payment(payment)
|
||||
return True
|
||||
if status.success:
|
||||
payment.status = PaymentState.SUCCESS
|
||||
await update_payment(payment)
|
||||
return True
|
||||
return False
|
||||
elif status.success:
|
||||
payment = await update_payment_success_status(payment, status)
|
||||
return payment
|
||||
|
||||
|
||||
async def check_pending_payments():
|
||||
"""
|
||||
check_pending_payments is called during startup to check for pending payments with
|
||||
the backend and also to delete expired invoices. Incoming payments will be
|
||||
checked only once, outgoing pending payments will be checked regularly.
|
||||
"""
|
||||
funding_source = get_funding_source()
|
||||
if funding_source.__class__.__name__ == "VoidWallet":
|
||||
logger.warning("Task: skipping pending check for VoidWallet")
|
||||
return
|
||||
start_time = time.time()
|
||||
pending_payments = await get_payments(
|
||||
since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago
|
||||
complete=False,
|
||||
pending=True,
|
||||
exclude_uncheckable=True,
|
||||
)
|
||||
count = len(pending_payments)
|
||||
if count > 0:
|
||||
logger.info(f"Task: checking {count} pending payments of last 15 days...")
|
||||
for i, payment in enumerate(pending_payments):
|
||||
payment = await update_pending_payment(payment)
|
||||
prefix = f"payment ({i+1} / {count})"
|
||||
logger.debug(f"{prefix} {payment.status} {payment.checking_id}")
|
||||
await asyncio.sleep(0.01) # to avoid complete blocking
|
||||
logger.info(
|
||||
f"Task: pending check finished for {count} payments"
|
||||
f" (took {time.time() - start_time:0.3f} s)"
|
||||
)
|
||||
|
||||
|
||||
def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
|
||||
@@ -709,19 +751,20 @@ async def _pay_external_invoice(
|
||||
)
|
||||
|
||||
fee_reserve_msat = fee_reserve(amount_msat, internal=False)
|
||||
service_fee_msat = service_fee(amount_msat, internal=False)
|
||||
|
||||
task = create_task(
|
||||
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
|
||||
)
|
||||
|
||||
# make sure a hold invoice or deferred payment is not blocking the server
|
||||
wait_time = max(1, settings.lnbits_funding_source_pay_invoice_wait_seconds)
|
||||
try:
|
||||
wait_time = max(1, settings.lnbits_funding_source_pay_invoice_wait_seconds)
|
||||
payment_response = await asyncio.wait_for(task, wait_time)
|
||||
payment_response = await asyncio.wait_for(task, timeout=wait_time)
|
||||
except asyncio.TimeoutError:
|
||||
# return pending payment on timeout
|
||||
logger.debug(f"payment timeout, {checking_id} is still pending")
|
||||
logger.debug(
|
||||
f"payment timeout after {wait_time}s, {checking_id} is still pending"
|
||||
)
|
||||
return payment
|
||||
|
||||
# payment failed
|
||||
@@ -735,18 +778,28 @@ async def _pay_external_invoice(
|
||||
message = payment_response.error_message or "without an error message."
|
||||
raise PaymentError(f"Payment failed: {message}", status="failed")
|
||||
|
||||
# payment.ok can be True (paid) or None (pending)!
|
||||
payment.status = (
|
||||
PaymentState.SUCCESS if payment_response.ok is True else PaymentState.PENDING
|
||||
)
|
||||
payment.fee = -(abs(payment_response.fee_msat or 0) + abs(service_fee_msat))
|
||||
payment.preimage = payment_response.preimage
|
||||
await update_payment(payment, payment_response.checking_id, conn=conn)
|
||||
payment.checking_id = payment_response.checking_id
|
||||
if payment.success:
|
||||
if payment_response.success:
|
||||
payment = await update_payment_success_status(
|
||||
payment, payment_response, conn=conn
|
||||
)
|
||||
await send_payment_notification(wallet, payment)
|
||||
logger.success(f"payment successful {payment_response.checking_id}")
|
||||
|
||||
payment.checking_id = payment_response.checking_id
|
||||
return payment
|
||||
|
||||
|
||||
async def update_payment_success_status(
|
||||
payment: Payment,
|
||||
status: PaymentStatus,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Payment:
|
||||
if status.success:
|
||||
service_fee_msat = service_fee(payment.amount, internal=False)
|
||||
payment.status = PaymentState.SUCCESS
|
||||
payment.fee = -(abs(status.fee_msat or 0) + abs(service_fee_msat))
|
||||
payment.preimage = payment.preimage or status.preimage
|
||||
await update_payment(payment, conn=conn)
|
||||
return payment
|
||||
|
||||
|
||||
@@ -780,8 +833,7 @@ async def _verify_external_payment(
|
||||
|
||||
if status.success:
|
||||
# payment was successful on the fundingsource
|
||||
payment.status = PaymentState.SUCCESS
|
||||
await update_payment(payment, conn=conn)
|
||||
await update_payment_success_status(payment, status, conn=conn)
|
||||
raise PaymentError(
|
||||
"Failed payment was already paid on the fundingsource.",
|
||||
status="success",
|
||||
|
||||
@@ -19,7 +19,8 @@ async def check_webpush_settings():
|
||||
vapid = Vapid()
|
||||
vapid.generate_keys()
|
||||
privkey = vapid.private_pem()
|
||||
assert vapid.public_key, "VAPID public key does not exist"
|
||||
if not vapid.public_key:
|
||||
raise ValueError("VAPID public key does not exist")
|
||||
pubkey = b64urlencode(
|
||||
vapid.public_key.public_bytes(
|
||||
serialization.Encoding.X962,
|
||||
|
||||
@@ -80,7 +80,8 @@ async def create_user_account_no_ckeck(
|
||||
logger.error(f"Error enabeling default extension {ext_id}: {e}")
|
||||
|
||||
user = await get_user_from_account(account)
|
||||
assert user, "Cannot find user for account."
|
||||
if not user:
|
||||
raise ValueError("Cannot find user for account.")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
+10
-10
@@ -13,7 +13,7 @@ from lnbits.core.crud.audit import delete_expired_audit_entries
|
||||
from lnbits.core.crud.payments import get_payments_status_count
|
||||
from lnbits.core.crud.users import get_accounts
|
||||
from lnbits.core.crud.wallets import get_wallets_count
|
||||
from lnbits.core.models import AuditEntry
|
||||
from lnbits.core.models.audit import AuditEntry
|
||||
from lnbits.core.models.extensions import InstallableExtension
|
||||
from lnbits.core.models.notifications import NotificationType
|
||||
from lnbits.core.services.funding_source import (
|
||||
@@ -31,10 +31,10 @@ from lnbits.settings import settings
|
||||
from lnbits.tasks import create_unique_task
|
||||
from lnbits.utils.exchange_rates import btc_rates
|
||||
|
||||
audit_queue: asyncio.Queue = asyncio.Queue()
|
||||
audit_queue: asyncio.Queue[AuditEntry] = asyncio.Queue()
|
||||
|
||||
|
||||
async def run_by_the_minute_tasks():
|
||||
async def run_by_the_minute_tasks() -> None:
|
||||
minute_counter = 0
|
||||
while settings.lnbits_running:
|
||||
status_minutes = settings.lnbits_notification_server_status_hours * 60
|
||||
@@ -69,7 +69,7 @@ async def run_by_the_minute_tasks():
|
||||
await asyncio.sleep(60)
|
||||
|
||||
|
||||
async def _notify_server_status():
|
||||
async def _notify_server_status() -> None:
|
||||
accounts = await get_accounts(filters=Filters(limit=0))
|
||||
wallets_count = await get_wallets_count()
|
||||
payments = await get_payments_status_count()
|
||||
@@ -90,7 +90,7 @@ async def _notify_server_status():
|
||||
enqueue_notification(NotificationType.server_status, values)
|
||||
|
||||
|
||||
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
|
||||
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue) -> None:
|
||||
"""
|
||||
This worker dispatches events to all extensions and dispatches webhooks.
|
||||
"""
|
||||
@@ -103,13 +103,13 @@ async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
|
||||
await send_payment_notification(wallet, payment)
|
||||
|
||||
|
||||
async def wait_for_audit_data():
|
||||
async def wait_for_audit_data() -> None:
|
||||
"""
|
||||
Waits for audit entries to be pushed to the queue.
|
||||
Then it inserts the entries into the DB.
|
||||
"""
|
||||
while settings.lnbits_running:
|
||||
data: AuditEntry = await audit_queue.get()
|
||||
data = await audit_queue.get()
|
||||
try:
|
||||
await create_audit_entry(data)
|
||||
except Exception as ex:
|
||||
@@ -117,7 +117,7 @@ async def wait_for_audit_data():
|
||||
await asyncio.sleep(3)
|
||||
|
||||
|
||||
async def wait_notification_messages():
|
||||
async def wait_notification_messages() -> None:
|
||||
|
||||
while settings.lnbits_running:
|
||||
try:
|
||||
@@ -127,7 +127,7 @@ async def wait_notification_messages():
|
||||
await asyncio.sleep(3)
|
||||
|
||||
|
||||
async def purge_audit_data():
|
||||
async def purge_audit_data() -> None:
|
||||
"""
|
||||
Remove audit entries which have passed their retention period.
|
||||
"""
|
||||
@@ -141,7 +141,7 @@ async def purge_audit_data():
|
||||
await asyncio.sleep(60 * 60)
|
||||
|
||||
|
||||
async def collect_exchange_rates_data():
|
||||
async def collect_exchange_rates_data() -> None:
|
||||
"""
|
||||
Collect exchange rates data. Used for monitoring only.
|
||||
"""
|
||||
|
||||
@@ -145,7 +145,9 @@
|
||||
type="text"
|
||||
:label="$t('ad_slots_label')"
|
||||
:hint="$t('ad_slots_hint')"
|
||||
>
|
||||
><q-tooltip>
|
||||
format {url};{img-light};{img-dark},{url};{img-light};{img-dark}"
|
||||
</q-tooltip>
|
||||
</q-input>
|
||||
<q-toggle
|
||||
v-model="formData.lnbits_ad_space_enabled"
|
||||
@@ -154,9 +156,9 @@
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<p><span v-text="$t('ui_default_theme')"></span></p>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col">
|
||||
<div class="row q-col-gutter-md q-my-md">
|
||||
<div class="col-12" v-text="$t('ui_default_theme')"></div>
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<q-select
|
||||
v-model="formData.lnbits_default_border"
|
||||
:options="globalBorderOptions"
|
||||
@@ -165,7 +167,7 @@
|
||||
>
|
||||
</q-select>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<q-select
|
||||
v-model="formData.lnbits_default_theme"
|
||||
:options="lnbits_theme_options"
|
||||
@@ -174,7 +176,7 @@
|
||||
>
|
||||
</q-select>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<q-select
|
||||
v-model="formData.lnbits_default_reaction"
|
||||
:options="reactionOptions"
|
||||
@@ -183,7 +185,7 @@
|
||||
>
|
||||
</q-select>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<q-input
|
||||
type="text"
|
||||
v-model="formData.lnbits_default_bgimage"
|
||||
@@ -193,7 +195,7 @@
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<q-toggle
|
||||
type="bool"
|
||||
v-model="formData.lnbits_default_gradient"
|
||||
|
||||
@@ -667,8 +667,36 @@
|
||||
dense
|
||||
type="textarea"
|
||||
rows="2"
|
||||
v-model.trim="receive.data.memo"
|
||||
v-model="receive.data.memo"
|
||||
:label="$t('memo')"
|
||||
>
|
||||
<template v-if="receive.data.internalMemo === null" v-slot:append>
|
||||
<q-icon
|
||||
name="add_comment"
|
||||
@click.stop.prevent="receive.data.internalMemo = ''"
|
||||
class="cursor-pointer"
|
||||
></q-icon>
|
||||
<q-tooltip>
|
||||
<span v-text="$t('internal_memo')"></span>
|
||||
</q-tooltip>
|
||||
</template>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-if="receive.data.internalMemo !== null"
|
||||
autogrow
|
||||
filled
|
||||
dense
|
||||
v-model="receive.data.internalMemo"
|
||||
class="q-mb-lg"
|
||||
:label="$t('internal_memo')"
|
||||
:hint="$t('internal_memo_hint_receive')"
|
||||
:rules="[ val => !val || val.length <= 512 || 'Please use maximum 512 characters' ]"
|
||||
><template v-slot:append>
|
||||
<q-icon
|
||||
name="cancel"
|
||||
@click.stop.prevent="receive.data.internalMemo = null"
|
||||
class="cursor-pointer"
|
||||
/> </template
|
||||
></q-input>
|
||||
<div v-if="g.user.fiat_providers?.length" class="q-mt-md">
|
||||
<q-list bordered dense class="rounded-borders">
|
||||
@@ -848,6 +876,21 @@
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
<h6 class="text-center" v-text="parse.invoice.description"></h6>
|
||||
<q-input
|
||||
autogrow
|
||||
filled
|
||||
dense
|
||||
v-model="parse.data.internalMemo"
|
||||
:label="$t('internal_memo')"
|
||||
:hint="$t('internal_memo_hint_pay')"
|
||||
class="q-mb-lg"
|
||||
:rules="[ val => !val || val.length <= 512 || 'Please use maximum 512 characters' ]"
|
||||
><template v-if="parse.data.internalMemo" v-slot:append>
|
||||
<q-icon
|
||||
name="cancel"
|
||||
@click.stop.prevent="parse.data.internalMemo = null"
|
||||
class="cursor-pointer" /></template
|
||||
></q-input>
|
||||
<q-list separator bordered dense class="q-mb-md">
|
||||
<q-expansion-item expand-separator icon="info" label="Details">
|
||||
<q-list separator>
|
||||
@@ -1041,7 +1084,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="col q-mb-lg">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
@@ -1075,9 +1118,39 @@
|
||||
filled
|
||||
dense
|
||||
v-model="parse.data.comment"
|
||||
:type="parse.lnurlpay.commentAllowed > 64 ? 'textarea' : 'text'"
|
||||
:type="parse.lnurlpay.commentAllowed > 512 ? 'textarea' : 'text'"
|
||||
label="Comment (optional)"
|
||||
:maxlength="parse.lnurlpay.commentAllowed"
|
||||
><template
|
||||
v-if="parse.data.internalMemo === null"
|
||||
v-slot:append
|
||||
>
|
||||
<q-icon
|
||||
name="add_comment"
|
||||
@click.stop.prevent="parse.data.internalMemo = ''"
|
||||
class="cursor-pointer"
|
||||
></q-icon>
|
||||
<q-tooltip>
|
||||
<span v-text="$t('internal_memo')"></span>
|
||||
</q-tooltip> </template
|
||||
></q-input>
|
||||
<br />
|
||||
<q-input
|
||||
v-if="parse.data.internalMemo !== null"
|
||||
autogrow
|
||||
filled
|
||||
dense
|
||||
v-model="parse.data.internalMemo"
|
||||
:label="$t('internal_memo')"
|
||||
:hint="$t('internal_memo_hint_pay')"
|
||||
class=""
|
||||
:rules="[ val => !val || val.length <= 512 || 'Please use maximum 512 characters' ]"
|
||||
><template v-slot:append>
|
||||
<q-icon
|
||||
name="cancel"
|
||||
@click.stop.prevent="parse.data.internalMemo = null"
|
||||
class="cursor-pointer"
|
||||
/> </template
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -85,7 +85,8 @@ async def api_update_settings(data: UpdateSettings, user: User = Depends(check_a
|
||||
enqueue_notification(NotificationType.settings_update, {"username": user.username})
|
||||
await update_admin_settings(data)
|
||||
admin_settings = await get_admin_settings(user.super_user)
|
||||
assert admin_settings, "Updated admin settings not found."
|
||||
if not admin_settings:
|
||||
raise ValueError("Updated admin settings not found.")
|
||||
update_cached_settings(admin_settings.dict())
|
||||
core_app_extra.register_new_ratelimiter()
|
||||
return {"status": "Success"}
|
||||
|
||||
@@ -93,7 +93,7 @@ async def api_create_account(data: CreateWallet) -> Wallet:
|
||||
|
||||
|
||||
@api_router.get("/api/v1/lnurlscan/{code}")
|
||||
async def api_lnurlscan(
|
||||
async def api_lnurlscan( # noqa: C901
|
||||
code: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
|
||||
):
|
||||
try:
|
||||
@@ -124,7 +124,8 @@ async def api_lnurlscan(
|
||||
params.update(callback=url) # with k1 already in it
|
||||
|
||||
lnurlauth_key = wallet.wallet.lnurlauth_key(domain)
|
||||
assert lnurlauth_key.verifying_key
|
||||
if not lnurlauth_key.verifying_key:
|
||||
raise ValueError("LNURL auth key not found for this domain.")
|
||||
params.update(pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex())
|
||||
else:
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
|
||||
@@ -191,12 +191,14 @@ async def api_create_user_api_token(
|
||||
data: ApiTokenRequest,
|
||||
user: User = Depends(check_user_exists),
|
||||
) -> ApiTokenResponse:
|
||||
assert data.expiration_time_minutes > 0, "Expiration time must be in the future."
|
||||
if not data.expiration_time_minutes > 0:
|
||||
raise ValueError("Expiration time must be in the future.")
|
||||
account = await get_account(user.id)
|
||||
if not account or not account.verify_password(data.password):
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
|
||||
|
||||
assert account.username, "Username must be configured."
|
||||
if not account.username:
|
||||
raise ValueError("Username must be configured.")
|
||||
|
||||
acls = await get_user_access_control_lists(user.id)
|
||||
acl = acls.get_acl_by_id(data.acl_id)
|
||||
@@ -223,7 +225,8 @@ async def api_delete_user_api_token(
|
||||
if not account or not account.verify_password(data.password):
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
|
||||
|
||||
assert account.username, "Username must be configured."
|
||||
if not account.username:
|
||||
raise ValueError("Username must be configured.")
|
||||
|
||||
acls = await get_user_access_control_lists(user.id)
|
||||
acl = acls.get_acl_by_id(data.acl_id)
|
||||
@@ -318,7 +321,7 @@ async def update_pubkey(
|
||||
payload: AccessTokenPayload = Depends(access_token_payload),
|
||||
) -> Optional[User]:
|
||||
if data.user_id != user.id:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
|
||||
raise ValueError("Invalid user ID.")
|
||||
|
||||
_validate_auth_timeout(payload.auth_time)
|
||||
if (
|
||||
@@ -326,7 +329,7 @@ async def update_pubkey(
|
||||
and data.pubkey != user.pubkey
|
||||
and await get_account_by_pubkey(data.pubkey)
|
||||
):
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Public key already in use.")
|
||||
raise ValueError("Public key already in use.")
|
||||
|
||||
account = await get_account(user.id)
|
||||
if not account:
|
||||
@@ -344,7 +347,8 @@ async def update_password(
|
||||
payload: AccessTokenPayload = Depends(access_token_payload),
|
||||
) -> Optional[User]:
|
||||
_validate_auth_timeout(payload.auth_time)
|
||||
assert data.user_id == user.id, "Invalid user ID."
|
||||
if data.user_id != user.id:
|
||||
raise ValueError("Invalid user ID.")
|
||||
if (
|
||||
data.username
|
||||
and user.username != data.username
|
||||
@@ -353,12 +357,15 @@ async def update_password(
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
|
||||
|
||||
account = await get_account(user.id)
|
||||
assert account, "Account not found."
|
||||
if not account:
|
||||
raise ValueError("Account not found.")
|
||||
|
||||
# old accounts do not have a password
|
||||
if account.password_hash:
|
||||
assert data.password_old, "Missing old password."
|
||||
assert account.verify_password(data.password_old), "Invalid old password."
|
||||
if not data.password_old:
|
||||
raise ValueError("Missing old password.")
|
||||
if not account.verify_password(data.password_old):
|
||||
raise ValueError("Invalid old password.")
|
||||
|
||||
account.username = data.username
|
||||
account.hash_password(data.password)
|
||||
@@ -376,8 +383,10 @@ async def reset_password(data: ResetUserPassword) -> JSONResponse:
|
||||
HTTPStatus.FORBIDDEN, "Auth by 'Username and Password' not allowed."
|
||||
)
|
||||
|
||||
assert data.password == data.password_repeat, "Passwords do not match."
|
||||
assert data.reset_key[:10].startswith("reset_key_"), "This is not a reset key."
|
||||
if data.password != data.password_repeat:
|
||||
raise ValueError("Passwords do not match.")
|
||||
if not data.reset_key[:10].startswith("reset_key_"):
|
||||
raise ValueError("This is not a reset key.")
|
||||
|
||||
try:
|
||||
reset_key = base64.b64decode(data.reset_key[10:]).decode()
|
||||
@@ -385,12 +394,16 @@ async def reset_password(data: ResetUserPassword) -> JSONResponse:
|
||||
except Exception as exc:
|
||||
raise ValueError("Invalid reset key.") from exc
|
||||
|
||||
assert reset_data_json, "Cannot process reset key."
|
||||
if not reset_data_json:
|
||||
raise ValueError("Cannot process reset key.")
|
||||
|
||||
action, user_id, request_time = json.loads(reset_data_json)
|
||||
assert action, "Missing action."
|
||||
assert user_id, "Missing user ID."
|
||||
assert request_time, "Missing reset time."
|
||||
if not action:
|
||||
raise ValueError("Missing action.")
|
||||
if not user_id:
|
||||
raise ValueError("Missing user ID.")
|
||||
if not request_time:
|
||||
raise ValueError("Missing reset time.")
|
||||
|
||||
_validate_auth_timeout(request_time)
|
||||
|
||||
@@ -557,8 +570,8 @@ def _find_auth_provider_class(provider: str) -> Callable:
|
||||
provider_class = getattr(provider_module, f"{provider.title()}SSO")
|
||||
if provider_class:
|
||||
return provider_class
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.debug(exc)
|
||||
|
||||
raise ValueError(f"No SSO provider found for '{provider}'.")
|
||||
|
||||
@@ -576,28 +589,40 @@ def _nostr_nip98_event(request: Request) -> dict:
|
||||
event = json.loads(event_json)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
assert event, "Nostr login event cannot be parsed."
|
||||
if not event:
|
||||
raise ValueError("Nostr login event cannot be parsed.")
|
||||
|
||||
if not verify_event(event):
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Nostr login event is not valid.")
|
||||
assert event["kind"] == 27_235, "Invalid event kind."
|
||||
if not event["kind"] == 27_235:
|
||||
raise ValueError("Invalid event kind.")
|
||||
|
||||
auth_threshold = settings.auth_credetials_update_threshold
|
||||
assert (
|
||||
abs(time() - event["created_at"]) < auth_threshold
|
||||
), f"More than {auth_threshold} seconds have passed since the event was signed."
|
||||
if not (abs(time() - event["created_at"]) < auth_threshold):
|
||||
raise ValueError(
|
||||
f"More than {auth_threshold} seconds have passed "
|
||||
"since the event was signed."
|
||||
)
|
||||
|
||||
_check_nostr_event_tags(event)
|
||||
|
||||
return event
|
||||
|
||||
|
||||
def _check_nostr_event_tags(event: dict):
|
||||
method: Optional[str] = next((v for k, v in event["tags"] if k == "method"), None)
|
||||
assert method, "Tag 'method' is missing."
|
||||
assert method.upper() == "POST", "Invalid value for tag 'method'."
|
||||
if not method:
|
||||
raise ValueError("Tag 'method' is missing.")
|
||||
if not method.upper() == "POST":
|
||||
raise ValueError("Invalid value for tag 'method'.")
|
||||
|
||||
url = next((v for k, v in event["tags"] if k == "u"), None)
|
||||
|
||||
assert url, "Tag 'u' for URL is missing."
|
||||
if not url:
|
||||
raise ValueError("Tag 'u' for URL is missing.")
|
||||
accepted_urls = [f"{u}/nostr" for u in settings.nostr_absolute_request_urls]
|
||||
assert url in accepted_urls, f"Invalid value for tag 'u': '{url}'."
|
||||
|
||||
return event
|
||||
if url not in accepted_urls:
|
||||
raise ValueError(f"Invalid value for tag 'u': '{url}'.")
|
||||
|
||||
|
||||
def _validate_auth_timeout(auth_time: Optional[int] = 0):
|
||||
|
||||
@@ -170,64 +170,52 @@ async def api_enable_extension(
|
||||
raise HTTPException(
|
||||
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist."
|
||||
)
|
||||
try:
|
||||
logger.info(f"Enabling extension: {ext_id}.")
|
||||
ext = await get_installed_extension(ext_id)
|
||||
assert ext, f"Extension '{ext_id}' is not installed."
|
||||
assert ext.active, f"Extension '{ext_id}' is not activated."
|
||||
|
||||
user_ext = await get_user_extension(user.id, ext_id)
|
||||
if not user_ext:
|
||||
user_ext = UserExtension(user=user.id, extension=ext_id, active=False)
|
||||
await create_user_extension(user_ext)
|
||||
logger.info(f"Enabling extension: {ext_id}.")
|
||||
ext = await get_installed_extension(ext_id)
|
||||
if not ext:
|
||||
raise ValueError(f"Extension '{ext_id}' is not installed.")
|
||||
if not ext.active:
|
||||
raise ValueError(f"Extension '{ext_id}' is not activated.")
|
||||
|
||||
if user.admin or not ext.requires_payment:
|
||||
user_ext.active = True
|
||||
await update_user_extension(user_ext)
|
||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
|
||||
user_ext = await get_user_extension(user.id, ext_id)
|
||||
if not user_ext:
|
||||
user_ext = UserExtension(user=user.id, extension=ext_id, active=False)
|
||||
await create_user_extension(user_ext)
|
||||
|
||||
if not (user_ext.extra and user_ext.extra.payment_hash_to_enable):
|
||||
raise HTTPException(
|
||||
HTTPStatus.PAYMENT_REQUIRED, f"Extension '{ext_id}' requires payment."
|
||||
)
|
||||
if user.admin or not ext.requires_payment:
|
||||
user_ext.active = True
|
||||
await update_user_extension(user_ext)
|
||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
|
||||
|
||||
if user_ext.is_paid:
|
||||
user_ext.active = True
|
||||
await update_user_extension(user_ext)
|
||||
return SimpleStatus(
|
||||
success=True, message=f"Paid extension '{ext_id}' enabled."
|
||||
)
|
||||
|
||||
assert (
|
||||
ext.meta and ext.meta.pay_to_enable and ext.meta.pay_to_enable.wallet
|
||||
), f"Extension '{ext_id}' is missing payment wallet."
|
||||
|
||||
payment_status = await check_transaction_status(
|
||||
wallet_id=ext.meta.pay_to_enable.wallet,
|
||||
payment_hash=user_ext.extra.payment_hash_to_enable,
|
||||
if not (user_ext.extra and user_ext.extra.payment_hash_to_enable):
|
||||
raise HTTPException(
|
||||
HTTPStatus.PAYMENT_REQUIRED, f"Extension '{ext_id}' requires payment."
|
||||
)
|
||||
|
||||
if not payment_status.paid:
|
||||
raise HTTPException(
|
||||
HTTPStatus.PAYMENT_REQUIRED,
|
||||
f"Invoice generated but not paid for enabeling extension '{ext_id}'.",
|
||||
)
|
||||
|
||||
if user_ext.is_paid:
|
||||
user_ext.active = True
|
||||
user_ext.extra.paid_to_enable = True
|
||||
await update_user_extension(user_ext)
|
||||
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
|
||||
|
||||
except AssertionError as exc:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
|
||||
except HTTPException as exc:
|
||||
raise exc from exc
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
if not ext.meta or not ext.meta.pay_to_enable or not ext.meta.pay_to_enable.wallet:
|
||||
raise ValueError(f"Extension '{ext_id}' is missing payment wallet.")
|
||||
|
||||
payment_status = await check_transaction_status(
|
||||
wallet_id=ext.meta.pay_to_enable.wallet,
|
||||
payment_hash=user_ext.extra.payment_hash_to_enable,
|
||||
)
|
||||
|
||||
if not payment_status.paid:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=(f"Failed to enable '{ext_id}' "),
|
||||
) from exc
|
||||
HTTPStatus.PAYMENT_REQUIRED,
|
||||
f"Invoice generated but not paid for enabeling extension '{ext_id}'.",
|
||||
)
|
||||
|
||||
user_ext.active = True
|
||||
user_ext.extra.paid_to_enable = True
|
||||
await update_user_extension(user_ext)
|
||||
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
|
||||
|
||||
|
||||
@extension_router.put("/{ext_id}/disable")
|
||||
@@ -255,7 +243,8 @@ async def api_activate_extension(ext_id: str) -> SimpleStatus:
|
||||
logger.info(f"Activating extension: '{ext_id}'.")
|
||||
|
||||
ext = await get_valid_extension(ext_id)
|
||||
assert ext, f"Extension '{ext_id}' doesn't exist."
|
||||
if not ext:
|
||||
raise ValueError(f"Extension '{ext_id}' doesn't exist.")
|
||||
|
||||
await activate_extension(ext)
|
||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' activated.")
|
||||
@@ -274,7 +263,8 @@ async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
|
||||
logger.info(f"Deactivating extension: '{ext_id}'.")
|
||||
|
||||
ext = await get_valid_extension(ext_id)
|
||||
assert ext, f"Extension '{ext_id}' doesn't exist."
|
||||
if not ext:
|
||||
raise ValueError(f"Extension '{ext_id}' doesn't exist.")
|
||||
|
||||
await deactivate_extension(ext_id)
|
||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' deactivated.")
|
||||
@@ -354,40 +344,35 @@ async def get_extension_releases(ext_id: str) -> list[ExtensionRelease]:
|
||||
async def get_pay_to_install_invoice(
|
||||
ext_id: str, data: CreateExtension
|
||||
) -> ReleasePaymentInfo:
|
||||
try:
|
||||
assert (
|
||||
ext_id == data.ext_id
|
||||
), f"Wrong extension id. Expected {ext_id}, but got {data.ext_id}"
|
||||
assert data.cost_sats, "A non-zero amount must be specified."
|
||||
release = await InstallableExtension.get_extension_release(
|
||||
data.ext_id, data.source_repo, data.archive, data.version
|
||||
if ext_id != data.ext_id:
|
||||
raise ValueError(
|
||||
f"Wrong extension id. Expected {ext_id}, but got {data.ext_id}"
|
||||
)
|
||||
assert release, "Release not found."
|
||||
assert release.pay_link, "Pay link not found for release."
|
||||
if not data.cost_sats:
|
||||
raise ValueError("A non-zero amount must be specified.")
|
||||
release = await InstallableExtension.get_extension_release(
|
||||
data.ext_id, data.source_repo, data.archive, data.version
|
||||
)
|
||||
if not release:
|
||||
raise ValueError("Release not found.")
|
||||
if not release.pay_link:
|
||||
raise ValueError("Pay link not found for release.")
|
||||
|
||||
payment_info = await release.fetch_release_payment_info(data.cost_sats)
|
||||
payment_info = await release.fetch_release_payment_info(data.cost_sats)
|
||||
|
||||
assert payment_info and payment_info.payment_request, "Cannot request invoice."
|
||||
invoice = bolt11_decode(payment_info.payment_request)
|
||||
if not (payment_info and payment_info.payment_request):
|
||||
raise ValueError("Cannot request invoice.")
|
||||
invoice = bolt11_decode(payment_info.payment_request)
|
||||
|
||||
assert invoice.amount_msat is not None, "Invoic amount is missing."
|
||||
invoice_amount = int(invoice.amount_msat / 1000)
|
||||
assert (
|
||||
invoice_amount == data.cost_sats
|
||||
), f"Wrong invoice amount: {invoice_amount}."
|
||||
assert (
|
||||
payment_info.payment_hash == invoice.payment_hash
|
||||
), "Wrong invoice payment hash."
|
||||
if invoice.amount_msat is None:
|
||||
raise ValueError("Invoic amount is missing.")
|
||||
invoice_amount = int(invoice.amount_msat / 1000)
|
||||
if invoice_amount != data.cost_sats:
|
||||
raise ValueError(f"Wrong invoice amount: {invoice_amount}.")
|
||||
if payment_info.payment_hash != invoice.payment_hash:
|
||||
raise ValueError("Wrong invoice payment hash.")
|
||||
|
||||
return payment_info
|
||||
|
||||
except AssertionError as exc:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
raise HTTPException(
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR, "Cannot request invoice"
|
||||
) from exc
|
||||
return payment_info
|
||||
|
||||
|
||||
@extension_router.put("/{ext_id}/invoice/enable")
|
||||
|
||||
@@ -60,10 +60,10 @@ from ..crud import (
|
||||
get_payments_paginated,
|
||||
get_standalone_payment,
|
||||
get_wallet_for_key,
|
||||
update_payment_extra,
|
||||
)
|
||||
from ..services import (
|
||||
create_fiat_invoice,
|
||||
create_wallet_invoice,
|
||||
create_payment_request,
|
||||
fee_reserve_total,
|
||||
get_payments_daily_stats,
|
||||
pay_invoice,
|
||||
@@ -121,7 +121,6 @@ async def api_payments_counting_stats(
|
||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||
user: User = Depends(check_user_exists),
|
||||
):
|
||||
|
||||
if user.admin:
|
||||
# admin user can see payments from all wallets
|
||||
for_user_id = None
|
||||
@@ -142,7 +141,6 @@ async def api_payments_wallets_stats(
|
||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||
user: User = Depends(check_user_exists),
|
||||
):
|
||||
|
||||
if user.admin:
|
||||
# admin user can see payments from all wallets
|
||||
for_user_id = None
|
||||
@@ -163,7 +161,6 @@ async def api_payments_daily_stats(
|
||||
user: User = Depends(check_user_exists),
|
||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
|
||||
if user.admin:
|
||||
# admin user can see payments from all wallets
|
||||
for_user_id = None
|
||||
@@ -264,10 +261,39 @@ async def api_payments_create(
|
||||
)
|
||||
|
||||
# If the payment is not outgoing, we can create a new invoice.
|
||||
if invoice_data.fiat_provider:
|
||||
return await create_fiat_invoice(wallet_id, invoice_data)
|
||||
return await create_payment_request(wallet_id, invoice_data)
|
||||
|
||||
return await create_wallet_invoice(wallet_id, invoice_data)
|
||||
|
||||
@payment_router.patch(
|
||||
"/extra/{payment_hash}", description="Update extra data for a payment"
|
||||
)
|
||||
async def api_payments_update_extra(
|
||||
payment_hash: str,
|
||||
extra: dict,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Payment:
|
||||
payment = await get_standalone_payment(payment_hash, wallet_id=wallet.wallet.id)
|
||||
if payment is None:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
|
||||
)
|
||||
if not isinstance(extra, dict):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Extra data must be a dictionary.",
|
||||
)
|
||||
if payment.extra is None:
|
||||
payment.extra = {}
|
||||
|
||||
for key, value in extra.items():
|
||||
if key in payment.extra:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Key '{key}' already exists in extra data.",
|
||||
)
|
||||
payment.extra[key] = value
|
||||
|
||||
return await update_payment_extra(payment)
|
||||
|
||||
|
||||
@payment_router.get("/fee-reserve")
|
||||
@@ -285,23 +311,36 @@ async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONRespo
|
||||
)
|
||||
|
||||
|
||||
@payment_router.post("/lnurl")
|
||||
async def api_payments_pay_lnurl(
|
||||
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> Payment:
|
||||
domain = urlparse(data.callback).netloc
|
||||
def _validate_lnurl_response(
|
||||
params: dict, domain: str, amount_msat: int
|
||||
) -> bolt11.Invoice:
|
||||
if params.get("status") == "ERROR":
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} said: '{params.get('reason', '')}'",
|
||||
)
|
||||
if not params.get("pr"):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} did not return a payment request.",
|
||||
)
|
||||
invoice = bolt11.decode(params["pr"])
|
||||
if invoice.amount_msat != amount_msat:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=(
|
||||
f"{domain} returned an invalid invoice. Expected"
|
||||
f" {amount_msat} msat, got {invoice.amount_msat}."
|
||||
),
|
||||
)
|
||||
return invoice
|
||||
|
||||
|
||||
async def _fetch_lnurl_params(data: CreateLnurl, amount_msat: int, domain: str) -> dict:
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
|
||||
try:
|
||||
if data.unit and data.unit != "sat":
|
||||
amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit)
|
||||
# no msat precision
|
||||
amount_msat = ceil(amount_msat // 1000) * 1000
|
||||
else:
|
||||
amount_msat = data.amount
|
||||
check_callback_url(data.callback)
|
||||
r = await client.get(
|
||||
r: httpx.Response = await client.get(
|
||||
data.callback,
|
||||
params={"amount": amount_msat, "comment": data.comment},
|
||||
timeout=40,
|
||||
@@ -315,29 +354,24 @@ async def api_payments_pay_lnurl(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Failed to connect to {domain}.",
|
||||
) from exc
|
||||
return json.loads(r.text)
|
||||
|
||||
params = json.loads(r.text)
|
||||
if params.get("status") == "ERROR":
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} said: '{params.get('reason', '')}'",
|
||||
)
|
||||
|
||||
if not params.get("pr"):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"{domain} did not return a payment request.",
|
||||
)
|
||||
@payment_router.post("/lnurl")
|
||||
async def api_payments_pay_lnurl(
|
||||
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> Payment:
|
||||
domain = urlparse(data.callback).netloc
|
||||
check_callback_url(data.callback)
|
||||
|
||||
invoice = bolt11.decode(params["pr"])
|
||||
if invoice.amount_msat != amount_msat:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=(
|
||||
f"{domain} returned an invalid invoice. Expected"
|
||||
f" {amount_msat} msat, got {invoice.amount_msat}."
|
||||
),
|
||||
)
|
||||
if data.unit and data.unit != "sat":
|
||||
amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit)
|
||||
amount_msat = ceil(amount_msat // 1000) * 1000
|
||||
else:
|
||||
amount_msat = data.amount
|
||||
|
||||
params = await _fetch_lnurl_params(data, amount_msat, domain)
|
||||
_validate_lnurl_response(params, domain, amount_msat)
|
||||
|
||||
extra = {}
|
||||
if params.get("successAction"):
|
||||
@@ -347,7 +381,12 @@ async def api_payments_pay_lnurl(
|
||||
if data.unit and data.unit != "sat":
|
||||
extra["fiat_currency"] = data.unit
|
||||
extra["fiat_amount"] = data.amount / 1000
|
||||
assert data.description is not None, "description is required"
|
||||
if data.internal_memo is not None:
|
||||
if len(data.internal_memo) > 512:
|
||||
raise ValueError("Internal memo must be 512 characters or less.")
|
||||
extra["internal_memo"] = data.internal_memo
|
||||
if data.description is None:
|
||||
raise ValueError("Description is required")
|
||||
|
||||
payment = await pay_invoice(
|
||||
wallet_id=wallet.wallet.id,
|
||||
|
||||
@@ -187,7 +187,8 @@ async def api_users_reset_password(user_id: str) -> str:
|
||||
reset_data = ["reset", user_id, int(time.time())]
|
||||
reset_data_json = json.dumps(reset_data, separators=(",", ":"), ensure_ascii=False)
|
||||
reset_key = encrypt_internal_message(reset_data_json)
|
||||
assert reset_key, "Cannot generate reset key."
|
||||
if not reset_key:
|
||||
raise ValueError("Cannot generate reset key.")
|
||||
reset_key_b64 = base64.b64encode(reset_key.encode()).decode()
|
||||
return f"reset_key_{reset_key_b64}"
|
||||
|
||||
|
||||
+4
-4
@@ -252,7 +252,7 @@ class Connection(Compat):
|
||||
{clause}
|
||||
{group_by_string}
|
||||
) as count
|
||||
""",
|
||||
""", # noqa: S608
|
||||
parsed_values,
|
||||
)
|
||||
row = result.mappings().first()
|
||||
@@ -597,7 +597,7 @@ def insert_query(table_name: str, model: BaseModel) -> str:
|
||||
# add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword)
|
||||
fields = ", ".join([f'"{key}"' for key in keys])
|
||||
values = ", ".join(placeholders)
|
||||
return f"INSERT INTO {table_name} ({fields}) VALUES ({values})"
|
||||
return f"INSERT INTO {table_name} ({fields}) VALUES ({values})" # noqa: S608
|
||||
|
||||
|
||||
def update_query(
|
||||
@@ -615,7 +615,7 @@ def update_query(
|
||||
# add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword)
|
||||
fields.append(f'"{field}" = {placeholder}')
|
||||
query = ", ".join(fields)
|
||||
return f"UPDATE {table_name} SET {query} {where}"
|
||||
return f"UPDATE {table_name} SET {query} {where}" # noqa: S608
|
||||
|
||||
|
||||
def model_to_dict(model: BaseModel) -> dict:
|
||||
@@ -658,7 +658,7 @@ def dict_to_submodel(model: type[TModel], value: dict | str) -> TModel | None:
|
||||
return dict_to_model(_subdict, model)
|
||||
|
||||
|
||||
def dict_to_model(_row: dict, model: type[TModel]) -> TModel:
|
||||
def dict_to_model(_row: dict, model: type[TModel]) -> TModel: # noqa: C901
|
||||
"""
|
||||
Convert a dictionary with JSON-encoded nested models to a Pydantic model
|
||||
:param _dict: Dictionary from database
|
||||
|
||||
@@ -69,7 +69,7 @@ def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
|
||||
)
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI):
|
||||
def register_exception_handlers(app: FastAPI): # noqa: C901
|
||||
"""Register exception handlers for the FastAPI app"""
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
|
||||
+5
-2
@@ -306,8 +306,11 @@ def check_callback_url(url: str):
|
||||
)
|
||||
|
||||
|
||||
def download_url(url, save_path):
|
||||
with request.urlopen(url, timeout=60) as dl_file:
|
||||
def download_url(url: str, save_path: Path):
|
||||
if not url.startswith(("http:", "https:")):
|
||||
raise ValueError(f"Invalid URL: {url}. Must start with 'http' or 'https'.")
|
||||
|
||||
with request.urlopen(url, timeout=60) as dl_file: # noqa: S310
|
||||
with open(save_path, "wb") as out_file:
|
||||
out_file.write(dl_file.read())
|
||||
|
||||
|
||||
@@ -131,7 +131,6 @@ class AuditMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
assert response
|
||||
return response
|
||||
finally:
|
||||
if request_details:
|
||||
|
||||
+30
-7
@@ -201,10 +201,11 @@ class InstalledExtensionsSettings(LNbitsSettings):
|
||||
if r.find_in_conflict(ext_redirect_paths)
|
||||
}
|
||||
|
||||
assert len(existing_redirects) == 0, (
|
||||
f"Cannot redirect for extension '{ext_id}'."
|
||||
f" Already mapped by {existing_redirects}."
|
||||
)
|
||||
if len(existing_redirects) != 0:
|
||||
raise ValueError(
|
||||
f"Cannot redirect for extension '{ext_id}'."
|
||||
f" Already mapped by {existing_redirects}."
|
||||
)
|
||||
|
||||
self._remove_extension_redirects(ext_id)
|
||||
self.lnbits_extensions_redirects += ext_redirect_paths
|
||||
@@ -255,7 +256,7 @@ class ThemesSettings(LNbitsSettings):
|
||||
lnbits_custom_image: str | None = Field(default="/static/images/logos/lnbits.svg")
|
||||
lnbits_ad_space_title: str = Field(default="Supported by")
|
||||
lnbits_ad_space: str = Field(
|
||||
default="https://shop.lnbits.com/;/static/images/bitcoin-shop-banner.png;/static/images/bitcoin-shop-banner.png,https://affil.trezor.io/aff_c?offer_id=169&aff_id=33845;/static/images/bitcoin-hardware-wallet.png;/static/images/bitcoin-hardware-wallet.png,https://opensats.org/;/static/images/open-sats.png;/static/images/open-sats.png"
|
||||
default="https://shop.lnbits.com/;/static/images/bitcoin-shop-banner.png;/static/images/bitcoin-shop-banner.png,https://affil.trezor.io/aff_c?offer_id=169&aff_id=33845;/static/images/bitcoin-hardware-wallet.png;/static/images/bitcoin-hardware-wallet.png,https://firefish.io/?ref=lnbits;/static/images/firefish.png;/static/images/firefish.png,https://opensats.org/;/static/images/open-sats.png;/static/images/open-sats.png"
|
||||
) # sneaky sneaky
|
||||
lnbits_ad_space_enabled: bool = Field(default=False)
|
||||
lnbits_allowed_currencies: list[str] = Field(default=[])
|
||||
@@ -305,7 +306,7 @@ class FeeSettings(LNbitsSettings):
|
||||
return 0
|
||||
reserve_min = self.lnbits_reserve_fee_min
|
||||
reserve_percent = self.lnbits_reserve_fee_percent
|
||||
return max(int(reserve_min), int(amount_msat * reserve_percent / 100.0))
|
||||
return max(int(reserve_min), int(abs(amount_msat) * reserve_percent / 100.0))
|
||||
|
||||
|
||||
class ExchangeProvidersSettings(LNbitsSettings):
|
||||
@@ -448,6 +449,18 @@ class ClicheFundingSource(LNbitsSettings):
|
||||
cliche_endpoint: str | None = Field(default=None)
|
||||
|
||||
|
||||
class CLNRestFundingSource(LNbitsSettings):
|
||||
clnrest_url: str | None = Field(default=None)
|
||||
clnrest_ca: str | None = Field(default=None)
|
||||
clnrest_cert: str | None = Field(default=None)
|
||||
clnrest_readonly_rune: str | None = Field(default=None)
|
||||
clnrest_invoice_rune: str | None = Field(default=None)
|
||||
clnrest_pay_rune: str | None = Field(default=None)
|
||||
clnrest_renepay_rune: str | None = Field(default=None)
|
||||
clnrest_last_pay_index: str | None = Field(default=None)
|
||||
clnrest_nodeid: str | None = Field(default=None)
|
||||
|
||||
|
||||
class CoreLightningFundingSource(LNbitsSettings):
|
||||
corelightning_rpc: str | None = Field(default=None)
|
||||
corelightning_pay_command: str = Field(default="pay")
|
||||
@@ -549,6 +562,12 @@ class BreezSdkFundingSource(LNbitsSettings):
|
||||
breez_use_trampoline: bool = Field(default=True)
|
||||
|
||||
|
||||
class BreezLiquidSdkFundingSource(LNbitsSettings):
|
||||
breez_liquid_api_key: str | None = Field(default=None)
|
||||
breez_liquid_seed: str | None = Field(default=None)
|
||||
breez_liquid_fee_offset_sat: int = Field(default=50)
|
||||
|
||||
|
||||
class BoltzFundingSource(LNbitsSettings):
|
||||
boltz_client_endpoint: str | None = Field(default="127.0.0.1:9002")
|
||||
boltz_client_macaroon: str | None = Field(default=None)
|
||||
@@ -601,6 +620,7 @@ class FundingSourcesSettings(
|
||||
FakeWalletFundingSource,
|
||||
LNbitsFundingSource,
|
||||
ClicheFundingSource,
|
||||
CLNRestFundingSource,
|
||||
CoreLightningFundingSource,
|
||||
CoreLightningRestFundingSource,
|
||||
EclairFundingSource,
|
||||
@@ -618,6 +638,7 @@ class FundingSourcesSettings(
|
||||
NWCFundingSource,
|
||||
BreezSdkFundingSource,
|
||||
StrikeFundingSource,
|
||||
BreezLiquidSdkFundingSource,
|
||||
):
|
||||
lnbits_backend_wallet_class: str = Field(default="VoidWallet")
|
||||
# How long to wait for the payment to be confirmed before returning a pending status
|
||||
@@ -676,7 +697,7 @@ class NodeUISettings(LNbitsSettings):
|
||||
|
||||
class AuthMethods(Enum):
|
||||
user_id_only = "user-id-only"
|
||||
username_and_password = "username-password"
|
||||
username_and_password = "username-password" # noqa: S105
|
||||
nostr_auth_nip98 = "nostr-auth-nip98"
|
||||
google_auth = "google-auth"
|
||||
github_auth = "github-auth"
|
||||
@@ -924,6 +945,8 @@ class SuperUserSettings(LNbitsSettings):
|
||||
"BoltzWallet",
|
||||
"BlinkWallet",
|
||||
"BreezSdkWallet",
|
||||
"BreezLiquidSdkWallet",
|
||||
"CLNRestWallet",
|
||||
"CoreLightningRestWallet",
|
||||
"CoreLightningWallet",
|
||||
"EclairWallet",
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -62,14 +62,6 @@ body[data-theme=monochrome].neon-border .q-date--dark {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
body[data-theme=salvador].neon-border .q-card,
|
||||
body[data-theme=salvador].neon-border .q-card.q-card--dark,
|
||||
body[data-theme=salvador].neon-border .q-date,
|
||||
body[data-theme=salvador].neon-border .q-date--dark {
|
||||
border: 2px solid #1976d2;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
body.hard-border .q-card,
|
||||
body.hard-border .q-card.q-card--dark,
|
||||
body.hard-border .q-date,
|
||||
@@ -117,21 +109,21 @@ body[data-theme=bitcoin] [data-theme=bitcoin] .q-stepper--dark {
|
||||
body[data-theme=freedom] {
|
||||
--q-primary: #e22156;
|
||||
--q-secondary: #b91a45;
|
||||
--q-dark-page: #462f36;
|
||||
--q-dark-page: #0a0a0a;
|
||||
}
|
||||
body[data-theme=freedom] [data-theme=freedom] .q-card--dark,
|
||||
body[data-theme=freedom] [data-theme=freedom] .q-stepper--dark {
|
||||
background: #47393d !important;
|
||||
background: #1b1b1b !important;
|
||||
}
|
||||
|
||||
body[data-theme=cyber] {
|
||||
--q-primary: #7cb342;
|
||||
--q-secondary: #558b2f;
|
||||
--q-dark-page: #000;
|
||||
--q-dark-page: #0a0a0a;
|
||||
}
|
||||
body[data-theme=cyber] [data-theme=cyber] .q-card--dark,
|
||||
body[data-theme=cyber] [data-theme=cyber] .q-stepper--dark {
|
||||
background: #1f2915 !important;
|
||||
background: #1b1b1b !important;
|
||||
}
|
||||
|
||||
body[data-theme=mint] {
|
||||
@@ -174,16 +166,6 @@ body[data-theme=monochrome] [data-theme=monochrome] .q-stepper--dark {
|
||||
background: rgb(39, 39, 39) !important;
|
||||
}
|
||||
|
||||
body[data-theme=salvador] {
|
||||
--q-primary: #1976d2;
|
||||
--q-secondary: #26a69a;
|
||||
--q-dark-page: #253647;
|
||||
}
|
||||
body[data-theme=salvador] [data-theme=salvador] .q-card--dark,
|
||||
body[data-theme=salvador] [data-theme=salvador] .q-stepper--dark {
|
||||
background: #343d47 !important;
|
||||
}
|
||||
|
||||
body.gradient-bg {
|
||||
background-image: linear-gradient(to bottom right, var(--q-dark-page), #0a0a0a);
|
||||
background-attachment: fixed;
|
||||
|
||||
@@ -101,6 +101,11 @@ window.localisation.en = {
|
||||
memo: 'Memo',
|
||||
date: 'Date',
|
||||
path: 'Path',
|
||||
internal_memo: 'Internal memo (optional)',
|
||||
internal_memo_hint_receive:
|
||||
"This memo is not shown to the payer but it's stored in the invoice for your reference.",
|
||||
internal_memo_hint_pay:
|
||||
"This memo is not shown to the payee but it's stored in the payment for your reference.",
|
||||
payment_processing: 'Processing payment...',
|
||||
payment_processing: 'Processing payment...',
|
||||
payment_successful: 'Payment successful!',
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
+35
-10
@@ -20,22 +20,35 @@ window.LNbits = {
|
||||
memo,
|
||||
unit = 'sat',
|
||||
lnurlCallback = null,
|
||||
fiatProvider = null
|
||||
fiatProvider = null,
|
||||
internalMemo = null
|
||||
) {
|
||||
return this.request('post', '/api/v1/payments', wallet.inkey, {
|
||||
const data = {
|
||||
out: false,
|
||||
amount: amount,
|
||||
memo: memo,
|
||||
lnurl_callback: lnurlCallback,
|
||||
unit: unit,
|
||||
lnurl_callback: lnurlCallback,
|
||||
fiat_provider: fiatProvider
|
||||
})
|
||||
}
|
||||
if (internalMemo) {
|
||||
data.extra = {
|
||||
internal_memo: String(internalMemo)
|
||||
}
|
||||
}
|
||||
return this.request('post', '/api/v1/payments', wallet.inkey, data)
|
||||
},
|
||||
payInvoice(wallet, bolt11) {
|
||||
return this.request('post', '/api/v1/payments', wallet.adminkey, {
|
||||
payInvoice(wallet, bolt11, internalMemo = null) {
|
||||
const data = {
|
||||
out: true,
|
||||
bolt11: bolt11
|
||||
})
|
||||
}
|
||||
if (internalMemo) {
|
||||
data.extra = {
|
||||
internal_memo: String(internalMemo)
|
||||
}
|
||||
}
|
||||
return this.request('post', '/api/v1/payments', wallet.adminkey, data)
|
||||
},
|
||||
payLnurl(
|
||||
wallet,
|
||||
@@ -44,16 +57,28 @@ window.LNbits = {
|
||||
amount,
|
||||
description = '',
|
||||
comment = '',
|
||||
unit = ''
|
||||
unit = '',
|
||||
internalMemo = null
|
||||
) {
|
||||
return this.request('post', '/api/v1/payments/lnurl', wallet.adminkey, {
|
||||
const data = {
|
||||
callback,
|
||||
description_hash,
|
||||
amount,
|
||||
comment,
|
||||
description,
|
||||
unit
|
||||
})
|
||||
}
|
||||
|
||||
if (internalMemo) {
|
||||
data.internal_memo = String(internalMemo)
|
||||
}
|
||||
|
||||
return this.request(
|
||||
'post',
|
||||
'/api/v1/payments/lnurl',
|
||||
wallet.adminkey,
|
||||
data
|
||||
)
|
||||
},
|
||||
authLnurl(wallet, callback) {
|
||||
return this.request('post', '/api/v1/lnurlauth', wallet.adminkey, {
|
||||
|
||||
@@ -48,6 +48,22 @@ window.app.component('lnbits-funding-sources', {
|
||||
lnbits_denomination: '"sats" or 3 Letter Custom Denomination'
|
||||
}
|
||||
],
|
||||
[
|
||||
'CLNRestWallet',
|
||||
'Core Lightning Rest (plugin)',
|
||||
{
|
||||
clnrest_url: 'Endpoint',
|
||||
clnrest_ca: 'ca.pem',
|
||||
clnrest_cert: 'server.pem',
|
||||
clnrest_readonly_rune: 'Rune used for readonly requests',
|
||||
clnrest_invoice_rune: 'Rune used for creating invoices',
|
||||
clnrest_pay_rune: 'Rune used for paying invoices using pay',
|
||||
clnrest_renepay_rune: 'Rune used for paying invoices using renepay',
|
||||
clnrest_last_pay_index:
|
||||
'Ignores any invoices paid prior to or including this index. 0 is equivalent to not specifying and negative value is invalid.',
|
||||
clnrest_nodeid: 'Node id'
|
||||
}
|
||||
],
|
||||
[
|
||||
'CoreLightningWallet',
|
||||
'Core Lightning',
|
||||
@@ -58,7 +74,7 @@ window.app.component('lnbits-funding-sources', {
|
||||
],
|
||||
[
|
||||
'CoreLightningRestWallet',
|
||||
'Core Lightning Rest',
|
||||
'Core Lightning Rest (legacy)',
|
||||
{
|
||||
corelightning_rest_url: 'Endpoint',
|
||||
corelightning_rest_cert: 'Certificate',
|
||||
@@ -219,6 +235,16 @@ window.app.component('lnbits-funding-sources', {
|
||||
strike_api_endpoint: 'API Endpoint',
|
||||
strike_api_key: 'API Key'
|
||||
}
|
||||
],
|
||||
[
|
||||
'BreezLiquidSdkWallet',
|
||||
'Breez Liquid SDK',
|
||||
{
|
||||
breez_liquid_api_key: 'Breez API Key (can be empty)',
|
||||
breez_liquid_seed: 'Liquid seed phrase',
|
||||
breez_liquid_fee_offset_sat:
|
||||
'Offset amount in sats to increase fee limit'
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ window.PaymentsPageLogic = {
|
||||
searchData: {
|
||||
wallet_id: null,
|
||||
payment_hash: null,
|
||||
memo: null
|
||||
memo: null,
|
||||
internal_memo: null
|
||||
},
|
||||
statusFilters: {
|
||||
success: true,
|
||||
@@ -60,10 +61,10 @@ window.PaymentsPageLogic = {
|
||||
sortable: false
|
||||
},
|
||||
{
|
||||
name: 'fee',
|
||||
name: 'fee_sats',
|
||||
align: 'left',
|
||||
label: 'Fee',
|
||||
field: 'fee',
|
||||
field: 'fee_sats',
|
||||
sortable: true
|
||||
},
|
||||
|
||||
@@ -82,6 +83,14 @@ window.PaymentsPageLogic = {
|
||||
sortable: false,
|
||||
max_length: 20
|
||||
},
|
||||
{
|
||||
name: 'internal_memo',
|
||||
align: 'left',
|
||||
label: 'Internal Memo',
|
||||
field: 'internal_memo',
|
||||
sortable: false,
|
||||
max_length: 20
|
||||
},
|
||||
{
|
||||
name: 'wallet_id',
|
||||
align: 'left',
|
||||
@@ -166,6 +175,11 @@ window.PaymentsPageLogic = {
|
||||
p.extra.wallet_fiat_currency
|
||||
)
|
||||
}
|
||||
if (p.extra?.internal_memo) {
|
||||
p.internal_memo = p.extra.internal_memo
|
||||
}
|
||||
p.fee_sats =
|
||||
new Intl.NumberFormat(window.LOCALE).format(p.fee / 1000) + ' sats'
|
||||
|
||||
return p
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ window.WalletPageLogic = {
|
||||
request: '',
|
||||
amount: 0,
|
||||
comment: '',
|
||||
internalMemo: null,
|
||||
unit: 'sat'
|
||||
},
|
||||
paymentChecker: null,
|
||||
@@ -38,7 +39,8 @@ window.WalletPageLogic = {
|
||||
fiatProvider: '',
|
||||
data: {
|
||||
amount: null,
|
||||
memo: ''
|
||||
memo: '',
|
||||
internalMemo: null
|
||||
}
|
||||
},
|
||||
invoiceQrCode: '',
|
||||
@@ -197,6 +199,7 @@ window.WalletPageLogic = {
|
||||
this.receive.paymentHash = null
|
||||
this.receive.data.amount = null
|
||||
this.receive.data.memo = null
|
||||
this.receive.data.internalMemo = null
|
||||
this.receive.unit = this.isFiatPriority
|
||||
? this.g.wallet.currency || 'sat'
|
||||
: 'sat'
|
||||
@@ -218,6 +221,7 @@ window.WalletPageLogic = {
|
||||
window.isSecureContext && navigator.clipboard?.readText !== undefined
|
||||
this.parse.data.request = ''
|
||||
this.parse.data.comment = ''
|
||||
this.parse.data.internalMemo = null
|
||||
this.parse.data.paymentChecker = null
|
||||
this.parse.camera.show = false
|
||||
this.focusInput('textArea')
|
||||
@@ -253,7 +257,8 @@ window.WalletPageLogic = {
|
||||
this.receive.data.memo,
|
||||
this.receive.unit,
|
||||
this.receive.lnurl && this.receive.lnurl.callback,
|
||||
this.receive.fiatProvider
|
||||
this.receive.fiatProvider,
|
||||
this.receive.data.internalMemo
|
||||
)
|
||||
.then(response => {
|
||||
this.g.updatePayments = !this.g.updatePayments
|
||||
@@ -477,7 +482,11 @@ window.WalletPageLogic = {
|
||||
})
|
||||
|
||||
LNbits.api
|
||||
.payInvoice(this.g.wallet, this.parse.data.request)
|
||||
.payInvoice(
|
||||
this.g.wallet,
|
||||
this.parse.data.request,
|
||||
this.parse.data.internalMemo
|
||||
)
|
||||
.then(response => {
|
||||
dismissPaymentMsg()
|
||||
this.updatePayments = !this.updatePayments
|
||||
@@ -515,7 +524,8 @@ window.WalletPageLogic = {
|
||||
this.parse.data.amount * 1000,
|
||||
this.parse.lnurlpay.description.slice(0, 120),
|
||||
this.parse.data.comment,
|
||||
this.parse.data.unit
|
||||
this.parse.data.unit,
|
||||
this.parse.data.internalMemo
|
||||
)
|
||||
.then(response => {
|
||||
this.parse.show = false
|
||||
|
||||
@@ -18,16 +18,16 @@ $themes: (
|
||||
'freedom': (
|
||||
primary: #e22156,
|
||||
secondary: #b91a45,
|
||||
dark: #462f36,
|
||||
info: #47393d,
|
||||
dark: #0a0a0a,
|
||||
info: #1b1b1b,
|
||||
marginal-bg: #2d293b,
|
||||
marginal-text: #fff
|
||||
),
|
||||
'cyber': (
|
||||
primary: #7cb342,
|
||||
secondary: #558b2f,
|
||||
dark: #000,
|
||||
info: #1f2915,
|
||||
dark: #0a0a0a,
|
||||
info: #1b1b1b,
|
||||
marginal-bg: #2d293b,
|
||||
marginal-text: #fff
|
||||
),
|
||||
@@ -62,13 +62,5 @@ $themes: (
|
||||
info: rgb(39, 39, 39),
|
||||
marginal-bg: #000,
|
||||
marginal-text: rgb(255, 255, 255)
|
||||
),
|
||||
'salvador': (
|
||||
primary: #1976d2,
|
||||
secondary: #26a69a,
|
||||
dark: #253647,
|
||||
info: #343d47,
|
||||
marginal-bg: #0d1620,
|
||||
marginal-text: rgb(255, 255, 255)
|
||||
)
|
||||
);
|
||||
|
||||
+11
-46
@@ -1,5 +1,4 @@
|
||||
import asyncio
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from collections.abc import Coroutine
|
||||
@@ -11,7 +10,6 @@ from typing import (
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud import (
|
||||
get_payments,
|
||||
get_standalone_payment,
|
||||
update_payment,
|
||||
)
|
||||
@@ -153,51 +151,18 @@ def wait_for_paid_invoices(
|
||||
return wrapper
|
||||
|
||||
|
||||
async def check_pending_payments():
|
||||
"""
|
||||
check_pending_payments is called during startup to check for pending payments with
|
||||
the backend and also to delete expired invoices. Incoming payments will be
|
||||
checked only once, outgoing pending payments will be checked regularly.
|
||||
"""
|
||||
sleep_time = 60 * 30 # 30 minutes
|
||||
def run_interval(
|
||||
interval_seconds: int,
|
||||
func: Callable[[], Coroutine],
|
||||
) -> Callable[[], Coroutine]:
|
||||
"""Run a function at a specified interval in seconds, while the server is running"""
|
||||
|
||||
while settings.lnbits_running:
|
||||
funding_source = get_funding_source()
|
||||
if funding_source.__class__.__name__ == "VoidWallet":
|
||||
logger.warning("Task: skipping pending check for VoidWallet")
|
||||
await asyncio.sleep(sleep_time)
|
||||
continue
|
||||
start_time = time.time()
|
||||
pending_payments = await get_payments(
|
||||
since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago
|
||||
complete=False,
|
||||
pending=True,
|
||||
exclude_uncheckable=True,
|
||||
)
|
||||
count = len(pending_payments)
|
||||
if count > 0:
|
||||
logger.info(f"Task: checking {count} pending payments of last 15 days...")
|
||||
for i, payment in enumerate(pending_payments):
|
||||
status = await payment.check_status()
|
||||
prefix = f"payment ({i+1} / {count})"
|
||||
if status.failed:
|
||||
payment.status = PaymentState.FAILED
|
||||
await update_payment(payment)
|
||||
logger.debug(f"{prefix} failed {payment.checking_id}")
|
||||
elif status.success:
|
||||
payment.fee = status.fee_msat or 0
|
||||
payment.preimage = status.preimage
|
||||
payment.status = PaymentState.SUCCESS
|
||||
await update_payment(payment)
|
||||
logger.debug(f"{prefix} success {payment.checking_id}")
|
||||
else:
|
||||
logger.debug(f"{prefix} pending {payment.checking_id}")
|
||||
await asyncio.sleep(0.01) # to avoid complete blocking
|
||||
logger.info(
|
||||
f"Task: pending check finished for {count} payments"
|
||||
f" (took {time.time() - start_time:0.3f} s)"
|
||||
)
|
||||
await asyncio.sleep(sleep_time)
|
||||
async def wrapper() -> None:
|
||||
while settings.lnbits_running:
|
||||
await func()
|
||||
await asyncio.sleep(interval_seconds)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
async def invoice_callback_dispatcher(checking_id: str, is_internal: bool = False):
|
||||
|
||||
@@ -880,14 +880,24 @@
|
||||
:props="props"
|
||||
style="white-space: normal; word-break: break-all"
|
||||
>
|
||||
<q-badge v-if="props.row.tag" color="yellow" text-color="black">
|
||||
<q-badge
|
||||
v-if="props.row.tag"
|
||||
color="yellow"
|
||||
text-color="black"
|
||||
class="q-mr-sm"
|
||||
>
|
||||
<a
|
||||
v-text="'#' + props.row.tag"
|
||||
class="inherit"
|
||||
:href="['/', props.row.tag].join('')"
|
||||
></a>
|
||||
</q-badge>
|
||||
<span class="q-ml-sm" v-text="props.row.memo"></span>
|
||||
<span v-text="props.row.memo"></span>
|
||||
<span
|
||||
class="text-grey-5 q-ml-sm ellipsis"
|
||||
v-if="props.row.extra.internal_memo"
|
||||
v-text="`(${props.row.extra.internal_memo})`"
|
||||
></span>
|
||||
<br />
|
||||
|
||||
<i>
|
||||
|
||||
@@ -66,7 +66,8 @@ class AESCipher:
|
||||
self, salt: bytes, output_len: int = 32 + 16
|
||||
) -> tuple[bytes, bytes]:
|
||||
# extended from https://gist.github.com/gsakkis/4546068
|
||||
assert len(salt) == 8, "Salt must be 8 bytes"
|
||||
if len(salt) != 8:
|
||||
raise ValueError("Salt must be 8 bytes")
|
||||
data = self.key + salt
|
||||
key = md5(data).digest()
|
||||
final_key = key
|
||||
|
||||
@@ -175,14 +175,17 @@ def normalize_private_key(key: str) -> str:
|
||||
def normalize_bech32_key(hrp: str, key: str) -> str:
|
||||
if key.startswith(hrp):
|
||||
_, decoded_data = bech32_decode(key)
|
||||
assert decoded_data, f"Key is not valid {hrp}."
|
||||
if not decoded_data:
|
||||
raise ValueError(f"Key is not valid {hrp}.")
|
||||
|
||||
decoded_data_bits = convertbits(decoded_data, 5, 8, False)
|
||||
assert decoded_data_bits, f"Key is not valid {hrp}."
|
||||
if not decoded_data_bits:
|
||||
raise ValueError(f"Key is not valid {hrp}.")
|
||||
|
||||
return bytes(decoded_data_bits).hex()
|
||||
|
||||
assert len(key) == 64, "Key has wrong length."
|
||||
if len(key) != 64:
|
||||
raise ValueError("Key has wrong length.")
|
||||
try:
|
||||
int(key, 16)
|
||||
except Exception as exc:
|
||||
@@ -203,7 +206,8 @@ def hex_to_npub(hex_pubkey: str) -> str:
|
||||
normalize_public_key(hex_pubkey)
|
||||
pubkey_bytes = bytes.fromhex(hex_pubkey)
|
||||
bits = convertbits(pubkey_bytes, 8, 5, True)
|
||||
assert bits
|
||||
if not bits:
|
||||
raise ValueError("Invalid Nostr public key.")
|
||||
return bech32_encode("npub", bits)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
MIIBYzCCARWgAwIBAgIHPjlTc1IbAzAFBgMrZXAwEDEOMAwGA1UEAxMFQnJlZXowHhcNMjUwNzAyMDkxODMzWhcNMzUwNjMwMDkxODMzWjAfMQ8wDQYDVQQKEwZsbmJpdHMxDDAKBgNVBAMTA2RuaTAqMAUGAytlcAMhANCD9cvfIDwcoiDKKYdT9BunHLS2/OuKzV8NS0SzqV13o38wfTAOBgNVHQ8BAf8EBAMCBaAwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQU2jmj7l5rSw0yVb/vlWAYkK/YBwkwHwYDVR0jBBgwFoAU3qrWklbzjed0khb8TLYgsmsomGswHQYDVR0RBBYwFIESb2ZmaWNlQGRuaWxhYnMuY29tMAUGAytlcANBAMGS8jEfZbfNpv6mVrg328NXnjA/nG6TuGA0aAw0NyDz499aeu/0TURjF8FzmxzmdNOiffUZ6akPWCZUKFYuGgA=
|
||||
@@ -10,7 +10,9 @@ from .alby import AlbyWallet
|
||||
from .blink import BlinkWallet
|
||||
from .boltz import BoltzWallet
|
||||
from .breez import BreezSdkWallet
|
||||
from .breez_liquid import BreezLiquidSdkWallet
|
||||
from .cliche import ClicheWallet
|
||||
from .clnrest import CLNRestWallet
|
||||
from .corelightning import CoreLightningWallet
|
||||
|
||||
# The following import is intentional to keep backwards compatibility
|
||||
@@ -57,7 +59,9 @@ __all__ = [
|
||||
"AlbyWallet",
|
||||
"BlinkWallet",
|
||||
"BoltzWallet",
|
||||
"BreezLiquidSdkWallet",
|
||||
"BreezSdkWallet",
|
||||
"CLNRestWallet",
|
||||
"CLightningWallet",
|
||||
"ClicheWallet",
|
||||
"CoreLightningRestWallet",
|
||||
|
||||
@@ -7,6 +7,8 @@ from typing import TYPE_CHECKING, NamedTuple
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from lnbits.nodes.base import Node
|
||||
|
||||
@@ -22,6 +24,7 @@ class InvoiceResponse(NamedTuple):
|
||||
payment_request: str | None = None
|
||||
error_message: str | None = None
|
||||
preimage: str | None = None
|
||||
fee_msat: int | None = None
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
@@ -139,7 +142,7 @@ class Wallet(ABC):
|
||||
pass
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
while True:
|
||||
while settings.lnbits_running:
|
||||
for invoice in self.pending_invoices:
|
||||
try:
|
||||
status = await self.get_invoice_status(invoice)
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Optional
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
from websockets.legacy.client import WebSocketClientProtocol, connect
|
||||
from websockets.legacy.client import connect
|
||||
from websockets.typing import Subprotocol
|
||||
|
||||
from lnbits import bolt11
|
||||
@@ -48,7 +48,7 @@ class BlinkWallet(Wallet):
|
||||
"payload": {"X-API-KEY": settings.blink_token},
|
||||
}
|
||||
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.auth)
|
||||
self.ws: Optional[WebSocketClientProtocol] = None
|
||||
self.ws = None
|
||||
self._wallet_id = None
|
||||
|
||||
@property
|
||||
@@ -252,7 +252,8 @@ class BlinkWallet(Wallet):
|
||||
response = await self._graphql_query(data)
|
||||
|
||||
response_data = response.get("data")
|
||||
assert response_data is not None
|
||||
if response_data is None:
|
||||
raise ValueError("No data found in response.")
|
||||
txs_data = (
|
||||
response_data.get("me", {})
|
||||
.get("defaultAccount", {})
|
||||
@@ -260,7 +261,8 @@ class BlinkWallet(Wallet):
|
||||
.get("transactionsByPaymentHash", [])
|
||||
)
|
||||
tx_data = next((t for t in txs_data if t.get("direction") == "SEND"), None)
|
||||
assert tx_data, "No SEND data found."
|
||||
if not tx_data:
|
||||
raise ValueError("No SEND data found.")
|
||||
fee = tx_data.get("settlementFee")
|
||||
preimage = tx_data.get("settlementVia", {}).get("preImage")
|
||||
status = tx_data.get("status")
|
||||
@@ -284,9 +286,8 @@ class BlinkWallet(Wallet):
|
||||
await ws.send(json.dumps(self.ws_auth))
|
||||
confirmation = await ws.recv()
|
||||
ack = json.loads(confirmation)
|
||||
assert (
|
||||
ack.get("type") == "connection_ack"
|
||||
), "Websocket connection not acknowledged."
|
||||
if ack.get("type") != "connection_ack":
|
||||
raise ValueError("Websocket connection not acknowledged.")
|
||||
|
||||
logger.info("Websocket connection acknowledged.")
|
||||
subscription_req = {
|
||||
|
||||
@@ -63,7 +63,7 @@ class BoltzWallet(Wallet):
|
||||
channel = grpc.aio.insecure_channel(settings.boltz_client_endpoint)
|
||||
|
||||
self.rpc = boltzrpc_pb2_grpc.BoltzStub(channel)
|
||||
self.wallet_id: int = 0
|
||||
self.wallet_id = 0
|
||||
|
||||
# Auto-create wallet if running in Docker mode
|
||||
async def _init_boltz_wallet():
|
||||
@@ -142,7 +142,8 @@ class BoltzWallet(Wallet):
|
||||
pair_info = await self.rpc.GetPairInfo(pair_request, metadata=self.metadata)
|
||||
invoice = decode(bolt11)
|
||||
|
||||
assert invoice.amount_msat, "amountless invoice"
|
||||
if not invoice.amount_msat:
|
||||
raise ValueError("amountless invoice")
|
||||
service_fee: float = invoice.amount_msat * pair_info.fees.percentage / 100
|
||||
estimate = service_fee + pair_info.fees.miner_fees * 1000
|
||||
if estimate > fee_limit_msat:
|
||||
@@ -236,7 +237,7 @@ class BoltzWallet(Wallet):
|
||||
return PaymentFailedStatus()
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
while True:
|
||||
while settings.lnbits_running:
|
||||
try:
|
||||
request = boltzrpc_pb2.GetSwapInfoRequest()
|
||||
info: boltzrpc_pb2.GetSwapInfoResponse
|
||||
|
||||
+100
-74
@@ -1,15 +1,9 @@
|
||||
import base64
|
||||
from importlib.util import find_spec
|
||||
|
||||
from lnbits.exceptions import UnsupportedError
|
||||
|
||||
try:
|
||||
import breez_sdk # type: ignore
|
||||
|
||||
BREEZ_SDK_INSTALLED = True
|
||||
except ImportError:
|
||||
BREEZ_SDK_INSTALLED = False
|
||||
|
||||
if not BREEZ_SDK_INSTALLED:
|
||||
if not find_spec("breez_sdk"):
|
||||
|
||||
class BreezSdkWallet: # pyright: ignore
|
||||
def __init__(self):
|
||||
@@ -24,9 +18,31 @@ else:
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from bolt11 import Bolt11Exception
|
||||
from bolt11 import decode as bolt11_decode
|
||||
from breez_sdk import (
|
||||
BreezEvent,
|
||||
ConnectRequest,
|
||||
EnvironmentType,
|
||||
EventListener,
|
||||
GreenlightCredentials,
|
||||
GreenlightNodeConfig,
|
||||
NodeConfig,
|
||||
PaymentDetails,
|
||||
PaymentType,
|
||||
ReceivePaymentRequest,
|
||||
ReceivePaymentResponse,
|
||||
ReportIssueRequest,
|
||||
ReportPaymentFailureDetails,
|
||||
SendPaymentRequest,
|
||||
SendPaymentResponse,
|
||||
connect,
|
||||
default_config,
|
||||
mnemonic_to_seed,
|
||||
)
|
||||
from breez_sdk import PaymentStatus as BreezPaymentStatus
|
||||
from loguru import logger
|
||||
|
||||
from lnbits import bolt11 as lnbits_bolt11
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
@@ -40,7 +56,15 @@ else:
|
||||
Wallet,
|
||||
)
|
||||
|
||||
breez_event_queue: asyncio.Queue = asyncio.Queue()
|
||||
breez_incoming_queue: asyncio.Queue[PaymentDetails.LN] = asyncio.Queue()
|
||||
|
||||
class PaymentsListener(EventListener):
|
||||
def on_event(self, e: BreezEvent) -> None:
|
||||
logger.debug(f"received breez sdk event: {e}")
|
||||
if isinstance(e, BreezEvent.PAYMENT_SUCCEED) and isinstance(
|
||||
e.details, PaymentDetails.LN
|
||||
):
|
||||
breez_incoming_queue.put_nowait(e.details)
|
||||
|
||||
def load_bytes(source: str, extension: str) -> Optional[bytes]:
|
||||
# first check if it can be read from a file
|
||||
@@ -57,15 +81,11 @@ else:
|
||||
# else convert from base64
|
||||
try:
|
||||
return base64.b64decode(source)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.debug(exc)
|
||||
return None
|
||||
|
||||
def load_greenlight_credentials() -> (
|
||||
Optional[
|
||||
breez_sdk.GreenlightCredentials # pyright: ignore[reportUnboundVariable]
|
||||
]
|
||||
):
|
||||
def load_greenlight_credentials() -> Optional[GreenlightCredentials]:
|
||||
if (
|
||||
settings.breez_greenlight_device_key
|
||||
and settings.breez_greenlight_device_cert
|
||||
@@ -78,19 +98,12 @@ else:
|
||||
"cannot decode breez_greenlight_device_key "
|
||||
"or breez_greenlight_device_cert"
|
||||
)
|
||||
return breez_sdk.GreenlightCredentials( # pyright: ignore[reportUnboundVariable]
|
||||
return GreenlightCredentials(
|
||||
developer_key=list(device_key_bytes),
|
||||
developer_cert=list(device_cert_bytes),
|
||||
)
|
||||
return None
|
||||
|
||||
class SDKListener(
|
||||
breez_sdk.EventListener # pyright: ignore[reportUnboundVariable]
|
||||
):
|
||||
def on_event(self, event):
|
||||
logger.debug(event)
|
||||
breez_event_queue.put_nowait(event)
|
||||
|
||||
class BreezSdkWallet(Wallet): # type: ignore[no-redef]
|
||||
def __init__(self):
|
||||
if not settings.breez_greenlight_seed:
|
||||
@@ -118,15 +131,15 @@ else:
|
||||
"missing breez_greenlight_device_key"
|
||||
)
|
||||
|
||||
self.config = breez_sdk.default_config(
|
||||
breez_sdk.EnvironmentType.PRODUCTION,
|
||||
gl_config = GreenlightNodeConfig(
|
||||
partner_credentials=load_greenlight_credentials(),
|
||||
invite_code=settings.breez_greenlight_invite_code,
|
||||
)
|
||||
node_config = NodeConfig.GREENLIGHT(config=gl_config)
|
||||
self.config = default_config(
|
||||
EnvironmentType.PRODUCTION,
|
||||
settings.breez_api_key,
|
||||
breez_sdk.NodeConfig.GREENLIGHT(
|
||||
config=breez_sdk.GreenlightNodeConfig(
|
||||
partner_credentials=load_greenlight_credentials(),
|
||||
invite_code=settings.breez_greenlight_invite_code,
|
||||
)
|
||||
),
|
||||
node_config=node_config, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
breez_sdk_working_dir = Path(settings.lnbits_data_folder, "breez-sdk")
|
||||
@@ -134,9 +147,9 @@ else:
|
||||
self.config.working_dir = breez_sdk_working_dir.absolute().as_posix()
|
||||
|
||||
try:
|
||||
seed = breez_sdk.mnemonic_to_seed(settings.breez_greenlight_seed)
|
||||
connect_request = breez_sdk.ConnectRequest(self.config, seed)
|
||||
self.sdk_services = breez_sdk.connect(connect_request, SDKListener())
|
||||
seed = mnemonic_to_seed(settings.breez_greenlight_seed)
|
||||
connect_request = ConnectRequest(config=self.config, seed=seed)
|
||||
self.sdk_services = connect(connect_request, PaymentsListener())
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
raise ValueError(f"cannot initialize BreezSdkWallet: {exc!s}") from exc
|
||||
@@ -146,7 +159,7 @@ else:
|
||||
|
||||
async def status(self) -> StatusResponse:
|
||||
try:
|
||||
node_info: breez_sdk.NodeState = self.sdk_services.node_info()
|
||||
node_info = self.sdk_services.node_info()
|
||||
except Exception as exc:
|
||||
return StatusResponse(f"Failed to connect to breez, got: '{exc}...'", 0)
|
||||
|
||||
@@ -168,14 +181,14 @@ else:
|
||||
"'description_hash' unsupported by Greenlight, provide"
|
||||
" 'unhashed_description'"
|
||||
)
|
||||
breez_invoice: breez_sdk.ReceivePaymentResponse = (
|
||||
breez_invoice: ReceivePaymentResponse = (
|
||||
self.sdk_services.receive_payment(
|
||||
breez_sdk.ReceivePaymentRequest(
|
||||
amount * 1000, # breez uses msat
|
||||
(
|
||||
ReceivePaymentRequest(
|
||||
amount_msat=amount * 1000, # breez uses msat
|
||||
description=(
|
||||
unhashed_description.decode()
|
||||
if unhashed_description
|
||||
else memo
|
||||
else memo or ""
|
||||
),
|
||||
preimage=kwargs.get("preimage"),
|
||||
opening_fee_params=None,
|
||||
@@ -198,36 +211,45 @@ else:
|
||||
async def pay_invoice(
|
||||
self, bolt11: str, fee_limit_msat: int
|
||||
) -> PaymentResponse:
|
||||
invoice = lnbits_bolt11.decode(bolt11)
|
||||
|
||||
logger.debug(f"fee_limit_msat {fee_limit_msat} is ignored by Breez SDK")
|
||||
try:
|
||||
send_payment_request = breez_sdk.SendPaymentRequest(
|
||||
invoice = bolt11_decode(bolt11)
|
||||
except Bolt11Exception as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(
|
||||
ok=False, error_message=f"invalid bolt11 invoice: {exc}"
|
||||
)
|
||||
try:
|
||||
send_payment_request = SendPaymentRequest(
|
||||
bolt11=bolt11, use_trampoline=settings.breez_use_trampoline
|
||||
)
|
||||
send_payment_response: breez_sdk.SendPaymentResponse = (
|
||||
send_payment_response: SendPaymentResponse = (
|
||||
self.sdk_services.send_payment(send_payment_request)
|
||||
)
|
||||
payment: breez_sdk.Payment = send_payment_response.payment
|
||||
payment = send_payment_response.payment
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
try:
|
||||
# try to report issue to Breez to improve LSP routing
|
||||
self.sdk_services.report_issue(
|
||||
breez_sdk.ReportIssueRequest.PAYMENT_FAILURE(
|
||||
breez_sdk.ReportPaymentFailureDetails(invoice.payment_hash)
|
||||
)
|
||||
# report issue to Breez to improve LSP routing
|
||||
payment_error = ReportIssueRequest.PAYMENT_FAILURE(
|
||||
ReportPaymentFailureDetails(payment_hash=invoice.payment_hash)
|
||||
)
|
||||
self.sdk_services.report_issue(payment_error) # type: ignore[arg-type]
|
||||
except Exception as ex:
|
||||
logger.info(ex)
|
||||
# assume that payment failed?
|
||||
return PaymentResponse(ok=False, error_message=f"payment failed: {exc}")
|
||||
return PaymentResponse(error_message=f"exception while payment {exc!s}")
|
||||
|
||||
if payment.status != breez_sdk.PaymentStatus.COMPLETE:
|
||||
return PaymentResponse(ok=False, error_message="payment is pending")
|
||||
if payment.status != BreezPaymentStatus.COMPLETE:
|
||||
return PaymentResponse(ok=None, error_message="payment is pending")
|
||||
|
||||
# let's use the payment_hash as the checking_id
|
||||
checking_id = invoice.payment_hash
|
||||
|
||||
if not isinstance(payment.details, PaymentDetails.LN):
|
||||
return PaymentResponse(
|
||||
error_message="Breez SDK returned a non-LN payment details object",
|
||||
)
|
||||
|
||||
return PaymentResponse(
|
||||
ok=True,
|
||||
checking_id=checking_id,
|
||||
@@ -237,18 +259,22 @@ else:
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
try:
|
||||
payment: breez_sdk.Payment = self.sdk_services.payment_by_hash(
|
||||
checking_id
|
||||
)
|
||||
payment = self.sdk_services.payment_by_hash(checking_id)
|
||||
if payment is None:
|
||||
return PaymentPendingStatus()
|
||||
if payment.payment_type != breez_sdk.PaymentType.RECEIVED:
|
||||
if payment.payment_type != PaymentType.RECEIVED:
|
||||
logger.warning(f"unexpected payment type: {payment.status}")
|
||||
return PaymentPendingStatus()
|
||||
if payment.status == breez_sdk.PaymentStatus.FAILED:
|
||||
if not isinstance(payment.details, PaymentDetails.LN):
|
||||
logger.warning(f"unexpected paymentdetails type: {payment.details}")
|
||||
return PaymentPendingStatus()
|
||||
if payment.status == BreezPaymentStatus.FAILED:
|
||||
return PaymentFailedStatus()
|
||||
if payment.status == breez_sdk.PaymentStatus.COMPLETE:
|
||||
return PaymentSuccessStatus()
|
||||
if payment.status == BreezPaymentStatus.COMPLETE:
|
||||
return PaymentSuccessStatus(
|
||||
fee_msat=payment.fee_msat,
|
||||
preimage=payment.details.data.payment_preimage,
|
||||
)
|
||||
return PaymentPendingStatus()
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
@@ -256,20 +282,21 @@ else:
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
try:
|
||||
payment: breez_sdk.Payment = self.sdk_services.payment_by_hash(
|
||||
checking_id
|
||||
)
|
||||
payment = self.sdk_services.payment_by_hash(checking_id)
|
||||
if payment is None:
|
||||
return PaymentPendingStatus()
|
||||
if payment.payment_type != breez_sdk.PaymentType.SENT:
|
||||
logger.warning(f"unexpected payment type: {payment.status}")
|
||||
if payment.payment_type != PaymentType.SENT:
|
||||
logger.warning(f"unexpected payment type: {payment.payment_type}")
|
||||
return PaymentPendingStatus()
|
||||
if payment.status == breez_sdk.PaymentStatus.COMPLETE:
|
||||
if not isinstance(payment.details, PaymentDetails.LN):
|
||||
logger.warning(f"unexpected paymentdetails type: {payment.details}")
|
||||
return PaymentPendingStatus()
|
||||
if payment.status == BreezPaymentStatus.COMPLETE:
|
||||
return PaymentSuccessStatus(
|
||||
fee_msat=payment.fee_msat,
|
||||
preimage=payment.details.data.payment_preimage,
|
||||
)
|
||||
if payment.status == breez_sdk.PaymentStatus.FAILED:
|
||||
if payment.status == BreezPaymentStatus.FAILED:
|
||||
return PaymentFailedStatus()
|
||||
return PaymentPendingStatus()
|
||||
except Exception as exc:
|
||||
@@ -277,7 +304,6 @@ else:
|
||||
return PaymentPendingStatus()
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
while True:
|
||||
event = await breez_event_queue.get()
|
||||
if event.is_invoice_paid():
|
||||
yield event.details.payment_hash
|
||||
while settings.lnbits_running:
|
||||
details = await breez_incoming_queue.get()
|
||||
yield details.data.payment_hash
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
# Based on breez.py
|
||||
|
||||
from importlib.util import find_spec
|
||||
|
||||
if not find_spec("breez_sdk_liquid"):
|
||||
|
||||
class BreezLiquidSdkWallet: # pyright: ignore
|
||||
def __init__(self):
|
||||
raise RuntimeError(
|
||||
"Breez Liquid SDK is not installed. "
|
||||
"Ask admin to run `poetry add -E breez` to install it."
|
||||
)
|
||||
|
||||
else:
|
||||
import asyncio
|
||||
from asyncio import Queue
|
||||
from collections.abc import AsyncGenerator
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from bolt11 import decode as bolt11_decode
|
||||
from breez_sdk_liquid import (
|
||||
ConnectRequest,
|
||||
EventListener,
|
||||
GetInfoResponse,
|
||||
GetPaymentRequest,
|
||||
LiquidNetwork,
|
||||
Payment,
|
||||
PaymentDetails,
|
||||
PaymentMethod,
|
||||
PaymentState,
|
||||
PaymentType,
|
||||
PrepareReceiveRequest,
|
||||
PrepareSendRequest,
|
||||
ReceiveAmount,
|
||||
ReceivePaymentRequest,
|
||||
SdkEvent,
|
||||
SendPaymentRequest,
|
||||
connect,
|
||||
default_config,
|
||||
)
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
PaymentFailedStatus,
|
||||
PaymentPendingStatus,
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
PaymentSuccessStatus,
|
||||
StatusResponse,
|
||||
Wallet,
|
||||
)
|
||||
|
||||
breez_incoming_queue: Queue[PaymentDetails.LIGHTNING] = Queue()
|
||||
breez_outgoing_queue: dict[str, Queue[PaymentDetails.LIGHTNING]] = {}
|
||||
|
||||
class PaymentsListener(EventListener):
|
||||
def on_event(self, e: SdkEvent) -> None:
|
||||
logger.debug(f"received breez sdk event: {e}")
|
||||
# TODO: when this issue is fixed:
|
||||
# https://github.com/breez/breez-sdk-liquid/issues/961
|
||||
# use SdkEvent.PAYMENT_WAITING_CONFIRMATION
|
||||
if not isinstance(e, SdkEvent.PAYMENT_SUCCEEDED) or not isinstance(
|
||||
e.details.details, PaymentDetails.LIGHTNING
|
||||
):
|
||||
return
|
||||
|
||||
payment = e.details
|
||||
payment_details = e.details.details
|
||||
|
||||
if payment.payment_type is PaymentType.RECEIVE:
|
||||
breez_incoming_queue.put_nowait(payment_details)
|
||||
elif (
|
||||
payment.payment_type is PaymentType.SEND
|
||||
and payment_details.payment_hash in breez_outgoing_queue
|
||||
):
|
||||
breez_outgoing_queue[payment_details.payment_hash].put_nowait(
|
||||
payment_details
|
||||
)
|
||||
|
||||
class BreezLiquidSdkWallet(Wallet): # type: ignore[no-redef]
|
||||
def __init__(self):
|
||||
if not settings.breez_liquid_seed:
|
||||
raise ValueError(
|
||||
"cannot initialize BreezLiquidSdkWallet: missing breez_liquid_seed"
|
||||
)
|
||||
|
||||
if not settings.breez_liquid_api_key:
|
||||
with open(Path("lnbits/wallets", ".breez")) as f:
|
||||
settings.breez_liquid_api_key = f.read().strip()
|
||||
|
||||
self.config = default_config(
|
||||
LiquidNetwork.MAINNET,
|
||||
breez_api_key=settings.breez_liquid_api_key,
|
||||
)
|
||||
|
||||
breez_sdk_working_dir = Path(
|
||||
settings.lnbits_data_folder, "breez-liquid-sdk"
|
||||
)
|
||||
breez_sdk_working_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.config.working_dir = breez_sdk_working_dir.absolute().as_posix()
|
||||
|
||||
try:
|
||||
mnemonic = settings.breez_liquid_seed
|
||||
connect_request = ConnectRequest(config=self.config, mnemonic=mnemonic)
|
||||
self.sdk_services = connect(connect_request)
|
||||
self.sdk_services.add_event_listener(PaymentsListener())
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
raise ValueError(
|
||||
f"cannot initialize BreezLiquidSdkWallet: {exc!s}"
|
||||
) from exc
|
||||
|
||||
async def cleanup(self):
|
||||
self.sdk_services.disconnect()
|
||||
|
||||
async def status(self) -> StatusResponse:
|
||||
try:
|
||||
info: GetInfoResponse = self.sdk_services.get_info()
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return StatusResponse(f"Failed to connect to breez, got: '{exc}...'", 0)
|
||||
return StatusResponse(None, int(info.wallet_info.balance_sat * 1000))
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**_,
|
||||
) -> InvoiceResponse:
|
||||
try:
|
||||
# issue with breez sdk, receive_amount is of type BITCOIN
|
||||
# not ReceiveAmount after initialisation
|
||||
receive_amount = ReceiveAmount.BITCOIN(amount)
|
||||
req = self.sdk_services.prepare_receive_payment(
|
||||
PrepareReceiveRequest(
|
||||
payment_method=PaymentMethod.BOLT11_INVOICE,
|
||||
amount=receive_amount, # type: ignore
|
||||
)
|
||||
)
|
||||
receive_fees_sats = req.fees_sat
|
||||
|
||||
description = memo or (
|
||||
unhashed_description.decode() if unhashed_description else ""
|
||||
)
|
||||
|
||||
res = self.sdk_services.receive_payment(
|
||||
ReceivePaymentRequest(
|
||||
prepare_response=req,
|
||||
description=description,
|
||||
use_description_hash=description_hash is not None,
|
||||
)
|
||||
)
|
||||
|
||||
bolt11 = res.destination
|
||||
invoice_data = bolt11_decode(bolt11)
|
||||
payment_hash = invoice_data.payment_hash
|
||||
|
||||
return InvoiceResponse(
|
||||
ok=True,
|
||||
checking_id=payment_hash,
|
||||
payment_request=bolt11,
|
||||
fee_msat=receive_fees_sats * 1000,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
return InvoiceResponse(ok=False, error_message=str(e))
|
||||
|
||||
async def pay_invoice(
|
||||
self, bolt11: str, fee_limit_msat: int
|
||||
) -> PaymentResponse:
|
||||
invoice_data = bolt11_decode(bolt11)
|
||||
|
||||
try:
|
||||
prepare_req = PrepareSendRequest(destination=bolt11)
|
||||
req = self.sdk_services.prepare_send_payment(prepare_req)
|
||||
|
||||
fee_limit_sat = settings.breez_liquid_fee_offset_sat + int(
|
||||
fee_limit_msat / 1000
|
||||
)
|
||||
|
||||
if req.fees_sat and req.fees_sat > fee_limit_sat:
|
||||
return PaymentResponse(
|
||||
ok=False,
|
||||
error_message=(
|
||||
f"fee of {req.fees_sat} sat exceeds limit of "
|
||||
f"{fee_limit_sat} sat"
|
||||
),
|
||||
)
|
||||
|
||||
send_response = self.sdk_services.send_payment(
|
||||
SendPaymentRequest(prepare_response=req)
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(error_message=f"Exception while payment: {exc}")
|
||||
|
||||
payment: Payment = send_response.payment
|
||||
logger.debug(f"pay invoice res: {payment}")
|
||||
checking_id = invoice_data.payment_hash
|
||||
|
||||
fees = req.fees_sat * 1000 if req.fees_sat and req.fees_sat > 0 else 0
|
||||
|
||||
if payment.status != PaymentState.COMPLETE:
|
||||
return await self._wait_for_outgoing_payment(checking_id, fees, 10)
|
||||
|
||||
if not isinstance(payment.details, PaymentDetails.LIGHTNING):
|
||||
return PaymentResponse(
|
||||
error_message="lightning payment details are not available"
|
||||
)
|
||||
|
||||
return PaymentResponse(
|
||||
ok=True,
|
||||
checking_id=checking_id,
|
||||
fee_msat=payment.fees_sat * 1000,
|
||||
preimage=payment.details.preimage,
|
||||
)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
try:
|
||||
req = GetPaymentRequest.PAYMENT_HASH(checking_id)
|
||||
payment = self.sdk_services.get_payment(req=req) # type: ignore
|
||||
if payment is None:
|
||||
return PaymentPendingStatus()
|
||||
if payment.payment_type != PaymentType.RECEIVE:
|
||||
logger.warning(f"unexpected payment type: {payment.status}")
|
||||
return PaymentPendingStatus()
|
||||
if payment.status == PaymentState.FAILED:
|
||||
return PaymentFailedStatus()
|
||||
if payment.status == PaymentState.COMPLETE and isinstance(
|
||||
payment.details, PaymentDetails.LIGHTNING
|
||||
):
|
||||
return PaymentSuccessStatus(
|
||||
paid=True,
|
||||
fee_msat=int(payment.fees_sat * 1000),
|
||||
preimage=payment.details.preimage,
|
||||
)
|
||||
return PaymentPendingStatus()
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentPendingStatus()
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
try:
|
||||
req = GetPaymentRequest.PAYMENT_HASH(checking_id)
|
||||
payment = self.sdk_services.get_payment(req=req) # type: ignore
|
||||
if payment is None:
|
||||
return PaymentPendingStatus()
|
||||
if payment.payment_type != PaymentType.SEND:
|
||||
logger.warning(f"unexpected payment type: {payment.status}")
|
||||
return PaymentPendingStatus()
|
||||
if payment.status == PaymentState.COMPLETE:
|
||||
if not isinstance(payment.details, PaymentDetails.LIGHTNING):
|
||||
logger.warning("payment details are not of type LIGHTNING")
|
||||
return PaymentPendingStatus()
|
||||
return PaymentSuccessStatus(
|
||||
fee_msat=int(payment.fees_sat * 1000),
|
||||
preimage=payment.details.preimage,
|
||||
)
|
||||
if payment.status == PaymentState.FAILED:
|
||||
return PaymentFailedStatus()
|
||||
return PaymentPendingStatus()
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentPendingStatus()
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
while settings.lnbits_running:
|
||||
details = await breez_incoming_queue.get()
|
||||
logger.debug(f"breez invoice paid event: {details}")
|
||||
if not details.invoice:
|
||||
logger.warning(
|
||||
"Paid invoices stream expected bolt11 invoice, got None"
|
||||
)
|
||||
continue
|
||||
|
||||
invoice_data = bolt11_decode(details.invoice)
|
||||
yield invoice_data.payment_hash
|
||||
|
||||
async def _wait_for_outgoing_payment(
|
||||
self, checking_id: str, fees: int, timeout: int
|
||||
) -> PaymentResponse:
|
||||
logger.debug(f"waiting for outgoing payment {checking_id} to complete")
|
||||
try:
|
||||
breez_outgoing_queue[checking_id] = Queue()
|
||||
payment_details = await asyncio.wait_for(
|
||||
breez_outgoing_queue[checking_id].get(), timeout
|
||||
)
|
||||
return PaymentResponse(
|
||||
ok=True,
|
||||
preimage=payment_details.preimage,
|
||||
checking_id=checking_id,
|
||||
fee_msat=fees,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug(
|
||||
f"payment '{checking_id}' is still pending after {timeout} seconds"
|
||||
)
|
||||
return PaymentResponse(
|
||||
checking_id=checking_id,
|
||||
fee_msat=fees,
|
||||
error_message="payment is pending",
|
||||
)
|
||||
finally:
|
||||
breez_outgoing_queue.pop(checking_id, None)
|
||||
@@ -182,7 +182,8 @@ class ClicheWallet(Wallet):
|
||||
try:
|
||||
if data["result"]["status"]:
|
||||
yield data["result"]["payment_hash"]
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.debug(exc)
|
||||
continue
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from bolt11 import Bolt11Exception
|
||||
from bolt11.decode import decode
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.exceptions import UnsupportedError
|
||||
from lnbits.helpers import normalize_endpoint
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.crypto import random_secret_and_hash
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
PaymentPendingStatus,
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
PaymentSuccessStatus,
|
||||
StatusResponse,
|
||||
Wallet,
|
||||
)
|
||||
|
||||
|
||||
class CLNRestWallet(Wallet):
|
||||
def __init__(self):
|
||||
if not settings.clnrest_url:
|
||||
raise ValueError("Cannot initialize CLNRestWallet: missing CLNREST_URL")
|
||||
|
||||
if not settings.clnrest_readonly_rune:
|
||||
raise ValueError(
|
||||
"cannot initialize CLNRestWallet: " "missing clnrest_readonly_rune"
|
||||
)
|
||||
|
||||
self.url = normalize_endpoint(settings.clnrest_url)
|
||||
|
||||
if not settings.clnrest_nodeid:
|
||||
logger.info("missing CLNREST_NODEID, but this is only needed for v23.08")
|
||||
|
||||
self.base_headers = {
|
||||
"accept": "application/json",
|
||||
"User-Agent": settings.user_agent,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
if settings.clnrest_nodeid is not None:
|
||||
self.base_headers["nodeid"] = settings.clnrest_nodeid
|
||||
|
||||
# Ensure the readonly rune is set
|
||||
if settings.clnrest_readonly_rune is not None:
|
||||
self.readonly_headers = {
|
||||
**self.base_headers,
|
||||
"rune": settings.clnrest_readonly_rune,
|
||||
}
|
||||
else:
|
||||
logger.warning(
|
||||
"Readonly rune 'CLNREST_READONLY_RUNE' is required but not set."
|
||||
)
|
||||
|
||||
if settings.clnrest_invoice_rune is not None:
|
||||
self.invoice_headers = {
|
||||
**self.base_headers,
|
||||
"rune": settings.clnrest_invoice_rune,
|
||||
}
|
||||
else:
|
||||
logger.warning(
|
||||
"Will be unable to create any invoices without "
|
||||
"setting 'CLNREST_INVOICE_RUNE[:4]'"
|
||||
)
|
||||
|
||||
if settings.clnrest_pay_rune is not None:
|
||||
self.pay_headers = {**self.base_headers, "rune": settings.clnrest_pay_rune}
|
||||
else:
|
||||
logger.warning(
|
||||
"Will be unable to call pay endpoint without setting 'CLNREST_PAY_RUNE'"
|
||||
)
|
||||
|
||||
if settings.clnrest_renepay_rune is not None:
|
||||
self.renepay_headers = {
|
||||
**self.base_headers,
|
||||
"rune": settings.clnrest_renepay_rune,
|
||||
}
|
||||
else:
|
||||
logger.warning(
|
||||
"Will be unable to call renepay endpoint without "
|
||||
"setting 'CLNREST_RENEPAY_RUNE'"
|
||||
)
|
||||
|
||||
# https://docs.corelightning.org/reference/lightning-pay
|
||||
# -32602: Invalid bolt11: Prefix bc is not for regtest
|
||||
# -1: Catchall nonspecific error.
|
||||
## 201: Already paid
|
||||
# 203: Permanent failure at destination.
|
||||
# 205: Unable to find a route.
|
||||
# 206: Route too expensive.
|
||||
# 207: Invoice expired.
|
||||
# 210: Payment timed out without a payment in progress.
|
||||
# 401: Unauthorized. Probably a rune issue
|
||||
|
||||
self.pay_failure_error_codes = [-32602, 203, 205, 206, 207, 210, 401]
|
||||
self.client = self._create_client()
|
||||
self.last_pay_index = settings.clnrest_last_pay_index
|
||||
self.statuses = {
|
||||
"paid": True,
|
||||
"complete": True,
|
||||
"failed": False,
|
||||
"pending": None,
|
||||
}
|
||||
|
||||
async def cleanup(self):
|
||||
try:
|
||||
await self.client.aclose()
|
||||
except RuntimeError as exc:
|
||||
logger.warning(f"Error closing wallet connection: {exc}")
|
||||
|
||||
async def status(self) -> StatusResponse:
|
||||
try:
|
||||
logger.debug("REQUEST to /v1/listfunds")
|
||||
|
||||
r = await self.client.post(
|
||||
"/v1/listfunds", timeout=15, headers=self.readonly_headers
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
response_data = r.json()
|
||||
|
||||
if not response_data:
|
||||
logger.warning("Received empty response data")
|
||||
return StatusResponse("no data", 0)
|
||||
|
||||
channels = response_data.get("channels", [])
|
||||
total_our_amount_msat = sum(
|
||||
channel.get("our_amount_msat", 0) for channel in channels
|
||||
)
|
||||
|
||||
return StatusResponse(None, total_our_amount_msat)
|
||||
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning(f"JSON decode error: {exc!s}")
|
||||
return StatusResponse(f"Failed to decode JSON response from {self.url}", 0)
|
||||
|
||||
except httpx.ReadTimeout:
|
||||
logger.warning(
|
||||
"Timeout error: The server did not respond in time. This can happen if "
|
||||
"the server is running HTTPS but the client is using HTTP."
|
||||
)
|
||||
return StatusResponse(
|
||||
"Unable to connect to 'v1/listfunds' due to timeout", 0
|
||||
)
|
||||
|
||||
except (httpx.ConnectError, httpx.RequestError) as exc:
|
||||
logger.warning(f"Connection error: {exc}")
|
||||
return StatusResponse("Unable to connect to 'v1/listfunds'", 0)
|
||||
|
||||
except httpx.HTTPStatusError as exc:
|
||||
logger.warning(
|
||||
f"HTTP error: {exc.response.status_code} {exc.response.reason_phrase} "
|
||||
f"while accessing {exc.request.url}"
|
||||
)
|
||||
return StatusResponse(
|
||||
f"Failed with HTTP {exc.response.status_code} on 'v1/listfunds'", 0
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return StatusResponse(f"Unable to connect to {self.url}.", 0)
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
|
||||
if not settings.clnrest_invoice_rune:
|
||||
return InvoiceResponse(
|
||||
ok=False, error_message="Unable to invoice without an invoice rune"
|
||||
)
|
||||
|
||||
data: dict = {
|
||||
"amount_msat": int(amount * 1000),
|
||||
"description": memo,
|
||||
"label": _generate_label(),
|
||||
}
|
||||
|
||||
if description_hash and not unhashed_description:
|
||||
raise UnsupportedError(
|
||||
"'description_hash' unsupported by CoreLightningRest, "
|
||||
"provide 'unhashed_description'"
|
||||
)
|
||||
|
||||
if unhashed_description:
|
||||
data["description"] = unhashed_description.decode()
|
||||
|
||||
if kwargs.get("expiry"):
|
||||
data["expiry"] = kwargs["expiry"]
|
||||
|
||||
if kwargs.get("preimage"):
|
||||
data["preimage"] = kwargs["preimage"]
|
||||
else:
|
||||
preimage, _ = random_secret_and_hash()
|
||||
data["preimage"] = preimage
|
||||
|
||||
try:
|
||||
r = await self.client.post(
|
||||
"/v1/invoice",
|
||||
json=data,
|
||||
headers=self.invoice_headers,
|
||||
)
|
||||
r.raise_for_status()
|
||||
response_data = r.json()
|
||||
|
||||
if "error" in response_data:
|
||||
error_message = response_data["error"]
|
||||
logger.debug(f"Error creating invoice: {error_message}")
|
||||
return InvoiceResponse(ok=False, error_message=error_message)
|
||||
|
||||
if "payment_hash" not in response_data or "bolt11" not in response_data:
|
||||
return InvoiceResponse(
|
||||
ok=False, error_message="Server error: 'missing required fields'"
|
||||
)
|
||||
return InvoiceResponse(
|
||||
ok=True,
|
||||
checking_id=response_data["payment_hash"],
|
||||
payment_request=response_data["bolt11"],
|
||||
preimage=data["preimage"],
|
||||
)
|
||||
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning(exc)
|
||||
return InvoiceResponse(
|
||||
ok=False, error_message="Server error: 'invalid json response'"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Unable to connect to {self.url}: {exc}")
|
||||
return InvoiceResponse(
|
||||
ok=False, error_message=f"Unable to connect to {self.url}."
|
||||
)
|
||||
|
||||
async def pay_invoice(
|
||||
self,
|
||||
bolt11: str,
|
||||
fee_limit_msat: int,
|
||||
**_,
|
||||
) -> PaymentResponse:
|
||||
|
||||
try:
|
||||
invoice = decode(bolt11)
|
||||
except Bolt11Exception as exc:
|
||||
return PaymentResponse(ok=False, error_message=str(exc))
|
||||
|
||||
if not invoice.amount_msat or invoice.amount_msat <= 0:
|
||||
return PaymentResponse(
|
||||
ok=False, error_message="0 amount invoices are not allowed"
|
||||
)
|
||||
|
||||
if not settings.clnrest_pay_rune and not settings.clnrest_renepay_rune:
|
||||
return PaymentResponse(
|
||||
ok=False,
|
||||
error_message="Unable to pay invoice without a pay or renepay rune",
|
||||
)
|
||||
|
||||
data = {
|
||||
"label": _generate_label(),
|
||||
"description": invoice.description,
|
||||
"maxfee": fee_limit_msat,
|
||||
}
|
||||
|
||||
if settings.clnrest_renepay_rune:
|
||||
endpoint = "/v1/renepay"
|
||||
headers = self.renepay_headers
|
||||
data["invstring"] = bolt11
|
||||
else:
|
||||
endpoint = "/v1/pay"
|
||||
headers = self.pay_headers
|
||||
data["bolt11"] = bolt11
|
||||
|
||||
try:
|
||||
r = await self.client.post(
|
||||
endpoint,
|
||||
json=data,
|
||||
headers=headers,
|
||||
timeout=None,
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
if "payment_preimage" not in data:
|
||||
error_message = data.get("error", "No payment preimage in response")
|
||||
logger.warning(error_message)
|
||||
return PaymentResponse(error_message=error_message)
|
||||
|
||||
return PaymentResponse(
|
||||
ok=self.statuses.get(data["status"]),
|
||||
checking_id=data["payment_hash"],
|
||||
fee_msat=data["amount_sent_msat"] - data["amount_msat"],
|
||||
preimage=data["payment_preimage"],
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
try:
|
||||
data = exc.response.json()
|
||||
error = data.get("error", {})
|
||||
error_code = int(error.get("code", 0))
|
||||
error_message = error.get("message", "Unknown error")
|
||||
if error_code in self.pay_failure_error_codes:
|
||||
return PaymentResponse(ok=False, error_message=error_message)
|
||||
else:
|
||||
return PaymentResponse(error_message=error_message)
|
||||
except Exception:
|
||||
error_message = f"Error parsing response from {self.url}: {exc!s}"
|
||||
logger.warning(error_message)
|
||||
return PaymentResponse(error_message=error_message)
|
||||
except Exception as exc:
|
||||
logger.info(f"Failed to pay invoice {bolt11}")
|
||||
logger.warning(exc)
|
||||
error_message = f"Unable to connect to {self.url}."
|
||||
return PaymentResponse(error_message=error_message)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
data: dict = {"payment_hash": checking_id}
|
||||
|
||||
try:
|
||||
r = await self.client.post(
|
||||
"/v1/listinvoices",
|
||||
json=data,
|
||||
headers=self.readonly_headers,
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if r.is_error or "error" in data or data.get("invoices") is None:
|
||||
logger.warning(f"error in cln response '{checking_id}'")
|
||||
return PaymentPendingStatus()
|
||||
status = self.statuses.get(data["invoices"][0]["status"])
|
||||
fee_msat = data["invoices"][0].get("amount_received_msat", 0) - data[
|
||||
"invoices"
|
||||
][0].get("amount_msat", 0)
|
||||
return PaymentStatus(
|
||||
paid=status,
|
||||
preimage=data["invoices"][0].get("preimage"),
|
||||
fee_msat=fee_msat,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Error getting invoice status: {exc}")
|
||||
return PaymentPendingStatus()
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
try:
|
||||
data: dict = {"payment_hash": checking_id}
|
||||
r = await self.client.post(
|
||||
"/v1/listpays",
|
||||
json=data,
|
||||
headers=self.readonly_headers,
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
if r.is_error or "error" in data:
|
||||
logger.warning(f"API response error: {data}")
|
||||
return PaymentPendingStatus()
|
||||
|
||||
pays_list = data.get("pays", [])
|
||||
if not pays_list:
|
||||
logger.warning(f"No payments found for payment hash {checking_id}.")
|
||||
return PaymentPendingStatus()
|
||||
|
||||
if len(pays_list) != 1:
|
||||
logger.warning(
|
||||
f"Expected one payment status, but found {len(pays_list)}"
|
||||
)
|
||||
return PaymentPendingStatus()
|
||||
|
||||
pay = pays_list[-1]
|
||||
|
||||
if pay["status"] == "complete":
|
||||
fee_msat = pay["amount_sent_msat"] - pay["amount_msat"]
|
||||
return PaymentSuccessStatus(fee_msat=fee_msat, preimage=pay["preimage"])
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning(f"Error getting payment status: {exc}")
|
||||
|
||||
return PaymentPendingStatus()
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
while settings.lnbits_running:
|
||||
try:
|
||||
waitanyinvoice_timeout = None
|
||||
request_timeout = httpx.Timeout(
|
||||
connect=5.0, read=waitanyinvoice_timeout, write=60.0, pool=60.0
|
||||
)
|
||||
data: dict = {
|
||||
"lastpay_index": self.last_pay_index,
|
||||
"timeout": waitanyinvoice_timeout,
|
||||
}
|
||||
url = "/v1/waitanyinvoice"
|
||||
async with self.client.stream(
|
||||
"POST",
|
||||
url,
|
||||
json=data,
|
||||
headers=self.readonly_headers,
|
||||
timeout=request_timeout,
|
||||
) as r:
|
||||
async for line in r.aiter_lines():
|
||||
inv = json.loads(line)
|
||||
if "error" in inv and "message" in inv["error"]:
|
||||
logger.warning("Error in paid_invoices_stream:", inv)
|
||||
raise Exception(inv["error"]["message"])
|
||||
try:
|
||||
paid = inv["status"] == "paid"
|
||||
self.last_pay_index = inv["pay_index"]
|
||||
if not paid:
|
||||
continue
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
continue
|
||||
logger.debug(f"paid invoice: {inv}")
|
||||
|
||||
if "payment_hash" in inv:
|
||||
payment_hash_from_waitanyinvoice = inv["payment_hash"]
|
||||
yield payment_hash_from_waitanyinvoice
|
||||
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
f"lost connection to corelightning-rest invoices stream: '{exc}', "
|
||||
"reconnecting..."
|
||||
)
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
def _create_client(self) -> httpx.AsyncClient:
|
||||
"""Create an HTTP client with specified headers and SSL configuration."""
|
||||
|
||||
parsed_url = urlparse(self.url)
|
||||
|
||||
# Validate the URL scheme
|
||||
if parsed_url.scheme == "http":
|
||||
if parsed_url.hostname in ("localhost", "127.0.0.1", "::1"):
|
||||
logger.warning("Not using SSL for connection to CLNRestWallet")
|
||||
else:
|
||||
raise ValueError(
|
||||
"Insecure HTTP connections are only allowed for localhost or "
|
||||
"equivalent IP addresses. Set CLNREST_URL to https:// for external "
|
||||
"connections or use localhost."
|
||||
)
|
||||
return httpx.AsyncClient(base_url=self.url)
|
||||
|
||||
if parsed_url.scheme == "https":
|
||||
logger.info(f"Using SSL to connect to {self.url}")
|
||||
|
||||
# Check for CA certificate
|
||||
if not settings.clnrest_ca:
|
||||
logger.warning(
|
||||
"No CA certificate provided for CLNRestWallet. "
|
||||
"This setup requires a CA certificate for server authentication "
|
||||
"and trust. Set CLNREST_CA to the CA certificate file path or the "
|
||||
"contents of the certificate."
|
||||
)
|
||||
raise ValueError("CA certificate is required for secure communication.")
|
||||
|
||||
logger.info(f"CA Certificate provided: {settings.clnrest_ca}")
|
||||
|
||||
# Create an SSL context and load the CA certificate
|
||||
ssl_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
|
||||
|
||||
# Load CA certificate
|
||||
if os.path.isfile(settings.clnrest_ca):
|
||||
logger.info(f"Using CA certificate file: {settings.clnrest_ca}")
|
||||
ssl_context.load_verify_locations(cafile=settings.clnrest_ca)
|
||||
else:
|
||||
logger.info("Using CA certificate from PEM string: ")
|
||||
ca_content = settings.clnrest_ca.replace("\\n", "\n")
|
||||
logger.info(ca_content)
|
||||
ssl_context.load_verify_locations(cadata=ca_content)
|
||||
|
||||
# Optional: Disable hostname checking if necessary
|
||||
# especially for ip addresses
|
||||
ssl_context.check_hostname = False
|
||||
|
||||
# Create the HTTP client without a client certificate
|
||||
client = httpx.AsyncClient(base_url=self.url, verify=ssl_context)
|
||||
|
||||
return client
|
||||
|
||||
else:
|
||||
raise ValueError("CLNREST_URL must start with http:// or https://")
|
||||
|
||||
|
||||
def _generate_label() -> str:
|
||||
"""Generate a unique label for the invoice."""
|
||||
random_uuid = base64.urlsafe_b64encode(uuid.uuid4().bytes).rstrip(b"=").decode()
|
||||
return f"LNbits_{random_uuid}"
|
||||
@@ -1,6 +1,6 @@
|
||||
import asyncio
|
||||
import random
|
||||
from collections.abc import AsyncGenerator
|
||||
from secrets import token_urlsafe
|
||||
from typing import Any, Optional
|
||||
|
||||
from bolt11.decode import decode as bolt11_decode
|
||||
@@ -92,7 +92,7 @@ class CoreLightningWallet(Wallet):
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
label = kwargs.get("label", f"lbl{random.random()}")
|
||||
label = kwargs.get("label", f"lbl{token_urlsafe(16)}")
|
||||
msat: int = int(amount * 1000)
|
||||
try:
|
||||
if description_hash and not unhashed_description:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import random
|
||||
from collections.abc import AsyncGenerator
|
||||
from secrets import token_urlsafe
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
@@ -111,7 +111,7 @@ class CoreLightningRestWallet(Wallet):
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
label = kwargs.get("label", f"lbl{random.random()}")
|
||||
label = kwargs.get("label", f"lbl{token_urlsafe(16)}")
|
||||
data: dict = {
|
||||
"amount": amount * 1000,
|
||||
"description": memo,
|
||||
@@ -298,7 +298,8 @@ class CoreLightningRestWallet(Wallet):
|
||||
self.last_pay_index = inv["pay_index"]
|
||||
if not paid:
|
||||
continue
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.debug(exc)
|
||||
continue
|
||||
logger.trace(f"paid invoice: {inv}")
|
||||
|
||||
@@ -313,11 +314,12 @@ class CoreLightningRestWallet(Wallet):
|
||||
)
|
||||
paid_invoice = r.json()
|
||||
logger.trace(f"paid invoice: {paid_invoice}")
|
||||
assert self.statuses[
|
||||
paid_invoice["invoices"][0]["status"]
|
||||
], "streamed invoice not paid"
|
||||
assert "invoices" in paid_invoice, "no invoices in response"
|
||||
assert len(paid_invoice["invoices"]), "no invoices in response"
|
||||
if not self.statuses[paid_invoice["invoices"][0]["status"]]:
|
||||
raise ValueError("streamed invoice not paid")
|
||||
if "invoices" not in paid_invoice:
|
||||
raise ValueError("no invoices in response")
|
||||
if not len(paid_invoice["invoices"]):
|
||||
raise ValueError("no invoices in response")
|
||||
yield paid_invoice["invoices"][0]["payment_hash"]
|
||||
|
||||
except Exception as exc:
|
||||
|
||||
@@ -292,7 +292,8 @@ class LndRestWallet(Wallet):
|
||||
)
|
||||
else:
|
||||
return PaymentPendingStatus()
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.debug(exc)
|
||||
continue
|
||||
|
||||
return PaymentPendingStatus()
|
||||
@@ -307,7 +308,8 @@ class LndRestWallet(Wallet):
|
||||
inv = json.loads(line)["result"]
|
||||
if not inv["settled"]:
|
||||
continue
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.debug(exc)
|
||||
continue
|
||||
|
||||
payment_hash = base64.b64decode(inv["r_hash"]).hex()
|
||||
|
||||
@@ -173,11 +173,12 @@ class LnTipsWallet(Wallet):
|
||||
inv = json.loads(data)
|
||||
if not inv.get("payment_hash"):
|
||||
continue
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.debug(exc)
|
||||
continue
|
||||
yield inv["payment_hash"]
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.debug(exc)
|
||||
|
||||
# do not sleep if the connection was active for more than 10s
|
||||
# since the backend is expected to drop the connection after 90s
|
||||
|
||||
@@ -2,6 +2,8 @@ import base64
|
||||
from getpass import getpass
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.utils.crypto import AESCipher
|
||||
|
||||
|
||||
@@ -20,7 +22,8 @@ def load_macaroon(
|
||||
aes = AESCipher(key.encode())
|
||||
return aes.decrypt(encrypted_macaroon)
|
||||
|
||||
assert macaroon, "macaroon must be set here"
|
||||
if not macaroon:
|
||||
raise ValueError("macaroon must be set here")
|
||||
|
||||
# if the macaroon is a file path, load it and return hex version
|
||||
if macaroon.split(".")[-1] == "macaroon":
|
||||
@@ -39,7 +42,7 @@ def load_macaroon(
|
||||
try:
|
||||
macaroon = base64.b64decode(macaroon).hex()
|
||||
return macaroon
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.debug(exc)
|
||||
|
||||
return macaroon
|
||||
|
||||
@@ -62,7 +62,7 @@ class NWCWallet(Wallet):
|
||||
# interval in seconds between checks for pending payments
|
||||
self.pending_payments_lookup_interval = 10
|
||||
# track paid invoices for paid_invoices_stream
|
||||
self.paid_invoices_queue: asyncio.Queue = asyncio.Queue(0)
|
||||
self.paid_invoices_queue = asyncio.Queue(0)
|
||||
# This task periodically checks if pending payments have been settled
|
||||
self.pending_payments_lookup_task = asyncio.create_task(
|
||||
self._handle_pending_payments()
|
||||
@@ -309,7 +309,8 @@ class NWCConnection:
|
||||
self.account_private_key = secp256k1.PrivateKey(bytes.fromhex(secret))
|
||||
self.account_private_key_hex = secret
|
||||
self.account_public_key = self.account_private_key.pubkey
|
||||
assert self.account_public_key
|
||||
if not self.account_public_key:
|
||||
raise ValueError("Missing account public key")
|
||||
self.account_public_key_hex = self.account_public_key.serialize().hex()[2:]
|
||||
|
||||
# Extract service key (used for encryption to identify the nwc service provider)
|
||||
@@ -390,7 +391,7 @@ class NWCConnection:
|
||||
n = max_length - len(subid)
|
||||
if n > 0:
|
||||
for _ in range(n):
|
||||
subid += chars[random.randint(0, len(chars) - 1)]
|
||||
subid += chars[random.randint(0, len(chars) - 1)] # noqa: S311
|
||||
return subid
|
||||
|
||||
async def _close_subscription_by_subid(
|
||||
@@ -513,7 +514,7 @@ class NWCConnection:
|
||||
if subscription: # Check if the subscription exists first
|
||||
subscription["future"].set_exception(Exception(info))
|
||||
|
||||
async def _on_event_message(self, msg: list[Union[str, dict]]):
|
||||
async def _on_event_message(self, msg: list[Union[str, dict]]): # noqa: C901
|
||||
"""
|
||||
Handles EVENT messages from the relay.
|
||||
"""
|
||||
|
||||
+82
-29
@@ -7,6 +7,7 @@ from collections.abc import AsyncGenerator
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from httpx import RequestError, TimeoutException
|
||||
from loguru import logger
|
||||
from websockets.legacy.client import connect
|
||||
|
||||
@@ -15,6 +16,7 @@ from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
PaymentFailedStatus,
|
||||
PaymentPendingStatus,
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
@@ -173,13 +175,29 @@ class PhoenixdWallet(Wallet):
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
|
||||
except TimeoutException:
|
||||
# be safe and return pending on timeouts
|
||||
msg = f"Timeout connecting to {self.endpoint}. keep pending..."
|
||||
logger.warning(msg)
|
||||
return PaymentResponse(ok=None, error_message=msg)
|
||||
except RequestError as exc:
|
||||
# RequestError is raised when the request never hit the destination server
|
||||
msg = f"Unable to connect to {self.endpoint}."
|
||||
logger.warning(msg)
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(ok=False, error_message=msg)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(
|
||||
ok=None, error_message=f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
|
||||
try:
|
||||
data = r.json()
|
||||
|
||||
if "routingFeeSat" not in data and "reason" in data:
|
||||
return PaymentResponse(error_message=data["reason"])
|
||||
|
||||
if r.is_error or "paymentHash" not in data:
|
||||
error_message = data["message"] if "message" in data else r.text
|
||||
if "routingFeeSat" not in data and ("reason" in data or "message" in data):
|
||||
error_message = data.get("reason", data.get("message", "Unknown error"))
|
||||
return PaymentResponse(error_message=error_message)
|
||||
|
||||
checking_id = data["paymentHash"]
|
||||
@@ -208,40 +226,75 @@ class PhoenixdWallet(Wallet):
|
||||
)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
try:
|
||||
r = await self.client.get(f"/payments/incoming/{checking_id}")
|
||||
if r.is_error:
|
||||
r = await self.client.get(
|
||||
f"/payments/incoming/{checking_id}?all=true&limit=1000"
|
||||
)
|
||||
if r.is_error:
|
||||
if r.status_code == 404:
|
||||
# invoice does not exist in phoenixd, so it was never paid
|
||||
return PaymentFailedStatus()
|
||||
else:
|
||||
# otherwise something unexpected happened, and we keep it pending
|
||||
logger.warning(
|
||||
f"Error getting invoice status: {r.text}, keeping pending"
|
||||
)
|
||||
return PaymentPendingStatus()
|
||||
try:
|
||||
data = r.json()
|
||||
|
||||
if data["isPaid"]:
|
||||
fee_msat = data["fees"]
|
||||
preimage = data["preimage"]
|
||||
return PaymentSuccessStatus(fee_msat=fee_msat, preimage=preimage)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# should never return invalid json, but just in case we keep it pending
|
||||
logger.warning(
|
||||
f"Phoenixd: invalid json response: {r.text}, keeping pending"
|
||||
)
|
||||
return PaymentPendingStatus()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting invoice status: {e}")
|
||||
|
||||
if "isPaid" not in data or "fees" not in data or "preimage" not in data:
|
||||
# should never return missing fields, but just in case we keep it pending
|
||||
return PaymentPendingStatus()
|
||||
|
||||
if data["isPaid"] is True:
|
||||
fee_msat = data["fees"]
|
||||
preimage = data["preimage"]
|
||||
return PaymentSuccessStatus(fee_msat=fee_msat, preimage=preimage)
|
||||
|
||||
return PaymentPendingStatus()
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
# TODO: how can we detect a failed payment?
|
||||
try:
|
||||
r = await self.client.get(f"/payments/outgoing/{checking_id}")
|
||||
if r.is_error:
|
||||
r = await self.client.get(
|
||||
f"/payments/outgoing/{checking_id}?all=true&limit=1000"
|
||||
)
|
||||
if r.is_error:
|
||||
if r.status_code == 404:
|
||||
# payment does not exist in phoenixd, so it was never paid
|
||||
return PaymentFailedStatus()
|
||||
else:
|
||||
# otherwise something unexpected happened, and we keep it pending
|
||||
logger.warning(
|
||||
f"Error getting payment status: {r.text}, keeping pending"
|
||||
)
|
||||
return PaymentPendingStatus()
|
||||
try:
|
||||
data = r.json()
|
||||
|
||||
if data["isPaid"]:
|
||||
fee_msat = data["fees"]
|
||||
preimage = data["preimage"]
|
||||
return PaymentSuccessStatus(fee_msat=fee_msat, preimage=preimage)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# should never return invalid json, but just in case we keep it pending
|
||||
logger.warning(
|
||||
f"Phoenixd: invalid json response: {r.text}, keeping pending"
|
||||
)
|
||||
return PaymentPendingStatus()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting invoice status: {e}")
|
||||
|
||||
if "isPaid" not in data or "fees" not in data or "preimage" not in data:
|
||||
# should never happen, but just in case we keep it pending
|
||||
logger.warning(
|
||||
f"Phoenixd: missing required fields: {data}, keeping pending"
|
||||
)
|
||||
return PaymentPendingStatus()
|
||||
|
||||
if data["isPaid"] is True:
|
||||
fee_msat = data["fees"]
|
||||
preimage = data["preimage"]
|
||||
return PaymentSuccessStatus(fee_msat=fee_msat, preimage=preimage)
|
||||
return PaymentPendingStatus()
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
while settings.lnbits_running:
|
||||
try:
|
||||
@@ -263,7 +316,7 @@ class PhoenixdWallet(Wallet):
|
||||
yield message_json["paymentHash"]
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
logger.warning(
|
||||
f"lost connection to phoenixd invoices stream: '{exc}'"
|
||||
"retrying in 5 seconds"
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
from collections.abc import AsyncGenerator
|
||||
from secrets import token_urlsafe
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
@@ -116,7 +116,7 @@ class SparkWallet(Wallet):
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
label = f"lbs{random.random()}"
|
||||
label = f"lbs{token_urlsafe(16)}"
|
||||
try:
|
||||
if description_hash:
|
||||
r = await self.invoicewithdescriptionhash(
|
||||
|
||||
@@ -27,7 +27,7 @@ class TokenBucket:
|
||||
Token bucket rate limiter for Strike API endpoints.
|
||||
"""
|
||||
|
||||
def __init__(self, rate: int, period_seconds: int):
|
||||
def __init__(self, rate: int, period_seconds: int) -> None:
|
||||
"""
|
||||
Initialize a token bucket.
|
||||
|
||||
@@ -77,7 +77,7 @@ class StrikeWallet(Wallet):
|
||||
# construction / teardown #
|
||||
# --------------------------------------------------------------------- #
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
if not settings.strike_api_endpoint:
|
||||
raise ValueError("Missing strike_api_endpoint")
|
||||
if not settings.strike_api_key:
|
||||
@@ -120,7 +120,7 @@ class StrikeWallet(Wallet):
|
||||
self._cached_balance_ts: float = 0.0
|
||||
self._cache_ttl = 30 # seconds
|
||||
|
||||
async def cleanup(self):
|
||||
async def cleanup(self) -> None:
|
||||
try:
|
||||
await self.client.aclose()
|
||||
except Exception:
|
||||
@@ -487,7 +487,7 @@ class StrikeWallet(Wallet):
|
||||
|
||||
return None
|
||||
|
||||
async def _get_payment_status_by_checking_id(
|
||||
async def _get_payment_status_by_checking_id( # noqa: C901
|
||||
self, checking_id: str
|
||||
) -> PaymentStatus:
|
||||
r_payment = await self._get(f"/payments/{checking_id}")
|
||||
|
||||
Generated
+135
-14
@@ -656,6 +656,47 @@ files = [
|
||||
{file = "breez_sdk-0.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:e51689ed2f2c2d49bdb53f0ee26d0dc2109a50f15b8f2129a250db275eb994bf"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "breez-sdk-liquid"
|
||||
version = "0.9.1"
|
||||
description = "Python language bindings for the Breez Liquid SDK"
|
||||
optional = true
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"breez\""
|
||||
files = [
|
||||
{file = "breez_sdk_liquid-0.9.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:414a7c04dbf869435fe234b3b9987699c15dc2f8d4660d9ca1a51af608331b45"},
|
||||
{file = "breez_sdk_liquid-0.9.1-cp310-cp310-manylinux_2_31_aarch64.whl", hash = "sha256:7f461b6b10ddfe9cfb58119e590e5f67da4d7c93fc580a84a0116ce9fd21dffa"},
|
||||
{file = "breez_sdk_liquid-0.9.1-cp310-cp310-manylinux_2_31_x86_64.whl", hash = "sha256:c27efe35539e4092dddbe5315f5cefffccbdd699017ed8733611d11eb52f4ce0"},
|
||||
{file = "breez_sdk_liquid-0.9.1-cp310-cp310-win32.whl", hash = "sha256:375f6322c2552d15d6ba68302a85fdc345a7671eeda8fea92c013a16c5e96505"},
|
||||
{file = "breez_sdk_liquid-0.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:f28d107099fb7caaac4051d5950a4f11bf823e11b56e797976c5fa2a7280d597"},
|
||||
{file = "breez_sdk_liquid-0.9.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:acefa49896bdb4ca06d351faacbf075646d574dda5539033df11310ec5a94446"},
|
||||
{file = "breez_sdk_liquid-0.9.1-cp311-cp311-manylinux_2_31_aarch64.whl", hash = "sha256:d0e54fd5c8ce542dc2061d8a1ef46e6a1d430dbaea5eab369bb91ae2c4a1be7b"},
|
||||
{file = "breez_sdk_liquid-0.9.1-cp311-cp311-manylinux_2_31_x86_64.whl", hash = "sha256:a5d1bfd4d3286d57c110d925332416b0903dda076dadede02c4b7db67576f0bf"},
|
||||
{file = "breez_sdk_liquid-0.9.1-cp311-cp311-win32.whl", hash = "sha256:3ce2152f0a2ba65f4425fd22efb8e1dafb419e5fcf3b93dfe06fc061b1d1c0c6"},
|
||||
{file = "breez_sdk_liquid-0.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:8cdb4f247bccc0f4b9bbfff4451faa78d7b17e76a9dc918804880b600f1d4249"},
|
||||
{file = "breez_sdk_liquid-0.9.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:c463191a89a829afcae19c94f7683dac415667755d68c212afc60d8f2b21ba63"},
|
||||
{file = "breez_sdk_liquid-0.9.1-cp312-cp312-manylinux_2_31_aarch64.whl", hash = "sha256:52efaeae2c278b498b37de1090e80f73987ee943743e69a363877ed93cd7a31e"},
|
||||
{file = "breez_sdk_liquid-0.9.1-cp312-cp312-manylinux_2_31_x86_64.whl", hash = "sha256:68aebea98990319c677bcbd7d1de258627b85063a29720879986694b9d393adb"},
|
||||
{file = "breez_sdk_liquid-0.9.1-cp312-cp312-win32.whl", hash = "sha256:f53e4a1973dfd41df3ac3ecb42cf72acb71f49d5727a8acab25668b51a953bb2"},
|
||||
{file = "breez_sdk_liquid-0.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:6adb1f3f3ccd4b743a11dcc0b307db604c3424f42e2547f94fa939f0afb9bb85"},
|
||||
{file = "breez_sdk_liquid-0.9.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f94432a75bc848e597fb9859a77bab6c703d5e423d470519f37c402fc3c5d79e"},
|
||||
{file = "breez_sdk_liquid-0.9.1-cp313-cp313-manylinux_2_31_aarch64.whl", hash = "sha256:8d6838e69620162c038d513f95ffade870e112ae5c6a0d713eaa2a3a31d6f212"},
|
||||
{file = "breez_sdk_liquid-0.9.1-cp313-cp313-manylinux_2_31_x86_64.whl", hash = "sha256:d6b9a8601cef5875447e10c75ca3e1579dd1e881ec57cabe9787616b8a6de263"},
|
||||
{file = "breez_sdk_liquid-0.9.1-cp313-cp313-win32.whl", hash = "sha256:d76fc905003ac5a0cfd2132df90ba6343b40b9948164a0be06a68f15b28789a2"},
|
||||
{file = "breez_sdk_liquid-0.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:d6b0fc1d77b7855d9a72799d65cdaaa0bd07a3999d6d4d27050d8ef174ce0fd3"},
|
||||
{file = "breez_sdk_liquid-0.9.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:d0d560657c29d7e3b054ad4be07955fc41b63d4b93a2b51b29bb85d1d52341af"},
|
||||
{file = "breez_sdk_liquid-0.9.1-cp38-cp38-manylinux_2_31_aarch64.whl", hash = "sha256:2531f788eb15bb00f4b3b505ac5b8ee3de23161dd4f65768a4aa3255ea04d68e"},
|
||||
{file = "breez_sdk_liquid-0.9.1-cp38-cp38-manylinux_2_31_x86_64.whl", hash = "sha256:f9c9f6255999cf403bc07a9deed99f8c1625a0405ae9231e3dbecb3b6af0fdc7"},
|
||||
{file = "breez_sdk_liquid-0.9.1-cp38-cp38-win32.whl", hash = "sha256:5232b25b7e8583100ec54423e7568a305cac7778736adc9f8c735798110350c7"},
|
||||
{file = "breez_sdk_liquid-0.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:67d2b894b70ba9011fc98e04ed1ade2ff18f0413b6f2a0994747e71418d004e4"},
|
||||
{file = "breez_sdk_liquid-0.9.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:bd77d2ce43a2c0ad77a7f22226e373034ff57677338b38e4ebe3b77eb34fa224"},
|
||||
{file = "breez_sdk_liquid-0.9.1-cp39-cp39-manylinux_2_31_aarch64.whl", hash = "sha256:14092d9d65f1b0a9009e64630614e7b8b936e9d382d0f6415f023f0b192f6b45"},
|
||||
{file = "breez_sdk_liquid-0.9.1-cp39-cp39-manylinux_2_31_x86_64.whl", hash = "sha256:5faa4d5e9a2b47899783aa0c44ac2274d32f74a7f469506af14520deabaabf9f"},
|
||||
{file = "breez_sdk_liquid-0.9.1-cp39-cp39-win32.whl", hash = "sha256:b5b2c4bae13655d108475f4f22efdc2b07283f72d99b9c6f99166d9e41e62942"},
|
||||
{file = "breez_sdk_liquid-0.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:f3ad7c65582558031451538c8a03caa3486a2d2c8906f2fbbccce84e9357d1ea"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.6.15"
|
||||
@@ -2714,23 +2755,102 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "protobuf"
|
||||
version = "5.29.1"
|
||||
version = "5.29.5"
|
||||
description = ""
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "protobuf-5.29.1-cp310-abi3-win32.whl", hash = "sha256:22c1f539024241ee545cbcb00ee160ad1877975690b16656ff87dde107b5f110"},
|
||||
{file = "protobuf-5.29.1-cp310-abi3-win_amd64.whl", hash = "sha256:1fc55267f086dd4050d18ef839d7bd69300d0d08c2a53ca7df3920cc271a3c34"},
|
||||
{file = "protobuf-5.29.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:d473655e29c0c4bbf8b69e9a8fb54645bc289dead6d753b952e7aa660254ae18"},
|
||||
{file = "protobuf-5.29.1-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:b5ba1d0e4c8a40ae0496d0e2ecfdbb82e1776928a205106d14ad6985a09ec155"},
|
||||
{file = "protobuf-5.29.1-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ee1461b3af56145aca2800e6a3e2f928108c749ba8feccc6f5dd0062c410c0d"},
|
||||
{file = "protobuf-5.29.1-cp38-cp38-win32.whl", hash = "sha256:50879eb0eb1246e3a5eabbbe566b44b10348939b7cc1b267567e8c3d07213853"},
|
||||
{file = "protobuf-5.29.1-cp38-cp38-win_amd64.whl", hash = "sha256:027fbcc48cea65a6b17028510fdd054147057fa78f4772eb547b9274e5219331"},
|
||||
{file = "protobuf-5.29.1-cp39-cp39-win32.whl", hash = "sha256:5a41deccfa5e745cef5c65a560c76ec0ed8e70908a67cc8f4da5fce588b50d57"},
|
||||
{file = "protobuf-5.29.1-cp39-cp39-win_amd64.whl", hash = "sha256:012ce28d862ff417fd629285aca5d9772807f15ceb1a0dbd15b88f58c776c98c"},
|
||||
{file = "protobuf-5.29.1-py3-none-any.whl", hash = "sha256:32600ddb9c2a53dedc25b8581ea0f1fd8ea04956373c0c07577ce58d312522e0"},
|
||||
{file = "protobuf-5.29.1.tar.gz", hash = "sha256:683be02ca21a6ffe80db6dd02c0b5b2892322c59ca57fd6c872d652cb80549cb"},
|
||||
{file = "protobuf-5.29.5-cp310-abi3-win32.whl", hash = "sha256:3f1c6468a2cfd102ff4703976138844f78ebd1fb45f49011afc5139e9e283079"},
|
||||
{file = "protobuf-5.29.5-cp310-abi3-win_amd64.whl", hash = "sha256:3f76e3a3675b4a4d867b52e4a5f5b78a2ef9565549d4037e06cf7b0942b1d3fc"},
|
||||
{file = "protobuf-5.29.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e38c5add5a311f2a6eb0340716ef9b039c1dfa428b28f25a7838ac329204a671"},
|
||||
{file = "protobuf-5.29.5-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:fa18533a299d7ab6c55a238bf8629311439995f2e7eca5caaff08663606e9015"},
|
||||
{file = "protobuf-5.29.5-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:63848923da3325e1bf7e9003d680ce6e14b07e55d0473253a690c3a8b8fd6e61"},
|
||||
{file = "protobuf-5.29.5-cp38-cp38-win32.whl", hash = "sha256:ef91363ad4faba7b25d844ef1ada59ff1604184c0bcd8b39b8a6bef15e1af238"},
|
||||
{file = "protobuf-5.29.5-cp38-cp38-win_amd64.whl", hash = "sha256:7318608d56b6402d2ea7704ff1e1e4597bee46d760e7e4dd42a3d45e24b87f2e"},
|
||||
{file = "protobuf-5.29.5-cp39-cp39-win32.whl", hash = "sha256:6f642dc9a61782fa72b90878af134c5afe1917c89a568cd3476d758d3c3a0736"},
|
||||
{file = "protobuf-5.29.5-cp39-cp39-win_amd64.whl", hash = "sha256:470f3af547ef17847a28e1f47200a1cbf0ba3ff57b7de50d22776607cd2ea353"},
|
||||
{file = "protobuf-5.29.5-py3-none-any.whl", hash = "sha256:6cf42630262c59b2d8de33954443d94b746c952b01434fc58a417fdbd2e84bd5"},
|
||||
{file = "protobuf-5.29.5.tar.gz", hash = "sha256:bc1463bafd4b0929216c35f437a8e28731a2b7fe3d98bb77a600efced5a15c84"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psycopg2-binary"
|
||||
version = "2.9.10"
|
||||
description = "psycopg2 - Python-PostgreSQL Database Adapter"
|
||||
optional = true
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"migration\""
|
||||
files = [
|
||||
{file = "psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2"},
|
||||
{file = "psycopg2_binary-2.9.10-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:0ea8e3d0ae83564f2fc554955d327fa081d065c8ca5cc6d2abb643e2c9c1200f"},
|
||||
{file = "psycopg2_binary-2.9.10-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:3e9c76f0ac6f92ecfc79516a8034a544926430f7b080ec5a0537bca389ee0906"},
|
||||
{file = "psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ad26b467a405c798aaa1458ba09d7e2b6e5f96b1ce0ac15d82fd9f95dc38a92"},
|
||||
{file = "psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:270934a475a0e4b6925b5f804e3809dd5f90f8613621d062848dd82f9cd62007"},
|
||||
{file = "psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:48b338f08d93e7be4ab2b5f1dbe69dc5e9ef07170fe1f86514422076d9c010d0"},
|
||||
{file = "psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f4152f8f76d2023aac16285576a9ecd2b11a9895373a1f10fd9db54b3ff06b4"},
|
||||
{file = "psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:32581b3020c72d7a421009ee1c6bf4a131ef5f0a968fab2e2de0c9d2bb4577f1"},
|
||||
{file = "psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:2ce3e21dc3437b1d960521eca599d57408a695a0d3c26797ea0f72e834c7ffe5"},
|
||||
{file = "psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e984839e75e0b60cfe75e351db53d6db750b00de45644c5d1f7ee5d1f34a1ce5"},
|
||||
{file = "psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3c4745a90b78e51d9ba06e2088a2fe0c693ae19cc8cb051ccda44e8df8a6eb53"},
|
||||
{file = "psycopg2_binary-2.9.10-cp310-cp310-win32.whl", hash = "sha256:e5720a5d25e3b99cd0dc5c8a440570469ff82659bb09431c1439b92caf184d3b"},
|
||||
{file = "psycopg2_binary-2.9.10-cp310-cp310-win_amd64.whl", hash = "sha256:3c18f74eb4386bf35e92ab2354a12c17e5eb4d9798e4c0ad3a00783eae7cd9f1"},
|
||||
{file = "psycopg2_binary-2.9.10-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:04392983d0bb89a8717772a193cfaac58871321e3ec69514e1c4e0d4957b5aff"},
|
||||
{file = "psycopg2_binary-2.9.10-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1a6784f0ce3fec4edc64e985865c17778514325074adf5ad8f80636cd029ef7c"},
|
||||
{file = "psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5f86c56eeb91dc3135b3fd8a95dc7ae14c538a2f3ad77a19645cf55bab1799c"},
|
||||
{file = "psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b3d2491d4d78b6b14f76881905c7a8a8abcf974aad4a8a0b065273a0ed7a2cb"},
|
||||
{file = "psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2286791ececda3a723d1910441c793be44625d86d1a4e79942751197f4d30341"},
|
||||
{file = "psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:512d29bb12608891e349af6a0cccedce51677725a921c07dba6342beaf576f9a"},
|
||||
{file = "psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5a507320c58903967ef7384355a4da7ff3f28132d679aeb23572753cbf2ec10b"},
|
||||
{file = "psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6d4fa1079cab9018f4d0bd2db307beaa612b0d13ba73b5c6304b9fe2fb441ff7"},
|
||||
{file = "psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:851485a42dbb0bdc1edcdabdb8557c09c9655dfa2ca0460ff210522e073e319e"},
|
||||
{file = "psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:35958ec9e46432d9076286dda67942ed6d968b9c3a6a2fd62b48939d1d78bf68"},
|
||||
{file = "psycopg2_binary-2.9.10-cp311-cp311-win32.whl", hash = "sha256:ecced182e935529727401b24d76634a357c71c9275b356efafd8a2a91ec07392"},
|
||||
{file = "psycopg2_binary-2.9.10-cp311-cp311-win_amd64.whl", hash = "sha256:ee0e8c683a7ff25d23b55b11161c2663d4b099770f6085ff0a20d4505778d6b4"},
|
||||
{file = "psycopg2_binary-2.9.10-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0"},
|
||||
{file = "psycopg2_binary-2.9.10-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a"},
|
||||
{file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539"},
|
||||
{file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526"},
|
||||
{file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1"},
|
||||
{file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e"},
|
||||
{file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f"},
|
||||
{file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00"},
|
||||
{file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5"},
|
||||
{file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47"},
|
||||
{file = "psycopg2_binary-2.9.10-cp312-cp312-win32.whl", hash = "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64"},
|
||||
{file = "psycopg2_binary-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0"},
|
||||
{file = "psycopg2_binary-2.9.10-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d"},
|
||||
{file = "psycopg2_binary-2.9.10-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb"},
|
||||
{file = "psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7"},
|
||||
{file = "psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d"},
|
||||
{file = "psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73"},
|
||||
{file = "psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673"},
|
||||
{file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f"},
|
||||
{file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909"},
|
||||
{file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1"},
|
||||
{file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567"},
|
||||
{file = "psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142"},
|
||||
{file = "psycopg2_binary-2.9.10-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:eb09aa7f9cecb45027683bb55aebaaf45a0df8bf6de68801a6afdc7947bb09d4"},
|
||||
{file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b73d6d7f0ccdad7bc43e6d34273f70d587ef62f824d7261c4ae9b8b1b6af90e8"},
|
||||
{file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce5ab4bf46a211a8e924d307c1b1fcda82368586a19d0a24f8ae166f5c784864"},
|
||||
{file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:056470c3dc57904bbf63d6f534988bafc4e970ffd50f6271fc4ee7daad9498a5"},
|
||||
{file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73aa0e31fa4bb82578f3a6c74a73c273367727de397a7a0f07bd83cbea696baa"},
|
||||
{file = "psycopg2_binary-2.9.10-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8de718c0e1c4b982a54b41779667242bc630b2197948405b7bd8ce16bcecac92"},
|
||||
{file = "psycopg2_binary-2.9.10-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:5c370b1e4975df846b0277b4deba86419ca77dbc25047f535b0bb03d1a544d44"},
|
||||
{file = "psycopg2_binary-2.9.10-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:ffe8ed017e4ed70f68b7b371d84b7d4a790368db9203dfc2d222febd3a9c8863"},
|
||||
{file = "psycopg2_binary-2.9.10-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:8aecc5e80c63f7459a1a2ab2c64df952051df196294d9f739933a9f6687e86b3"},
|
||||
{file = "psycopg2_binary-2.9.10-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:7a813c8bdbaaaab1f078014b9b0b13f5de757e2b5d9be6403639b298a04d218b"},
|
||||
{file = "psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d00924255d7fc916ef66e4bf22f354a940c67179ad3fd7067d7a0a9c84d2fbfc"},
|
||||
{file = "psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7559bce4b505762d737172556a4e6ea8a9998ecac1e39b5233465093e8cee697"},
|
||||
{file = "psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b58f0a96e7a1e341fc894f62c1177a7c83febebb5ff9123b579418fdc8a481"},
|
||||
{file = "psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b269105e59ac96aba877c1707c600ae55711d9dcd3fc4b5012e4af68e30c648"},
|
||||
{file = "psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:79625966e176dc97ddabc142351e0409e28acf4660b88d1cf6adb876d20c490d"},
|
||||
{file = "psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:8aabf1c1a04584c168984ac678a668094d831f152859d06e055288fa515e4d30"},
|
||||
{file = "psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:19721ac03892001ee8fdd11507e6a2e01f4e37014def96379411ca99d78aeb2c"},
|
||||
{file = "psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7f5d859928e635fa3ce3477704acee0f667b3a3d3e4bb109f2b18d4005f38287"},
|
||||
{file = "psycopg2_binary-2.9.10-cp39-cp39-win32.whl", hash = "sha256:3216ccf953b3f267691c90c6fe742e45d890d8272326b4a8b20850a03d05b7b8"},
|
||||
{file = "psycopg2_binary-2.9.10-cp39-cp39-win_amd64.whl", hash = "sha256:30e34c4e97964805f715206c7b789d54a78b70f3ff19fbe590104b71c45600e5"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4373,10 +4493,11 @@ multidict = ">=4.0"
|
||||
propcache = ">=0.2.1"
|
||||
|
||||
[extras]
|
||||
breez = ["breez-sdk"]
|
||||
breez = ["breez-sdk", "breez-sdk-liquid"]
|
||||
liquid = ["wallycore"]
|
||||
migration = ["psycopg2-binary"]
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "~3.12 | ~3.11 | ~3.10"
|
||||
content-hash = "434e37e5aeb0eeb1a22e12fd1914b45f27195162ef4ebc1055071fd4718d85fc"
|
||||
content-hash = "265fb5ec81a3c703ca7db07dfe206194fbb8616cb6e196d0917f33e6df58753c"
|
||||
|
||||
+31
-6
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "lnbits"
|
||||
version = "1.2.1"
|
||||
version = "1.2.0"
|
||||
description = "LNbits, free and open-source Lightning wallet and accounts system."
|
||||
authors = ["Alan Bits <alan@lnbits.com>"]
|
||||
readme = "README.md"
|
||||
@@ -33,7 +33,7 @@ uvloop = "0.21.0"
|
||||
websockets = "15.0.1"
|
||||
loguru = "0.7.3"
|
||||
grpcio = "1.69.0"
|
||||
protobuf = "5.29.1"
|
||||
protobuf = "5.29.5"
|
||||
pyln-client = "25.5"
|
||||
pywebpush = "2.0.3"
|
||||
slowapi = "0.1.9"
|
||||
@@ -57,6 +57,9 @@ python-crontab = "3.2.0"
|
||||
wallycore = {version = "1.4.0", optional = true}
|
||||
# needed for breez funding source
|
||||
breez-sdk = {version = "0.8.0", optional = true}
|
||||
breez-sdk-liquid = {version = "0.9.1", optional = true}
|
||||
# needed for migration tests
|
||||
psycopg2-binary = {version = "2.9.10", optional = true}
|
||||
|
||||
jsonpath-ng = "^1.7.0"
|
||||
pynostr = "^0.6.2"
|
||||
@@ -65,8 +68,9 @@ filetype = "^1.2.0"
|
||||
nostr-sdk = "^0.42.1"
|
||||
|
||||
[tool.poetry.extras]
|
||||
breez = ["breez-sdk"]
|
||||
breez = ["breez-sdk", "breez-sdk-liquid"]
|
||||
liquid = ["wallycore"]
|
||||
migration = ["psycopg2-binary"]
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
black = "^25.1.0"
|
||||
@@ -147,6 +151,8 @@ module = [
|
||||
"json5.*",
|
||||
"jsonpath_ng.*",
|
||||
"filetype.*",
|
||||
"breez_sdk.*",
|
||||
"breez_sdk_liquid.*",
|
||||
]
|
||||
ignore_missing_imports = "True"
|
||||
|
||||
@@ -191,7 +197,8 @@ extend-exclude = [
|
||||
# UP - pyupgrade
|
||||
# RUF - ruff specific rules
|
||||
# B - bugbear
|
||||
select = ["F", "E", "W", "I", "A", "C", "N", "UP", "RUF", "B"]
|
||||
# S - bandit
|
||||
select = ["F", "E", "W", "I", "A", "C", "N", "UP", "RUF", "B", "S"]
|
||||
# UP007: pyupgrade: use X | Y instead of Optional. (python3.10)
|
||||
# RUF012: mutable-class-default
|
||||
ignore = ["RUF012"]
|
||||
@@ -210,9 +217,27 @@ classmethod-decorators = [
|
||||
"validator",
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# S101: Use of assert detected. mostly for tests...
|
||||
# S105: Use of hard-coded password. mostly for tests...
|
||||
# S106: Possible hardcoded password: 'password'.
|
||||
# S307 Use of possibly insecure function; consider using `ast.literal_eval
|
||||
# S602 `subprocess` call with `shell=True` identified, security issue
|
||||
# S603 `subprocess` call: check for execution of untrusted input
|
||||
# S607: Starting a process with a partial executable path
|
||||
# S608: Possible SQL injection vector through string-based query construction
|
||||
# S324 Probable use of insecure hash functions in `hashlib`: `md5`
|
||||
# TODO: remove S101 ignore
|
||||
"lnbits/*" = ["S101"]
|
||||
"lnbits/core/views/admin_api.py" = ["S602", "S603", "S607"]
|
||||
"crypto.py" = ["S324"]
|
||||
"test*.py" = ["S101", "S105", "S106", "S307"]
|
||||
"tools*.py" = ["S101", "S608"]
|
||||
"tests/*" = ["S311"]
|
||||
"tests/regtest/helpers.py" = ["S603"]
|
||||
|
||||
[tool.ruff.lint.mccabe]
|
||||
# TODO: Decrease this to 10.
|
||||
max-complexity = 16
|
||||
max-complexity = 10
|
||||
|
||||
[tool.ruff.lint.flake8-bugbear]
|
||||
# Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`.
|
||||
|
||||
@@ -307,6 +307,7 @@ def _settings_cleanup(settings: Settings):
|
||||
settings.lnbits_reserve_fee_percent = 1
|
||||
settings.lnbits_reserve_fee_min = 2000
|
||||
settings.lnbits_service_fee = 0
|
||||
settings.lnbits_reserve_fee_percent = 0
|
||||
settings.lnbits_wallet_limit_daily_max_withdraw = 0
|
||||
settings.lnbits_admin_extensions = []
|
||||
settings.lnbits_admin_users = []
|
||||
|
||||
@@ -81,7 +81,7 @@ async def test_crud_search_payments():
|
||||
|
||||
user = await create_user_account()
|
||||
wallet = await create_wallet(user_id=user.id)
|
||||
filters: Filters = Filters(
|
||||
filters = Filters(
|
||||
search="",
|
||||
model=PaymentFilters,
|
||||
)
|
||||
|
||||
@@ -359,16 +359,19 @@ async def test_retry_failed_invoice(
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pay_external_invoice_pending(
|
||||
from_wallet: Wallet, mocker: MockerFixture, external_funding_source: FakeWallet
|
||||
from_wallet: Wallet,
|
||||
mocker: MockerFixture,
|
||||
external_funding_source: FakeWallet,
|
||||
settings: Settings,
|
||||
):
|
||||
settings.lnbits_reserve_fee_min = 1000 # msats
|
||||
invoice_amount = 2103
|
||||
external_invoice = await external_funding_source.create_invoice(invoice_amount)
|
||||
assert external_invoice.payment_request
|
||||
assert external_invoice.checking_id
|
||||
|
||||
preimage = "0000000000000000000000000000000000000000000000000000000000002103"
|
||||
payment_reponse_pending = PaymentResponse(
|
||||
ok=None, checking_id=external_invoice.checking_id, preimage=preimage
|
||||
ok=None, checking_id=external_invoice.checking_id
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.wallets.FakeWallet.pay_invoice",
|
||||
@@ -392,12 +395,12 @@ async def test_pay_external_invoice_pending(
|
||||
assert _payment.checking_id == payment.payment_hash
|
||||
assert _payment.amount == -2103_000
|
||||
assert _payment.bolt11 == external_invoice.payment_request
|
||||
assert _payment.preimage == preimage
|
||||
|
||||
wallet = await get_wallet(from_wallet.id)
|
||||
assert wallet
|
||||
reserve_fee_sat = int(abs(settings.lnbits_reserve_fee_min // 1000))
|
||||
assert (
|
||||
balance_before - invoice_amount == wallet.balance
|
||||
balance_before - invoice_amount - reserve_fee_sat == wallet.balance
|
||||
), "Pending payment is subtracted."
|
||||
|
||||
assert ws_notification.call_count == 0, "Websocket notification not sent."
|
||||
@@ -405,8 +408,12 @@ async def test_pay_external_invoice_pending(
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_retry_pay_external_invoice_pending(
|
||||
from_wallet: Wallet, mocker: MockerFixture, external_funding_source: FakeWallet
|
||||
from_wallet: Wallet,
|
||||
mocker: MockerFixture,
|
||||
external_funding_source: FakeWallet,
|
||||
settings: Settings,
|
||||
):
|
||||
settings.lnbits_reserve_fee_min = 2000 # msats
|
||||
invoice_amount = 2106
|
||||
external_invoice = await external_funding_source.create_invoice(invoice_amount)
|
||||
assert external_invoice.payment_request
|
||||
@@ -440,9 +447,10 @@ async def test_retry_pay_external_invoice_pending(
|
||||
|
||||
wallet = await get_wallet(from_wallet.id)
|
||||
assert wallet
|
||||
# TODO: is this correct?
|
||||
reserve_fee_sat = int(abs(settings.lnbits_reserve_fee_min // 1000))
|
||||
|
||||
assert (
|
||||
balance_before - invoice_amount == wallet.balance
|
||||
balance_before - invoice_amount - reserve_fee_sat == wallet.balance
|
||||
), "Failed payment is subtracted."
|
||||
|
||||
assert ws_notification.call_count == 0, "Websocket notification not sent."
|
||||
|
||||
@@ -2182,8 +2182,16 @@
|
||||
"get_invoice_status_endpoint": []
|
||||
},
|
||||
"phoenixd": {
|
||||
"description": "phoenixd.py doesn't handle the 'failed' status for `get_invoice_status`",
|
||||
"get_invoice_status_endpoint": []
|
||||
"get_invoice_status_endpoint": [
|
||||
{
|
||||
"description": "http 404",
|
||||
"response_type": "response",
|
||||
"response": {
|
||||
"response": "Not Found",
|
||||
"status": 404
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2389,14 +2397,6 @@
|
||||
"description": "bad json",
|
||||
"response_type": "data",
|
||||
"response": "data-not-json"
|
||||
},
|
||||
{
|
||||
"description": "http 404",
|
||||
"response_type": "response",
|
||||
"response": {
|
||||
"response": "Not Found",
|
||||
"status": 404
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2674,8 +2674,16 @@
|
||||
]
|
||||
},
|
||||
"phoenixd": {
|
||||
"description": "phoenixd.py doesn't handle the 'failed' status for `get_invoice_status`",
|
||||
"get_payment_status_endpoint": []
|
||||
"get_payment_status_endpoint": [
|
||||
{
|
||||
"description": "http 404",
|
||||
"response_type": "response",
|
||||
"response": {
|
||||
"response": "Not Found",
|
||||
"status": 404
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2932,14 +2940,6 @@
|
||||
"description": "bad json",
|
||||
"response_type": "data",
|
||||
"response": "data-not-json"
|
||||
},
|
||||
{
|
||||
"description": "http 404",
|
||||
"response_type": "response",
|
||||
"response": {
|
||||
"response": "Not Found",
|
||||
"status": 404
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ def sign_event(pub_key, priv_key, event):
|
||||
return event
|
||||
|
||||
|
||||
async def handle(wallet, mock_settings, data, websocket, path):
|
||||
async def handle(wallet, mock_settings, data, websocket, path): # noqa: C901
|
||||
async for message in websocket:
|
||||
if not wallet:
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user