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"]])