From 00bc148aae1f41c90099ad2e88744a45e02c38b3 Mon Sep 17 00:00:00 2001 From: Lee Salminen Date: Wed, 21 Feb 2024 08:47:58 -0600 Subject: [PATCH 1/6] add optional expiration date field to cards --- crud.py | 6 ++++-- lnurl.py | 3 +++ migrations.py | 7 +++++++ models.py | 4 +++- static/js/index.js | 6 ++++++ templates/boltcards/index.html | 9 +++++++++ 6 files changed, 32 insertions(+), 3 deletions(-) diff --git a/crud.py b/crud.py index 0a678e7..dc244aa 100644 --- a/crud.py +++ b/crud.py @@ -27,9 +27,10 @@ async def create_card(data: CreateCardData, wallet_id: str) -> Card: k0, k1, k2, - otp + otp, + expiration_date ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( card_id, @@ -45,6 +46,7 @@ async def create_card(data: CreateCardData, wallet_id: str) -> Card: data.k1, data.k2, secrets.token_hex(16), + data.expiration_date, ), ) card = await get_card(card_id) diff --git a/lnurl.py b/lnurl.py index 6ebea46..10248fa 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 @@ -42,6 +43,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 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(): diff --git a/migrations.py b/migrations.py index 43d5bb0..c9a1472 100644 --- a/migrations.py +++ b/migrations.py @@ -55,3 +55,10 @@ async def m001_initial(db): ); """ ) + +async def m002_add_features(db): + await db.execute( + """ + ALTER TABLE boltcards.cards ADD expiration_date DATE NULL; + """ + ) \ No newline at end of file diff --git a/models.py b/models.py index bd044ac..ffee44f 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" @@ -28,6 +29,7 @@ class Card(BaseModel): prev_k2: str otp: str time: int + expiration_date: Optional[str] @classmethod def from_row(cls, row: Row) -> "Card": @@ -54,7 +56,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 diff --git a/static/js/index.js b/static/js/index.js index 880a555..54aa821 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -63,6 +63,12 @@ new Vue({ align: 'left', label: 'Daily tx limit', field: 'daily_limit' + }, + { + 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..0f8db30 100644 --- a/templates/boltcards/index.html +++ b/templates/boltcards/index.html @@ -257,6 +257,15 @@
> + + Date: Wed, 21 Feb 2024 11:06:44 -0600 Subject: [PATCH 2/6] add monthly limit, allow daily/monthly limit tracking to be denominated in fiat --- crud.py | 26 ++++++++++++++- lnurl.py | 58 ++++++++++++++++++++++++++++++++-- migrations.py | 21 ++++++++++++ models.py | 5 +++ static/js/index.js | 12 +++++++ templates/boltcards/index.html | 41 ++++++++++++++++++------ 6 files changed, 149 insertions(+), 14 deletions(-) diff --git a/crud.py b/crud.py index dc244aa..a7ca94a 100644 --- a/crud.py +++ b/crud.py @@ -23,6 +23,8 @@ async def create_card(data: CreateCardData, wallet_id: str) -> Card: counter, tx_limit, daily_limit, + monthly_limit, + limit_type, enable, k0, k1, @@ -30,7 +32,7 @@ async def create_card(data: CreateCardData, wallet_id: str) -> Card: otp, expiration_date ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( card_id, @@ -41,6 +43,8 @@ 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, @@ -195,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( @@ -204,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 10248fa..04f4344 100644 --- a/lnurl.py +++ b/lnurl.py @@ -12,6 +12,9 @@ from lnbits import bolt11 from lnbits.core.services import create_invoice from lnbits.core.views.api import pay_invoice +from lnbits.core.crud import ( + get_standalone_payment, +) from . import boltcards_ext from .crud import ( @@ -21,7 +24,9 @@ get_card_by_otp, get_hit, get_hits_today, + get_hits_this_month, spend_hit, + link_hit, update_card_counter, update_card_otp, ) @@ -43,7 +48,7 @@ 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 datetime.strptime(card.expiration_date, '%Y-%m-%d') < datetime.now(): + 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)) @@ -74,9 +79,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 @@ -129,15 +153,43 @@ async def lnurl_callback( card = await get_card(hit.card_id) assert card + + todays_hits = await get_hits_today(card.id) + + 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 c9a1472..38db883 100644 --- a/migrations.py +++ b/migrations.py @@ -61,4 +61,25 @@ async def m002_add_features(db): """ ALTER TABLE boltcards.cards ADD expiration_date DATE 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"; + """ ) \ No newline at end of file diff --git a/models.py b/models.py index ffee44f..e13f94a 100644 --- a/models.py +++ b/models.py @@ -20,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 @@ -49,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) @@ -68,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 54aa821..db76f82 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -64,6 +64,18 @@ new Vue({ 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', diff --git a/templates/boltcards/index.html b/templates/boltcards/index.html index 0f8db30..b1a1609 100644 --- a/templates/boltcards/index.html +++ b/templates/boltcards/index.html @@ -234,15 +234,25 @@
label="Wallet *" > +

Limits

+
@@ -251,21 +261,32 @@
filled dense emit-value - v-model.trim="cardDialog.data.daily_limit" + v-model.trim="cardDialog.data.monthly_limit" type="number" - label="Daily limit (sats)" + label="Per Month" + class="q-pr-sm" >
+
+ + +
- - + Date: Wed, 21 Feb 2024 11:13:23 -0600 Subject: [PATCH 3/6] typo for postgres --- migrations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations.py b/migrations.py index 38db883..5cc1b77 100644 --- a/migrations.py +++ b/migrations.py @@ -80,6 +80,6 @@ async def m004_add_features(db): async def m005_add_features(db): await db.execute( """ - ALTER TABLE boltcards.cards ADD limit_type TEXT NOT NULL DEFAULT "sats"; + ALTER TABLE boltcards.cards ADD limit_type TEXT NOT NULL DEFAULT 'sats'; """ ) \ No newline at end of file From 21ace1b98c6cbb110c60ce52b7f58bdc8241efed Mon Sep 17 00:00:00 2001 From: Lee Salminen Date: Wed, 21 Feb 2024 12:07:12 -0600 Subject: [PATCH 4/6] Update migrations.py --- migrations.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/migrations.py b/migrations.py index 5cc1b77..386214a 100644 --- a/migrations.py +++ b/migrations.py @@ -59,7 +59,7 @@ async def m001_initial(db): async def m002_add_features(db): await db.execute( """ - ALTER TABLE boltcards.cards ADD expiration_date DATE NULL; + ALTER TABLE boltcards.cards ADD expiration_date TEXT NULL; """ ) @@ -82,4 +82,4 @@ async def m005_add_features(db): """ ALTER TABLE boltcards.cards ADD limit_type TEXT NOT NULL DEFAULT 'sats'; """ - ) \ No newline at end of file + ) From 756b1dfa856fdb3065995d183b605bbcb91c8fb3 Mon Sep 17 00:00:00 2001 From: Lee Salminen Date: Thu, 23 May 2024 10:41:31 -0300 Subject: [PATCH 5/6] fix bug in fiat daily limit amount calculation --- lnurl.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lnurl.py b/lnurl.py index 04f4344..39ba613 100644 --- a/lnurl.py +++ b/lnurl.py @@ -10,7 +10,7 @@ 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, @@ -156,7 +156,14 @@ async def lnurl_callback( todays_hits = await get_hits_today(card.id) - hits_amount = int(invoice.amount_msat / 1000) + 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) From 7586f2ff4a3c6e7732e1643318a7c0a304a21e74 Mon Sep 17 00:00:00 2001 From: Lee Salminen Date: Thu, 23 May 2024 10:41:42 -0300 Subject: [PATCH 6/6] add balance check endpoint --- lnurl.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/lnurl.py b/lnurl.py index 39ba613..47bac3c 100644 --- a/lnurl.py +++ b/lnurl.py @@ -14,6 +14,7 @@ from lnbits.core.views.api import pay_invoice from lnbits.core.crud import ( get_standalone_payment, + get_wallet, ) from . import boltcards_ext @@ -35,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):