Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"
Expand Down
295 changes: 219 additions & 76 deletions crud.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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


Expand All @@ -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,
)

Expand All @@ -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})
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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"])
Loading
Loading