diff --git a/Makefile b/Makefile index 0fac253..d39bf3c 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,5 @@ +.PHONY: all format check prettier pyright mypy black ruff checkruff checkprettier checkblack checkeditorconfig test teststatic install-pre-commit-hook pre-commit checkbundle + all: format check format: prettier black ruff @@ -30,10 +32,13 @@ checkblack: checkeditorconfig: editorconfig-checker -test: +test: teststatic PYTHONUNBUFFERED=1 \ DEBUG=true \ uv run pytest + +teststatic: + node tests/test_ui.js install-pre-commit-hook: @echo "Installing pre-commit hook to git" @echo "Uninstall the hook with uv run pre-commit uninstall" diff --git a/crud.py b/crud.py index 9efa945..924eed2 100644 --- a/crud.py +++ b/crud.py @@ -1,8 +1,12 @@ import secrets -from datetime import datetime +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from datetime import datetime, timedelta, timezone -from lnbits.db import Database +from lnbits.db import Connection, Database, model_to_dict, update_query from lnbits.helpers import urlsafe_short_hash +from loguru import logger +from sqlalchemy import text from .models import Card, CreateCardData, Hit, Refund @@ -57,7 +61,29 @@ async def create_card(data: CreateCardData, wallet_id: str) -> Card: async def update_card(card: Card) -> Card: - await db.update("boltcards.cards", card) + async with _card_transaction(card.id) as conn: + previous = await conn.fetchone( + "SELECT * FROM boltcards.cards WHERE id = :id", {"id": card.id}, Card + ) + keys_changed = previous is not None and ( + previous.uid.upper() != card.uid.upper() + or bytes.fromhex(previous.k1) != bytes.fromhex(card.k1) + or bytes.fromhex(previous.k2) != bytes.fromhex(card.k2) + ) + if previous and not keys_changed: + card.counter = max(card.counter, previous.counter) + card.otp = secrets.token_hex(16) + await conn.conn.execute( + text(update_query("boltcards.cards", card)), model_to_dict(card) + ) + if previous and (not card.enable or keys_changed): + await conn.conn.execute( + text( + "UPDATE boltcards.hits SET spent = TRUE " + "WHERE card_id = :id AND spent = FALSE" + ), + {"id": card.id}, + ) return card @@ -79,10 +105,10 @@ async def get_card(card_id: str) -> Card | None: ) -async def get_card_by_uid(card_uid: str) -> Card | None: +async def get_card_by_uid(card_uid: str, wallet_id: str) -> Card | None: return await db.fetchone( - "SELECT * FROM boltcards.cards WHERE uid = :uid", - {"uid": card_uid.upper()}, + "SELECT * FROM boltcards.cards WHERE uid = :uid AND wallet = :wallet", + {"uid": card_uid.upper(), "wallet": wallet_id}, Card, ) @@ -95,14 +121,6 @@ async def get_card_by_external_id(external_id: str) -> Card | None: ) -async def get_card_by_otp(otp: str) -> Card | None: - return await db.fetchone( - "SELECT * FROM boltcards.cards WHERE otp = :otp", - {"otp": otp}, - Card, - ) - - async def delete_card(card_id: str) -> None: # Delete cards await db.execute("DELETE FROM boltcards.cards WHERE id = :id", {"id": card_id}) @@ -118,26 +136,73 @@ async def delete_card(card_id: str) -> None: ) -async def update_card_counter(counter: int, card_id: str): - await db.execute( - "UPDATE boltcards.cards SET counter = :counter WHERE id = :id", - {"counter": counter, "id": card_id}, - ) - - async def enable_disable_card(enable: bool, card_id: str) -> Card | None: - await db.execute( - "UPDATE boltcards.cards SET enable = :enable WHERE id = :id", - {"enable": enable, "id": card_id}, - ) - return await get_card(card_id) + async with _card_transaction(card_id) as conn: + card = await conn.fetchone( + "SELECT * FROM boltcards.cards WHERE id = :id", {"id": card_id}, Card + ) + if not card: + return None + if enable and not all((card.k0, card.k1, card.k2)): + raise ValueError("Re-provision the card before enabling it.") + await conn.conn.execute( + text("UPDATE boltcards.cards SET enable = :enable WHERE id = :id"), + {"enable": enable, "id": card_id}, + ) + if not enable: + await conn.conn.execute( + text( + "UPDATE boltcards.hits SET spent = TRUE " + "WHERE card_id = :id AND spent = FALSE" + ), + {"id": card_id}, + ) + card.enable = enable + return card -async def update_card_otp(otp: str, card_id: str): - await db.execute( - "UPDATE boltcards.cards SET otp = :otp WHERE id = :id", - {"otp": otp, "id": card_id}, +async def consume_card_otp( + otp: str, wipe: bool = False, uid: str | None = None +) -> Card | None: + if len(otp) != 32: + return None + card = await db.fetchone( + "SELECT * FROM boltcards.cards WHERE otp = :otp", {"otp": otp}, Card ) + if not card: + return None + async with _card_transaction(card.id) as conn: + card = await conn.fetchone( + "SELECT * FROM boltcards.cards WHERE id = :id AND otp = :otp", + {"id": card.id, "otp": otp}, + Card, + ) + if not card or (uid and uid.upper() != card.uid.upper()): + return None + if not all((card.k0, card.k1, card.k2)): + return None + changes = "otp = :new_otp" + if wipe: + changes += ( + ", enable = FALSE, k0 = '', k1 = '', k2 = '', " + "prev_k0 = '', prev_k1 = '', prev_k2 = ''" + ) + result = await conn.conn.execute( + text(f"UPDATE boltcards.cards SET {changes} WHERE id = :id AND otp = :otp"), + {"id": card.id, "otp": otp, "new_otp": secrets.token_hex(16)}, + ) + if result.rowcount != 1: + return None + if wipe: + await conn.conn.execute( + text( + "UPDATE boltcards.hits SET spent = TRUE " + "WHERE card_id = :id AND spent = FALSE" + ), + {"id": card.id}, + ) + logger.info("Boltcard auth redeemed: {} (wipe={})", card.id, wipe) + return card async def get_hit(hit_id: str) -> Hit | None: @@ -159,58 +224,106 @@ async def get_hits(cards_ids: list[str]) -> list[Hit]: ) -async def get_hits_today(card_id: str) -> list[Hit]: - rows = await db.fetchall( - "SELECT * FROM boltcards.hits WHERE card_id = :id", - {"id": card_id}, - Hit, - ) - updatedrow = [] - for hit in rows: - if datetime.now().date() == hit.time.date(): - updatedrow.append(hit) - - return updatedrow +async def create_hit(card: Card, ip: str, useragent: str, new_ctr: int) -> Hit: + async with _card_transaction(card.id) as conn: + current = await conn.fetchone( + "SELECT * FROM boltcards.cards WHERE id = :id", {"id": card.id}, Card + ) + if not current or not current.enable: + raise ValueError("Card is disabled.") + if (current.uid, current.k1, current.k2) != (card.uid, card.k1, card.k2): + raise ValueError("Card has changed. Please tap again.") + max_msat = min(current.tx_limit * 1000, await _remaining_budget(conn, current)) + if max_msat < 1000: + max_msat = 0 + result = await conn.conn.execute( + text( + "UPDATE boltcards.cards SET counter = :counter " + "WHERE id = :id AND counter < :counter AND enable = TRUE" + ), + {"counter": new_ctr, "id": card.id}, + ) + if result.rowcount != 1: + raise ValueError("This link is already used.") + hit_id = secrets.token_hex(16) + await conn.conn.execute( + text( + """ + INSERT INTO boltcards.hits + (id, card_id, ip, spent, useragent, old_ctr, new_ctr, amount, max_msat) + VALUES (:id, :card_id, :ip, FALSE, :agent, + :old_ctr, :new_ctr, 0, :max_msat) + """ + ), + { + "id": hit_id, + "card_id": card.id, + "ip": ip, + "agent": useragent, + "old_ctr": current.counter, + "new_ctr": new_ctr, + "max_msat": max_msat, + }, + ) + return await conn.fetchone( + "SELECT * FROM boltcards.hits WHERE id = :id", {"id": hit_id}, Hit + ) -async def spend_hit(card_id: str, amount: int): - await db.execute( - "UPDATE boltcards.hits SET spent = :spent, amount = :amount WHERE id = :id", - {"spent": True, "amount": amount, "id": card_id}, - ) - return await get_hit(card_id) +async def spend_hit(hit_id: str, amount_msat: int) -> tuple[Hit, Card]: + hit = await get_hit(hit_id) + if not hit: + raise ValueError("LNURL-withdraw record not found.") + async with _card_transaction(hit.card_id) as conn: + card = await conn.fetchone( + "SELECT * FROM boltcards.cards WHERE id = :id", {"id": hit.card_id}, Card + ) + hit = await conn.fetchone( + "SELECT * FROM boltcards.hits WHERE id = :id", {"id": hit_id}, Hit + ) + if not card or not card.enable: + raise ValueError("Card is disabled.") + if not hit or hit.spent: + raise ValueError("Payment already claimed.") + now = datetime.now(timezone.utc) + remaining = await _remaining_budget(conn, card, now) + if ( + not 1000 + <= amount_msat + <= min(hit.max_msat, card.tx_limit * 1000, remaining) + ): + raise ValueError("Invoice exceeds the available withdrawal limit.") + result = await conn.conn.execute( + text( + f""" + UPDATE boltcards.hits + SET spent = TRUE, amount = :amount, amount_msat = :amount_msat, + claimed_at = {db.timestamp_placeholder('claimed_at')} + WHERE id = :id AND spent = FALSE + """ + ), + { + "id": hit.id, + "amount": (amount_msat + 999) // 1000, + "amount_msat": amount_msat, + "claimed_at": now.timestamp(), + }, + ) + if result.rowcount != 1: + raise ValueError("Payment already claimed.") + hit.spent = True + hit.amount = (amount_msat + 999) // 1000 + hit.amount_msat = amount_msat + hit.claimed_at = now + return hit, card -async def create_hit(card_id, ip, useragent, old_ctr, new_ctr) -> Hit: - hit_id = urlsafe_short_hash() +async def release_hit(hit_id: str) -> None: + # A failed claim stays consumed, so the same bearer token cannot be retried. await db.execute( - """ - INSERT INTO boltcards.hits ( - id, - card_id, - ip, - spent, - useragent, - old_ctr, - new_ctr, - amount - ) - VALUES (:id, :card_id, :ip, :spent, :useragent, :old_ctr, :new_ctr, :amount) - """, - { - "id": hit_id, - "card_id": card_id, - "ip": ip, - "spent": False, - "useragent": useragent, - "old_ctr": old_ctr, - "new_ctr": new_ctr, - "amount": 0, - }, + "UPDATE boltcards.hits SET amount = 0, amount_msat = 0 WHERE id = :id", + {"id": hit_id}, ) - hit = await get_hit(hit_id) - assert hit, "Newly recorded hit couldn't be retrieved" - return hit async def create_refund(hit_id, refund_amount) -> Refund: @@ -251,3 +364,33 @@ async def get_refunds(hits_ids: list[str]) -> list[Refund]: f"SELECT * FROM boltcards.refunds WHERE hit_id IN ({q})", model=Refund, ) + + +@asynccontextmanager +async def _card_transaction(card_id: str) -> AsyncIterator[Connection]: + async with db.connect() as conn: + # Connection.execute commits; keep these writes in one transaction. + async with conn.conn.begin(): + # Lock before reading; this also serializes SQLite writers. + await conn.conn.execute( + text("UPDATE boltcards.cards SET counter = counter WHERE id = :id"), + {"id": card_id}, + ) + yield conn + + +async def _remaining_budget( + conn: Connection, card: Card, now: datetime | None = None +) -> int: + now = now or datetime.now(timezone.utc) + start = now.replace(hour=0, minute=0, second=0, microsecond=0) + end = start + timedelta(days=1) + row: dict = await conn.fetchone( + f""" + SELECT COALESCE(SUM(amount_msat), 0) AS total FROM boltcards.hits + WHERE card_id = :id AND claimed_at >= {db.timestamp_placeholder('start')} + AND claimed_at < {db.timestamp_placeholder('end')} + """, + {"id": card.id, "start": start.timestamp(), "end": end.timestamp()}, + ) + return card.daily_limit * 1000 - int(row["total"]) diff --git a/migrations.py b/migrations.py index 8d8464f..db6831f 100644 --- a/migrations.py +++ b/migrations.py @@ -128,3 +128,27 @@ async def m002_correct_typing(db): """ ) await db.execute("DROP TABLE boltcards.cards_m001;") + + +async def m003_claims(db): + await db.execute( + "ALTER TABLE boltcards.hits ADD COLUMN amount_msat " + f"{db.big_int} NOT NULL DEFAULT 0" + ) + await db.execute( + "ALTER TABLE boltcards.hits ADD COLUMN max_msat " + f"{db.big_int} NOT NULL DEFAULT 0" + ) + await db.execute("ALTER TABLE boltcards.hits ADD COLUMN claimed_at TIMESTAMP") + await db.execute( + "UPDATE boltcards.hits SET amount_msat = amount * 1000, claimed_at = time " + "WHERE spent = TRUE" + ) + await db.execute( + f""" + UPDATE boltcards.hits SET max_msat = COALESCE( + (SELECT CAST(tx_limit AS {db.big_int}) * 1000 FROM boltcards.cards + WHERE boltcards.cards.id = boltcards.hits.card_id), 0) + WHERE spent = FALSE + """ + ) diff --git a/models.py b/models.py index 0856594..c982d0c 100644 --- a/models.py +++ b/models.py @@ -10,7 +10,7 @@ ZERO_KEY = "00000000000000000000000000000000" -class Card(BaseModel): +class CardInfo(BaseModel): id: str wallet: str card_name: str @@ -20,6 +20,10 @@ class Card(BaseModel): tx_limit: int daily_limit: int enable: bool + time: datetime + + +class Card(CardInfo): k0: str k1: str k2: str @@ -27,7 +31,6 @@ class Card(BaseModel): prev_k1: str prev_k2: str otp: str - time: datetime def lnurl(self, req: Request) -> Lnurl: url = str( @@ -42,9 +45,9 @@ async def lnurlpay_metadata(self) -> LnurlPayMetadata: class CreateCardData(BaseModel): card_name: str = Query(...) uid: str = Query(...) - counter: int = Query(0) - tx_limit: int = Query(0) - daily_limit: int = Query(0) + counter: int = Query(0, ge=0, le=16777215) + tx_limit: int = Query(0, ge=0) + daily_limit: int = Query(0, ge=0) enable: bool = Query(True) k0: str = Query(ZERO_KEY) k1: str = Query(ZERO_KEY) @@ -63,6 +66,9 @@ class Hit(BaseModel): old_ctr: int new_ctr: int amount: int + amount_msat: int = 0 + max_msat: int = 0 + claimed_at: datetime | None = None time: datetime diff --git a/pyproject.toml b/pyproject.toml index dd4b55f..dd1a310 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lnbits-boltcards" -version = "0.0.0" +version = "1.2.0" 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" }] @@ -10,20 +10,24 @@ dependencies = [ "lnbits>1" ] [tool.poetry] package-mode = false -[tool.uv] -dev-dependencies = [ +[dependency-groups] +dev = [ "black", "pytest-asyncio", "pytest", "mypy", "pre-commit", "ruff", - "pytest-md", ] [tool.mypy] plugins = ["pydantic.mypy"] +# SQLAlchemy 1.4 does not bundle type information. +[[tool.mypy.overrides]] +module = ["sqlalchemy.*"] +ignore_missing_imports = true + [tool.pydantic-mypy] init_forbid_extra = true init_typed = true diff --git a/services.py b/services.py new file mode 100644 index 0000000..dff0033 --- /dev/null +++ b/services.py @@ -0,0 +1,59 @@ +import bolt11 +from lnbits.core.services import pay_invoice +from lnbits.exceptions import PaymentError +from loguru import logger + +from .crud import create_hit, get_card_by_external_id, get_hit, release_hit, spend_hit +from .models import Hit +from .nxp424 import decrypt_sun, get_sun_mac + + +async def scan_card(external_id: str, p: str, c: str, ip: str, useragent: str) -> Hit: + card = await get_card_by_external_id(external_id) + if not card: + raise ValueError("Card not found.") + if not card.enable: + raise ValueError("Card is disabled.") + try: + card_uid, counter = decrypt_sun(bytes.fromhex(p), bytes.fromhex(card.k1)) + expected_mac = get_sun_mac(card_uid, counter, bytes.fromhex(card.k2)) + except Exception as exc: + raise ValueError("Error decrypting card.") from exc + if card.uid.upper() != card_uid.hex().upper(): + raise ValueError("Card UID mis-match.") + if c.upper() != expected_mac.hex().upper(): + raise ValueError("CMAC does not check.") + return await create_hit(card, ip, useragent, int.from_bytes(counter, "little")) + + +async def pay_card_invoice(hit_id: str, payment_request: str | None) -> None: + hit = await get_hit(hit_id) + if not hit: + raise ValueError("LNURL-withdraw record not found.") + if hit.spent: + raise ValueError("Payment already claimed.") + if not payment_request: + raise ValueError("Missing payment request.") + try: + invoice = bolt11.decode(payment_request) + except bolt11.Bolt11Exception as exc: + raise ValueError("Failed to decode payment request.") from exc + if not invoice.amount_msat: + raise ValueError("Invoice has no amount.") + hit, card = await spend_hit(hit.id, int(invoice.amount_msat)) + try: + await pay_invoice( + wallet_id=card.wallet, + payment_request=payment_request, + max_sat=card.tx_limit, + extra={"tag": "boltcards", "hit": hit.id}, + ) + except PaymentError as exc: + if exc.status == "failed": + await release_hit(hit.id) + raise ValueError("Payment failed or is still pending.") from exc + except Exception as exc: + # shortcut: unknown outcomes keep their allowance until UTC midnight; + # reconcile payment status to release it sooner. + logger.warning("Boltcard payment outcome unknown: {}", hit.id) + raise ValueError("Payment failed or is still pending.") from exc diff --git a/static/js/index.js b/static/js/index.js index b553347..182e9cd 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -144,6 +144,9 @@ window.app = Vue.createApp({ qrCodeDialog: { show: false, wipe: false, + wiping: false, + wipePrepared: false, + data_wipe: null, data: null } } @@ -191,11 +194,11 @@ window.app = Vue.createApp({ }) }, getCards() { - LNbits.api + return LNbits.api .request( 'GET', '/boltcards/api/v1/cards?all_wallets=true', - this.g.user.wallets[0].inkey + this.g.user.wallets[0].adminkey ) .then(response => { this.cards = response.data.map(function (obj) { @@ -211,7 +214,7 @@ window.app = Vue.createApp({ .request( 'GET', '/boltcards/api/v1/hits?all_wallets=true', - this.g.user.wallets[0].inkey + this.g.user.wallets[0].adminkey ) .then(response => { this.hits = response.data.map(obj => { @@ -225,7 +228,7 @@ window.app = Vue.createApp({ .request( 'GET', '/boltcards/api/v1/refunds?all_wallets=true', - this.g.user.wallets[0].inkey + this.g.user.wallets[0].adminkey ) .then(response => { this.refunds = response.data.map(obj => { @@ -233,35 +236,72 @@ window.app = Vue.createApp({ }) }) }, - openQrCodeDialog(cardId, wipe) { - var card = _.findWhere(this.cards, {id: cardId}) - this.qrCodeDialog.data = { - id: card.id, - link: window.location.origin + '/boltcards/api/v1/auth?a=' + card.otp, - encodedURI: encodeURIComponent( - window.location.origin + '/boltcards/api/v1/auth?a=' + card.otp - ), - name: card.card_name, - uid: card.uid, - external_id: card.external_id, - k0: card.k0, - k1: card.k1, - k2: card.k2, - k3: card.k1, - k4: card.k2 + async openQrCodeDialog(cardId, wipe) { + try { + await this.getCards() + const card = _.findWhere(this.cards, {id: cardId}) + this.qrCodeDialog.data = { + id: card.id, + link: window.location.origin + '/boltcards/api/v1/auth?a=' + card.otp, + encodedURI: encodeURIComponent( + window.location.origin + '/boltcards/api/v1/auth?a=' + card.otp + ), + name: card.card_name, + uid: card.uid, + external_id: card.external_id, + k0: card.k0, + k1: card.k1, + k2: card.k2, + k3: card.k1, + k4: card.k2 + } + this.qrCodeDialog.data_wipe = null + this.qrCodeDialog.wipePrepared = false + this.qrCodeDialog.wipe = wipe + this.qrCodeDialog.show = true + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + async prepareWipe() { + if (this.qrCodeDialog.wiping) return + this.qrCodeDialog.wiping = true + try { + const data = this.qrCodeDialog.data + if (!this.qrCodeDialog.data_wipe) { + this.qrCodeDialog.data_wipe = JSON.stringify({ + action: 'wipe', + k0: data.k0, + k1: data.k1, + k2: data.k2, + k3: data.k3, + k4: data.k4, + uid: data.uid, + version: 1 + }) + } + this.copyText(this.qrCodeDialog.data_wipe) + if (!this.qrCodeDialog.wipePrepared) { + await this.getCards() + const card = _.findWhere(this.cards, {id: data.id}) + // A previous request may have succeeded despite a lost response. + if (card.enable || card.k0 || card.k1 || card.k2) { + const wallet = _.findWhere(this.g.user.wallets, {id: card.wallet}) + await LNbits.api.request( + 'POST', + data.link + '&wipe=true', + wallet.adminkey, + {UID: data.uid} + ) + } + this.qrCodeDialog.wipePrepared = true + await this.getCards() + } + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + this.qrCodeDialog.wiping = false } - this.qrCodeDialog.data_wipe = JSON.stringify({ - action: 'wipe', - k0: card.k0, - k1: card.k1, - k2: card.k2, - k3: card.k1, - k4: card.k2, - uid: card.uid, - version: 1 - }) - this.qrCodeDialog.wipe = wipe - this.qrCodeDialog.show = true }, addCardOpen() { this.cardDialog.show = true @@ -273,21 +313,9 @@ window.app = Vue.createApp({ .getRandomValues(new Uint8Array(size)) .reduce((acc, i) => acc + i.toString(16).padStart(2, '0'), '') - debugcard = - typeof this.cardDialog.data.card_name === 'string' && - this.cardDialog.data.card_name.search('debug') > -1 - - this.cardDialog.data.k0 = debugcard - ? '11111111111111111111111111111111' - : genRandomHexBytes(16) - - this.cardDialog.data.k1 = debugcard - ? '22222222222222222222222222222222' - : genRandomHexBytes(16) - - this.cardDialog.data.k2 = debugcard - ? '33333333333333333333333333333333' - : genRandomHexBytes(16) + this.cardDialog.data.k0 = genRandomHexBytes(16) + this.cardDialog.data.k1 = genRandomHexBytes(16) + this.cardDialog.data.k2 = genRandomHexBytes(16) }, closeFormDialog() { this.cardDialog.data = {} @@ -375,6 +403,7 @@ window.app = Vue.createApp({ }) }, deleteCard(cardId) { + if (!this.qrCodeDialog.wipePrepared || this.qrCodeDialog.wiping) return let cards = _.findWhere(this.cards, {id: cardId}) Quasar.exportFile( diff --git a/tasks.py b/tasks.py index e6d3141..34492e2 100644 --- a/tasks.py +++ b/tasks.py @@ -1,10 +1,10 @@ import asyncio -from lnbits.core.crud import update_payment +from lnbits.core.crud import get_wallet, update_payment from lnbits.core.models import Payment from lnbits.tasks import register_invoice_listener -from .crud import create_refund, get_hit +from .crud import create_refund, get_card, get_hit async def wait_for_paid_invoices(): @@ -17,7 +17,6 @@ async def wait_for_paid_invoices(): async def on_invoice_paid(payment: Payment) -> None: - if not payment.extra or not payment.extra.get("refund"): return @@ -27,7 +26,18 @@ async def on_invoice_paid(payment: Payment) -> None: hit = await get_hit(str(payment.extra.get("refund"))) - if hit: - await create_refund(hit_id=hit.id, refund_amount=(payment.amount / 1000)) - payment.extra["wh_status"] = 1 - await update_payment(payment) + if not hit: + return + card = await get_card(hit.card_id) + if not card: + return + wallet = await get_wallet(card.wallet) + # Shared wallets record payments on their source wallet in newer LNbits versions. + if not wallet or payment.wallet_id != getattr( + wallet, "source_wallet_id", wallet.id + ): + return + + await create_refund(hit_id=hit.id, refund_amount=(payment.amount / 1000)) + payment.extra["wh_status"] = 1 + await update_payment(payment) diff --git a/templates/boltcards/display.html b/templates/boltcards/display.html index 3e43d6a..47c6d14 100644 --- a/templates/boltcards/display.html +++ b/templates/boltcards/display.html @@ -104,18 +104,6 @@ label: 'Card UID', value: 'uid' }, - { - label: 'Auth Key', - value: 'k0' - }, - { - label: 'Meta Key', - value: 'k1' - }, - { - label: 'File Key', - value: 'k2' - }, { label: 'Transaction Limit', value: 'tx_limit' diff --git a/templates/boltcards/index.html b/templates/boltcards/index.html index 4e2d6e1..48509d0 100644 --- a/templates/boltcards/index.html +++ b/templates/boltcards/index.html @@ -376,7 +376,7 @@
- +

@@ -411,6 +412,7 @@

]" />
+

+ Save the wipe data before closing. Wiping disables this card and removes + the server's copy of its keys. +

Name:
@@ -454,8 +460,9 @@

unelevated outline color="grey" - @click="copyText(qrCodeDialog.data_wipe)" - label="Wipe data" + @click="prepareWipe" + :loading="qrCodeDialog.wiping" + :label="qrCodeDialog.wipePrepared ? 'Copy wipe data' : 'Prepare wipe data'" v-show="qrCodeDialog.wipe" > Click to copy, then paste to NFC Card Creator @@ -466,6 +473,7 @@
outline color="red" @click="deleteCard(qrCodeDialog.data.id)" + :disable="!qrCodeDialog.wipePrepared || qrCodeDialog.wiping" label="Delete card" v-show="qrCodeDialog.wipe" v-close-popup @@ -478,13 +486,21 @@
outline color="grey" :href="deeplinkUrl" + v-show="!qrCodeDialog.wipePrepared" target="_blank" label="Use Boltcard App" > Use Boltcard Programmer App
- Close + Close
diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..49b2938 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,80 @@ +import asyncio +from secrets import token_hex +from typing import Any, cast + +import lnbits.db as lnbits_db +import pytest +import pytest_asyncio +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from lnbits.core import migrations as core_migrations +from lnbits.core.db import db as core_db +from lnbits.core.helpers import run_migration +from lnbits.core.services.users import create_user_account_no_ckeck +from slowapi import Limiter +from slowapi.util import get_remote_address +from sqlalchemy.ext.asyncio import create_async_engine + +from .. import boltcards_ext, migrations +from ..crud import db, get_card + + +@pytest_asyncio.fixture(scope="session", loop_scope="session", autouse=True) +async def databases(tmp_path_factory): + folder = tmp_path_factory.mktemp("boltcards") + with pytest.MonkeyPatch.context() as patch: + patch.setattr(lnbits_db, "DB_TYPE", lnbits_db.SQLITE) + for database, migration, name in ( + (core_db, core_migrations, "core"), + (db, migrations, "boltcards"), + ): + path = str(folder / f"{name}.sqlite3") + engine = create_async_engine(f"sqlite+aiosqlite:///{path}") + patch.setattr(database, "path", path) + patch.setattr(database, "type", lnbits_db.SQLITE) + patch.setattr(database, "engine", engine) + patch.setattr(database, "lock", asyncio.Lock()) + async with database.connect() as conn: + await run_migration(conn, migration, name) + yield + await db.engine.dispose() + await core_db.engine.dispose() + + +@pytest_asyncio.fixture(loop_scope="session") +async def client(databases): + app = FastAPI() + app.state.limiter = Limiter(key_func=get_remote_address) + app.include_router(boltcards_ext) + async with AsyncClient( + transport=ASGITransport(app=cast(Any, app)), base_url="https://testserver" + ) as client: + yield client + + +@pytest_asyncio.fixture(loop_scope="session") +async def owner(): + user = await create_user_account_no_ckeck(default_exts=["boltcards"]) + return user, user.wallets[0] + + +@pytest_asyncio.fixture(loop_scope="session") +async def card(client, owner): + _, wallet = owner + response = await client.post( + "/boltcards/api/v1/cards", + headers={"X-API-KEY": wallet.adminkey}, + json={ + "card_name": "Test card", + "uid": token_hex(7), + "k0": token_hex(16), + "k1": token_hex(16), + "k2": token_hex(16), + "tx_limit": 10, + "daily_limit": 15, + }, + ) + assert response.status_code == 201, response.text + card = await get_card(response.json()["id"]) + assert card + return card diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..0a3aa4a --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,375 @@ +import asyncio +import json +import time +from secrets import token_hex +from unittest.mock import AsyncMock + +import bolt11 +import pytest +from Cryptodome.Cipher import AES +from fastapi.responses import HTMLResponse +from lnbits.core.services.users import create_user_account_no_ckeck +from lnbits.decorators import optional_user_id +from lnbits.exceptions import PaymentError + +from .. import services, views +from ..crud import get_card, get_card_by_uid, get_hit +from ..nxp424 import get_sun_mac + +pytestmark = pytest.mark.asyncio(loop_scope="session") +SECRETS = {"k0", "k1", "k2", "prev_k0", "prev_k1", "prev_k2", "otp"} + + +def tap(card, counter): + uid = bytes.fromhex(card.uid) + ctr = counter.to_bytes(3, "little") + plaintext = b"\xc7" + uid + ctr + bytes(5) + p = AES.new(bytes.fromhex(card.k1), AES.MODE_CBC, bytes(16)).encrypt(plaintext) + return {"p": p.hex(), "c": get_sun_mac(uid, ctr, bytes.fromhex(card.k2)).hex()} + + +def invoice(amount_msat): + return bolt11.encode( + bolt11.Bolt11( + currency="bc", + date=int(time.time()), + amount_msat=bolt11.MilliSatoshi(amount_msat), + tags=bolt11.Tags( + [ + bolt11.Tag(bolt11.TagChar.payment_hash, token_hex(32)), + bolt11.Tag(bolt11.TagChar.payment_secret, token_hex(32)), + bolt11.Tag(bolt11.TagChar.description, "Test withdrawal"), + ] + ), + ), + private_key="01" * 32, + ) + + +async def read_card(card_id): + card = await get_card(card_id) + assert card + return card + + +async def scan(client, card, counter): + response = await client.get( + f"/boltcards/api/v1/scan/{card.external_id}", params=tap(card, counter) + ) + assert response.status_code == 200, response.text + return response.json() + + +async def claim(client, hit, amount_msat): + return await client.get( + hit["callback"], params={"k1": hit["k1"], "pr": invoice(amount_msat)} + ) + + +async def test_card_list_requires_admin_and_preserves_payload(client, owner, card): + _, wallet = owner + path = "/boltcards/api/v1/cards?all_wallets=true" + for headers, status in (({}, 401), ({"X-API-KEY": wallet.inkey}, 403)): + response = await client.get(path, headers=headers) + assert response.status_code == status + assert card.k1 not in response.text + response = await client.get(path, headers={"X-API-KEY": wallet.adminkey}) + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + item = next(c for c in response.json() if c["id"] == card.id) + assert item == json.loads(card.json()) + other = await create_user_account_no_ckeck(default_exts=["boltcards"]) + response = await client.get(path, headers={"X-API-KEY": other.wallets[0].adminkey}) + assert response.status_code == 200 + assert card.k1 not in response.text + + +async def test_hit_list_requires_admin_and_preserves_payload(client, owner, card): + _, wallet = owner + withdrawal = await scan(client, card, 1) + hit = await get_hit(withdrawal["k1"]) + assert hit + path = "/boltcards/api/v1/hits?all_wallets=true" + for headers, status in (({}, 401), ({"X-API-KEY": wallet.inkey}, 403)): + response = await client.get(path, headers=headers) + assert response.status_code == status + assert hit.id not in response.text + response = await client.get(path, headers={"X-API-KEY": wallet.adminkey}) + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + assert response.json() == [json.loads(hit.json())] + other = await create_user_account_no_ckeck(default_exts=["boltcards"]) + response = await client.get(path, headers={"X-API-KEY": other.wallets[0].adminkey}) + assert response.status_code == 200 + assert hit.id not in response.text + + +async def test_card_page_context_excludes_credentials(client, owner, card, monkeypatch): + # Check the real page context without the global theme setup. + class Renderer: + def TemplateResponse(self, _name, context): # noqa: N802 + return HTMLResponse(context["card"]) + + monkeypatch.setattr(views, "boltcards_renderer", Renderer) + client_app = client._transport.app + client_app.dependency_overrides[optional_user_id] = lambda: owner[0].id + response = await client.get(f"/boltcards/{card.external_id}") + assert response.status_code == 200 + assert SECRETS.isdisjoint(json.loads(response.text)) + assert card.k1 not in response.text + client_app.dependency_overrides[optional_user_id] = lambda: "another-user" + response = await client.get(f"/boltcards/{card.external_id}") + assert response.status_code == 403 + + +@pytest.mark.parametrize("method", ["get", "post"]) +async def test_auth_rejects_uid_and_default_token(client, card, method): + before = await read_card(card.id) + for token in (card.uid, "0" * 32): + kwargs = {"json": {"UID": card.uid}} if method == "post" else {} + response = await getattr(client, method)( + "/boltcards/api/v1/auth", params={"a": token}, **kwargs + ) + assert response.status_code == 404 + assert card.k1 not in response.text + assert (await read_card(card.id)).otp == before.otp + + +@pytest.mark.parametrize("method", ["get", "post"]) +async def test_otp_is_single_use_under_concurrency(client, card, method): + kwargs = {"json": {"UID": card.uid}} if method == "post" else {} + responses = await asyncio.gather( + *( + getattr(client, method)( + "/boltcards/api/v1/auth", params={"a": card.otp}, **kwargs + ) + for _ in range(2) + ) + ) + assert sorted(r.status_code for r in responses) == [200, 404] + success = next(r for r in responses if r.status_code == 200) + assert success.headers["cache-control"] == "no-store" + assert card.k1 in success.text + + +async def test_wrong_uid_does_not_consume_otp(client, card): + response = await client.post( + "/boltcards/api/v1/auth", params={"a": card.otp}, json={"UID": "wrong"} + ) + assert response.status_code == 404 + assert (await read_card(card.id)).otp == card.otp + + +@pytest.mark.parametrize("wipe", [False, True]) +async def test_programmer_app_post_payloads(client, card, wipe): + payload = ( + {"LNURLW": f"lnurlw://cards.example/boltcards/api/v1/scan/{card.external_id}"} + if wipe + else {"UID": card.uid} + ) + response = await client.post( + "/boltcards/api/v1/auth", + params={"a": card.otp, "wipe": str(wipe).lower()}, + json=payload, + ) + assert response.status_code == 200 + result = response.json() + assert result["K0"] == card.k0 + assert result["K1"] == card.k1 + + +async def test_auth_rate_limit_shared_between_methods(client): + for i in range(10): + method = client.get if i % 2 else client.post + kwargs = {} if i % 2 else {"json": {}} + response = await method( + "/boltcards/api/v1/auth", params={"a": "invalid"}, **kwargs + ) + assert response.status_code == 404 + response = await client.get("/boltcards/api/v1/auth", params={"a": "invalid"}) + assert response.status_code == 429 + + +async def test_replayed_sun_creates_one_hit(client, card): + results = await asyncio.gather(scan(client, card, 1), scan(client, card, 1)) + assert sum(r.get("tag") == "withdrawRequest" for r in results) == 1 + assert sum(r.get("status") == "ERROR" for r in results) == 1 + assert (await read_card(card.id)).counter == 1 + + +async def test_invalid_cmac_does_not_consume_counter(client, card): + params = tap(card, 1) + params["c"] = "00" * 8 + response = await client.get( + f"/boltcards/api/v1/scan/{card.external_id}", params=params + ) + assert response.json()["status"] == "ERROR" + assert (await read_card(card.id)).counter == 0 + + +@pytest.mark.parametrize("key_format", ["upper", "spaced"]) +async def test_stale_card_edit_preserves_counter(client, owner, card, key_format): + data = card.dict(exclude={"time"}) + data["card_name"] = "Renamed card" + data["k1"] = ( + card.k1.upper() + if key_format == "upper" + else " ".join(card.k1[i : i + 2] for i in range(0, len(card.k1), 2)) + ) + await scan(client, card, 1) + response = await client.put( + f"/boltcards/api/v1/cards/{card.id}", + headers={"X-API-KEY": owner[1].adminkey}, + json=data, + ) + assert response.status_code == 200 + assert response.json()["counter"] == 1 + assert response.json()["card_name"] == "Renamed card" + assert (await read_card(card.id)).counter == 1 + assert (await scan(client, card, 1))["status"] == "ERROR" + + +async def test_pending_hits_cannot_exceed_daily_limit(client, card, monkeypatch): + pay = AsyncMock() + monkeypatch.setattr(services, "pay_invoice", pay) + hits = [await scan(client, card, counter) for counter in (1, 2, 3)] + responses = await asyncio.gather(*(claim(client, hit, 10_000) for hit in hits)) + assert sum(r.json()["status"] == "OK" for r in responses) == 1 + assert pay.await_count == 1 + # Equality is permitted; any additional spend is refused without burning the tap. + remaining = next( + hit + for hit, r in zip(hits, responses, strict=False) + if r.json()["status"] == "ERROR" + ) + response = await claim(client, remaining, 5_000) + assert response.json()["status"] == "OK" + response = await scan(client, card, 4) + assert response["maxWithdrawable"] == 0 + assert (await claim(client, response, 1_000)).json()["status"] == "ERROR" + assert (await read_card(card.id)).counter == 4 + + +async def test_duplicate_callback_pays_once(client, card, monkeypatch): + pay = AsyncMock() + monkeypatch.setattr(services, "pay_invoice", pay) + hit = await scan(client, card, 1) + responses = await asyncio.gather( + claim(client, hit, 5_000), claim(client, hit, 5_000) + ) + assert sorted(r.json()["status"] for r in responses) == ["ERROR", "OK"] + assert pay.await_count == 1 + + +async def test_disabled_card_cannot_claim_pending_hit(client, owner, card, monkeypatch): + pay = AsyncMock() + monkeypatch.setattr(services, "pay_invoice", pay) + hit = await scan(client, card, 1) + response = await client.get( + f"/boltcards/api/v1/cards/enable/{card.id}/false", + headers={"X-API-KEY": owner[1].adminkey}, + ) + assert response.status_code == 200 + assert (await claim(client, hit, 1_000)).json()["status"] == "ERROR" + await client.get( + f"/boltcards/api/v1/cards/enable/{card.id}/true", + headers={"X-API-KEY": owner[1].adminkey}, + ) + assert (await claim(client, hit, 1_000)).json()["status"] == "ERROR" + pay.assert_not_awaited() + + +@pytest.mark.parametrize("method", ["get", "post"]) +async def test_wipe_clears_keys_and_invalidates_hits(client, owner, card, method): + hit = await scan(client, card, 1) + kwargs = {"json": {"UID": card.uid}} if method == "post" else {} + response = await getattr(client, method)( + "/boltcards/api/v1/auth", params={"a": card.otp, "wipe": "true"}, **kwargs + ) + assert response.status_code == 200 + assert card.k0 in response.text # Physical wipe still needs the old key. + wiped = await read_card(card.id) + assert not wiped.enable + assert all(not getattr(wiped, key) for key in SECRETS - {"otp"}) + assert wiped.otp != card.otp + saved = await get_hit(hit["k1"]) + assert saved and saved.spent + assert (await scan(client, card, 2))["status"] == "ERROR" + assert (await claim(client, hit, 1_000)).json()["status"] == "ERROR" + enabled = await client.get( + f"/boltcards/api/v1/cards/enable/{card.id}/true", + headers={"X-API-KEY": owner[1].adminkey}, + ) + assert enabled.status_code == 400 + + +@pytest.mark.parametrize("status", ["failed", "pending", "unknown"]) +async def test_payment_failure_budget_and_errors(client, card, monkeypatch, status): + error = ( + RuntimeError("sensitive internal detail") + if status == "unknown" + else PaymentError("sensitive internal detail", status=status) + ) + pay = AsyncMock(side_effect=error) + monkeypatch.setattr(services, "pay_invoice", pay) + hit = await scan(client, card, 1) + response = await claim(client, hit, 10_000) + assert response.json()["status"] == "ERROR" + assert "sensitive internal detail" not in response.text + saved = await get_hit(hit["k1"]) + assert saved and saved.spent + assert saved.amount_msat == (0 if status == "failed" else 10_000) + assert (await claim(client, hit, 1_000)).json()["status"] == "ERROR" + assert pay.await_count == 1 + + +async def test_fractional_sat_budget_is_exact(client, card, monkeypatch): + monkeypatch.setattr(services, "pay_invoice", AsyncMock()) + hits = [await scan(client, card, n) for n in (1, 2)] + assert (await claim(client, hits[0], 9_999)).json()["status"] == "OK" + assert (await claim(client, hits[1], 5_002)).json()["status"] == "ERROR" + assert (await claim(client, hits[1], 5_001)).json()["status"] == "OK" + + +async def test_uid_lookup_is_wallet_scoped(client, owner, card): + other = await create_user_account_no_ckeck(default_exts=["boltcards"]) + wallet = other.wallets[0] + assert await get_card_by_uid(card.uid, wallet.id) is None + owned = await get_card_by_uid(card.uid.lower(), owner[1].id) + assert owned and owned.id == card.id + data = { + "card_name": "Other card", + "uid": card.uid.lower(), + "k0": token_hex(16), + "k1": token_hex(16), + "k2": token_hex(16), + } + response = await client.post( + "/boltcards/api/v1/cards", headers={"X-API-KEY": wallet.adminkey}, json=data + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Could not save card details." + assert card.wallet not in response.text + data["uid"] = token_hex(7) + response = await client.post( + "/boltcards/api/v1/cards", headers={"X-API-KEY": wallet.adminkey}, json=data + ) + assert response.status_code == 201 + other_card_id = response.json()["id"] + data["uid"] = card.uid + response = await client.put( + f"/boltcards/api/v1/cards/{other_card_id}", + headers={"X-API-KEY": wallet.adminkey}, + json=data, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Could not save card details." + # An unrelated card update remains available after the rejected collision. + data["uid"] = card.uid.lower() + response = await client.put( + f"/boltcards/api/v1/cards/{card.id}", + headers={"X-API-KEY": owner[1].adminkey}, + json=data, + ) + assert response.status_code == 200 + assert response.json()["uid"] == card.uid diff --git a/tests/test_crud.py b/tests/test_crud.py new file mode 100644 index 0000000..71e3383 --- /dev/null +++ b/tests/test_crud.py @@ -0,0 +1,124 @@ +import asyncio +from datetime import datetime, timedelta, timezone +from secrets import token_hex + +import pytest +from sqlalchemy.ext.asyncio import AsyncConnection + +from ..crud import create_hit, db, get_card, get_hit, get_hits, spend_hit, update_card + +pytestmark = pytest.mark.asyncio(loop_scope="session") + + +class IndependentWorkers: + """Separate workers share database locks, but have no shared asyncio lock.""" + + async def acquire(self): + pass + + def release(self): + pass + + +async def test_database_serializes_independent_workers(card, monkeypatch): + monkeypatch.setattr(db, "lock", IndependentWorkers()) + results = await asyncio.gather( + create_hit(card, "peer", "agent", 1), + create_hit(card, "peer", "agent", 1), + return_exceptions=True, + ) + assert sum(isinstance(r, ValueError) for r in results) == 1 + hits = await get_hits([card.id]) + assert len(hits) == 1 + second = await create_hit(card, "peer", "agent", 2) + claims = await asyncio.gather( + spend_hit(hits[0].id, 10_000), + spend_hit(second.id, 10_000), + return_exceptions=True, + ) + assert sum(isinstance(r, ValueError) for r in claims) == 1 + assert sum(hit.amount_msat for hit in await get_hits([card.id])) == 10_000 + + +async def test_hit_insert_failure_rolls_back_counter(card, monkeypatch): + execute = AsyncConnection.execute + + async def fail_insert(self, statement, *args, **kwargs): + if "INSERT INTO boltcards.hits" in str(statement): + raise RuntimeError("insert failed") + return await execute(self, statement, *args, **kwargs) + + monkeypatch.setattr(AsyncConnection, "execute", fail_insert) + with pytest.raises(RuntimeError, match="insert failed"): + await create_hit(card, "peer", "agent", 1) + saved = await get_card(card.id) + assert saved and saved.counter == 0 + assert await get_hits([card.id]) == [] + + +async def test_claim_is_counted_on_claim_day(card): + first = await create_hit(card, "peer", "agent", 1) + second = await create_hit(card, "peer", "agent", 2) + yesterday = datetime.now(timezone.utc) - timedelta(days=1) + await db.execute( + f"UPDATE boltcards.hits SET time = {db.timestamp_placeholder('time')} " + "WHERE id = :id", + {"id": first.id, "time": yesterday.timestamp()}, + ) + await spend_hit(first.id, 10_000) + with pytest.raises(ValueError, match="withdrawal limit"): + await spend_hit(second.id, 6_000) + saved = await get_hit(first.id) + assert saved and saved.claimed_at + assert saved.claimed_at.date() == datetime.now(timezone.utc).date() + + +async def test_hit_keeps_scan_amount_bound(card): + hit = await create_hit(card, "peer", "agent", 1) + await db.execute( + "UPDATE boltcards.cards SET tx_limit = 100, daily_limit = 100 WHERE id = :id", + {"id": card.id}, + ) + with pytest.raises(ValueError, match="withdrawal limit"): + await spend_hit(hit.id, 11_000) + saved = await get_hit(hit.id) + assert saved and not saved.spent + + +async def test_spend_hit_returns_claim_state(card): + hit = await create_hit(card, "peer", "agent", 1) + claimed, claimed_card = await spend_hit(hit.id, 1_001) + assert claimed_card.id == card.id + assert claimed.spent + assert claimed.amount == 2 + assert claimed.amount_msat == 1_001 + assert claimed.claimed_at + saved = await get_hit(hit.id) + assert saved and saved.claimed_at + assert (claimed.spent, claimed.amount, claimed.amount_msat) == ( + saved.spent, + saved.amount, + saved.amount_msat, + ) + assert abs((claimed.claimed_at - saved.claimed_at).total_seconds()) < 1 + + +async def test_key_update_invalidates_old_taps_and_hits(card): + hit = await create_hit(card, "peer", "agent", 1) + changed = card.copy(update={"k1": token_hex(16)}) + await update_card(changed) + with pytest.raises(ValueError, match="has changed"): + await create_hit(card, "peer", "agent", 2) + with pytest.raises(ValueError, match="already claimed"): + await spend_hit(hit.id, 1_000) + + +@pytest.mark.parametrize("field", ["uid", "k1", "k2"]) +async def test_reprovisioning_allows_counter_reset(card, field): + await create_hit(card, "peer", "agent", 5) + changed = card.copy(update={field: token_hex(7 if field == "uid" else 16)}) + await update_card(changed) + saved = await get_card(card.id) + assert saved and saved.counter == 0 + hit = await create_hit(saved, "peer", "agent", 1) + assert hit.new_ctr == 1 diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..c78853c --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,70 @@ +import pytest +from lnbits.db import Database +from sqlalchemy.ext.asyncio import create_async_engine + +from .. import crud, migrations +from ..models import Card, Hit + +pytestmark = pytest.mark.asyncio(loop_scope="session") + + +@pytest.mark.parametrize("enabled", [True, False]) +async def test_upgrade_preserves_programmed_card_and_pending_withdrawal( + tmp_path, monkeypatch, enabled +): + legacy = Database("ext_boltcards") + legacy.path = str(tmp_path / "legacy.sqlite3") + legacy.engine = create_async_engine(f"sqlite+aiosqlite:///{legacy.path}") + try: + async with legacy.connect() as conn: + await migrations.m001_initial(conn) + await migrations.m002_correct_typing(conn) + await legacy.execute( + """ + INSERT INTO boltcards.cards + (id, wallet, card_name, uid, external_id, counter, tx_limit, + daily_limit, enable, k0, k1, k2, otp) + VALUES ('card', 'wallet', 'Existing card', '01020304050607', + 'existing', 254, 10000000, 100000000, :enabled, :k0, :k1, :k2, :otp) + """, + { + "enabled": enabled, + "k0": "ab" * 16, + "k1": "cd" * 16, + "k2": "ef" * 16, + "otp": "aa" * 16, + }, + ) + for hit_id, spent, amount in (("paid", True, 10000), ("pending", False, 0)): + await legacy.execute( + """ + INSERT INTO boltcards.hits (id, card_id, ip, spent, amount, useragent) + VALUES (:id, 'card', 'peer', :spent, :amount, 'agent') + """, + {"id": hit_id, "spent": spent, "amount": amount}, + ) + before = await legacy.fetchone("SELECT * FROM boltcards.cards", model=Card) + async with legacy.connect() as conn: + await migrations.m003_claims(conn) + after = await legacy.fetchone("SELECT * FROM boltcards.cards", model=Card) + assert before == after + paid = await legacy.fetchone( + "SELECT * FROM boltcards.hits WHERE id = 'paid'", model=Hit + ) + assert paid.amount == 10000 and paid.amount_msat == 10_000_000 + assert paid.claimed_at == paid.time + pending = await legacy.fetchone( + "SELECT * FROM boltcards.hits WHERE id = 'pending'", model=Hit + ) + assert not pending.spent and pending.max_msat == 10_000_000_000 + monkeypatch.setattr(crud, "db", legacy) + # The old bearer token still works with the newly enforced daily budget. + if enabled: + await crud.spend_hit("pending", 10_000_000) + hit = await crud.create_hit(after, "peer", "agent", 255) + assert hit.new_ctr == 255 + else: + with pytest.raises(ValueError, match="disabled"): + await crud.spend_hit("pending", 10_000_000) + finally: + await legacy.engine.dispose() diff --git a/tests/test_refunds.py b/tests/test_refunds.py new file mode 100644 index 0000000..5d26916 --- /dev/null +++ b/tests/test_refunds.py @@ -0,0 +1,143 @@ +from secrets import token_hex +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from lnbits.core.crud import create_wallet +from lnbits.core.models import Payment + +from .. import services, tasks, views_lnurl +from ..crud import create_hit, create_refund, get_card, get_hit, get_refunds, spend_hit +from .test_api import claim, invoice, scan + +pytestmark = pytest.mark.asyncio(loop_scope="session") + + +async def test_refund_list_requires_admin_key(client, owner, card): + hit = await create_hit(card, "peer", "agent", 1) + await spend_hit(hit.id, 1_000) + refund = await create_refund(hit.id, 1) + for headers, status in (({}, 401), ({"X-API-KEY": owner[1].inkey}, 403)): + response = await client.get("/boltcards/api/v1/refunds", headers=headers) + assert response.status_code == status + assert hit.id not in response.text + response = await client.get( + "/boltcards/api/v1/refunds", headers={"X-API-KEY": owner[1].adminkey} + ) + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + assert response.json()[0]["id"] == refund.id + + +@pytest.mark.parametrize("shared", [False, True]) +async def test_refund_listener_checks_payment_wallet(owner, card, monkeypatch, shared): + hit = await create_hit(card, "peer", "agent", 1) + await spend_hit(hit.id, 2_000) + sibling = await create_wallet(user_id=owner[0].id) + source_wallet_id = "source-wallet" if shared else card.wallet + if shared: + monkeypatch.setattr( + tasks, + "get_wallet", + AsyncMock( + return_value=SimpleNamespace( + id=card.wallet, source_wallet_id=source_wallet_id + ) + ), + ) + update = AsyncMock() + monkeypatch.setattr(tasks, "update_payment", update) + payment = Payment( + checking_id=token_hex(16), + payment_hash=token_hex(32), + wallet_id=sibling.id, + amount=1_000, + fee=0, + bolt11="", + status="success", + extra={"refund": hit.id}, + ) + await tasks.on_invoice_paid(payment) + assert await get_refunds([hit.id]) == [] + update.assert_not_awaited() + assert "wh_status" not in payment.extra + payment.wallet_id = source_wallet_id + await tasks.on_invoice_paid(payment) + assert len(await get_refunds([hit.id])) == 1 + update.assert_awaited_once() + await tasks.on_invoice_paid(payment) + assert len(await get_refunds([hit.id])) == 1 + + +@pytest.mark.parametrize("claimed", [False, True]) +async def test_pay_link_accepts_receive_only_and_refund_payments( + client, card, monkeypatch, claimed +): + pay = AsyncMock() + monkeypatch.setattr(services, "pay_invoice", pay) + monkeypatch.setattr(tasks, "update_payment", AsyncMock()) + payment = Payment( + checking_id=token_hex(16), + payment_hash=token_hex(32), + wallet_id=card.wallet, + amount=5_000, + fee=0, + bolt11=invoice(5_000), + ) + create_invoice = AsyncMock(return_value=payment) + monkeypatch.setattr(views_lnurl, "create_invoice", create_invoice) + withdrawal = await scan(client, card, 1) + metadata_path = f"/boltcards/api/v1/lnurlp/{withdrawal['k1']}" + metadata = (await client.get(metadata_path)).json() + assert metadata["tag"] == "payRequest" + if claimed: + assert (await claim(client, withdrawal, 1_000)).json()["status"] == "OK" + before = await get_card(card.id) + response = await client.get(metadata["callback"], params={"amount": 5_000}) + assert response.json()["pr"] == payment.bolt11 + create_invoice.assert_awaited_once() + assert create_invoice.call_args.kwargs["wallet_id"] == card.wallet + assert create_invoice.call_args.kwargs["amount"] == 5 + assert await get_card(card.id) == before + payment.status = "success" + payment.extra = create_invoice.call_args.kwargs["extra"] + await tasks.on_invoice_paid(payment) + refunds = await get_refunds([withdrawal["k1"]]) + assert len(refunds) == 1 and refunds[0].refund_amount == 5 + hit = await get_hit(withdrawal["k1"]) + assert hit and hit.spent is claimed + assert pay.await_count == int(claimed) + + +@pytest.mark.parametrize("amount", ["abc", "1.5", "", "-1000", "999", "1500", "10001"]) +async def test_receive_callback_rejects_invalid_amount( + client, card, monkeypatch, amount +): + create_invoice = AsyncMock() + monkeypatch.setattr(views_lnurl, "create_invoice", create_invoice) + hit = await create_hit(card, "peer", "agent", 1) + response = await client.get( + f"/boltcards/api/v1/lnurlp/cb/{hit.id}", params={"amount": amount} + ) + assert response.status_code == 200 + assert response.json()["status"] == "ERROR" + create_invoice.assert_not_awaited() + + +@pytest.mark.parametrize("remaining_msat", [0, 499]) +async def test_receive_link_remains_available_after_spending_allowance( + client, card, monkeypatch, remaining_msat +): + pay = AsyncMock() + monkeypatch.setattr(services, "pay_invoice", pay) + for counter, amount in ((1, 10_000), (2, 5_000 - remaining_msat)): + withdrawal = await scan(client, card, counter) + assert (await claim(client, withdrawal, amount)).json()["status"] == "OK" + receive = await scan(client, card, 3) + assert receive["tag"] == "withdrawRequest" + assert receive["minWithdrawable"] == receive["maxWithdrawable"] == 0 + assert receive["payLink"].startswith("lnurlp://") + metadata = await client.get(f"/boltcards/api/v1/lnurlp/{receive['k1']}") + assert metadata.json()["tag"] == "payRequest" + assert (await claim(client, receive, 1_000)).json()["status"] == "ERROR" + assert pay.await_count == 2 diff --git a/tests/test_ui.js b/tests/test_ui.js new file mode 100644 index 0000000..e5461d4 --- /dev/null +++ b/tests/test_ui.js @@ -0,0 +1,199 @@ +const assert = require('node:assert/strict') +const fs = require('node:fs') +const vm = require('node:vm') +const {webcrypto} = require('node:crypto') + +let options +const requests = [] +const copied = [] +const errors = [] +const exportedFiles = [] +const confirmations = [] +const card = { + id: 'card', + enable: true, + wallet: 'wallet', + otp: 'otp', + uid: 'uid', + k0: 'old-k0', + k1: 'old-k1', + k2: 'old-k2' +} +let storedCard = {...card} +let failWipe = false +let failRefresh = false +let loseWipeResponse = false +let failRefreshAfterWipe = false +let holdWipe = false +let finishWipe +vm.runInNewContext(fs.readFileSync('static/js/index.js', 'utf8'), { + window: {location: {origin: 'https://cards.example', host: 'cards.example'}}, + windowMixin: {}, + Vue: { + createApp: value => { + options = value + return value + } + }, + Quasar: { + date: {formatDate: () => 'date'}, + exportFile: (...args) => exportedFiles.push(args) + }, + _: { + findWhere: (items, fields) => + items.find(item => + Object.entries(fields).every(([key, value]) => item[key] === value) + ) + }, + crypto: webcrypto, + LNbits: { + api: { + request: async (...args) => { + requests.push(args) + if (args[0] === 'POST' && failWipe) + throw new Error('network unavailable') + if (args[0] === 'POST' && holdWipe) + await new Promise(resolve => { + finishWipe = resolve + }) + if (args[0] === 'POST') { + storedCard = {...storedCard, enable: false, k0: '', k1: '', k2: ''} + if (loseWipeResponse) throw new Error('response lost') + if (failRefreshAfterWipe) failRefresh = true + } + if (args[0] === 'GET' && args[1].includes('/cards') && failRefresh) + throw new Error('refresh unavailable') + return {data: args[1].includes('/cards') ? [{...storedCard}] : []} + } + }, + utils: { + notifyApiError: error => errors.push(error), + confirmDialog: message => { + confirmations.push(message) + return {onOk: () => {}} + } + } + } +}) +const app = Object.assign(options.data(), options.methods, { + g: {user: {wallets: [{id: 'wallet', adminkey: 'admin', inkey: 'invoice'}]}}, + getHits: () => {}, + copyText: value => copied.push(value) +}) + +async function main() { + await app.openQrCodeDialog('card', true) + assert.equal(requests[0][2], 'admin') + assert.equal( + requests.some(([method]) => method === 'POST'), + false + ) + assert.equal(app.qrCodeDialog.data_wipe, null) + assert.equal(app.qrCodeDialog.data.k0, card.k0) + assert.match( + options.computed.deeplinkUrl.call(app), + /^boltcard:\/\/reset\?url=/ + ) + await options.methods.getHits.call(app) + assert.equal(requests.find(([, url]) => url.includes('/hits'))[2], 'admin') + await options.methods.getRefunds.call(app) + assert.equal(requests.find(([, url]) => url.includes('/refunds'))[2], 'admin') + app.deleteCard('card') + assert.equal(exportedFiles.length, 0) + assert.equal(confirmations.length, 0) + + failWipe = true + await app.prepareWipe() + assert.equal(errors.length, 1) + assert.equal(JSON.parse(app.qrCodeDialog.data_wipe).k0, card.k0) + assert.equal(app.qrCodeDialog.data.k0, card.k0) + assert.equal(copied.length, 1) + assert.equal(app.qrCodeDialog.wiping, false) + assert.equal(app.qrCodeDialog.wipePrepared, false) + app.deleteCard('card') + assert.equal(exportedFiles.length, 0) + const failedPosts = requests.filter(([method]) => method === 'POST').length + await app.prepareWipe() + assert.equal( + requests.filter(([method]) => method === 'POST').length, + failedPosts + 1 + ) + assert.equal(copied[1], copied[0]) + + failWipe = false + await app.prepareWipe() + assert.equal(app.qrCodeDialog.wipePrepared, true) + assert.equal(JSON.parse(app.qrCodeDialog.data_wipe).k0, card.k0) + storedCard = {...card} + await app.openQrCodeDialog('card', true) + holdWipe = true + const pending = app.prepareWipe() + await new Promise(resolve => setImmediate(resolve)) + assert.equal(app.qrCodeDialog.wiping, true) + const pendingCopies = copied.length + const pendingPosts = requests.filter(([method]) => method === 'POST').length + await app.prepareWipe() + assert.equal( + requests.filter(([method]) => method === 'POST').length, + pendingPosts + ) + assert.equal(copied.length, pendingCopies) + app.deleteCard('card') + assert.equal(exportedFiles.length, 0) + finishWipe() + await pending + assert.equal(app.qrCodeDialog.wiping, false) + assert.equal(errors.length, 2) + const wipe = JSON.parse(copied[0]) + assert.equal(wipe.action, 'wipe') + assert.equal(wipe.k0, card.k0) + assert.equal(wipe.k1, card.k1) + assert.equal( + requests.find(([method]) => method === 'POST')[1], + 'https://cards.example/boltcards/api/v1/auth?a=otp&wipe=true' + ) + const posts = requests.filter(([method]) => method === 'POST').length + await app.prepareWipe() + assert.equal(requests.filter(([method]) => method === 'POST').length, posts) + app.deleteCard('card') + assert.equal(exportedFiles.length, 1) + assert.equal(exportedFiles[0][1], app.qrCodeDialog.data_wipe) + assert.equal(confirmations.length, 1) + + holdWipe = false + storedCard = {...card} + await app.openQrCodeDialog('card', true) + loseWipeResponse = true + await app.prepareWipe() + assert.equal(app.qrCodeDialog.wipePrepared, false) + const lostResponsePosts = requests.filter( + ([method]) => method === 'POST' + ).length + loseWipeResponse = false + await app.prepareWipe() + assert.equal(app.qrCodeDialog.wipePrepared, true) + assert.equal( + requests.filter(([method]) => method === 'POST').length, + lostResponsePosts + ) + assert.equal(JSON.parse(app.qrCodeDialog.data_wipe).k0, card.k0) + + storedCard = {...card} + await app.openQrCodeDialog('card', true) + failRefreshAfterWipe = true + await app.prepareWipe() + assert.equal(errors.at(-1).message, 'refresh unavailable') + assert.equal(app.qrCodeDialog.wipePrepared, true) + assert.equal(app.qrCodeDialog.wiping, false) + assert.equal(JSON.parse(app.qrCodeDialog.data_wipe).k0, card.k0) + + app.cardDialog.data.card_name = 'debug card' + app.generateKeys() + assert.match(app.cardDialog.data.k1, /^[0-9a-f]{32}$/) + assert.notEqual(app.cardDialog.data.k1, '2'.repeat(32)) + console.log('Card UI checks passed') +} +main().catch(error => { + console.error(error) + process.exitCode = 1 +}) diff --git a/uv.lock b/uv.lock index 9acf9d5..6d6a3e6 100644 --- a/uv.lock +++ b/uv.lock @@ -835,29 +835,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/ed/6bfa4109fcb23a58819600392564fea69cdc6551ffd5e69ccf1d52a40cbc/greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c", size = 271061, upload-time = "2025-08-07T13:17:15.373Z" }, { url = "https://files.pythonhosted.org/packages/2a/fc/102ec1a2fc015b3a7652abab7acf3541d58c04d3d17a8d3d6a44adae1eb1/greenlet-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590", size = 629475, upload-time = "2025-08-07T13:42:54.009Z" }, { url = "https://files.pythonhosted.org/packages/c5/26/80383131d55a4ac0fb08d71660fd77e7660b9db6bdb4e8884f46d9f2cc04/greenlet-3.2.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f10fd42b5ee276335863712fa3da6608e93f70629c631bf77145021600abc23c", size = 640802, upload-time = "2025-08-07T13:45:25.52Z" }, - { url = "https://files.pythonhosted.org/packages/9f/7c/e7833dbcd8f376f3326bd728c845d31dcde4c84268d3921afcae77d90d08/greenlet-3.2.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c8c9e331e58180d0d83c5b7999255721b725913ff6bc6cf39fa2a45841a4fd4b", size = 636703, upload-time = "2025-08-07T13:53:12.622Z" }, { url = "https://files.pythonhosted.org/packages/e9/49/547b93b7c0428ede7b3f309bc965986874759f7d89e4e04aeddbc9699acb/greenlet-3.2.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58b97143c9cc7b86fc458f215bd0932f1757ce649e05b640fea2e79b54cedb31", size = 635417, upload-time = "2025-08-07T13:18:25.189Z" }, { url = "https://files.pythonhosted.org/packages/7f/91/ae2eb6b7979e2f9b035a9f612cf70f1bf54aad4e1d125129bef1eae96f19/greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d", size = 584358, upload-time = "2025-08-07T13:18:23.708Z" }, { url = "https://files.pythonhosted.org/packages/f7/85/433de0c9c0252b22b16d413c9407e6cb3b41df7389afc366ca204dbc1393/greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5", size = 1113550, upload-time = "2025-08-07T13:42:37.467Z" }, { url = "https://files.pythonhosted.org/packages/a1/8d/88f3ebd2bc96bf7747093696f4335a0a8a4c5acfcf1b757717c0d2474ba3/greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f", size = 1137126, upload-time = "2025-08-07T13:18:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/f1/29/74242b7d72385e29bcc5563fba67dad94943d7cd03552bac320d597f29b2/greenlet-3.2.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f47617f698838ba98f4ff4189aef02e7343952df3a615f847bb575c3feb177a7", size = 1544904, upload-time = "2025-11-04T12:42:04.763Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e2/1572b8eeab0f77df5f6729d6ab6b141e4a84ee8eb9bc8c1e7918f94eda6d/greenlet-3.2.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af41be48a4f60429d5cad9d22175217805098a9ef7c40bfef44f7669fb9d74d8", size = 1611228, upload-time = "2025-11-04T12:42:08.423Z" }, { url = "https://files.pythonhosted.org/packages/d6/6f/b60b0291d9623c496638c582297ead61f43c4b72eef5e9c926ef4565ec13/greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c", size = 298654, upload-time = "2025-08-07T13:50:00.469Z" }, { url = "https://files.pythonhosted.org/packages/a4/de/f28ced0a67749cac23fecb02b694f6473f47686dff6afaa211d186e2ef9c/greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", size = 272305, upload-time = "2025-08-07T13:15:41.288Z" }, { url = "https://files.pythonhosted.org/packages/09/16/2c3792cba130000bf2a31c5272999113f4764fd9d874fb257ff588ac779a/greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", size = 632472, upload-time = "2025-08-07T13:42:55.044Z" }, { url = "https://files.pythonhosted.org/packages/ae/8f/95d48d7e3d433e6dae5b1682e4292242a53f22df82e6d3dda81b1701a960/greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3", size = 644646, upload-time = "2025-08-07T13:45:26.523Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5e/405965351aef8c76b8ef7ad370e5da58d57ef6068df197548b015464001a/greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633", size = 640519, upload-time = "2025-08-07T13:53:13.928Z" }, { url = "https://files.pythonhosted.org/packages/25/5d/382753b52006ce0218297ec1b628e048c4e64b155379331f25a7316eb749/greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079", size = 639707, upload-time = "2025-08-07T13:18:27.146Z" }, { url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" }, { url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" }, { url = "https://files.pythonhosted.org/packages/3f/cc/b07000438a29ac5cfb2194bfc128151d52f333cee74dd7dfe3fb733fc16c/greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa", size = 1142073, upload-time = "2025-08-07T13:18:21.737Z" }, + { url = "https://files.pythonhosted.org/packages/67/24/28a5b2fa42d12b3d7e5614145f0bd89714c34c08be6aabe39c14dd52db34/greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c", size = 1548385, upload-time = "2025-11-04T12:42:11.067Z" }, + { url = "https://files.pythonhosted.org/packages/6a/05/03f2f0bdd0b0ff9a4f7b99333d57b53a7709c27723ec8123056b084e69cd/greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5", size = 1613329, upload-time = "2025-11-04T12:42:12.928Z" }, { url = "https://files.pythonhosted.org/packages/d8/0f/30aef242fcab550b0b3520b8e3561156857c94288f0332a79928c31a52cf/greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9", size = 299100, upload-time = "2025-08-07T13:44:12.287Z" }, { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" }, - { url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" }, { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" }, { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, + { url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" }, + { url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" }, { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, ] @@ -1072,7 +1075,7 @@ wheels = [ [[package]] name = "lnbits-boltcards" -version = "0.0.0" +version = "1.2.0" source = { virtual = "." } dependencies = [ { name = "lnbits" }, @@ -1085,7 +1088,6 @@ dev = [ { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, - { name = "pytest-md" }, { name = "ruff" }, ] @@ -1099,7 +1101,6 @@ dev = [ { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, - { name = "pytest-md" }, { name = "ruff" }, ] @@ -1687,18 +1688,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" }, ] -[[package]] -name = "pytest-md" -version = "0.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0e/55/1d4248f08a97255abb23b05d8ba07586333194fadb17beda96b707aebecd/pytest-md-0.2.0.tar.gz", hash = "sha256:3b248d5b360ea5198e05b4f49c7442234812809a63137ec6cdd3643a40cf0112", size = 5985, upload-time = "2019-07-11T08:15:59.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/80/71/23d03f57c18116c6770141478e33b3500c4e92500cf4b49a396e9226733f/pytest_md-0.2.0-py3-none-any.whl", hash = "sha256:4c4cd16fea6d1485e87ee254558712c804a96d2aa9674b780e7eb8fb6526e1d1", size = 6117, upload-time = "2019-07-11T08:15:57.829Z" }, -] - [[package]] name = "python-crontab" version = "3.2.0" @@ -1856,10 +1845,19 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/9b/41/bb668a6e4192303542d2d90c3b38d564af3c17c61bd7d4039af4f29405fe/secp256k1-0.14.0.tar.gz", hash = "sha256:82c06712d69ef945220c8b53c1a0d424c2ff6a1f64aee609030df79ad8383397", size = 2420607, upload-time = "2021-11-06T01:36:10.707Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/68/6f/be058f4c36f488c3528e5800559a4857e64b1b6e852612533dec4bac63cf/secp256k1-0.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2cf51c4f6892000a781d09cecd80466f4b9bea74b31b0470704ebcf26ae003ce", size = 1298106, upload-time = "2026-01-29T16:26:23.572Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c0/edfd6d53e2a7b19aace28dccd6b3359c52ddd1d905d5a060b7961a1bda3e/secp256k1-0.14.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2f54320f5dac8b740765e1ff5e47fd3cd2604ec334104c8f9113d29666b8ce6", size = 1498901, upload-time = "2026-01-29T16:26:25.89Z" }, { url = "https://files.pythonhosted.org/packages/09/46/5d3ca364058d39160e3623f0babafe78c2236d359e86924aa07524377c98/secp256k1-0.14.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f666c67dcf1dc69e1448b2ede5e12aaf382b600204a61dbc65e4f82cea444405", size = 1376587, upload-time = "2022-01-24T12:36:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c6/25b43bd42e24c8ac5babe262b40af0d6cf76ab035ad5e6b894eb9038ab4c/secp256k1-0.14.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53c779fd35328598522eb2b6cdb6d104e80440cb301ccc312b52bf62e5ea78ee", size = 1494595, upload-time = "2026-01-29T16:26:27.391Z" }, { url = "https://files.pythonhosted.org/packages/ce/e2/5b1616593ed1fa0e07e87b9f5118dc098bd1ddb2a6a7698d82b0ff85ad3f/secp256k1-0.14.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fcabb3c3497a902fb61eec72d1b69bf72747d7bcc2a732d56d9319a1e8322262", size = 1362187, upload-time = "2022-01-24T12:36:41.677Z" }, { url = "https://files.pythonhosted.org/packages/c9/9c/8148f74dd1fc65d0b97c8cbb101b468e27a2e93a3f291807d0d8ebe4bbf3/secp256k1-0.14.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7a27c479ab60571502516a1506a562d0a9df062de8ad645313fabfcc97252816", size = 1376363, upload-time = "2022-01-24T12:36:44.314Z" }, { url = "https://files.pythonhosted.org/packages/64/11/ff6d18314bc05a8f66619959a61be2fb6f37793d73e1dd106954b0978d92/secp256k1-0.14.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f4b9306bff6dde020444dfee9ca9b9f5b20ca53a2c0b04898361a3f43d5daf2e", size = 1369109, upload-time = "2022-01-24T12:36:46.782Z" }, + { url = "https://files.pythonhosted.org/packages/77/12/4c9815a819816587df70aa38fe7d09b54724a0b1b9b8e8ea2af1c205f2a5/secp256k1-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:539d1d9750299ec4e8df6211978ba78779f5095c7ef19985313f03d1d1b816bd", size = 1298105, upload-time = "2026-01-29T16:26:28.697Z" }, + { url = "https://files.pythonhosted.org/packages/b1/86/f01ee0f4c44e12933c460f2b868a3888b93a7c7f4e9fc9be173401b55e8d/secp256k1-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85d597a59e3918b0e41181a1c872851ac2e6137882de7f0487b8c42b25333ada", size = 1498906, upload-time = "2026-01-29T16:26:30.138Z" }, + { url = "https://files.pythonhosted.org/packages/05/c8/79f2990b72556c3f416ecfde2116a08afb41e324f51b8bf61268d7b72715/secp256k1-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:393d189b4ada9ab3de0b053f484a3b7e86024f4b8cd36616c05f07dbae3ca180", size = 1494612, upload-time = "2026-01-29T16:26:32.252Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e8/8dd140270b4e12a7f5876f1641f996854d700866352875f161f770b69ebb/secp256k1-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e4ec14534c1e8b8991376915ef059b7a3e62366aeda60df50b3932ad6529d26a", size = 1298100, upload-time = "2026-01-29T16:26:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6c/e63892de8d7582ab30602ccc1cf0ecd88a30b1a09424eb847c863fd46d9f/secp256k1-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1041694e429eb465123cb742911d2aad5cbd9e0cf2891aaaf794a887938647d1", size = 1499269, upload-time = "2026-01-29T16:26:35.717Z" }, + { url = "https://files.pythonhosted.org/packages/b8/5c/2faa8c523c0204af249890eb51b697e9a19d59d101625149d7b4f482e894/secp256k1-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bf03e6d45892172046d4e085d5cc91d13a73a465c0f4c8b5633d823b0ca667e2", size = 1494878, upload-time = "2026-01-29T16:26:37.857Z" }, ] [[package]] diff --git a/views.py b/views.py index 15dfb32..34005df 100644 --- a/views.py +++ b/views.py @@ -8,6 +8,7 @@ from lnbits.helpers import template_renderer from .crud import get_card_by_external_id, get_hits, get_refunds +from .models import CardInfo boltcards_generic_router = APIRouter() @@ -39,16 +40,15 @@ async def display( wallet = await get_wallet(card.wallet) wallet_balance = 0 - if wallet: - if wallet.user != user_id: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, detail="Card does not belong to user." - ) - wallet_balance = wallet.balance + if not wallet or wallet.user != user_id: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="Card does not belong to user." + ) + wallet_balance = wallet.balance hits = await get_hits([card.id]) hits_json = [hit.json() for hit in hits] refunds = [refund.hit_id for refund in await get_refunds([hit.id for hit in hits])] - card_json = card.json(exclude={"wallet"}) + card_json = CardInfo(**card.dict()).json(exclude={"wallet"}) return boltcards_renderer().TemplateResponse( "boltcards/display.html", { diff --git a/views_api.py b/views_api.py index ee75708..c686e14 100644 --- a/views_api.py +++ b/views_api.py @@ -1,9 +1,11 @@ from http import HTTPStatus -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Response from lnbits.core.crud import get_user from lnbits.core.models import WalletTypeInfo -from lnbits.decorators import require_admin_key, require_invoice_key +from lnbits.decorators import require_admin_key +from loguru import logger +from sqlalchemy.exc import IntegrityError from .crud import ( create_card, @@ -23,14 +25,18 @@ @boltcards_api_router.get("/api/v1/cards") async def api_cards( - key_info: WalletTypeInfo = Depends(require_invoice_key), all_wallets: bool = False + response: Response, + key_info: WalletTypeInfo = Depends(require_admin_key), + all_wallets: bool = False, ) -> list[Card]: + response.headers["Cache-Control"] = "no-store" wallet_ids = [key_info.wallet.id] if all_wallets: user = await get_user(key_info.wallet.user) wallet_ids = user.wallet_ids if user else [] + logger.info("Boltcard list retrieved: {}", key_info.wallet.id) return await get_cards(wallet_ids) @@ -69,8 +75,10 @@ def validate_card(data: CreateCardData): async def api_card_update( data: CreateCardData, card_id: str, + response: Response, wallet: WalletTypeInfo = Depends(require_admin_key), ) -> Card: + response.headers["Cache-Control"] = "no-store" card = await get_card(card_id) if not card: raise HTTPException( @@ -78,7 +86,7 @@ async def api_card_update( ) if card.wallet != wallet.wallet.id: raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN) - check_uid = await get_card_by_uid(data.uid) + check_uid = await get_card_by_uid(data.uid, wallet.wallet.id) if check_uid and check_uid.id != card_id: raise HTTPException( detail="UID already registered. Delete registered card and try again.", @@ -86,7 +94,13 @@ async def api_card_update( ) for key, value in data.dict().items(): setattr(card, key, value) - await update_card(card) + card.uid = card.uid.upper() + try: + await update_card(card) + except IntegrityError as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="Could not save card details." + ) from exc return card @@ -97,15 +111,22 @@ async def api_card_update( ) async def api_card_create( data: CreateCardData, + response: Response, wallet: WalletTypeInfo = Depends(require_admin_key), ) -> Card: - check_uid = await get_card_by_uid(data.uid) + response.headers["Cache-Control"] = "no-store" + check_uid = await get_card_by_uid(data.uid, wallet.wallet.id) if check_uid: raise HTTPException( detail="UID already registered. Delete registered card and try again.", status_code=HTTPStatus.BAD_REQUEST, ) - card = await create_card(wallet_id=wallet.wallet.id, data=data) + try: + card = await create_card(wallet_id=wallet.wallet.id, data=data) + except IntegrityError as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="Could not save card details." + ) from exc if not card: raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, @@ -115,19 +136,27 @@ async def api_card_create( @boltcards_api_router.get( - "/api/v1/cards/enable/{card_id}/{enable}", status_code=HTTPStatus.OK + "/api/v1/cards/enable/{card_id}/{enable}", + status_code=HTTPStatus.OK, ) async def enable_card( card_id: str, enable: bool, + response: Response, wallet: WalletTypeInfo = Depends(require_admin_key), ) -> Card: + response.headers["Cache-Control"] = "no-store" card = await get_card(card_id) if not card: raise HTTPException(detail="No card found.", status_code=HTTPStatus.NOT_FOUND) if card.wallet != wallet.wallet.id: raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN) - card = await enable_disable_card(enable=enable, card_id=card_id) + try: + card = await enable_disable_card(enable=enable, card_id=card_id) + except ValueError as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail=str(exc) + ) from exc if not card: raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, @@ -155,9 +184,11 @@ async def api_card_delete( @boltcards_api_router.get("/api/v1/hits") async def api_hits( - key_info: WalletTypeInfo = Depends(require_invoice_key), + response: Response, + key_info: WalletTypeInfo = Depends(require_admin_key), all_wallets: bool = Query(False), ) -> list[Hit]: + response.headers["Cache-Control"] = "no-store" wallet_ids = [key_info.wallet.id] if all_wallets: @@ -174,9 +205,11 @@ async def api_hits( @boltcards_api_router.get("/api/v1/refunds") async def api_refunds( - key_info: WalletTypeInfo = Depends(require_invoice_key), + response: Response, + key_info: WalletTypeInfo = Depends(require_admin_key), all_wallets: bool = Query(False), ) -> list[Refund]: + response.headers["Cache-Control"] = "no-store" wallet_ids = [key_info.wallet.id] if all_wallets: diff --git a/views_lnurl.py b/views_lnurl.py index d62a87f..8496560 100644 --- a/views_lnurl.py +++ b/views_lnurl.py @@ -1,11 +1,11 @@ import json -import secrets from http import HTTPStatus from urllib.parse import urlparse -import bolt11 -from fastapi import APIRouter, HTTPException, Query, Request -from lnbits.core.services import create_invoice, pay_invoice +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response +from fastapi.responses import JSONResponse +from limits import parse as parse_limit +from lnbits.core.services import create_invoice from lnurl import ( CallbackUrl, LightningInvoice, @@ -19,75 +19,36 @@ MessageAction, MilliSatoshi, ) +from loguru import logger from pydantic import parse_obj_as -from .crud import ( - create_hit, - get_card, - get_card_by_external_id, - get_card_by_otp, - get_card_by_uid, - get_hit, - get_hits_today, - spend_hit, - update_card_counter, - update_card_otp, -) +from .crud import consume_card_otp, get_card, get_hit from .models import UIDPost -from .nxp424 import decrypt_sun, get_sun_mac +from .services import pay_card_invoice, scan_card boltcards_lnurl_router = APIRouter() # /boltcards/api/v1/scan?p=00000000000000000000000000000000&c=0000000000000000 -@boltcards_lnurl_router.get("/api/v1/scan/{external_id}") +@boltcards_lnurl_router.get( + "/api/v1/scan/{external_id}", + response_model=LnurlWithdrawResponse | LnurlErrorResponse, +) async def api_scan( - p, c, request: Request, external_id: str -) -> LnurlWithdrawResponse | LnurlErrorResponse: - # some wallets send everything as lower case, no bueno - p = p.upper() - c = c.upper() - card = None - counter = b"" - card = await get_card_by_external_id(external_id) - if not card: - return LnurlErrorResponse(reason="Card not found.") - if not card.enable: - return LnurlErrorResponse(reason="Card is disabled.") - try: - card_uid, counter = decrypt_sun(bytes.fromhex(p), bytes.fromhex(card.k1)) - if card.uid.upper() != card_uid.hex().upper(): - return LnurlErrorResponse(reason="Card UID mis-match.") - if c != get_sun_mac(card_uid, counter, bytes.fromhex(card.k2)).hex().upper(): - return LnurlErrorResponse(reason="CMAC does not check.") - except Exception: - return LnurlErrorResponse(reason="Error decrypting card.") - - ctr_int = int.from_bytes(counter, "little") - - if ctr_int <= card.counter: - return LnurlErrorResponse(reason="This link is already used.") - - await update_card_counter(ctr_int, card.id) - - # gathering some info for hit record + p: str, c: str, request: Request, external_id: str +) -> JSONResponse | LnurlErrorResponse: if not request.client: return LnurlErrorResponse(reason="Cannot get client info.") - ip = request.client.host - if "x-real-ip" in request.headers: - ip = request.headers["x-real-ip"] - elif "x-forwarded-for" in request.headers: - ip = request.headers["x-forwarded-for"] - - agent = request.headers["user-agent"] if "user-agent" in request.headers else "" - todays_hits = await get_hits_today(card.id) - - hits_amount = 0 - for hit in todays_hits: - hits_amount += hit.amount - if hits_amount > int(card.daily_limit): - return LnurlErrorResponse(reason="Max daily limit spent.") - hit = await create_hit(card.id, ip, agent, card.counter, ctr_int) + try: + hit = await scan_card( + external_id, + p, + c, + request.client.host, + request.headers.get("user-agent", ""), + ) + except ValueError as exc: + return LnurlErrorResponse(reason=str(exc)) # create a lud17 lnurlp to support lud19, add payLink field of the withdrawRequest lnurlpay_url = str(request.url_for("boltcards.lnurlp_response", hit_id=hit.id)) @@ -97,14 +58,15 @@ async def api_scan( callback_url = parse_obj_as( CallbackUrl, str(request.url_for("boltcards.lnurl_callback", hit_id=hit.id)) ) - return LnurlWithdrawResponse( + response = LnurlWithdrawResponse( callback=callback_url, k1=hit.id, - minWithdrawable=MilliSatoshi(1000), - maxWithdrawable=MilliSatoshi(int(card.tx_limit) * 1000), + minWithdrawable=MilliSatoshi(min(1000, hit.max_msat)), + maxWithdrawable=MilliSatoshi(hit.max_msat), defaultDescription=f"Boltcard (refund address {pay_link})", - payLink=pay_link, # type: ignore ) + # lnurl's payLink validator loses the lnurlp scheme during model validation. + return JSONResponse({**response.dict(), "payLink": pay_link}) @boltcards_lnurl_router.get( @@ -122,58 +84,37 @@ async def lnurl_callback( if k1 != hit_id: return LnurlErrorResponse(reason="K1 token does not match.") - hit = await get_hit(hit_id) - if not hit: - return LnurlErrorResponse(reason="LNURL-withdraw record not found.") - if hit.spent: - return LnurlErrorResponse(reason="Payment already claimed.") - if not pr: - return LnurlErrorResponse(reason="Missing payment request.") - try: - invoice = bolt11.decode(pr) - except bolt11.Bolt11Exception: - return LnurlErrorResponse(reason="Failed to decode payment request.") - if not invoice.amount_msat: - return LnurlErrorResponse(reason="Invoice has no amount.") - card = await get_card(hit.card_id) - if not card: - return LnurlErrorResponse(reason="Card not found.") - hit = await spend_hit(card_id=hit.id, amount=int(invoice.amount_msat / 1000)) - if not hit: - return LnurlErrorResponse(reason="Failed to update hit as spent.") - try: - await pay_invoice( - wallet_id=card.wallet, - payment_request=pr, - max_sat=int(card.tx_limit), - extra={"tag": "boltcards", "hit": hit.id}, + await pay_card_invoice(hit_id, pr) + except ValueError as exc: + return LnurlErrorResponse(reason=str(exc)) + return LnurlSuccessResponse() + + +def auth_request(request: Request, response: Response): + response.headers["Cache-Control"] = "no-store" + response.headers["Referrer-Policy"] = "no-referrer" + # Reuse LNbits' configured limiter storage and trusted transport client address. + limiter = request.app.state.limiter + peer = request.client.host if request.client else "unknown" + if not limiter.limiter.hit(parse_limit("10/minute"), "boltcards-auth", peer): + logger.warning("Boltcard auth rate limit exceeded") + raise HTTPException( + status_code=HTTPStatus.TOO_MANY_REQUESTS, detail="Try again later." ) - return LnurlSuccessResponse() - except Exception as exc: - return LnurlErrorResponse(reason=f"Payment failed - {exc}") -# /boltcards/api/v1/auth?a=00000000000000000000000000000000 -@boltcards_lnurl_router.get("/api/v1/auth") -async def api_auth(a, request: Request): - if a == "00000000000000000000000000000000": - response = {"k0": "0" * 32, "k1": "1" * 32, "k2": "2" * 32} - return response - - card = await get_card_by_otp(a) +@boltcards_lnurl_router.get("/api/v1/auth", dependencies=[Depends(auth_request)]) +async def api_auth(a: str, request: Request, wipe: bool = False): + card = await consume_card_otp(a, wipe=wipe) if not card: + logger.warning("Boltcard auth rejected") raise HTTPException( - detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND + detail="Invalid or expired authorization.", status_code=HTTPStatus.NOT_FOUND ) - - new_otp = secrets.token_hex(16) - await update_card_otp(new_otp, card.id) - lnurlw_base = ( f"{urlparse(str(request.url)).netloc}/boltcards/api/v1/scan/{card.external_id}" ) - response = { "card_name": card.card_name, "id": str(1), @@ -186,29 +127,19 @@ async def api_auth(a, request: Request): "protocol_name": "new_bolt_card_response", "protocol_version": str(1), } - + if wipe: + response["action"] = "wipe" return response -# /boltcards/api/v1/auth?a=00000000000000000000000000000000 -@boltcards_lnurl_router.post("/api/v1/auth") +@boltcards_lnurl_router.post("/api/v1/auth", dependencies=[Depends(auth_request)]) async def api_auth_post(a: str, request: Request, data: UIDPost, wipe: bool = False): - card = None - if wipe: - card = await get_card_by_otp(a) - else: - if not data.UID: - raise HTTPException( - detail="Missing UID.", status_code=HTTPStatus.BAD_REQUEST - ) - - card = await get_card_by_uid(data.UID) + card = await consume_card_otp(a, wipe=wipe, uid=data.UID) if not card: + logger.warning("Boltcard auth rejected") raise HTTPException( - detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND + detail="Invalid or expired authorization.", status_code=HTTPStatus.NOT_FOUND ) - new_otp = secrets.token_hex(16) - await update_card_otp(new_otp, card.id) lnurlw_base = ( f"{urlparse(str(request.url)).netloc}/boltcards/api/v1/scan/{card.external_id}" ) @@ -248,14 +179,20 @@ async def lnurlp_callback( return LnurlErrorResponse(reason="Card is disabled.") if not amount: return LnurlErrorResponse(reason="Missing amount.") - if int(amount) < 1000: + try: + amount_msat = int(amount) + except ValueError: + return LnurlErrorResponse(reason="Invalid amount.") + if amount_msat < 1000: return LnurlErrorResponse(reason="Amount too low.") - if int(amount) > int(card.tx_limit) * 1000: + if amount_msat > card.tx_limit * 1000: return LnurlErrorResponse(reason="Amount too high.") + if amount_msat % 1000: + return LnurlErrorResponse(reason="Amount must be a whole number of satoshis.") payment = await create_invoice( wallet_id=card.wallet, - amount=int(int(amount) / 1000), + amount=amount_msat // 1000, memo=f"Refund {hit_id}", unhashed_description=LnurlPayMetadata( json.dumps([["text/plain", "Refund"]])