feat: code quality (#39)

This commit is contained in:
dni ⚡ 2024-08-29 12:24:15 +02:00 committed by GitHub
commit 7213d5ca93
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 2902 additions and 159 deletions

10
.github/workflows/lint.yml vendored Normal file
View file

@ -0,0 +1,10 @@
name: lint
on:
push:
branches:
- main
pull_request:
jobs:
lint:
uses: lnbits/lnbits/.github/workflows/lint.yml@dev

View file

@ -1,10 +1,9 @@
on: on:
push: push:
tags: tags:
- "v[0-9]+.[0-9]+.[0-9]+" - 'v[0-9]+.[0-9]+.[0-9]+'
jobs: jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@ -34,12 +33,12 @@ jobs:
- name: Create pull request in extensions repo - name: Create pull request in extensions repo
env: env:
GH_TOKEN: ${{ secrets.EXT_GITHUB }} GH_TOKEN: ${{ secrets.EXT_GITHUB }}
repo_name: "${{ github.event.repository.name }}" repo_name: '${{ github.event.repository.name }}'
tag: "${{ github.ref_name }}" tag: '${{ github.ref_name }}'
branch: "update-${{ github.event.repository.name }}-${{ github.ref_name }}" branch: 'update-${{ github.event.repository.name }}-${{ github.ref_name }}'
title: "[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}" title: '[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}'
body: "https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}" body: 'https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}'
archive: "https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip" archive: 'https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip'
run: | run: |
cd lnbits-extensions cd lnbits-extensions
git checkout -b $branch git checkout -b $branch

3
.gitignore vendored
View file

@ -1 +1,4 @@
__pycache__ __pycache__
node_modules
.mypy_cache
.venv

12
.prettierrc Normal file
View file

@ -0,0 +1,12 @@
{
"semi": false,
"arrowParens": "avoid",
"insertPragma": false,
"printWidth": 80,
"proseWrap": "preserve",
"singleQuote": true,
"trailingComma": "none",
"useTabs": false,
"bracketSameLine": false,
"bracketSpacing": false
}

47
Makefile Normal file
View file

@ -0,0 +1,47 @@
all: format check
format: prettier black ruff
check: mypy pyright checkblack checkruff checkprettier
prettier:
poetry run ./node_modules/.bin/prettier --write .
pyright:
poetry run ./node_modules/.bin/pyright
mypy:
poetry run mypy .
black:
poetry run black .
ruff:
poetry run ruff check . --fix
checkruff:
poetry run ruff check .
checkprettier:
poetry run ./node_modules/.bin/prettier --check .
checkblack:
poetry run black --check .
checkeditorconfig:
editorconfig-checker
test:
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest
install-pre-commit-hook:
@echo "Installing pre-commit hook to git"
@echo "Uninstall the hook with poetry run pre-commit uninstall"
poetry run pre-commit install
pre-commit:
poetry run pre-commit run --all-files
checkbundle:
@echo "skipping checkbundle"

View file

@ -1,48 +1,46 @@
# Bolt Cards - <small>[LNbits](https://github.com/lnbits/lnbits) extension</small> # Bolt Cards - <small>[LNbits](https://github.com/lnbits/lnbits) extension</small>
<small>For more about LNBits extensions check [this tutorial](https://youtu.be/_sW7miqaXJc)</small> <small>For more about LNBits extensions check [this tutorial](https://youtu.be/_sW7miqaXJc)</small>
This extension allows you to link your [Bolt Card](https://github.com/boltcard) on a NXP NTAG424 DNA tag with a LNbits hub that generated new links on each tab which allows a better privacy and security than a static LNURLw that you can also write to a NFC tag (fromon NTAG 213) in the withdraw-extension e.g. for one-time usage as a gift-card.
This extension allows you to link your [Bolt Card](https://github.com/boltcard) on a NXP NTAG424 DNA tag with a LNbits hub that generated new links on each tab which allows a better privacy and security than a static LNURLw that you can also write to a NFC tag (fromon NTAG 213) in the withdraw-extension e.g. for one-time usage as a gift-card.
<a class="text-secondary" href="https://youtu.be/_sW7miqaXJc">Video Tutorial</a> <a class="text-secondary" href="https://youtu.be/_sW7miqaXJc">Video Tutorial</a>
**Disclaimer:** **_Use this only if you either know what you are doing or are a reckless lightning pioneer.
**Disclaimer:** ***Use this only if you either know what you are doing or are a reckless lightning pioneer. Only you are responsible for all your sats, cards and other devices. Always backup all your card keys!_**
Only you are responsible for all your sats, cards and other devices. Always backup all your card keys!***
For the easy way you need: For the easy way you need:
* an LNbits instance in clearnet - an LNbits instance in clearnet
* opened on Android in Chrome browser - opened on Android in Chrome browser
* Boltcard extension installed for your LNbits wallet - Boltcard extension installed for your LNbits wallet
* [Boltcard NFC Card Creator App](https://github.com/boltcard/bolt-nfc-android-app) from the [Apple-](https://apps.apple.com/us/app/boltcard-nfc-programmer/id6450968873) or [Play-Store](https://play.google.com/store/search?q=bolt+card+nfc+card+creator&c=apps) to write your keys to the tags once they were generated on LNbits - [Boltcard NFC Card Creator App](https://github.com/boltcard/bolt-nfc-android-app) from the [Apple-](https://apps.apple.com/us/app/boltcard-nfc-programmer/id6450968873) or [Play-Store](https://play.google.com/store/search?q=bolt+card+nfc+card+creator&c=apps) to write your keys to the tags once they were generated on LNbits
If you want to gift a Boltcard, make sure to [include the following data](https://www.figma.com/proto/OH6aGCxH45vNpKsZ2nD96S/Untitled?node-id=6%3A37&scaling=min-zoom&page-id=0%3A1) in your present, so that the user is able to make full use of it. If you want to gift a Boltcard, make sure to [include the following data](https://www.figma.com/proto/OH6aGCxH45vNpKsZ2nD96S/Untitled?node-id=6%3A37&scaling=min-zoom&page-id=0%3A1) in your present, so that the user is able to make full use of it.
***Always backup all keys that you're trying to write on the card. Without them you may not be able to change them in the future!*** **_Always backup all keys that you're trying to write on the card. Without them you may not be able to change them in the future!_**
## Setting the card - Boltcard NFC Card Creator (easy way) ## Setting the card - Boltcard NFC Card Creator (easy way)
- Add new card in the extension. - Add new card in the extension.
- Set a max sats per transaction. Any transaction greater than this amount will be rejected. This is usually set higher than the funds in the wallet are to prevent accidential withdraws. - Set a max sats per transaction. Any transaction greater than this amount will be rejected. This is usually set higher than the funds in the wallet are to prevent accidential withdraws.
- Set a max sats per day. After the card spends this amount of sats in a day, additional transactions will be rejected. - Set a max sats per day. After the card spends this amount of sats in a day, additional transactions will be rejected.
- Set a card name. This is just for your reference inside LNbits. - Set a card name. This is just for your reference inside LNbits.
- Set the card UID. This is the unique identifier of your NFC card and is 7 bytes. - Set the card UID. This is the unique identifier of your NFC card and is 7 bytes.
- If on an Android device with a newish version of Chrome, you can click the icon next to the input and tap your card to autofill this field. - If on an Android device with a newish version of Chrome, you can click the icon next to the input and tap your card to autofill this field.
- Otherwise read it with the Bolt-Card app (Read NFC) and paste it to the field. - Otherwise read it with the Bolt-Card app (Read NFC) and paste it to the field.
- Advanced Options - Advanced Options
- Card Keys (k0, k1, k2) will be automatically generated if not explicitly set. - Card Keys (k0, k1, k2) will be automatically generated if not explicitly set.
- Set to 16 bytes of 0s (00000000000000000000000000000000) to leave the keys in default (empty) state (this is unsecure). - Set to 16 bytes of 0s (00000000000000000000000000000000) to leave the keys in default (empty) state (this is unsecure).
- GENERATE KEY button fill the keys randomly. - GENERATE KEY button fill the keys randomly.
- Click CREATE CARD button - Click CREATE CARD button
- Click the QR code button next to a card to view its details. Backup the keys now! They'll be comfortable in your password manager. - Click the QR code button next to a card to view its details. Backup the keys now! They'll be comfortable in your password manager.
- Now you can scan the QR code with the Boltcard app (Create Bolt Card -> SCAN QR CODE). - Now you can scan the QR code with the Boltcard app (Create Bolt Card -> SCAN QR CODE).
- Or the "KEYS / AUTH LINK" button to copy the auth URL to the clipboard. Then paste it into the Android app (Create Bolt Card -> PASTE AUTH URL). - Or the "KEYS / AUTH LINK" button to copy the auth URL to the clipboard. Then paste it into the Android app (Create Bolt Card -> PASTE AUTH URL).
- Click WRITE CARD NOW and approach the NFC card to set it up. DO NOT REMOVE THE CARD PREMATURELY! - Click WRITE CARD NOW and approach the NFC card to set it up. DO NOT REMOVE THE CARD PREMATURELY!
## Erasing the card - Boltcard NFC Card Creator ## Erasing the card - Boltcard NFC Card Creator
Updated for v0.1.9 Updated for v0.1.9
Since v0.1.2 of Boltcard NFC Card Creator it is possible not only to reset the keys but also to disable the SUN function and do the complete erase so the card can be used again as a static tag (or set as a new Bolt Card, ofc). Since v0.1.2 of Boltcard NFC Card Creator it is possible not only to reset the keys but also to disable the SUN function and do the complete erase so the card can be used again as a static tag (or set as a new Bolt Card, ofc).
@ -50,14 +48,13 @@ Since v0.1.2 of Boltcard NFC Card Creator it is possible not only to reset the k
- In the Boltcard extension click the QR code button next to a card to view its details and select WIPE - In the Boltcard extension click the QR code button next to a card to view its details and select WIPE
- OR click the red cross icon on the right side to reach the same - OR click the red cross icon on the right side to reach the same
- In the Boltcard app (Reset Keys) - In the Boltcard app (Reset Keys)
- Click SCAN QR CODE to scan the QR - Click SCAN QR CODE to scan the QR
- Or click WIPE DATA in LNbits to copy and paste in to the app (PASTE KEY JSON) - Or click WIPE DATA in LNbits to copy and paste in to the app (PASTE KEY JSON)
- Click RESET CARD NOW and approach the NFC card to erase it. DO NOT REMOVE THE CARD PREMATURELY! - Click RESET CARD NOW and approach the NFC card to erase it. DO NOT REMOVE THE CARD PREMATURELY!
- Now if all is successful the card can be safely deleted from LNbits (but keep the keys backuped anyway; batter safe than brick). - Now if all is successful the card can be safely deleted from LNbits (but keep the keys backuped anyway; batter safe than brick).
If you somehow find yourself in some non-standard state (for instance only k3 and k4 remains filled after previous unsuccessful reset), then you need to edit the key fields manually (for instance leave k0-k2 to zeroes and provide the right k3 and k4). If you somehow find yourself in some non-standard state (for instance only k3 and k4 remains filled after previous unsuccessful reset), then you need to edit the key fields manually (for instance leave k0-k2 to zeroes and provide the right k3 and k4).
## Setting the card (advanced) ## Setting the card (advanced)
A technology called [Secure Unique NFC](https://web.archive.org/web/20220706134959/https://mishka-scan.com/blog/secure-unique-nfc) is utilized in this workflow. A technology called [Secure Unique NFC](https://web.archive.org/web/20220706134959/https://mishka-scan.com/blog/secure-unique-nfc) is utilized in this workflow.
@ -74,8 +71,8 @@ The key #00, K0 (also know as auth key) is used as authentification key. It is n
### The writing process ### The writing process
There's also a more [advanced guide](https://www.whitewolftech.com/articles/payment-card/) to set cards up manually with a card reader connected to your computer. There's also a more [advanced guide](https://www.whitewolftech.com/articles/payment-card/) to set cards up manually with a card reader connected to your computer.
Writing can also be done (without setting the keys) via the [TagWriter app by NXP](https://play.google.com/store/apps/details?id=com.nxp.nfc.tagwriter) on Android. Writing can also be done (without setting the keys) via the [TagWriter app by NXP](https://play.google.com/store/apps/details?id=com.nxp.nfc.tagwriter) on Android.
The URI should be `lnurlw://YOUR_LNBITS_DOMAIN/boltcards/api/v1/scan/{YOUR_card_external_id}?p=00000000000000000000000000000000&c=0000000000000000` The URI should be `lnurlw://YOUR_LNBITS_DOMAIN/boltcards/api/v1/scan/{YOUR_card_external_id}?p=00000000000000000000000000000000&c=0000000000000000`

View file

@ -1,12 +1,13 @@
import asyncio import asyncio
from loguru import logger
from fastapi import APIRouter from fastapi import APIRouter
from lnbits.db import Database from loguru import logger
from lnbits.helpers import template_renderer
from lnbits.tasks import create_permanent_unique_task
db = Database("ext_boltcards") from .crud import db
from .tasks import wait_for_paid_invoices
from .views import boltcards_generic_router
from .views_api import boltcards_api_router
from .views_lnurl import boltcards_lnurl_router
boltcards_static_files = [ boltcards_static_files = [
{ {
@ -16,14 +17,9 @@ boltcards_static_files = [
] ]
boltcards_ext: APIRouter = APIRouter(prefix="/boltcards", tags=["boltcards"]) boltcards_ext: APIRouter = APIRouter(prefix="/boltcards", tags=["boltcards"])
boltcards_ext.include_router(boltcards_generic_router)
boltcards_ext.include_router(boltcards_api_router)
def boltcards_renderer(): boltcards_ext.include_router(boltcards_lnurl_router)
return template_renderer(["boltcards/templates"])
from .lnurl import * # noqa: F401,F403
from .tasks import * # noqa: F401,F403
scheduled_tasks: list[asyncio.Task] = [] scheduled_tasks: list[asyncio.Task] = []
@ -37,9 +33,16 @@ def boltcards_stop():
def boltcards_start(): def boltcards_start():
from lnbits.tasks import create_permanent_unique_task
task = create_permanent_unique_task("ext_boltcards", wait_for_paid_invoices) task = create_permanent_unique_task("ext_boltcards", wait_for_paid_invoices)
scheduled_tasks.append(task) scheduled_tasks.append(task)
from .views import * # noqa: F401,F403 __all__ = [
from .views_api import * # noqa: F401,F403 "db",
"boltcards_ext",
"boltcards_static_files",
"boltcards_start",
"boltcards_stop",
]

24
crud.py
View file

@ -2,11 +2,13 @@ import secrets
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List, Optional
from lnbits.db import Database
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import Card, CreateCardData, Hit, Refund from .models import Card, CreateCardData, Hit, Refund
db = Database("ext_boltcards")
async def create_card(data: CreateCardData, wallet_id: str) -> Card: async def create_card(data: CreateCardData, wallet_id: str) -> Card:
card_id = urlsafe_short_hash().upper() card_id = urlsafe_short_hash().upper()
@ -137,25 +139,25 @@ async def delete_card(card_id: str) -> None:
) )
async def update_card_counter(counter: int, 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 = ? WHERE id = ?",
(counter, id), (counter, card_id),
) )
async def enable_disable_card(enable: bool, 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 = ? WHERE id = ?",
(enable, id), (enable, card_id),
) )
return await get_card(id) return await get_card(card_id)
async def update_card_otp(otp: str, 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 = ? WHERE id = ?",
(otp, id), (otp, card_id),
) )
@ -194,12 +196,12 @@ async def get_hits_today(card_id: str) -> List[Hit]:
return [Hit(**row) for row in updatedrow] return [Hit(**row) for row in updatedrow]
async def spend_hit(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 = ?, amount = ? WHERE id = ?",
(True, amount, id), (True, amount, card_id),
) )
return await get_hit(id) return await get_hit(card_id)
async def create_hit(card_id, ip, useragent, old_ctr, new_ctr) -> Hit: async def create_hit(card_id, ip, useragent, old_ctr, new_ctr) -> Hit:

View file

@ -2,7 +2,7 @@ This extension enables you to link your Bolt Card to an NXP NTAG424 DNA tag via
For a simpler setup, you will need: For a simpler setup, you will need:
* An LNbits instance accessible over clearnet. - An LNbits instance accessible over clearnet.
* Google Chrome browser opened on Android. - Google Chrome browser opened on Android.
* Boltcard extension installed on your LNbits wallet. - Boltcard extension installed on your LNbits wallet.
* Boltcard NFC Card Creator App, available in the Apple or Play Store, to write your keys to the tags after they have been generated on LNbits. - Boltcard NFC Card Creator App, available in the Apple or Play Store, to write your keys to the tags after they have been generated on LNbits.

View file

@ -34,7 +34,9 @@ class Card(BaseModel):
return cls(**dict(row)) return cls(**dict(row))
def lnurl(self, req: Request) -> Lnurl: def lnurl(self, req: Request) -> Lnurl:
url = str(req.url_for("boltcard.lnurl_response", device_id=self.id, _external=True)) url = str(
req.url_for("boltcard.lnurl_response", device_id=self.id, _external=True)
)
return lnurl_encode(url) return lnurl_encode(url)
async def lnurlpay_metadata(self) -> LnurlPayMetadata: async def lnurlpay_metadata(self) -> LnurlPayMetadata:

View file

@ -1,5 +1,4 @@
# https://www.nxp.com/docs/en/application-note/AN12196.pdf # https://www.nxp.com/docs/en/application-note/AN12196.pdf
from typing import Tuple
from Cryptodome.Cipher import AES from Cryptodome.Cipher import AES
from Cryptodome.Hash import CMAC from Cryptodome.Hash import CMAC
@ -7,30 +6,30 @@ from Cryptodome.Hash import CMAC
SV2 = "3CC300010080" SV2 = "3CC300010080"
def myCMAC(key: bytes, msg: bytes = b"") -> bytes: def my_cmac(key: bytes, msg: bytes = b"") -> bytes:
cobj = CMAC.new(key, ciphermod=AES) cobj = CMAC.new(key, ciphermod=AES)
if msg != b"": if msg != b"":
cobj.update(msg) cobj.update(msg)
return cobj.digest() return cobj.digest()
def decryptSUN(sun: bytes, key: bytes) -> Tuple[bytes, bytes]: def decrypt_sun(sun: bytes, key: bytes) -> tuple[bytes, bytes]:
IVbytes = b"\x00" * 16 ivbytes = b"\x00" * 16
cipher = AES.new(key, AES.MODE_CBC, IVbytes) cipher = AES.new(key, AES.MODE_CBC, ivbytes)
sun_plain = cipher.decrypt(sun) sun_plain = cipher.decrypt(sun)
UID = sun_plain[1:8] uid = sun_plain[1:8]
counter = sun_plain[8:11] counter = sun_plain[8:11]
return UID, counter return uid, counter
def getSunMAC(UID: bytes, counter: bytes, key: bytes) -> bytes: def get_sun_mac(uid: bytes, counter: bytes, key: bytes) -> bytes:
sv2prefix = bytes.fromhex(SV2) sv2prefix = bytes.fromhex(SV2)
sv2bytes = sv2prefix + UID + counter sv2bytes = sv2prefix + uid + counter
mac1 = myCMAC(key, sv2bytes) mac1 = my_cmac(key, sv2bytes)
mac2 = myCMAC(mac1) mac2 = my_cmac(mac1)
return mac2[1::2] return mac2[1::2]

59
package-lock.json generated Normal file
View file

@ -0,0 +1,59 @@
{
"name": "boltcards",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "boltcards",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"prettier": "^3.2.5",
"pyright": "^1.1.358"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/prettier": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pyright": {
"version": "1.1.374",
"resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.374.tgz",
"integrity": "sha512-ISbC1YnYDYrEatoKKjfaA5uFIp0ddC/xw9aSlN/EkmwupXUMVn41Jl+G6wHEjRhC+n4abHZeGpEvxCUus/K9dA==",
"bin": {
"pyright": "index.js",
"pyright-langserver": "langserver.index.js"
},
"engines": {
"node": ">=14.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
}
}
}

15
package.json Normal file
View file

@ -0,0 +1,15 @@
{
"name": "boltcards",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"prettier": "^3.2.5",
"pyright": "^1.1.358"
}
}

2492
poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

94
pyproject.toml Normal file
View file

@ -0,0 +1,94 @@
[tool.poetry]
name = "lnbits-boltcards"
version = "0.0.0"
description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = ["Alan Bits <alan@lnbits.com>"]
[tool.poetry.dependencies]
python = "^3.10 | ^3.9"
lnbits = "*"
[tool.poetry.group.dev.dependencies]
black = "^24.3.0"
pytest-asyncio = "^0.21.0"
pytest = "^7.3.2"
mypy = "^1.5.1"
pre-commit = "^3.2.2"
ruff = "^0.3.2"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.mypy]
exclude = "(nostr/*)"
[[tool.mypy.overrides]]
module = [
"lnbits.*",
"lnurl.*",
"loguru.*",
"fastapi.*",
"pydantic.*",
"pyqrcode.*",
"shortuuid.*",
"httpx.*",
]
ignore_missing_imports = "True"
[tool.pytest.ini_options]
log_cli = false
testpaths = [
"tests"
]
[tool.black]
line-length = 88
[tool.ruff]
# Same as Black. + 10% rule of black
line-length = 88
exclude = [
"nostr",
]
[tool.ruff.lint]
# Enable:
# F - pyflakes
# E - pycodestyle errors
# W - pycodestyle warnings
# I - isort
# A - flake8-builtins
# C - mccabe
# N - naming
# UP - pyupgrade
# RUF - ruff
# B - bugbear
select = ["F", "E", "W", "I", "A", "C", "N", "UP", "RUF", "B"]
ignore = ["C901"]
# Allow autofix for all enabled rules (when `--fix`) is provided.
fixable = ["ALL"]
unfixable = []
# Allow unused variables when underscore-prefixed.
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
# needed for pydantic
[tool.ruff.lint.pep8-naming]
classmethod-decorators = [
"root_validator",
]
# Ignore unused imports in __init__.py files.
# [tool.ruff.lint.extend-per-file-ignores]
# "__init__.py" = ["F401", "F403"]
# [tool.ruff.lint.mccabe]
# max-complexity = 10
[tool.ruff.lint.flake8-bugbear]
# Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`.
extend-immutable-calls = [
"fastapi.Depends",
"fastapi.Query",
]

View file

@ -1,7 +1,6 @@
import asyncio import asyncio
import json
from lnbits.core import db as core_db from lnbits.core.crud import update_payment_extra
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.helpers import get_current_extension_name from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
@ -31,17 +30,5 @@ 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))
await mark_webhook_sent(payment, 1) payment.extra["wh_status"] = 1
await update_payment_extra(payment.payment_hash, payment.extra)
async def mark_webhook_sent(payment: Payment, status: int) -> None:
payment.extra["wh_status"] = status
await core_db.execute(
"""
UPDATE apipayments SET extra = ?
WHERE hash = ?
""",
(json.dumps(payment.extra), payment.payment_hash),
)

View file

@ -10,9 +10,7 @@
<p> <p>
Manage your Bolt Cards self custodian way<br /> Manage your Bolt Cards self custodian way<br />
<a <a class="text-secondary" href="https://github.com/lnbits/boltcards"
class="text-secondary"
href="https://github.com/lnbits/boltcards"
>More details</a >More details</a
> >
<br /> <br />

0
tests/__init__.py Normal file
View file

11
tests/test_init.py Normal file
View file

@ -0,0 +1,11 @@
import pytest
from fastapi import APIRouter
from .. import boltcards_ext
# just import router and add it to a test router
@pytest.mark.asyncio
async def test_router():
router = APIRouter()
router.include_router(boltcards_ext)

9
toc.md
View file

@ -1,22 +1,29 @@
# Terms and Conditions for LNbits Extension # Terms and Conditions for LNbits Extension
## 1. Acceptance of Terms ## 1. Acceptance of Terms
By installing and using the LNbits extension ("Extension"), you agree to be bound by these terms and conditions ("Terms"). If you do not agree to these Terms, do not use the Extension. By installing and using the LNbits extension ("Extension"), you agree to be bound by these terms and conditions ("Terms"). If you do not agree to these Terms, do not use the Extension.
## 2. License ## 2. License
The Extension is free and open-source software, released under [specify the FOSS license here, e.g., GPL-3.0, MIT, etc.]. You are permitted to use, copy, modify, and distribute the Extension under the terms of that license. The Extension is free and open-source software, released under [specify the FOSS license here, e.g., GPL-3.0, MIT, etc.]. You are permitted to use, copy, modify, and distribute the Extension under the terms of that license.
## 3. No Warranty ## 3. No Warranty
The Extension is provided "as is" and with all faults, and the developer expressly disclaims all warranties of any kind, whether express, implied, statutory, or otherwise, including but not limited to warranties of merchantability, fitness for a particular purpose, non-infringement, and any warranties arising out of course of dealing or usage of trade. No advice or information, whether oral or written, obtained from the developer or elsewhere will create any warranty not expressly stated in this Terms. The Extension is provided "as is" and with all faults, and the developer expressly disclaims all warranties of any kind, whether express, implied, statutory, or otherwise, including but not limited to warranties of merchantability, fitness for a particular purpose, non-infringement, and any warranties arising out of course of dealing or usage of trade. No advice or information, whether oral or written, obtained from the developer or elsewhere will create any warranty not expressly stated in this Terms.
## 4. Limitation of Liability ## 4. Limitation of Liability
In no event will the developer be liable to you or any third party for any direct, indirect, incidental, special, consequential, or punitive damages, including lost profit, lost revenue, loss of data, or other damages arising out of or in connection with your use of the Extension, even if the developer has been advised of the possibility of such damages. The foregoing limitation of liability shall apply to the fullest extent permitted by law in the applicable jurisdiction. In no event will the developer be liable to you or any third party for any direct, indirect, incidental, special, consequential, or punitive damages, including lost profit, lost revenue, loss of data, or other damages arising out of or in connection with your use of the Extension, even if the developer has been advised of the possibility of such damages. The foregoing limitation of liability shall apply to the fullest extent permitted by law in the applicable jurisdiction.
## 5. Modification of Terms ## 5. Modification of Terms
The developer reserves the right to modify these Terms at any time. You are advised to review these Terms periodically for any changes. Changes to these Terms are effective when they are posted on the appropriate location within or associated with the Extension. The developer reserves the right to modify these Terms at any time. You are advised to review these Terms periodically for any changes. Changes to these Terms are effective when they are posted on the appropriate location within or associated with the Extension.
## 6. General Provisions ## 6. General Provisions
If any provision of these Terms is held to be invalid or unenforceable, that provision will be enforced to the maximum extent permissible, and the other provisions of these Terms will remain in full force and effect. These Terms constitute the entire agreement between you and the developer regarding the use of the Extension. If any provision of these Terms is held to be invalid or unenforceable, that provision will be enforced to the maximum extent permissible, and the other provisions of these Terms will remain in full force and effect. These Terms constitute the entire agreement between you and the developer regarding the use of the Extension.
## 7. Contact Information ## 7. Contact Information
If you have any questions about these Terms, please contact the developer at [developer's contact information].
If you have any questions about these Terms, please contact the developer at [developer's contact information].

View file

@ -1,27 +1,31 @@
from http import HTTPStatus from http import HTTPStatus
from fastapi import Depends, Request from fastapi import APIRouter, Depends, Request
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from lnbits.helpers import template_renderer
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import boltcards_ext, boltcards_renderer
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") templates = Jinja2Templates(directory="templates")
boltcards_generic_router = APIRouter()
@boltcards_ext.get("/", response_class=HTMLResponse) def boltcards_renderer():
return template_renderer(["boltcards/templates"])
@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.dict()}
) )
@boltcards_ext.get("/{card_id}", response_class=HTMLResponse) @boltcards_generic_router.get("/{card_id}", response_class=HTMLResponse)
async def display(request: Request, card_id: str): async def display(request: Request, card_id: str):
card = await get_card_by_external_id(card_id) card = await get_card_by_external_id(card_id)
if not card: if not card:
@ -32,11 +36,11 @@ async def display(request: Request, card_id: str):
refunds = [ refunds = [
refund.hit_id for refund in await get_refunds([hit["id"] for hit in hits]) refund.hit_id for refund in await get_refunds([hit["id"] for hit in hits])
] ]
card = card.dict() card_dict = card.dict()
# Remove wallet id from card dict # Remove wallet id from card dict
del card["wallet"] del card_dict["wallet"]
return boltcards_renderer().TemplateResponse( return boltcards_renderer().TemplateResponse(
"boltcards/display.html", "boltcards/display.html",
{"request": request, "card": card, "hits": hits, "refunds": refunds}, {"request": request, "card": card_dict, "hits": hits, "refunds": refunds},
) )

View file

@ -1,12 +1,10 @@
from http import HTTPStatus from http import HTTPStatus
from typing import Optional
from fastapi import 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.decorators import WalletTypeInfo, get_key_type, require_admin_key from lnbits.core.models import WalletTypeInfo
from lnbits.decorators import get_key_type, require_admin_key
from . import boltcards_ext
from .crud import ( from .crud import (
create_card, create_card,
delete_card, delete_card,
@ -20,8 +18,10 @@ from .crud import (
) )
from .models import Card, CreateCardData from .models import Card, CreateCardData
boltcards_api_router = APIRouter()
@boltcards_ext.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 g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = False
): ):
@ -55,18 +55,17 @@ def validate_card(data: CreateCardData):
raise HTTPException( raise HTTPException(
detail="Invalid bytes for k2.", status_code=HTTPStatus.BAD_REQUEST detail="Invalid bytes for k2.", status_code=HTTPStatus.BAD_REQUEST
) )
except Exception: except Exception as exc:
raise HTTPException( raise HTTPException(
detail="Invalid byte data provided.", status_code=HTTPStatus.BAD_REQUEST detail="Invalid byte data provided.", status_code=HTTPStatus.BAD_REQUEST
) ) from exc
@boltcards_ext.put( @boltcards_api_router.put(
"/api/v1/cards/{card_id}", "/api/v1/cards/{card_id}",
status_code=HTTPStatus.OK, status_code=HTTPStatus.OK,
dependencies=[Depends(validate_card)] dependencies=[Depends(validate_card)],
) )
async def api_card_update( async def api_card_update(
data: CreateCardData, data: CreateCardData,
card_id: str, card_id: str,
@ -79,11 +78,9 @@ async def api_card_update(
detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND
) )
if card.wallet != wallet.wallet.id: if card.wallet != wallet.wallet.id:
raise HTTPException( raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN)
detail="Not your card.", status_code=HTTPStatus.FORBIDDEN check_uid = await get_card_by_uid(data.uid)
) if check_uid and check_uid.id != card_id:
checkUid = await get_card_by_uid(data.uid)
if checkUid and checkUid.id != card_id:
raise HTTPException( raise HTTPException(
detail="UID already registered. Delete registered card and try again.", detail="UID already registered. Delete registered card and try again.",
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
@ -93,17 +90,17 @@ async def api_card_update(
return card return card
@boltcards_ext.post( @boltcards_api_router.post(
"/api/v1/cards", "/api/v1/cards",
status_code=HTTPStatus.CREATED, status_code=HTTPStatus.CREATED,
dependencies=[Depends(validate_card)] dependencies=[Depends(validate_card)],
) )
async def api_card_create( async def api_card_create(
data: CreateCardData, data: CreateCardData,
wallet: WalletTypeInfo = Depends(require_admin_key), wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Card: ) -> Card:
checkUid = await get_card_by_uid(data.uid) check_uid = await get_card_by_uid(data.uid)
if checkUid: if check_uid:
raise HTTPException( raise HTTPException(
detail="UID already registered. Delete registered card and try again.", detail="UID already registered. Delete registered card and try again.",
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
@ -113,7 +110,9 @@ async def api_card_create(
return card return card
@boltcards_ext.get("/api/v1/cards/enable/{card_id}/{enable}", status_code=HTTPStatus.OK) @boltcards_api_router.get(
"/api/v1/cards/enable/{card_id}/{enable}", status_code=HTTPStatus.OK
)
async def enable_card( async def enable_card(
card_id, card_id,
enable, enable,
@ -124,12 +123,12 @@ async def enable_card(
raise HTTPException(detail="No card found.", status_code=HTTPStatus.NOT_FOUND) raise HTTPException(detail="No card found.", status_code=HTTPStatus.NOT_FOUND)
if card.wallet != wallet.wallet.id: if card.wallet != wallet.wallet.id:
raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN) raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN)
card = await enable_disable_card(enable=enable, id=card_id) card = await enable_disable_card(enable=enable, card_id=card_id)
assert card assert card
return card.dict() return card.dict()
@boltcards_ext.delete("/api/v1/cards/{card_id}") @boltcards_api_router.delete("/api/v1/cards/{card_id}")
async def api_card_delete(card_id, wallet: WalletTypeInfo = Depends(require_admin_key)): async def api_card_delete(card_id, wallet: WalletTypeInfo = Depends(require_admin_key)):
card = await get_card(card_id) card = await get_card(card_id)
@ -145,7 +144,7 @@ async def api_card_delete(card_id, wallet: WalletTypeInfo = Depends(require_admi
return "", HTTPStatus.NO_CONTENT return "", HTTPStatus.NO_CONTENT
@boltcards_ext.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) g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)
): ):
@ -163,7 +162,7 @@ async def api_hits(
return [hit.dict() for hit in await get_hits(cards_ids)] return [hit.dict() for hit in await get_hits(cards_ids)]
@boltcards_ext.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) g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)
): ):

View file

@ -3,16 +3,14 @@ import secrets
from http import HTTPStatus from http import HTTPStatus
from urllib.parse import urlparse from urllib.parse import urlparse
from fastapi import HTTPException, Query, Request import bolt11
from fastapi import APIRouter, HTTPException, Query, Request
from lnbits.core.services import create_invoice, pay_invoice
from lnurl import encode as lnurl_encode from lnurl import encode as lnurl_encode
from lnurl.types import LnurlPayMetadata from lnurl.types import LnurlPayMetadata
from loguru import logger
from starlette.responses import HTMLResponse from starlette.responses import HTMLResponse
from lnbits import bolt11
from lnbits.core.services import create_invoice
from lnbits.core.views.api import pay_invoice
from . import boltcards_ext
from .crud import ( from .crud import (
create_hit, create_hit,
get_card, get_card,
@ -24,13 +22,13 @@ from .crud import (
update_card_counter, update_card_counter,
update_card_otp, update_card_otp,
) )
from .nxp424 import decryptSUN, getSunMAC from .nxp424 import decrypt_sun, get_sun_mac
###############LNURLWITHDRAW################# boltcards_lnurl_router = APIRouter()
# /boltcards/api/v1/scan?p=00000000000000000000000000000000&c=0000000000000000 # /boltcards/api/v1/scan?p=00000000000000000000000000000000&c=0000000000000000
@boltcards_ext.get("/api/v1/scan/{external_id}") @boltcards_lnurl_router.get("/api/v1/scan/{external_id}")
async def api_scan(p, c, request: Request, external_id: str): async def api_scan(p, c, request: Request, external_id: str):
# some wallets send everything as lower case, no bueno # some wallets send everything as lower case, no bueno
p = p.upper() p = p.upper()
@ -43,12 +41,12 @@ async def api_scan(p, c, request: Request, external_id: str):
if not card.enable: if not card.enable:
return {"status": "ERROR", "reason": "Card is disabled."} return {"status": "ERROR", "reason": "Card is disabled."}
try: try:
card_uid, counter = decryptSUN(bytes.fromhex(p), bytes.fromhex(card.k1)) card_uid, counter = decrypt_sun(bytes.fromhex(p), bytes.fromhex(card.k1))
if card.uid.upper() != card_uid.hex().upper(): if card.uid.upper() != card_uid.hex().upper():
return {"status": "ERROR", "reason": "Card UID mis-match."} return {"status": "ERROR", "reason": "Card UID mis-match."}
if c != getSunMAC(card_uid, counter, bytes.fromhex(card.k2)).hex().upper(): if c != get_sun_mac(card_uid, counter, bytes.fromhex(card.k2)).hex().upper():
return {"status": "ERROR", "reason": "CMAC does not check."} return {"status": "ERROR", "reason": "CMAC does not check."}
except: except Exception:
return {"status": "ERROR", "reason": "Error decrypting card."} return {"status": "ERROR", "reason": "Error decrypting card."}
ctr_int = int.from_bytes(counter, "little") ctr_int = int.from_bytes(counter, "little")
@ -80,8 +78,10 @@ async def api_scan(p, c, request: Request, external_id: str):
lnurlpay_raw = str(request.url_for("boltcards.lnurlp_response", hit_id=hit.id)) lnurlpay_raw = str(request.url_for("boltcards.lnurlp_response", hit_id=hit.id))
# bech32 encoded lnurl # bech32 encoded lnurl
lnurlpay_bech32 = lnurl_encode(lnurlpay_raw) lnurlpay_bech32 = lnurl_encode(lnurlpay_raw)
# create a lud17 lnurlp to support lud19, add to payLink field of the withdrawRequest # create a lud17 lnurlp to support lud19, add payLink field of the withdrawRequest
lnurlpay_nonbech32_lud17 = lnurlpay_raw.replace("https://", "lnurlp://").replace("http://","lnurlp://") lnurlpay_nonbech32_lud17 = lnurlpay_raw.replace("https://", "lnurlp://").replace(
"http://", "lnurlp://"
)
return { return {
"tag": "withdrawRequest", "tag": "withdrawRequest",
@ -94,7 +94,7 @@ async def api_scan(p, c, request: Request, external_id: str):
} }
@boltcards_ext.get( @boltcards_lnurl_router.get(
"/api/v1/lnurl/cb/{hit_id}", "/api/v1/lnurl/cb/{hit_id}",
status_code=HTTPStatus.OK, status_code=HTTPStatus.OK,
name="boltcards.lnurl_callback", name="boltcards.lnurl_callback",
@ -104,6 +104,8 @@ async def lnurl_callback(
k1: str = Query(None), k1: str = Query(None),
pr: str = Query(None), pr: str = Query(None),
): ):
# TODO: why no hit_id? its not used why is it passed by url?
logger.debug(f"TODO: why no hit_id? {hit_id}")
if not k1: if not k1:
return {"status": "ERROR", "reason": "Missing K1 token"} return {"status": "ERROR", "reason": "Missing K1 token"}
@ -121,12 +123,13 @@ async def lnurl_callback(
try: try:
invoice = bolt11.decode(pr) invoice = bolt11.decode(pr)
except: except bolt11.Bolt11Exception:
return {"status": "ERROR", "reason": "Failed to decode payment request"} return {"status": "ERROR", "reason": "Failed to decode payment request"}
card = await get_card(hit.card_id) card = await get_card(hit.card_id)
assert card assert card
hit = await spend_hit(id=hit.id, amount=int(invoice.amount_msat / 1000)) assert invoice.amount_msat, "Invoice amount is missing"
hit = await spend_hit(card_id=hit.id, amount=int(invoice.amount_msat / 1000))
assert hit assert hit
try: try:
await pay_invoice( await pay_invoice(
@ -141,7 +144,7 @@ async def lnurl_callback(
# /boltcards/api/v1/auth?a=00000000000000000000000000000000 # /boltcards/api/v1/auth?a=00000000000000000000000000000000
@boltcards_ext.get("/api/v1/auth") @boltcards_lnurl_router.get("/api/v1/auth")
async def api_auth(a, request: Request): async def api_auth(a, request: Request):
if a == "00000000000000000000000000000000": if a == "00000000000000000000000000000000":
response = {"k0": "0" * 32, "k1": "1" * 32, "k2": "2" * 32} response = {"k0": "0" * 32, "k1": "1" * 32, "k2": "2" * 32}
@ -179,7 +182,7 @@ async def api_auth(a, request: Request):
###############LNURLPAY REFUNDS################# ###############LNURLPAY REFUNDS#################
@boltcards_ext.get( @boltcards_lnurl_router.get(
"/api/v1/lnurlp/{hit_id}", "/api/v1/lnurlp/{hit_id}",
response_class=HTMLResponse, response_class=HTMLResponse,
name="boltcards.lnurlp_response", name="boltcards.lnurlp_response",
@ -193,17 +196,17 @@ async def lnurlp_response(req: Request, hit_id: str):
return {"status": "ERROR", "reason": "LNURL-pay record not found."} return {"status": "ERROR", "reason": "LNURL-pay record not found."}
if not card.enable: if not card.enable:
return {"status": "ERROR", "reason": "Card is disabled."} return {"status": "ERROR", "reason": "Card is disabled."}
payResponse = { pay_response = {
"tag": "payRequest", "tag": "payRequest",
"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": card.tx_limit * 1000,
} }
return json.dumps(payResponse) return json.dumps(pay_response)
@boltcards_ext.get( @boltcards_lnurl_router.get(
"/api/v1/lnurlp/cb/{hit_id}", "/api/v1/lnurlp/cb/{hit_id}",
response_class=HTMLResponse, response_class=HTMLResponse,
name="boltcards.lnurlp_callback", name="boltcards.lnurlp_callback",
@ -226,6 +229,6 @@ async def lnurlp_callback(hit_id: str, amount: str = Query(None)):
extra={"refund": hit_id}, extra={"refund": hit_id},
) )
payResponse = {"pr": payment_request, "routes": []} pay_response = {"pr": payment_request, "routes": []}
return json.dumps(payResponse) return json.dumps(pay_response)