feat: update to lnbits 1.0.0 (#41)

---------

Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
This commit is contained in:
dni ⚡ 2024-10-23 15:45:23 +02:00 committed by GitHub
commit 7f8b269ecd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1513 additions and 1457 deletions

View file

@ -2,7 +2,7 @@
"name": "Bolt Cards", "name": "Bolt Cards",
"short_description": "Self custody Bolt Cards with one time LNURLw", "short_description": "Self custody Bolt Cards with one time LNURLw",
"tile": "/boltcards/static/image/boltcard.png", "tile": "/boltcards/static/image/boltcard.png",
"min_lnbits_version": "0.12.5", "min_lnbits_version": "1.0.0",
"contributors": [ "contributors": [
{ {
"name": "dni", "name": "dni",

230
crud.py
View file

@ -1,6 +1,6 @@
import secrets import secrets
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import Optional
from lnbits.db import Database from lnbits.db import Database
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
@ -31,175 +31,157 @@ async def create_card(data: CreateCardData, wallet_id: str) -> Card:
k2, k2,
otp otp
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (
:id, :uid, :external_id, :wallet, :card_name, :counter,
:tx_limit, :daily_limit, :enable, :k0, :k1, :k2, :otp
)
""", """,
( {
card_id, "id": card_id,
data.uid.upper(), "uid": data.uid.upper(),
extenal_id, "external_id": extenal_id,
wallet_id, "wallet": wallet_id,
data.card_name, "card_name": data.card_name,
data.counter, "counter": data.counter,
data.tx_limit, "tx_limit": data.tx_limit,
data.daily_limit, "daily_limit": data.daily_limit,
True, "enable": True,
data.k0, "k0": data.k0,
data.k1, "k1": data.k1,
data.k2, "k2": data.k2,
secrets.token_hex(16), "otp": secrets.token_hex(16),
), },
) )
card = await get_card(card_id) card = await get_card(card_id)
assert card, "Newly created card couldn't be retrieved" assert card, "Newly created card couldn't be retrieved"
return card return card
async def update_card(card_id: str, **kwargs) -> Optional[Card]: async def update_card(card_id: str, data: CreateCardData) -> Card:
if "is_unique" in kwargs: card = Card(
kwargs["is_unique"] = int(kwargs["is_unique"]) id=card_id,
if "uid" in kwargs: **data.dict(),
kwargs["uid"] = kwargs["uid"].upper()
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
f"UPDATE boltcards.cards SET {q} WHERE id = ?",
(*kwargs.values(), card_id),
) )
row = await db.fetchone("SELECT * FROM boltcards.cards WHERE id = ?", (card_id,)) await db.update("boltcards.cards", card)
return Card(**row) if row else None return card
async def get_cards(wallet_ids: List[str]) -> List[Card]: async def get_cards(wallet_ids: list[str]) -> list[Card]:
if len(wallet_ids) == 0: if len(wallet_ids) == 0:
return [] return []
q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids])
q = ",".join(["?"] * len(wallet_ids)) return await db.fetchall(
rows = await db.fetchall( f"SELECT * FROM boltcards.cards WHERE wallet IN ({q})",
f"SELECT * FROM boltcards.cards WHERE wallet IN ({q})", (*wallet_ids,) model=Card,
) )
return [Card(**row) for row in rows]
async def get_card(card_id: str) -> Optional[Card]: async def get_card(card_id: str) -> Optional[Card]:
row = await db.fetchone("SELECT * FROM boltcards.cards WHERE id = ?", (card_id,)) return await db.fetchone(
if not row: "SELECT * FROM boltcards.cards WHERE id = :id",
return None {"id": card_id},
Card,
card = dict(**row) )
return Card.parse_obj(card)
async def get_card_by_uid(card_uid: str) -> Optional[Card]: async def get_card_by_uid(card_uid: str) -> Optional[Card]:
row = await db.fetchone( return await db.fetchone(
"SELECT * FROM boltcards.cards WHERE uid = ?", (card_uid.upper(),) "SELECT * FROM boltcards.cards WHERE uid = :uid",
{"uid": card_uid.upper()},
Card,
) )
if not row:
return None
card = dict(**row)
return Card.parse_obj(card)
async def get_card_by_external_id(external_id: str) -> Optional[Card]: async def get_card_by_external_id(external_id: str) -> Optional[Card]:
row = await db.fetchone( return await db.fetchone(
"SELECT * FROM boltcards.cards WHERE external_id = ?", (external_id.lower(),) "SELECT * FROM boltcards.cards WHERE external_id = :ext_id",
{"ext_id": external_id.lower()},
Card,
) )
if not row:
return None
card = dict(**row)
return Card.parse_obj(card)
async def get_card_by_otp(otp: str) -> Optional[Card]: async def get_card_by_otp(otp: str) -> Optional[Card]:
row = await db.fetchone("SELECT * FROM boltcards.cards WHERE otp = ?", (otp,)) return await db.fetchone(
if not row: "SELECT * FROM boltcards.cards WHERE otp = :otp",
return None {"otp": otp},
Card,
card = dict(**row) )
return Card.parse_obj(card)
async def delete_card(card_id: str) -> None: async def delete_card(card_id: str) -> None:
# Delete cards # Delete cards
await db.execute("DELETE FROM boltcards.cards WHERE id = ?", (card_id,)) await db.execute("DELETE FROM boltcards.cards WHERE id = :id", {"id": card_id})
# Delete hits # Delete hits
hits = await get_hits([card_id]) hits = await get_hits([card_id])
for hit in hits: for hit in hits:
await db.execute("DELETE FROM boltcards.hits WHERE id = ?", (hit.id,)) await db.execute("DELETE FROM boltcards.hits WHERE id = :id", {"id": hit.id})
# Delete refunds # Delete refunds
refunds = await get_refunds([hit.id]) refunds = await get_refunds([hit.id])
for refund in refunds: for refund in refunds:
await db.execute( await db.execute(
"DELETE FROM boltcards.refunds WHERE id = ?", (refund.hit_id,) "DELETE FROM boltcards.refunds WHERE id = :id", {"id": refund.id}
) )
async def update_card_counter(counter: int, card_id: str): async def update_card_counter(counter: int, card_id: str):
await db.execute( await db.execute(
"UPDATE boltcards.cards SET counter = ? WHERE id = ?", "UPDATE boltcards.cards SET counter = :counter WHERE id = :id",
(counter, card_id), {"counter": counter, "id": card_id},
) )
async def enable_disable_card(enable: bool, card_id: str) -> Optional[Card]: async def enable_disable_card(enable: bool, card_id: str) -> Optional[Card]:
await db.execute( await db.execute(
"UPDATE boltcards.cards SET enable = ? WHERE id = ?", "UPDATE boltcards.cards SET enable = :enable WHERE id = :id",
(enable, card_id), {"enable": enable, "id": card_id},
) )
return await get_card(card_id) return await get_card(card_id)
async def update_card_otp(otp: str, card_id: str): async def update_card_otp(otp: str, card_id: str):
await db.execute( await db.execute(
"UPDATE boltcards.cards SET otp = ? WHERE id = ?", "UPDATE boltcards.cards SET otp = :otp WHERE id = :id",
(otp, card_id), {"otp": otp, "id": card_id},
) )
async def get_hit(hit_id: str) -> Optional[Hit]: async def get_hit(hit_id: str) -> Optional[Hit]:
row = await db.fetchone("SELECT * FROM boltcards.hits WHERE id = ?", (hit_id,)) return await db.fetchone(
if not row: "SELECT * FROM boltcards.hits WHERE id = :id",
return None {"id": hit_id},
Hit,
hit = dict(**row) )
return Hit.parse_obj(hit)
async def get_hits(cards_ids: List[str]) -> List[Hit]: async def get_hits(cards_ids: list[str]) -> list[Hit]:
if len(cards_ids) == 0: if len(cards_ids) == 0:
return [] return []
q = ",".join(["?"] * len(cards_ids)) q = ",".join([f"'{card_id}'" for card_id in cards_ids])
rows = await db.fetchall( return await db.fetchall(
f"SELECT * FROM boltcards.hits WHERE card_id IN ({q})", (*cards_ids,) f"SELECT * FROM boltcards.hits WHERE card_id IN ({q})",
model=Hit,
) )
return [Hit(**row) for row in rows]
async def get_hits_today(card_id: str) -> list[Hit]:
async def get_hits_today(card_id: str) -> List[Hit]:
rows = await db.fetchall( rows = await db.fetchall(
"SELECT * FROM boltcards.hits WHERE card_id = ?", "SELECT * FROM boltcards.hits WHERE card_id = :id",
(card_id,), {"id": card_id},
Hit,
) )
updatedrow = [] updatedrow = []
for row in rows: for hit in rows:
if datetime.now().date() == datetime.fromtimestamp(row.time).date(): if datetime.now().date() == hit.time.date():
updatedrow.append(row) updatedrow.append(hit)
return [Hit(**row) for row in updatedrow] return updatedrow
async def spend_hit(card_id: str, amount: int): async def spend_hit(card_id: str, amount: int):
await db.execute( await db.execute(
"UPDATE boltcards.hits SET spent = ?, amount = ? WHERE id = ?", "UPDATE boltcards.hits SET spent = :spent, amount = :amount WHERE id = :id",
(True, amount, card_id), {"spent": True, "amount": amount, "id": card_id},
) )
return await get_hit(card_id) return await get_hit(card_id)
@ -218,18 +200,18 @@ async def create_hit(card_id, ip, useragent, old_ctr, new_ctr) -> Hit:
new_ctr, new_ctr,
amount amount
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (:id, :card_id, :ip, :spent, :useragent, :old_ctr, :new_ctr, :amount)
""", """,
( {
hit_id, "id": hit_id,
card_id, "card_id": card_id,
ip, "ip": ip,
False, "spent": False,
useragent, "useragent": useragent,
old_ctr, "old_ctr": old_ctr,
new_ctr, "new_ctr": new_ctr,
0, "amount": 0,
), },
) )
hit = await get_hit(hit_id) hit = await get_hit(hit_id)
assert hit, "Newly recorded hit couldn't be retrieved" assert hit, "Newly recorded hit couldn't be retrieved"
@ -245,13 +227,13 @@ async def create_refund(hit_id, refund_amount) -> Refund:
hit_id, hit_id,
refund_amount refund_amount
) )
VALUES (?, ?, ?) VALUES (:id, :hit_id, :refund_amount)
""", """,
( {
refund_id, "id": refund_id,
hit_id, "hit_id": hit_id,
refund_amount, "refund_amount": refund_amount,
), },
) )
refund = await get_refund(refund_id) refund = await get_refund(refund_id)
assert refund, "Newly recorded hit couldn't be retrieved" assert refund, "Newly recorded hit couldn't be retrieved"
@ -259,22 +241,18 @@ async def create_refund(hit_id, refund_amount) -> Refund:
async def get_refund(refund_id: str) -> Optional[Refund]: async def get_refund(refund_id: str) -> Optional[Refund]:
row = await db.fetchone( return await db.fetchone(
"SELECT * FROM boltcards.refunds WHERE id = ?", (refund_id,) "SELECT * FROM boltcards.refunds WHERE id = :id",
{"id": refund_id},
Refund,
) )
if not row:
return None
refund = dict(**row)
return Refund.parse_obj(refund)
async def get_refunds(hits_ids: List[str]) -> List[Refund]: async def get_refunds(hits_ids: list[str]) -> list[Refund]:
if len(hits_ids) == 0: if len(hits_ids) == 0:
return [] return []
q = ",".join([f"'{hit_id}'" for hit_id in hits_ids])
q = ",".join(["?"] * len(hits_ids)) return await db.fetchall(
rows = await db.fetchall( f"SELECT * FROM boltcards.refunds WHERE hit_id IN ({q})",
f"SELECT * FROM boltcards.refunds WHERE hit_id IN ({q})", (*hits_ids,) model=Refund,
) )
return [Refund(**row) for row in rows]

View file

@ -1,5 +1,5 @@
import json import json
from sqlite3 import Row from datetime import datetime
from fastapi import Query, Request from fastapi import Query, Request
from lnurl import Lnurl from lnurl import Lnurl
@ -17,8 +17,10 @@ class Card(BaseModel):
uid: str uid: str
external_id: str external_id: str
counter: int counter: int
tx_limit: int # TODO: database column is TEXT should be INT
daily_limit: int tx_limit: str
# TODO: database column is TEXT should be INT
daily_limit: str
enable: bool enable: bool
k0: str k0: str
k1: str k1: str
@ -27,11 +29,7 @@ class Card(BaseModel):
prev_k1: str prev_k1: str
prev_k2: str prev_k2: str
otp: str otp: str
time: int time: datetime
@classmethod
def from_row(cls, row: Row) -> "Card":
return cls(**dict(row))
def lnurl(self, req: Request) -> Lnurl: def lnurl(self, req: Request) -> Lnurl:
url = str( url = str(
@ -67,19 +65,11 @@ class Hit(BaseModel):
old_ctr: int old_ctr: int
new_ctr: int new_ctr: int
amount: int amount: int
time: int time: datetime
@classmethod
def from_row(cls, row: Row) -> "Hit":
return cls(**dict(row))
class Refund(BaseModel): class Refund(BaseModel):
id: str id: str
hit_id: str hit_id: str
refund_amount: int refund_amount: int
time: int time: datetime
@classmethod
def from_row(cls, row: Row) -> "Refund":
return cls(**dict(row))

2236
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,7 @@ authors = ["Alan Bits <alan@lnbits.com>"]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.10 | ^3.9" python = "^3.10 | ^3.9"
lnbits = "*" lnbits = {version = "*", allow-prereleases = true}
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
black = "^24.3.0" black = "^24.3.0"
@ -14,7 +14,7 @@ pytest-asyncio = "^0.21.0"
pytest = "^7.3.2" pytest = "^7.3.2"
mypy = "^1.5.1" mypy = "^1.5.1"
pre-commit = "^3.2.2" pre-commit = "^3.2.2"
ruff = "^0.3.2" ruff = "^0.6.3"
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.0.0"]

View file

@ -1,21 +1,15 @@
Vue.component(VueQrcode.name, VueQrcode)
const mapCards = obj => { const mapCards = obj => {
obj.date = Quasar.utils.date.formatDate( obj.date = Quasar.date.formatDate(new Date(obj.time), 'YYYY-MM-DD HH:mm')
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
return obj return obj
} }
new Vue({ window.app = Vue.createApp({
el: '#vue', el: '#vue',
mixins: [windowMixin], mixins: [windowMixin],
data: function () { data() {
return { return {
toggleAdvanced: false, toggleAdvanced: false,
nfcTagReading: false, disableNfcButton: true,
lnurlLink: `${window.location.host}/boltcards/api/v1/scan/`, lnurlLink: `${window.location.host}/boltcards/api/v1/scan/`,
cards: [], cards: [],
hits: [], hits: [],
@ -155,107 +149,77 @@ new Vue({
} }
}, },
methods: { methods: {
readNfcTag: function () { readNfcTag() {
try { const ndef = new NDEFReader()
const self = this const readerAbortController = new AbortController()
readerAbortController.signal.onabort = event => {
if (typeof NDEFReader == 'undefined') { console.log('All NFC Read operations have been aborted.')
throw {
toString: function () {
return 'NFC not supported on this device or browser.'
}
}
}
const ndef = new NDEFReader()
const readerAbortController = new AbortController()
readerAbortController.signal.onabort = event => {
console.log('All NFC Read operations have been aborted.')
}
this.nfcTagReading = true
this.$q.notify({
message: 'Tap your NFC tag to copy its UID here.'
})
return ndef.scan({signal: readerAbortController.signal}).then(() => {
ndef.onreadingerror = () => {
self.nfcTagReading = false
this.$q.notify({
type: 'negative',
message: 'There was an error reading this NFC tag.'
})
readerAbortController.abort()
}
ndef.onreading = ({message, serialNumber}) => {
//Decode NDEF data from tag
var self = this
self.cardDialog.data.uid = serialNumber
.toUpperCase()
.replaceAll(':', '')
this.$q.notify({
type: 'positive',
message: 'NFC tag read successfully.'
})
}
})
} catch (error) {
this.nfcTagReading = false
this.$q.notify({
type: 'negative',
message: error
? error.toString()
: 'An unexpected error has occurred.'
})
} }
},
getCards: function () {
var self = this
Quasar.Notify.create({
message: 'Tap your NFC tag to copy its UID here.'
})
return ndef.scan({signal: readerAbortController.signal}).then(() => {
ndef.onreadingerror = () => {
this.disableNfcButton = false
Quasar.Notify.create({
type: 'negative',
message: 'There was an error reading this NFC tag.'
})
readerAbortController.abort()
}
ndef.onreading = ({message, serialNumber}) => {
const uid = serialNumber.toUpperCase().replaceAll(':', '')
//Decode NDEF data from tag
this.cardDialog.data.uid = uid
Quasar.Notify.create({
type: 'positive',
message: 'NFC tag read successfully.'
})
}
})
},
getCards() {
LNbits.api LNbits.api
.request( .request(
'GET', 'GET',
'/boltcards/api/v1/cards?all_wallets=true', '/boltcards/api/v1/cards?all_wallets=true',
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
) )
.then(function (response) { .then(response => {
self.cards = response.data.map(function (obj) { this.cards = response.data.map(function (obj) {
return mapCards(obj) return mapCards(obj)
}) })
}) })
.then(function () { .then(() => {
self.getHits() this.getHits()
}) })
}, },
getHits: function () { getHits() {
var self = this
LNbits.api LNbits.api
.request( .request(
'GET', 'GET',
'/boltcards/api/v1/hits?all_wallets=true', '/boltcards/api/v1/hits?all_wallets=true',
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
) )
.then(function (response) { .then(response => {
self.hits = response.data.map(function (obj) { this.hits = response.data.map(obj => {
obj.card_name = self.cards.find(d => d.id == obj.card_id).card_name obj.card_name = this.cards.find(d => d.id == obj.card_id).card_name
return mapCards(obj) return mapCards(obj)
}) })
}) })
}, },
getRefunds: function () { getRefunds() {
var self = this
LNbits.api LNbits.api
.request( .request(
'GET', 'GET',
'/boltcards/api/v1/refunds?all_wallets=true', '/boltcards/api/v1/refunds?all_wallets=true',
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
) )
.then(function (response) { .then(response => {
self.refunds = response.data.map(function (obj) { this.refunds = response.data.map(obj => {
return mapCards(obj) return mapCards(obj)
}) })
}) })
@ -287,12 +251,11 @@ new Vue({
this.qrCodeDialog.wipe = wipe this.qrCodeDialog.wipe = wipe
this.qrCodeDialog.show = true this.qrCodeDialog.show = true
}, },
addCardOpen: function () { addCardOpen() {
this.cardDialog.show = true this.cardDialog.show = true
this.generateKeys() this.generateKeys()
}, },
generateKeys: function () { generateKeys() {
var self = this
const genRandomHexBytes = size => const genRandomHexBytes = size =>
crypto crypto
.getRandomValues(new Uint8Array(size)) .getRandomValues(new Uint8Array(size))
@ -302,22 +265,22 @@ new Vue({
typeof this.cardDialog.data.card_name === 'string' && typeof this.cardDialog.data.card_name === 'string' &&
this.cardDialog.data.card_name.search('debug') > -1 this.cardDialog.data.card_name.search('debug') > -1
self.cardDialog.data.k0 = debugcard this.cardDialog.data.k0 = debugcard
? '11111111111111111111111111111111' ? '11111111111111111111111111111111'
: genRandomHexBytes(16) : genRandomHexBytes(16)
self.cardDialog.data.k1 = debugcard this.cardDialog.data.k1 = debugcard
? '22222222222222222222222222222222' ? '22222222222222222222222222222222'
: genRandomHexBytes(16) : genRandomHexBytes(16)
self.cardDialog.data.k2 = debugcard this.cardDialog.data.k2 = debugcard
? '33333333333333333333333333333333' ? '33333333333333333333333333333333'
: genRandomHexBytes(16) : genRandomHexBytes(16)
}, },
closeFormDialog: function () { closeFormDialog() {
this.cardDialog.data = {} this.cardDialog.data = {}
}, },
sendFormData: function () { sendFormData() {
let wallet = _.findWhere(this.g.user.wallets, { let wallet = _.findWhere(this.g.user.wallets, {
id: this.cardDialog.data.wallet id: this.cardDialog.data.wallet
}) })
@ -328,21 +291,17 @@ new Vue({
this.createCard(wallet, data) this.createCard(wallet, data)
} }
}, },
createCard: function (wallet, data) { createCard(wallet, data) {
var self = this
LNbits.api LNbits.api
.request('POST', '/boltcards/api/v1/cards', wallet.adminkey, data) .request('POST', '/boltcards/api/v1/cards', wallet.adminkey, data)
.then(function (response) { .then(response => {
self.cards.push(mapCards(response.data)) this.cards.push(mapCards(response.data))
self.cardDialog.show = false this.cardDialog.show = false
self.cardDialog.data = {} this.cardDialog.data = {}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
}) })
.catch(LNbits.utils.notifyApiError)
}, },
updateCardDialog: function (formId) { updateCardDialog(formId) {
var card = _.findWhere(this.cards, {id: formId}) var card = _.findWhere(this.cards, {id: formId})
this.cardDialog.data = _.clone(card) this.cardDialog.data = _.clone(card)
@ -352,9 +311,7 @@ new Vue({
this.cardDialog.show = true this.cardDialog.show = true
}, },
updateCard: function (wallet, data) { updateCard(wallet, data) {
var self = this
if ( if (
this.cardDialog.temp.k0 != data.k0 || this.cardDialog.temp.k0 != data.k0 ||
this.cardDialog.temp.k1 != data.k1 || this.cardDialog.temp.k1 != data.k1 ||
@ -372,21 +329,20 @@ new Vue({
wallet.adminkey, wallet.adminkey,
data data
) )
.then(function (response) { .then(response => {
self.cards = _.reject(self.cards, function (obj) { this.cards = _.reject(this.cards, function (obj) {
return obj.id == data.id return obj.id == data.id
}) })
self.cards.push(mapCards(response.data)) this.cards.push(mapCards(response.data))
self.cardDialog.show = false this.cardDialog.show = false
self.cardDialog.data = {} this.cardDialog.data = {}
}) })
.catch(function (error) { .catch(function (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
}) })
}, },
enableCard: function (wallet, card_id, enable) { enableCard(wallet, card_id, enable) {
var self = this let fullWallet = _.findWhere(this.g.user.wallets, {
let fullWallet = _.findWhere(self.g.user.wallets, {
id: wallet id: wallet
}) })
LNbits.api LNbits.api
@ -395,19 +351,18 @@ new Vue({
'/boltcards/api/v1/cards/enable/' + card_id + '/' + enable, '/boltcards/api/v1/cards/enable/' + card_id + '/' + enable,
fullWallet.adminkey fullWallet.adminkey
) )
.then(function (response) { .then(response => {
console.log(response.data) console.log(response.data)
self.cards = _.reject(self.cards, function (obj) { this.cards = _.reject(this.cards, function (obj) {
return obj.id == response.data.id return obj.id == response.data.id
}) })
self.cards.push(mapCards(response.data)) this.cards.push(mapCards(response.data))
}) })
.catch(function (error) { .catch(function (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
}) })
}, },
deleteCard: function (cardId) { deleteCard(cardId) {
let self = this
let cards = _.findWhere(this.cards, {id: cardId}) let cards = _.findWhere(this.cards, {id: cardId})
Quasar.utils.exportFile( Quasar.utils.exportFile(
@ -420,15 +375,15 @@ new Vue({
.confirmDialog( .confirmDialog(
"Are you sure you want to delete this card? Without access to the card keys you won't be able to reset them in the future!" "Are you sure you want to delete this card? Without access to the card keys you won't be able to reset them in the future!"
) )
.onOk(function () { .onOk(() => {
LNbits.api LNbits.api
.request( .request(
'DELETE', 'DELETE',
'/boltcards/api/v1/cards/' + cardId, '/boltcards/api/v1/cards/' + cardId,
_.findWhere(self.g.user.wallets, {id: cards.wallet}).adminkey _.findWhere(this.g.user.wallets, {id: cards.wallet}).adminkey
) )
.then(function (response) { .then(response => {
self.cards = _.reject(self.cards, function (obj) { this.cards = _.reject(this.cards, function (obj) {
return obj.id == cardId return obj.id == cardId
}) })
}) })
@ -437,20 +392,39 @@ new Vue({
}) })
}) })
}, },
exportCardsCSV: function () { exportCardsCSV() {
LNbits.utils.exportCSV(this.cardsTable.columns, this.cards) LNbits.utils.exportCSV(this.cardsTable.columns, this.cards)
}, },
exportHitsCSV: function () { exportHitsCSV() {
LNbits.utils.exportCSV(this.hitsTable.columns, this.hits) LNbits.utils.exportCSV(this.hitsTable.columns, this.hits)
}, },
exportRefundsCSV: function () { exportRefundsCSV() {
LNbits.utils.exportCSV(this.refundsTable.columns, this.refunds) LNbits.utils.exportCSV(this.refundsTable.columns, this.refunds)
} }
}, },
created: function () { created() {
if (this.g.user.wallets.length) { if (this.g.user.wallets.length) {
this.getCards() this.getCards()
this.getRefunds() this.getRefunds()
} }
try {
if (typeof NDEFReader == 'undefined') {
throw {
toString() {
return 'NFC not supported on this device or browser.'
}
}
}
this.disableNfcButton = false
Quasar.Notify.create({
type: 'positive',
message: 'NFC is supported on this device. You can now read NFC tags.'
})
} catch (error) {
Quasar.Notify.create({
type: 'negative',
message: error ? error.toString() : 'An unexpected error has occurred.'
})
}
} }
}) })

View file

@ -1,8 +1,7 @@
import asyncio import asyncio
from lnbits.core.crud import update_payment_extra from lnbits.core.crud import update_payment
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
from .crud import create_refund, get_hit from .crud import create_refund, get_hit
@ -10,7 +9,7 @@ from .crud import create_refund, get_hit
async def wait_for_paid_invoices(): async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue() invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue, get_current_extension_name()) register_invoice_listener(invoice_queue, "ext_boltcards")
while True: while True:
payment = await invoice_queue.get() payment = await invoice_queue.get()
@ -19,7 +18,7 @@ async def wait_for_paid_invoices():
async def on_invoice_paid(payment: Payment) -> None: async def on_invoice_paid(payment: Payment) -> None:
if not payment.extra.get("refund"): if not payment.extra or not payment.extra.get("refund"):
return return
if payment.extra.get("wh_status"): if payment.extra.get("wh_status"):
@ -31,4 +30,4 @@ async def on_invoice_paid(payment: Payment) -> None:
if hit: if hit:
await create_refund(hit_id=hit.id, refund_amount=(payment.amount / 1000)) await create_refund(hit_id=hit.id, refund_amount=(payment.amount / 1000))
payment.extra["wh_status"] = 1 payment.extra["wh_status"] = 1
await update_payment_extra(payment.payment_hash, payment.extra) await update_payment(payment)

View file

@ -74,20 +74,19 @@ card.card_name }{% endblock %} {% block page %}
{% endblock %}{% block scripts %} {% endblock %}{% block scripts %}
<script> <script>
const mapHits = obj => { const mapHits = obj => {
obj.date = Quasar.utils.date.formatDate( obj.date = Quasar.date.formatDate(
new Date(obj.time * 1000), new Date(obj.time),
'YYYY-MM-DD HH:mm' 'YYYY-MM-DD HH:mm'
) )
return obj return obj
} }
Vue.component(VueQrcode.name, VueQrcode)
new Vue({ window.app = Vue.createApp({
el: '#vue', el: '#vue',
delimiters: ['${', '}'], delimiters: ['${', '}'],
mixins: [windowMixin], mixins: [windowMixin],
data: function () { data() {
return { return {
card: null, card: null,
hits: null, hits: null,
@ -120,11 +119,11 @@ card.card_name }{% endblock %} {% block page %}
} }
}, },
created() { created() {
this.card = JSON.parse('{{ card | tojson}}') this.card = JSON.parse({{ card | tojson }})
let hits = JSON.parse('{{ hits | tojson}}') const hits = {{ hits | tojson | safe }}
let refunds = JSON.parse('{{ refunds | tojson}}') this.hits = hits.map(JSON.parse).map(mapHits)
const refunds = {{ refunds | tojson | safe }}
this.refunds = refunds || [] this.refunds = refunds || []
this.hits = hits.map(mapHits)
}, },
computed: { computed: {
enabled() { enabled() {
@ -133,8 +132,8 @@ card.card_name }{% endblock %} {% block page %}
}, },
methods: { methods: {
copyText(text, message, position) { copyText(text, message, position) {
Quasar.utils.copyToClipboard(text).then(() => { Quasar.copyToClipboard(text).then(() => {
this.$q.notify({ Quasar.Notify.create({
message: message || 'Copied to clipboard!', message: message || 'Copied to clipboard!',
position: position || 'bottom' position: position || 'bottom'
}) })

View file

@ -34,19 +34,21 @@
<q-table <q-table
dense dense
flat flat
:data="cards" :rows="cards"
row-key="id" row-key="id"
:columns="cardsTable.columns" :columns="cardsTable.columns"
:pagination.sync="cardsTable.pagination" v-model:pagination="cardsTable.pagination"
> >
{% raw %}
<template v-slot:header="props"> <template v-slot:header="props">
<q-tr :props="props"> <q-tr :props="props">
<q-th auto-width></q-th> <q-th auto-width></q-th>
<q-th auto-width></q-th> <q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props"> <q-th
{{ col.label }} v-for="col in props.cols"
</q-th> :key="col.name"
:props="props"
v-text="col.label"
></q-th>
<q-th auto-width></q-th> <q-th auto-width></q-th>
<q-th auto-width></q-th> <q-th auto-width></q-th>
<q-th auto-width></q-th> <q-th auto-width></q-th>
@ -78,9 +80,12 @@
<q-tooltip>Card Stats</q-tooltip> <q-tooltip>Card Stats</q-tooltip>
</q-btn> </q-btn>
</q-td> </q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props"> <q-td
{{ col.value }} v-for="col in props.cols"
</q-td> :key="col.name"
:props="props"
v-text="col.value"
></q-td>
<q-td auto-width> <q-td auto-width>
<q-btn <q-btn
v-if="props.row.enable" v-if="props.row.enable"
@ -125,7 +130,6 @@
</q-td> </q-td>
</q-tr> </q-tr>
</template> </template>
{% endraw %}
</q-table> </q-table>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -144,27 +148,31 @@
<q-table <q-table
dense dense
flat flat
:data="hits" :rows="hits"
row-key="id" row-key="id"
:columns="hitsTable.columns" :columns="hitsTable.columns"
:pagination.sync="hitsTable.pagination" v-model:pagination="hitsTable.pagination"
> >
{% raw %}
<template v-slot:header="props"> <template v-slot:header="props">
<q-tr :props="props"> <q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props"> <q-th
{{ col.label }} v-for="col in props.cols"
</q-th> :key="col.name"
:props="props"
v-text="col.label"
></q-th>
</q-tr> </q-tr>
</template> </template>
<template v-slot:body="props"> <template v-slot:body="props">
<q-tr :props="props"> <q-tr :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props"> <q-td
{{ col.value }} v-for="col in props.cols"
</q-td> :key="col.name"
:props="props"
v-text="col.value"
></q-td>
</q-tr> </q-tr>
</template> </template>
{% endraw %}
</q-table> </q-table>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -183,27 +191,31 @@
<q-table <q-table
dense dense
flat flat
:data="refunds" :rows="refunds"
row-key="id" row-key="id"
:columns="refundsTable.columns" :columns="refundsTable.columns"
:pagination.sync="refundsTable.pagination" v-model:pagination="refundsTable.pagination"
> >
{% raw %}
<template v-slot:header="props"> <template v-slot:header="props">
<q-tr :props="props"> <q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props"> <q-th
{{ col.label }} v-for="col in props.cols"
</q-th> :key="col.name"
:props="props"
v-text="col.label"
></q-th>
</q-tr> </q-tr>
</template> </template>
<template v-slot:body="props"> <template v-slot:body="props">
<q-tr :props="props"> <q-tr :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props"> <q-td
{{ col.value }} v-for="col in props.cols"
</q-td> :key="col.name"
:props="props"
v-text="col.value"
></q-td>
</q-tr> </q-tr>
</template> </template>
{% endraw %}
</q-table> </q-table>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -240,7 +252,7 @@
filled filled
dense dense
emit-value emit-value
v-model.trim="cardDialog.data.tx_limit" v-model="cardDialog.data.tx_limit"
type="number" type="number"
label="Max transaction (sats)" label="Max transaction (sats)"
class="q-pr-sm" class="q-pr-sm"
@ -251,7 +263,7 @@
filled filled
dense dense
emit-value emit-value
v-model.trim="cardDialog.data.daily_limit" v-model="cardDialog.data.daily_limit"
type="number" type="number"
label="Daily limit (sats)" label="Daily limit (sats)"
></q-input> ></q-input>
@ -261,7 +273,7 @@
filled filled
dense dense
emit-value emit-value
v-model.trim="cardDialog.data.card_name" v-model="cardDialog.data.card_name"
type="text" type="text"
label="Card name " label="Card name "
> >
@ -272,7 +284,7 @@
filled filled
dense dense
emit-value emit-value
v-model.trim="cardDialog.data.uid" v-model="cardDialog.data.uid"
type="text" type="text"
label="Card UID " label="Card UID "
> >
@ -281,36 +293,34 @@
<div class="col-2 q-pl-sm"> <div class="col-2 q-pl-sm">
<q-btn <q-btn
outline outline
disable
color="grey" color="grey"
icon="nfc" icon="nfc"
:disable="nfcTagReading" :disable="disableNfcButton"
@click="readNfcTag()" @click="readNfcTag()"
> >
<q-tooltip>Tap card to scan UID</q-tooltip> <q-tooltip>Tap card to scan UID</q-tooltip>
</q-btn> </q-btn>
</div> </div>
</div> </div>
<q-toggle <q-toggle
v-model="toggleAdvanced" v-model="toggleAdvanced"
label="Show advanced options" label="Show advanced options"
></q-toggle> ></q-toggle>
<div v-show="toggleAdvanced" class="q-gutter-y-md"> <div v-show="toggleAdvanced" class="q-gutter-y-md">
<q-input <q-input
filled filled
dense dense
v-model.trim="cardDialog.data.k0" v-model="cardDialog.data.k0"
type="text" type="text"
label="Card Auth key (K0)" label="Card Auth key (K0)"
hint="Used to authentificate with the card (16 bytes in HEX). " hint="Used to authentificate with the card (16 bytes in HEX). "
@randomkey @randomkey
> ></q-input>
</q-input>
<q-input <q-input
filled filled
dense dense
v-model.trim="cardDialog.data.k1" v-model="cardDialog.data.k1"
type="text" type="text"
label="Card Meta key (K1)" label="Card Meta key (K1)"
hint="Used for encypting of the message (16 bytes in HEX)." hint="Used for encypting of the message (16 bytes in HEX)."
@ -318,12 +328,11 @@
<q-input <q-input
filled filled
dense dense
v-model.trim="cardDialog.data.k2" v-model="cardDialog.data.k2"
type="text" type="text"
label="Card File key (K2)" label="Card File key (K2)"
hint="Used for CMAC of the message (16 bytes in HEX)." hint="Used for CMAC of the message (16 bytes in HEX)."
> ></q-input>
</q-input>
<q-input <q-input
filled filled
dense dense
@ -339,8 +348,7 @@
unelevated unelevated
color="primary" color="primary"
class="q-ml-auto" class="q-ml-auto"
v-on:click="generateKeys" @click="generateKeys"
v-on:click.right="debugKeys"
>Generate keys</q-btn >Generate keys</q-btn
> >
</div> </div>
@ -370,19 +378,12 @@
<q-dialog v-model="qrCodeDialog.show" position="top"> <q-dialog v-model="qrCodeDialog.show" position="top">
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card"> <q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
{% raw %}
<div class="col q-mt-lg text-center"> <div class="col q-mt-lg text-center">
<q-responsive <lnbits-qrcode
:ratio="1" :value="qrCodeDialog.data.link"
class="q-mx-xl q-mb-md" class="rounded-borders"
v-show="!qrCodeDialog.wipe" v-show="!qrCodeDialog.wipe"
> ></lnbits-qrcode>
<qrcode
:value="qrCodeDialog.data.link"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
<p class="text-center" v-show="!qrCodeDialog.wipe"> <p class="text-center" v-show="!qrCodeDialog.wipe">
(QR for <strong>create</strong> the card in (QR for <strong>create</strong> the card in
<a <a
@ -393,17 +394,11 @@
>Boltcard NFC Card Creator</a >Boltcard NFC Card Creator</a
>) >)
</p> </p>
<q-responsive <lnbits-qrcode
:ratio="1" :value="qrCodeDialog.data_wipe"
class="q-mx-xl q-mb-md" class="rounded-borders"
v-show="qrCodeDialog.wipe" v-show="qrCodeDialog.wipe"
> ></lnbits-qrcode>
<qrcode
:value="qrCodeDialog.data_wipe"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
<p class="text-center" v-show="qrCodeDialog.wipe"> <p class="text-center" v-show="qrCodeDialog.wipe">
(QR for <strong>wipe</strong> the card in (QR for <strong>wipe</strong> the card in
<a <a
@ -430,12 +425,18 @@
/> />
</div> </div>
<p style="word-break: break-all"> <p style="word-break: break-all">
<strong>Name:</strong> {{ qrCodeDialog.data.name }}<br /> <strong>Name: </strong><span v-text="qrCodeDialog.data.name"></span
<strong>UID:</strong> {{ qrCodeDialog.data.uid }}<br /> ><br />
<strong>External ID:</strong> {{ qrCodeDialog.data.external_id }}<br /> <strong>UID: </strong> <span v-text="qrCodeDialog.data.uid"></span
<strong>Lock key (K0):</strong> {{ qrCodeDialog.data.k0 }}<br /> ><br />
<strong>Meta key (K1 & K3):</strong> {{ qrCodeDialog.data.k1 }}<br /> <strong>External ID:</strong>
<strong>File key (K2 & K4):</strong> {{ qrCodeDialog.data.k2 }}<br /> <span v-text="qrCodeDialog.data.external_id"></span><br />
<strong>Lock key (K0):</strong>
<span v-text="qrCodeDialog.data.k0"></span><br />
<strong>Meta key (K1 & K3):</strong>
<span v-text="qrCodeDialog.data.k1"></span><br />
<strong>File key (K2 & K4):</strong>
<span v-text="qrCodeDialog.data.k2"></span><br />
</p> </p>
<p> <p>
Always backup all keys that you're trying to write on the card. Without Always backup all keys that you're trying to write on the card. Without
@ -472,7 +473,6 @@
> >
<q-tooltip>Backup the keys, or wipe the card first!</q-tooltip> <q-tooltip>Backup the keys, or wipe the card first!</q-tooltip>
</q-btn> </q-btn>
{% endraw %}
<div class="row q-mt-lg q-gutter-sm"> <div class="row q-mt-lg q-gutter-sm">
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn> <q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div> </div>
@ -481,5 +481,5 @@
</div> </div>
{% endblock %} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="/boltcards/static/js/index.js"></script> <script src="{{ static_url_for('boltcards/static', path='js/index.js') }}"></script>
{% endblock %} {% endblock %}

View file

@ -1,16 +1,13 @@
from http import HTTPStatus from http import HTTPStatus
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.templating import Jinja2Templates from fastapi.responses import HTMLResponse
from lnbits.core.models import User from lnbits.core.models import User
from lnbits.decorators import check_user_exists from lnbits.decorators import check_user_exists
from lnbits.helpers import template_renderer from lnbits.helpers import template_renderer
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse
from .crud import get_card_by_external_id, get_hits, get_refunds from .crud import get_card_by_external_id, get_hits, get_refunds
templates = Jinja2Templates(directory="templates")
boltcards_generic_router = APIRouter() boltcards_generic_router = APIRouter()
@ -21,7 +18,7 @@ def boltcards_renderer():
@boltcards_generic_router.get("/", response_class=HTMLResponse) @boltcards_generic_router.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)): async def index(request: Request, user: User = Depends(check_user_exists)):
return boltcards_renderer().TemplateResponse( return boltcards_renderer().TemplateResponse(
"boltcards/index.html", {"request": request, "user": user.dict()} "boltcards/index.html", {"request": request, "user": user.json()}
) )
@ -32,15 +29,11 @@ async def display(request: Request, card_id: str):
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Card does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Card does not exist."
) )
hits = [hit.dict() for hit in await get_hits([card.id])] hits = await get_hits([card.id])
refunds = [ hits_json = [hit.json() for hit in hits]
refund.hit_id for refund in await get_refunds([hit["id"] for hit in hits]) refunds = [refund.hit_id for refund in await get_refunds([hit.id for hit in hits])]
] card_json = card.json(exclude={"wallet"})
card_dict = card.dict()
# Remove wallet id from card dict
del card_dict["wallet"]
return boltcards_renderer().TemplateResponse( return boltcards_renderer().TemplateResponse(
"boltcards/display.html", "boltcards/display.html",
{"request": request, "card": card_dict, "hits": hits, "refunds": refunds}, {"request": request, "card": card_json, "hits": hits_json, "refunds": refunds},
) )

View file

@ -3,7 +3,7 @@ from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from lnbits.core.crud import get_user from lnbits.core.crud import get_user
from lnbits.core.models import WalletTypeInfo from lnbits.core.models import WalletTypeInfo
from lnbits.decorators import get_key_type, require_admin_key from lnbits.decorators import require_admin_key, require_invoice_key
from .crud import ( from .crud import (
create_card, create_card,
@ -16,22 +16,22 @@ from .crud import (
get_refunds, get_refunds,
update_card, update_card,
) )
from .models import Card, CreateCardData from .models import Card, CreateCardData, Hit, Refund
boltcards_api_router = APIRouter() boltcards_api_router = APIRouter()
@boltcards_api_router.get("/api/v1/cards") @boltcards_api_router.get("/api/v1/cards")
async def api_cards( async def api_cards(
g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = False key_info: WalletTypeInfo = Depends(require_invoice_key), all_wallets: bool = False
): ) -> list[Card]:
wallet_ids = [g.wallet.id] wallet_ids = [key_info.wallet.id]
if all_wallets: if all_wallets:
user = await get_user(g.wallet.user) user = await get_user(key_info.wallet.user)
wallet_ids = user.wallet_ids if user else [] wallet_ids = user.wallet_ids if user else []
return [card.dict() for card in await get_cards(wallet_ids)] return await get_cards(wallet_ids)
def validate_card(data: CreateCardData): def validate_card(data: CreateCardData):
@ -146,12 +146,13 @@ async def api_card_delete(card_id, wallet: WalletTypeInfo = Depends(require_admi
@boltcards_api_router.get("/api/v1/hits") @boltcards_api_router.get("/api/v1/hits")
async def api_hits( async def api_hits(
g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False) key_info: WalletTypeInfo = Depends(require_invoice_key),
): all_wallets: bool = Query(False),
wallet_ids = [g.wallet.id] ) -> list[Hit]:
wallet_ids = [key_info.wallet.id]
if all_wallets: if all_wallets:
user = await get_user(g.wallet.user) user = await get_user(key_info.wallet.user)
wallet_ids = user.wallet_ids if user else [] wallet_ids = user.wallet_ids if user else []
cards = await get_cards(wallet_ids) cards = await get_cards(wallet_ids)
@ -159,17 +160,18 @@ async def api_hits(
for card in cards: for card in cards:
cards_ids.append(card.id) cards_ids.append(card.id)
return [hit.dict() for hit in await get_hits(cards_ids)] return await get_hits(cards_ids)
@boltcards_api_router.get("/api/v1/refunds") @boltcards_api_router.get("/api/v1/refunds")
async def api_refunds( async def api_refunds(
g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False) key_info: WalletTypeInfo = Depends(require_invoice_key),
): all_wallets: bool = Query(False),
wallet_ids = [g.wallet.id] ) -> list[Refund]:
wallet_ids = [key_info.wallet.id]
if all_wallets: if all_wallets:
user = await get_user(g.wallet.user) user = await get_user(key_info.wallet.user)
wallet_ids = user.wallet_ids if user else [] wallet_ids = user.wallet_ids if user else []
cards = await get_cards(wallet_ids) cards = await get_cards(wallet_ids)
@ -181,4 +183,4 @@ async def api_refunds(
for hit in hits: for hit in hits:
hits_ids.append(hit.id) hits_ids.append(hit.id)
return [refund.dict() for refund in await get_refunds(hits_ids)] return await get_refunds(hits_ids)

View file

@ -69,8 +69,8 @@ async def api_scan(p, c, request: Request, external_id: str):
hits_amount = 0 hits_amount = 0
for hit in todays_hits: for hit in todays_hits:
hits_amount = hits_amount + hit.amount hits_amount += hit.amount
if hits_amount > card.daily_limit: if hits_amount > int(card.daily_limit):
return {"status": "ERROR", "reason": "Max daily limit spent."} return {"status": "ERROR", "reason": "Max daily limit spent."}
hit = await create_hit(card.id, ip, agent, card.counter, ctr_int) hit = await create_hit(card.id, ip, agent, card.counter, ctr_int)
@ -88,7 +88,7 @@ async def api_scan(p, c, request: Request, external_id: str):
"callback": str(request.url_for("boltcards.lnurl_callback", hit_id=hit.id)), "callback": str(request.url_for("boltcards.lnurl_callback", hit_id=hit.id)),
"k1": hit.id, "k1": hit.id,
"minWithdrawable": 1 * 1000, "minWithdrawable": 1 * 1000,
"maxWithdrawable": card.tx_limit * 1000, "maxWithdrawable": int(card.tx_limit) * 1000,
"defaultDescription": f"Boltcard (refund address lnurl://{lnurlpay_bech32})", "defaultDescription": f"Boltcard (refund address lnurl://{lnurlpay_bech32})",
"payLink": lnurlpay_nonbech32_lud17, # LUD-19 compatibility "payLink": lnurlpay_nonbech32_lud17, # LUD-19 compatibility
} }
@ -135,7 +135,7 @@ async def lnurl_callback(
await pay_invoice( await pay_invoice(
wallet_id=card.wallet, wallet_id=card.wallet,
payment_request=pr, payment_request=pr,
max_sat=card.tx_limit, max_sat=int(card.tx_limit),
extra={"tag": "boltcards", "hit": hit.id}, extra={"tag": "boltcards", "hit": hit.id},
) )
return {"status": "OK"} return {"status": "OK"}
@ -201,14 +201,13 @@ async def lnurlp_response(req: Request, hit_id: str):
"callback": str(req.url_for("boltcards.lnurlp_callback", hit_id=hit_id)), "callback": str(req.url_for("boltcards.lnurlp_callback", hit_id=hit_id)),
"metadata": LnurlPayMetadata(json.dumps([["text/plain", "Refund"]])), "metadata": LnurlPayMetadata(json.dumps([["text/plain", "Refund"]])),
"minSendable": 1 * 1000, "minSendable": 1 * 1000,
"maxSendable": card.tx_limit * 1000, "maxSendable": int(card.tx_limit) * 1000,
} }
return json.dumps(pay_response) return json.dumps(pay_response)
@boltcards_lnurl_router.get( @boltcards_lnurl_router.get(
"/api/v1/lnurlp/cb/{hit_id}", "/api/v1/lnurlp/cb/{hit_id}",
response_class=HTMLResponse,
name="boltcards.lnurlp_callback", name="boltcards.lnurlp_callback",
) )
async def lnurlp_callback(hit_id: str, amount: str = Query(None)): async def lnurlp_callback(hit_id: str, amount: str = Query(None)):
@ -219,7 +218,7 @@ async def lnurlp_callback(hit_id: str, amount: str = Query(None)):
if not hit: if not hit:
return {"status": "ERROR", "reason": "LNURL-pay record not found."} return {"status": "ERROR", "reason": "LNURL-pay record not found."}
_, payment_request = await create_invoice( payment = await create_invoice(
wallet_id=card.wallet, wallet_id=card.wallet,
amount=int(int(amount) / 1000), amount=int(int(amount) / 1000),
memo=f"Refund {hit_id}", memo=f"Refund {hit_id}",
@ -229,6 +228,4 @@ async def lnurlp_callback(hit_id: str, amount: str = Query(None)):
extra={"refund": hit_id}, extra={"refund": hit_id},
) )
pay_response = {"pr": payment_request, "routes": []} return {"pr": payment.bolt11, "routes": []}
return json.dumps(pay_response)