mirror of
https://github.com/lnbits/lnbits.git
synced 2026-08-20 13:37:35 +02:00
Merge branch 'dev' into feat/amboss-payments-funding-source
This commit is contained in:
commit
ddcb88220b
104 changed files with 6180 additions and 387 deletions
30
Makefile
30
Makefile
|
|
@ -93,16 +93,36 @@ migration:
|
|||
uv run python tools/conv.py
|
||||
|
||||
openapi:
|
||||
@OPENAPI_SPEC_FILE=$$(mktemp); \
|
||||
OPENAPI_DATA_DIR=$$(mktemp -d); \
|
||||
LNBITS_ADMIN_UI=False \
|
||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
LNBITS_DATA_FOLDER="$$OPENAPI_DATA_DIR" \
|
||||
LNBITS_EXTENSIONS_DEFAULT_INSTALL='[]' \
|
||||
LNBITS_EXTENSIONS_DEACTIVATE_ALL=true \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=false \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=5003 \
|
||||
uv run lnbits &
|
||||
sleep 15
|
||||
curl -s http://0.0.0.0:5003/openapi.json | uv run openapi-spec-validator --errors=all -
|
||||
# kill -9 %1
|
||||
uv run lnbits & \
|
||||
OPENAPI_SERVER_PID=$$!; \
|
||||
trap 'kill "$$OPENAPI_SERVER_PID" 2>/dev/null || true; wait "$$OPENAPI_SERVER_PID" 2>/dev/null || true; rm -f "$$OPENAPI_SPEC_FILE"; rm -rf "$$OPENAPI_DATA_DIR"' EXIT; \
|
||||
OPENAPI_ATTEMPT=0; \
|
||||
while [ "$$OPENAPI_ATTEMPT" -lt 60 ]; do \
|
||||
if curl --fail --silent --max-time 2 --output "$$OPENAPI_SPEC_FILE" \
|
||||
http://127.0.0.1:5003/openapi.json; then \
|
||||
uv run openapi-spec-validator --errors=all "$$OPENAPI_SPEC_FILE"; \
|
||||
exit $$?; \
|
||||
fi; \
|
||||
if ! kill -0 "$$OPENAPI_SERVER_PID" 2>/dev/null; then \
|
||||
echo "LNbits exited before serving the OpenAPI schema." >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
OPENAPI_ATTEMPT=$$((OPENAPI_ATTEMPT + 1)); \
|
||||
sleep 1; \
|
||||
done; \
|
||||
echo "LNbits did not serve the OpenAPI schema within 60 seconds." >&2; \
|
||||
exit 1
|
||||
|
||||
bak:
|
||||
# LNBITS_DATABASE_URL=postgres://postgres:postgres@0.0.0.0:5432/postgres
|
||||
|
|
|
|||
36
SECURITY.md
Normal file
36
SECURITY.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security fixes are provided for the current released version of LNbits and the
|
||||
`dev` branch. Older releases and release candidates are not supported unless a
|
||||
maintainer explicitly states otherwise.
|
||||
|
||||
| Version | Supported |
|
||||
| ------------------ | --------- |
|
||||
| Current release | Yes |
|
||||
| `dev` branch | Yes |
|
||||
| Older releases | No |
|
||||
| Release candidates | No |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report suspected vulnerabilities privately using [GitHub's private
|
||||
vulnerability reporting](https://github.com/lnbits/lnbits/security/advisories/new).
|
||||
Do not open a public issue, discussion, or pull request for a security
|
||||
vulnerability.
|
||||
|
||||
Include enough detail for maintainers to reproduce and assess the issue, such
|
||||
as the affected version or commit, configuration, steps to reproduce, impact,
|
||||
and any proof of concept. Do not include credentials, API keys, wallet data, or
|
||||
other sensitive information unless it is necessary and can be shared safely.
|
||||
|
||||
Maintainers will acknowledge the report, investigate it, and coordinate a fix
|
||||
and disclosure timeline with you. Please allow time for a fix to be prepared
|
||||
before publicly disclosing the vulnerability.
|
||||
|
||||
## Scope
|
||||
|
||||
This policy covers the LNbits core repository and LNbits extensions in the LNbits GitHub organisation. Vulnerabilities in third-party
|
||||
funding sources, dependencies or hosted LNbits instances may need to be reported to their respective maintainers or
|
||||
operators as well.
|
||||
|
|
@ -568,7 +568,6 @@ async def check_and_register_extensions(app: FastAPI) -> None:
|
|||
|
||||
|
||||
def register_async_tasks() -> None:
|
||||
|
||||
task_manager.init()
|
||||
|
||||
# listen to all incoming payments and dispatch payment notifications
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from .views.api import api_router
|
|||
from .views.asset_api import asset_router
|
||||
from .views.audit_api import audit_router
|
||||
from .views.auth_api import auth_router
|
||||
from .views.blockexplorer_api import blockexplorer_router
|
||||
from .views.callback_api import callback_router
|
||||
from .views.extension_api import extension_router
|
||||
from .views.extensions_builder_api import extension_builder_router
|
||||
|
|
@ -49,6 +50,7 @@ def init_core_routers(app: FastAPI):
|
|||
app.include_router(asset_router)
|
||||
app.include_router(fiat_router)
|
||||
app.include_router(lnurl_router)
|
||||
app.include_router(blockexplorer_router)
|
||||
|
||||
|
||||
__all__ = ["core_app", "core_app_extra", "db"]
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from uuid import uuid4
|
|||
from lnbits.core.db import db
|
||||
from lnbits.core.models.wallets import BaseWallet, WalletsFilters, WalletType
|
||||
from lnbits.db import Connection, Filters, Page
|
||||
from lnbits.helpers import generate_ln_address
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.cache import cache
|
||||
|
||||
|
|
@ -30,6 +31,8 @@ async def create_wallet(
|
|||
inkey=uuid4().hex,
|
||||
currency=settings.lnbits_default_accounting_currency or "USD",
|
||||
)
|
||||
if settings.ln_address_creation_allowed and wallet.is_lightning_wallet:
|
||||
wallet.lightning_address = await generate_lightning_address_local_part(conn)
|
||||
|
||||
await (conn or db).insert("wallets", wallet)
|
||||
return wallet
|
||||
|
|
@ -123,11 +126,21 @@ async def get_standalone_wallet(
|
|||
"""
|
||||
if deleted is not None:
|
||||
query += " AND deleted = :deleted "
|
||||
return await (conn or db).fetchone(
|
||||
wallet = await (conn or db).fetchone(
|
||||
query,
|
||||
{"wallet": wallet_id, "deleted": deleted},
|
||||
Wallet,
|
||||
)
|
||||
if not wallet:
|
||||
return None
|
||||
if deleted is True:
|
||||
return wallet
|
||||
|
||||
if not wallet.lightning_address and settings.ln_address_creation_allowed:
|
||||
wallet.lightning_address = await generate_lightning_address_local_part(conn)
|
||||
await update_wallet(wallet, conn)
|
||||
|
||||
return wallet
|
||||
|
||||
|
||||
async def get_wallet(
|
||||
|
|
@ -220,6 +233,30 @@ async def get_wallets_count():
|
|||
return row.get("count", 0)
|
||||
|
||||
|
||||
async def generate_lightning_address_local_part(
|
||||
conn: Connection | None = None,
|
||||
) -> str:
|
||||
for _ in range(100):
|
||||
local_part = generate_ln_address()
|
||||
if await get_wallet_id_by_ln_address(local_part, conn):
|
||||
continue
|
||||
return local_part
|
||||
raise ValueError("Could not generate a unique wallet lightning address.")
|
||||
|
||||
|
||||
async def get_wallet_id_by_ln_address(
|
||||
local_part: str, conn: Connection | None = None
|
||||
) -> str | None:
|
||||
row: dict = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT id FROM wallets
|
||||
WHERE lightning_address = :lightning_address
|
||||
""",
|
||||
{"lightning_address": local_part.lower()},
|
||||
)
|
||||
return row["id"] if row else None
|
||||
|
||||
|
||||
async def get_wallet_for_key(
|
||||
key: str,
|
||||
conn: Connection | None = None,
|
||||
|
|
|
|||
|
|
@ -890,3 +890,15 @@ async def m049_add_permissions_to_user_extensions(db: Connection):
|
|||
Adds user-level extension permission grants.
|
||||
"""
|
||||
await db.execute("ALTER TABLE extensions ADD COLUMN permissions TEXT DEFAULT '{}'")
|
||||
|
||||
|
||||
async def m050_add_lightning_address_to_wallets(db: Connection):
|
||||
"""
|
||||
Adds a LUD-16 lightning address local-part to wallets.
|
||||
"""
|
||||
await db.execute("ALTER TABLE wallets ADD COLUMN lightning_address TEXT")
|
||||
logger.debug("Creating index idx_wallets_lightning_address...")
|
||||
await db.execute("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_wallets_lightning_address
|
||||
ON wallets (lightning_address);
|
||||
""")
|
||||
|
|
|
|||
|
|
@ -275,6 +275,9 @@ class CreateInvoice(BaseModel):
|
|||
labels: list[str] = []
|
||||
external_id: str | None = Query(default=None, max_length=256)
|
||||
|
||||
def is_fiat_subscription(self) -> bool:
|
||||
return (self.extra or {}).get("fiat_method") == "subscription"
|
||||
|
||||
@validator("payment_hash")
|
||||
def check_hex(cls, v):
|
||||
if v:
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@ class Wallet(BaseWallet):
|
|||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
currency: str | None = None
|
||||
lightning_address: str | None = None
|
||||
balance_msat: int = Field(default=0, no_database=True)
|
||||
extra: WalletExtra = WalletExtra()
|
||||
stored_paylinks: StoredPayLinks = StoredPayLinks()
|
||||
|
|
@ -150,6 +151,7 @@ class Wallet(BaseWallet):
|
|||
|
||||
if len(self.share_permissions):
|
||||
self.currency = shared_wallet.currency
|
||||
self.lightning_address = shared_wallet.lightning_address
|
||||
self.balance_msat = shared_wallet.balance_msat
|
||||
|
||||
self.stored_paylinks = shared_wallet.stored_paylinks
|
||||
|
|
@ -240,10 +242,18 @@ class BaseWalletTypeInfo:
|
|||
|
||||
|
||||
class WalletsFilters(FilterModel):
|
||||
__search_fields__ = ["id", "name", "currency"]
|
||||
__search_fields__ = ["id", "name", "currency", "lightning_address"]
|
||||
|
||||
__sort_fields__ = ["id", "name", "currency", "created_at", "updated_at"]
|
||||
__sort_fields__ = [
|
||||
"id",
|
||||
"name",
|
||||
"currency",
|
||||
"lightning_address",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
id: str | None
|
||||
name: str | None
|
||||
currency: str | None
|
||||
lightning_address: str | None
|
||||
|
|
|
|||
|
|
@ -1,3 +1,10 @@
|
|||
from .blockexplorer import (
|
||||
fetch_fee_estimates,
|
||||
fetch_onchain_balance,
|
||||
fetch_recent_blocks,
|
||||
fetch_tip,
|
||||
fetch_transaction,
|
||||
)
|
||||
from .fiat_providers import check_fiat_status
|
||||
from .funding_source import (
|
||||
get_balance_delta,
|
||||
|
|
@ -56,7 +63,12 @@ __all__ = [
|
|||
"enqueue_admin_notification",
|
||||
"fee_reserve",
|
||||
"fee_reserve_total",
|
||||
"fetch_fee_estimates",
|
||||
"fetch_lnurl_pay_request",
|
||||
"fetch_onchain_balance",
|
||||
"fetch_recent_blocks",
|
||||
"fetch_tip",
|
||||
"fetch_transaction",
|
||||
"get_balance_delta",
|
||||
"get_payments_daily_stats",
|
||||
"get_pr_from_lnurl",
|
||||
|
|
|
|||
97
lnbits/core/services/blockexplorer.py
Normal file
97
lnbits/core/services/blockexplorer.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import asyncio
|
||||
|
||||
from lnbits.settings import settings
|
||||
from lnbits.task_manager import OnchainAddressEvent
|
||||
from lnbits.utils.electrum import (
|
||||
UTXO,
|
||||
AddressResponse,
|
||||
Balance,
|
||||
BlockHeader,
|
||||
BlockInfo,
|
||||
ElectrumClient,
|
||||
FeeResponse,
|
||||
Transaction,
|
||||
network_from_name,
|
||||
parse_block_header,
|
||||
parse_raw_tx,
|
||||
scripthash_from_address,
|
||||
)
|
||||
|
||||
|
||||
def _client() -> ElectrumClient:
|
||||
return ElectrumClient(
|
||||
settings.lnbits_blockexplorer_electrum_url,
|
||||
network=network_from_name(settings.lnbits_blockexplorer_network),
|
||||
)
|
||||
|
||||
|
||||
async def fetch_recent_blocks(count: int = 5) -> list[BlockInfo]:
|
||||
async with _client() as c:
|
||||
tip = await c.get_tip()
|
||||
start = max(0, tip.height - count + 1)
|
||||
headers = await c.get_block_headers(start, tip.height - start + 1)
|
||||
raw = bytes.fromhex(headers.hex)
|
||||
blocks = [
|
||||
parse_block_header(raw[i * 80 : (i + 1) * 80].hex(), start + i)
|
||||
for i in range(headers.count)
|
||||
]
|
||||
return list(reversed(blocks))
|
||||
|
||||
|
||||
async def fetch_tip() -> BlockHeader:
|
||||
async with _client() as c:
|
||||
return await c.get_tip()
|
||||
|
||||
|
||||
async def fetch_fee_estimates() -> FeeResponse:
|
||||
async with _client() as c:
|
||||
estimates_raw = await asyncio.gather(
|
||||
c.estimate_fee(1),
|
||||
c.estimate_fee(3),
|
||||
c.estimate_fee(6),
|
||||
c.estimate_fee(144),
|
||||
)
|
||||
histogram = await c.fee_histogram()
|
||||
estimates = {
|
||||
str(blocks): fee
|
||||
for blocks, fee in zip([1, 3, 6, 144], estimates_raw, strict=False)
|
||||
if fee >= 0
|
||||
}
|
||||
return FeeResponse(estimates=estimates, histogram=histogram)
|
||||
|
||||
|
||||
async def fetch_transaction(txid: str) -> Transaction:
|
||||
async with _client() as c:
|
||||
raw_hex = await c.get_transaction(txid)
|
||||
return parse_raw_tx(raw_hex, network=c.network)
|
||||
|
||||
|
||||
async def fetch_onchain_balance(onchain_address: str) -> AddressResponse:
|
||||
scripthash = scripthash_from_address(onchain_address)
|
||||
async with _client() as client:
|
||||
balance_res, history_res = await asyncio.gather(
|
||||
client.get_balance(scripthash),
|
||||
client.get_history(scripthash),
|
||||
return_exceptions=True,
|
||||
)
|
||||
if isinstance(balance_res, BaseException):
|
||||
raise balance_res
|
||||
history = [] if isinstance(history_res, BaseException) else history_res
|
||||
history_error = str(history_res) if isinstance(history_res, BaseException) else None
|
||||
return AddressResponse(
|
||||
balance=balance_res, history=history, history_error=history_error
|
||||
)
|
||||
|
||||
|
||||
async def fetch_utxos(onchain_address: str) -> list[UTXO]:
|
||||
scripthash = scripthash_from_address(onchain_address)
|
||||
async with _client() as client:
|
||||
return await client.listunspent(scripthash)
|
||||
|
||||
|
||||
def address_event_to_response(event: OnchainAddressEvent) -> AddressResponse:
|
||||
return AddressResponse(
|
||||
balance=Balance(confirmed=event.confirmed, unconfirmed=event.unconfirmed),
|
||||
history=event.history,
|
||||
history_error=event.history_error,
|
||||
)
|
||||
242
lnbits/core/services/lightning_address.py
Normal file
242
lnbits/core/services/lightning_address.py
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import json
|
||||
import re
|
||||
|
||||
from fastapi import Query, Request
|
||||
from lnurl import (
|
||||
CallbackUrl,
|
||||
LightningInvoice,
|
||||
LnurlErrorResponse,
|
||||
LnurlPayActionResponse,
|
||||
LnurlPayMetadata,
|
||||
LnurlPayResponse,
|
||||
MilliSatoshi,
|
||||
)
|
||||
from pydantic import parse_obj_as
|
||||
|
||||
from lnbits.core.crud.wallets import (
|
||||
get_wallet,
|
||||
get_wallet_id_by_ln_address,
|
||||
update_wallet,
|
||||
)
|
||||
from lnbits.core.models.payments import CreateInvoice
|
||||
from lnbits.core.models.wallets import Wallet
|
||||
from lnbits.core.services.payments import (
|
||||
create_invoice,
|
||||
create_wallet_invoice,
|
||||
pay_invoice,
|
||||
)
|
||||
from lnbits.db import Connection
|
||||
from lnbits.exceptions import PaymentError
|
||||
from lnbits.settings import settings
|
||||
|
||||
MAX_SENDABLE_MSAT = 2_100_000_000_000_000_000
|
||||
COMMENT_ALLOWED = 799
|
||||
LIGHTNING_ADDRESS_REGEX = re.compile(r"^[a-z0-9_.-]{1,210}$")
|
||||
|
||||
|
||||
async def set_wallet_lightning_address(
|
||||
*,
|
||||
wallet: Wallet,
|
||||
local_part: str,
|
||||
allow_blacklisted: bool = False,
|
||||
charge: bool = False,
|
||||
conn: Connection | None = None,
|
||||
) -> Wallet:
|
||||
if not settings.ln_address_creation_allowed:
|
||||
raise ValueError("Wallet Lightning Addresses are disabled.")
|
||||
if not wallet.is_lightning_wallet or wallet.deleted:
|
||||
raise ValueError("Lightning Address can only be set for active wallets.")
|
||||
|
||||
local_part = await _validate_local_part(
|
||||
local_part, wallet.id, allow_blacklisted, conn=conn
|
||||
)
|
||||
if wallet.lightning_address == local_part:
|
||||
return wallet
|
||||
|
||||
if charge:
|
||||
await _charge_for_lightning_address(wallet)
|
||||
|
||||
wallet.lightning_address = local_part
|
||||
return await update_wallet(wallet, conn=conn)
|
||||
|
||||
|
||||
async def wallet_lightning_address_response(
|
||||
username: str, request: Request
|
||||
) -> LnurlPayResponse | LnurlErrorResponse:
|
||||
local_part, tag = _split_tagged_local_part(username)
|
||||
wallet_id = await get_wallet_id_by_ln_address(local_part)
|
||||
if not wallet_id:
|
||||
return LnurlErrorResponse(reason="Lightning address not found.")
|
||||
|
||||
tagged_local_part = local_part
|
||||
if tag:
|
||||
tagged_local_part = f"{tagged_local_part}+{tag}"
|
||||
|
||||
callback = request.url_for(
|
||||
"lnurl.api_wallet_lightning_address_callback",
|
||||
username=tagged_local_part,
|
||||
)
|
||||
identifier = _lightning_address_for_request(request, tagged_local_part)
|
||||
return LnurlPayResponse(
|
||||
callback=parse_obj_as(CallbackUrl, str(callback)),
|
||||
minSendable=MilliSatoshi(1000),
|
||||
maxSendable=MilliSatoshi(MAX_SENDABLE_MSAT),
|
||||
metadata=LnurlPayMetadata(json.dumps(_metadata(identifier, tag))),
|
||||
commentAllowed=COMMENT_ALLOWED,
|
||||
)
|
||||
|
||||
|
||||
async def wallet_lightning_address_callback(
|
||||
username: str,
|
||||
request: Request,
|
||||
amount: int = Query(...),
|
||||
) -> LnurlErrorResponse | LnurlPayActionResponse:
|
||||
local_part, tag = _split_tagged_local_part(username)
|
||||
wallet_id = await get_wallet_id_by_ln_address(local_part)
|
||||
if not wallet_id:
|
||||
return LnurlErrorResponse(reason="Lightning address not found.")
|
||||
|
||||
if amount < 1000:
|
||||
return LnurlErrorResponse(reason="Amount is smaller than minimum 1000.")
|
||||
if amount > MAX_SENDABLE_MSAT:
|
||||
return LnurlErrorResponse(
|
||||
reason=f"Amount is greater than maximum {MAX_SENDABLE_MSAT}."
|
||||
)
|
||||
|
||||
comment = request.query_params.get("comment")
|
||||
if len(comment or "") > COMMENT_ALLOWED:
|
||||
return LnurlErrorResponse(
|
||||
reason=(
|
||||
f"Got a comment with {len(comment or '')} characters, "
|
||||
f"but can only accept {COMMENT_ALLOWED}"
|
||||
)
|
||||
)
|
||||
|
||||
tagged_local_part = local_part
|
||||
if tag:
|
||||
tagged_local_part = f"{tagged_local_part}+{tag}"
|
||||
identifier = _lightning_address_for_request(request, tagged_local_part)
|
||||
extra = {
|
||||
"tag": "wallet_lightning_address",
|
||||
"lnaddress": identifier,
|
||||
}
|
||||
if tag:
|
||||
extra["lnaddress_tag"] = tag
|
||||
if comment:
|
||||
extra["comment"] = comment
|
||||
|
||||
metadata = LnurlPayMetadata(json.dumps(_metadata(identifier, tag)))
|
||||
payment = await create_invoice(
|
||||
wallet_id=wallet_id,
|
||||
amount=int(amount / 1000),
|
||||
memo=f"Payment to {identifier}",
|
||||
unhashed_description=metadata.encode(),
|
||||
extra=extra,
|
||||
)
|
||||
invoice = parse_obj_as(LightningInvoice, LightningInvoice(payment.bolt11))
|
||||
return LnurlPayActionResponse(pr=invoice, disposable=False)
|
||||
|
||||
|
||||
def _lightning_address_for_request(request: Request, local_part: str) -> str:
|
||||
return f"{local_part}@{request.url.netloc}"
|
||||
|
||||
|
||||
async def _validate_local_part(
|
||||
local_part: str,
|
||||
wallet_id: str,
|
||||
allow_blacklisted: bool = False,
|
||||
conn: Connection | None = None,
|
||||
) -> str:
|
||||
local_part = local_part.strip().lower()
|
||||
if not local_part:
|
||||
raise ValueError("Lightning Address is required.")
|
||||
if "+" in local_part:
|
||||
raise ValueError("Lightning Address cannot include tags.")
|
||||
if "@" in local_part:
|
||||
raise ValueError("Enter only the Lightning Address name before @.")
|
||||
if not LIGHTNING_ADDRESS_REGEX.match(local_part):
|
||||
raise ValueError(
|
||||
"Lightning Address can only contain lowercase letters, numbers, "
|
||||
"dash, underscore, and dot."
|
||||
)
|
||||
if not allow_blacklisted and _uses_blacklisted_word(local_part):
|
||||
raise ValueError("Lightning Address contains a reserved word.")
|
||||
existing_wallet_id = await get_wallet_id_by_ln_address(local_part, conn=conn)
|
||||
if existing_wallet_id and existing_wallet_id != wallet_id:
|
||||
raise ValueError("Lightning Address is already taken.")
|
||||
|
||||
return local_part
|
||||
|
||||
|
||||
def _split_tagged_local_part(local_part: str) -> tuple[str, str | None]:
|
||||
username, separator, tag = local_part.partition("+")
|
||||
if not separator or not tag:
|
||||
return username.lower(), None
|
||||
return username.lower(), tag
|
||||
|
||||
|
||||
def _metadata(identifier: str, tag: str | None = None) -> list[list[str]]:
|
||||
metadata = [
|
||||
["text/plain", f"Payment to {identifier}"],
|
||||
["text/identifier", identifier],
|
||||
]
|
||||
if tag:
|
||||
metadata.append(["text/tag", tag])
|
||||
return metadata
|
||||
|
||||
|
||||
async def _charge_for_lightning_address(wallet: Wallet) -> None:
|
||||
price_sats = settings.lnbits_wallet_lightning_address_price_sats
|
||||
if not settings.lnbits_charge_wallet_lightning_addresses or price_sats <= 0:
|
||||
return
|
||||
if not settings.lnbits_service_fee_wallet:
|
||||
raise ValueError("Lightning Address fee wallet is not configured.")
|
||||
if settings.lnbits_service_fee_wallet == wallet.source_wallet_id:
|
||||
raise ValueError("Lightning Address fee wallet cannot be the same wallet.")
|
||||
|
||||
fee_wallet = await get_wallet(settings.lnbits_service_fee_wallet)
|
||||
if not fee_wallet:
|
||||
raise ValueError("Lightning Address fee wallet is not configured.")
|
||||
|
||||
invoice = await create_wallet_invoice(
|
||||
settings.lnbits_service_fee_wallet,
|
||||
CreateInvoice(
|
||||
out=False,
|
||||
amount=price_sats,
|
||||
memo="Lightning Address fee",
|
||||
internal=True,
|
||||
extra={
|
||||
"tag": "wallet_lightning_address_fee",
|
||||
"wallet": wallet.source_wallet_id,
|
||||
},
|
||||
),
|
||||
)
|
||||
try:
|
||||
await pay_invoice(
|
||||
wallet_id=wallet.source_wallet_id,
|
||||
payment_request=invoice.bolt11,
|
||||
description="Lightning Address fee",
|
||||
tag="wallet_lightning_address_fee",
|
||||
extra={
|
||||
"tag": "wallet_lightning_address_fee",
|
||||
"fee_wallet": settings.lnbits_service_fee_wallet,
|
||||
},
|
||||
)
|
||||
except PaymentError as exc:
|
||||
raise ValueError(exc.message) from exc
|
||||
|
||||
|
||||
def _blacklist_words() -> set[str]:
|
||||
return {
|
||||
word.strip().lower()
|
||||
for word in settings.lnbits_wallet_lightning_address_blacklist
|
||||
if word.strip()
|
||||
}
|
||||
|
||||
|
||||
def _uses_blacklisted_word(local_part: str) -> bool:
|
||||
words = _blacklist_words()
|
||||
if not words:
|
||||
return False
|
||||
segments = [segment for segment in re.split(r"[._-]+", local_part) if segment]
|
||||
return local_part in words or any(segment in words for segment in segments)
|
||||
|
|
@ -118,6 +118,8 @@ async def create_payment_request(
|
|||
Create a lightning invoice or a fiat payment request.
|
||||
"""
|
||||
if invoice_data.fiat_provider:
|
||||
if invoice_data.is_fiat_subscription():
|
||||
raise ValueError("Cannot create direct fiat subscription payments.")
|
||||
return await create_fiat_invoice(wallet_id, invoice_data)
|
||||
|
||||
return await create_wallet_invoice(wallet_id, invoice_data)
|
||||
|
|
@ -848,26 +850,46 @@ async def _pay_external_invoice(
|
|||
)
|
||||
return payment
|
||||
|
||||
# IMPORTANT PAYMENT RULES!
|
||||
# True -> success
|
||||
# False-> failed
|
||||
# None -> pending (any ambigous payment responses MUST be set as pending)
|
||||
|
||||
# payment failed
|
||||
if (
|
||||
payment_response.checking_id is None
|
||||
or payment_response.ok is False
|
||||
or payment_response.checking_id != checking_id
|
||||
):
|
||||
if payment_response.failed:
|
||||
payment.status = PaymentState.FAILED
|
||||
await update_payment(payment, conn=conn)
|
||||
message = payment_response.error_message or "without an error message."
|
||||
raise PaymentError(f"Payment failed: {message}", status="failed")
|
||||
|
||||
if payment_response.success:
|
||||
# payment successful
|
||||
elif payment_response.success:
|
||||
payment = await update_payment_success_status(
|
||||
payment, payment_response, conn=conn
|
||||
payment,
|
||||
payment_response,
|
||||
conn=conn,
|
||||
new_checking_id=payment_response.checking_id,
|
||||
)
|
||||
|
||||
await _send_payment_notification_in_background(wallet.id, payment, conn=conn)
|
||||
logger.success(f"payment successful {payment_response.checking_id}")
|
||||
logger.success(f"payment successful {payment.checking_id}")
|
||||
|
||||
# payment pending
|
||||
else:
|
||||
if (
|
||||
payment_response.checking_id
|
||||
and payment_response.checking_id != payment.checking_id
|
||||
):
|
||||
payment = await update_payment(
|
||||
payment,
|
||||
new_checking_id=payment_response.checking_id,
|
||||
conn=conn,
|
||||
)
|
||||
logger.warning(
|
||||
f"payment status unknown {payment.checking_id}: "
|
||||
f"{payment_response.error_message or 'no error message'}"
|
||||
)
|
||||
|
||||
payment.checking_id = payment_response.checking_id
|
||||
return payment
|
||||
|
||||
|
||||
|
|
@ -875,13 +897,16 @@ async def update_payment_success_status(
|
|||
payment: Payment,
|
||||
status: PaymentStatus,
|
||||
conn: Connection | None = None,
|
||||
new_checking_id: str | None = 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
|
||||
payment = await update_payment(payment, conn=conn)
|
||||
payment = await update_payment(
|
||||
payment, new_checking_id=new_checking_id, conn=conn
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
|
|
|
|||
170
lnbits/core/views/blockexplorer_api.py
Normal file
170
lnbits/core/views/blockexplorer_api.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import asyncio
|
||||
from http import HTTPStatus
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket
|
||||
from pydantic.types import UUID4
|
||||
|
||||
from lnbits.core.services.blockexplorer import (
|
||||
address_event_to_response,
|
||||
fetch_fee_estimates,
|
||||
fetch_onchain_balance,
|
||||
fetch_recent_blocks,
|
||||
fetch_tip,
|
||||
fetch_transaction,
|
||||
fetch_utxos,
|
||||
)
|
||||
from lnbits.decorators import check_access_token, check_user_exists
|
||||
from lnbits.settings import settings
|
||||
from lnbits.task_manager import (
|
||||
OnchainAddressEvent,
|
||||
OnchainTxEvent,
|
||||
relay_ws_queue,
|
||||
task_manager,
|
||||
)
|
||||
from lnbits.utils.electrum import (
|
||||
UTXO,
|
||||
AddressResponse,
|
||||
BlockHeader,
|
||||
BlockInfo,
|
||||
ElectrumError,
|
||||
FeeResponse,
|
||||
Transaction,
|
||||
scripthash_from_address,
|
||||
)
|
||||
|
||||
blockexplorer_router = APIRouter(
|
||||
tags=["Block Explorer"],
|
||||
prefix="/blockexplorer/api/v1",
|
||||
)
|
||||
|
||||
|
||||
def _check_enabled() -> None:
|
||||
if not settings.lnbits_blockexplorer_enabled:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
detail="Block explorer is not enabled.",
|
||||
)
|
||||
|
||||
|
||||
async def _check_api_access(
|
||||
r: Request,
|
||||
access_token: Annotated[str | None, Depends(check_access_token)],
|
||||
usr: UUID4 | None = None,
|
||||
) -> None:
|
||||
_check_enabled()
|
||||
if not settings.lnbits_blockexplorer_public_api:
|
||||
await check_user_exists(r, access_token, usr)
|
||||
|
||||
|
||||
# ---- REST ----
|
||||
|
||||
|
||||
@blockexplorer_router.get("/blocks", dependencies=[Depends(_check_api_access)])
|
||||
async def api_blocks() -> list[BlockInfo]:
|
||||
try:
|
||||
return await fetch_recent_blocks()
|
||||
except ElectrumError as e:
|
||||
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
|
||||
|
||||
|
||||
@blockexplorer_router.get("/tip", dependencies=[Depends(_check_api_access)])
|
||||
async def api_tip() -> BlockHeader:
|
||||
try:
|
||||
return await fetch_tip()
|
||||
except ElectrumError as e:
|
||||
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
|
||||
|
||||
|
||||
@blockexplorer_router.get("/fees", dependencies=[Depends(_check_api_access)])
|
||||
async def api_fees() -> FeeResponse:
|
||||
try:
|
||||
return await fetch_fee_estimates()
|
||||
except ElectrumError as e:
|
||||
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
|
||||
|
||||
|
||||
@blockexplorer_router.get("/tx/{txid}", dependencies=[Depends(_check_api_access)])
|
||||
async def api_tx(txid: str) -> Transaction:
|
||||
try:
|
||||
return await fetch_transaction(txid)
|
||||
except ElectrumError as e:
|
||||
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
|
||||
|
||||
|
||||
@blockexplorer_router.get(
|
||||
"/address/{address}", dependencies=[Depends(_check_api_access)]
|
||||
)
|
||||
async def api_address(address: str) -> AddressResponse:
|
||||
try:
|
||||
scripthash_from_address(address)
|
||||
except ValueError as e:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=str(e)) from e
|
||||
try:
|
||||
return await fetch_onchain_balance(address)
|
||||
except ElectrumError as e:
|
||||
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
|
||||
|
||||
|
||||
@blockexplorer_router.get("/utxos/{address}", dependencies=[Depends(_check_api_access)])
|
||||
async def api_utxos(address: str) -> list[UTXO]:
|
||||
try:
|
||||
scripthash_from_address(address)
|
||||
except ValueError as e:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=str(e)) from e
|
||||
try:
|
||||
return await fetch_utxos(address)
|
||||
except ElectrumError as e:
|
||||
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
|
||||
|
||||
|
||||
# ---- WebSocket ----
|
||||
|
||||
|
||||
@blockexplorer_router.websocket("/ws/blocks")
|
||||
async def ws_blocks(websocket: WebSocket) -> None:
|
||||
if not settings.lnbits_blockexplorer_enabled:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
await websocket.accept()
|
||||
|
||||
queue: asyncio.Queue[BlockInfo] = asyncio.Queue()
|
||||
task_manager.register_ws_block_queue(queue)
|
||||
try:
|
||||
await relay_ws_queue(websocket, queue)
|
||||
finally:
|
||||
task_manager.unregister_ws_block_queue(queue)
|
||||
|
||||
|
||||
@blockexplorer_router.websocket("/ws/address/{address}")
|
||||
async def ws_address(websocket: WebSocket, address: str) -> None:
|
||||
if not settings.lnbits_blockexplorer_enabled:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
await websocket.accept()
|
||||
|
||||
queue: asyncio.Queue[OnchainAddressEvent] = asyncio.Queue()
|
||||
try:
|
||||
task_manager.register_ws_address_queue(address, queue)
|
||||
except ValueError as e:
|
||||
await websocket.close(code=1008, reason=str(e))
|
||||
return
|
||||
try:
|
||||
await relay_ws_queue(websocket, queue, serialize=address_event_to_response)
|
||||
finally:
|
||||
task_manager.unregister_ws_address_queue(address, queue)
|
||||
|
||||
|
||||
@blockexplorer_router.websocket("/ws/tx/{txid}")
|
||||
async def ws_tx(websocket: WebSocket, txid: str) -> None:
|
||||
if not settings.lnbits_blockexplorer_enabled:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
await websocket.accept()
|
||||
|
||||
queue: asyncio.Queue[OnchainTxEvent] = asyncio.Queue()
|
||||
task_manager.register_ws_tx_queue(txid, queue)
|
||||
try:
|
||||
await relay_ws_queue(websocket, queue, stop_after=lambda e: e.confirmed)
|
||||
finally:
|
||||
task_manager.unregister_ws_tx_queue(txid, queue)
|
||||
|
|
@ -185,6 +185,8 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
|
|||
@generic_router.get("/wallets")
|
||||
@generic_router.get("/account")
|
||||
@generic_router.get("/extensions")
|
||||
@generic_router.get("/blockexplorer")
|
||||
@generic_router.get("/blockexplorer/{resource_type}/{resource}")
|
||||
@generic_router.get("/users", dependencies=admin_ui_checks)
|
||||
@generic_router.get("/audit", dependencies=admin_ui_checks)
|
||||
@generic_router.get("/node", dependencies=admin_ui_checks)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Query,
|
||||
Request,
|
||||
)
|
||||
from lnurl import (
|
||||
LnurlAuthResponse,
|
||||
|
|
@ -18,6 +21,7 @@ from lnurl import execute_login as lnurlauth
|
|||
from lnurl import handle as lnurl_handle
|
||||
from lnurl.models import LnurlResponseModel
|
||||
from loguru import logger
|
||||
from pydantic import ValidationError
|
||||
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.models.lnurl import CreateLnurlPayment, LnurlScan
|
||||
|
|
@ -27,13 +31,50 @@ from lnbits.decorators import (
|
|||
require_base_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import check_callback_url
|
||||
from lnbits.settings import settings
|
||||
from lnbits.settings import RedirectPath, settings
|
||||
|
||||
from ..services import fetch_lnurl_pay_request, pay_invoice
|
||||
from ..services.lightning_address import (
|
||||
wallet_lightning_address_callback,
|
||||
wallet_lightning_address_response,
|
||||
)
|
||||
|
||||
lnurl_router = APIRouter(tags=["LNURL"])
|
||||
|
||||
|
||||
@lnurl_router.get(
|
||||
"/.well-known/lnurlp/{username}",
|
||||
name="lnurl.api_wallet_lightning_address_response",
|
||||
)
|
||||
async def api_wallet_lightning_address_response(
|
||||
username: str, request: Request
|
||||
) -> LnurlPayResponse | LnurlErrorResponse:
|
||||
if settings.lnbits_ln_address_mode in ["extension_first", "extension_only"]:
|
||||
req_headers = request["headers"] if "headers" in request else []
|
||||
redirect = settings.find_extension_redirect(request.url.path, req_headers)
|
||||
if redirect:
|
||||
resp = await _check_extension_well_known(redirect, request)
|
||||
if resp and resp.ok:
|
||||
return resp
|
||||
|
||||
if settings.lnbits_ln_address_mode == "extension_only":
|
||||
return LnurlErrorResponse(
|
||||
reason="Lightning addresses are not supported on this instance."
|
||||
)
|
||||
|
||||
return await wallet_lightning_address_response(username, request)
|
||||
|
||||
|
||||
@lnurl_router.get(
|
||||
"/api/v1/lnurl/wallet/{username}/cb",
|
||||
name="lnurl.api_wallet_lightning_address_callback",
|
||||
)
|
||||
async def api_wallet_lightning_address_callback(
|
||||
username: str, request: Request, amount: int = Query(...)
|
||||
) -> LnurlErrorResponse | Any:
|
||||
return await wallet_lightning_address_callback(username, request, amount)
|
||||
|
||||
|
||||
async def _handle(lnurl: str) -> LnurlResponseModel:
|
||||
try:
|
||||
if "@" in lnurl: # lower case lightning addresses
|
||||
|
|
@ -137,3 +178,32 @@ async def api_payments_pay_lnurl(
|
|||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def _check_extension_well_known(
|
||||
redirect: RedirectPath, request: Request
|
||||
) -> LnurlPayResponse | LnurlErrorResponse | None:
|
||||
target_path = redirect.new_path_from(request.url.path)
|
||||
|
||||
transport = httpx.ASGITransport(app=request.app)
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url=str(request.base_url),
|
||||
) as client:
|
||||
response = await client.get(
|
||||
target_path,
|
||||
headers={"accept": "application/json"},
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
response_data = response.json()
|
||||
try:
|
||||
return LnurlPayResponse.parse_obj(response_data)
|
||||
except ValidationError:
|
||||
return LnurlErrorResponse.parse_obj(response_data)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"Failed to fetch LNURL response Extension redirect {target_path}: {exc}"
|
||||
)
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ from lnbits.core.services import (
|
|||
update_user_extensions,
|
||||
update_wallet_balance,
|
||||
)
|
||||
from lnbits.core.services.lightning_address import set_wallet_lightning_address
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.decorators import check_admin, check_super_user, parse_filters
|
||||
from lnbits.helpers import (
|
||||
|
|
@ -280,6 +281,34 @@ async def api_users_create_user_wallet(
|
|||
return wallet
|
||||
|
||||
|
||||
@users_router.put(
|
||||
"/user/{user_id}/wallet/{wallet}/lightning-address",
|
||||
name="Set wallet Lightning Address",
|
||||
)
|
||||
async def api_users_set_wallet_lightning_address(
|
||||
user_id: str,
|
||||
wallet: str,
|
||||
lightning_address: str = Body(..., embed=True),
|
||||
) -> Wallet:
|
||||
wal = await get_wallet(wallet)
|
||||
if not wal:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="Wallet does not exist.",
|
||||
)
|
||||
if user_id != wal.user:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="Wallet does not belong to user.",
|
||||
)
|
||||
return await set_wallet_lightning_address(
|
||||
wallet=wal,
|
||||
local_part=lightning_address,
|
||||
allow_blacklisted=True,
|
||||
charge=False,
|
||||
)
|
||||
|
||||
|
||||
@users_router.put(
|
||||
"/user/{user_id}/wallet/{wallet}/undelete", name="Reactivate deleted wallet"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from lnbits.core.models.wallets import (
|
|||
WalletSharePermission,
|
||||
WalletType,
|
||||
)
|
||||
from lnbits.core.services.lightning_address import set_wallet_lightning_address
|
||||
from lnbits.core.services.wallets import (
|
||||
create_lightning_shared_wallet,
|
||||
delete_wallet_share,
|
||||
|
|
@ -38,6 +39,7 @@ from lnbits.decorators import (
|
|||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import generate_filter_params_openapi
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..crud import (
|
||||
delete_wallet,
|
||||
|
|
@ -164,6 +166,7 @@ async def api_update_wallet(
|
|||
color: str | None = Body(None),
|
||||
currency: str | None = Body(None),
|
||||
pinned: bool | None = Body(None),
|
||||
lightning_address: str | None = Body(None),
|
||||
key_info: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Wallet:
|
||||
wallet = await get_wallet(key_info.wallet.id)
|
||||
|
|
@ -175,6 +178,20 @@ async def api_update_wallet(
|
|||
wallet.extra.pinned = pinned if pinned is not None else wallet.extra.pinned
|
||||
wallet.currency = currency if currency is not None else wallet.currency
|
||||
|
||||
if lightning_address and lightning_address != wallet.lightning_address:
|
||||
if not settings.lnbits_allow_custom_wallet_lightning_addresses:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="Users cannot specify Lightning Addresses.",
|
||||
)
|
||||
# too much logic here
|
||||
wallet = await set_wallet_lightning_address(
|
||||
wallet=wallet,
|
||||
local_part=lightning_address,
|
||||
charge=True,
|
||||
)
|
||||
return wallet
|
||||
|
||||
await update_wallet(wallet)
|
||||
return wallet
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from fastapi.routing import APIRoute
|
|||
from loguru import logger
|
||||
from packaging import version
|
||||
from pydantic.schema import field_schema
|
||||
from random_username.generate import generate_username # type: ignore[import-untyped]
|
||||
from starlette.templating import Jinja2Templates
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
|
@ -22,6 +23,10 @@ from lnbits.utils.exchange_rates import currencies
|
|||
from .db import FilterModel
|
||||
|
||||
|
||||
def generate_ln_address() -> str:
|
||||
return generate_username(1)[0].lower()
|
||||
|
||||
|
||||
def get_db_vendor_name():
|
||||
db_url = settings.lnbits_database_url
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ class ExtensionsRedirectMiddleware:
|
|||
|
||||
req_headers = scope["headers"] if "headers" in scope else []
|
||||
redirect = settings.find_extension_redirect(scope["path"], req_headers)
|
||||
if redirect:
|
||||
if redirect and not redirect.is_duplicate_well_known():
|
||||
scope["path"] = redirect.new_path_from(scope["path"])
|
||||
|
||||
await self.app(scope, receive, send)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from enum import Enum
|
|||
from os import path
|
||||
from pathlib import Path
|
||||
from time import gmtime, strftime, time
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
from uuid import uuid4
|
||||
|
||||
from loguru import logger
|
||||
|
|
@ -45,6 +45,16 @@ class UsersSettings(LNbitsSettings):
|
|||
lnbits_admin_users: list[str] = Field(default=[])
|
||||
lnbits_allowed_users: list[str] = Field(default=[])
|
||||
lnbits_allow_new_accounts: bool = Field(default=True)
|
||||
|
||||
lnbits_ln_address_mode: Literal[
|
||||
"core_first", "extension_first", "extension_only"
|
||||
] = Field(default="extension_first")
|
||||
lnbits_allow_custom_wallet_lightning_addresses: bool = Field(default=False)
|
||||
lnbits_charge_wallet_lightning_addresses: bool = Field(default=False)
|
||||
lnbits_wallet_lightning_address_price_sats: int = Field(default=1000, ge=0)
|
||||
lnbits_wallet_lightning_address_blacklist: list[str] = Field(
|
||||
default=["admin", "info", "support", "help", "security"]
|
||||
)
|
||||
lnbits_require_user_activation: bool = Field(default=False)
|
||||
|
||||
lnbits_user_activation_by_email: bool = Field(default=False)
|
||||
|
|
@ -58,6 +68,10 @@ class UsersSettings(LNbitsSettings):
|
|||
def new_accounts_allowed(self) -> bool:
|
||||
return self.lnbits_allow_new_accounts and len(self.lnbits_allowed_users) == 0
|
||||
|
||||
@property
|
||||
def ln_address_creation_allowed(self) -> bool:
|
||||
return self.lnbits_ln_address_mode != "extension_only"
|
||||
|
||||
|
||||
class ExtensionsSettings(LNbitsSettings):
|
||||
lnbits_admin_extensions: list[str] = Field(default=[])
|
||||
|
|
@ -126,6 +140,9 @@ class RedirectPath(BaseModel):
|
|||
redirect_to_path: str
|
||||
header_filters: dict = {}
|
||||
|
||||
def is_duplicate_well_known(self) -> bool:
|
||||
return self.from_path in ["/.well-known/lnurlp"]
|
||||
|
||||
def in_conflict(self, other: RedirectPath) -> bool:
|
||||
if self.ext_id == other.ext_id:
|
||||
return False
|
||||
|
|
@ -603,6 +620,10 @@ class BlinkFundingSource(LNbitsSettings):
|
|||
blink_api_endpoint: str | None = Field(default="https://api.blink.sv/graphql")
|
||||
blink_ws_endpoint: str | None = Field(default="wss://ws.blink.sv/graphql")
|
||||
blink_token: str | None = Field(default=None)
|
||||
# If probing fails or is unsupported by the destination (e.g. fedimints),
|
||||
# send the payment anyway. Blink reserves its max fee and reconciles any
|
||||
# excess separately. If disabled, payments that cannot be probed will fail.
|
||||
blink_send_without_probe: bool = Field(default=True)
|
||||
|
||||
|
||||
class AmbossFundingSource(LNbitsSettings):
|
||||
|
|
@ -889,6 +910,16 @@ class NodeUISettings(LNbitsSettings):
|
|||
lnbits_node_ui_transactions: bool = Field(default=False)
|
||||
|
||||
|
||||
class BlockExplorerSettings(LNbitsSettings):
|
||||
lnbits_blockexplorer_enabled: bool = Field(default=False)
|
||||
lnbits_blockexplorer_public_api: bool = Field(default=False)
|
||||
lnbits_blockexplorer_electrum_url: str = Field(
|
||||
default="ssl://electrum.blockstream.info:50002"
|
||||
)
|
||||
# one of: main, test, regtest, signet (see embit.networks.NETWORKS)
|
||||
lnbits_blockexplorer_network: str = Field(default="main")
|
||||
|
||||
|
||||
class AuthMethods(Enum):
|
||||
user_id_only = "user-id-only"
|
||||
username_and_password = "username-password" # noqa: S105
|
||||
|
|
@ -1063,6 +1094,7 @@ class EditableSettings(
|
|||
LightningSettings,
|
||||
WebPushSettings,
|
||||
NodeUISettings,
|
||||
BlockExplorerSettings,
|
||||
AuditSettings,
|
||||
AuthSettings,
|
||||
NostrAuthSettings,
|
||||
|
|
@ -1074,6 +1106,7 @@ class EditableSettings(
|
|||
@validator(
|
||||
"lnbits_admin_users",
|
||||
"lnbits_allowed_users",
|
||||
"lnbits_wallet_lightning_address_blacklist",
|
||||
"lnbits_theme_options",
|
||||
"lnbits_admin_extensions",
|
||||
"lnbits_extensions_manifests",
|
||||
|
|
@ -1343,6 +1376,7 @@ class PublicSettings(BaseModel):
|
|||
webpush_pubkey: str | None = Field(alias="webpushPubkey")
|
||||
show_extensions: bool = Field(alias="showExtensions")
|
||||
show_audit: bool = Field(alias="showAudit")
|
||||
show_block_explorer: bool = Field(alias="showBlockExplorer")
|
||||
show_admin: bool = Field(alias="showAdmin")
|
||||
ad_space: list[list[str]] = Field(alias="adSpace")
|
||||
ad_space_title: str = Field(alias="adSpaceTitle")
|
||||
|
|
@ -1375,6 +1409,18 @@ class PublicSettings(BaseModel):
|
|||
wallet_featured_button_label: str | None = Field(alias="walletFeaturedButtonLabel")
|
||||
wallet_featured_button_url: str | None = Field(alias="walletFeaturedButtonUrl")
|
||||
wallet_featured_button_icon: str | None = Field(alias="walletFeaturedButtonIcon")
|
||||
enable_wallet_lightning_addresses: bool = Field(
|
||||
alias="enableWalletLightningAddresses"
|
||||
)
|
||||
allow_custom_wallet_lightning_addresses: bool = Field(
|
||||
alias="allowCustomWalletLightningAddresses"
|
||||
)
|
||||
charge_wallet_lightning_addresses: bool = Field(
|
||||
alias="chargeWalletLightningAddresses"
|
||||
)
|
||||
wallet_lightning_address_price_sats: int = Field(
|
||||
alias="walletLightningAddressPriceSats"
|
||||
)
|
||||
lnbits_user_activation_by_email: bool = Field(alias="userActivationByEmail")
|
||||
lnbits_user_activation_by_payment: bool = Field(alias="userActivationByPayment")
|
||||
lnbits_user_activation_by_invitation_code: bool = Field(
|
||||
|
|
@ -1411,6 +1457,7 @@ class PublicSettings(BaseModel):
|
|||
webpushPubkey=settings.lnbits_webpush_pubkey,
|
||||
showExtensions=not settings.lnbits_extensions_deactivate_all,
|
||||
showAudit=settings.lnbits_audit_enabled,
|
||||
showBlockExplorer=settings.lnbits_blockexplorer_enabled,
|
||||
showAdmin=settings.lnbits_admin_ui,
|
||||
customImage=settings.lnbits_custom_image,
|
||||
customBadge=settings.lnbits_custom_badge,
|
||||
|
|
@ -1440,6 +1487,16 @@ class PublicSettings(BaseModel):
|
|||
walletFeaturedButtonLabel=settings.lnbits_wallet_featured_button_label,
|
||||
walletFeaturedButtonUrl=settings.lnbits_wallet_featured_button_url,
|
||||
walletFeaturedButtonIcon=settings.lnbits_wallet_featured_button_icon,
|
||||
enableWalletLightningAddresses=settings.ln_address_creation_allowed,
|
||||
allowCustomWalletLightningAddresses=(
|
||||
settings.lnbits_allow_custom_wallet_lightning_addresses
|
||||
),
|
||||
chargeWalletLightningAddresses=(
|
||||
settings.lnbits_charge_wallet_lightning_addresses
|
||||
),
|
||||
walletLightningAddressPriceSats=(
|
||||
settings.lnbits_wallet_lightning_address_price_sats
|
||||
),
|
||||
userActivationByEmail=settings.lnbits_user_activation_by_email,
|
||||
userActivationByPayment=settings.lnbits_user_activation_by_payment,
|
||||
userActivationByInvitationCode=settings.lnbits_user_activation_by_invitation_code,
|
||||
|
|
|
|||
2
lnbits/static/bundle-components.min.js
vendored
2
lnbits/static/bundle-components.min.js
vendored
File diff suppressed because one or more lines are too long
20
lnbits/static/bundle.min.js
vendored
20
lnbits/static/bundle.min.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -828,5 +828,48 @@ window.localisation.br = {
|
|||
payment_labels_updated: 'Rótulos de pagamento atualizados',
|
||||
color: 'Cor',
|
||||
sort: 'Ordenar',
|
||||
sort_by: 'Ordenar por'
|
||||
sort_by: 'Ordenar por',
|
||||
block_explorer: 'Block Explorer',
|
||||
enable_block_explorer: 'Ativar Block Explorer',
|
||||
block_explorer_desc:
|
||||
'Permite aos usuários explorar transações e endereços Bitcoin via Electrum.',
|
||||
blockexplorer_public_api: 'Acesso à API pública',
|
||||
blockexplorer_public_api_desc:
|
||||
'Permitir acesso não autenticado aos endpoints da API do explorador de blocos.',
|
||||
electrum_server_url: 'URL do servidor Electrum',
|
||||
electrum_server_url_hint:
|
||||
'ex. ssl://electrum.blockstream.info:50002 ou tcp://localhost:50001',
|
||||
blockexplorer_search_label: 'Pesquisar por TXID ou endereço',
|
||||
blockexplorer_search_hint:
|
||||
'Hex de 64 caracteres = transação · qualquer outra coisa = endereço Bitcoin',
|
||||
recent_blocks: 'Blocos recentes',
|
||||
chain_tip: 'Ponta da cadeia',
|
||||
block_height: 'Altura do bloco',
|
||||
block_fee: 'taxa de bloco',
|
||||
fee_estimates: 'Estimativas de taxa',
|
||||
confirmed_balance: 'Saldo confirmado',
|
||||
unconfirmed_balance: 'Saldo não confirmado',
|
||||
transaction_history: 'Histórico de transações',
|
||||
coinbase: 'Coinbase',
|
||||
inputs: 'Entradas',
|
||||
outputs: 'Saídas',
|
||||
confirmations: 'Confirmações',
|
||||
confirmed: 'Confirmado',
|
||||
unconfirmed: 'Não confirmado',
|
||||
history_unavailable:
|
||||
'Histórico de transações indisponível (endereço tem transações demais)',
|
||||
address: 'Endereço',
|
||||
block_number: 'Bloco #{height}',
|
||||
block_diff: 'diff {value}',
|
||||
block_hash: 'Hash',
|
||||
previous_block: 'Bloco anterior',
|
||||
merkle_root: 'Raiz de Merkle',
|
||||
version: 'Versão',
|
||||
bits: 'Bits',
|
||||
difficulty: 'Dificuldade',
|
||||
nonce: 'Nonce',
|
||||
txid: 'TXID',
|
||||
vsize: 'Tamanho virtual',
|
||||
weight: 'Peso',
|
||||
n_block_fee: 'taxa {n} blocos'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -422,5 +422,44 @@ window.localisation.cn = {
|
|||
http_request_methods: 'HTTP请求方法',
|
||||
http_response_codes: 'HTTP响应代码',
|
||||
request_details: '请求详情',
|
||||
http_request_details: 'HTTP请求详细信息'
|
||||
http_request_details: 'HTTP请求详细信息',
|
||||
block_explorer: '区块浏览器',
|
||||
enable_block_explorer: '启用区块浏览器',
|
||||
block_explorer_desc: '允许用户通过 Electrum 浏览比特币交易和地址。',
|
||||
blockexplorer_public_api: '公开 API 访问',
|
||||
blockexplorer_public_api_desc: '允许对区块浏览器 API 端点的未认证访问。',
|
||||
electrum_server_url: 'Electrum 服务器 URL',
|
||||
electrum_server_url_hint:
|
||||
'例如 ssl://electrum.blockstream.info:50002 或 tcp://localhost:50001',
|
||||
blockexplorer_search_label: '按 TXID 或地址搜索',
|
||||
blockexplorer_search_hint: '64位十六进制 = 交易 · 其他 = 比特币地址',
|
||||
recent_blocks: '最新区块',
|
||||
chain_tip: '链尖',
|
||||
block_height: '区块高度',
|
||||
block_fee: '区块手续费',
|
||||
fee_estimates: '手续费估算',
|
||||
confirmed_balance: '已确认余额',
|
||||
unconfirmed_balance: '未确认余额',
|
||||
transaction_history: '交易历史',
|
||||
coinbase: 'Coinbase',
|
||||
inputs: '输入',
|
||||
outputs: '输出',
|
||||
confirmations: '确认数',
|
||||
confirmed: '已确认',
|
||||
unconfirmed: '未确认',
|
||||
history_unavailable: '交易历史不可用(地址交易过多)',
|
||||
address: '地址',
|
||||
block_number: '区块 #{height}',
|
||||
block_diff: '难度 {value}',
|
||||
block_hash: '哈希',
|
||||
previous_block: '上一区块',
|
||||
merkle_root: 'Merkle 根',
|
||||
version: '版本',
|
||||
bits: 'Bits',
|
||||
difficulty: '难度',
|
||||
nonce: 'Nonce',
|
||||
txid: 'TXID',
|
||||
vsize: '虚拟大小',
|
||||
weight: '权重',
|
||||
n_block_fee: '{n} 区块手续费'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -443,5 +443,48 @@ window.localisation.cs = {
|
|||
http_request_methods: 'Metody HTTP požadavků',
|
||||
http_response_codes: 'Kódy HTTP odpovědí',
|
||||
request_details: 'Podrobnosti žádosti',
|
||||
http_request_details: 'Podrobnosti HTTP žádosti'
|
||||
http_request_details: 'Podrobnosti HTTP žádosti',
|
||||
block_explorer: 'Průzkumník bloků',
|
||||
enable_block_explorer: 'Povolit průzkumník bloků',
|
||||
block_explorer_desc:
|
||||
'Umožňuje uživatelům procházet bitcoinové transakce a adresy přes Electrum.',
|
||||
blockexplorer_public_api: 'Veřejný přístup k API',
|
||||
blockexplorer_public_api_desc:
|
||||
'Povolit neověřený přístup k API koncovým bodům průzkumníku bloků.',
|
||||
electrum_server_url: 'URL Electrum serveru',
|
||||
electrum_server_url_hint:
|
||||
'např. ssl://electrum.blockstream.info:50002 nebo tcp://localhost:50001',
|
||||
blockexplorer_search_label: 'Hledat podle TXID nebo adresy',
|
||||
blockexplorer_search_hint:
|
||||
'64-znakový hex = transakce · cokoli jiného = bitcoinová adresa',
|
||||
recent_blocks: 'Nedávné bloky',
|
||||
chain_tip: 'Vrchol řetězu',
|
||||
block_height: 'Výška bloku',
|
||||
block_fee: 'poplatek bloku',
|
||||
fee_estimates: 'Odhady poplatků',
|
||||
confirmed_balance: 'Potvrzený zůstatek',
|
||||
unconfirmed_balance: 'Nepotvrzený zůstatek',
|
||||
transaction_history: 'Historie transakcí',
|
||||
coinbase: 'Coinbase',
|
||||
inputs: 'Vstupy',
|
||||
outputs: 'Výstupy',
|
||||
confirmations: 'Potvrzení',
|
||||
confirmed: 'Potvrzeno',
|
||||
unconfirmed: 'Nepotvrzeno',
|
||||
history_unavailable:
|
||||
'Historie transakcí nedostupná (adresa má příliš mnoho transakcí)',
|
||||
address: 'Adresa',
|
||||
block_number: 'Blok #{height}',
|
||||
block_diff: 'obth. {value}',
|
||||
block_hash: 'Hash',
|
||||
previous_block: 'Předchozí blok',
|
||||
merkle_root: 'Merkle kořen',
|
||||
version: 'Verze',
|
||||
bits: 'Bity',
|
||||
difficulty: 'Obtížnost',
|
||||
nonce: 'Nonce',
|
||||
txid: 'TXID',
|
||||
vsize: 'Virtuální velikost',
|
||||
weight: 'Váha',
|
||||
n_block_fee: 'poplatek {n} bloků'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -456,5 +456,48 @@ window.localisation.de = {
|
|||
http_request_methods: 'HTTP-Anfragemethoden',
|
||||
http_response_codes: 'HTTP-Antwortcodes',
|
||||
request_details: 'Anfragedetails',
|
||||
http_request_details: 'HTTP-Anfragedetails'
|
||||
http_request_details: 'HTTP-Anfragedetails',
|
||||
block_explorer: 'Block Explorer',
|
||||
enable_block_explorer: 'Block Explorer aktivieren',
|
||||
block_explorer_desc:
|
||||
'Ermöglicht Nutzern das Durchsuchen von Bitcoin-Transaktionen und -Adressen über Electrum.',
|
||||
blockexplorer_public_api: 'Öffentlicher API-Zugang',
|
||||
blockexplorer_public_api_desc:
|
||||
'Nicht-authentifizierten Zugriff auf die Block-Explorer-API-Endpunkte erlauben.',
|
||||
electrum_server_url: 'Electrum-Server-URL',
|
||||
electrum_server_url_hint:
|
||||
'z.B. ssl://electrum.blockstream.info:50002 oder tcp://localhost:50001',
|
||||
blockexplorer_search_label: 'Nach TXID oder Adresse suchen',
|
||||
blockexplorer_search_hint:
|
||||
'64-Zeichen-Hex = Transaktion · Alles andere = Bitcoin-Adresse',
|
||||
recent_blocks: 'Aktuelle Blöcke',
|
||||
chain_tip: 'Kettenspitze',
|
||||
block_height: 'Blockhöhe',
|
||||
block_fee: 'Blockgebühr',
|
||||
fee_estimates: 'Gebührenschätzungen',
|
||||
confirmed_balance: 'Bestätigtes Guthaben',
|
||||
unconfirmed_balance: 'Unbestätigtes Guthaben',
|
||||
transaction_history: 'Transaktionsverlauf',
|
||||
coinbase: 'Coinbase',
|
||||
inputs: 'Eingaben',
|
||||
outputs: 'Ausgaben',
|
||||
confirmations: 'Bestätigungen',
|
||||
confirmed: 'Bestätigt',
|
||||
unconfirmed: 'Unbestätigt',
|
||||
history_unavailable:
|
||||
'Transaktionsverlauf nicht verfügbar (Adresse hat zu viele Transaktionen)',
|
||||
address: 'Adresse',
|
||||
block_number: 'Block #{height}',
|
||||
block_diff: 'Schw. {value}',
|
||||
block_hash: 'Hash',
|
||||
previous_block: 'Vorheriger Block',
|
||||
merkle_root: 'Merkle-Wurzel',
|
||||
version: 'Version',
|
||||
bits: 'Bits',
|
||||
difficulty: 'Schwierigkeit',
|
||||
nonce: 'Nonce',
|
||||
txid: 'TXID',
|
||||
vsize: 'Virtuelle Größe',
|
||||
weight: 'Gewicht',
|
||||
n_block_fee: '{n}-Block-Gebühr'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,8 @@ window.localisation.en = {
|
|||
reset_defaults_tooltip: 'Delete all settings and reset to defaults.',
|
||||
download_backup: 'Download database backup',
|
||||
name_your_wallet: 'Name your {name} wallet',
|
||||
paste_invoice_label: 'Paste an invoice, payment request or lnurl code *',
|
||||
paste_invoice_label:
|
||||
'Paste an invoice, payment request, Lightning Address or LNURL*',
|
||||
lnbits_description:
|
||||
'Easy to set up and lightweight, LNbits can run on any Lightning Network funding source and even LNbits itself! You can run LNbits for yourself, or easily offer a custodian solution for others. Each wallet has its own API keys and there is no limit to the number of wallets you can make. Being able to partition funds makes LNbits a useful tool for money management and as a development tool. Extensions add extra functionality to LNbits so you can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage people to develop and submit their own.',
|
||||
export_to_phone: 'Export to Phone with QR Code',
|
||||
|
|
@ -64,6 +65,7 @@ window.localisation.en = {
|
|||
wallets: 'Wallets',
|
||||
exclude_wallets: 'Exclude Wallets',
|
||||
add_wallet: 'Add wallet',
|
||||
add_field: 'Add field',
|
||||
reject_wallet: 'Reject wallet',
|
||||
add_new_wallet: 'Add a new wallet',
|
||||
pin_wallet: 'Pin wallet',
|
||||
|
|
@ -913,5 +915,80 @@ window.localisation.en = {
|
|||
payment_labels_updated: 'Payment labels updated',
|
||||
color: 'Color',
|
||||
sort: 'Sort',
|
||||
sort_by: 'Sort by'
|
||||
sort_by: 'Sort by',
|
||||
lightning_address: 'Lightning Address',
|
||||
lightning_addresses: 'Lightning Addresses',
|
||||
lightning_address_price: 'Lightning Address price',
|
||||
enable_lightning_address: 'Enable Lightning Addresses',
|
||||
ln_address_mode: 'Lightning Address Resolution Mode',
|
||||
ln_address_core_first: 'Resolve from LNbits Core first',
|
||||
ln_address_extension_first: 'Resolve from Pay Links extension first',
|
||||
ln_address_extension_only: 'Resolve from Pay Links extension only',
|
||||
ln_address_mode_hint:
|
||||
'Choose how LNbits should resolve Lightning Addresses. Using both LNbits Core and the Pay Links extension will have a small impact on performance.',
|
||||
enable_lightning_address_for_all_wallets:
|
||||
'Enable Lightning Addresses for all LNbits wallets',
|
||||
allow_users_specify_lightning_addresses:
|
||||
'Allow users to specify Lightning Addresses',
|
||||
allow_wallet_owners_set_custom_lightning_addresses:
|
||||
'Allow wallet owners to set custom Lightning Addresses',
|
||||
charge_for_lightning_addresses: 'Charge for Lightning Addresses',
|
||||
charge_users_set_change_lightning_address:
|
||||
'Charge users when they set or change a Lightning Address.',
|
||||
service_fee_wallet_id_must_be_set:
|
||||
'Service Fee Wallet ID must be set in the Service Fees section below for this to work.',
|
||||
lightning_address_blacklist: 'Lightning Address blacklist',
|
||||
lightning_address_blacklist_instructions:
|
||||
'Newline separated reserved words. Users cannot choose a Lightning Address that matches any of these words.',
|
||||
set_lightning_address: 'Set Lightning Address',
|
||||
block_explorer: 'Block Explorer',
|
||||
enable_block_explorer: 'Enable Block Explorer',
|
||||
block_explorer_desc:
|
||||
'Allow users to explore Bitcoin transactions and addresses via Electrum.',
|
||||
blockexplorer_public_api: 'Public API Access',
|
||||
blockexplorer_public_api_desc:
|
||||
'Allow unauthenticated access to the block explorer API endpoints.',
|
||||
electrum_compatible_server: 'Electrum compatible server',
|
||||
electrum_server_url: 'Electrum Server URL',
|
||||
electrum_server_url_hint:
|
||||
'Choose a public Electrum server or enter your own.',
|
||||
electrum_server_url_custom: 'Custom Electrum Server URL',
|
||||
view_public_electrum_servers: 'View public Electrum servers',
|
||||
blockexplorer_network: 'Bitcoin Network',
|
||||
blockexplorer_network_hint:
|
||||
'The network the Electrum server is connected to, used to render addresses correctly.',
|
||||
blockexplorer_search_label: 'Search by TXID or Address',
|
||||
blockexplorer_search_hint:
|
||||
'64-char hex = transaction · anything else = Bitcoin address',
|
||||
recent_blocks: 'Recent Blocks',
|
||||
chain_tip: 'Chain Tip',
|
||||
block_height: 'Block Height',
|
||||
block_fee: 'block fee',
|
||||
fee_estimates: 'Fee Estimates',
|
||||
confirmed_balance: 'Confirmed Balance',
|
||||
unconfirmed_balance: 'Unconfirmed Balance',
|
||||
transaction_history: 'Transaction History',
|
||||
coinbase: 'Coinbase',
|
||||
inputs: 'Inputs',
|
||||
outputs: 'Outputs',
|
||||
confirmations: 'Confirmations',
|
||||
confirmed: 'Confirmed',
|
||||
unconfirmed: 'Unconfirmed',
|
||||
no_transactions: 'No transactions found',
|
||||
history_unavailable:
|
||||
'Transaction history unavailable (address has too many transactions)',
|
||||
address: 'Address',
|
||||
block_number: 'Block #{height}',
|
||||
block_diff: 'diff {value}',
|
||||
block_hash: 'Hash',
|
||||
previous_block: 'Previous Block',
|
||||
merkle_root: 'Merkle Root',
|
||||
version: 'Version',
|
||||
bits: 'Bits',
|
||||
difficulty: 'Difficulty',
|
||||
nonce: 'Nonce',
|
||||
txid: 'TXID',
|
||||
vsize: 'Virtual Size',
|
||||
weight: 'Weight',
|
||||
n_block_fee: '{n}-block fee'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -457,5 +457,48 @@ window.localisation.es = {
|
|||
http_request_methods: 'Métodos de solicitud HTTP',
|
||||
http_response_codes: 'Códigos de Respuesta HTTP',
|
||||
request_details: 'Detalles de la solicitud',
|
||||
http_request_details: 'Detalles de la Solicitud HTTP'
|
||||
http_request_details: 'Detalles de la Solicitud HTTP',
|
||||
block_explorer: 'Block Explorer',
|
||||
enable_block_explorer: 'Activar Block Explorer',
|
||||
block_explorer_desc:
|
||||
'Permite a los usuarios explorar transacciones y direcciones de Bitcoin a través de Electrum.',
|
||||
blockexplorer_public_api: 'Acceso a la API pública',
|
||||
blockexplorer_public_api_desc:
|
||||
'Permitir acceso no autenticado a los endpoints de la API del explorador de bloques.',
|
||||
electrum_server_url: 'URL del servidor Electrum',
|
||||
electrum_server_url_hint:
|
||||
'p.ej. ssl://electrum.blockstream.info:50002 o tcp://localhost:50001',
|
||||
blockexplorer_search_label: 'Buscar por TXID o dirección',
|
||||
blockexplorer_search_hint:
|
||||
'Hex de 64 caracteres = transacción · cualquier otra cosa = dirección Bitcoin',
|
||||
recent_blocks: 'Bloques recientes',
|
||||
chain_tip: 'Punta de cadena',
|
||||
block_height: 'Altura de bloque',
|
||||
block_fee: 'tarifa de bloque',
|
||||
fee_estimates: 'Estimaciones de tarifa',
|
||||
confirmed_balance: 'Saldo confirmado',
|
||||
unconfirmed_balance: 'Saldo no confirmado',
|
||||
transaction_history: 'Historial de transacciones',
|
||||
coinbase: 'Coinbase',
|
||||
inputs: 'Entradas',
|
||||
outputs: 'Salidas',
|
||||
confirmations: 'Confirmaciones',
|
||||
confirmed: 'Confirmado',
|
||||
unconfirmed: 'No confirmado',
|
||||
history_unavailable:
|
||||
'Historial de transacciones no disponible (la dirección tiene demasiadas transacciones)',
|
||||
address: 'Dirección',
|
||||
block_number: 'Bloque #{height}',
|
||||
block_diff: 'dif {value}',
|
||||
block_hash: 'Hash',
|
||||
previous_block: 'Bloque anterior',
|
||||
merkle_root: 'Raíz de Merkle',
|
||||
version: 'Versión',
|
||||
bits: 'Bits',
|
||||
difficulty: 'Dificultad',
|
||||
nonce: 'Nonce',
|
||||
txid: 'TXID',
|
||||
vsize: 'Tamaño virtual',
|
||||
weight: 'Peso',
|
||||
n_block_fee: 'tarifa {n} bloques'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -651,5 +651,48 @@ window.localisation.fi = {
|
|||
'On the PayPal side configure a webhook pointing to your LNbits server.',
|
||||
callback_success_url: 'Callback Success URL',
|
||||
callback_success_url_hint:
|
||||
'The user will be redirected to this URL after the payment is successful'
|
||||
'The user will be redirected to this URL after the payment is successful',
|
||||
block_explorer: 'Lohkoselain',
|
||||
enable_block_explorer: 'Ota lohkoselain käyttöön',
|
||||
block_explorer_desc:
|
||||
'Salli käyttäjien tutkia Bitcoin-transaktioita ja -osoitteita Electrumin kautta.',
|
||||
blockexplorer_public_api: 'Julkinen API-pääsy',
|
||||
blockexplorer_public_api_desc:
|
||||
'Salli todentamaton pääsy lohkoselain API-päätteisiin.',
|
||||
electrum_server_url: 'Electrum-palvelimen URL',
|
||||
electrum_server_url_hint:
|
||||
'esim. ssl://electrum.blockstream.info:50002 tai tcp://localhost:50001',
|
||||
blockexplorer_search_label: 'Hae TXID:llä tai osoitteella',
|
||||
blockexplorer_search_hint:
|
||||
'64 merkin heksa = transaktio · muu = Bitcoin-osoite',
|
||||
recent_blocks: 'Viimeisimmät lohkot',
|
||||
chain_tip: 'Ketjun kärki',
|
||||
block_height: 'Lohkokorkeus',
|
||||
block_fee: 'lohkomaksu',
|
||||
fee_estimates: 'Maksuarviot',
|
||||
confirmed_balance: 'Vahvistettu saldo',
|
||||
unconfirmed_balance: 'Vahvistamaton saldo',
|
||||
transaction_history: 'Tapahtumahistoria',
|
||||
coinbase: 'Coinbase',
|
||||
inputs: 'Syötteet',
|
||||
outputs: 'Tulosteet',
|
||||
confirmations: 'Vahvistukset',
|
||||
confirmed: 'Vahvistettu',
|
||||
unconfirmed: 'Vahvistamaton',
|
||||
history_unavailable:
|
||||
'Tapahtumahistoria ei saatavilla (osoitteella on liikaa tapahtumia)',
|
||||
address: 'Osoite',
|
||||
block_number: 'Lohko #{height}',
|
||||
block_diff: 'vaikeus {value}',
|
||||
block_hash: 'Hash',
|
||||
previous_block: 'Edellinen lohko',
|
||||
merkle_root: 'Merkle-juuri',
|
||||
version: 'Versio',
|
||||
bits: 'Bitit',
|
||||
difficulty: 'Vaikeus',
|
||||
nonce: 'Nonce',
|
||||
txid: 'TXID',
|
||||
vsize: 'Virtuaalikoko',
|
||||
weight: 'Paino',
|
||||
n_block_fee: '{n} lohkon maksu'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -460,5 +460,48 @@ window.localisation.fr = {
|
|||
http_request_methods: 'Méthodes de requête HTTP',
|
||||
http_response_codes: 'Codes de réponse HTTP',
|
||||
request_details: 'Détails de la demande',
|
||||
http_request_details: 'Détails de la requête HTTP'
|
||||
http_request_details: 'Détails de la requête HTTP',
|
||||
block_explorer: 'Block Explorer',
|
||||
enable_block_explorer: 'Activer le Block Explorer',
|
||||
block_explorer_desc:
|
||||
"Permet aux utilisateurs d'explorer les transactions et adresses Bitcoin via Electrum.",
|
||||
blockexplorer_public_api: 'Accès API public',
|
||||
blockexplorer_public_api_desc:
|
||||
"Autoriser l'accès non authentifié aux endpoints de l'API de l'explorateur de blocs.",
|
||||
electrum_server_url: 'URL du serveur Electrum',
|
||||
electrum_server_url_hint:
|
||||
'p.ex. ssl://electrum.blockstream.info:50002 ou tcp://localhost:50001',
|
||||
blockexplorer_search_label: 'Rechercher par TXID ou adresse',
|
||||
blockexplorer_search_hint:
|
||||
'Hex 64 caractères = transaction · autre chose = adresse Bitcoin',
|
||||
recent_blocks: 'Blocs récents',
|
||||
chain_tip: 'Sommet de chaîne',
|
||||
block_height: 'Hauteur de bloc',
|
||||
block_fee: 'frais de bloc',
|
||||
fee_estimates: 'Estimations de frais',
|
||||
confirmed_balance: 'Solde confirmé',
|
||||
unconfirmed_balance: 'Solde non confirmé',
|
||||
transaction_history: 'Historique des transactions',
|
||||
coinbase: 'Coinbase',
|
||||
inputs: 'Entrées',
|
||||
outputs: 'Sorties',
|
||||
confirmations: 'Confirmations',
|
||||
confirmed: 'Confirmé',
|
||||
unconfirmed: 'Non confirmé',
|
||||
history_unavailable:
|
||||
'Historique des transactions indisponible (adresse avec trop de transactions)',
|
||||
address: 'Adresse',
|
||||
block_number: 'Bloc #{height}',
|
||||
block_diff: 'diff {value}',
|
||||
block_hash: 'Hash',
|
||||
previous_block: 'Bloc précédent',
|
||||
merkle_root: 'Racine de Merkle',
|
||||
version: 'Version',
|
||||
bits: 'Bits',
|
||||
difficulty: 'Difficulté',
|
||||
nonce: 'Nonce',
|
||||
txid: 'TXID',
|
||||
vsize: 'Taille virtuelle',
|
||||
weight: 'Poids',
|
||||
n_block_fee: 'frais {n} blocs'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -454,5 +454,48 @@ window.localisation.it = {
|
|||
http_request_methods: 'Metodi di richiesta HTTP',
|
||||
http_response_codes: 'Codici di risposta HTTP',
|
||||
request_details: 'Dettagli della richiesta',
|
||||
http_request_details: 'Dettagli della richiesta HTTP'
|
||||
http_request_details: 'Dettagli della richiesta HTTP',
|
||||
block_explorer: 'Block Explorer',
|
||||
enable_block_explorer: 'Abilita Block Explorer',
|
||||
block_explorer_desc:
|
||||
'Consenti agli utenti di esplorare transazioni e indirizzi Bitcoin tramite Electrum.',
|
||||
blockexplorer_public_api: 'Accesso API pubblico',
|
||||
blockexplorer_public_api_desc:
|
||||
"Consenti accesso non autenticato agli endpoint API dell'esploratore di blocchi.",
|
||||
electrum_server_url: 'URL server Electrum',
|
||||
electrum_server_url_hint:
|
||||
'es. ssl://electrum.blockstream.info:50002 o tcp://localhost:50001',
|
||||
blockexplorer_search_label: 'Cerca per TXID o indirizzo',
|
||||
blockexplorer_search_hint:
|
||||
'Hex 64 caratteri = transazione · altro = indirizzo Bitcoin',
|
||||
recent_blocks: 'Blocchi recenti',
|
||||
chain_tip: 'Punta della catena',
|
||||
block_height: 'Altezza blocco',
|
||||
block_fee: 'commissione blocco',
|
||||
fee_estimates: 'Stime delle commissioni',
|
||||
confirmed_balance: 'Saldo confermato',
|
||||
unconfirmed_balance: 'Saldo non confermato',
|
||||
transaction_history: 'Storico transazioni',
|
||||
coinbase: 'Coinbase',
|
||||
inputs: 'Input',
|
||||
outputs: 'Output',
|
||||
confirmations: 'Conferme',
|
||||
confirmed: 'Confermato',
|
||||
unconfirmed: 'Non confermato',
|
||||
history_unavailable:
|
||||
"Storico transazioni non disponibile (l'indirizzo ha troppe transazioni)",
|
||||
address: 'Indirizzo',
|
||||
block_number: 'Blocco #{height}',
|
||||
block_diff: 'diff {value}',
|
||||
block_hash: 'Hash',
|
||||
previous_block: 'Blocco precedente',
|
||||
merkle_root: 'Radice di Merkle',
|
||||
version: 'Versione',
|
||||
bits: 'Bit',
|
||||
difficulty: 'Difficoltà',
|
||||
nonce: 'Nonce',
|
||||
txid: 'TXID',
|
||||
vsize: 'Dimensione virtuale',
|
||||
weight: 'Peso',
|
||||
n_block_fee: 'commissione {n} blocchi'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -444,5 +444,48 @@ window.localisation.jp = {
|
|||
http_request_methods: 'HTTPリクエストメソッド',
|
||||
http_response_codes: 'HTTPレスポンスコード',
|
||||
request_details: 'リクエストの詳細',
|
||||
http_request_details: 'HTTPリクエストの詳細'
|
||||
http_request_details: 'HTTPリクエストの詳細',
|
||||
block_explorer: 'ブロックエクスプローラー',
|
||||
enable_block_explorer: 'ブロックエクスプローラーを有効化',
|
||||
block_explorer_desc:
|
||||
'Electrumを介してビットコインのトランザクションとアドレスを探索できます。',
|
||||
blockexplorer_public_api: 'パブリックAPIアクセス',
|
||||
blockexplorer_public_api_desc:
|
||||
'ブロックエクスプローラーAPIエンドポイントへの非認証アクセスを許可します。',
|
||||
electrum_server_url: 'ElectrumサーバーURL',
|
||||
electrum_server_url_hint:
|
||||
'例: ssl://electrum.blockstream.info:50002 または tcp://localhost:50001',
|
||||
blockexplorer_search_label: 'TXIDまたはアドレスで検索',
|
||||
blockexplorer_search_hint:
|
||||
'64文字の16進数 = トランザクション · それ以外 = ビットコインアドレス',
|
||||
recent_blocks: '最新ブロック',
|
||||
chain_tip: 'チェーン先端',
|
||||
block_height: 'ブロック高さ',
|
||||
block_fee: 'ブロック手数料',
|
||||
fee_estimates: '手数料見積もり',
|
||||
confirmed_balance: '確認済み残高',
|
||||
unconfirmed_balance: '未確認残高',
|
||||
transaction_history: 'トランザクション履歴',
|
||||
coinbase: 'コインベース',
|
||||
inputs: 'インプット',
|
||||
outputs: 'アウトプット',
|
||||
confirmations: '確認数',
|
||||
confirmed: '確認済み',
|
||||
unconfirmed: '未確認',
|
||||
history_unavailable:
|
||||
'トランザクション履歴が取得できません(アドレスのトランザクションが多すぎます)',
|
||||
address: 'アドレス',
|
||||
block_number: 'ブロック #{height}',
|
||||
block_diff: 'diff {value}',
|
||||
block_hash: 'ハッシュ',
|
||||
previous_block: '前のブロック',
|
||||
merkle_root: 'マークルルート',
|
||||
version: 'バージョン',
|
||||
bits: 'Bits',
|
||||
difficulty: '難易度',
|
||||
nonce: 'Nonce',
|
||||
txid: 'TXID',
|
||||
vsize: '仮想サイズ',
|
||||
weight: '重量',
|
||||
n_block_fee: '{n}ブロック手数料'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -439,5 +439,47 @@ window.localisation.kr = {
|
|||
http_request_methods: 'HTTP 요청 메서드',
|
||||
http_response_codes: 'HTTP 응답 코드',
|
||||
request_details: '요청 세부사항',
|
||||
http_request_details: 'HTTP 요청 세부사항'
|
||||
http_request_details: 'HTTP 요청 세부사항',
|
||||
block_explorer: '블록 탐색기',
|
||||
enable_block_explorer: '블록 탐색기 활성화',
|
||||
block_explorer_desc:
|
||||
'Electrum을 통해 비트코인 거래 및 주소를 탐색할 수 있습니다.',
|
||||
blockexplorer_public_api: '공개 API 접근',
|
||||
blockexplorer_public_api_desc:
|
||||
'블록 탐색기 API 엔드포인트에 대한 비인증 접근을 허용합니다.',
|
||||
electrum_server_url: 'Electrum 서버 URL',
|
||||
electrum_server_url_hint:
|
||||
'예: ssl://electrum.blockstream.info:50002 또는 tcp://localhost:50001',
|
||||
blockexplorer_search_label: 'TXID 또는 주소로 검색',
|
||||
blockexplorer_search_hint: '64자 16진수 = 거래 · 그 외 = 비트코인 주소',
|
||||
recent_blocks: '최근 블록',
|
||||
chain_tip: '체인 끝',
|
||||
block_height: '블록 높이',
|
||||
block_fee: '블록 수수료',
|
||||
fee_estimates: '수수료 추정',
|
||||
confirmed_balance: '확인된 잔액',
|
||||
unconfirmed_balance: '미확인 잔액',
|
||||
transaction_history: '거래 내역',
|
||||
coinbase: 'Coinbase',
|
||||
inputs: '입력',
|
||||
outputs: '출력',
|
||||
confirmations: '확인 수',
|
||||
confirmed: '확인됨',
|
||||
unconfirmed: '미확인',
|
||||
history_unavailable:
|
||||
'거래 내역을 불러올 수 없습니다 (주소의 거래가 너무 많음)',
|
||||
address: '주소',
|
||||
block_number: '블록 #{height}',
|
||||
block_diff: 'diff {value}',
|
||||
block_hash: '해시',
|
||||
previous_block: '이전 블록',
|
||||
merkle_root: '머클 루트',
|
||||
version: '버전',
|
||||
bits: 'Bits',
|
||||
difficulty: '난이도',
|
||||
nonce: 'Nonce',
|
||||
txid: 'TXID',
|
||||
vsize: '가상 크기',
|
||||
weight: '무게',
|
||||
n_block_fee: '{n}블록 수수료'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -454,5 +454,48 @@ window.localisation.nl = {
|
|||
http_request_methods: 'HTTP-aanvraagmethoden',
|
||||
http_response_codes: 'HTTP-responscodes',
|
||||
request_details: 'Aanvraagdetails',
|
||||
http_request_details: 'HTTP-verzoekdetails'
|
||||
http_request_details: 'HTTP-verzoekdetails',
|
||||
block_explorer: 'Block Explorer',
|
||||
enable_block_explorer: 'Block Explorer inschakelen',
|
||||
block_explorer_desc:
|
||||
'Laat gebruikers Bitcoin-transacties en -adressen verkennen via Electrum.',
|
||||
blockexplorer_public_api: 'Publieke API-toegang',
|
||||
blockexplorer_public_api_desc:
|
||||
'Niet-geauthenticeerde toegang tot de block explorer API-eindpunten toestaan.',
|
||||
electrum_server_url: 'Electrum-server-URL',
|
||||
electrum_server_url_hint:
|
||||
'bijv. ssl://electrum.blockstream.info:50002 of tcp://localhost:50001',
|
||||
blockexplorer_search_label: 'Zoeken op TXID of adres',
|
||||
blockexplorer_search_hint:
|
||||
'64-karakter hex = transactie · alles anders = Bitcoin-adres',
|
||||
recent_blocks: 'Recente blokken',
|
||||
chain_tip: 'Kettingtop',
|
||||
block_height: 'Blokhoogte',
|
||||
block_fee: 'blokvergoeding',
|
||||
fee_estimates: 'Vergoedingsschattingen',
|
||||
confirmed_balance: 'Bevestigd saldo',
|
||||
unconfirmed_balance: 'Onbevestigd saldo',
|
||||
transaction_history: 'Transactiegeschiedenis',
|
||||
coinbase: 'Coinbase',
|
||||
inputs: 'Invoer',
|
||||
outputs: 'Uitvoer',
|
||||
confirmations: 'Bevestigingen',
|
||||
confirmed: 'Bevestigd',
|
||||
unconfirmed: 'Onbevestigd',
|
||||
history_unavailable:
|
||||
'Transactiegeschiedenis niet beschikbaar (adres heeft te veel transacties)',
|
||||
address: 'Adres',
|
||||
block_number: 'Blok #{height}',
|
||||
block_diff: 'moeil. {value}',
|
||||
block_hash: 'Hash',
|
||||
previous_block: 'Vorig blok',
|
||||
merkle_root: 'Merkle-wortel',
|
||||
version: 'Versie',
|
||||
bits: 'Bits',
|
||||
difficulty: 'Moeilijkheid',
|
||||
nonce: 'Nonce',
|
||||
txid: 'TXID',
|
||||
vsize: 'Virtuele grootte',
|
||||
weight: 'Gewicht',
|
||||
n_block_fee: '{n}-blok vergoeding'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -445,5 +445,48 @@ window.localisation.pi = {
|
|||
http_request_methods: 'HTTP Request Methods',
|
||||
http_response_codes: 'HTTP Response Codes',
|
||||
request_details: 'Request Details',
|
||||
http_request_details: 'HTTP Request Details'
|
||||
http_request_details: 'HTTP Request Details',
|
||||
block_explorer: 'Treasure Map',
|
||||
enable_block_explorer: 'Hoist the Treasure Map',
|
||||
block_explorer_desc:
|
||||
"Let scallywags spy on Bitcoin doubloons an' addresses via Electrum.",
|
||||
blockexplorer_public_api: 'Open Seas API',
|
||||
blockexplorer_public_api_desc:
|
||||
'Allow any landlubber access to the block explorer API ports.',
|
||||
electrum_server_url: 'Electrum Port URL',
|
||||
electrum_server_url_hint:
|
||||
'e.g. ssl://electrum.blockstream.info:50002 or tcp://localhost:50001',
|
||||
blockexplorer_search_label: 'Search by TXID or Port',
|
||||
blockexplorer_search_hint:
|
||||
'64-char hex = plunder · anything else = Bitcoin port',
|
||||
recent_blocks: 'Recent Plunder',
|
||||
chain_tip: "Tip o' the Anchor Chain",
|
||||
block_height: 'Plunder Height',
|
||||
block_fee: 'plunder fee',
|
||||
fee_estimates: 'Booty Estimates',
|
||||
confirmed_balance: 'Confirmed Booty',
|
||||
unconfirmed_balance: 'Unconfirmed Booty',
|
||||
transaction_history: 'Plunder History',
|
||||
coinbase: 'Coinbase',
|
||||
inputs: 'Inbound Plunder',
|
||||
outputs: 'Outbound Plunder',
|
||||
confirmations: 'Confirmations, arr',
|
||||
confirmed: 'Confirmed, arr',
|
||||
unconfirmed: 'Unconfirmed, arr',
|
||||
history_unavailable:
|
||||
'Plunder history lost at sea (too many transactions, matey!)',
|
||||
address: 'Port',
|
||||
block_number: 'Block #{height}',
|
||||
block_diff: 'diff {value}',
|
||||
block_hash: 'Hash',
|
||||
previous_block: 'Previous Plunder Block',
|
||||
merkle_root: 'Merkle Root',
|
||||
version: 'Version',
|
||||
bits: 'Bits',
|
||||
difficulty: 'Difficulty',
|
||||
nonce: 'Nonce',
|
||||
txid: 'TXID',
|
||||
vsize: 'Virtual Size',
|
||||
weight: 'Weight',
|
||||
n_block_fee: '{n}-block booty'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -448,5 +448,48 @@ window.localisation.pl = {
|
|||
http_request_methods: 'Metody żądań HTTP',
|
||||
http_response_codes: 'Kody Odpowiedzi HTTP',
|
||||
request_details: 'Szczegóły żądania',
|
||||
http_request_details: 'Szczegóły żądania HTTP'
|
||||
http_request_details: 'Szczegóły żądania HTTP',
|
||||
block_explorer: 'Przeglądarka bloków',
|
||||
enable_block_explorer: 'Włącz przeglądarkę bloków',
|
||||
block_explorer_desc:
|
||||
'Umożliwia użytkownikom przeglądanie transakcji i adresów Bitcoin przez Electrum.',
|
||||
blockexplorer_public_api: 'Publiczny dostęp do API',
|
||||
blockexplorer_public_api_desc:
|
||||
'Zezwól na nieuwierzytelniony dostęp do punktów końcowych API przeglądarki bloków.',
|
||||
electrum_server_url: 'URL serwera Electrum',
|
||||
electrum_server_url_hint:
|
||||
'np. ssl://electrum.blockstream.info:50002 lub tcp://localhost:50001',
|
||||
blockexplorer_search_label: 'Szukaj po TXID lub adresie',
|
||||
blockexplorer_search_hint:
|
||||
'64-znakowy hex = transakcja · cokolwiek innego = adres Bitcoin',
|
||||
recent_blocks: 'Ostatnie bloki',
|
||||
chain_tip: 'Wierzchołek łańcucha',
|
||||
block_height: 'Wysokość bloku',
|
||||
block_fee: 'opłata bloku',
|
||||
fee_estimates: 'Szacunki opłat',
|
||||
confirmed_balance: 'Potwierdzony saldo',
|
||||
unconfirmed_balance: 'Niepotwierdzony saldo',
|
||||
transaction_history: 'Historia transakcji',
|
||||
coinbase: 'Coinbase',
|
||||
inputs: 'Wejścia',
|
||||
outputs: 'Wyjścia',
|
||||
confirmations: 'Potwierdzenia',
|
||||
confirmed: 'Potwierdzone',
|
||||
unconfirmed: 'Niepotwierdzone',
|
||||
history_unavailable:
|
||||
'Historia transakcji niedostępna (adres ma zbyt wiele transakcji)',
|
||||
address: 'Adres',
|
||||
block_number: 'Blok #{height}',
|
||||
block_diff: 'trud. {value}',
|
||||
block_hash: 'Hash',
|
||||
previous_block: 'Poprzedni blok',
|
||||
merkle_root: 'Korzeń Merkle',
|
||||
version: 'Wersja',
|
||||
bits: 'Bity',
|
||||
difficulty: 'Trudność',
|
||||
nonce: 'Nonce',
|
||||
txid: 'TXID',
|
||||
vsize: 'Rozmiar wirtualny',
|
||||
weight: 'Waga',
|
||||
n_block_fee: 'opłata {n} bloków'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -450,5 +450,48 @@ window.localisation.pt = {
|
|||
http_request_methods: 'Métodos de Requisição HTTP',
|
||||
http_response_codes: 'Códigos de Resposta HTTP',
|
||||
request_details: 'Detalhes da solicitação',
|
||||
http_request_details: 'Detalhes da Solicitação HTTP'
|
||||
http_request_details: 'Detalhes da Solicitação HTTP',
|
||||
block_explorer: 'Block Explorer',
|
||||
enable_block_explorer: 'Ativar Block Explorer',
|
||||
block_explorer_desc:
|
||||
'Permite aos utilizadores explorar transações e endereços Bitcoin via Electrum.',
|
||||
blockexplorer_public_api: 'Acesso à API pública',
|
||||
blockexplorer_public_api_desc:
|
||||
'Permitir acesso não autenticado aos endpoints da API do explorador de blocos.',
|
||||
electrum_server_url: 'URL do servidor Electrum',
|
||||
electrum_server_url_hint:
|
||||
'ex. ssl://electrum.blockstream.info:50002 ou tcp://localhost:50001',
|
||||
blockexplorer_search_label: 'Pesquisar por TXID ou endereço',
|
||||
blockexplorer_search_hint:
|
||||
'Hex de 64 caracteres = transação · qualquer outra coisa = endereço Bitcoin',
|
||||
recent_blocks: 'Blocos recentes',
|
||||
chain_tip: 'Ponta da cadeia',
|
||||
block_height: 'Altura do bloco',
|
||||
block_fee: 'taxa de bloco',
|
||||
fee_estimates: 'Estimativas de taxa',
|
||||
confirmed_balance: 'Saldo confirmado',
|
||||
unconfirmed_balance: 'Saldo não confirmado',
|
||||
transaction_history: 'Histórico de transações',
|
||||
coinbase: 'Coinbase',
|
||||
inputs: 'Entradas',
|
||||
outputs: 'Saídas',
|
||||
confirmations: 'Confirmações',
|
||||
confirmed: 'Confirmado',
|
||||
unconfirmed: 'Não confirmado',
|
||||
history_unavailable:
|
||||
'Histórico de transações indisponível (endereço tem demasiadas transações)',
|
||||
address: 'Endereço',
|
||||
block_number: 'Bloco #{height}',
|
||||
block_diff: 'diff {value}',
|
||||
block_hash: 'Hash',
|
||||
previous_block: 'Bloco anterior',
|
||||
merkle_root: 'Raiz de Merkle',
|
||||
version: 'Versão',
|
||||
bits: 'Bits',
|
||||
difficulty: 'Dificuldade',
|
||||
nonce: 'Nonce',
|
||||
txid: 'TXID',
|
||||
vsize: 'Tamanho virtual',
|
||||
weight: 'Peso',
|
||||
n_block_fee: 'taxa {n} blocos'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -448,5 +448,48 @@ window.localisation.sk = {
|
|||
http_request_methods: 'Metódy HTTP žiadostí',
|
||||
http_response_codes: 'Kódy odpovedí HTTP',
|
||||
request_details: 'Podrobnosti žiadosti',
|
||||
http_request_details: 'Podrobnosti požiadavky HTTP'
|
||||
http_request_details: 'Podrobnosti požiadavky HTTP',
|
||||
block_explorer: 'Prehliadač blokov',
|
||||
enable_block_explorer: 'Povoliť prehliadač blokov',
|
||||
block_explorer_desc:
|
||||
'Umožňuje používateľom prehliadať bitcoinové transakcie a adresy cez Electrum.',
|
||||
blockexplorer_public_api: 'Verejný prístup k API',
|
||||
blockexplorer_public_api_desc:
|
||||
'Povoliť neoverený prístup k API koncovým bodom prieskumníka blokov.',
|
||||
electrum_server_url: 'URL Electrum servera',
|
||||
electrum_server_url_hint:
|
||||
'napr. ssl://electrum.blockstream.info:50002 alebo tcp://localhost:50001',
|
||||
blockexplorer_search_label: 'Hľadať podľa TXID alebo adresy',
|
||||
blockexplorer_search_hint:
|
||||
'64-znakový hex = transakcia · čokoľvek iné = bitcoinová adresa',
|
||||
recent_blocks: 'Nedávne bloky',
|
||||
chain_tip: 'Vrchol reťaze',
|
||||
block_height: 'Výška bloku',
|
||||
block_fee: 'poplatok bloku',
|
||||
fee_estimates: 'Odhady poplatkov',
|
||||
confirmed_balance: 'Potvrdený zostatok',
|
||||
unconfirmed_balance: 'Nepotvrdený zostatok',
|
||||
transaction_history: 'História transakcií',
|
||||
coinbase: 'Coinbase',
|
||||
inputs: 'Vstupy',
|
||||
outputs: 'Výstupy',
|
||||
confirmations: 'Potvrdenia',
|
||||
confirmed: 'Potvrdené',
|
||||
unconfirmed: 'Nepotvrdené',
|
||||
history_unavailable:
|
||||
'História transakcií nedostupná (adresa má príliš veľa transakcií)',
|
||||
address: 'Adresa',
|
||||
block_number: 'Blok #{height}',
|
||||
block_diff: 'obth. {value}',
|
||||
block_hash: 'Hash',
|
||||
previous_block: 'Predchádzajúci blok',
|
||||
merkle_root: 'Merkle koreň',
|
||||
version: 'Verzia',
|
||||
bits: 'Bity',
|
||||
difficulty: 'Obťažnosť',
|
||||
nonce: 'Nonce',
|
||||
txid: 'TXID',
|
||||
vsize: 'Virtuálna veľkosť',
|
||||
weight: 'Váha',
|
||||
n_block_fee: 'poplatok {n} blokov'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -446,5 +446,48 @@ window.localisation.we = {
|
|||
http_request_methods: 'Dulliau Cais HTTP',
|
||||
http_response_codes: 'Codau Ymateb HTTP',
|
||||
request_details: 'Manylion y Cais',
|
||||
http_request_details: 'Manylion Cais HTTP'
|
||||
http_request_details: 'Manylion Cais HTTP',
|
||||
block_explorer: 'Archwiliwr Bloc',
|
||||
enable_block_explorer: "Galluogi'r Archwiliwr Bloc",
|
||||
block_explorer_desc:
|
||||
'Caniatáu i ddefnyddwyr archwilio trafodion a chyfeiriadau Bitcoin drwy Electrum.',
|
||||
blockexplorer_public_api: 'Mynediad API Cyhoeddus',
|
||||
blockexplorer_public_api_desc:
|
||||
'Caniatáu mynediad heb ddilysu i bwyntiau terfyn API yr archwiliwr bloc.',
|
||||
electrum_server_url: 'URL Gweinydd Electrum',
|
||||
electrum_server_url_hint:
|
||||
'e.e. ssl://electrum.blockstream.info:50002 neu tcp://localhost:50001',
|
||||
blockexplorer_search_label: 'Chwilio yn ôl TXID neu Gyfeiriad',
|
||||
blockexplorer_search_hint:
|
||||
'Hex 64 nod = trafodiad · unrhyw beth arall = cyfeiriad Bitcoin',
|
||||
recent_blocks: 'Blociau Diweddar',
|
||||
chain_tip: 'Blaen y Gadwyn',
|
||||
block_height: 'Uchder Bloc',
|
||||
block_fee: 'ffi bloc',
|
||||
fee_estimates: 'Amcangyfrifon Ffi',
|
||||
confirmed_balance: 'Balans Cadarnhawyd',
|
||||
unconfirmed_balance: 'Balans Heb ei Gadarnhau',
|
||||
transaction_history: 'Hanes Trafodion',
|
||||
coinbase: 'Coinbase',
|
||||
inputs: 'Mewnbynnau',
|
||||
outputs: 'Allbynnau',
|
||||
confirmations: 'Cadarnhadau',
|
||||
confirmed: 'Cadarnhawyd',
|
||||
unconfirmed: 'Heb ei Gadarnhau',
|
||||
history_unavailable:
|
||||
'Hanes trafodion ar goll (mae cyfeiriad â gormod o drafodion)',
|
||||
address: 'Cyfeiriad',
|
||||
block_number: 'Bloc #{height}',
|
||||
block_diff: 'anhawster {value}',
|
||||
block_hash: 'Hash',
|
||||
previous_block: 'Bloc Blaenorol',
|
||||
merkle_root: 'Gwreiddyn Merkle',
|
||||
version: 'Fersiwn',
|
||||
bits: 'Bits',
|
||||
difficulty: 'Anhawster',
|
||||
nonce: 'Nonce',
|
||||
txid: 'TXID',
|
||||
vsize: 'Maint Rhithwir',
|
||||
weight: 'Pwysau',
|
||||
n_block_fee: 'ffi {n} bloc'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -196,5 +196,14 @@ window._lnbitsApi = {
|
|||
return LNbits.api
|
||||
.request('GET', `/admin/api/v1/settings/default?field_name=${fieldName}`)
|
||||
.catch(LNbits.utils.notifyApiError)
|
||||
},
|
||||
getBlockexplorerAddress(address) {
|
||||
return this.request('get', `/blockexplorer/api/v1/address/${address}`)
|
||||
},
|
||||
getBlockexplorerTransaction(txid) {
|
||||
return this.request('get', `/blockexplorer/api/v1/tx/${txid}`)
|
||||
},
|
||||
getBlockexplorerUtxos(address) {
|
||||
return this.request('get', `/blockexplorer/api/v1/utxos/${address}`)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ window.LNbits = {
|
|||
adminkey: data.adminkey,
|
||||
inkey: data.inkey,
|
||||
currency: data.currency,
|
||||
lightningAddress: data.lightning_address,
|
||||
extra: data.extra,
|
||||
canReceivePayments: true,
|
||||
canSendPayments: true
|
||||
|
|
@ -59,6 +60,9 @@ window.LNbits = {
|
|||
newWallet.canSendPayments = perms.includes('send-payments')
|
||||
}
|
||||
newWallet.url = `/wallet?&wal=${data.id}`
|
||||
newWallet.lightningAddressFull = newWallet.lightningAddress
|
||||
? `${newWallet.lightningAddress}@${window.location.host}`
|
||||
: null
|
||||
newWallet.storedPaylinks = data.stored_paylinks.links
|
||||
return newWallet
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
window.app.component('lnbits-admin-blockexplorer', {
|
||||
props: ['form-data'],
|
||||
template: '#lnbits-admin-blockexplorer',
|
||||
data() {
|
||||
return {
|
||||
electrumServers: [
|
||||
'ssl://fulcrum.lnbits.com:50002',
|
||||
'ssl://mainnet.nunchuk.io:52002',
|
||||
'ssl://fulcrum.grey.pw:50002',
|
||||
'ssl://electrum2.bluewallet.io:443',
|
||||
'ssl://electrum.acinq.co:50002',
|
||||
'ssl://electrum.blockstream.info:50002',
|
||||
'ssl://bitcoin.mullvad.net:5010'
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
electrumServerOptions() {
|
||||
return [...this.electrumServers, 'Custom']
|
||||
},
|
||||
electrumServerPreset: {
|
||||
get() {
|
||||
return this.electrumServers.includes(
|
||||
this.formData.lnbits_blockexplorer_electrum_url
|
||||
)
|
||||
? this.formData.lnbits_blockexplorer_electrum_url
|
||||
: 'Custom'
|
||||
},
|
||||
set(value) {
|
||||
if (value === 'Custom') {
|
||||
if (this.electrumServerPreset !== 'Custom') {
|
||||
this.formData.lnbits_blockexplorer_electrum_url = ''
|
||||
}
|
||||
return
|
||||
}
|
||||
this.formData.lnbits_blockexplorer_electrum_url = value
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -154,7 +154,12 @@ window.app.component('lnbits-admin-funding-sources', {
|
|||
{
|
||||
blink_api_endpoint: 'Endpoint',
|
||||
blink_ws_endpoint: 'WebSocket',
|
||||
blink_token: 'Key'
|
||||
blink_token: 'Key',
|
||||
blink_send_without_probe: {
|
||||
advanced: true,
|
||||
label: 'Send payment if fee probe fails',
|
||||
hint: 'If enabled (default), payments to destinations that cannot be probed (e.g. fedimints) are still sent. If disabled, such payments fail.'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
|
|
|
|||
|
|
@ -1,4 +1,18 @@
|
|||
window.app.component('lnbits-admin-server', {
|
||||
props: ['form-data'],
|
||||
template: '#lnbits-admin-server'
|
||||
template: '#lnbits-admin-server',
|
||||
computed: {
|
||||
lightningAddressBlacklistText: {
|
||||
get() {
|
||||
const value = this.formData.lnbits_wallet_lightning_address_blacklist
|
||||
return Array.isArray(value) ? value.join('\n') : value || ''
|
||||
},
|
||||
set(value) {
|
||||
this.formData.lnbits_wallet_lightning_address_blacklist = value
|
||||
.split(/[\n,]/)
|
||||
.map(word => word.trim().toLowerCase())
|
||||
.filter(word => word.length)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@ window.app.component('lnbits-admin-site-customisation', {
|
|||
'confettiBothSides',
|
||||
'confettiFireworks',
|
||||
'confettiStars',
|
||||
'confettiTop'
|
||||
'confettiTop',
|
||||
'lightningStrike'
|
||||
],
|
||||
globalBorderOptions: [
|
||||
'retro-border',
|
||||
|
|
|
|||
|
|
@ -1,12 +1,46 @@
|
|||
window.app.component('lnbits-wallet-extra', {
|
||||
template: '#lnbits-wallet-extra',
|
||||
props: ['chartConfig'],
|
||||
data() {
|
||||
return {
|
||||
lightningAddressInput: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
exportUrl() {
|
||||
return `${window.location.origin}/wallet?usr=${this.g.user.id}&wal=${this.g.wallet.id}`
|
||||
},
|
||||
canEditLightningAddress() {
|
||||
return (
|
||||
this.g.settings.enableWalletLightningAddresses &&
|
||||
this.g.settings.allowCustomWalletLightningAddresses &&
|
||||
this.g.wallet.walletType === 'lightning'
|
||||
)
|
||||
},
|
||||
lightningAddressSuffix() {
|
||||
return `@${window.location.host}`
|
||||
},
|
||||
lightningAddressChanged() {
|
||||
return (
|
||||
this.lightningAddressInput !== (this.g.wallet.lightningAddress || '')
|
||||
)
|
||||
},
|
||||
lightningAddressFeeHint() {
|
||||
if (!this.g.settings.chargeWalletLightningAddresses) return ''
|
||||
return `Fee: ${this.g.settings.walletLightningAddressPriceSats} sats`
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'g.wallet.id': 'resetLightningAddressInput',
|
||||
'g.wallet.lightningAddress': 'resetLightningAddressInput'
|
||||
},
|
||||
methods: {
|
||||
resetLightningAddressInput() {
|
||||
this.lightningAddressInput = this.g.wallet.lightningAddress || ''
|
||||
},
|
||||
saveLightningAddress() {
|
||||
this.updateWallet({lightning_address: this.lightningAddressInput})
|
||||
},
|
||||
handleSendLnurl(lnurl) {
|
||||
this.$emit('send-lnurl', lnurl)
|
||||
},
|
||||
|
|
@ -80,6 +114,7 @@ window.app.component('lnbits-wallet-extra', {
|
|||
}
|
||||
},
|
||||
created() {
|
||||
this.resetLightningAddressInput()
|
||||
if (this.g.wallet.currency !== '' && this.g.isSatsDenomination) {
|
||||
this.g.fiatTracking = true
|
||||
this.updateFiatBalance()
|
||||
|
|
|
|||
|
|
@ -154,6 +154,99 @@ function confettiStars() {
|
|||
setTimeout(shoot, 100)
|
||||
setTimeout(shoot, 200)
|
||||
}
|
||||
function lightningStrike() {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
|
||||
canvas.style.position = 'fixed'
|
||||
canvas.style.inset = '0'
|
||||
canvas.style.pointerEvents = 'none'
|
||||
canvas.style.zIndex = 999999
|
||||
canvas.width = Math.floor(window.innerWidth * dpr)
|
||||
canvas.height = Math.floor(window.innerHeight * dpr)
|
||||
ctx.scale(dpr, dpr)
|
||||
document.body.appendChild(canvas)
|
||||
|
||||
const startX = Math.random() * window.innerWidth
|
||||
const endY = window.innerHeight * (0.45 + Math.random() * 0.35)
|
||||
const segments = 18 + Math.floor(Math.random() * 10)
|
||||
const points = [{x: startX, y: -20}]
|
||||
|
||||
for (let i = 1; i <= segments; i++) {
|
||||
const progress = i / segments
|
||||
const previous = points[i - 1]
|
||||
points.push({
|
||||
x: previous.x + (Math.random() - 0.5) * (34 + progress * 42),
|
||||
y: progress * endY
|
||||
})
|
||||
}
|
||||
|
||||
const branches = []
|
||||
for (
|
||||
let i = 4;
|
||||
i < points.length - 3;
|
||||
i += 3 + Math.floor(Math.random() * 3)
|
||||
) {
|
||||
const base = points[i]
|
||||
const branch = [{...base}]
|
||||
const direction = Math.random() > 0.5 ? 1 : -1
|
||||
const length = 3 + Math.floor(Math.random() * 4)
|
||||
|
||||
for (let j = 1; j <= length; j++) {
|
||||
branch.push({
|
||||
x: base.x + direction * j * (18 + Math.random() * 22),
|
||||
y: base.y + j * (14 + Math.random() * 18)
|
||||
})
|
||||
}
|
||||
branches.push(branch)
|
||||
}
|
||||
|
||||
let frame = 0
|
||||
const maxFrames = 48
|
||||
|
||||
function drawBolt(path, width, alpha) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(path[0].x, path[0].y)
|
||||
path.slice(1).forEach(point => ctx.lineTo(point.x, point.y))
|
||||
ctx.strokeStyle = `rgba(170, 220, 255, ${alpha})`
|
||||
ctx.lineWidth = width
|
||||
ctx.lineJoin = 'round'
|
||||
ctx.lineCap = 'round'
|
||||
ctx.shadowBlur = 18
|
||||
ctx.shadowColor = '#7dd3fc'
|
||||
ctx.stroke()
|
||||
|
||||
ctx.strokeStyle = `rgba(255, 255, 255, ${Math.min(1, alpha + 0.2)})`
|
||||
ctx.lineWidth = Math.max(1, width * 0.35)
|
||||
ctx.shadowBlur = 4
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
function animate() {
|
||||
const alpha = 1 - frame / maxFrames
|
||||
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight)
|
||||
|
||||
if (frame < 3) {
|
||||
ctx.fillStyle = `rgba(255, 255, 255, ${0.22 - frame * 0.06})`
|
||||
ctx.fillRect(0, 0, window.innerWidth, window.innerHeight)
|
||||
}
|
||||
|
||||
drawBolt(points, 5 * alpha + 1, alpha)
|
||||
branches.forEach(branch =>
|
||||
drawBolt(branch, 2.5 * alpha + 0.5, alpha * 0.75)
|
||||
)
|
||||
|
||||
frame += 1
|
||||
if (frame <= maxFrames) {
|
||||
requestAnimationFrame(animate)
|
||||
} else {
|
||||
canvas.remove()
|
||||
}
|
||||
}
|
||||
|
||||
animate()
|
||||
}
|
||||
!(function (t, e) {
|
||||
;(!(function t(e, n, a, i) {
|
||||
var o = !!(
|
||||
|
|
|
|||
|
|
@ -66,6 +66,12 @@ const routes = [
|
|||
name: 'NodePublic',
|
||||
component: PageNodePublic
|
||||
},
|
||||
{
|
||||
path: '/blockexplorer/:type(tx|address|block)?/:id?',
|
||||
name: 'BlockExplorer',
|
||||
component: PageBlockExplorer,
|
||||
meta: {stableKey: true}
|
||||
},
|
||||
{
|
||||
path: '/payments',
|
||||
name: 'Payments',
|
||||
|
|
|
|||
|
|
@ -51,7 +51,8 @@ window.PageAccount = {
|
|||
'confettiBothSides',
|
||||
'confettiFireworks',
|
||||
'confettiStars',
|
||||
'confettiTop'
|
||||
'confettiTop',
|
||||
'lightningStrike'
|
||||
],
|
||||
borderOptions: [
|
||||
'retro-border',
|
||||
|
|
|
|||
233
lnbits/static/js/pages/blockexplorer.js
Normal file
233
lnbits/static/js/pages/blockexplorer.js
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
window.PageBlockExplorer = {
|
||||
template: '#page-blockexplorer',
|
||||
data() {
|
||||
return {
|
||||
query: '',
|
||||
loading: false,
|
||||
tip: null,
|
||||
fees: null,
|
||||
blocks: [],
|
||||
selectedBlock: null,
|
||||
blockDialog: false,
|
||||
txResult: null,
|
||||
txStatus: null,
|
||||
addressResult: null,
|
||||
currentAddress: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
feeList() {
|
||||
if (!this.fees || !this.fees.estimates) return []
|
||||
return Object.entries(this.fees.estimates).map(([blocks, rate]) => ({
|
||||
label: this.$t('n_block_fee', {n: blocks}),
|
||||
rate: (rate * 100000).toFixed(1) + ' sat/vB'
|
||||
}))
|
||||
},
|
||||
formattedBlocks() {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
return this.blocks.map(b => ({
|
||||
...b,
|
||||
shortHash: b.hash.slice(0, 8) + '...' + b.hash.slice(-4),
|
||||
timeAgo: this._timeAgo(now - b.timestamp),
|
||||
utcTime: new Date(b.timestamp * 1000).toUTCString(),
|
||||
difficulty: this._difficulty(b.bits)
|
||||
}))
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
await Promise.all([this.loadTip(), this.loadFees(), this.loadBlocks()])
|
||||
this._blockWsActive = true
|
||||
this._connectBlocksWs()
|
||||
this._loadFromRoute()
|
||||
},
|
||||
beforeUnmount() {
|
||||
this._blockWsActive = false
|
||||
if (this._blockWs) this._blockWs.close()
|
||||
if (this._searchWs) this._searchWs.close()
|
||||
},
|
||||
watch: {
|
||||
$route(to) {
|
||||
this._loadFromRoute(to)
|
||||
},
|
||||
blockDialog(val) {
|
||||
if (!val && this.$route.params.type === 'block') {
|
||||
this.$router.push('/blockexplorer')
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
_loadFromRoute(route) {
|
||||
route = route || this.$route
|
||||
const {type, id} = route.params
|
||||
if (type === 'tx') {
|
||||
this.query = id
|
||||
this._fetchTx(id)
|
||||
} else if (type === 'address') {
|
||||
this.query = id
|
||||
this._fetchAddress(id)
|
||||
} else if (type === 'block') {
|
||||
this._openBlockByHeight(id)
|
||||
} else {
|
||||
this._resetResults()
|
||||
this.blockDialog = false
|
||||
}
|
||||
},
|
||||
_openBlockByHeight(height) {
|
||||
const h = parseInt(height, 10)
|
||||
const block =
|
||||
this.formattedBlocks.find(b => b.height === h) ||
|
||||
this.blocks.find(b => b.height === h)
|
||||
if (block) {
|
||||
this.selectedBlock = block
|
||||
this.blockDialog = true
|
||||
} else {
|
||||
this.selectedBlock = null
|
||||
this.blockDialog = false
|
||||
}
|
||||
},
|
||||
_resetResults() {
|
||||
this.txResult = null
|
||||
this.txStatus = null
|
||||
this.addressResult = null
|
||||
if (this._searchWs) {
|
||||
this._searchWs.close()
|
||||
this._searchWs = null
|
||||
}
|
||||
},
|
||||
_wsUrl(path) {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${proto}//${window.location.host}/blockexplorer/api/v1${path}`
|
||||
},
|
||||
_connectBlocksWs() {
|
||||
const ws = new WebSocket(this._wsUrl('/ws/blocks'))
|
||||
ws.onmessage = e => {
|
||||
const block = JSON.parse(e.data)
|
||||
const rest = this.blocks.filter(b => b.height !== block.height)
|
||||
this.blocks = [block, ...rest].slice(0, 5)
|
||||
}
|
||||
ws.onerror = () => ws.close()
|
||||
ws.onclose = () => {
|
||||
if (this._blockWsActive) setTimeout(() => this._connectBlocksWs(), 5000)
|
||||
}
|
||||
this._blockWs = ws
|
||||
},
|
||||
_connectSearchWs(path, onMessage) {
|
||||
if (this._searchWs) {
|
||||
this._searchWs.close()
|
||||
this._searchWs = null
|
||||
}
|
||||
const ws = new WebSocket(this._wsUrl(path))
|
||||
ws.onmessage = e => {
|
||||
try {
|
||||
onMessage(JSON.parse(e.data))
|
||||
} catch (_) {}
|
||||
}
|
||||
ws.onerror = () => ws.close()
|
||||
this._searchWs = ws
|
||||
},
|
||||
_timeAgo(seconds) {
|
||||
if (seconds < 60) return seconds + 's ago'
|
||||
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago'
|
||||
return Math.floor(seconds / 3600) + 'h ago'
|
||||
},
|
||||
_difficulty(bitsHex) {
|
||||
const exp = parseInt(bitsHex.slice(0, 2), 16)
|
||||
const mantissa = parseInt(bitsHex.slice(2), 16)
|
||||
const diff1 = 0xffff * Math.pow(2, 208)
|
||||
const target = mantissa * Math.pow(2, 8 * (exp - 3))
|
||||
const d = diff1 / target
|
||||
if (d >= 1e12) return (d / 1e12).toFixed(2) + 'T'
|
||||
if (d >= 1e9) return (d / 1e9).toFixed(2) + 'G'
|
||||
if (d >= 1e6) return (d / 1e6).toFixed(2) + 'M'
|
||||
return d.toFixed(0)
|
||||
},
|
||||
openBlock(b) {
|
||||
this.$router.push(`/blockexplorer/block/${b.height}`)
|
||||
},
|
||||
async loadBlocks() {
|
||||
try {
|
||||
const r = await LNbits.api.request(
|
||||
'GET',
|
||||
'/blockexplorer/api/v1/blocks'
|
||||
)
|
||||
this.blocks = r.data
|
||||
} catch (_) {}
|
||||
},
|
||||
async loadTip() {
|
||||
try {
|
||||
const r = await LNbits.api.request('GET', '/blockexplorer/api/v1/tip')
|
||||
this.tip = r.data
|
||||
} catch (e) {
|
||||
LNbits.utils.notifyApiError(e)
|
||||
}
|
||||
},
|
||||
async loadFees() {
|
||||
try {
|
||||
const r = await LNbits.api.request('GET', '/blockexplorer/api/v1/fees')
|
||||
this.fees = r.data
|
||||
} catch (_) {}
|
||||
},
|
||||
clearResult() {
|
||||
this.query = ''
|
||||
if (this.$route.path !== '/blockexplorer') {
|
||||
this.$router.push('/blockexplorer')
|
||||
} else {
|
||||
this._resetResults()
|
||||
}
|
||||
},
|
||||
search() {
|
||||
const q = this.query.trim()
|
||||
if (!q) return
|
||||
if (/^[0-9a-fA-F]{64}$/.test(q)) {
|
||||
this.loadTx(q)
|
||||
} else {
|
||||
this.loadAddress(q)
|
||||
}
|
||||
},
|
||||
loadTx(txid) {
|
||||
this.$router.push(`/blockexplorer/tx/${txid}`)
|
||||
},
|
||||
loadAddress(address) {
|
||||
this.$router.push(`/blockexplorer/address/${address}`)
|
||||
},
|
||||
async _fetchTx(txid) {
|
||||
this.loading = true
|
||||
try {
|
||||
const r = await LNbits.api.request(
|
||||
'GET',
|
||||
'/blockexplorer/api/v1/tx/' + txid
|
||||
)
|
||||
this.txResult = r.data
|
||||
this.txStatus = null
|
||||
this.addressResult = null
|
||||
this._connectSearchWs(`/ws/tx/${txid}`, data => {
|
||||
if (!data.error) this.txStatus = data
|
||||
})
|
||||
} catch (e) {
|
||||
LNbits.utils.notifyApiError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async _fetchAddress(address) {
|
||||
this.loading = true
|
||||
try {
|
||||
const r = await LNbits.api.request(
|
||||
'GET',
|
||||
'/blockexplorer/api/v1/address/' + address
|
||||
)
|
||||
this.addressResult = r.data
|
||||
this.txResult = null
|
||||
this.txStatus = null
|
||||
this.currentAddress = address
|
||||
this._connectSearchWs(`/ws/address/${address}`, data => {
|
||||
if (!data.error) this.addressResult = data
|
||||
})
|
||||
} catch (e) {
|
||||
LNbits.utils.notifyApiError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -29,6 +29,11 @@ window.PageUsers = {
|
|||
data: {},
|
||||
show: false
|
||||
},
|
||||
lightningAddressDialog: {
|
||||
wallet: null,
|
||||
lightningAddress: '',
|
||||
show: false
|
||||
},
|
||||
walletTable: {
|
||||
columns: [
|
||||
{
|
||||
|
|
@ -173,6 +178,11 @@ window.PageUsers = {
|
|||
created() {
|
||||
this.fetchUsers()
|
||||
},
|
||||
computed: {
|
||||
lightningAddressSuffix() {
|
||||
return `@${window.location.host}`
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
formatSat(value) {
|
||||
|
|
@ -348,6 +358,35 @@ window.PageUsers = {
|
|||
const url = `${window.location.origin}/wallet?usr=${this.activeWallet.userId}&wal=${walletId}`
|
||||
this.utils.copyText(url)
|
||||
},
|
||||
showLightningAddressDialog(wallet) {
|
||||
this.lightningAddressDialog.wallet = wallet
|
||||
this.lightningAddressDialog.lightningAddress =
|
||||
wallet.lightning_address || ''
|
||||
this.lightningAddressDialog.show = true
|
||||
},
|
||||
saveLightningAddress() {
|
||||
const wallet = this.lightningAddressDialog.wallet
|
||||
if (!wallet) return
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
`/users/api/v1/user/${wallet.user}/wallet/${wallet.id}/lightning-address`,
|
||||
null,
|
||||
{
|
||||
lightning_address: this.lightningAddressDialog.lightningAddress
|
||||
}
|
||||
)
|
||||
.then(response => {
|
||||
Object.assign(wallet, response.data)
|
||||
this.lightningAddressDialog.show = false
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: this.$t('lightning_address_updated'),
|
||||
icon: null
|
||||
})
|
||||
})
|
||||
.catch(LNbits.utils.notifyApiError)
|
||||
},
|
||||
fetchUsers(props) {
|
||||
this.relaxFilterForFields(['username', 'email'])
|
||||
const params = LNbits.utils.prepareFilterQuery(this.usersTable, props)
|
||||
|
|
|
|||
|
|
@ -631,14 +631,19 @@ window.PageWallet = {
|
|||
LNbits.api
|
||||
.request('PATCH', '/api/v1/wallet', this.g.wallet.adminkey, data)
|
||||
.then(response => {
|
||||
this.g.wallet = {...this.g.wallet, ...response.data}
|
||||
const walletData = {...response.data}
|
||||
if (walletData.lightning_address) {
|
||||
walletData.lightningAddress = walletData.lightning_address
|
||||
walletData.lightningAddressFull = `${walletData.lightning_address}@${window.location.host}`
|
||||
}
|
||||
this.g.wallet = {...this.g.wallet, ...walletData}
|
||||
const walletIndex = this.g.user.wallets.findIndex(
|
||||
wallet => wallet.id === response.data.id
|
||||
)
|
||||
if (walletIndex !== -1) {
|
||||
this.g.user.wallets[walletIndex] = {
|
||||
...this.g.user.wallets[walletIndex],
|
||||
...response.data
|
||||
...walletData
|
||||
}
|
||||
}
|
||||
Quasar.Notify.create({
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@
|
|||
"js/components/admin/lnbits-admin-site-customisation.js",
|
||||
"js/components/admin/lnbits-admin-assets-config.js",
|
||||
"js/components/admin/lnbits-admin-audit.js",
|
||||
"js/components/admin/lnbits-admin-blockexplorer.js",
|
||||
"js/pages/blockexplorer.js",
|
||||
"js/components/lnbits-wallet-charts.js",
|
||||
"js/components/lnbits-wallet-api-docs.js",
|
||||
"js/components/lnbits-wallet-icon.js",
|
||||
|
|
|
|||
|
|
@ -3,12 +3,23 @@ import traceback
|
|||
import uuid
|
||||
from collections.abc import Callable, Coroutine
|
||||
from datetime import datetime, timezone
|
||||
from typing import TypeVar
|
||||
|
||||
from fastapi import WebSocket
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.electrum import (
|
||||
AddressTracker,
|
||||
BlockInfo,
|
||||
BlockTracker,
|
||||
OnchainAddressEvent,
|
||||
OnchainTxEvent,
|
||||
TransactionTracker,
|
||||
scripthash_from_address,
|
||||
)
|
||||
|
||||
|
||||
class PublicTask(BaseModel):
|
||||
|
|
@ -26,26 +37,41 @@ class Task:
|
|||
created_at: datetime
|
||||
task: asyncio.Task
|
||||
invoice_queue: asyncio.Queue[Payment] | None = None
|
||||
onchain_address_queue: asyncio.Queue[OnchainAddressEvent] | None = None
|
||||
onchain_tx_queue: asyncio.Queue[OnchainTxEvent] | None = None
|
||||
block_queue: asyncio.Queue[BlockInfo] | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coro: Coroutine,
|
||||
name: str | None = None,
|
||||
invoice_queue: asyncio.Queue | None = None,
|
||||
onchain_address_queue: asyncio.Queue | None = None,
|
||||
onchain_tx_queue: asyncio.Queue | None = None,
|
||||
block_queue: asyncio.Queue | None = None,
|
||||
) -> None:
|
||||
self.coro = coro
|
||||
self.name = name or f"task_{uuid.uuid4()}"
|
||||
self.created_at = datetime.now(timezone.utc)
|
||||
self.task = asyncio.create_task(self.coro, name=self.name)
|
||||
self.invoice_queue = invoice_queue
|
||||
self.onchain_address_queue = onchain_address_queue
|
||||
self.onchain_tx_queue = onchain_tx_queue
|
||||
self.block_queue = block_queue
|
||||
|
||||
|
||||
class TaskManager:
|
||||
"""Singleton class to manage background tasks."""
|
||||
|
||||
ONCHAIN_ADDRESS_LISTENER_SUFFIX = "_onchain_address_listener"
|
||||
|
||||
tasks: list[Task] = []
|
||||
invoice_queue: asyncio.Queue[Payment] = asyncio.Queue()
|
||||
internal_invoice_queue: asyncio.Queue[Payment] = asyncio.Queue()
|
||||
_address_tracker: "AddressTracker | None" = None
|
||||
_block_tracker: "BlockTracker | None" = None
|
||||
_tx_trackers: dict[str, "TransactionTracker"] = {}
|
||||
_tracked_addresses_by_listener: dict[str, set[str]] = {}
|
||||
|
||||
def init(self) -> None:
|
||||
self.create_permanent_task(
|
||||
|
|
@ -84,13 +110,23 @@ class TaskManager:
|
|||
coro: Coroutine,
|
||||
name: str | None = None,
|
||||
invoice_queue: asyncio.Queue | None = None,
|
||||
onchain_address_queue: asyncio.Queue | None = None,
|
||||
onchain_tx_queue: asyncio.Queue | None = None,
|
||||
block_queue: asyncio.Queue | None = None,
|
||||
) -> Task:
|
||||
"""Create a task. If a task with the same name exists, it will be cancelled."""
|
||||
if name:
|
||||
task = self.get_task(name)
|
||||
if task:
|
||||
self.cancel_task(task)
|
||||
task = Task(coro=coro, name=name, invoice_queue=invoice_queue)
|
||||
task = Task(
|
||||
coro=coro,
|
||||
name=name,
|
||||
invoice_queue=invoice_queue,
|
||||
onchain_address_queue=onchain_address_queue,
|
||||
onchain_tx_queue=onchain_tx_queue,
|
||||
block_queue=block_queue,
|
||||
)
|
||||
self.tasks.append(task)
|
||||
return task
|
||||
|
||||
|
|
@ -98,6 +134,9 @@ class TaskManager:
|
|||
self,
|
||||
func: Callable[[], Coroutine],
|
||||
invoice_queue: asyncio.Queue | None = None,
|
||||
onchain_address_queue: asyncio.Queue | None = None,
|
||||
onchain_tx_queue: asyncio.Queue | None = None,
|
||||
block_queue: asyncio.Queue | None = None,
|
||||
name: str | None = None,
|
||||
interval: int = 0,
|
||||
) -> Task:
|
||||
|
|
@ -110,7 +149,12 @@ class TaskManager:
|
|||
await asyncio.sleep(interval)
|
||||
|
||||
return self.create_task(
|
||||
coro=wrapper(), name=name or func.__name__, invoice_queue=invoice_queue
|
||||
coro=wrapper(),
|
||||
name=name or func.__name__,
|
||||
invoice_queue=invoice_queue,
|
||||
onchain_address_queue=onchain_address_queue,
|
||||
onchain_tx_queue=onchain_tx_queue,
|
||||
block_queue=block_queue,
|
||||
)
|
||||
|
||||
def register_invoice_listener(
|
||||
|
|
@ -130,6 +174,185 @@ class TaskManager:
|
|||
invoice_queue=queue,
|
||||
)
|
||||
|
||||
def register_onchain_listener(
|
||||
self,
|
||||
func: Callable[[OnchainAddressEvent], Coroutine],
|
||||
name: str | None = None,
|
||||
) -> Task:
|
||||
"""
|
||||
Register a callback for onchain address events. Only dispatches events
|
||||
for addresses tracked under the same `name` via track_address, e.g. an
|
||||
extension registering as "ext_satspay" only sees events for addresses
|
||||
it tracked with that same name. Defaults to the shared "core" listener
|
||||
if no name is given.
|
||||
"""
|
||||
name = name or "core"
|
||||
queue: asyncio.Queue[OnchainAddressEvent] = asyncio.Queue()
|
||||
return self.create_permanent_task(
|
||||
self._onchain_address_listener_worker(func, queue),
|
||||
name=f"{name}{self.ONCHAIN_ADDRESS_LISTENER_SUFFIX}",
|
||||
onchain_address_queue=queue,
|
||||
)
|
||||
|
||||
def register_onchain_tx_listener(
|
||||
self,
|
||||
func: Callable[[OnchainTxEvent], Coroutine],
|
||||
name: str | None = None,
|
||||
) -> Task:
|
||||
"""
|
||||
Register a callback for onchain transaction events dispatched for any
|
||||
transaction currently tracked via register_ws_tx_queue.
|
||||
Will call the provided coroutine with an OnchainTxEvent on each update.
|
||||
"""
|
||||
name = f"{name or uuid.uuid4()}_onchain_tx_listener"
|
||||
queue: asyncio.Queue[OnchainTxEvent] = asyncio.Queue()
|
||||
return self.create_permanent_task(
|
||||
self._onchain_tx_listener_worker(func, queue),
|
||||
name=name,
|
||||
onchain_tx_queue=queue,
|
||||
)
|
||||
|
||||
def register_block_listener(
|
||||
self,
|
||||
func: Callable[[BlockInfo], Coroutine],
|
||||
name: str | None = None,
|
||||
) -> Task:
|
||||
"""
|
||||
Register a callback for new block events dispatched while the shared
|
||||
block tracker is running (i.e. while a websocket or other consumer has
|
||||
requested block updates via register_ws_block_queue).
|
||||
Will call the provided coroutine with a BlockInfo on each new block.
|
||||
"""
|
||||
name = f"{name or uuid.uuid4()}_block_listener"
|
||||
queue: asyncio.Queue[BlockInfo] = asyncio.Queue()
|
||||
return self.create_permanent_task(
|
||||
self._block_listener_worker(func, queue),
|
||||
name=name,
|
||||
block_queue=queue,
|
||||
)
|
||||
|
||||
def track_address(self, address: str, name: str) -> None:
|
||||
"""Start tracking a Bitcoin address via Electrum (ref-counted).
|
||||
|
||||
`name` identifies the listener (see register_onchain_listener) that
|
||||
should receive events for this address.
|
||||
"""
|
||||
self._get_address_tracker().add(address)
|
||||
self._tracked_addresses_by_listener.setdefault(name, set()).add(address)
|
||||
|
||||
def untrack_address(self, address: str, name: str) -> None:
|
||||
"""Decrement ref count; remove from shared tracker when last caller leaves."""
|
||||
if self._address_tracker:
|
||||
self._address_tracker.remove(address)
|
||||
tracked = self._tracked_addresses_by_listener.get(name)
|
||||
if tracked:
|
||||
tracked.discard(address)
|
||||
if not tracked:
|
||||
self._tracked_addresses_by_listener.pop(name, None)
|
||||
|
||||
def _get_address_tracker(self) -> "AddressTracker":
|
||||
if self._address_tracker is None:
|
||||
self._address_tracker = AddressTracker(
|
||||
settings.lnbits_blockexplorer_electrum_url
|
||||
)
|
||||
if not self.get_task("address_tracker"):
|
||||
self.create_task(
|
||||
self._address_tracker.run(
|
||||
self._dispatch_onchain_event,
|
||||
lambda: settings.lnbits_running,
|
||||
),
|
||||
name="address_tracker",
|
||||
)
|
||||
return self._address_tracker
|
||||
|
||||
def register_ws_address_queue(
|
||||
self, address: str, queue: asyncio.Queue[OnchainAddressEvent]
|
||||
) -> None:
|
||||
"""Register a per-connection queue for a watched address.
|
||||
|
||||
Raises ValueError if the address is invalid.
|
||||
"""
|
||||
scripthash_from_address(address)
|
||||
self._get_address_tracker().register_queue(address, queue)
|
||||
|
||||
def unregister_ws_address_queue(
|
||||
self, address: str, queue: asyncio.Queue[OnchainAddressEvent]
|
||||
) -> None:
|
||||
"""Deregister a per-connection queue and decrement the address ref count."""
|
||||
if self._address_tracker:
|
||||
self._address_tracker.unregister_queue(address, queue)
|
||||
|
||||
def register_ws_tx_queue(
|
||||
self, txid: str, queue: asyncio.Queue[OnchainTxEvent]
|
||||
) -> None:
|
||||
"""Register a per-connection queue for a watched transaction."""
|
||||
self._get_tx_tracker(txid).register_queue(queue)
|
||||
|
||||
def unregister_ws_tx_queue(
|
||||
self, txid: str, queue: asyncio.Queue[OnchainTxEvent]
|
||||
) -> None:
|
||||
"""Deregister a per-connection queue; cancel tracker when last one leaves."""
|
||||
tracker = self._tx_trackers.get(txid)
|
||||
if not tracker:
|
||||
return
|
||||
tracker.unregister_queue(queue)
|
||||
if not tracker.has_queues():
|
||||
self._tx_trackers.pop(txid, None)
|
||||
task = self.get_task(f"ws_tx_{txid}")
|
||||
if task:
|
||||
self.cancel_task(task)
|
||||
|
||||
def _get_tx_tracker(self, txid: str) -> "TransactionTracker":
|
||||
tracker = self._tx_trackers.get(txid)
|
||||
if tracker is None:
|
||||
tracker = TransactionTracker(settings.lnbits_blockexplorer_electrum_url)
|
||||
self._tx_trackers[txid] = tracker
|
||||
if not self.get_task(f"ws_tx_{txid}"):
|
||||
self.create_task(
|
||||
self._transaction_tracker_dispatch(txid, tracker),
|
||||
name=f"ws_tx_{txid}",
|
||||
)
|
||||
return tracker
|
||||
|
||||
def register_ws_block_queue(self, queue: asyncio.Queue[BlockInfo]) -> None:
|
||||
"""Register a per-connection queue for new block events."""
|
||||
if self._block_tracker is None:
|
||||
self._block_tracker = BlockTracker(
|
||||
settings.lnbits_blockexplorer_electrum_url
|
||||
)
|
||||
tracker = self._block_tracker
|
||||
was_empty = not tracker.has_queues()
|
||||
tracker.register_queue(queue)
|
||||
if was_empty:
|
||||
self.create_task(
|
||||
tracker.run(
|
||||
self._dispatch_block_event,
|
||||
lambda: tracker.has_queues() and settings.lnbits_running,
|
||||
),
|
||||
name="block_tracker",
|
||||
)
|
||||
|
||||
def unregister_ws_block_queue(self, queue: asyncio.Queue[BlockInfo]) -> None:
|
||||
"""Deregister a per-connection queue; cancel tracker when last one leaves."""
|
||||
if not self._block_tracker:
|
||||
return
|
||||
self._block_tracker.unregister_queue(queue)
|
||||
if not self._block_tracker.has_queues():
|
||||
task = self.get_task("block_tracker")
|
||||
if task:
|
||||
self.cancel_task(task)
|
||||
|
||||
def track_transaction(
|
||||
self,
|
||||
txid: str,
|
||||
callback: Callable[[OnchainTxEvent], Coroutine],
|
||||
) -> Task:
|
||||
"""Track a transaction until confirmed, calling callback on each change."""
|
||||
return self.create_task(
|
||||
self._transaction_tracker(txid, callback),
|
||||
name=f"onchain_tx_{txid}",
|
||||
)
|
||||
|
||||
async def _heart_beat(self) -> None:
|
||||
"""A heartbeat that removes done tasks logs the number of tasks."""
|
||||
for task in self.tasks:
|
||||
|
|
@ -142,10 +365,17 @@ class TaskManager:
|
|||
if task.task and task.task.done():
|
||||
logger.debug(f"Task Manager: task `{task.name}` is done.")
|
||||
self.cancel_task(task)
|
||||
listeners_count = sum(1 for task in self.tasks if task.invoice_queue)
|
||||
invoice_listeners = sum(1 for task in self.tasks if task.invoice_queue)
|
||||
onchain_listeners = sum(
|
||||
1
|
||||
for task in self.tasks
|
||||
if task.onchain_address_queue or task.onchain_tx_queue or task.block_queue
|
||||
)
|
||||
other_tasks = len(self.tasks) - invoice_listeners - onchain_listeners
|
||||
logger.debug(
|
||||
f"Task Manager: {len(self.tasks) - listeners_count} tasks "
|
||||
f"and {listeners_count} invoice listeners."
|
||||
f"Task Manager: {other_tasks} tasks, "
|
||||
f"{invoice_listeners} invoice listeners, "
|
||||
f"{onchain_listeners} onchain listeners."
|
||||
)
|
||||
|
||||
async def _catch_everything_and_restart(
|
||||
|
|
@ -178,6 +408,39 @@ class TaskManager:
|
|||
|
||||
return wrapper
|
||||
|
||||
def _onchain_address_listener_worker(
|
||||
self,
|
||||
func: Callable[[OnchainAddressEvent], Coroutine],
|
||||
queue: asyncio.Queue[OnchainAddressEvent],
|
||||
) -> Callable:
|
||||
async def wrapper() -> None:
|
||||
event: OnchainAddressEvent = await queue.get()
|
||||
await func(event)
|
||||
|
||||
return wrapper
|
||||
|
||||
def _onchain_tx_listener_worker(
|
||||
self,
|
||||
func: Callable[[OnchainTxEvent], Coroutine],
|
||||
queue: asyncio.Queue[OnchainTxEvent],
|
||||
) -> Callable:
|
||||
async def wrapper() -> None:
|
||||
event: OnchainTxEvent = await queue.get()
|
||||
await func(event)
|
||||
|
||||
return wrapper
|
||||
|
||||
def _block_listener_worker(
|
||||
self,
|
||||
func: Callable[[BlockInfo], Coroutine],
|
||||
queue: asyncio.Queue[BlockInfo],
|
||||
) -> Callable:
|
||||
async def wrapper() -> None:
|
||||
event: BlockInfo = await queue.get()
|
||||
await func(event)
|
||||
|
||||
return wrapper
|
||||
|
||||
def _invoice_dispatcher(self, payment: Payment) -> None:
|
||||
"""Dispatches a payment to all registered invoice listeners."""
|
||||
for task in self.tasks:
|
||||
|
|
@ -186,6 +449,39 @@ class TaskManager:
|
|||
logger.debug(f"Enqueing payment to task {task.name}")
|
||||
task.invoice_queue.put_nowait(payment)
|
||||
|
||||
async def _dispatch_onchain_event(self, event: OnchainAddressEvent) -> None:
|
||||
"""Dispatches an onchain address event to listeners tracking that
|
||||
address under their own name (see track_address).
|
||||
|
||||
Per-address WS queue fan-out is handled by AddressTracker itself.
|
||||
"""
|
||||
for task in self.tasks:
|
||||
if not task.onchain_address_queue:
|
||||
continue
|
||||
if not task.name.endswith(self.ONCHAIN_ADDRESS_LISTENER_SUFFIX):
|
||||
continue
|
||||
name = task.name[: -len(self.ONCHAIN_ADDRESS_LISTENER_SUFFIX)]
|
||||
if event.address in self._tracked_addresses_by_listener.get(name, ()):
|
||||
task.onchain_address_queue.put_nowait(event)
|
||||
|
||||
async def _dispatch_onchain_tx_event(self, event: OnchainTxEvent) -> None:
|
||||
"""Dispatches an onchain tx event to registered listeners.
|
||||
|
||||
Per-tx WS queue fan-out is handled by TransactionTracker itself.
|
||||
"""
|
||||
for task in self.tasks:
|
||||
if task.onchain_tx_queue:
|
||||
task.onchain_tx_queue.put_nowait(event)
|
||||
|
||||
async def _dispatch_block_event(self, event: BlockInfo) -> None:
|
||||
"""Dispatches a new block event to registered listeners.
|
||||
|
||||
Per-connection WS queue fan-out is handled by BlockTracker itself.
|
||||
"""
|
||||
for task in self.tasks:
|
||||
if task.block_queue:
|
||||
task.block_queue.put_nowait(event)
|
||||
|
||||
async def _invoice_listener_consumer(self) -> None:
|
||||
payment = await self.invoice_queue.get()
|
||||
logger.info(f"got a payment notification {payment.checking_id}")
|
||||
|
|
@ -196,5 +492,64 @@ class TaskManager:
|
|||
logger.info(f"got an internal payment notification {payment.checking_id}")
|
||||
self._invoice_dispatcher(payment)
|
||||
|
||||
async def _transaction_tracker(
|
||||
self, txid: str, callback: Callable[[OnchainTxEvent], Coroutine]
|
||||
) -> None:
|
||||
await TransactionTracker(settings.lnbits_blockexplorer_electrum_url).track(
|
||||
txid,
|
||||
callback,
|
||||
lambda: settings.lnbits_running,
|
||||
)
|
||||
|
||||
async def _transaction_tracker_dispatch(
|
||||
self, txid: str, tracker: "TransactionTracker"
|
||||
) -> None:
|
||||
await tracker.track(
|
||||
txid,
|
||||
self._dispatch_onchain_tx_event,
|
||||
lambda: tracker.has_queues() and settings.lnbits_running,
|
||||
)
|
||||
|
||||
|
||||
T = TypeVar("T", bound=BaseModel)
|
||||
|
||||
|
||||
async def relay_ws_queue(
|
||||
websocket: WebSocket,
|
||||
queue: "asyncio.Queue[T]",
|
||||
serialize: Callable[[T], BaseModel] = lambda e: e,
|
||||
stop_after: Callable[[T], bool] = lambda _: False,
|
||||
) -> None:
|
||||
"""
|
||||
Pumps events from `queue` to `websocket` as JSON until the client
|
||||
disconnects, sending fails, or `stop_after` returns True for an event.
|
||||
Shared by the blockexplorer address/tx/block websocket endpoints.
|
||||
"""
|
||||
try:
|
||||
while settings.lnbits_running:
|
||||
recv_task = asyncio.create_task(websocket.receive())
|
||||
event_task = asyncio.create_task(queue.get())
|
||||
done, pending = await asyncio.wait(
|
||||
[recv_task, event_task], return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
disconnect = recv_task in done and (
|
||||
recv_task.result().get("type") == "websocket.disconnect"
|
||||
)
|
||||
if disconnect:
|
||||
break
|
||||
if event_task in done:
|
||||
event = event_task.result()
|
||||
try:
|
||||
await websocket.send_json(serialize(event).dict())
|
||||
except Exception as exc:
|
||||
logger.debug(f"ws relay send error: {exc}")
|
||||
break
|
||||
if stop_after(event):
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.debug(f"ws relay error: {exc}")
|
||||
|
||||
|
||||
task_manager = TaskManager()
|
||||
|
|
|
|||
|
|
@ -72,7 +72,9 @@
|
|||
{% block page %}{% endblock %}
|
||||
</div>
|
||||
<!-- vue router-view -->
|
||||
<router-view :key="$route.path"></router-view>
|
||||
<router-view
|
||||
:key="$route.meta.stableKey ? $route.matched[0]?.path : $route.path"
|
||||
></router-view>
|
||||
</q-page>
|
||||
</q-page-container>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ include('components/admin/wasm-limit-config.vue') %} {%
|
|||
include('components/admin/assets-config.vue') %} {%
|
||||
include('components/admin/notifications.vue') %} {%
|
||||
include('components/admin/server.vue') %} {%
|
||||
include('components/admin/blockexplorer.vue') %} {%
|
||||
include('components/lnbits-qrcode.vue') %} {%
|
||||
include('components/lnbits-qrcode-scanner.vue') %} {%
|
||||
include('components/lnbits-disclaimer.vue') %} {%
|
||||
|
|
@ -100,6 +101,21 @@ include('components/lnbits-error.vue') %}
|
|||
<q-icon name="chevron_right" color="grey-5" size="md"></q-icon>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-if="g.settings.showBlockExplorer" to="/blockexplorer">
|
||||
<q-item-section side>
|
||||
<q-icon
|
||||
name="travel_explore"
|
||||
:color="isActive('/blockexplorer') ? 'primary' : 'grey-5'"
|
||||
size="md"
|
||||
></q-icon>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label lines="1" v-text="$t('block_explorer')"></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side v-show="isActive('/blockexplorer')">
|
||||
<q-icon name="chevron_right" color="grey-5" size="md"></q-icon>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
<q-item to="/payments">
|
||||
<q-item-section side>
|
||||
|
|
|
|||
90
lnbits/templates/components/admin/blockexplorer.vue
Normal file
90
lnbits/templates/components/admin/blockexplorer.vue
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<template id="lnbits-admin-blockexplorer">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none q-mb-sm">
|
||||
<span v-text="$t('block_explorer')"></span>
|
||||
</h6>
|
||||
<div class="row q-mb-lg">
|
||||
<div class="col-md-6 col-sm-12 q-pr-sm">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_blockexplorer_enabled"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('enable_block_explorer')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('block_explorer_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_blockexplorer_public_api"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label
|
||||
v-text="$t('blockexplorer_public_api')"
|
||||
></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('blockexplorer_public_api_desc')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator class="q-mb-lg q-mt-sm"></q-separator>
|
||||
<h6 class="q-my-none q-mb-sm">
|
||||
<span v-text="$t('electrum_compatible_server')"></span>
|
||||
</h6>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-4">
|
||||
<q-select
|
||||
filled
|
||||
v-model="electrumServerPreset"
|
||||
:options="electrumServerOptions"
|
||||
:label="$t('electrum_server_url')"
|
||||
:hint="$t('electrum_server_url_hint')"
|
||||
></q-select>
|
||||
</div>
|
||||
<div class="col-12 col-md-4" v-if="electrumServerPreset === 'Custom'">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_blockexplorer_electrum_url"
|
||||
:label="$t('electrum_server_url_custom')"
|
||||
:hint="$t('electrum_server_url_hint')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.lnbits_blockexplorer_network"
|
||||
:options="['main', 'test', 'regtest', 'signet']"
|
||||
:label="$t('blockexplorer_network')"
|
||||
:hint="$t('blockexplorer_network_hint')"
|
||||
></q-select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<a
|
||||
href="https://1209k.com/bitcoin-eye/ele.php?chain=btc"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span v-text="$t('view_public_electrum_servers')"></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</template>
|
||||
|
|
@ -107,7 +107,7 @@
|
|||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>
|
||||
<span v-text="$t('miscellaneous')"></span>
|
||||
<span v-text="$t('miscellanous')"></span>
|
||||
</p>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
|
|
|
|||
|
|
@ -69,6 +69,127 @@
|
|||
</div>
|
||||
|
||||
<q-separator class="q-mb-lg q-mt-sm"></q-separator>
|
||||
<h6 class="q-my-none q-mb-sm" v-text="$t('lightning_addresses')"></h6>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6 q-mt-sm">
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('ln_address_mode')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('ln_address_mode_hint')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-select
|
||||
filled
|
||||
emit-value
|
||||
map-options
|
||||
v-model="formData.lnbits_ln_address_mode"
|
||||
:label="$t('ln_address_mode')"
|
||||
:options="[
|
||||
{label: $t('ln_address_core_first'), value: 'core_first'},
|
||||
{
|
||||
label: $t('ln_address_extension_first'),
|
||||
value: 'extension_first'
|
||||
},
|
||||
{
|
||||
label: $t('ln_address_extension_only'),
|
||||
value: 'extension_only'
|
||||
}
|
||||
]"
|
||||
></q-select>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
<div
|
||||
v-if="formData.lnbits_ln_address_mode != 'extension_only'"
|
||||
class="col-12 col-md-6 q-mt-sm"
|
||||
>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label
|
||||
v-text="$t('allow_users_specify_lightning_addresses')"
|
||||
></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="
|
||||
$t('allow_wallet_owners_set_custom_lightning_addresses')
|
||||
"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="
|
||||
formData.lnbits_allow_custom_wallet_lightning_addresses
|
||||
"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
<div
|
||||
v-if="formData.lnbits_ln_address_mode != 'extension_only'"
|
||||
class="col-12 col-md-6 q-mt-sm"
|
||||
>
|
||||
<div>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label
|
||||
v-text="$t('charge_for_lightning_addresses')"
|
||||
></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('charge_users_set_change_lightning_address')"
|
||||
></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="$t('service_fee_wallet_id_must_be_set')"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_charge_wallet_lightning_addresses"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</div>
|
||||
<q-input
|
||||
v-if="formData.lnbits_charge_wallet_lightning_addresses"
|
||||
class="q-mt-sm"
|
||||
filled
|
||||
dense
|
||||
type="number"
|
||||
min="0"
|
||||
v-model.number="formData.lnbits_wallet_lightning_address_price_sats"
|
||||
:label="$t('lightning_address_price')"
|
||||
suffix="sats"
|
||||
></q-input>
|
||||
</div>
|
||||
<div
|
||||
v-if="formData.lnbits_ln_address_mode != 'extension_only'"
|
||||
class="col-12 col-md-6 q-mt-sm"
|
||||
>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
type="textarea"
|
||||
autogrow
|
||||
v-model="lightningAddressBlacklistText"
|
||||
:label="$t('lightning_address_blacklist')"
|
||||
:hint="$t('lightning_address_blacklist_instructions')"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-separator class="q-mb-lg q-mt-md"></q-separator>
|
||||
<h6 class="q-my-none q-mb-sm">
|
||||
<span v-text="$t('wallet_limiter')"></span>
|
||||
</h6>
|
||||
|
|
|
|||
|
|
@ -157,6 +157,60 @@
|
|||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section
|
||||
v-if="
|
||||
g.settings.enableWalletLightningAddresses &&
|
||||
(g.wallet.lightningAddressFull || canEditLightningAddress)
|
||||
"
|
||||
>
|
||||
<div class="row">
|
||||
<div :class="canEditLightningAddress ? 'col-8' : 'col-10'">
|
||||
<q-input
|
||||
v-if="canEditLightningAddress"
|
||||
filled
|
||||
dense
|
||||
v-model.trim="lightningAddressInput"
|
||||
:label="$t('lightning_address')"
|
||||
:suffix="lightningAddressSuffix"
|
||||
:hint="lightningAddressFeeHint"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
v-else
|
||||
filled
|
||||
readonly
|
||||
dense
|
||||
:model-value="g.wallet.lightningAddressFull"
|
||||
:label="$t('lightning_address')"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div v-if="canEditLightningAddress" class="col-2 q-pl-sm">
|
||||
<q-btn
|
||||
dense
|
||||
color="primary"
|
||||
class="q-mt-xs full-width"
|
||||
:disable="
|
||||
!lightningAddressInput || !lightningAddressChanged
|
||||
"
|
||||
label="Save"
|
||||
@click="saveLightningAddress"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
icon="content_copy"
|
||||
class="float-right q-mt-xs"
|
||||
:disable="!g.wallet.lightningAddressFull"
|
||||
@click="utils.copyText(g.wallet.lightningAddressFull)"
|
||||
>
|
||||
<q-tooltip v-text="$t('copy')"></q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
|
|
|
|||
|
|
@ -4,4 +4,4 @@ include('pages/users.vue') %} {% include('pages/admin.vue') %} {%
|
|||
include('pages/account.vue') %} {% include('pages/extensions_builder.vue') %} {%
|
||||
include('pages/extensions.vue') %} {% include('pages/first-install.vue') %} {%
|
||||
include('pages/home.vue') %} {% include('pages/wallet.vue') %} {%
|
||||
include('pages/error.vue') %}
|
||||
include('pages/error.vue') %} {% include('pages/blockexplorer.vue') %}
|
||||
|
|
|
|||
|
|
@ -94,7 +94,8 @@
|
|||
{value: 'notifications', label: $t('notifications')},
|
||||
{value: 'audit', label: $t('audit')},
|
||||
{value: 'assets-config', label: $t('assets')},
|
||||
{value: 'site_customisation', label: $t('site_customisation')}
|
||||
{value: 'site_customisation', label: $t('site_customisation')},
|
||||
{value: 'blockexplorer', label: $t('block_explorer')}
|
||||
]"
|
||||
option-value="value"
|
||||
option-label="label"
|
||||
|
|
@ -189,6 +190,13 @@
|
|||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('site_customisation')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="blockexplorer"
|
||||
icon="travel_explore"
|
||||
:label="$q.screen.gt.sm ? $t('block_explorer') : null"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('block_explorer')"></span></q-tooltip
|
||||
></q-tab>
|
||||
</q-tabs>
|
||||
</template>
|
||||
|
||||
|
|
@ -247,6 +255,9 @@
|
|||
<q-tab-panel name="assets-config">
|
||||
<lnbits-admin-assets-config :form-data="formData" />
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="blockexplorer">
|
||||
<lnbits-admin-blockexplorer :form-data="formData" />
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
</q-scroll-area>
|
||||
</q-form>
|
||||
|
|
|
|||
385
lnbits/templates/pages/blockexplorer.vue
Normal file
385
lnbits/templates/pages/blockexplorer.vue
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
<template id="page-blockexplorer">
|
||||
<div class="row q-col-gutter-md">
|
||||
<!-- Left column: blocks + search + results -->
|
||||
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||
<!-- Recent blocks (mempool-style squares) -->
|
||||
<q-card v-if="blocks.length">
|
||||
<q-card-section>
|
||||
<div
|
||||
class="text-subtitle1 q-mb-md"
|
||||
v-text="$t('recent_blocks')"
|
||||
></div>
|
||||
<div class="row q-gutter-sm">
|
||||
<q-card
|
||||
v-for="b in formattedBlocks"
|
||||
:key="b.height"
|
||||
flat
|
||||
class="bg-primary text-white cursor-pointer"
|
||||
v-ripple
|
||||
@click="openBlock(b)"
|
||||
>
|
||||
<q-card-section class="q-pa-sm">
|
||||
<div
|
||||
class="text-subtitle1 text-weight-bold"
|
||||
v-text="'#' + b.height.toLocaleString()"
|
||||
></div>
|
||||
<div class="text-caption q-mt-xs" v-text="b.timeAgo"></div>
|
||||
<div class="text-caption q-mt-sm">
|
||||
<code v-text="b.shortHash"></code>
|
||||
</div>
|
||||
<div
|
||||
class="text-caption"
|
||||
v-text="$t('block_diff', {value: b.difficulty})"
|
||||
></div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<!-- Block detail dialog -->
|
||||
<q-dialog v-model="blockDialog">
|
||||
<q-card v-if="selectedBlock">
|
||||
<q-card-section class="bg-primary text-white q-pb-sm">
|
||||
<div
|
||||
class="text-h6"
|
||||
v-text="
|
||||
$t('block_number', {
|
||||
height: selectedBlock.height.toLocaleString()
|
||||
})
|
||||
"
|
||||
></div>
|
||||
<div class="text-caption" v-text="selectedBlock.utcTime"></div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<div class="q-mb-md">
|
||||
<div
|
||||
class="text-caption text-grey q-mb-xs"
|
||||
v-text="$t('block_hash')"
|
||||
></div>
|
||||
<code
|
||||
class="text-caption be-wrap"
|
||||
v-text="selectedBlock.hash"
|
||||
></code>
|
||||
</div>
|
||||
<div class="q-mb-md">
|
||||
<div
|
||||
class="text-caption text-grey q-mb-xs"
|
||||
v-text="$t('previous_block')"
|
||||
></div>
|
||||
<code
|
||||
class="text-caption be-wrap"
|
||||
v-text="selectedBlock.prev_hash"
|
||||
></code>
|
||||
</div>
|
||||
<div class="q-mb-lg">
|
||||
<div
|
||||
class="text-caption text-grey q-mb-xs"
|
||||
v-text="$t('merkle_root')"
|
||||
></div>
|
||||
<code
|
||||
class="text-caption be-wrap"
|
||||
v-text="selectedBlock.merkle_root"
|
||||
></code>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-6 col-sm-3">
|
||||
<div
|
||||
class="text-caption text-grey"
|
||||
v-text="$t('version')"
|
||||
></div>
|
||||
<div v-text="'0x' + selectedBlock.version.toString(16)"></div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-3">
|
||||
<div class="text-caption text-grey" v-text="$t('bits')"></div>
|
||||
<div v-text="selectedBlock.bits"></div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-3">
|
||||
<div
|
||||
class="text-caption text-grey"
|
||||
v-text="$t('difficulty')"
|
||||
></div>
|
||||
<div v-text="selectedBlock.difficulty"></div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-3">
|
||||
<div class="text-caption text-grey" v-text="$t('nonce')"></div>
|
||||
<div v-text="selectedBlock.nonce.toLocaleString()"></div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat v-close-popup v-text="$t('close')"></q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="text-h6" v-text="$t('block_explorer')"></div>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pt-none">
|
||||
<q-input
|
||||
filled
|
||||
v-model="query"
|
||||
:label="$t('blockexplorer_search_label')"
|
||||
:hint="$t('blockexplorer_search_hint')"
|
||||
@keyup.enter="search"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
dense
|
||||
icon="search"
|
||||
:loading="loading"
|
||||
@click="search"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<!-- Transaction result -->
|
||||
<q-card v-if="txResult">
|
||||
<q-card-section>
|
||||
<div class="row items-center justify-between q-mb-sm">
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<div class="text-subtitle1" v-text="$t('transaction')"></div>
|
||||
<q-badge
|
||||
v-if="txStatus"
|
||||
:color="txStatus.confirmed ? 'positive' : 'orange'"
|
||||
:label="
|
||||
txStatus.confirmed ? $t('confirmed') : $t('unconfirmed')
|
||||
"
|
||||
></q-badge>
|
||||
<q-spinner v-if="!txStatus" size="1em" color="grey" />
|
||||
</div>
|
||||
<q-btn flat round dense icon="close" @click="clearResult" />
|
||||
</div>
|
||||
<div class="q-mb-sm">
|
||||
<span
|
||||
class="text-caption text-grey"
|
||||
v-text="$t('txid') + ': '"
|
||||
></span>
|
||||
<code class="text-caption be-wrap" v-text="txResult.txid"></code>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md q-mb-sm">
|
||||
<div class="col-auto" v-if="txStatus && txStatus.height">
|
||||
<div
|
||||
class="text-caption text-grey"
|
||||
v-text="$t('block_height')"
|
||||
></div>
|
||||
<div v-text="txStatus.height.toLocaleString()"></div>
|
||||
</div>
|
||||
<div class="col-auto" v-if="txStatus && txStatus.fee !== null">
|
||||
<div class="text-caption text-grey" v-text="$t('fee')"></div>
|
||||
<div v-text="txStatus.fee + ' sat'"></div>
|
||||
</div>
|
||||
<div class="col-auto" v-if="txResult.vsize || txResult.size">
|
||||
<div class="text-caption text-grey" v-text="$t('vsize')"></div>
|
||||
<div v-text="(txResult.vsize || txResult.size) + ' vB'"></div>
|
||||
</div>
|
||||
<div class="col-auto" v-if="txResult.weight">
|
||||
<div class="text-caption text-grey" v-text="$t('weight')"></div>
|
||||
<div v-text="txResult.weight + ' WU'"></div>
|
||||
</div>
|
||||
</div>
|
||||
<q-expansion-item
|
||||
icon="login"
|
||||
:label="$t('inputs') + ' (' + txResult.vin.length + ')'"
|
||||
dense
|
||||
class="q-mb-xs"
|
||||
>
|
||||
<q-list dense separator>
|
||||
<q-item v-for="(vin, i) in txResult.vin" :key="i">
|
||||
<q-item-section>
|
||||
<q-item-label
|
||||
v-if="vin.coinbase"
|
||||
class="text-grey"
|
||||
v-text="$t('coinbase')"
|
||||
>
|
||||
</q-item-label>
|
||||
<q-item-label v-else class="be-wrap">
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="loadTx(vin.txid)"
|
||||
class="text-primary"
|
||||
v-text="vin.txid + ':' + vin.vout"
|
||||
></a>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
icon="logout"
|
||||
:label="$t('outputs') + ' (' + txResult.vout.length + ')'"
|
||||
dense
|
||||
>
|
||||
<q-list dense separator>
|
||||
<q-item v-for="(vout, i) in txResult.vout" :key="i">
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<template
|
||||
v-if="vout.scriptPubKey && vout.scriptPubKey.address"
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="loadAddress(vout.scriptPubKey.address)"
|
||||
class="text-primary"
|
||||
v-text="vout.scriptPubKey.address"
|
||||
></a>
|
||||
</template>
|
||||
<template
|
||||
v-else-if="
|
||||
vout.scriptPubKey &&
|
||||
vout.scriptPubKey.type === 'nulldata'
|
||||
"
|
||||
>
|
||||
<span class="text-grey">OP_RETURN</span>
|
||||
</template>
|
||||
<template v-else-if="vout.scriptPubKey">
|
||||
<span v-text="vout.scriptPubKey.type"></span>
|
||||
</template>
|
||||
</q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="vout.value + ' BTC'"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-expansion-item>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<!-- Address result -->
|
||||
<q-card v-if="addressResult">
|
||||
<q-card-section>
|
||||
<div class="row items-center justify-between q-mb-xs">
|
||||
<div class="text-subtitle1" v-text="$t('address')"></div>
|
||||
<q-btn flat round dense icon="close" @click="clearResult" />
|
||||
</div>
|
||||
<div class="text-caption q-mb-sm">
|
||||
<code class="be-wrap" v-text="currentAddress"></code>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-auto">
|
||||
<div
|
||||
class="text-caption text-grey"
|
||||
v-text="$t('confirmed_balance')"
|
||||
></div>
|
||||
<div
|
||||
v-text="
|
||||
addressResult.balance.confirmed.toLocaleString() + ' sat'
|
||||
"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
class="col-auto"
|
||||
v-if="addressResult.balance.unconfirmed !== 0"
|
||||
>
|
||||
<div
|
||||
class="text-caption text-grey"
|
||||
v-text="$t('unconfirmed_balance')"
|
||||
></div>
|
||||
<div
|
||||
v-text="
|
||||
addressResult.balance.unconfirmed.toLocaleString() + ' sat'
|
||||
"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="text-subtitle2 q-mb-xs"
|
||||
v-text="
|
||||
$t('transaction_history') +
|
||||
' (' +
|
||||
addressResult.history.length +
|
||||
')'
|
||||
"
|
||||
></div>
|
||||
<q-list dense separator>
|
||||
<q-item
|
||||
v-for="h in addressResult.history"
|
||||
:key="h.tx_hash"
|
||||
clickable
|
||||
v-ripple
|
||||
@click="loadTx(h.tx_hash)"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label
|
||||
class="text-primary be-wrap"
|
||||
v-text="h.tx_hash"
|
||||
></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="
|
||||
h.height > 0
|
||||
? $t('block_height') + ': ' + h.height.toLocaleString()
|
||||
: $t('unconfirmed')
|
||||
"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-icon name="chevron_right" color="grey-5"></q-icon>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<div
|
||||
v-if="addressResult.history_error"
|
||||
class="text-warning q-mt-sm text-caption"
|
||||
v-text="$t('history_unavailable')"
|
||||
></div>
|
||||
<div
|
||||
v-else-if="addressResult.history.length === 0"
|
||||
class="text-grey q-mt-sm"
|
||||
v-text="$t('no_transactions')"
|
||||
></div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<!-- Right column: chain tip + fees -->
|
||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||
<q-card v-if="tip">
|
||||
<q-card-section>
|
||||
<div class="text-subtitle1 q-mb-sm" v-text="$t('chain_tip')"></div>
|
||||
<div class="text-caption text-grey" v-text="$t('block_height')"></div>
|
||||
<div
|
||||
class="text-h6 q-mb-md"
|
||||
v-text="tip.height.toLocaleString()"
|
||||
></div>
|
||||
<template v-if="feeList.length">
|
||||
<div
|
||||
class="text-subtitle2 q-mb-sm"
|
||||
v-text="$t('fee_estimates')"
|
||||
></div>
|
||||
<q-list dense>
|
||||
<q-item v-for="f in feeList" :key="f.label" class="q-px-none">
|
||||
<q-item-section>
|
||||
<q-item-label
|
||||
class="text-caption text-grey"
|
||||
v-text="f.label"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-item-label
|
||||
class="text-body2"
|
||||
v-text="f.rate"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</template>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.be-wrap {
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -109,6 +109,18 @@
|
|||
<q-tooltip>Copy Invoice Key</q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<q-btn
|
||||
round
|
||||
v-if="g.user.super_user && !props.row.deleted"
|
||||
icon="alternate_email"
|
||||
size="sm"
|
||||
color="accent"
|
||||
class="q-ml-xs"
|
||||
@click="showLightningAddressDialog(props.row)"
|
||||
>
|
||||
<q-tooltip v-text="$t('set_lightning_address')"></q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<q-btn
|
||||
round
|
||||
icon="delete"
|
||||
|
|
@ -201,6 +213,39 @@
|
|||
</template>
|
||||
</q-table>
|
||||
</q-card>
|
||||
<q-dialog v-model="lightningAddressDialog.show">
|
||||
<q-card class="q-pa-md lnbits__dialog-card">
|
||||
<q-card-section>
|
||||
<div class="text-h6" v-text="$t('lightning_address')"></div>
|
||||
<div
|
||||
v-if="lightningAddressDialog.wallet"
|
||||
class="text-caption q-mt-xs"
|
||||
>
|
||||
<span v-text="lightningAddressDialog.wallet.name"></span>
|
||||
<span> · </span>
|
||||
<span v-text="lightningAddressDialog.wallet.id"></span>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="lightningAddressDialog.lightningAddress"
|
||||
:label="$t('lightning_address')"
|
||||
:suffix="lightningAddressSuffix"
|
||||
></q-input>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat :label="$t('cancel')" v-close-popup></q-btn>
|
||||
<q-btn
|
||||
color="primary"
|
||||
:label="$t('save')"
|
||||
:disable="!lightningAddressDialog.lightningAddress"
|
||||
@click="saveLightningAddress"
|
||||
></q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
<div v-if="activeUser.show" class="row">
|
||||
<div class="col-12 col-md-6">
|
||||
|
|
|
|||
|
|
@ -596,6 +596,34 @@
|
|||
:value="'LIGHTNING:' + receive.paymentReq.toUpperCase()"
|
||||
>
|
||||
</lnbits-qrcode>
|
||||
<div
|
||||
v-if="
|
||||
!receive.fiatPaymentReq &&
|
||||
g.settings.enableWalletLightningAddresses &&
|
||||
g.wallet.lightningAddressFull
|
||||
"
|
||||
class="q-mt-md"
|
||||
>
|
||||
<q-input
|
||||
filled
|
||||
readonly
|
||||
dense
|
||||
:model-value="g.wallet.lightningAddressFull"
|
||||
:label="$t('lightning_address')"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
dense
|
||||
icon="content_copy"
|
||||
@click="utils.copyText(g.wallet.lightningAddressFull)"
|
||||
>
|
||||
<q-tooltip v-text="$t('copy')"></q-tooltip>
|
||||
</q-btn>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<h3 class="q-my-md">
|
||||
<span v-text="formattedAmount"></span>
|
||||
|
|
|
|||
|
|
@ -11,13 +11,29 @@ import hashlib
|
|||
import itertools
|
||||
import json
|
||||
import ssl
|
||||
from collections.abc import Callable
|
||||
import struct
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from embit.networks import NETWORKS
|
||||
from embit.script import Script
|
||||
from embit.transaction import Transaction as EmbitTransaction
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
DEFAULT_NETWORK = NETWORKS["main"]
|
||||
|
||||
|
||||
def network_from_name(name: str) -> dict:
|
||||
"""Look up an embit network dict (see embit.networks.NETWORKS) by name."""
|
||||
try:
|
||||
return NETWORKS[name]
|
||||
except KeyError as exc:
|
||||
raise ValueError(
|
||||
f"Unknown network {name!r}, expected one of {list(NETWORKS)}"
|
||||
) from exc
|
||||
|
||||
|
||||
class ElectrumError(Exception):
|
||||
pass
|
||||
|
|
@ -28,6 +44,47 @@ def scripthash_from_scriptpubkey(scriptpubkey: bytes) -> str:
|
|||
return hashlib.sha256(scriptpubkey).digest()[::-1].hex()
|
||||
|
||||
|
||||
def address_to_scriptpubkey(address: str) -> bytes:
|
||||
"""Convert a Bitcoin address (P2PKH/P2SH/P2WPKH/P2WSH/P2TR) to scriptPubKey."""
|
||||
try:
|
||||
script = Script.from_address(address)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"Invalid address: {address!r}") from exc
|
||||
if script is None:
|
||||
raise ValueError(f"Invalid address: {address!r}")
|
||||
return script.data
|
||||
|
||||
|
||||
def scripthash_from_address(address: str) -> str:
|
||||
return scripthash_from_scriptpubkey(address_to_scriptpubkey(address))
|
||||
|
||||
|
||||
_SCRIPT_TYPE_NAMES = {
|
||||
"p2pkh": "pubkeyhash",
|
||||
"p2sh": "scripthash",
|
||||
"p2wpkh": "witness_v0_keyhash",
|
||||
"p2wsh": "witness_v0_scripthash",
|
||||
"p2tr": "witness_v1_taproot",
|
||||
}
|
||||
|
||||
|
||||
def _scriptpubkey_info(spk: bytes, network: dict) -> tuple[str, str | None]:
|
||||
"""Return (type, address_or_None) for a scriptPubKey."""
|
||||
n = len(spk)
|
||||
# P2PK (not classified by embit)
|
||||
if n in (35, 67) and spk[-1] == 0xAC:
|
||||
return "pubkey", None
|
||||
# OP_RETURN (not classified by embit)
|
||||
if n >= 1 and spk[0] == 0x6A:
|
||||
return "nulldata", None
|
||||
|
||||
script = Script(spk)
|
||||
script_type = script.script_type()
|
||||
if script_type is None:
|
||||
return "nonstandard", None
|
||||
return _SCRIPT_TYPE_NAMES[script_type], script.address(network)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -105,6 +162,145 @@ class ServerFeatures(BaseModel):
|
|||
hosts: dict[str, Any] = {}
|
||||
|
||||
|
||||
class ScriptSig(BaseModel):
|
||||
hex: str
|
||||
|
||||
|
||||
class ScriptPubKey(BaseModel):
|
||||
hex: str
|
||||
type: str
|
||||
address: str | None = None
|
||||
|
||||
|
||||
class TxInput(BaseModel):
|
||||
txid: str | None = None
|
||||
vout: int | None = None
|
||||
scriptSig: ScriptSig | None = None # noqa: N815
|
||||
sequence: int
|
||||
coinbase: str | None = None
|
||||
|
||||
|
||||
class TxOutput(BaseModel):
|
||||
value: float
|
||||
n: int
|
||||
scriptPubKey: ScriptPubKey # noqa: N815
|
||||
|
||||
|
||||
class Transaction(BaseModel):
|
||||
txid: str
|
||||
version: int
|
||||
locktime: int
|
||||
vin: list[TxInput]
|
||||
vout: list[TxOutput]
|
||||
size: int
|
||||
vsize: int
|
||||
weight: int
|
||||
hex: str
|
||||
|
||||
|
||||
class FeeResponse(BaseModel):
|
||||
estimates: dict[str, float]
|
||||
histogram: list[FeeHistogramEntry]
|
||||
|
||||
|
||||
class AddressResponse(BaseModel):
|
||||
balance: Balance
|
||||
history: list[HistoryEntry]
|
||||
history_error: str | None = None
|
||||
|
||||
|
||||
class BlockInfo(BaseModel):
|
||||
height: int
|
||||
hash: str
|
||||
timestamp: int
|
||||
version: int
|
||||
bits: str
|
||||
nonce: int
|
||||
prev_hash: str
|
||||
merkle_root: str
|
||||
|
||||
|
||||
def parse_block_header(header_hex: str, height: int) -> BlockInfo:
|
||||
"""Parse an 80-byte block header hex string into a BlockInfo model."""
|
||||
data = bytes.fromhex(header_hex)
|
||||
version = struct.unpack_from("<I", data, 0)[0]
|
||||
prev_hash = data[4:36][::-1].hex()
|
||||
merkle_root = data[36:68][::-1].hex()
|
||||
timestamp = struct.unpack_from("<I", data, 68)[0]
|
||||
bits = format(struct.unpack_from("<I", data, 72)[0], "08x")
|
||||
nonce = struct.unpack_from("<I", data, 76)[0]
|
||||
block_hash = hashlib.sha256(hashlib.sha256(data).digest()).digest()[::-1].hex()
|
||||
return BlockInfo(
|
||||
height=height,
|
||||
hash=block_hash,
|
||||
timestamp=timestamp,
|
||||
version=version,
|
||||
bits=bits,
|
||||
nonce=nonce,
|
||||
prev_hash=prev_hash,
|
||||
merkle_root=merkle_root,
|
||||
)
|
||||
|
||||
|
||||
def parse_raw_tx(hex_str: str, network: dict | None = None) -> Transaction:
|
||||
"""Parse a raw transaction hex string into a Transaction model."""
|
||||
network = network or DEFAULT_NETWORK
|
||||
data = bytes.fromhex(hex_str)
|
||||
tx = EmbitTransaction.parse(data)
|
||||
|
||||
vin: list[TxInput] = []
|
||||
for inp in tx.vin:
|
||||
if inp.txid == b"\x00" * 32 and inp.vout == 0xFFFFFFFF:
|
||||
vin.append(
|
||||
TxInput(sequence=inp.sequence, coinbase=inp.script_sig.data.hex())
|
||||
)
|
||||
else:
|
||||
vin.append(
|
||||
TxInput(
|
||||
txid=inp.txid.hex(),
|
||||
vout=inp.vout,
|
||||
scriptSig=ScriptSig(hex=inp.script_sig.data.hex()),
|
||||
sequence=inp.sequence,
|
||||
)
|
||||
)
|
||||
|
||||
vout: list[TxOutput] = []
|
||||
for n_out, out in enumerate(tx.vout):
|
||||
spk_type, address = _scriptpubkey_info(out.script_pubkey.data, network)
|
||||
vout.append(
|
||||
TxOutput(
|
||||
value=round(out.value / 1e8, 8),
|
||||
n=n_out,
|
||||
scriptPubKey=ScriptPubKey(
|
||||
hex=out.script_pubkey.data.hex(), type=spk_type, address=address
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if tx.is_segwit:
|
||||
# base (non-witness) size = full size minus the segwit marker/flag
|
||||
# (2 bytes) and each input's witness stack
|
||||
witness_bytes = sum(len(inp.witness.serialize()) for inp in tx.vin)
|
||||
base_size = len(data) - 2 - witness_bytes
|
||||
weight = base_size * 3 + len(data)
|
||||
vsize = (weight + 3) // 4
|
||||
else:
|
||||
weight = len(data) * 4
|
||||
vsize = len(data)
|
||||
|
||||
return Transaction(
|
||||
txid=tx.txid().hex(),
|
||||
version=tx.version,
|
||||
locktime=tx.locktime,
|
||||
vin=vin,
|
||||
vout=vout,
|
||||
size=len(data),
|
||||
vsize=vsize,
|
||||
weight=weight,
|
||||
hex=hex_str,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -135,6 +331,7 @@ class ElectrumClient:
|
|||
client_name: str = "lnbits",
|
||||
protocol_version: str = "1.4",
|
||||
ping_interval: float = 60.0,
|
||||
network: dict | None = None,
|
||||
) -> None:
|
||||
parsed = urlparse(url)
|
||||
self.host = parsed.hostname or ""
|
||||
|
|
@ -145,6 +342,7 @@ class ElectrumClient:
|
|||
self.client_name = client_name
|
||||
self.protocol_version = protocol_version
|
||||
self.ping_interval = ping_interval
|
||||
self.network = network or DEFAULT_NETWORK
|
||||
self._counter = itertools.count(1)
|
||||
self._pending: dict[int, asyncio.Future[Any]] = {}
|
||||
self._subscriptions: dict[str, list[Callable[[list[Any]], Any]]] = {}
|
||||
|
|
@ -152,6 +350,7 @@ class ElectrumClient:
|
|||
self._ping_task: asyncio.Task[None] | None = None
|
||||
self._reader: asyncio.StreamReader | None = None
|
||||
self._writer: asyncio.StreamWriter | None = None
|
||||
self.closed: asyncio.Event = asyncio.Event()
|
||||
self.server_version: str = ""
|
||||
self.negotiated_protocol: str = ""
|
||||
|
||||
|
|
@ -286,6 +485,7 @@ class ElectrumClient:
|
|||
except Exception:
|
||||
logger.exception("Electrum: recv loop error")
|
||||
finally:
|
||||
self.closed.set()
|
||||
for fut in self._pending.values():
|
||||
if not fut.done():
|
||||
fut.set_exception(ElectrumError("Connection closed"))
|
||||
|
|
@ -381,10 +581,9 @@ class ElectrumClient:
|
|||
"""Broadcast a raw transaction hex; returns txid on success."""
|
||||
return await self._call("blockchain.transaction.broadcast", [raw_tx])
|
||||
|
||||
async def get_transaction(
|
||||
self, txid: str, verbose: bool = False
|
||||
) -> str | dict[str, Any]:
|
||||
return await self._call("blockchain.transaction.get", [txid, verbose])
|
||||
async def get_transaction(self, txid: str) -> str:
|
||||
"""Fetch raw transaction hex by txid."""
|
||||
return await self._call("blockchain.transaction.get", [txid])
|
||||
|
||||
async def get_merkle(self, txid: str, height: int) -> MerkleProof:
|
||||
data = await self._call("blockchain.transaction.get_merkle", [txid, height])
|
||||
|
|
@ -437,3 +636,424 @@ class ElectrumClient:
|
|||
"""Returns mempool fee histogram as FeeHistogramEntry(fee_rate, vsize) list."""
|
||||
data = await self._call("mempool.get_fee_histogram")
|
||||
return [FeeHistogramEntry(fee_rate=r[0], vsize=r[1]) for r in data]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Address tracking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OnchainAddressEvent(BaseModel):
|
||||
address: str
|
||||
confirmed: int # satoshis
|
||||
unconfirmed: int # satoshis
|
||||
history: list[HistoryEntry] = []
|
||||
history_error: str | None = None
|
||||
|
||||
@property
|
||||
def txids(self) -> list[str]:
|
||||
return [e.tx_hash for e in self.history]
|
||||
|
||||
|
||||
class AddressTracker:
|
||||
"""
|
||||
Subscribes to a set of Bitcoin addresses over a single shared Electrum
|
||||
connection and calls a callback on every balance/history change.
|
||||
Addresses can be added/removed at runtime via :meth:`add`/:meth:`remove`,
|
||||
and per-connection queues can be attached via :meth:`register_queue` for
|
||||
consumers (e.g. websockets) that want events for one specific address.
|
||||
Reconnects automatically on failure.
|
||||
|
||||
Args:
|
||||
url: Electrum server URL (e.g. ``ssl://electrum.blockstream.info:50002``).
|
||||
"""
|
||||
|
||||
def __init__(self, url: str) -> None:
|
||||
self.url = url
|
||||
self._ref_counts: dict[str, int] = {}
|
||||
self._queues: dict[str, list[asyncio.Queue[OnchainAddressEvent]]] = {}
|
||||
self._updated = asyncio.Event()
|
||||
|
||||
def add(self, address: str) -> None:
|
||||
"""Start tracking an address on the shared connection (ref-counted)."""
|
||||
count = self._ref_counts.get(address, 0)
|
||||
self._ref_counts[address] = count + 1
|
||||
if count == 0:
|
||||
self._updated.set()
|
||||
|
||||
def remove(self, address: str) -> None:
|
||||
"""Decrement ref count; drop the subscription once the last caller leaves."""
|
||||
count = self._ref_counts.get(address, 0)
|
||||
if count <= 1:
|
||||
self._ref_counts.pop(address, None)
|
||||
self._updated.set()
|
||||
else:
|
||||
self._ref_counts[address] = count - 1
|
||||
|
||||
def register_queue(
|
||||
self, address: str, queue: "asyncio.Queue[OnchainAddressEvent]"
|
||||
) -> None:
|
||||
"""Register a per-connection queue to receive events for `address`."""
|
||||
self._queues.setdefault(address, []).append(queue)
|
||||
self.add(address)
|
||||
|
||||
def unregister_queue(
|
||||
self, address: str, queue: "asyncio.Queue[OnchainAddressEvent]"
|
||||
) -> None:
|
||||
"""Deregister a per-connection queue for `address`."""
|
||||
queues = self._queues.get(address, [])
|
||||
if queue in queues:
|
||||
queues.remove(queue)
|
||||
if not queues:
|
||||
self._queues.pop(address, None)
|
||||
self.remove(address)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
callback: Callable[[OnchainAddressEvent], Coroutine[Any, Any, None]],
|
||||
is_active: Callable[[], bool],
|
||||
) -> None:
|
||||
while is_active():
|
||||
try:
|
||||
await self._run_once(callback, is_active)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
if not is_active():
|
||||
return
|
||||
logger.warning(f"AddressTracker: {exc!s}, retrying in 5s")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def _run_once(
|
||||
self,
|
||||
callback: Callable[[OnchainAddressEvent], Coroutine[Any, Any, None]],
|
||||
is_active: Callable[[], bool],
|
||||
) -> None:
|
||||
async with ElectrumClient(self.url) as client:
|
||||
subscribed: dict[str, str] = {} # scripthash -> address
|
||||
|
||||
async def on_status_change(params: list[Any]) -> None:
|
||||
if not params:
|
||||
return
|
||||
address = subscribed.get(params[0])
|
||||
if address:
|
||||
await self._fetch_and_dispatch(client, address, params[0], callback)
|
||||
|
||||
client.on("blockchain.scripthash.subscribe", on_status_change)
|
||||
|
||||
while is_active():
|
||||
self._updated.clear()
|
||||
await self._sync_subscriptions(client, subscribed, callback)
|
||||
if await self._wait_for_change_or_close(client):
|
||||
break # connection closed; reconnect
|
||||
|
||||
async def _sync_subscriptions(
|
||||
self,
|
||||
client: ElectrumClient,
|
||||
subscribed: dict[str, str],
|
||||
callback: Callable[[OnchainAddressEvent], Coroutine[Any, Any, None]],
|
||||
) -> None:
|
||||
wanted = {a: scripthash_from_address(a) for a in self._ref_counts}
|
||||
for address, scripthash in wanted.items():
|
||||
if scripthash not in subscribed:
|
||||
subscribed[scripthash] = address
|
||||
await client.subscribe_scripthash(scripthash)
|
||||
await self._fetch_and_dispatch(client, address, scripthash, callback)
|
||||
still_wanted = set(wanted.values())
|
||||
for scripthash, address in list(subscribed.items()):
|
||||
if address not in still_wanted:
|
||||
del subscribed[scripthash]
|
||||
try:
|
||||
await client.unsubscribe_scripthash(scripthash)
|
||||
except ElectrumError:
|
||||
# blockchain.scripthash.unsubscribe is part of the spec but
|
||||
# many ElectrumX deployments don't implement it, returning
|
||||
# "unknown method". This is expected and harmless: we've
|
||||
# already dropped the scripthash from `subscribed` above,
|
||||
# so if the server keeps pushing notifications for it
|
||||
# anyway, on_status_change() looks it up, finds nothing,
|
||||
# and drops them. Not logged since it fires on every
|
||||
# untrack against these servers.
|
||||
pass
|
||||
|
||||
async def _wait_for_change_or_close(self, client: ElectrumClient) -> bool:
|
||||
"""Waits until addresses change or the connection closes; returns True
|
||||
if it was the connection that closed."""
|
||||
wait_task = asyncio.create_task(self._updated.wait())
|
||||
closed_task = asyncio.create_task(client.closed.wait())
|
||||
try:
|
||||
done, _ = await asyncio.wait(
|
||||
[wait_task, closed_task],
|
||||
timeout=30,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
finally:
|
||||
for t in (wait_task, closed_task):
|
||||
if not t.done():
|
||||
t.cancel()
|
||||
return closed_task in done
|
||||
|
||||
async def _fetch_and_dispatch(
|
||||
self,
|
||||
client: ElectrumClient,
|
||||
address: str,
|
||||
scripthash: str,
|
||||
callback: Callable[[OnchainAddressEvent], Coroutine[Any, Any, None]],
|
||||
) -> None:
|
||||
balance_r, history_r, mempool_r = await asyncio.gather(
|
||||
client.get_balance(scripthash),
|
||||
client.get_history(scripthash),
|
||||
client.get_mempool(scripthash),
|
||||
return_exceptions=True,
|
||||
)
|
||||
if isinstance(balance_r, BaseException):
|
||||
raise balance_r
|
||||
history: list[HistoryEntry] = (
|
||||
[] if isinstance(history_r, BaseException) else history_r
|
||||
)
|
||||
history_error: str | None = (
|
||||
str(history_r) if isinstance(history_r, BaseException) else None
|
||||
)
|
||||
if not isinstance(mempool_r, BaseException):
|
||||
seen = {e.tx_hash for e in history}
|
||||
for m in mempool_r:
|
||||
if m.tx_hash not in seen:
|
||||
history.append(HistoryEntry(tx_hash=m.tx_hash, height=0, fee=m.fee))
|
||||
event = OnchainAddressEvent(
|
||||
address=address,
|
||||
confirmed=balance_r.confirmed,
|
||||
unconfirmed=balance_r.unconfirmed,
|
||||
history=history,
|
||||
history_error=history_error,
|
||||
)
|
||||
for q in list(self._queues.get(address, [])):
|
||||
q.put_nowait(event)
|
||||
await callback(event)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transaction tracking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OnchainTxEvent(BaseModel):
|
||||
txid: str
|
||||
confirmed: bool
|
||||
height: int | None = None
|
||||
fee: int | None = None
|
||||
|
||||
|
||||
def tx_watch_scripthash(tx: Transaction) -> str | None:
|
||||
"""Return the scripthash of the first spendable output, used to subscribe
|
||||
for confirmation notifications."""
|
||||
for out in tx.vout:
|
||||
if out.scriptPubKey.type != "nulldata":
|
||||
return scripthash_from_scriptpubkey(bytes.fromhex(out.scriptPubKey.hex))
|
||||
return None
|
||||
|
||||
|
||||
class TransactionTracker:
|
||||
"""
|
||||
Subscribes to a Bitcoin transaction via Electrum and calls a callback on
|
||||
each status change (unconfirmed → confirmed). Stops automatically once
|
||||
the transaction is confirmed or ``is_active()`` returns ``False``.
|
||||
Per-connection queues can be attached via :meth:`register_queue` for
|
||||
consumers (e.g. websockets) that want events for this transaction.
|
||||
|
||||
Args:
|
||||
url: Electrum server URL (e.g. ``ssl://electrum.blockstream.info:50002``).
|
||||
"""
|
||||
|
||||
def __init__(self, url: str) -> None:
|
||||
self.url = url
|
||||
self._queues: list[asyncio.Queue[OnchainTxEvent]] = []
|
||||
|
||||
def register_queue(self, queue: asyncio.Queue[OnchainTxEvent]) -> None:
|
||||
"""Register a per-connection queue to receive events for this tx."""
|
||||
self._queues.append(queue)
|
||||
|
||||
def unregister_queue(self, queue: asyncio.Queue[OnchainTxEvent]) -> None:
|
||||
"""Deregister a per-connection queue."""
|
||||
if queue in self._queues:
|
||||
self._queues.remove(queue)
|
||||
|
||||
def has_queues(self) -> bool:
|
||||
return bool(self._queues)
|
||||
|
||||
async def track(
|
||||
self,
|
||||
txid: str,
|
||||
callback: Callable[[OnchainTxEvent], Coroutine[Any, Any, None]],
|
||||
is_active: Callable[[], bool],
|
||||
) -> None:
|
||||
while is_active():
|
||||
try:
|
||||
confirmed = await self._track_once(txid, callback, is_active)
|
||||
if confirmed:
|
||||
return
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
if not is_active():
|
||||
return
|
||||
logger.warning(
|
||||
f"TransactionTracker {txid[:8]}: {exc!s}, retrying in 5s"
|
||||
)
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def _track_once(
|
||||
self,
|
||||
txid: str,
|
||||
callback: Callable[[OnchainTxEvent], Coroutine[Any, Any, None]],
|
||||
is_active: Callable[[], bool],
|
||||
) -> bool:
|
||||
"""One connection attempt; returns True if the tx is confirmed."""
|
||||
async with ElectrumClient(self.url) as client:
|
||||
try:
|
||||
raw = await client.get_transaction(txid)
|
||||
except ElectrumError as exc:
|
||||
logger.warning(f"TransactionTracker {txid[:8]}: {exc!s}")
|
||||
await asyncio.sleep(10)
|
||||
return False
|
||||
|
||||
scripthash = tx_watch_scripthash(parse_raw_tx(raw))
|
||||
confirmed_event = asyncio.Event()
|
||||
|
||||
async def on_change(
|
||||
params: list[Any],
|
||||
_sh: str | None = scripthash,
|
||||
_done: asyncio.Event = confirmed_event,
|
||||
) -> None:
|
||||
if params and params[0] == _sh:
|
||||
ev = await self._fetch_status(client, txid, _sh)
|
||||
await self._dispatch(ev, callback)
|
||||
if ev.confirmed:
|
||||
_done.set()
|
||||
|
||||
if scripthash:
|
||||
await client.subscribe_scripthash(scripthash, on_change)
|
||||
|
||||
event = await self._fetch_status(client, txid, scripthash)
|
||||
await self._dispatch(event, callback)
|
||||
if event.confirmed:
|
||||
return True
|
||||
|
||||
while is_active() and not confirmed_event.is_set():
|
||||
try:
|
||||
await asyncio.wait_for(client.closed.wait(), timeout=30)
|
||||
break # connection closed; reconnect
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
return confirmed_event.is_set()
|
||||
|
||||
async def _dispatch(
|
||||
self,
|
||||
event: OnchainTxEvent,
|
||||
callback: Callable[[OnchainTxEvent], Coroutine[Any, Any, None]],
|
||||
) -> None:
|
||||
for q in list(self._queues):
|
||||
q.put_nowait(event)
|
||||
await callback(event)
|
||||
|
||||
@staticmethod
|
||||
async def _fetch_status(
|
||||
client: ElectrumClient, txid: str, scripthash: str | None
|
||||
) -> OnchainTxEvent:
|
||||
if scripthash:
|
||||
try:
|
||||
for entry in await client.get_history(scripthash):
|
||||
if entry.tx_hash == txid:
|
||||
return OnchainTxEvent(
|
||||
txid=txid,
|
||||
confirmed=entry.height > 0,
|
||||
height=entry.height if entry.height > 0 else None,
|
||||
fee=entry.fee,
|
||||
)
|
||||
except ElectrumError:
|
||||
try:
|
||||
for m in await client.get_mempool(scripthash):
|
||||
if m.tx_hash == txid:
|
||||
return OnchainTxEvent(txid=txid, confirmed=False, fee=m.fee)
|
||||
return OnchainTxEvent(txid=txid, confirmed=True)
|
||||
except ElectrumError:
|
||||
pass
|
||||
return OnchainTxEvent(txid=txid, confirmed=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Block tracking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BlockTracker:
|
||||
"""
|
||||
Subscribes to new block headers via Electrum and dispatches them to
|
||||
registered queues. Per-connection queues can be attached via
|
||||
:meth:`register_queue`. Reconnects automatically on failure.
|
||||
|
||||
Args:
|
||||
url: Electrum server URL (e.g. ``ssl://electrum.blockstream.info:50002``).
|
||||
"""
|
||||
|
||||
def __init__(self, url: str) -> None:
|
||||
self.url = url
|
||||
self._queues: list[asyncio.Queue[BlockInfo]] = []
|
||||
|
||||
def register_queue(self, queue: "asyncio.Queue[BlockInfo]") -> None:
|
||||
"""Register a per-connection queue to receive new block events."""
|
||||
self._queues.append(queue)
|
||||
|
||||
def unregister_queue(self, queue: "asyncio.Queue[BlockInfo]") -> None:
|
||||
"""Deregister a per-connection queue."""
|
||||
if queue in self._queues:
|
||||
self._queues.remove(queue)
|
||||
|
||||
def has_queues(self) -> bool:
|
||||
return bool(self._queues)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
callback: Callable[[BlockInfo], Coroutine[Any, Any, None]],
|
||||
is_active: Callable[[], bool],
|
||||
) -> None:
|
||||
while is_active():
|
||||
try:
|
||||
await self._run_once(callback, is_active)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
if not is_active():
|
||||
return
|
||||
logger.warning(f"BlockTracker: {exc!s}, retrying in 5s")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def _run_once(
|
||||
self,
|
||||
callback: Callable[[BlockInfo], Coroutine[Any, Any, None]],
|
||||
is_active: Callable[[], bool],
|
||||
) -> None:
|
||||
async with ElectrumClient(self.url) as client:
|
||||
|
||||
async def on_header(params: list[Any]) -> None:
|
||||
h = params[0]
|
||||
event = parse_block_header(h["hex"], h["height"])
|
||||
await self._dispatch(event, callback)
|
||||
|
||||
tip = await client.subscribe_headers(on_header)
|
||||
await self._dispatch(parse_block_header(tip.hex, tip.height), callback)
|
||||
|
||||
while is_active():
|
||||
try:
|
||||
await asyncio.wait_for(client.closed.wait(), timeout=30)
|
||||
break # connection closed; reconnect
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
async def _dispatch(
|
||||
self,
|
||||
event: BlockInfo,
|
||||
callback: Callable[[BlockInfo], Coroutine[Any, Any, None]],
|
||||
) -> None:
|
||||
for q in list(self._queues):
|
||||
q.put_nowait(event)
|
||||
await callback(event)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from .base import (
|
|||
PaymentStatus,
|
||||
StatusResponse,
|
||||
Wallet,
|
||||
payment_request_was_rejected,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -146,6 +147,21 @@ class AlbyWallet(Wallet):
|
|||
return PaymentResponse(
|
||||
ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
logger.warning(exc)
|
||||
rejected = payment_request_was_rejected(exc.response.status_code)
|
||||
try:
|
||||
response_message = exc.response.json().get("message", exc.response.text)
|
||||
except Exception:
|
||||
response_message = exc.response.text
|
||||
return PaymentResponse(
|
||||
ok=False if rejected else None,
|
||||
error_message=(
|
||||
response_message
|
||||
if rejected
|
||||
else f"Unable to connect to {self.endpoint}."
|
||||
),
|
||||
)
|
||||
except KeyError as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(
|
||||
|
|
@ -185,7 +201,7 @@ class AlbyWallet(Wallet):
|
|||
# - https://api.getalby.com/invoices/incoming
|
||||
# - https://api.getalby.com/invoices/outgoing
|
||||
return PaymentStatus(
|
||||
statuses[data.get("state")], fee_msat=None, preimage=None
|
||||
statuses.get(data.get("state")), fee_msat=None, preimage=None
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting invoice status: {e}")
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from .base import (
|
|||
PaymentSuccessStatus,
|
||||
StatusResponse,
|
||||
Wallet,
|
||||
payment_request_was_rejected,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -396,7 +397,7 @@ class BarkWallet(Wallet):
|
|||
logger.warning(message)
|
||||
return self._pending_payment_response(bolt11, checking_id, message)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.is_client_error:
|
||||
if payment_request_was_rejected(exc.response.status_code):
|
||||
return PaymentResponse(
|
||||
ok=False,
|
||||
checking_id=checking_id,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,14 @@ if TYPE_CHECKING:
|
|||
from lnbits.nodes.base import Node
|
||||
|
||||
|
||||
def payment_request_was_rejected(status_code: int) -> bool:
|
||||
"""Return whether HTTP rejected the request before payment dispatch."""
|
||||
# Generic 400 and 422 responses are provider-specific. They can report an
|
||||
# existing payment, so adapters must not treat them as terminal based only
|
||||
# on the status code. Timeouts, conflicts and rate limits are also ambiguous.
|
||||
return status_code in {401, 403, 404, 405}
|
||||
|
||||
|
||||
class Feature(Enum):
|
||||
nodemanager = "nodemanager"
|
||||
holdinvoice = "holdinvoice"
|
||||
|
|
|
|||
|
|
@ -164,9 +164,82 @@ class BlinkWallet(Wallet):
|
|||
ok=False, error_message=f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
|
||||
async def _fee_probe(self, bolt11: str) -> tuple[int | None, str | None]:
|
||||
"""
|
||||
Probe the route for the fee of an amount lightning invoice.
|
||||
|
||||
Probing caches the route on the Blink backend so that the subsequent
|
||||
payment settles with the exact fee instead of Blink's max fee reserve.
|
||||
Only invoices that carry an amount can be probed here, since the
|
||||
pay_invoice interface does not provide a separate amount for
|
||||
zero-amount invoices.
|
||||
|
||||
Returns a tuple of (fee_sat, error_message). On success the fee in
|
||||
satoshis is returned; on failure an error message is returned.
|
||||
"""
|
||||
probe_input = {
|
||||
"paymentRequest": bolt11,
|
||||
"walletId": self.wallet_id,
|
||||
}
|
||||
data = {"query": q.fee_probe_query, "variables": {"input": probe_input}}
|
||||
response = await self._graphql_query(data)
|
||||
|
||||
errors = response.get("errors") or []
|
||||
if len(errors) > 0:
|
||||
return None, errors[0].get("message") or "Fee probe failed."
|
||||
|
||||
result = (response.get("data") or {}).get("lnInvoiceFeeProbe") or {}
|
||||
|
||||
errors = result.get("errors") or []
|
||||
if len(errors) > 0:
|
||||
return None, errors[0].get("message") or "Fee probe failed."
|
||||
|
||||
fee_sat = result.get("amount")
|
||||
if fee_sat is None:
|
||||
return None, "Server error: 'missing fee probe amount'"
|
||||
|
||||
return fee_sat, None
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
# https://dev.blink.sv/api/btc-ln-send
|
||||
# Future: add check fee estimate is < fee_limit_msat before paying invoice
|
||||
|
||||
invoice = bolt11_lib.decode(bolt11)
|
||||
|
||||
# Only amount invoices can be probed: lnInvoiceFeeProbe takes no amount,
|
||||
# and zero-amount invoices have no amount to probe with (pay_invoice
|
||||
# does not receive a separate amount). Zero-amount invoices skip the
|
||||
# probe and fall back to the send-without-probe behaviour.
|
||||
try:
|
||||
if invoice.amount_msat:
|
||||
fee_sat, probe_error = await self._fee_probe(bolt11)
|
||||
if probe_error is not None:
|
||||
if not settings.blink_send_without_probe:
|
||||
logger.info(f"Fee probe failed for invoice {bolt11}")
|
||||
return PaymentResponse(ok=False, error_message=probe_error)
|
||||
logger.warning(
|
||||
f"Fee probe failed ('{probe_error}'), "
|
||||
"sending payment without probe."
|
||||
)
|
||||
elif fee_sat is not None and fee_sat * 1000 > fee_limit_msat:
|
||||
error_message = (
|
||||
f"fee of {fee_sat * 1000} msat exceeds "
|
||||
f"limit of {fee_limit_msat} msat"
|
||||
)
|
||||
return PaymentResponse(ok=False, error_message=error_message)
|
||||
elif not settings.blink_send_without_probe:
|
||||
logger.info(f"Cannot probe zero-amount invoice {bolt11}")
|
||||
return PaymentResponse(
|
||||
ok=False,
|
||||
error_message="Cannot probe fee for zero-amount invoice.",
|
||||
)
|
||||
except Exception as exc:
|
||||
if not settings.blink_send_without_probe:
|
||||
logger.info(f"Failed to probe fee for invoice {bolt11}")
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(
|
||||
ok=False, error_message=f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
logger.warning(f"Fee probe errored ('{exc}'), sending without probe.")
|
||||
|
||||
payment_variables = {
|
||||
"input": {
|
||||
|
|
@ -179,22 +252,26 @@ class BlinkWallet(Wallet):
|
|||
try:
|
||||
response = await self._graphql_query(data)
|
||||
|
||||
errors = (
|
||||
response.get("data", {})
|
||||
.get("lnInvoicePaymentSend", {})
|
||||
.get("errors", {})
|
||||
)
|
||||
payment_result = response.get("data", {}).get("lnInvoicePaymentSend", {})
|
||||
errors = payment_result.get("errors", {})
|
||||
if len(errors) > 0:
|
||||
error_message = errors[0].get("message")
|
||||
return PaymentResponse(ok=False, error_message=error_message)
|
||||
status = payment_result.get("status")
|
||||
return PaymentResponse(
|
||||
ok=False if status in {"FAILURE", "FAILED"} else None,
|
||||
error_message=error_message,
|
||||
)
|
||||
|
||||
checking_id = bolt11_lib.decode(bolt11).payment_hash
|
||||
checking_id = invoice.payment_hash
|
||||
|
||||
payment_status = await self.get_payment_status(checking_id)
|
||||
fee_msat = payment_status.fee_msat
|
||||
preimage = payment_status.preimage
|
||||
return PaymentResponse(
|
||||
ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage
|
||||
ok=payment_status.paid,
|
||||
checking_id=checking_id,
|
||||
fee_msat=fee_msat,
|
||||
preimage=preimage,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.info(f"Failed to pay invoice {bolt11}")
|
||||
|
|
@ -365,6 +442,7 @@ class BlinkGrafqlQueries(BaseModel):
|
|||
balance_query: str
|
||||
invoice_query: str
|
||||
payment_query: str
|
||||
fee_probe_query: str
|
||||
status_query: str
|
||||
wallet_query: str
|
||||
tx_query: str
|
||||
|
|
@ -413,6 +491,16 @@ q = BlinkGrafqlQueries(
|
|||
}
|
||||
}
|
||||
""",
|
||||
fee_probe_query="""
|
||||
mutation LnInvoiceFeeProbe($input: LnInvoiceFeeProbeInput!) {
|
||||
lnInvoiceFeeProbe(input: $input) {
|
||||
amount
|
||||
errors {
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
""",
|
||||
status_query="""
|
||||
query InvoiceByPaymentHash($walletId: WalletId!, $paymentHash: PaymentHash!) {
|
||||
me {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import asyncio
|
|||
from collections.abc import AsyncGenerator
|
||||
|
||||
from bolt11.decode import decode
|
||||
from bolt11.types import Bolt11
|
||||
from grpc import StatusCode
|
||||
from grpc.aio import AioRpcError
|
||||
from loguru import logger
|
||||
|
||||
|
|
@ -124,26 +126,12 @@ class BoltzWallet(Wallet):
|
|||
)
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
prepared = await self._prepare_payment(bolt11, fee_limit_msat)
|
||||
if isinstance(prepared, PaymentResponse):
|
||||
return prepared
|
||||
pair, invoice = prepared
|
||||
|
||||
pair = boltzrpc_pb2.Pair(**{"from": boltzrpc_pb2.LBTC})
|
||||
try:
|
||||
pair_info: boltzrpc_pb2.PairInfo
|
||||
pair_request = boltzrpc_pb2.GetPairInfoRequest(
|
||||
type=boltzrpc_pb2.SUBMARINE, pair=pair
|
||||
)
|
||||
pair_info = await self.rpc.GetPairInfo(pair_request, metadata=self.metadata)
|
||||
invoice = decode(bolt11)
|
||||
|
||||
if not invoice.amount_msat:
|
||||
raise ValueError("amountless invoice")
|
||||
|
||||
service_fee: float = invoice.amount_msat * pair_info.fees.percentage / 100
|
||||
estimate = int(service_fee + pair_info.fees.miner_fees * 1000)
|
||||
if estimate > fee_limit_msat:
|
||||
error = f"fee of {estimate} msat exceeds limit of {fee_limit_msat} msat"
|
||||
|
||||
return PaymentResponse(ok=False, error_message=error)
|
||||
|
||||
request = boltzrpc_pb2.CreateSwapRequest(
|
||||
invoice=bolt11,
|
||||
pair=pair,
|
||||
|
|
@ -165,8 +153,13 @@ class BoltzWallet(Wallet):
|
|||
)
|
||||
return PaymentResponse(ok=True, checking_id=invoice.payment_hash)
|
||||
except AioRpcError as exc:
|
||||
return await self._resolve_create_swap_error(invoice, exc)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(ok=False, error_message=exc.details())
|
||||
return PaymentResponse(
|
||||
checking_id=invoice.payment_hash,
|
||||
error_message=str(exc),
|
||||
)
|
||||
|
||||
try:
|
||||
info_request = boltzrpc_pb2.GetSwapInfoRequest(id=response.id)
|
||||
|
|
@ -186,14 +179,87 @@ class BoltzWallet(Wallet):
|
|||
fee_msat=fee_msat,
|
||||
preimage=info.swap.preimage,
|
||||
)
|
||||
elif info.swap.error != "":
|
||||
return PaymentResponse(ok=False, error_message=info.swap.error)
|
||||
return PaymentResponse(
|
||||
ok=False, error_message="stream stopped unexpectedly"
|
||||
)
|
||||
if info.swap.state in {
|
||||
boltzrpc_pb2.ERROR,
|
||||
boltzrpc_pb2.SERVER_ERROR,
|
||||
boltzrpc_pb2.REFUNDED,
|
||||
boltzrpc_pb2.ABANDONED,
|
||||
}:
|
||||
return PaymentResponse(
|
||||
ok=False,
|
||||
checking_id=invoice.payment_hash,
|
||||
error_message=info.swap.error or "swap failed",
|
||||
)
|
||||
return PaymentResponse(error_message="stream stopped unexpectedly")
|
||||
except AioRpcError as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(ok=False, error_message=exc.details())
|
||||
return PaymentResponse(error_message=exc.details())
|
||||
|
||||
async def _resolve_create_swap_error(
|
||||
self, invoice: Bolt11, exc: AioRpcError
|
||||
) -> PaymentResponse:
|
||||
logger.warning(exc)
|
||||
if _is_pre_dispatch_create_swap_error(exc):
|
||||
status: PaymentStatus = PaymentFailedStatus()
|
||||
else:
|
||||
try:
|
||||
status = await self.get_payment_status(invoice.payment_hash)
|
||||
except Exception as status_exc:
|
||||
logger.warning(status_exc)
|
||||
status = PaymentPendingStatus()
|
||||
|
||||
return PaymentResponse(
|
||||
ok=status.paid,
|
||||
checking_id=invoice.payment_hash,
|
||||
fee_msat=status.fee_msat,
|
||||
preimage=status.preimage,
|
||||
error_message=exc.details(),
|
||||
)
|
||||
|
||||
async def _prepare_payment(
|
||||
self, bolt11: str, fee_limit_msat: int
|
||||
) -> tuple[boltzrpc_pb2.Pair, Bolt11] | PaymentResponse:
|
||||
pair = boltzrpc_pb2.Pair(**{"from": boltzrpc_pb2.LBTC})
|
||||
try:
|
||||
invoice = decode(bolt11)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(
|
||||
ok=False,
|
||||
error_message=f"invalid bolt11 invoice: {exc}",
|
||||
)
|
||||
|
||||
if not invoice.amount_msat:
|
||||
return PaymentResponse(
|
||||
ok=False,
|
||||
checking_id=invoice.payment_hash,
|
||||
error_message="amountless invoice",
|
||||
)
|
||||
|
||||
try:
|
||||
pair_info: boltzrpc_pb2.PairInfo
|
||||
pair_request = boltzrpc_pb2.GetPairInfoRequest(
|
||||
type=boltzrpc_pb2.SUBMARINE, pair=pair
|
||||
)
|
||||
pair_info = await self.rpc.GetPairInfo(pair_request, metadata=self.metadata)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(
|
||||
ok=False,
|
||||
checking_id=invoice.payment_hash,
|
||||
error_message=f"unable to get swap terms: {exc}",
|
||||
)
|
||||
|
||||
service_fee: float = invoice.amount_msat * pair_info.fees.percentage / 100
|
||||
estimate = int(service_fee + pair_info.fees.miner_fees * 1000)
|
||||
if estimate > fee_limit_msat:
|
||||
error = f"fee of {estimate} msat exceeds limit of {fee_limit_msat} msat"
|
||||
return PaymentResponse(
|
||||
ok=False,
|
||||
checking_id=invoice.payment_hash,
|
||||
error_message=error,
|
||||
)
|
||||
return pair, invoice
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
try:
|
||||
|
|
@ -217,10 +283,14 @@ class BoltzWallet(Wallet):
|
|||
fee_msat=fee_msat,
|
||||
preimage=swap.preimage,
|
||||
)
|
||||
elif swap.state == boltzrpc_pb2.SwapState.PENDING:
|
||||
return PaymentPendingStatus()
|
||||
|
||||
return PaymentFailedStatus()
|
||||
if swap.state in {
|
||||
boltzrpc_pb2.SwapState.ERROR,
|
||||
boltzrpc_pb2.SwapState.SERVER_ERROR,
|
||||
boltzrpc_pb2.SwapState.REFUNDED,
|
||||
boltzrpc_pb2.SwapState.ABANDONED,
|
||||
}:
|
||||
return PaymentFailedStatus()
|
||||
return PaymentPendingStatus()
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
try:
|
||||
|
|
@ -231,7 +301,7 @@ class BoltzWallet(Wallet):
|
|||
metadata=self.metadata,
|
||||
)
|
||||
swap = response.swap
|
||||
except AioRpcError as exc:
|
||||
except (AioRpcError, ValueError) as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentPendingStatus()
|
||||
if swap.state == boltzrpc_pb2.SwapState.SUCCESSFUL:
|
||||
|
|
@ -243,10 +313,14 @@ class BoltzWallet(Wallet):
|
|||
fee_msat=fee_msat,
|
||||
preimage=swap.preimage,
|
||||
)
|
||||
elif swap.state == boltzrpc_pb2.SwapState.PENDING:
|
||||
return PaymentPendingStatus()
|
||||
|
||||
return PaymentFailedStatus()
|
||||
if swap.state in {
|
||||
boltzrpc_pb2.SwapState.ERROR,
|
||||
boltzrpc_pb2.SwapState.SERVER_ERROR,
|
||||
boltzrpc_pb2.SwapState.REFUNDED,
|
||||
boltzrpc_pb2.SwapState.ABANDONED,
|
||||
}:
|
||||
return PaymentFailedStatus()
|
||||
return PaymentPendingStatus()
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
while settings.lnbits_running:
|
||||
|
|
@ -352,3 +426,23 @@ class BoltzWallet(Wallet):
|
|||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to create Boltz wallet: {e}")
|
||||
|
||||
|
||||
_PRE_DISPATCH_CREATE_SWAP_ERROR_CODES = {
|
||||
StatusCode.INVALID_ARGUMENT,
|
||||
StatusCode.PERMISSION_DENIED,
|
||||
StatusCode.UNAUTHENTICATED,
|
||||
}
|
||||
_PRE_DISPATCH_CREATE_SWAP_ERROR_MESSAGES = (
|
||||
"boltz error: could not find route to pay invoice",
|
||||
)
|
||||
|
||||
|
||||
def _is_pre_dispatch_create_swap_error(exc: AioRpcError) -> bool:
|
||||
if exc.code() in _PRE_DISPATCH_CREATE_SWAP_ERROR_CODES:
|
||||
return True
|
||||
|
||||
details = (exc.details() or "").lower()
|
||||
return any(
|
||||
message in details for message in _PRE_DISPATCH_CREATE_SWAP_ERROR_MESSAGES
|
||||
)
|
||||
|
|
|
|||
|
|
@ -240,6 +240,12 @@ else:
|
|||
logger.info(ex)
|
||||
return PaymentResponse(error_message=f"exception while payment {exc!s}")
|
||||
|
||||
if payment.status == BreezPaymentStatus.FAILED:
|
||||
return PaymentResponse(
|
||||
ok=False,
|
||||
checking_id=invoice.payment_hash,
|
||||
error_message="payment failed",
|
||||
)
|
||||
if payment.status != BreezPaymentStatus.COMPLETE:
|
||||
return PaymentResponse(ok=None, error_message="payment is pending")
|
||||
|
||||
|
|
|
|||
|
|
@ -173,29 +173,44 @@ else:
|
|||
async def pay_invoice(
|
||||
self, bolt11: str, fee_limit_msat: int
|
||||
) -> PaymentResponse:
|
||||
invoice_data = bolt11_decode(bolt11)
|
||||
try:
|
||||
invoice_data = bolt11_decode(bolt11)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(
|
||||
ok=False,
|
||||
error_message=f"invalid bolt11 invoice: {exc}",
|
||||
)
|
||||
|
||||
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
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(
|
||||
ok=False,
|
||||
checking_id=invoice_data.payment_hash,
|
||||
error_message=f"unable to prepare payment: {exc}",
|
||||
)
|
||||
|
||||
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"
|
||||
),
|
||||
)
|
||||
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,
|
||||
checking_id=invoice_data.payment_hash,
|
||||
error_message=(
|
||||
f"fee of {req.fees_sat} sat exceeds limit of "
|
||||
f"{fee_limit_sat} sat"
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
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}")
|
||||
|
|
@ -206,6 +221,14 @@ else:
|
|||
|
||||
fees = req.fees_sat * 1000 if req.fees_sat and req.fees_sat > 0 else 0
|
||||
|
||||
if payment.status in {PaymentState.FAILED, PaymentState.TIMED_OUT}:
|
||||
return PaymentResponse(
|
||||
ok=False,
|
||||
checking_id=checking_id,
|
||||
fee_msat=fees,
|
||||
error_message=f"payment {payment.status!s}",
|
||||
)
|
||||
|
||||
if payment.status != PaymentState.COMPLETE:
|
||||
return await self._wait_for_outgoing_payment(checking_id, fees, 10)
|
||||
|
||||
|
|
@ -262,7 +285,10 @@ else:
|
|||
fee_msat=int(payment.fees_sat * 1000),
|
||||
preimage=payment.details.preimage,
|
||||
)
|
||||
if payment.status == PaymentState.FAILED:
|
||||
if payment.status in {
|
||||
PaymentState.FAILED,
|
||||
PaymentState.TIMED_OUT,
|
||||
}:
|
||||
return PaymentFailedStatus()
|
||||
return PaymentPendingStatus()
|
||||
except Exception as exc:
|
||||
|
|
|
|||
|
|
@ -104,41 +104,42 @@ class ClicheWallet(Wallet):
|
|||
)
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
ws = create_connection(self.endpoint)
|
||||
ws.send(f"pay-invoice --invoice {bolt11}")
|
||||
checking_id, fee_msat, preimage, payment_ok = (
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
for _ in range(2):
|
||||
r = ws.recv()
|
||||
data = json.loads(r)
|
||||
try:
|
||||
ws = create_connection(self.endpoint)
|
||||
ws.send(f"pay-invoice --invoice {bolt11}")
|
||||
checking_id, fee_msat, preimage, payment_ok = (
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
for _ in range(2):
|
||||
r = ws.recv()
|
||||
data = json.loads(r)
|
||||
|
||||
if data.get("error") is not None:
|
||||
error_message = data["error"].get("message")
|
||||
return PaymentResponse(ok=False, error_message=error_message)
|
||||
if data.get("error") is not None:
|
||||
error_message = data["error"].get("message")
|
||||
return PaymentResponse(error_message=error_message)
|
||||
|
||||
if data.get("method") == "payment_succeeded":
|
||||
payment_ok = True
|
||||
checking_id = data["params"]["payment_hash"]
|
||||
fee_msat = data["params"]["fee_msatoshi"]
|
||||
preimage = data["params"]["preimage"]
|
||||
continue
|
||||
if data.get("method") == "payment_succeeded":
|
||||
payment_ok = True
|
||||
checking_id = data["params"]["payment_hash"]
|
||||
fee_msat = data["params"]["fee_msatoshi"]
|
||||
preimage = data["params"]["preimage"]
|
||||
continue
|
||||
|
||||
if data.get("result") is None:
|
||||
return PaymentResponse(error_message="result is None")
|
||||
if data.get("result") is None:
|
||||
return PaymentResponse(error_message="result is None")
|
||||
|
||||
return PaymentResponse(
|
||||
ok=payment_ok, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage
|
||||
)
|
||||
return PaymentResponse(
|
||||
ok=payment_ok,
|
||||
checking_id=checking_id,
|
||||
fee_msat=fee_msat,
|
||||
preimage=preimage,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(error_message=f"Unable to query {self.endpoint}.")
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
ws = create_connection(self.endpoint)
|
||||
|
|
@ -154,21 +155,25 @@ class ClicheWallet(Wallet):
|
|||
return PaymentStatus(statuses[data["result"]["status"]])
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
ws = create_connection(self.endpoint)
|
||||
ws.send(f"check-payment --hash {checking_id}")
|
||||
r = ws.recv()
|
||||
data = json.loads(r)
|
||||
try:
|
||||
ws = create_connection(self.endpoint)
|
||||
ws.send(f"check-payment --hash {checking_id}")
|
||||
r = ws.recv()
|
||||
data = json.loads(r)
|
||||
|
||||
if data.get("error") is not None and data["error"].get("message"):
|
||||
logger.error(data["error"]["message"])
|
||||
if data.get("error") is not None and data["error"].get("message"):
|
||||
logger.error(data["error"]["message"])
|
||||
return PaymentPendingStatus()
|
||||
payment = data["result"]
|
||||
statuses = {"pending": None, "complete": True, "failed": False}
|
||||
return PaymentStatus(
|
||||
statuses.get(payment.get("status")),
|
||||
payment.get("fee_msatoshi"),
|
||||
payment.get("preimage"),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentPendingStatus()
|
||||
payment = data["result"]
|
||||
statuses = {"pending": None, "complete": True, "failed": False}
|
||||
return PaymentStatus(
|
||||
statuses[payment["status"]],
|
||||
payment.get("fee_msatoshi"),
|
||||
payment.get("preimage"),
|
||||
)
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
while settings.lnbits_running:
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from lnbits.utils.crypto import random_secret_and_hash
|
|||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
PaymentFailedStatus,
|
||||
PaymentPendingStatus,
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
|
|
@ -379,9 +380,12 @@ class CLNRestWallet(Wallet):
|
|||
|
||||
pay = pays_list[-1]
|
||||
|
||||
if pay["status"] == "complete":
|
||||
status = pay.get("status")
|
||||
if status == "complete":
|
||||
fee_msat = pay["amount_sent_msat"] - pay["amount_msat"]
|
||||
return PaymentSuccessStatus(fee_msat=fee_msat, preimage=pay["preimage"])
|
||||
if status == "failed":
|
||||
return PaymentFailedStatus()
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning(f"Error getting payment status: {exc}")
|
||||
|
|
|
|||
|
|
@ -31,6 +31,21 @@ async def run_sync(func) -> Any:
|
|||
return await loop.run_in_executor(None, func)
|
||||
|
||||
|
||||
def _all_payment_attempts_failed(error: object) -> bool:
|
||||
if not isinstance(error, dict):
|
||||
return False
|
||||
|
||||
attempts = error.get("attempts")
|
||||
return (
|
||||
isinstance(attempts, list)
|
||||
and bool(attempts)
|
||||
and all(
|
||||
isinstance(attempt, dict) and attempt.get("status") == "failed"
|
||||
for attempt in attempts
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class CoreLightningWallet(Wallet):
|
||||
"""Core Lightning RPC implementation."""
|
||||
|
||||
|
|
@ -184,7 +199,9 @@ class CoreLightningWallet(Wallet):
|
|||
logger.warning(exc)
|
||||
try:
|
||||
error_code = exc.error.get("code") # type: ignore
|
||||
if error_code in self.pay_failure_error_codes:
|
||||
if error_code in self.pay_failure_error_codes or (
|
||||
_all_payment_attempts_failed(exc.error)
|
||||
):
|
||||
error_message = exc.error.get("message", error_code) # type: ignore
|
||||
return PaymentResponse(
|
||||
ok=False, error_message=f"Payment failed: {error_message}"
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from .base import (
|
|||
PaymentStatus,
|
||||
StatusResponse,
|
||||
Wallet,
|
||||
payment_request_was_rejected,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -165,6 +166,24 @@ class EclairWallet(Wallet):
|
|||
checking_id = data["paymentHash"]
|
||||
preimage = data["paymentPreimage"]
|
||||
|
||||
except httpx.HTTPStatusError as exc:
|
||||
error_message = f"Unable to connect to {self.url}."
|
||||
try:
|
||||
error_data = exc.response.json()
|
||||
if isinstance(error_data, dict) and error_data.get("error"):
|
||||
error_message = str(error_data["error"])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Eclair uses HTTP 400 for invoice and form validation failures,
|
||||
# which happen before it dispatches the payment.
|
||||
rejected = exc.response.status_code == 400 or payment_request_was_rejected(
|
||||
exc.response.status_code
|
||||
)
|
||||
return PaymentResponse(
|
||||
ok=False if rejected else None,
|
||||
error_message=error_message,
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
return PaymentResponse(
|
||||
error_message="Server error: 'invalid json response'"
|
||||
|
|
|
|||
|
|
@ -198,9 +198,15 @@ class LndWallet(Wallet):
|
|||
)
|
||||
try:
|
||||
res: Payment = await self.router_rpc.SendPaymentV2(req).read()
|
||||
except grpc.aio.AioRpcError as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(
|
||||
ok=False if _is_pre_dispatch_payment_error(exc) else None,
|
||||
error_message=exc.details() or str(exc),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(error_message=str(exc))
|
||||
return PaymentResponse(ok=None, error_message=str(exc))
|
||||
|
||||
if res.status == Payment.PaymentStatus.SUCCEEDED:
|
||||
return PaymentResponse(
|
||||
|
|
@ -378,3 +384,22 @@ class LndWallet(Wallet):
|
|||
)
|
||||
# If we reach here, the invoice was successfully canceled and payment failed
|
||||
return InvoiceResponse(True, checking_id=payment_hash)
|
||||
|
||||
|
||||
_PRE_DISPATCH_PAYMENT_ERROR_CODES = {
|
||||
grpc.StatusCode.INVALID_ARGUMENT,
|
||||
grpc.StatusCode.PERMISSION_DENIED,
|
||||
grpc.StatusCode.UNAUTHENTICATED,
|
||||
}
|
||||
_PRE_DISPATCH_PAYMENT_ERROR_MESSAGES = (
|
||||
"invoice not for current active network",
|
||||
"invoice expired",
|
||||
)
|
||||
|
||||
|
||||
def _is_pre_dispatch_payment_error(exc: grpc.aio.AioRpcError) -> bool:
|
||||
if exc.code() in _PRE_DISPATCH_PAYMENT_ERROR_CODES:
|
||||
return True
|
||||
|
||||
details = (exc.details() or "").lower()
|
||||
return any(message in details for message in _PRE_DISPATCH_PAYMENT_ERROR_MESSAGES)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,42 @@ from .base import (
|
|||
)
|
||||
from .macaroon import load_macaroon
|
||||
|
||||
_PRE_DISPATCH_PAYMENT_ERROR_CODES = {3, 7, 16}
|
||||
_PRE_DISPATCH_PAYMENT_ERROR_MESSAGES = (
|
||||
"invoice not for current active network",
|
||||
"invoice expired",
|
||||
)
|
||||
|
||||
|
||||
def _is_pre_dispatch_payment_error(code: int | None, message: str) -> bool:
|
||||
# LND's REST gateway uses the numeric gRPC status codes. UNKNOWN (2)
|
||||
# is ambiguous unless LND returned one of its request-validation errors.
|
||||
if code in _PRE_DISPATCH_PAYMENT_ERROR_CODES:
|
||||
return True
|
||||
|
||||
message = message.lower()
|
||||
return any(error in message for error in _PRE_DISPATCH_PAYMENT_ERROR_MESSAGES)
|
||||
|
||||
|
||||
def _payment_response_from_http_error(
|
||||
exc: httpx.HTTPStatusError, endpoint: str
|
||||
) -> PaymentResponse:
|
||||
try:
|
||||
error = exc.response.json()["error"]
|
||||
error_code = error.get("code")
|
||||
error_message = str(error.get("message") or exc)
|
||||
except (json.JSONDecodeError, KeyError, TypeError, AttributeError):
|
||||
error_code = None
|
||||
error_message = f"Unable to connect to {endpoint}."
|
||||
|
||||
logger.warning(f"LndRestWallet pay_invoice POST error: {error_message}.")
|
||||
return PaymentResponse(
|
||||
ok=(
|
||||
False if _is_pre_dispatch_payment_error(error_code, error_message) else None
|
||||
),
|
||||
error_message=error_message,
|
||||
)
|
||||
|
||||
|
||||
class LndRestWallet(Wallet):
|
||||
"""https://api.lightning.community/#lnd-rest-api-reference"""
|
||||
|
|
@ -162,6 +198,8 @@ class LndRestWallet(Wallet):
|
|||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
return _payment_response_from_http_error(exc, self.endpoint)
|
||||
except json.JSONDecodeError:
|
||||
return PaymentResponse(
|
||||
error_message="Server error: 'invalid json response'"
|
||||
|
|
@ -201,7 +239,7 @@ class LndRestWallet(Wallet):
|
|||
elif status == "IN_FLIGHT":
|
||||
return PaymentResponse(ok=None, checking_id=checking_id)
|
||||
return PaymentResponse(
|
||||
ok=False,
|
||||
ok=None,
|
||||
checking_id=checking_id,
|
||||
error_message="Server error: 'unknown payment status returned'",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from .base import (
|
|||
PaymentStatus,
|
||||
StatusResponse,
|
||||
Wallet,
|
||||
payment_request_was_rejected,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -105,44 +106,58 @@ class LNPayWallet(Wallet):
|
|||
)
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
r = await self.client.post(
|
||||
f"/wallet/{self.wallet_key}/withdraw",
|
||||
json={"payment_request": bolt11},
|
||||
timeout=None,
|
||||
)
|
||||
try:
|
||||
r = await self.client.post(
|
||||
f"/wallet/{self.wallet_key}/withdraw",
|
||||
json={"payment_request": bolt11},
|
||||
timeout=None,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(error_message="Unable to connect to LNPay.")
|
||||
|
||||
try:
|
||||
data = r.json()
|
||||
except Exception:
|
||||
return PaymentResponse(ok=False, error_message="Got invalid JSON.")
|
||||
return PaymentResponse(error_message="Got invalid JSON.")
|
||||
|
||||
if r.is_error:
|
||||
return PaymentResponse(ok=False, error_message=data["message"])
|
||||
return PaymentResponse(
|
||||
ok=False if payment_request_was_rejected(r.status_code) else None,
|
||||
error_message=data.get("message", r.text),
|
||||
)
|
||||
|
||||
checking_id = data["lnTx"]["id"]
|
||||
fee_msat = 0
|
||||
preimage = data["lnTx"]["payment_preimage"]
|
||||
try:
|
||||
checking_id = data["lnTx"]["id"]
|
||||
preimage = data["lnTx"]["payment_preimage"]
|
||||
except (KeyError, TypeError):
|
||||
return PaymentResponse(
|
||||
error_message="LNPay response is missing required payment fields."
|
||||
)
|
||||
return PaymentResponse(
|
||||
ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage
|
||||
ok=True, checking_id=checking_id, fee_msat=0, preimage=preimage
|
||||
)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
return await self.get_payment_status(checking_id)
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
r = await self.client.get(
|
||||
url=f"/lntx/{checking_id}",
|
||||
)
|
||||
try:
|
||||
r = await self.client.get(
|
||||
url=f"/lntx/{checking_id}",
|
||||
)
|
||||
if r.is_error:
|
||||
return PaymentPendingStatus()
|
||||
|
||||
if r.is_error:
|
||||
data = r.json()
|
||||
paid = {0: None, 1: True, -1: False}.get(data.get("settled"))
|
||||
return PaymentStatus(
|
||||
paid, data.get("fee_msat"), data.get("payment_preimage")
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentPendingStatus()
|
||||
|
||||
data = r.json()
|
||||
preimage = data["payment_preimage"]
|
||||
fee_msat = data["fee_msat"]
|
||||
statuses = {0: None, 1: True, -1: False}
|
||||
return PaymentStatus(statuses[data["settled"]], fee_msat, preimage)
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
self.queue: asyncio.Queue = asyncio.Queue(0)
|
||||
while settings.lnbits_running:
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from .base import (
|
|||
PaymentStatus,
|
||||
StatusResponse,
|
||||
Wallet,
|
||||
payment_request_was_rejected,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -103,26 +104,43 @@ class LnTipsWallet(Wallet):
|
|||
)
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
r = await self.client.post(
|
||||
"/api/v1/payinvoice",
|
||||
json={"pay_req": bolt11},
|
||||
timeout=None,
|
||||
)
|
||||
try:
|
||||
r = await self.client.post(
|
||||
"/api/v1/payinvoice",
|
||||
json={"pay_req": bolt11},
|
||||
timeout=None,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(
|
||||
error_message=f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
if r.is_error:
|
||||
return PaymentResponse(ok=False, error_message=r.text)
|
||||
return PaymentResponse(
|
||||
ok=False if payment_request_was_rejected(r.status_code) else None,
|
||||
error_message=r.text,
|
||||
)
|
||||
|
||||
if "error" in r.json():
|
||||
try:
|
||||
data = r.json()
|
||||
error_message = data["error"]
|
||||
except Exception:
|
||||
error_message = r.text
|
||||
return PaymentResponse(ok=False, error_message=error_message)
|
||||
try:
|
||||
response = r.json()
|
||||
except json.JSONDecodeError:
|
||||
return PaymentResponse(
|
||||
error_message="Server error: 'invalid json response'"
|
||||
)
|
||||
|
||||
data = r.json()["details"]
|
||||
checking_id = data["payment_hash"]
|
||||
fee_msat = -data["fee"]
|
||||
preimage = data["preimage"]
|
||||
if "error" in response:
|
||||
error_message = response.get("error") or r.text
|
||||
return PaymentResponse(error_message=error_message)
|
||||
|
||||
try:
|
||||
data = response["details"]
|
||||
checking_id = data["payment_hash"]
|
||||
fee_msat = -data["fee"]
|
||||
preimage = data["preimage"]
|
||||
except (KeyError, TypeError):
|
||||
return PaymentResponse(
|
||||
error_message="Server error: 'missing required fields'"
|
||||
)
|
||||
return PaymentResponse(
|
||||
ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage
|
||||
)
|
||||
|
|
|
|||
|
|
@ -625,8 +625,6 @@ class NWCWallet(Wallet):
|
|||
"QUOTA_EXCEEDED",
|
||||
"RESTRICTED",
|
||||
"UNAUTHORIZED",
|
||||
"INTERNAL",
|
||||
"OTHER",
|
||||
"PAYMENT_FAILED",
|
||||
]
|
||||
failed = e.code in failure_codes
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from .base import (
|
|||
PaymentStatus,
|
||||
StatusResponse,
|
||||
Wallet,
|
||||
payment_request_was_rejected,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -100,24 +101,38 @@ class OpenNodeWallet(Wallet):
|
|||
)
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
r = await self.client.post(
|
||||
"/v2/withdrawals",
|
||||
json={"type": "ln", "address": bolt11},
|
||||
timeout=None,
|
||||
)
|
||||
try:
|
||||
r = await self.client.post(
|
||||
"/v2/withdrawals",
|
||||
json={"type": "ln", "address": bolt11},
|
||||
timeout=None,
|
||||
)
|
||||
|
||||
if r.is_error:
|
||||
error_message = r.json()["message"]
|
||||
logger.warning(error_message)
|
||||
return PaymentResponse(ok=None, error_message=error_message)
|
||||
if r.is_error:
|
||||
error_message = r.json().get("message", r.text)
|
||||
logger.warning(error_message)
|
||||
return PaymentResponse(
|
||||
ok=(False if payment_request_was_rejected(r.status_code) else None),
|
||||
error_message=error_message,
|
||||
)
|
||||
|
||||
data = r.json()["data"]
|
||||
checking_id = data["id"]
|
||||
fee_msat = -data["fee"] * 1000
|
||||
# pending
|
||||
if data["status"] != "paid":
|
||||
data = r.json()["data"]
|
||||
checking_id = data.get("id")
|
||||
fee = data.get("fee")
|
||||
fee_msat = -fee * 1000 if fee is not None else None
|
||||
status = str(data.get("status", "")).lower()
|
||||
if status in {"paid", "confirmed"}:
|
||||
return PaymentResponse(
|
||||
ok=True, checking_id=checking_id, fee_msat=fee_msat
|
||||
)
|
||||
if status in {"error", "failed"}:
|
||||
return PaymentResponse(
|
||||
ok=False, checking_id=checking_id, fee_msat=fee_msat
|
||||
)
|
||||
return PaymentResponse(ok=None, checking_id=checking_id, fee_msat=fee_msat)
|
||||
return PaymentResponse(ok=True, checking_id=checking_id, fee_msat=fee_msat)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(error_message="Invalid OpenNode payment response.")
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
r = await self.client.get(f"/v1/charge/{checking_id}")
|
||||
|
|
@ -128,22 +143,26 @@ class OpenNodeWallet(Wallet):
|
|||
return PaymentStatus(statuses[data.get("status")])
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
r = await self.client.get(f"/v1/withdrawal/{checking_id}")
|
||||
try:
|
||||
r = await self.client.get(f"/v1/withdrawal/{checking_id}")
|
||||
if r.is_error:
|
||||
return PaymentPendingStatus()
|
||||
|
||||
if r.is_error:
|
||||
data = r.json()["data"]
|
||||
statuses = {
|
||||
"initial": None,
|
||||
"pending": None,
|
||||
"confirmed": True,
|
||||
"error": False,
|
||||
"failed": False,
|
||||
}
|
||||
fee = data.get("fee")
|
||||
fee_msat = -fee * 1000 if fee is not None else None
|
||||
return PaymentStatus(statuses.get(data.get("status")), fee_msat)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentPendingStatus()
|
||||
|
||||
data = r.json()["data"]
|
||||
statuses = {
|
||||
"initial": None,
|
||||
"pending": None,
|
||||
"confirmed": True,
|
||||
"error": None,
|
||||
"failed": False,
|
||||
}
|
||||
fee_msat = -data.get("fee") * 1000
|
||||
return PaymentStatus(statuses[data.get("status")], fee_msat)
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
self.queue: asyncio.Queue = asyncio.Queue(0)
|
||||
while settings.lnbits_running:
|
||||
|
|
|
|||
|
|
@ -208,11 +208,11 @@ class PhoenixdWallet(Wallet):
|
|||
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
|
||||
# RequestError can also be raised after the server received the request.
|
||||
msg = f"Unable to connect to {self.endpoint}."
|
||||
logger.warning(msg)
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(ok=False, error_message=msg)
|
||||
return PaymentResponse(ok=None, error_message=msg)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(
|
||||
|
|
|
|||
|
|
@ -162,11 +162,16 @@ class SparkWallet(Wallet):
|
|||
)
|
||||
|
||||
except (SparkError, UnknownError) as exc:
|
||||
listpays = await self.listpays(bolt11)
|
||||
try:
|
||||
listpays = await self.listpays(bolt11)
|
||||
except (SparkError, UnknownError):
|
||||
return PaymentResponse(error_message=str(exc))
|
||||
if not listpays:
|
||||
return PaymentResponse(ok=False, error_message=str(exc))
|
||||
return PaymentResponse(error_message=str(exc))
|
||||
|
||||
pays = listpays["pays"]
|
||||
pays = listpays.get("pays")
|
||||
if not isinstance(pays, list):
|
||||
return PaymentResponse(error_message=str(exc))
|
||||
|
||||
if len(pays) == 0:
|
||||
return PaymentResponse(ok=False, error_message=str(exc))
|
||||
|
|
@ -175,10 +180,12 @@ class SparkWallet(Wallet):
|
|||
payment_hash = pay["payment_hash"]
|
||||
|
||||
if len(pays) > 1:
|
||||
raise SparkError(
|
||||
f"listpays({payment_hash}) returned an unexpected response:"
|
||||
f" {listpays}"
|
||||
) from exc
|
||||
return PaymentResponse(
|
||||
error_message=(
|
||||
f"listpays({payment_hash}) returned an unexpected response:"
|
||||
f" {listpays}"
|
||||
)
|
||||
)
|
||||
|
||||
if pay["status"] == "failed":
|
||||
return PaymentResponse(ok=False, error_message=str(exc))
|
||||
|
|
@ -203,7 +210,7 @@ class SparkWallet(Wallet):
|
|||
preimage=preimage,
|
||||
)
|
||||
else:
|
||||
return PaymentResponse(ok=False, error_message=str(exc))
|
||||
return PaymentResponse(error_message=str(exc))
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
try:
|
||||
|
|
@ -214,10 +221,12 @@ class SparkWallet(Wallet):
|
|||
if not r or not r.get("invoices"):
|
||||
return PaymentPendingStatus()
|
||||
|
||||
if r["invoices"][0]["status"] == "paid":
|
||||
status = r["invoices"][0]["status"]
|
||||
if status == "paid":
|
||||
return PaymentSuccessStatus()
|
||||
else:
|
||||
if status == "expired":
|
||||
return PaymentFailedStatus()
|
||||
return PaymentPendingStatus()
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
# check if it's 32 bytes hex
|
||||
|
|
@ -249,7 +258,8 @@ class SparkWallet(Wallet):
|
|||
if status == "failed":
|
||||
return PaymentFailedStatus()
|
||||
return PaymentPendingStatus()
|
||||
raise KeyError("supplied an invalid checking_id")
|
||||
logger.warning(f"supplied an invalid checking_id: {checking_id}")
|
||||
return PaymentPendingStatus()
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
url = f"/stream?access-key={self.token}"
|
||||
|
|
|
|||
|
|
@ -162,7 +162,6 @@ class SparkL2Wallet(Wallet):
|
|||
checking_id = res.get("checking_id")
|
||||
if not checking_id:
|
||||
return PaymentResponse(
|
||||
ok=False,
|
||||
error_message="Spark sidecar payment response missing checking_id.",
|
||||
)
|
||||
status = res.get("status")
|
||||
|
|
@ -178,7 +177,7 @@ class SparkL2Wallet(Wallet):
|
|||
)
|
||||
|
||||
except Exception as e:
|
||||
return PaymentResponse(ok=False, error_message=str(e))
|
||||
return PaymentResponse(error_message=str(e))
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -237,50 +237,59 @@ class StrikeWallet(Wallet):
|
|||
ok=False, error_message=f"Invalid invoice: {decode_exc!s}"
|
||||
)
|
||||
|
||||
# Creating a quote cannot make the payment. Any failure before the execute
|
||||
# request is therefore a definite failure of this payment attempt.
|
||||
try:
|
||||
# 1) Create a payment quote
|
||||
quote_id, error = await self._create_payment_quote(bolt11)
|
||||
if error or not quote_id:
|
||||
return PaymentResponse(ok=False, error_message=error or "Unknown error")
|
||||
except Exception as exc:
|
||||
logger.warning(f"Strike quote creation exception: {exc}", exc_info=True)
|
||||
return PaymentResponse(
|
||||
ok=False,
|
||||
error_message=f"Failed to create payment quote: {exc!s}",
|
||||
)
|
||||
|
||||
try:
|
||||
# Keep the quote id while this process is running. Strike only documents
|
||||
# payment status lookup by payment id, which an ambiguous execute request
|
||||
# may not return.
|
||||
self.pending_payments[payment_hash] = quote_id
|
||||
|
||||
# 2) Execute the payment quote
|
||||
data, error = await self._execute_payment_quote(quote_id)
|
||||
if error or not data:
|
||||
return PaymentResponse(ok=False, error_message=error or "Unknown error")
|
||||
return PaymentResponse(error_message=error or "Unknown error")
|
||||
|
||||
state = data.get("state", "").upper()
|
||||
payment_id = data.get("paymentId")
|
||||
checking_id = payment_id or payment_hash
|
||||
|
||||
# Parse fee
|
||||
fee_msat = self._parse_payment_fee(data, payment_id or "")
|
||||
fee_msat = self._parse_payment_fee(data, checking_id)
|
||||
|
||||
# Handle successful payment
|
||||
if state in {"SUCCEEDED", "COMPLETED"}:
|
||||
preimage = self._extract_preimage(data)
|
||||
return PaymentResponse(
|
||||
ok=True,
|
||||
checking_id=payment_hash,
|
||||
checking_id=checking_id,
|
||||
fee_msat=fee_msat,
|
||||
preimage=preimage,
|
||||
)
|
||||
|
||||
# Handle failed payment
|
||||
failed_states = {"CANCELED", "FAILED", "TIMED_OUT"}
|
||||
if state in failed_states:
|
||||
if state == "FAILED":
|
||||
logger.warning(
|
||||
f"Strike payment {payment_id} failed with state: {state}"
|
||||
)
|
||||
return PaymentResponse(
|
||||
ok=False,
|
||||
checking_id=payment_hash,
|
||||
checking_id=checking_id,
|
||||
error_message=f"Payment {state.lower()}",
|
||||
)
|
||||
|
||||
# Store mapping for later polling
|
||||
self.pending_payments[payment_hash] = quote_id
|
||||
|
||||
# Treat all other states as pending
|
||||
return PaymentResponse(ok=None, checking_id=payment_hash)
|
||||
return PaymentResponse(ok=None, checking_id=payment_id)
|
||||
|
||||
except httpx.HTTPStatusError as http_exc:
|
||||
logger.warning(f"Strike HTTP error during payment: {http_exc}")
|
||||
|
|
@ -289,7 +298,6 @@ class StrikeWallet(Wallet):
|
|||
f"body: {http_exc.response.text}"
|
||||
)
|
||||
return PaymentResponse(
|
||||
ok=False,
|
||||
error_message=f"Strike API error: {http_exc.response.status_code}",
|
||||
)
|
||||
except Exception as e:
|
||||
|
|
@ -343,7 +351,8 @@ class StrikeWallet(Wallet):
|
|||
quote_id = self.pending_payments.get(checking_id)
|
||||
|
||||
try:
|
||||
# Attempt 1: Use quote_id if available (from in-memory store)
|
||||
# A quote id can only be associated with an invoice hash while this
|
||||
# process is running. Persisted payment ids are checked below.
|
||||
if quote_id:
|
||||
status = await self._get_payment_status_by_quote_id(
|
||||
checking_id, quote_id
|
||||
|
|
@ -527,10 +536,8 @@ class StrikeWallet(Wallet):
|
|||
return None, error_msg
|
||||
|
||||
data = e.json() if e.content else {}
|
||||
payment_id = data.get("paymentId")
|
||||
if not payment_id:
|
||||
if not data.get("paymentId"):
|
||||
logger.warning(f"Strike: missing paymentId in response: {data}")
|
||||
return None, "Strike: missing paymentId in response"
|
||||
|
||||
return data, None
|
||||
|
||||
|
|
@ -629,7 +636,7 @@ class StrikeWallet(Wallet):
|
|||
self.pending_payments.pop(checking_id, None)
|
||||
return PaymentFailedStatus()
|
||||
|
||||
return None
|
||||
return PaymentPendingStatus()
|
||||
|
||||
async def _get_payment_status_by_checking_id( # noqa: C901
|
||||
self, checking_id: str
|
||||
|
|
@ -693,16 +700,28 @@ class StrikeWallet(Wallet):
|
|||
continue
|
||||
logger.warning(
|
||||
f"Payment '{checking_id}' not a valid Strike payment. "
|
||||
f"Marked as failed. Response: {r_payment.text}"
|
||||
"Keeping pending because it may be the invoice payment "
|
||||
f"hash fallback. Response: {r_payment.text}"
|
||||
)
|
||||
self.pending_payments.pop(checking_id, None)
|
||||
return PaymentFailedStatus()
|
||||
return PaymentPendingStatus()
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
|
||||
return PaymentPendingStatus()
|
||||
|
||||
if r_payment.status_code == 404:
|
||||
if len(checking_id) == 64:
|
||||
try:
|
||||
bytes.fromhex(checking_id)
|
||||
logger.warning(
|
||||
f"Payment '{checking_id}' not found, but the identifier may "
|
||||
"be a legacy invoice payment hash. Keeping pending."
|
||||
)
|
||||
return PaymentPendingStatus()
|
||||
except ValueError as exc:
|
||||
logger.warning(
|
||||
f"Payment identifier '{checking_id}' is not valid hex: {exc}"
|
||||
)
|
||||
logger.warning(f"Payment {checking_id} not found. Marking as failed.")
|
||||
self.pending_payments.pop(checking_id, None)
|
||||
return PaymentFailedStatus()
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import hashlib
|
|||
from collections.abc import AsyncGenerator
|
||||
|
||||
import httpx
|
||||
from bolt11 import decode as bolt11_decode
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.helpers import normalize_endpoint
|
||||
|
|
@ -16,6 +15,7 @@ from .base import (
|
|||
PaymentStatus,
|
||||
StatusResponse,
|
||||
Wallet,
|
||||
payment_request_was_rejected,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -105,30 +105,62 @@ class ZBDWallet(Wallet):
|
|||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
# https://api.zebedee.io/v0/payments
|
||||
r = await self.client.post(
|
||||
"payments",
|
||||
json={
|
||||
"invoice": bolt11,
|
||||
"description": "",
|
||||
"amount": "",
|
||||
"internalId": "",
|
||||
"callbackUrl": "",
|
||||
},
|
||||
timeout=40,
|
||||
)
|
||||
try:
|
||||
r = await self.client.post(
|
||||
"payments",
|
||||
json={
|
||||
"invoice": bolt11,
|
||||
"description": "",
|
||||
"amount": "",
|
||||
"internalId": "",
|
||||
"callbackUrl": "",
|
||||
},
|
||||
timeout=40,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(error_message="Unable to query ZBD.")
|
||||
|
||||
if r.is_error:
|
||||
error_message = r.json()["message"]
|
||||
return PaymentResponse(ok=False, error_message=error_message)
|
||||
try:
|
||||
error_message = r.json().get("message", r.text)
|
||||
except Exception:
|
||||
error_message = r.text
|
||||
return PaymentResponse(
|
||||
ok=False if payment_request_was_rejected(r.status_code) else None,
|
||||
error_message=error_message,
|
||||
)
|
||||
|
||||
data = r.json()
|
||||
|
||||
checking_id = bolt11_decode(bolt11).payment_hash
|
||||
fee_msat = -int(data["data"]["fee"])
|
||||
preimage = data["data"]["preimage"]
|
||||
try:
|
||||
data = r.json()["data"]
|
||||
checking_id = data.get("id")
|
||||
fee = data.get("fee")
|
||||
fee_msat = -int(fee) if fee is not None else None
|
||||
preimage = data.get("preimage")
|
||||
status = str(data.get("status", "")).lower()
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(error_message="Invalid ZBD payment response.")
|
||||
|
||||
if status == "completed":
|
||||
return PaymentResponse(
|
||||
ok=True,
|
||||
checking_id=checking_id,
|
||||
fee_msat=fee_msat,
|
||||
preimage=preimage,
|
||||
)
|
||||
if status in {"failed", "expired"}:
|
||||
return PaymentResponse(
|
||||
ok=False,
|
||||
checking_id=checking_id,
|
||||
fee_msat=fee_msat,
|
||||
error_message=data.get("errorMessage"),
|
||||
)
|
||||
return PaymentResponse(
|
||||
ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage
|
||||
ok=None,
|
||||
checking_id=checking_id,
|
||||
fee_msat=fee_msat,
|
||||
error_message=data.get("errorMessage"),
|
||||
)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
|
|
@ -147,11 +179,20 @@ class ZBDWallet(Wallet):
|
|||
return PaymentStatus(paid=statuses[data.get("status")])
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
r = await self.client.get(f"payments/{checking_id}")
|
||||
try:
|
||||
r = await self.client.get(f"payments/{checking_id}")
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentPendingStatus()
|
||||
|
||||
if r.is_error:
|
||||
return PaymentPendingStatus()
|
||||
|
||||
data = r.json()["data"]
|
||||
try:
|
||||
data = r.json()["data"]
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentPendingStatus()
|
||||
|
||||
statuses = {
|
||||
"initial": None,
|
||||
|
|
@ -161,8 +202,7 @@ class ZBDWallet(Wallet):
|
|||
"expired": False,
|
||||
"failed": False,
|
||||
}
|
||||
|
||||
return PaymentStatus(paid=statuses[data.get("status")])
|
||||
return PaymentStatus(paid=statuses.get(data.get("status")))
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
self.queue: asyncio.Queue = asyncio.Queue(0)
|
||||
|
|
|
|||
|
|
@ -129,6 +129,8 @@
|
|||
"js/components/admin/lnbits-admin-site-customisation.js",
|
||||
"js/components/admin/lnbits-admin-assets-config.js",
|
||||
"js/components/admin/lnbits-admin-audit.js",
|
||||
"js/components/admin/lnbits-admin-blockexplorer.js",
|
||||
"js/pages/blockexplorer.js",
|
||||
"js/components/lnbits-wallet-charts.js",
|
||||
"js/components/lnbits-wallet-api-docs.js",
|
||||
"js/components/lnbits-wallet-icon.js",
|
||||
|
|
|
|||
14
poetry.lock
generated
14
poetry.lock
generated
|
|
@ -3954,6 +3954,18 @@ files = [
|
|||
{file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "random-username"
|
||||
version = "1.0.2"
|
||||
description = "Randomly generate compelling usernames."
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "random-username-1.0.2.tar.gz", hash = "sha256:5fdc0604b5d1bdfe4acf4cd7491a9de1caf41bbdd890f646b434e09ae2a1b7ce"},
|
||||
{file = "random_username-1.0.2-py3-none-any.whl", hash = "sha256:2536feb63fecde7e01ede4a541aadb6f0b58794a7ab327ca5369d2a4b7664c06"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.36.2"
|
||||
|
|
@ -5184,4 +5196,4 @@ migration = ["psycopg2-binary"]
|
|||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.10,<3.13"
|
||||
content-hash = "b167368cde275d9b12ea2c352a9ca2558da1aa9476ada0795cfd4f45d2ca72bc"
|
||||
content-hash = "e41fd327115a1614f2e7b5dae7d7077e30f1cb723e1880a88132ec38df2b4b1a"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "lnbits"
|
||||
version = "1.5.6"
|
||||
version = "1.6.0-rc2"
|
||||
requires-python = ">=3.10,<3.13"
|
||||
description = "LNbits, free and open-source Lightning wallet and accounts system."
|
||||
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
|
||||
|
|
@ -54,6 +54,7 @@ dependencies = [
|
|||
"urllib3>=2.7.0",
|
||||
"pyinstrument>=5.1.2",
|
||||
"wasmtime>=45.0.0",
|
||||
"random-username~=1.0.2",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
|
|
|||
|
|
@ -233,6 +233,40 @@ async def test_create_fiat_invoice(
|
|||
assert invoice["extra"]["fiat_payment_request"] == fiat_payment_request
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_fiat_subscription_invoice_rejected(
|
||||
client, inkey_headers_to, mocker: MockerFixture
|
||||
):
|
||||
fiat_mock = mocker.patch(
|
||||
"lnbits.core.services.payments.create_fiat_invoice",
|
||||
AsyncMock(),
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/payments",
|
||||
headers=inkey_headers_to,
|
||||
json={
|
||||
"unit": "USD",
|
||||
"out": False,
|
||||
"amount": 2100,
|
||||
"fiat_provider": "stripe",
|
||||
"extra": {
|
||||
"fiat_method": "subscription",
|
||||
"subscription": {
|
||||
"checking_id": "fiat_stripe_cs_paid_session",
|
||||
"payment_request": "",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == (
|
||||
"Cannot create direct fiat subscription payments."
|
||||
)
|
||||
fiat_mock.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("currency", ("msat", "RRR"))
|
||||
async def test_create_invoice_validates_used_currency(
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import json
|
||||
import re
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from uuid import uuid4
|
||||
|
||||
|
|
@ -16,9 +19,11 @@ from lnurl.models import MessageAction
|
|||
from lnurl.types import CallbackUrl, LightningInvoice
|
||||
from pydantic import parse_obj_as
|
||||
|
||||
from lnbits.core.crud.wallets import create_wallet, get_wallet
|
||||
from lnbits.core.models import Account, CreateInvoice
|
||||
from lnbits.core.models.lnurl import CreateLnurlPayment, LnurlScan
|
||||
from lnbits.core.models.wallets import KeyType, WalletTypeInfo
|
||||
from lnbits.core.services.lightning_address import wallet_lightning_address_callback
|
||||
from lnbits.core.services.payments import create_wallet_invoice
|
||||
from lnbits.core.services.users import create_user_account
|
||||
from lnbits.core.views.lnurl_api import (
|
||||
|
|
@ -38,6 +43,90 @@ TEST_BOLT11 = (
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wallet_lightning_address_lookup_and_callback(
|
||||
client, to_user, settings, mocker
|
||||
):
|
||||
settings.lnbits_ln_address_mode = "core_first"
|
||||
wallet = await create_wallet(user_id=to_user.id, wallet_name="ln address")
|
||||
assert wallet.lightning_address
|
||||
|
||||
response = await client.get(f"/.well-known/lnurlp/{wallet.lightning_address}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
metadata = data["metadata"]
|
||||
assert data["minSendable"] == 1000
|
||||
assert data["maxSendable"] == 2_100_000_000_000_000_000
|
||||
assert data["commentAllowed"] == 799
|
||||
assert f"{wallet.lightning_address}@" in metadata
|
||||
assert "text/identifier" in metadata
|
||||
|
||||
tagged_response = await client.get(
|
||||
f"/.well-known/lnurlp/{wallet.lightning_address}+market"
|
||||
)
|
||||
assert tagged_response.status_code == 200
|
||||
tagged_data = tagged_response.json()
|
||||
tagged_metadata = json.loads(tagged_data["metadata"])
|
||||
assert ["text/tag", "market"] in tagged_metadata
|
||||
assert any(
|
||||
entry[0] == "text/identifier"
|
||||
and entry[1].startswith(f"{wallet.lightning_address}+market@")
|
||||
for entry in tagged_metadata
|
||||
)
|
||||
|
||||
create_invoice_mock = mocker.patch(
|
||||
"lnbits.core.services.lightning_address.create_invoice",
|
||||
mocker.AsyncMock(return_value=SimpleNamespace(bolt11=TEST_BOLT11)),
|
||||
)
|
||||
callback = tagged_data["callback"].split("testserver")[-1]
|
||||
callback_response = await client.get(f"{callback}?amount=21000&comment=hello")
|
||||
assert callback_response.status_code == 200
|
||||
assert callback_response.json()["pr"] == TEST_BOLT11
|
||||
create_invoice_mock.assert_awaited_once()
|
||||
kwargs = create_invoice_mock.await_args.kwargs
|
||||
assert kwargs["wallet_id"] == wallet.id
|
||||
assert kwargs["amount"] == 21
|
||||
assert kwargs["extra"]["tag"] == "wallet_lightning_address"
|
||||
assert kwargs["extra"]["comment"] == "hello"
|
||||
assert kwargs["extra"]["lnaddress"].startswith(f"{wallet.lightning_address}+")
|
||||
assert kwargs["extra"]["lnaddress_tag"] == "market"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wallet_lightning_address_generation_settings(to_user, settings):
|
||||
settings.lnbits_ln_address_mode = "core_first"
|
||||
wallet = await create_wallet(user_id=to_user.id)
|
||||
assert wallet.lightning_address
|
||||
assert re.fullmatch(r"[a-z]+[a-z]+[0-9]", wallet.lightning_address)
|
||||
|
||||
settings.lnbits_ln_address_mode = "extension_only"
|
||||
disabled_wallet = await create_wallet(user_id=to_user.id)
|
||||
assert disabled_wallet.lightning_address is None
|
||||
|
||||
settings.lnbits_ln_address_mode = "core_first"
|
||||
backfilled = await get_wallet(disabled_wallet.id)
|
||||
assert backfilled
|
||||
assert backfilled.lightning_address
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wallet_lightning_address_callback_validates_comment(
|
||||
to_user, settings, mocker
|
||||
):
|
||||
settings.lnbits_ln_address_mode = "core_first"
|
||||
wallet = await create_wallet(user_id=to_user.id)
|
||||
assert wallet.lightning_address
|
||||
request = mocker.Mock()
|
||||
request.url.netloc = "example.com"
|
||||
request.query_params.get.return_value = "x" * 800
|
||||
|
||||
result = await wallet_lightning_address_callback(
|
||||
wallet.lightning_address, request, amount=1000
|
||||
)
|
||||
assert isinstance(result, LnurlErrorResponse)
|
||||
assert "can only accept 799" in result.reason
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_lnurl_api_scan_routes_validate_and_forward(mocker):
|
||||
pay_response = make_lnurl_pay_response()
|
||||
|
|
|
|||
|
|
@ -83,6 +83,45 @@ async def test_user_api_get_wallets_and_delete_all_wallets(
|
|||
assert active_wallets == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_user_api_superuser_sets_wallet_lightning_address(
|
||||
http_client: AsyncClient, superuser_token: str
|
||||
):
|
||||
user = await create_user_account(
|
||||
Account(
|
||||
id=uuid4().hex,
|
||||
username=f"user_{uuid4().hex[:8]}",
|
||||
email=f"user_{uuid4().hex[:8]}@lnbits.com",
|
||||
)
|
||||
)
|
||||
wallet = user.wallets[0]
|
||||
|
||||
settings.lnbits_ln_address_mode = "core_first"
|
||||
settings.lnbits_wallet_lightning_address_blacklist = ["admin"]
|
||||
settings.lnbits_charge_wallet_lightning_addresses = True
|
||||
settings.lnbits_wallet_lightning_address_price_sats = 1_000
|
||||
settings.lnbits_service_fee_wallet = None
|
||||
|
||||
unauthorized = await http_client.put(
|
||||
f"/users/api/v1/user/{user.id}/wallet/{wallet.id}/lightning-address",
|
||||
json={"lightning_address": "admin"},
|
||||
)
|
||||
assert unauthorized.status_code == 401
|
||||
|
||||
response = await http_client.put(
|
||||
f"/users/api/v1/user/{user.id}/wallet/{wallet.id}/lightning-address",
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
json={"lightning_address": "admin"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["lightning_address"] == "admin"
|
||||
|
||||
updated_wallet = await get_wallet(wallet.id)
|
||||
assert updated_wallet
|
||||
assert updated_wallet.lightning_address == "admin"
|
||||
assert updated_wallet.balance == 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_user_api_create_wallet_validates_currency():
|
||||
user = await create_user_account(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ from httpx import AsyncClient
|
|||
|
||||
from lnbits.core.crud.wallets import create_wallet, get_wallet
|
||||
from lnbits.core.models.users import Account
|
||||
from lnbits.core.services import update_wallet_balance
|
||||
from lnbits.core.services.users import create_user_account
|
||||
from lnbits.settings import settings
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
@ -166,6 +168,127 @@ async def test_wallet_api_paginated_update_reset_and_store_paylinks(
|
|||
assert updated.json()["extra"]["pinned"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wallet_api_custom_lightning_address_owner_rules(
|
||||
http_client: AsyncClient,
|
||||
):
|
||||
user = await create_user_account(
|
||||
Account(
|
||||
id=uuid4().hex,
|
||||
username=f"user_{uuid4().hex[:8]}",
|
||||
email=f"user_{uuid4().hex[:8]}@lnbits.com",
|
||||
)
|
||||
)
|
||||
wallet = user.wallets[0]
|
||||
headers = _admin_headers(wallet.adminkey)
|
||||
|
||||
settings.lnbits_ln_address_mode = "core_first"
|
||||
settings.lnbits_allow_custom_wallet_lightning_addresses = False
|
||||
disabled = await http_client.patch(
|
||||
"/api/v1/wallet",
|
||||
headers=headers,
|
||||
json={"lightning_address": "custom.name"},
|
||||
)
|
||||
assert disabled.status_code == 403
|
||||
|
||||
settings.lnbits_allow_custom_wallet_lightning_addresses = True
|
||||
settings.lnbits_wallet_lightning_address_blacklist = ["admin"]
|
||||
blacklisted = await http_client.patch(
|
||||
"/api/v1/wallet",
|
||||
headers=headers,
|
||||
json={"lightning_address": "admin"},
|
||||
)
|
||||
assert blacklisted.status_code == 400
|
||||
|
||||
invalid = await http_client.patch(
|
||||
"/api/v1/wallet",
|
||||
headers=headers,
|
||||
json={"lightning_address": "custom+tag"},
|
||||
)
|
||||
assert invalid.status_code == 400
|
||||
|
||||
existing_wallet = await create_wallet(
|
||||
user_id=user.id,
|
||||
wallet_name="existing lightning address",
|
||||
)
|
||||
existing = await http_client.patch(
|
||||
"/api/v1/wallet",
|
||||
headers=_admin_headers(existing_wallet.adminkey),
|
||||
json={"lightning_address": "pay.link"},
|
||||
)
|
||||
assert existing.status_code == 200
|
||||
|
||||
conflict = await http_client.patch(
|
||||
"/api/v1/wallet",
|
||||
headers=headers,
|
||||
json={"lightning_address": "pay.link"},
|
||||
)
|
||||
assert conflict.status_code == 400
|
||||
|
||||
updated = await http_client.patch(
|
||||
"/api/v1/wallet",
|
||||
headers=headers,
|
||||
json={"lightning_address": "custom.name"},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["lightning_address"] == "custom.name"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wallet_api_custom_lightning_address_charges_fee(
|
||||
http_client: AsyncClient,
|
||||
):
|
||||
user = await create_user_account(
|
||||
Account(
|
||||
id=uuid4().hex,
|
||||
username=f"user_{uuid4().hex[:8]}",
|
||||
email=f"user_{uuid4().hex[:8]}@lnbits.com",
|
||||
)
|
||||
)
|
||||
fee_user = await create_user_account(
|
||||
Account(
|
||||
id=uuid4().hex,
|
||||
username=f"fees_{uuid4().hex[:8]}",
|
||||
email=f"fees_{uuid4().hex[:8]}@lnbits.com",
|
||||
)
|
||||
)
|
||||
wallet = user.wallets[0]
|
||||
fee_wallet = fee_user.wallets[0]
|
||||
await update_wallet_balance(wallet=wallet, amount=2_000)
|
||||
|
||||
settings.lnbits_ln_address_mode = "core_first"
|
||||
settings.lnbits_allow_custom_wallet_lightning_addresses = True
|
||||
settings.lnbits_charge_wallet_lightning_addresses = True
|
||||
settings.lnbits_wallet_lightning_address_price_sats = 1_000
|
||||
settings.lnbits_service_fee_wallet = fee_wallet.id
|
||||
|
||||
updated = await http_client.patch(
|
||||
"/api/v1/wallet",
|
||||
headers=_admin_headers(wallet.adminkey),
|
||||
json={"lightning_address": "paid.name"},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["lightning_address"] == "paid.name"
|
||||
|
||||
charged_wallet = await get_wallet(wallet.id)
|
||||
credited_wallet = await get_wallet(fee_wallet.id)
|
||||
assert charged_wallet
|
||||
assert credited_wallet
|
||||
assert charged_wallet.balance == 1_000
|
||||
assert credited_wallet.balance == 1_000
|
||||
|
||||
settings.lnbits_service_fee_wallet = None
|
||||
missing_fee_wallet = await http_client.patch(
|
||||
"/api/v1/wallet",
|
||||
headers=_admin_headers(wallet.adminkey),
|
||||
json={"lightning_address": "paid.other"},
|
||||
)
|
||||
assert missing_fee_wallet.status_code == 400
|
||||
assert missing_fee_wallet.json()["detail"] == (
|
||||
"Lightning Address fee wallet is not configured."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wallet_api_shared_wallet_requires_source_id(http_client: AsyncClient):
|
||||
user = await create_user_account(
|
||||
|
|
|
|||
|
|
@ -287,6 +287,10 @@ async def test_pay_failed(
|
|||
"lnbits.wallets.FakeWallet.pay_invoice",
|
||||
AsyncMock(return_value=payment_reponse_failed),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.payments.get_funding_source",
|
||||
return_value=external_funding_source,
|
||||
)
|
||||
|
||||
external_invoice = await external_funding_source.create_invoice(2101)
|
||||
assert external_invoice.payment_request
|
||||
|
|
@ -375,25 +379,33 @@ async def test_retry_failed_invoice(
|
|||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("returns_checking_id", [True, False])
|
||||
async def test_pay_external_invoice_pending(
|
||||
from_wallet: Wallet,
|
||||
mocker: MockerFixture,
|
||||
external_funding_source: FakeWallet,
|
||||
settings: Settings,
|
||||
returns_checking_id: bool,
|
||||
):
|
||||
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
|
||||
|
||||
payment_reponse_pending = PaymentResponse(
|
||||
ok=None, checking_id=external_invoice.checking_id
|
||||
backend_checking_id = (
|
||||
f"backend_{external_invoice.checking_id}" if returns_checking_id else None
|
||||
)
|
||||
expected_checking_id = backend_checking_id or external_invoice.checking_id
|
||||
|
||||
payment_reponse_pending = PaymentResponse(ok=None, checking_id=backend_checking_id)
|
||||
mocker.patch(
|
||||
"lnbits.wallets.FakeWallet.pay_invoice",
|
||||
AsyncMock(return_value=payment_reponse_pending),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.payments.get_funding_source",
|
||||
return_value=external_funding_source,
|
||||
)
|
||||
ws_notification = mocker.patch(
|
||||
"lnbits.core.services.payments.send_payment_notification_in_background",
|
||||
AsyncMock(return_value=None),
|
||||
|
|
@ -409,7 +421,9 @@ async def test_pay_external_invoice_pending(
|
|||
_payment = await get_standalone_payment(payment.payment_hash)
|
||||
assert _payment
|
||||
assert _payment.status == PaymentState.PENDING.value
|
||||
assert _payment.checking_id == payment.payment_hash
|
||||
assert _payment.checking_id == expected_checking_id
|
||||
assert _payment.payment_hash == external_invoice.checking_id
|
||||
assert payment.checking_id == expected_checking_id
|
||||
assert _payment.amount == -2103_000
|
||||
assert _payment.bolt11 == external_invoice.payment_request
|
||||
|
||||
|
|
@ -565,36 +579,43 @@ async def test_retry_pay_success(
|
|||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pay_external_invoice_success_bad_checking_id(
|
||||
async def test_pay_external_invoice_success_with_backend_checking_id(
|
||||
from_wallet: Wallet, mocker: MockerFixture, external_funding_source: FakeWallet
|
||||
):
|
||||
invoice_amount = 2108
|
||||
external_invoice = await external_funding_source.create_invoice(invoice_amount)
|
||||
assert external_invoice.payment_request
|
||||
assert external_invoice.checking_id
|
||||
bad_checking_id = f"bad_{external_invoice.checking_id}"
|
||||
backend_checking_id = f"backend_{external_invoice.checking_id}"
|
||||
|
||||
preimage = "0000000000000000000000000000000000000000000000000000000000002108"
|
||||
payment_reponse_success = PaymentResponse(
|
||||
ok=True, checking_id=bad_checking_id, preimage=preimage
|
||||
ok=True, checking_id=backend_checking_id, preimage=preimage
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.wallets.FakeWallet.pay_invoice",
|
||||
AsyncMock(return_value=payment_reponse_success),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.payments.get_funding_source",
|
||||
return_value=external_funding_source,
|
||||
)
|
||||
|
||||
with pytest.raises(PaymentError):
|
||||
await pay_invoice(
|
||||
wallet_id=from_wallet.id,
|
||||
payment_request=external_invoice.payment_request,
|
||||
)
|
||||
payment = await pay_invoice(
|
||||
wallet_id=from_wallet.id,
|
||||
payment_request=external_invoice.payment_request,
|
||||
)
|
||||
|
||||
payment = await get_standalone_payment(bad_checking_id)
|
||||
assert payment is None, "Payment should not be created with bad checking_id"
|
||||
stored_payment = await get_standalone_payment(external_invoice.checking_id)
|
||||
assert stored_payment
|
||||
assert stored_payment.status == PaymentState.SUCCESS.value
|
||||
assert stored_payment.checking_id == backend_checking_id
|
||||
assert stored_payment.payment_hash == external_invoice.checking_id
|
||||
assert payment.checking_id == backend_checking_id
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_no_checking_id(
|
||||
async def test_pay_external_invoice_success_without_checking_id(
|
||||
from_wallet: Wallet, mocker: MockerFixture, external_funding_source: FakeWallet
|
||||
):
|
||||
invoice_amount = 2110
|
||||
|
|
@ -603,29 +624,33 @@ async def test_no_checking_id(
|
|||
assert external_invoice.checking_id
|
||||
|
||||
preimage = "0000000000000000000000000000000000000000000000000000000000002110"
|
||||
payment_reponse_pending = PaymentResponse(
|
||||
payment_response_success = PaymentResponse(
|
||||
ok=True, checking_id=None, preimage=preimage
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.wallets.FakeWallet.pay_invoice",
|
||||
AsyncMock(return_value=payment_reponse_pending),
|
||||
AsyncMock(return_value=payment_response_success),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.payments.get_funding_source",
|
||||
return_value=external_funding_source,
|
||||
)
|
||||
|
||||
with pytest.raises(PaymentError):
|
||||
await pay_invoice(
|
||||
wallet_id=from_wallet.id,
|
||||
payment_request=external_invoice.payment_request,
|
||||
)
|
||||
returned_payment = await pay_invoice(
|
||||
wallet_id=from_wallet.id,
|
||||
payment_request=external_invoice.payment_request,
|
||||
)
|
||||
|
||||
payment = await get_standalone_payment(external_invoice.checking_id)
|
||||
|
||||
assert payment
|
||||
assert payment.status == PaymentState.FAILED.value
|
||||
assert payment.status == PaymentState.SUCCESS.value
|
||||
|
||||
assert payment.checking_id == external_invoice.checking_id
|
||||
assert payment.payment_hash == external_invoice.checking_id
|
||||
assert payment.amount == -2110_000
|
||||
assert payment.preimage is None
|
||||
assert payment.preimage == preimage
|
||||
assert returned_payment.checking_id == external_invoice.checking_id
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
|
|||
|
|
@ -43,10 +43,38 @@ from lnbits.wallets.base import (
|
|||
InvoiceResponse,
|
||||
PaymentFailedStatus,
|
||||
PaymentPendingStatus,
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
PaymentSuccessStatus,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
(True, (True, False, False)),
|
||||
(None, (False, True, False)),
|
||||
(False, (False, False, True)),
|
||||
],
|
||||
)
|
||||
def test_payment_response_states_are_mutually_exclusive(value, expected):
|
||||
response = PaymentResponse(ok=value)
|
||||
assert (response.success, response.pending, response.failed) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
(True, (True, False, False)),
|
||||
(None, (False, True, False)),
|
||||
(False, (False, True, True)),
|
||||
],
|
||||
)
|
||||
def test_payment_status_properties(value, expected):
|
||||
status = PaymentStatus(paid=value)
|
||||
assert (status.success, status.pending, status.failed) == expected
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_payment_request_routes_by_invoice_type(mocker: MockerFixture):
|
||||
wallet_payment = SimpleNamespace(checking_id="wallet")
|
||||
|
|
@ -75,6 +103,39 @@ async def test_create_payment_request_routes_by_invoice_type(mocker: MockerFixtu
|
|||
fiat_mock.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("fiat_provider", ("stripe", "square", "paypal"))
|
||||
async def test_create_payment_request_rejects_fiat_subscription(
|
||||
fiat_provider: str, mocker: MockerFixture
|
||||
):
|
||||
fiat_mock = mocker.patch(
|
||||
"lnbits.core.services.payments.create_fiat_invoice",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Cannot create direct fiat subscription payments.",
|
||||
):
|
||||
await create_payment_request(
|
||||
"wallet-1",
|
||||
CreateInvoice(
|
||||
unit="USD",
|
||||
amount=2100,
|
||||
fiat_provider=fiat_provider,
|
||||
extra={
|
||||
"fiat_method": "subscription",
|
||||
"subscription": {
|
||||
"checking_id": "fiat_stripe_cs_paid_session",
|
||||
"payment_request": "",
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
fiat_mock.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_pending_payment_and_bulk_pending_updates(mocker: MockerFixture):
|
||||
wallet = await _create_wallet()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
import pytest
|
||||
from pytest_mock.plugin import MockerFixture
|
||||
|
|
@ -14,6 +14,7 @@ from lnbits.settings import (
|
|||
RedirectPath,
|
||||
SecuritySettings,
|
||||
Settings,
|
||||
UsersSettings,
|
||||
list_parse_fallback,
|
||||
set_cli_settings,
|
||||
)
|
||||
|
|
@ -40,6 +41,33 @@ nostrrelay_redirect_path: dict[str, Any] = {
|
|||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "creation_allowed"),
|
||||
[
|
||||
("core_first", True),
|
||||
("extension_first", True),
|
||||
("extension_only", False),
|
||||
],
|
||||
)
|
||||
def test_ln_address_mode(
|
||||
mode: Literal["core_first", "extension_first", "extension_only"],
|
||||
creation_allowed: bool,
|
||||
):
|
||||
users_settings = UsersSettings(lnbits_ln_address_mode=mode)
|
||||
|
||||
assert users_settings.lnbits_ln_address_mode == mode
|
||||
assert users_settings.ln_address_creation_allowed is creation_allowed
|
||||
|
||||
|
||||
def test_ln_address_mode_defaults_to_extension_first():
|
||||
assert UsersSettings().lnbits_ln_address_mode == "extension_first"
|
||||
|
||||
|
||||
def test_ln_address_mode_rejects_invalid_value():
|
||||
with pytest.raises(ValueError):
|
||||
UsersSettings.parse_obj({"lnbits_ln_address_mode": "invalid"})
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def lnurlp():
|
||||
return RedirectPath(ext_id="lnurlp", **lnurlp_redirect_path)
|
||||
|
|
|
|||
890
tests/unit/test_wallet_payment_ambiguity.py
Normal file
890
tests/unit/test_wallet_payment_ambiguity.py
Normal file
|
|
@ -0,0 +1,890 @@
|
|||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
import grpc
|
||||
import httpx
|
||||
import pytest
|
||||
from pyln.client import RpcError
|
||||
from pytest_mock.plugin import MockerFixture
|
||||
|
||||
import lnbits.wallets.breez as breez_wallet_module
|
||||
import lnbits.wallets.breez_liquid as breez_liquid_wallet_module
|
||||
from lnbits.wallets.alby import AlbyWallet
|
||||
from lnbits.wallets.base import PaymentPendingStatus
|
||||
from lnbits.wallets.blink import BlinkWallet
|
||||
from lnbits.wallets.boltz import BoltzWallet
|
||||
from lnbits.wallets.boltz_grpc_files import boltzrpc_pb2
|
||||
from lnbits.wallets.corelightning import CoreLightningWallet
|
||||
from lnbits.wallets.eclair import EclairWallet
|
||||
from lnbits.wallets.lnd_grpc_files.lightning_pb2 import Payment as LndPayment
|
||||
from lnbits.wallets.lndgrpc import LndWallet
|
||||
from lnbits.wallets.lndrest import LndRestWallet
|
||||
from lnbits.wallets.lnpay import LNPayWallet
|
||||
from lnbits.wallets.lntips import LnTipsWallet
|
||||
from lnbits.wallets.nwc import NWCError, NWCWallet
|
||||
from lnbits.wallets.opennode import OpenNodeWallet
|
||||
from lnbits.wallets.phoenixd import PhoenixdWallet
|
||||
from lnbits.wallets.spark import SparkWallet
|
||||
from lnbits.wallets.sparkl2 import SparkL2Wallet
|
||||
from lnbits.wallets.strike import StrikeWallet
|
||||
from lnbits.wallets.zbd import ZBDWallet
|
||||
|
||||
|
||||
def _response(status_code: int, **kwargs) -> httpx.Response:
|
||||
request = httpx.Request("POST", "https://wallet.test/pay")
|
||||
return httpx.Response(status_code, request=request, **kwargs)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("status_code", "expected"),
|
||||
[
|
||||
(400, None),
|
||||
(401, False),
|
||||
(403, False),
|
||||
(404, False),
|
||||
(405, False),
|
||||
(408, None),
|
||||
(409, None),
|
||||
(422, None),
|
||||
(429, None),
|
||||
(500, None),
|
||||
],
|
||||
)
|
||||
async def test_alby_only_treats_definite_http_rejection_as_failed(
|
||||
mocker: MockerFixture, status_code: int, expected: bool | None
|
||||
):
|
||||
wallet = object.__new__(AlbyWallet)
|
||||
wallet.endpoint = "https://wallet.test"
|
||||
cast(Any, wallet).client = SimpleNamespace(
|
||||
post=mocker.AsyncMock(
|
||||
return_value=_response(status_code, json={"message": "error"})
|
||||
)
|
||||
)
|
||||
|
||||
response = await wallet.pay_invoice("bolt11", 1_000)
|
||||
|
||||
assert response.ok is expected
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_blink_keeps_unconfirmed_payment_pending(mocker: MockerFixture):
|
||||
wallet = object.__new__(BlinkWallet)
|
||||
wallet._wallet_id = "wallet-id"
|
||||
wallet.endpoint = "https://wallet.test"
|
||||
mocker.patch(
|
||||
"lnbits.wallets.blink.bolt11_lib.decode",
|
||||
return_value=SimpleNamespace(payment_hash="payment-hash"),
|
||||
)
|
||||
mocker.patch.object(
|
||||
wallet,
|
||||
"_graphql_query",
|
||||
return_value={"data": {"lnInvoicePaymentSend": {"errors": []}}},
|
||||
)
|
||||
mocker.patch.object(
|
||||
wallet, "get_payment_status", return_value=PaymentPendingStatus()
|
||||
)
|
||||
|
||||
response = await wallet.pay_invoice("bolt11", 1_000)
|
||||
|
||||
assert response.ok is None
|
||||
assert response.checking_id == "payment-hash"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("status_code", "expected"),
|
||||
[
|
||||
(400, False),
|
||||
(401, False),
|
||||
(403, False),
|
||||
(404, False),
|
||||
(405, False),
|
||||
(408, None),
|
||||
(409, None),
|
||||
(422, None),
|
||||
(429, None),
|
||||
(500, None),
|
||||
],
|
||||
)
|
||||
async def test_eclair_only_treats_request_rejections_as_failed(
|
||||
mocker: MockerFixture, status_code: int, expected: bool | None
|
||||
):
|
||||
wallet = object.__new__(EclairWallet)
|
||||
wallet.url = "https://wallet.test"
|
||||
cast(Any, wallet).client = SimpleNamespace(
|
||||
post=mocker.AsyncMock(
|
||||
return_value=_response(status_code, json={"error": "invoice has expired"})
|
||||
)
|
||||
)
|
||||
|
||||
response = await wallet.pay_invoice("bolt11", 1_000)
|
||||
|
||||
assert response.ok is expected
|
||||
assert response.error_message == "invoice has expired"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_lndrest_unknown_payment_state_is_pending(
|
||||
mocker: MockerFixture, settings
|
||||
):
|
||||
settings.lnd_rest_allow_self_payment = False
|
||||
wallet = object.__new__(LndRestWallet)
|
||||
cast(Any, wallet).client = SimpleNamespace(
|
||||
post=mocker.AsyncMock(
|
||||
return_value=_response(
|
||||
200,
|
||||
json={
|
||||
"result": {
|
||||
"status": "FUTURE_STATUS",
|
||||
"payment_hash": "payment-hash",
|
||||
"payment_preimage": "",
|
||||
"fee_msat": "0",
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
response = await wallet.pay_invoice("bolt11", 1_000)
|
||||
|
||||
assert response.ok is None
|
||||
assert response.checking_id == "payment-hash"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("error", "expected"),
|
||||
[
|
||||
(
|
||||
{
|
||||
"code": 2,
|
||||
"message": "invoice not for current active network 'regtest'",
|
||||
},
|
||||
False,
|
||||
),
|
||||
({"code": 2, "message": "invoice expired"}, False),
|
||||
({"code": 3, "message": "invalid payment request"}, False),
|
||||
({"code": 2, "message": "payment stream interrupted"}, None),
|
||||
({"code": 14, "message": "transport unavailable"}, None),
|
||||
],
|
||||
)
|
||||
async def test_lndrest_only_pre_dispatch_rpc_errors_are_failed(
|
||||
mocker: MockerFixture,
|
||||
settings,
|
||||
error: dict,
|
||||
expected: bool | None,
|
||||
):
|
||||
settings.lnd_rest_allow_self_payment = False
|
||||
wallet = object.__new__(LndRestWallet)
|
||||
wallet.endpoint = "https://wallet.test"
|
||||
cast(Any, wallet).client = SimpleNamespace(
|
||||
post=mocker.AsyncMock(
|
||||
return_value=_response(500, json={"error": error}),
|
||||
)
|
||||
)
|
||||
|
||||
response = await wallet.pay_invoice("bolt11", 1_000)
|
||||
|
||||
assert response.ok is expected
|
||||
assert response.error_message == error["message"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("code", "details", "expected"),
|
||||
[
|
||||
(
|
||||
grpc.StatusCode.UNKNOWN,
|
||||
"invoice not for current active network 'regtest'",
|
||||
False,
|
||||
),
|
||||
(grpc.StatusCode.UNKNOWN, "invoice expired", False),
|
||||
(grpc.StatusCode.INVALID_ARGUMENT, "invalid payment request", False),
|
||||
(grpc.StatusCode.PERMISSION_DENIED, "permission denied", False),
|
||||
(grpc.StatusCode.UNAUTHENTICATED, "invalid macaroon", False),
|
||||
(grpc.StatusCode.UNAVAILABLE, "transport is closing", None),
|
||||
(grpc.StatusCode.DEADLINE_EXCEEDED, "deadline exceeded", None),
|
||||
(grpc.StatusCode.ALREADY_EXISTS, "payment is in flight", None),
|
||||
(grpc.StatusCode.UNKNOWN, "payment stream interrupted", None),
|
||||
],
|
||||
)
|
||||
async def test_lndgrpc_only_pre_dispatch_rpc_errors_are_failed(
|
||||
mocker: MockerFixture,
|
||||
settings,
|
||||
code: grpc.StatusCode,
|
||||
details: str,
|
||||
expected: bool | None,
|
||||
):
|
||||
settings.lnd_grpc_allow_self_payment = False
|
||||
metadata = grpc.aio.Metadata()
|
||||
error = grpc.aio.AioRpcError(code, metadata, metadata, details=details)
|
||||
wallet = object.__new__(LndWallet)
|
||||
cast(Any, wallet).router_rpc = SimpleNamespace(
|
||||
SendPaymentV2=mocker.Mock(
|
||||
return_value=SimpleNamespace(read=mocker.AsyncMock(side_effect=error))
|
||||
)
|
||||
)
|
||||
|
||||
response = await wallet.pay_invoice("bolt11", 1_000)
|
||||
|
||||
assert response.ok is expected
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_lndgrpc_in_flight_payment_is_pending(mocker: MockerFixture, settings):
|
||||
settings.lnd_grpc_allow_self_payment = False
|
||||
wallet = object.__new__(LndWallet)
|
||||
cast(Any, wallet).router_rpc = SimpleNamespace(
|
||||
SendPaymentV2=mocker.Mock(
|
||||
return_value=SimpleNamespace(
|
||||
read=mocker.AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
status=LndPayment.PaymentStatus.IN_FLIGHT,
|
||||
payment_hash="payment-hash",
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
response = await wallet.pay_invoice("bolt11", 1_000)
|
||||
|
||||
assert response.ok is None
|
||||
assert response.checking_id == "payment-hash"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("error", "expected"),
|
||||
[
|
||||
(
|
||||
{
|
||||
"code": 0,
|
||||
"message": "destination is not reachable",
|
||||
"attempts": [{"status": "failed"}],
|
||||
},
|
||||
False,
|
||||
),
|
||||
(
|
||||
{
|
||||
"code": 0,
|
||||
"message": "payment is still running",
|
||||
"attempts": [{"status": "pending"}],
|
||||
},
|
||||
None,
|
||||
),
|
||||
({"code": 0, "message": "unclassified RPC error"}, None),
|
||||
({"code": 205, "message": "unable to find a route"}, False),
|
||||
],
|
||||
)
|
||||
async def test_corelightning_only_terminal_rpc_errors_are_failed(
|
||||
mocker: MockerFixture,
|
||||
error: dict,
|
||||
expected: bool | None,
|
||||
):
|
||||
wallet = object.__new__(CoreLightningWallet)
|
||||
wallet.pay = "pay"
|
||||
wallet.pay_failure_error_codes = [-32602, 201, 203, 205, 206, 207, 210]
|
||||
cast(Any, wallet).ln = SimpleNamespace(
|
||||
call=mocker.Mock(side_effect=RpcError("pay", {}, cast(Any, error)))
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.wallets.corelightning.bolt11_decode",
|
||||
return_value=SimpleNamespace(
|
||||
payment_hash="payment-hash",
|
||||
amount_msat=1_000,
|
||||
description="",
|
||||
),
|
||||
)
|
||||
mocker.patch.object(
|
||||
wallet, "get_payment_status", return_value=PaymentPendingStatus()
|
||||
)
|
||||
|
||||
response = await wallet.pay_invoice("bolt11", 1_000)
|
||||
|
||||
assert response.ok is expected
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("status_code", "expected"),
|
||||
[
|
||||
(400, None),
|
||||
(401, False),
|
||||
(403, False),
|
||||
(404, False),
|
||||
(405, False),
|
||||
(408, None),
|
||||
(409, None),
|
||||
(422, None),
|
||||
(429, None),
|
||||
(500, None),
|
||||
],
|
||||
)
|
||||
async def test_lnpay_only_treats_client_rejection_as_failed(
|
||||
mocker: MockerFixture, status_code: int, expected: bool | None
|
||||
):
|
||||
wallet = object.__new__(LNPayWallet)
|
||||
wallet.wallet_key = "wallet-key"
|
||||
cast(Any, wallet).client = SimpleNamespace(
|
||||
post=mocker.AsyncMock(
|
||||
return_value=_response(status_code, json={"message": "error"})
|
||||
)
|
||||
)
|
||||
|
||||
response = await wallet.pay_invoice("bolt11", 1_000)
|
||||
|
||||
assert response.ok is expected
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_lnpay_malformed_payment_response_is_pending(mocker: MockerFixture):
|
||||
wallet = object.__new__(LNPayWallet)
|
||||
wallet.wallet_key = "wallet-key"
|
||||
cast(Any, wallet).client = SimpleNamespace(
|
||||
post=mocker.AsyncMock(return_value=_response(200, content=b"not-json"))
|
||||
)
|
||||
|
||||
response = await wallet.pay_invoice("bolt11", 1_000)
|
||||
|
||||
assert response.ok is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("wallet_class", [LnTipsWallet, OpenNodeWallet, ZBDWallet])
|
||||
@pytest.mark.parametrize(
|
||||
("status_code", "expected"),
|
||||
[(400, None), (401, False), (422, None)],
|
||||
)
|
||||
async def test_http_wallets_only_fail_definite_request_rejections(
|
||||
mocker: MockerFixture,
|
||||
wallet_class: type[LnTipsWallet | OpenNodeWallet | ZBDWallet],
|
||||
status_code: int,
|
||||
expected: bool | None,
|
||||
):
|
||||
wallet = object.__new__(wallet_class)
|
||||
wallet.endpoint = "https://wallet.test"
|
||||
cast(Any, wallet).client = SimpleNamespace(
|
||||
post=mocker.AsyncMock(
|
||||
return_value=_response(status_code, json={"message": "error"})
|
||||
)
|
||||
)
|
||||
|
||||
response = await wallet.pay_invoice("bolt11", 1_000)
|
||||
|
||||
assert response.ok is expected
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_breez_immediate_failed_state_is_failed(mocker: MockerFixture, settings):
|
||||
settings.breez_use_trampoline = False
|
||||
breez_wallet = cast(Any, breez_wallet_module)
|
||||
wallet = object.__new__(breez_wallet.BreezSdkWallet)
|
||||
mocker.patch(
|
||||
"lnbits.wallets.breez.bolt11_decode",
|
||||
return_value=SimpleNamespace(payment_hash="payment-hash"),
|
||||
)
|
||||
cast(Any, wallet).sdk_services = SimpleNamespace(
|
||||
send_payment=mocker.Mock(
|
||||
return_value=SimpleNamespace(
|
||||
payment=SimpleNamespace(status=breez_wallet.BreezPaymentStatus.FAILED)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
response = await cast(Any, wallet).pay_invoice("bolt11", 1_000)
|
||||
|
||||
assert response.ok is False
|
||||
assert response.checking_id == "payment-hash"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_breez_liquid_timed_out_outgoing_payment_is_failed(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
breez_liquid_wallet = cast(Any, breez_liquid_wallet_module)
|
||||
wallet = object.__new__(breez_liquid_wallet.BreezLiquidSdkWallet)
|
||||
cast(Any, wallet).sdk_services = SimpleNamespace(
|
||||
get_payment=mocker.Mock(
|
||||
return_value=SimpleNamespace(
|
||||
payment_type=breez_liquid_wallet.PaymentType.SEND,
|
||||
status=breez_liquid_wallet.PaymentState.TIMED_OUT,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
status = await cast(Any, wallet).get_payment_status("payment-hash")
|
||||
|
||||
assert status.paid is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_breez_liquid_prepare_error_is_failed(mocker: MockerFixture):
|
||||
breez_liquid_wallet = cast(Any, breez_liquid_wallet_module)
|
||||
wallet = object.__new__(breez_liquid_wallet.BreezLiquidSdkWallet)
|
||||
mocker.patch(
|
||||
"lnbits.wallets.breez_liquid.bolt11_decode",
|
||||
return_value=SimpleNamespace(payment_hash="payment-hash"),
|
||||
)
|
||||
cast(Any, wallet).sdk_services = SimpleNamespace(
|
||||
prepare_send_payment=mocker.Mock(side_effect=RuntimeError("cannot prepare"))
|
||||
)
|
||||
|
||||
response = await cast(Any, wallet).pay_invoice("bolt11", 1_000)
|
||||
|
||||
assert response.ok is False
|
||||
assert response.checking_id == "payment-hash"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("code", "expected"),
|
||||
[("PAYMENT_FAILED", False), ("INTERNAL", None), ("OTHER", None)],
|
||||
)
|
||||
async def test_nwc_only_explicit_payment_failure_is_failed(
|
||||
mocker: MockerFixture, code: str, expected: bool | None
|
||||
):
|
||||
wallet = object.__new__(NWCWallet)
|
||||
cast(Any, wallet).conn = SimpleNamespace(
|
||||
call=mocker.AsyncMock(side_effect=NWCError(code, "error"))
|
||||
)
|
||||
|
||||
response = await wallet.pay_invoice("bolt11", 1_000)
|
||||
|
||||
assert response.ok is expected
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_phoenix_request_error_is_pending(mocker: MockerFixture):
|
||||
wallet = object.__new__(PhoenixdWallet)
|
||||
wallet.endpoint = "https://wallet.test"
|
||||
request = httpx.Request("POST", "https://wallet.test/payinvoice")
|
||||
cast(Any, wallet).client = SimpleNamespace(
|
||||
post=mocker.AsyncMock(
|
||||
side_effect=httpx.ReadError("read failed", request=request)
|
||||
)
|
||||
)
|
||||
|
||||
response = await wallet.pay_invoice("bolt11", 1_000)
|
||||
|
||||
assert response.ok is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_spark_sidecar_missing_checking_id_is_pending(mocker: MockerFixture):
|
||||
wallet = object.__new__(SparkL2Wallet)
|
||||
mocker.patch.object(wallet, "_request", return_value={"status": "PENDING"})
|
||||
|
||||
response = await wallet.pay_invoice("not-a-bolt11", 1_000)
|
||||
|
||||
assert response.ok is None
|
||||
assert response.checking_id is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("provider_status", "expected"),
|
||||
[("unpaid", None), ("expired", False), ("paid", True)],
|
||||
)
|
||||
async def test_spark_invoice_uses_exact_terminal_status(
|
||||
mocker: MockerFixture,
|
||||
provider_status: str,
|
||||
expected: bool | None,
|
||||
):
|
||||
wallet = object.__new__(SparkWallet)
|
||||
mocker.patch.object(
|
||||
wallet,
|
||||
"listinvoices",
|
||||
return_value={"invoices": [{"status": provider_status}]},
|
||||
)
|
||||
|
||||
status = await wallet.get_invoice_status("invoice-id")
|
||||
|
||||
assert status.paid is expected
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("state", "expected"),
|
||||
[
|
||||
(boltzrpc_pb2.SwapState.ERROR, False),
|
||||
(999, None),
|
||||
],
|
||||
)
|
||||
async def test_boltz_only_known_terminal_swap_state_is_failed(
|
||||
mocker: MockerFixture, state: int, expected: bool | None
|
||||
):
|
||||
wallet = object.__new__(BoltzWallet)
|
||||
wallet.metadata = None
|
||||
cast(Any, wallet).rpc = SimpleNamespace(
|
||||
GetSwapInfo=mocker.AsyncMock(
|
||||
return_value=SimpleNamespace(swap=SimpleNamespace(state=state))
|
||||
)
|
||||
)
|
||||
|
||||
status = await wallet.get_payment_status("00" * 32)
|
||||
|
||||
assert status.paid is expected
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("code", "details", "expected"),
|
||||
[
|
||||
(
|
||||
grpc.StatusCode.INVALID_ARGUMENT,
|
||||
"invalid invoice or lnurl: invalid HRP",
|
||||
False,
|
||||
),
|
||||
(
|
||||
grpc.StatusCode.UNKNOWN,
|
||||
"boltz error: could not find route to pay invoice",
|
||||
False,
|
||||
),
|
||||
(grpc.StatusCode.UNKNOWN, "payment response interrupted", None),
|
||||
(grpc.StatusCode.ALREADY_EXISTS, "swap already exists", None),
|
||||
],
|
||||
)
|
||||
async def test_boltz_only_pre_dispatch_create_swap_errors_are_failed(
|
||||
mocker: MockerFixture,
|
||||
code: grpc.StatusCode,
|
||||
details: str,
|
||||
expected: bool | None,
|
||||
):
|
||||
metadata = grpc.aio.Metadata()
|
||||
error = grpc.aio.AioRpcError(code, metadata, metadata, details=details)
|
||||
wallet = object.__new__(BoltzWallet)
|
||||
wallet.metadata = None
|
||||
wallet.wallet_id = 1
|
||||
cast(Any, wallet).rpc = SimpleNamespace(
|
||||
GetPairInfo=mocker.AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
fees=SimpleNamespace(percentage=0, miner_fees=0)
|
||||
)
|
||||
),
|
||||
CreateSwap=mocker.AsyncMock(side_effect=error),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.wallets.boltz.decode",
|
||||
return_value=SimpleNamespace(
|
||||
amount_msat=1_000,
|
||||
payment_hash="payment-hash",
|
||||
),
|
||||
)
|
||||
|
||||
response = await wallet.pay_invoice("bolt11", 1_000)
|
||||
|
||||
assert response.ok is expected
|
||||
assert response.checking_id == "payment-hash"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("state", "expected"),
|
||||
[
|
||||
(boltzrpc_pb2.ERROR, False),
|
||||
(boltzrpc_pb2.PENDING, None),
|
||||
(boltzrpc_pb2.SUCCESSFUL, True),
|
||||
],
|
||||
)
|
||||
async def test_boltz_resolves_ambiguous_create_swap_error_from_backend_state(
|
||||
mocker: MockerFixture,
|
||||
state: int,
|
||||
expected: bool | None,
|
||||
):
|
||||
metadata = grpc.aio.Metadata()
|
||||
error = grpc.aio.AioRpcError(
|
||||
grpc.StatusCode.UNKNOWN,
|
||||
metadata,
|
||||
metadata,
|
||||
details='sendrawtransaction RPC error: {"message":"txn-mempool-conflict"}',
|
||||
)
|
||||
payment_hash = "00" * 32
|
||||
wallet = object.__new__(BoltzWallet)
|
||||
wallet.metadata = None
|
||||
wallet.wallet_id = 1
|
||||
cast(Any, wallet).rpc = SimpleNamespace(
|
||||
GetPairInfo=mocker.AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
fees=SimpleNamespace(percentage=0, miner_fees=0)
|
||||
)
|
||||
),
|
||||
CreateSwap=mocker.AsyncMock(side_effect=error),
|
||||
GetSwapInfo=mocker.AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
swap=SimpleNamespace(
|
||||
state=state,
|
||||
service_fee=1,
|
||||
onchain_fee=2,
|
||||
status="swap status",
|
||||
preimage="preimage",
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.wallets.boltz.decode",
|
||||
return_value=SimpleNamespace(
|
||||
amount_msat=1_000,
|
||||
payment_hash=payment_hash,
|
||||
),
|
||||
)
|
||||
|
||||
response = await wallet.pay_invoice("bolt11", 1_000)
|
||||
|
||||
assert response.ok is expected
|
||||
assert response.checking_id == payment_hash
|
||||
assert response.fee_msat == (3_000 if expected is True else None)
|
||||
assert response.preimage == ("preimage" if expected is True else None)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_boltz_error_text_without_terminal_state_is_pending(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
async def swap_updates():
|
||||
yield SimpleNamespace(
|
||||
swap=SimpleNamespace(state=999, error="unrecognized transient error")
|
||||
)
|
||||
|
||||
wallet = object.__new__(BoltzWallet)
|
||||
wallet.metadata = None
|
||||
wallet.wallet_id = 1
|
||||
cast(Any, wallet).rpc = SimpleNamespace(
|
||||
GetPairInfo=mocker.AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
fees=SimpleNamespace(percentage=0, miner_fees=0)
|
||||
)
|
||||
),
|
||||
CreateSwap=mocker.AsyncMock(return_value=SimpleNamespace(id="swap-id")),
|
||||
GetSwapInfoStream=mocker.Mock(return_value=swap_updates()),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.wallets.boltz.decode",
|
||||
return_value=SimpleNamespace(
|
||||
amount_msat=1_000,
|
||||
payment_hash="payment-hash",
|
||||
),
|
||||
)
|
||||
|
||||
response = await wallet.pay_invoice("bolt11", 1_000)
|
||||
|
||||
assert response.ok is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_opennode_terminal_error_status_is_failed(mocker: MockerFixture):
|
||||
wallet = object.__new__(OpenNodeWallet)
|
||||
cast(Any, wallet).client = SimpleNamespace(
|
||||
get=mocker.AsyncMock(
|
||||
return_value=_response(
|
||||
200,
|
||||
json={"data": {"status": "error", "fee": 1}},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
status = await wallet.get_payment_status("withdrawal-id")
|
||||
|
||||
assert status.paid is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_opennode_terminal_status_does_not_require_provider_id(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
wallet = object.__new__(OpenNodeWallet)
|
||||
cast(Any, wallet).client = SimpleNamespace(
|
||||
post=mocker.AsyncMock(
|
||||
return_value=_response(
|
||||
200,
|
||||
json={"data": {"status": "failed"}},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
response = await wallet.pay_invoice("bolt11", 1_000)
|
||||
|
||||
assert response.ok is False
|
||||
assert response.checking_id is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("provider_status", "expected"),
|
||||
[("processing", None), ("completed", True), ("failed", False)],
|
||||
)
|
||||
async def test_zbd_preserves_provider_id_and_exact_status(
|
||||
mocker: MockerFixture,
|
||||
provider_status: str,
|
||||
expected: bool | None,
|
||||
):
|
||||
wallet = object.__new__(ZBDWallet)
|
||||
cast(Any, wallet).client = SimpleNamespace(
|
||||
post=mocker.AsyncMock(
|
||||
return_value=_response(
|
||||
200,
|
||||
json={
|
||||
"data": {
|
||||
"id": "zbd-payment-id",
|
||||
"status": provider_status,
|
||||
"fee": "10",
|
||||
"preimage": "preimage",
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
response = await wallet.pay_invoice("bolt11", 1_000)
|
||||
|
||||
assert response.ok is expected
|
||||
assert response.checking_id == "zbd-payment-id"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_zbd_terminal_status_does_not_require_provider_id(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
wallet = object.__new__(ZBDWallet)
|
||||
cast(Any, wallet).client = SimpleNamespace(
|
||||
post=mocker.AsyncMock(
|
||||
return_value=_response(
|
||||
200,
|
||||
json={"data": {"status": "failed"}},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
response = await wallet.pay_invoice("bolt11", 1_000)
|
||||
|
||||
assert response.ok is False
|
||||
assert response.checking_id is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_strike_invalid_fallback_identifier_is_pending(mocker: MockerFixture):
|
||||
wallet = object.__new__(StrikeWallet)
|
||||
wallet.pending_payments = {}
|
||||
cast(Any, wallet)._get = mocker.AsyncMock(
|
||||
return_value=_response(
|
||||
400,
|
||||
json={
|
||||
"data": {
|
||||
"code": "INVALID_DATA",
|
||||
"validationErrors": {
|
||||
"paymentId": [
|
||||
{
|
||||
"code": "INVALID_DATA",
|
||||
"message": "paymentId is not valid.",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
status = await wallet._get_payment_status_by_checking_id("payment-hash")
|
||||
|
||||
assert status.paid is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_strike_ambiguous_execution_uses_payment_hash_fallback(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
wallet = object.__new__(StrikeWallet)
|
||||
wallet.pending_payments = {}
|
||||
mocker.patch(
|
||||
"lnbits.wallets.strike.bolt11_decode",
|
||||
return_value=SimpleNamespace(payment_hash="payment-hash"),
|
||||
)
|
||||
mocker.patch.object(
|
||||
wallet,
|
||||
"_create_payment_quote",
|
||||
return_value=("quote-id", None),
|
||||
)
|
||||
mocker.patch.object(
|
||||
wallet,
|
||||
"_execute_payment_quote",
|
||||
return_value=(None, "request timed out"),
|
||||
)
|
||||
|
||||
response = await wallet.pay_invoice("bolt11", 1_000)
|
||||
|
||||
assert response.ok is None
|
||||
assert response.checking_id is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_strike_terminal_state_does_not_require_payment_id(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
wallet = object.__new__(StrikeWallet)
|
||||
wallet.pending_payments = {}
|
||||
mocker.patch(
|
||||
"lnbits.wallets.strike.bolt11_decode",
|
||||
return_value=SimpleNamespace(payment_hash="payment-hash"),
|
||||
)
|
||||
mocker.patch.object(
|
||||
wallet,
|
||||
"_create_payment_quote",
|
||||
return_value=("quote-id", None),
|
||||
)
|
||||
mocker.patch.object(
|
||||
wallet,
|
||||
"_execute_payment_quote",
|
||||
return_value=({"state": "FAILED"}, None),
|
||||
)
|
||||
|
||||
response = await wallet.pay_invoice("bolt11", 1_000)
|
||||
|
||||
assert response.ok is False
|
||||
assert response.checking_id == "payment-hash"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("state", ["CANCELED", "TIMED_OUT", "UNKNOWN"])
|
||||
async def test_strike_undocumented_payment_state_is_pending(
|
||||
mocker: MockerFixture, state: str
|
||||
):
|
||||
wallet = object.__new__(StrikeWallet)
|
||||
wallet.pending_payments = {}
|
||||
mocker.patch(
|
||||
"lnbits.wallets.strike.bolt11_decode",
|
||||
return_value=SimpleNamespace(payment_hash="payment-hash"),
|
||||
)
|
||||
mocker.patch.object(
|
||||
wallet,
|
||||
"_create_payment_quote",
|
||||
return_value=("quote-id", None),
|
||||
)
|
||||
mocker.patch.object(
|
||||
wallet,
|
||||
"_execute_payment_quote",
|
||||
return_value=({"state": state, "paymentId": "payment-id"}, None),
|
||||
)
|
||||
|
||||
response = await wallet.pay_invoice("bolt11", 1_000)
|
||||
|
||||
assert response.ok is None
|
||||
assert response.checking_id == "payment-id"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_strike_persisted_payment_hash_not_found_stays_pending(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
wallet = object.__new__(StrikeWallet)
|
||||
wallet.pending_payments = {}
|
||||
cast(Any, wallet)._get = mocker.AsyncMock(
|
||||
return_value=_response(404, text="Not Found")
|
||||
)
|
||||
|
||||
payment_hash = "ab" * 32
|
||||
status = await wallet.get_payment_status(payment_hash)
|
||||
|
||||
assert status.paid is None
|
||||
cast(Any, wallet)._get.assert_awaited_once_with(f"/payments/{payment_hash}")
|
||||
|
|
@ -38,7 +38,9 @@
|
|||
"wallet_class": "LNbitsWallet",
|
||||
"settings": {
|
||||
"lnbits_endpoint": "http://127.0.0.1:8555",
|
||||
"lnbits_key": null,
|
||||
"lnbits_admin_key": "f171ba022a764e679eef950b21fb1c04",
|
||||
"lnbits_invoice_key": null,
|
||||
"user_agent": "LNbits/Tests"
|
||||
}
|
||||
},
|
||||
|
|
@ -1831,6 +1833,26 @@
|
|||
"fee_msat": null,
|
||||
"preimage": null
|
||||
},
|
||||
"expect_by_funding_source": {
|
||||
"alby": {
|
||||
"error_message": "Not Found",
|
||||
"success": false,
|
||||
"pending": false,
|
||||
"failed": true,
|
||||
"checking_id": null,
|
||||
"fee_msat": null,
|
||||
"preimage": null
|
||||
},
|
||||
"eclair": {
|
||||
"error_message": "Unable to connect to http://127.0.0.1:8555.",
|
||||
"success": false,
|
||||
"pending": false,
|
||||
"failed": true,
|
||||
"checking_id": null,
|
||||
"fee_msat": null,
|
||||
"preimage": null
|
||||
}
|
||||
},
|
||||
"mocks": {
|
||||
"corelightningrest": {
|
||||
"pay_invoice_endpoint": [
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue