Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding 3 new features to extension #29

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
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
30 changes: 28 additions & 2 deletions crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
110 changes: 107 additions & 3 deletions lnurl.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,20 @@
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
from lnurl.types import LnurlPayMetadata
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 (
Expand All @@ -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,
)
Expand All @@ -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):
Expand All @@ -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():
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}"}
Expand Down
28 changes: 28 additions & 0 deletions migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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';
"""
)
9 changes: 8 additions & 1 deletion models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -19,6 +20,8 @@ class Card(BaseModel):
counter: int
tx_limit: int
daily_limit: int
monthly_limit: int
limit_type: str
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't the limit_type be some kind of an enum?

https://docs.pydantic.dev/1.10/usage/types/#enums-and-choices

enable: bool
k0: str
k1: str
Expand All @@ -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":
Expand All @@ -47,14 +51,16 @@ 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)
k2: str = Query(ZERO_KEY)
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
Expand All @@ -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":
Expand Down
18 changes: 18 additions & 0 deletions static/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading