diff --git a/crud.py b/crud.py index 0a678e7..a7ca94a 100644 --- a/crud.py +++ b/crud.py @@ -23,13 +23,16 @@ async def create_card(data: CreateCardData, wallet_id: str) -> Card: counter, tx_limit, daily_limit, + monthly_limit, + limit_type, enable, k0, k1, k2, - otp + otp, + expiration_date ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( card_id, @@ -40,11 +43,14 @@ async def create_card(data: CreateCardData, wallet_id: str) -> Card: data.counter, data.tx_limit, data.daily_limit, + data.monthly_limit, + data.limit_type, True, data.k0, data.k1, data.k2, secrets.token_hex(16), + data.expiration_date, ), ) card = await get_card(card_id) @@ -193,6 +199,18 @@ async def get_hits_today(card_id: str) -> List[Hit]: return [Hit(**row) for row in updatedrow] +async def get_hits_this_month(card_id: str) -> List[Hit]: + rows = await db.fetchall( + "SELECT * FROM boltcards.hits WHERE card_id = ?", + (card_id,), + ) + updatedrow = [] + for row in rows: + if datetime.fromtimestamp(row.time).date() >= datetime.today().replace(day=1).date(): + updatedrow.append(row) + + return [Hit(**row) for row in updatedrow] + async def spend_hit(id: str, amount: int): await db.execute( @@ -202,6 +220,14 @@ async def spend_hit(id: str, amount: int): return await get_hit(id) +async def link_hit(id: str, hash: str): + await db.execute( + "UPDATE boltcards.hits SET payment_hash = ? WHERE id = ?", + (hash, id), + ) + return await get_hit(id) + + async def create_hit(card_id, ip, useragent, old_ctr, new_ctr) -> Hit: hit_id = urlsafe_short_hash() await db.execute( diff --git a/lnurl.py b/lnurl.py index 6ebea46..47bac3c 100644 --- a/lnurl.py +++ b/lnurl.py @@ -2,6 +2,7 @@ import secrets from http import HTTPStatus from urllib.parse import urlparse +from datetime import datetime from fastapi import HTTPException, Query, Request from lnurl import encode as lnurl_encode @@ -9,8 +10,12 @@ from starlette.responses import HTMLResponse from lnbits import bolt11 -from lnbits.core.services import create_invoice +from lnbits.core.services import create_invoice, calculate_fiat_amounts from lnbits.core.views.api import pay_invoice +from lnbits.core.crud import ( + get_standalone_payment, + get_wallet, +) from . import boltcards_ext from .crud import ( @@ -20,7 +25,9 @@ get_card_by_otp, get_hit, get_hits_today, + get_hits_this_month, spend_hit, + link_hit, update_card_counter, update_card_otp, ) @@ -29,6 +36,47 @@ ###############LNURLWITHDRAW################# +@boltcards_ext.get("/api/v1/balance/{external_id}") +async def api_balance(p, c, request: Request, external_id: str): + # 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 {"status": "ERROR", "reason": "No card."} + if not card.enable: + return {"status": "ERROR", "reason": "Card is disabled."} + if card.expiration_date is not None and card.expiration_date != "" and datetime.strptime(card.expiration_date, '%Y-%m-%d') < datetime.now(): + return {"status": "ERROR", "reason": "Card is expired."} + try: + card_uid, counter = decryptSUN(bytes.fromhex(p), bytes.fromhex(card.k1)) + if card.uid.upper() != card_uid.hex().upper(): + return {"status": "ERROR", "reason": "Card UID mis-match."} + if c != getSunMAC(card_uid, counter, bytes.fromhex(card.k2)).hex().upper(): + return {"status": "ERROR", "reason": "CMAC does not check."} + except: + return {"status": "ERROR", "reason": "Error decrypting card."} + + ctr_int = int.from_bytes(counter, "little") + + if ctr_int <= card.counter: + return {"status": "ERROR", "reason": "This link is already used."} + + await update_card_counter(ctr_int, card.id) + + wallet = await get_wallet(card.wallet) + balance = 0 + + if wallet: + balance = wallet.balance_msat / 1000 + + return { + "balance": balance, + } + + # /boltcards/api/v1/scan?p=00000000000000000000000000000000&c=0000000000000000 @boltcards_ext.get("/api/v1/scan/{external_id}") async def api_scan(p, c, request: Request, external_id: str): @@ -42,6 +90,8 @@ async def api_scan(p, c, request: Request, external_id: str): return {"status": "ERROR", "reason": "No card."} if not card.enable: return {"status": "ERROR", "reason": "Card is disabled."} + if card.expiration_date is not None and card.expiration_date != "" and datetime.strptime(card.expiration_date, '%Y-%m-%d') < datetime.now(): + return {"status": "ERROR", "reason": "Card is expired."} try: card_uid, counter = decryptSUN(bytes.fromhex(p), bytes.fromhex(card.k1)) if card.uid.upper() != card_uid.hex().upper(): @@ -71,9 +121,28 @@ async def api_scan(p, c, request: Request, external_id: str): hits_amount = 0 for hit in todays_hits: - hits_amount = hits_amount + hit.amount + if card.limit_type == "fiat": + payment = await get_standalone_payment(checking_id_or_hash=hit.payment_hash, wallet_id=card.wallet) + if payment != None and payment.extra != None: + hits_amount = hits_amount + payment.extra.get("wallet_fiat_amount") + else: + hits_amount = hits_amount + hit.amount if hits_amount > card.daily_limit: return {"status": "ERROR", "reason": "Max daily limit spent."} + + this_month_hits = await get_hits_this_month(card.id) + + this_month_hits_amount = 0 + for hit in this_month_hits: + if card.limit_type == "fiat": + payment = await get_standalone_payment(checking_id_or_hash=hit.payment_hash, wallet_id=card.wallet) + if payment != None and payment.extra != None: + this_month_hits_amount = this_month_hits_amount + payment.extra.get("wallet_fiat_amount") + else: + this_month_hits_amount = this_month_hits_amount + hit.amount + if this_month_hits_amount > card.monthly_limit: + return {"status": "ERROR", "reason": "Max monthly limit spent."} + hit = await create_hit(card.id, ip, agent, card.counter, ctr_int) # the raw lnurl @@ -126,15 +195,50 @@ async def lnurl_callback( card = await get_card(hit.card_id) assert card + + todays_hits = await get_hits_today(card.id) + + hits_amount = 0 + + if card.limit_type == "fiat": + amount_sat, extra = await calculate_fiat_amounts(wallet_id=card.wallet, amount=invoice.amount_msat / 1000) + hits_amount = extra.get("wallet_fiat_amount") + else: + hits_amount = int(invoice.amount_msat / 1000) + + for hit in todays_hits: + if card.limit_type == "fiat": + payment = await get_standalone_payment(checking_id_or_hash=hit.payment_hash, wallet_id=card.wallet) + if payment != None and payment.extra != None: + hits_amount = hits_amount + payment.extra.get("wallet_fiat_amount") + else: + hits_amount = hits_amount + hit.amount + if hits_amount > card.daily_limit: + return {"status": "ERROR", "reason": "Max daily limit spent."} + + this_month_hits = await get_hits_this_month(card.id) + + this_month_hits_amount = int(invoice.amount_msat / 1000) + for hit in this_month_hits: + if card.limit_type == "fiat": + payment = await get_standalone_payment(checking_id_or_hash=hit.payment_hash, wallet_id=card.wallet) + if payment != None and payment.extra != None: + this_month_hits_amount = this_month_hits_amount + payment.extra.get("wallet_fiat_amount") + else: + this_month_hits_amount = this_month_hits_amount + hit.amount + if this_month_hits_amount > card.monthly_limit: + return {"status": "ERROR", "reason": "Max monthly limit spent."} + hit = await spend_hit(id=hit.id, amount=int(invoice.amount_msat / 1000)) assert hit try: - await pay_invoice( + payment_hash = await pay_invoice( wallet_id=card.wallet, payment_request=pr, max_sat=card.tx_limit, extra={"tag": "boltcards", "hit": hit.id}, ) + await link_hit(id=hit.id, hash=payment_hash) return {"status": "OK"} except Exception as exc: return {"status": "ERROR", "reason": f"Payment failed - {exc}"} diff --git a/migrations.py b/migrations.py index 43d5bb0..386214a 100644 --- a/migrations.py +++ b/migrations.py @@ -55,3 +55,31 @@ async def m001_initial(db): ); """ ) + +async def m002_add_features(db): + await db.execute( + """ + ALTER TABLE boltcards.cards ADD expiration_date TEXT NULL; + """ + ) + +async def m003_add_features(db): + await db.execute( + """ + ALTER TABLE boltcards.hits ADD payment_hash TEXT NULL; + """ + ) + +async def m004_add_features(db): + await db.execute( + """ + ALTER TABLE boltcards.cards ADD monthly_limit TEXT NOT NULL DEFAULT 0; + """ + ) + +async def m005_add_features(db): + await db.execute( + """ + ALTER TABLE boltcards.cards ADD limit_type TEXT NOT NULL DEFAULT 'sats'; + """ + ) diff --git a/models.py b/models.py index bd044ac..e13f94a 100644 --- a/models.py +++ b/models.py @@ -6,6 +6,7 @@ from lnurl import encode as lnurl_encode from lnurl.types import LnurlPayMetadata from pydantic import BaseModel +from pydantic.schema import Optional ZERO_KEY = "00000000000000000000000000000000" @@ -19,6 +20,8 @@ class Card(BaseModel): counter: int tx_limit: int daily_limit: int + monthly_limit: int + limit_type: str enable: bool k0: str k1: str @@ -28,6 +31,7 @@ class Card(BaseModel): prev_k2: str otp: str time: int + expiration_date: Optional[str] @classmethod def from_row(cls, row: Row) -> "Card": @@ -47,6 +51,8 @@ class CreateCardData(BaseModel): counter: int = Query(0) tx_limit: int = Query(0) daily_limit: int = Query(0) + monthly_limit: int = Query(0) + limit_type: str = Query("sats") enable: bool = Query(True) k0: str = Query(ZERO_KEY) k1: str = Query(ZERO_KEY) @@ -54,7 +60,7 @@ class CreateCardData(BaseModel): prev_k0: str = Query(ZERO_KEY) prev_k1: str = Query(ZERO_KEY) prev_k2: str = Query(ZERO_KEY) - + expiration_date: Optional[str] class Hit(BaseModel): id: str @@ -66,6 +72,7 @@ class Hit(BaseModel): new_ctr: int amount: int time: int + payment_hash: Optional[str] @classmethod def from_row(cls, row: Row) -> "Hit": diff --git a/static/js/index.js b/static/js/index.js index 880a555..db76f82 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -63,6 +63,24 @@ new Vue({ align: 'left', label: 'Daily tx limit', field: 'daily_limit' + }, + { + name: 'monthly_limit', + align: 'left', + label: 'Monthly tx limit', + field: 'monthly_limit' + }, + { + name: 'limit_type', + align: 'left', + label: 'Limit type', + field: 'limit_type' + }, + { + name: 'expiration_date', + align: 'left', + label: 'Expiration Date', + field: 'expiration_date' } ], pagination: { diff --git a/templates/boltcards/index.html b/templates/boltcards/index.html index 7e06e5e..b1a1609 100644 --- a/templates/boltcards/index.html +++ b/templates/boltcards/index.html @@ -234,15 +234,25 @@
Limits
+