diff --git a/lnbits/extensions/boltcards/__init__.py b/lnbits/extensions/boltcards/__init__.py index 63b1252d..fe99ee2e 100644 --- a/lnbits/extensions/boltcards/__init__.py +++ b/lnbits/extensions/boltcards/__init__.py @@ -20,5 +20,6 @@ def boltcards_renderer(): return template_renderer(["lnbits/extensions/boltcards/templates"]) from .lnurl import * # noqa +from .tasks import * # noqa from .views import * # noqa from .views_api import * # noqa diff --git a/lnbits/extensions/boltcards/crud.py b/lnbits/extensions/boltcards/crud.py index 6d9f8b1c..ab6fde09 100644 --- a/lnbits/extensions/boltcards/crud.py +++ b/lnbits/extensions/boltcards/crud.py @@ -8,28 +8,32 @@ from .models import Card, CreateCardData, Hit, Refund async def create_card(data: CreateCardData, wallet_id: str) -> Card: - card_id = urlsafe_short_hash() + card_id = urlsafe_short_hash().upper() await db.execute( """ INSERT INTO boltcards.cards ( id, + uid, wallet, card_name, counter, - withdraw, + tx_limit, + daily_limit, k0, k1, k2, otp ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( card_id, + data.uid.upper(), wallet_id, data.card_name, data.counter, - data.withdraw, + data.tx_limit, + data.daily_limit, data.k0, data.k1, data.k2, @@ -67,12 +71,6 @@ async def get_cards(wallet_ids: Union[str, List[str]]) -> List[Card]: return [Card(**row) for row in rows] -async def get_all_cards() -> List[Card]: - rows = await db.fetchall(f"SELECT * FROM boltcards.cards") - - return [Card(**row) for row in rows] - - async def get_card(card_id: str) -> Optional[Card]: row = await db.fetchone("SELECT * FROM boltcards.cards WHERE id = ?", (card_id,)) if not row: @@ -143,11 +141,16 @@ async def get_hits(cards_ids: Union[str, List[str]]) -> List[Hit]: async def get_hits_today(card_id: Union[str, List[str]]) -> List[Hit]: rows = await db.fetchall( - f"SELECT * FROM boltcards.hits WHERE card_id = ? AND timestamp >= DATE() AND timestamp < DATE() + INTERVAL ? DAY", (card_id, 1) + f"SELECT * FROM boltcards.hits WHERE card_id = ? AND time >= DATE('now') AND time < DATE('now', '+1 day')", (card_id,) ) return [Hit(**row) for row in rows] +async def spend_hit(id: str): + await db.execute( + "UPDATE boltcards.hits SET spent = ? WHERE id = ?", + (True, id), + ) async def create_hit(card_id, ip, useragent, old_ctr, new_ctr) -> Hit: hit_id = urlsafe_short_hash() @@ -157,21 +160,63 @@ async def create_hit(card_id, ip, useragent, old_ctr, new_ctr) -> Hit: id, card_id, ip, + spent, useragent, old_ctr, - new_ctr + new_ctr, + amount ) - VALUES (?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( hit_id, card_id, ip, + False, useragent, old_ctr, new_ctr, + 0, ), ) 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: + refund_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO boltcards.hits ( + id, + hit_id, + refund_amount, + payment_hash + ) + VALUES (?, ?, ?, ?) + """, + ( + refund_id, + hit_id, + refund_amount, + payment_hash, + ), + ) + refund = await get_refund(refund_id) + assert refund, "Newly recorded hit couldn't be retrieved" + return refund + +async def get_refund(refund_id: str) -> Optional[Refund]: + row = await db.fetchone(f"SELECT * FROM boltcards.refunds WHERE id = ?", (refund_id)) + if not row: + return None + refund = dict(**row) + return Refund.parse_obj(refund) + +async def get_refunds(hits_ids: Union[str, List[str]]) -> List[Refund]: + q = ",".join(["?"] * len(hits_ids)) + rows = await db.fetchall( + f"SELECT * FROM boltcards.refunds WHERE hit_id IN ({q})", (*hits_ids,) + ) + + return [Refund(**row) for row in rows] \ No newline at end of file diff --git a/lnbits/extensions/boltcards/lnurl.py b/lnbits/extensions/boltcards/lnurl.py index 1d62199f..a41cc5d1 100644 --- a/lnbits/extensions/boltcards/lnurl.py +++ b/lnbits/extensions/boltcards/lnurl.py @@ -1,10 +1,13 @@ import base64 import hashlib import hmac +import json from http import HTTPStatus from io import BytesIO from typing import Optional +from loguru import logger + from embit import bech32, compact from fastapi import Request from fastapi.param_functions import Query @@ -33,6 +36,7 @@ from .crud import ( get_card, get_hit, get_hits_today, + spend_hit, update_card, update_card_counter, update_card_otp, @@ -50,11 +54,11 @@ async def api_scan(p, c, request: Request, card_id: str = None): c = c.upper() card = None counter = b"" - try: - card = await get_card(card_id) - card_id, counter = decryptSUN(bytes.fromhex(p), bytes.fromhex(card.k1)) - if card.uid.upper() != card_id.hex().upper(): + try: + card = await get_card_by_uid(card_uid) + 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."} except: return {"status": "ERROR", "reason": "Error decrypting card."} @@ -67,8 +71,8 @@ async def api_scan(p, c, request: Request, card_id: str = None): ctr_int = int.from_bytes(counter, "little") - if ctr_int <= card.counter: - return {"status": "ERROR", "reason": "This link is already used."} + # if ctr_int <= card.counter: + # return {"status": "ERROR", "reason": "This link is already used."} await update_card_counter(ctr_int, card.id) @@ -86,13 +90,13 @@ async def api_scan(p, c, request: Request, card_id: str = None): for hit in todays_hits: hits_amount = hits_amount + hit.amount if (hits_amount + card.tx_limit) > card.daily_limit: - return {"status": "ERROR", "reason": "Max daily liit spent."} + return {"status": "ERROR", "reason": "Max daily limit spent."} hit = await create_hit(card.id, ip, agent, card.counter, ctr_int) lnurlpay = lnurl_encode(request.url_for("boltcards.lnurlp_response", hit_id=hit.id)) return { "tag": "withdrawRequest", "callback": request.url_for( - "boltcards.lnurl_callback" + "boltcards.lnurl_callback", hitid=hit.id ), "k1": hit.id, "minWithdrawable": 1 * 1000, @@ -166,14 +170,15 @@ async def api_auth(a, request: Request): ) async def lnurlp_response(req: Request, hit_id: str = Query(None)): hit = await get_hit(hit_id) + card = await get_card(hit.card_id) if not hit: return {"status": "ERROR", "reason": f"LNURL-pay record not found."} payResponse = { "tag": "payRequest", "callback": req.url_for("boltcards.lnurlp_callback", hit_id=hit_id), "metadata": LnurlPayMetadata(json.dumps([["text/plain", "Refund"]])), - "minSendable": math.ceil(link.min_bet * 1) * 1000, - "maxSendable": round(link.max_bet * 1) * 1000, + "minSendable": 1 * 1000, + "maxSendable": card.tx_limit * 1000, } return json.dumps(payResponse) @@ -187,14 +192,15 @@ async def lnurlp_callback( req: Request, hit_id: str = Query(None), amount: str = Query(None) ): hit = await get_hit(hit_id) + card = await get_card(hit.card_id) if not hit: return {"status": "ERROR", "reason": f"LNURL-pay record not found."} payment_hash, payment_request = await create_invoice( - wallet_id=link.wallet, - amount=int(amount / 1000), + wallet_id=card.wallet, + amount=int(amount) / 1000, memo=f"Refund {hit_id}", - unhashed_description=LnurlPayMetadata(json.dumps([["text/plain", hit_id]])).encode("utf-8"), + unhashed_description=LnurlPayMetadata(json.dumps([["text/plain", "Refund"]])).encode("utf-8"), extra={"refund": hit_id}, ) diff --git a/lnbits/extensions/boltcards/models.py b/lnbits/extensions/boltcards/models.py index 022d636c..4f23b745 100644 --- a/lnbits/extensions/boltcards/models.py +++ b/lnbits/extensions/boltcards/models.py @@ -64,6 +64,7 @@ class Hit(BaseModel): useragent: str old_ctr: int new_ctr: int + amount: int time: int def from_row(cls, row: Row) -> "Hit": diff --git a/lnbits/extensions/boltcards/static/js/index.js b/lnbits/extensions/boltcards/static/js/index.js index 4ce6d759..ceea0508 100644 --- a/lnbits/extensions/boltcards/static/js/index.js +++ b/lnbits/extensions/boltcards/static/js/index.js @@ -18,6 +18,7 @@ new Vue({ cards: [], hits: [], refunds: [], + lnurlLink: location.hostname + '/boltcards/api/v1/scan/', cardDialog: { show: false, data: { @@ -43,10 +44,10 @@ new Vue({ field: 'counter' }, { - name: 'withdraw', + name: 'uid', align: 'left', - label: 'Withdraw ID', - field: 'withdraw' + label: 'Card ID', + field: 'uid' } ], pagination: { @@ -139,7 +140,7 @@ new Vue({ .request( 'GET', '/boltcards/api/v1/cards?all_wallets=true', - this.g.user.wallets[0].inkey + this.g.user.wallets[0].adminkey ) .then(function (response) { self.cards = response.data.map(function (obj) { diff --git a/lnbits/extensions/boltcards/tasks.py b/lnbits/extensions/boltcards/tasks.py new file mode 100644 index 00000000..30a290e9 --- /dev/null +++ b/lnbits/extensions/boltcards/tasks.py @@ -0,0 +1,34 @@ +import asyncio +import json + +import httpx + +from lnbits.core import db as core_db +from lnbits.core.models import Payment +from lnbits.tasks import register_invoice_listener + +from .crud import get_hit, create_refund + + +async def wait_for_paid_invoices(): + invoice_queue = asyncio.Queue() + register_invoice_listener(invoice_queue) + + while True: + payment = await invoice_queue.get() + await on_invoice_paid(payment) + + +async def on_invoice_paid(payment: Payment) -> None: + if payment.extra.get("tag")[0:6] != "Refund": + # not an lnurlp invoice + return + + if payment.extra.get("wh_status"): + # this webhook has already been sent + return + hit = await get_hit(payment.extra.get("tag")[7:len(payment.extra.get("tag"))]) + if hit: + refund = await create_refund(hit_id=hit.id, refund_amount=payment.extra.get("amount")) + await mark_webhook_sent(payment, 1) + diff --git a/lnbits/extensions/boltcards/templates/boltcards/index.html b/lnbits/extensions/boltcards/templates/boltcards/index.html index 1c58f8e5..0ba64f0e 100644 --- a/lnbits/extensions/boltcards/templates/boltcards/index.html +++ b/lnbits/extensions/boltcards/templates/boltcards/index.html @@ -34,6 +34,7 @@