Compare commits
10 commits
8fe5bebfad
...
f1bb3a1813
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1bb3a1813 | ||
|
|
407506ad9f |
||
|
|
f08060d0f4 |
||
|
|
7bdf7db2c4 |
||
|
|
c060f10e65 |
||
|
|
b86263cefa |
||
|
|
74e746c9e9 |
||
|
|
79c74708e1 |
||
|
|
3edd81d85e |
||
|
|
8da3127e29 |
16 changed files with 2749 additions and 2776 deletions
24
Makefile
24
Makefile
|
|
@ -5,27 +5,27 @@ format: prettier black ruff
|
|||
check: mypy pyright checkblack checkruff checkprettier
|
||||
|
||||
prettier:
|
||||
poetry run ./node_modules/.bin/prettier --write .
|
||||
uv run ./node_modules/.bin/prettier --write .
|
||||
pyright:
|
||||
poetry run ./node_modules/.bin/pyright
|
||||
uv run ./node_modules/.bin/pyright
|
||||
|
||||
mypy:
|
||||
poetry run mypy .
|
||||
uv run mypy .
|
||||
|
||||
black:
|
||||
poetry run black .
|
||||
uv run black .
|
||||
|
||||
ruff:
|
||||
poetry run ruff check . --fix
|
||||
uv run ruff check . --fix
|
||||
|
||||
checkruff:
|
||||
poetry run ruff check .
|
||||
uv run ruff check .
|
||||
|
||||
checkprettier:
|
||||
poetry run ./node_modules/.bin/prettier --check .
|
||||
uv run ./node_modules/.bin/prettier --check .
|
||||
|
||||
checkblack:
|
||||
poetry run black --check .
|
||||
uv run black --check .
|
||||
|
||||
checkeditorconfig:
|
||||
editorconfig-checker
|
||||
|
|
@ -33,14 +33,14 @@ checkeditorconfig:
|
|||
test:
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
poetry run pytest
|
||||
uv 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
|
||||
@echo "Uninstall the hook with uv run pre-commit uninstall"
|
||||
uv run pre-commit install
|
||||
|
||||
pre-commit:
|
||||
poetry run pre-commit run --all-files
|
||||
uv run pre-commit run --all-files
|
||||
|
||||
|
||||
checkbundle:
|
||||
|
|
|
|||
30
README.md
30
README.md
|
|
@ -97,3 +97,33 @@ Then fill up the card parameters in the extension. Card Auth key (K0) can be fil
|
|||
- Scan with compatible Wallet
|
||||
|
||||
This app afaik cannot change the keys. If you cannot change them any other way, leave them empty in the extension dialog and remember you're not secured. Card Auth key (K0) can be omitted anyway. Initical counter can be 0.
|
||||
|
||||
---
|
||||
|
||||
## aiolabs fork — tap-to-receive (top-up)
|
||||
|
||||
This fork adds a **deposit** counterpart to the `/scan` withdraw, so a Bolt
|
||||
Card can be tapped to *receive* sats (e.g. the buy flow on a bitSpire ATM), not
|
||||
only to spend.
|
||||
|
||||
A Bolt Card only ever emits its `lnurlw` (a spend voucher), so the tap is used
|
||||
purely as an **authenticated identity**: the same NTAG424 SUN `p`/`c` that
|
||||
`/scan` verifies proves card possession, and the endpoint returns an
|
||||
**lnurl-pay** (LUD-06) response for the card's *own* wallet instead of a
|
||||
withdraw voucher. No card re-writing — same NDEF, keys, and `external_id`.
|
||||
|
||||
**Endpoint** (sibling of `/scan`):
|
||||
|
||||
```
|
||||
GET /boltcards/api/v1/pay/{external_id}?p=<32-hex>&c=<16-hex>
|
||||
→ LnurlPayResponse { tag:"payRequest", callback, minSendable, maxSendable, metadata }
|
||||
GET /boltcards/api/v1/pay/cb/{hit_id}?amount=<msat>
|
||||
→ LnurlPayActionResponse { pr:<bolt11 on the card wallet> }
|
||||
```
|
||||
|
||||
- SUN verification, counter monotonicity, and the single-use `hit` bearer token
|
||||
mirror `/scan` exactly (`hit_id` bridges the two LUD-06 steps like `k1` does
|
||||
for withdraw). A cloned UID can't misdirect a deposit.
|
||||
- No daily-limit check (that gates *spending*); per-deposit max is `tx_limit`.
|
||||
- Distinct from the existing LUD-19 refund `lnurlp` (which is keyed by a prior
|
||||
scan's `hit`); this is reachable directly by a tap via `external_id`.
|
||||
|
|
|
|||
|
|
@ -40,9 +40,9 @@ def boltcards_start():
|
|||
|
||||
|
||||
__all__ = [
|
||||
"db",
|
||||
"boltcards_ext",
|
||||
"boltcards_static_files",
|
||||
"boltcards_start",
|
||||
"boltcards_static_files",
|
||||
"boltcards_stop",
|
||||
"db",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -2,8 +2,14 @@
|
|||
"name": "Bolt Cards",
|
||||
"short_description": "Self custody Bolt Cards with one time LNURLw",
|
||||
"tile": "/boltcards/static/image/boltcard.png",
|
||||
"min_lnbits_version": "1.0.0",
|
||||
"version": "1.1.0-aio.1",
|
||||
"min_lnbits_version": "1.3.0",
|
||||
"contributors": [
|
||||
{
|
||||
"name": "aiolabs",
|
||||
"uri": "https://git.atitlan.io/aiolabs",
|
||||
"role": "Fork maintainer (tap-to-receive)"
|
||||
},
|
||||
{
|
||||
"name": "dni",
|
||||
"uri": "https://github.com/dni",
|
||||
|
|
|
|||
21
crud.py
21
crud.py
|
|
@ -1,6 +1,5 @@
|
|||
import secrets
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
|
@ -57,11 +56,7 @@ async def create_card(data: CreateCardData, wallet_id: str) -> Card:
|
|||
return card
|
||||
|
||||
|
||||
async def update_card(card_id: str, data: CreateCardData) -> Card:
|
||||
card = Card(
|
||||
id=card_id,
|
||||
**data.dict(),
|
||||
)
|
||||
async def update_card(card: Card) -> Card:
|
||||
await db.update("boltcards.cards", card)
|
||||
return card
|
||||
|
||||
|
|
@ -76,7 +71,7 @@ async def get_cards(wallet_ids: list[str]) -> list[Card]:
|
|||
)
|
||||
|
||||
|
||||
async def get_card(card_id: str) -> Optional[Card]:
|
||||
async def get_card(card_id: str) -> Card | None:
|
||||
return await db.fetchone(
|
||||
"SELECT * FROM boltcards.cards WHERE id = :id",
|
||||
{"id": card_id},
|
||||
|
|
@ -84,7 +79,7 @@ async def get_card(card_id: str) -> Optional[Card]:
|
|||
)
|
||||
|
||||
|
||||
async def get_card_by_uid(card_uid: str) -> Optional[Card]:
|
||||
async def get_card_by_uid(card_uid: str) -> Card | None:
|
||||
return await db.fetchone(
|
||||
"SELECT * FROM boltcards.cards WHERE uid = :uid",
|
||||
{"uid": card_uid.upper()},
|
||||
|
|
@ -92,7 +87,7 @@ async def get_card_by_uid(card_uid: str) -> Optional[Card]:
|
|||
)
|
||||
|
||||
|
||||
async def get_card_by_external_id(external_id: str) -> Optional[Card]:
|
||||
async def get_card_by_external_id(external_id: str) -> Card | None:
|
||||
return await db.fetchone(
|
||||
"SELECT * FROM boltcards.cards WHERE external_id = :ext_id",
|
||||
{"ext_id": external_id.lower()},
|
||||
|
|
@ -100,7 +95,7 @@ async def get_card_by_external_id(external_id: str) -> Optional[Card]:
|
|||
)
|
||||
|
||||
|
||||
async def get_card_by_otp(otp: str) -> Optional[Card]:
|
||||
async def get_card_by_otp(otp: str) -> Card | None:
|
||||
return await db.fetchone(
|
||||
"SELECT * FROM boltcards.cards WHERE otp = :otp",
|
||||
{"otp": otp},
|
||||
|
|
@ -130,7 +125,7 @@ async def update_card_counter(counter: int, card_id: str):
|
|||
)
|
||||
|
||||
|
||||
async def enable_disable_card(enable: bool, card_id: str) -> Optional[Card]:
|
||||
async def enable_disable_card(enable: bool, card_id: str) -> Card | None:
|
||||
await db.execute(
|
||||
"UPDATE boltcards.cards SET enable = :enable WHERE id = :id",
|
||||
{"enable": enable, "id": card_id},
|
||||
|
|
@ -145,7 +140,7 @@ async def update_card_otp(otp: str, card_id: str):
|
|||
)
|
||||
|
||||
|
||||
async def get_hit(hit_id: str) -> Optional[Hit]:
|
||||
async def get_hit(hit_id: str) -> Hit | None:
|
||||
return await db.fetchone(
|
||||
"SELECT * FROM boltcards.hits WHERE id = :id",
|
||||
{"id": hit_id},
|
||||
|
|
@ -240,7 +235,7 @@ async def create_refund(hit_id, refund_amount) -> Refund:
|
|||
return refund
|
||||
|
||||
|
||||
async def get_refund(refund_id: str) -> Optional[Refund]:
|
||||
async def get_refund(refund_id: str) -> Refund | None:
|
||||
return await db.fetchone(
|
||||
"SELECT * FROM boltcards.refunds WHERE id = :id",
|
||||
{"id": refund_id},
|
||||
|
|
|
|||
|
|
@ -55,3 +55,76 @@ async def m001_initial(db):
|
|||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m002_correct_typing(db):
|
||||
await db.execute("ALTER TABLE boltcards.cards RENAME TO cards_m001;")
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE boltcards.cards (
|
||||
id TEXT PRIMARY KEY UNIQUE,
|
||||
wallet TEXT NOT NULL,
|
||||
card_name TEXT NOT NULL,
|
||||
uid TEXT NOT NULL UNIQUE,
|
||||
external_id TEXT NOT NULL UNIQUE,
|
||||
counter INT NOT NULL DEFAULT 0,
|
||||
tx_limit INT NOT NULL,
|
||||
daily_limit INT NOT NULL,
|
||||
enable BOOL NOT NULL,
|
||||
k0 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
|
||||
k1 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
|
||||
k2 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
|
||||
prev_k0 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
|
||||
prev_k1 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
|
||||
prev_k2 TEXT NOT NULL DEFAULT '00000000000000000000000000000000',
|
||||
otp TEXT NOT NULL DEFAULT '',
|
||||
time TIMESTAMP NOT NULL DEFAULT """
|
||||
+ db.timestamp_now
|
||||
+ """
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO boltcards.cards (
|
||||
id,
|
||||
wallet,
|
||||
card_name,
|
||||
uid,
|
||||
external_id,
|
||||
counter,
|
||||
tx_limit,
|
||||
daily_limit,
|
||||
enable,
|
||||
k0,
|
||||
k1,
|
||||
k2,
|
||||
prev_k0,
|
||||
prev_k1,
|
||||
prev_k2,
|
||||
otp,
|
||||
time
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
wallet,
|
||||
card_name,
|
||||
uid,
|
||||
external_id,
|
||||
counter,
|
||||
CAST(tx_limit AS INT),
|
||||
CAST(daily_limit AS INT),
|
||||
enable,
|
||||
k0,
|
||||
k1,
|
||||
k2,
|
||||
prev_k0,
|
||||
prev_k1,
|
||||
prev_k2,
|
||||
otp,
|
||||
time
|
||||
FROM boltcards.cards_m001;
|
||||
"""
|
||||
)
|
||||
await db.execute("DROP TABLE boltcards.cards_m001;")
|
||||
|
|
|
|||
13
models.py
13
models.py
|
|
@ -5,7 +5,7 @@ from fastapi import Query, Request
|
|||
from lnurl import Lnurl
|
||||
from lnurl import encode as lnurl_encode
|
||||
from lnurl.types import LnurlPayMetadata
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
ZERO_KEY = "00000000000000000000000000000000"
|
||||
|
||||
|
|
@ -17,10 +17,8 @@ class Card(BaseModel):
|
|||
uid: str
|
||||
external_id: str
|
||||
counter: int
|
||||
# TODO: database column is TEXT should be INT
|
||||
tx_limit: str
|
||||
# TODO: database column is TEXT should be INT
|
||||
daily_limit: str
|
||||
tx_limit: int
|
||||
daily_limit: int
|
||||
enable: bool
|
||||
k0: str
|
||||
k1: str
|
||||
|
|
@ -73,3 +71,8 @@ class Refund(BaseModel):
|
|||
hit_id: str
|
||||
refund_amount: int
|
||||
time: datetime
|
||||
|
||||
|
||||
class UIDPost(BaseModel):
|
||||
UID: str | None = Field(None, description="The UID of the card.")
|
||||
LNURLW: str | None = Field(None, description="The LNURLW of the card.")
|
||||
|
|
|
|||
2616
poetry.lock
generated
2616
poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,39 +1,34 @@
|
|||
[tool.poetry]
|
||||
[project]
|
||||
name = "lnbits-boltcards"
|
||||
version = "0.0.0"
|
||||
requires-python = ">=3.10,<3.13"
|
||||
description = "LNbits, free and open-source Lightning wallet and accounts system."
|
||||
authors = ["Alan Bits <alan@lnbits.com>"]
|
||||
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
|
||||
urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/bitcoinswitch_extension" }
|
||||
dependencies = [ "lnbits>1" ]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.10 | ^3.9"
|
||||
lnbits = {version = "*", allow-prereleases = true}
|
||||
[tool.poetry]
|
||||
package-mode = false
|
||||
|
||||
[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.6.3"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
[tool.uv]
|
||||
dev-dependencies = [
|
||||
"black",
|
||||
"pytest-asyncio",
|
||||
"pytest",
|
||||
"mypy",
|
||||
"pre-commit",
|
||||
"ruff",
|
||||
"pytest-md",
|
||||
]
|
||||
|
||||
[tool.mypy]
|
||||
exclude = "(nostr/*)"
|
||||
[[tool.mypy.overrides]]
|
||||
module = [
|
||||
"lnbits.*",
|
||||
"lnurl.*",
|
||||
"loguru.*",
|
||||
"fastapi.*",
|
||||
"pydantic.*",
|
||||
"pyqrcode.*",
|
||||
"shortuuid.*",
|
||||
"httpx.*",
|
||||
]
|
||||
ignore_missing_imports = "True"
|
||||
plugins = ["pydantic.mypy"]
|
||||
|
||||
[tool.pydantic-mypy]
|
||||
init_forbid_extra = true
|
||||
init_typed = true
|
||||
warn_required_dynamic_aliases = true
|
||||
warn_untyped_fields = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
log_cli = false
|
||||
|
|
|
|||
|
|
@ -148,6 +148,15 @@ window.app = Vue.createApp({
|
|||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
deeplinkUrl() {
|
||||
const baseUrl = `boltcard://${this.qrCodeDialog.wipe ? 'reset' : 'program'}`
|
||||
const url =
|
||||
this.qrCodeDialog.data.link +
|
||||
(this.qrCodeDialog.wipe ? '&wipe=true' : '')
|
||||
return `${baseUrl}?url=${encodeURIComponent(url)}`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
readNfcTag() {
|
||||
const ndef = new NDEFReader()
|
||||
|
|
@ -229,6 +238,9 @@ window.app = Vue.createApp({
|
|||
this.qrCodeDialog.data = {
|
||||
id: card.id,
|
||||
link: window.location.origin + '/boltcards/api/v1/auth?a=' + card.otp,
|
||||
encodedURI: encodeURIComponent(
|
||||
window.location.origin + '/boltcards/api/v1/auth?a=' + card.otp
|
||||
),
|
||||
name: card.card_name,
|
||||
uid: card.uid,
|
||||
external_id: card.external_id,
|
||||
|
|
|
|||
|
|
@ -56,12 +56,16 @@ card.card_name }{% endblock %} {% block page %}
|
|||
:color="hit.spent > 0 ? 'green' : 'grey'"
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-section class="overflow-hidden">
|
||||
<q-item-label
|
||||
>ID: ${hit.id} ${refunds.some(r => r.hit_id == hit.id) ?
|
||||
'(Refunded)' : null}</q-item-label
|
||||
>
|
||||
<q-item-label caption lines="1">IP: ${hit.ip}</q-item-label>
|
||||
'(Refunded)' : null}
|
||||
<q-tooltip><span v-text="hit.id"></span></q-tooltip>
|
||||
</q-item-label>
|
||||
<q-item-label caption lines="1"
|
||||
>IP: ${hit.ip}<q-tooltip
|
||||
><span v-text="hit.ip"></span></q-tooltip
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section side top>
|
||||
|
|
|
|||
|
|
@ -254,7 +254,7 @@
|
|||
emit-value
|
||||
v-model="cardDialog.data.tx_limit"
|
||||
type="number"
|
||||
label="Max transaction (sats)"
|
||||
label="Max transaction ({{LNBITS_DENOMINATION}})"
|
||||
class="q-pr-sm"
|
||||
></q-input>
|
||||
</div>
|
||||
|
|
@ -265,7 +265,7 @@
|
|||
emit-value
|
||||
v-model="cardDialog.data.daily_limit"
|
||||
type="number"
|
||||
label="Daily limit (sats)"
|
||||
label="Daily limit ({{LNBITS_DENOMINATION}})"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -381,7 +381,6 @@
|
|||
<div class="col q-mt-lg text-center">
|
||||
<lnbits-qrcode
|
||||
:value="qrCodeDialog.data.link"
|
||||
class="rounded-borders"
|
||||
v-show="!qrCodeDialog.wipe"
|
||||
></lnbits-qrcode>
|
||||
<p class="text-center" v-show="!qrCodeDialog.wipe">
|
||||
|
|
@ -396,7 +395,6 @@
|
|||
</p>
|
||||
<lnbits-qrcode
|
||||
:value="qrCodeDialog.data_wipe"
|
||||
class="rounded-borders"
|
||||
v-show="qrCodeDialog.wipe"
|
||||
></lnbits-qrcode>
|
||||
<p class="text-center" v-show="qrCodeDialog.wipe">
|
||||
|
|
@ -463,6 +461,7 @@
|
|||
<q-tooltip>Click to copy, then paste to NFC Card Creator</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
class="q-ml-sm"
|
||||
unelevated
|
||||
outline
|
||||
color="red"
|
||||
|
|
@ -473,6 +472,17 @@
|
|||
>
|
||||
<q-tooltip>Backup the keys, or wipe the card first!</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
class="q-ml-sm"
|
||||
unelevated
|
||||
outline
|
||||
color="grey"
|
||||
:href="deeplinkUrl"
|
||||
target="_blank"
|
||||
label="Use Boltcard App"
|
||||
>
|
||||
<q-tooltip>Use Boltcard Programmer App</q-tooltip>
|
||||
</q-btn>
|
||||
<div class="row q-mt-lg q-gutter-sm">
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</div>
|
||||
|
|
|
|||
15
views.py
15
views.py
|
|
@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
|||
from fastapi.responses import HTMLResponse
|
||||
from lnbits.core.crud import get_wallet
|
||||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_user_exists
|
||||
from lnbits.decorators import check_user_exists, optional_user_id
|
||||
from lnbits.helpers import template_renderer
|
||||
|
||||
from .crud import get_card_by_external_id, get_hits, get_refunds
|
||||
|
|
@ -24,15 +24,26 @@ async def index(request: Request, user: User = Depends(check_user_exists)):
|
|||
|
||||
|
||||
@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, user_id: str | None = Depends(optional_user_id)
|
||||
):
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized."
|
||||
)
|
||||
card = await get_card_by_external_id(card_id)
|
||||
if not card:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Card does not exist."
|
||||
)
|
||||
|
||||
wallet = await get_wallet(card.wallet)
|
||||
wallet_balance = 0
|
||||
if wallet:
|
||||
if wallet.user != user_id:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="Card does not belong to user."
|
||||
)
|
||||
wallet_balance = wallet.balance
|
||||
hits = await get_hits([card.id])
|
||||
hits_json = [hit.json() for hit in hits]
|
||||
|
|
|
|||
31
views_api.py
31
views_api.py
|
|
@ -71,7 +71,6 @@ async def api_card_update(
|
|||
card_id: str,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Card:
|
||||
|
||||
card = await get_card(card_id)
|
||||
if not card:
|
||||
raise HTTPException(
|
||||
|
|
@ -85,8 +84,9 @@ async def api_card_update(
|
|||
detail="UID already registered. Delete registered card and try again.",
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
card = await update_card(card_id, **data.dict())
|
||||
assert card, "update_card should always return a card"
|
||||
for key, value in data.dict().items():
|
||||
setattr(card, key, value)
|
||||
await update_card(card)
|
||||
return card
|
||||
|
||||
|
||||
|
|
@ -106,7 +106,11 @@ async def api_card_create(
|
|||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
card = await create_card(wallet_id=wallet.wallet.id, data=data)
|
||||
assert card, "create_card should always return a card"
|
||||
if not card:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail="Could not create card.",
|
||||
)
|
||||
return card
|
||||
|
||||
|
||||
|
|
@ -114,22 +118,28 @@ async def api_card_create(
|
|||
"/api/v1/cards/enable/{card_id}/{enable}", status_code=HTTPStatus.OK
|
||||
)
|
||||
async def enable_card(
|
||||
card_id,
|
||||
enable,
|
||||
card_id: str,
|
||||
enable: bool,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
) -> Card:
|
||||
card = await get_card(card_id)
|
||||
if not card:
|
||||
raise HTTPException(detail="No card found.", status_code=HTTPStatus.NOT_FOUND)
|
||||
if card.wallet != wallet.wallet.id:
|
||||
raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN)
|
||||
card = await enable_disable_card(enable=enable, card_id=card_id)
|
||||
assert card
|
||||
return card.dict()
|
||||
if not card:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail="Could not update card.",
|
||||
)
|
||||
return card
|
||||
|
||||
|
||||
@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)
|
||||
) -> None:
|
||||
card = await get_card(card_id)
|
||||
|
||||
if not card:
|
||||
|
|
@ -141,7 +151,6 @@ async def api_card_delete(card_id, wallet: WalletTypeInfo = Depends(require_admi
|
|||
raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN)
|
||||
|
||||
await delete_card(card_id)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
@boltcards_api_router.get("/api/v1/hits")
|
||||
|
|
|
|||
319
views_lnurl.py
319
views_lnurl.py
|
|
@ -6,22 +6,34 @@ from urllib.parse import urlparse
|
|||
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.types import LnurlPayMetadata
|
||||
from loguru import logger
|
||||
from starlette.responses import HTMLResponse
|
||||
from lnurl import (
|
||||
CallbackUrl,
|
||||
LightningInvoice,
|
||||
LnurlErrorResponse,
|
||||
LnurlPayActionResponse,
|
||||
LnurlPayMetadata,
|
||||
LnurlPayResponse,
|
||||
LnurlSuccessResponse,
|
||||
LnurlWithdrawResponse,
|
||||
Max144Str,
|
||||
MessageAction,
|
||||
MilliSatoshi,
|
||||
)
|
||||
from pydantic import parse_obj_as
|
||||
|
||||
from .crud import (
|
||||
create_hit,
|
||||
get_card,
|
||||
get_card_by_external_id,
|
||||
get_card_by_otp,
|
||||
get_card_by_uid,
|
||||
get_hit,
|
||||
get_hits_today,
|
||||
spend_hit,
|
||||
update_card_counter,
|
||||
update_card_otp,
|
||||
)
|
||||
from .models import UIDPost
|
||||
from .nxp424 import decrypt_sun, get_sun_mac
|
||||
|
||||
boltcards_lnurl_router = APIRouter()
|
||||
|
|
@ -29,7 +41,9 @@ boltcards_lnurl_router = APIRouter()
|
|||
|
||||
# /boltcards/api/v1/scan?p=00000000000000000000000000000000&c=0000000000000000
|
||||
@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
|
||||
) -> LnurlWithdrawResponse | LnurlErrorResponse:
|
||||
# some wallets send everything as lower case, no bueno
|
||||
p = p.upper()
|
||||
c = c.upper()
|
||||
|
|
@ -37,27 +51,28 @@ async def api_scan(p, c, request: Request, external_id: str):
|
|||
counter = b""
|
||||
card = await get_card_by_external_id(external_id)
|
||||
if not card:
|
||||
return {"status": "ERROR", "reason": "No card."}
|
||||
return LnurlErrorResponse(reason="Card not found.")
|
||||
if not card.enable:
|
||||
return {"status": "ERROR", "reason": "Card is disabled."}
|
||||
return LnurlErrorResponse(reason="Card is disabled.")
|
||||
try:
|
||||
card_uid, counter = decrypt_sun(bytes.fromhex(p), bytes.fromhex(card.k1))
|
||||
if card.uid.upper() != card_uid.hex().upper():
|
||||
return {"status": "ERROR", "reason": "Card UID mis-match."}
|
||||
return LnurlErrorResponse(reason="Card UID mis-match.")
|
||||
if c != get_sun_mac(card_uid, counter, bytes.fromhex(card.k2)).hex().upper():
|
||||
return {"status": "ERROR", "reason": "CMAC does not check."}
|
||||
return LnurlErrorResponse(reason="CMAC does not check.")
|
||||
except Exception:
|
||||
return {"status": "ERROR", "reason": "Error decrypting card."}
|
||||
return LnurlErrorResponse(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."}
|
||||
return LnurlErrorResponse(reason="This link is already used.")
|
||||
|
||||
await update_card_counter(ctr_int, card.id)
|
||||
|
||||
# gathering some info for hit record
|
||||
assert request.client
|
||||
if not request.client:
|
||||
return LnurlErrorResponse(reason="Cannot get client info.")
|
||||
ip = request.client.host
|
||||
if "x-real-ip" in request.headers:
|
||||
ip = request.headers["x-real-ip"]
|
||||
|
|
@ -71,27 +86,25 @@ async def api_scan(p, c, request: Request, external_id: str):
|
|||
for hit in todays_hits:
|
||||
hits_amount += hit.amount
|
||||
if hits_amount > int(card.daily_limit):
|
||||
return {"status": "ERROR", "reason": "Max daily limit spent."}
|
||||
return LnurlErrorResponse(reason="Max daily limit spent.")
|
||||
hit = await create_hit(card.id, ip, agent, card.counter, ctr_int)
|
||||
|
||||
# the raw lnurl
|
||||
lnurlpay_raw = str(request.url_for("boltcards.lnurlp_response", hit_id=hit.id))
|
||||
# bech32 encoded lnurl
|
||||
lnurlpay_bech32 = lnurl_encode(lnurlpay_raw)
|
||||
# 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_url = str(request.url_for("boltcards.lnurlp_response", hit_id=hit.id))
|
||||
pay_link = lnurlpay_url.replace("http://", "lnurlp://").replace(
|
||||
"https://", "lnurlp://"
|
||||
)
|
||||
callback_url = parse_obj_as(
|
||||
CallbackUrl, str(request.url_for("boltcards.lnurl_callback", hit_id=hit.id))
|
||||
)
|
||||
return LnurlWithdrawResponse(
|
||||
callback=callback_url,
|
||||
k1=hit.id,
|
||||
minWithdrawable=MilliSatoshi(1000),
|
||||
maxWithdrawable=MilliSatoshi(int(card.tx_limit) * 1000),
|
||||
defaultDescription=f"Boltcard (refund address {pay_link})",
|
||||
payLink=pay_link, # type: ignore
|
||||
)
|
||||
|
||||
return {
|
||||
"tag": "withdrawRequest",
|
||||
"callback": str(request.url_for("boltcards.lnurl_callback", hit_id=hit.id)),
|
||||
"k1": hit.id,
|
||||
"minWithdrawable": 1 * 1000,
|
||||
"maxWithdrawable": int(card.tx_limit) * 1000,
|
||||
"defaultDescription": f"Boltcard (refund address lnurl://{lnurlpay_bech32})",
|
||||
"payLink": lnurlpay_nonbech32_lud17, # LUD-19 compatibility
|
||||
}
|
||||
|
||||
|
||||
@boltcards_lnurl_router.get(
|
||||
|
|
@ -103,34 +116,32 @@ async def lnurl_callback(
|
|||
hit_id: str,
|
||||
k1: 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}")
|
||||
) -> LnurlErrorResponse | LnurlSuccessResponse:
|
||||
if not k1:
|
||||
return {"status": "ERROR", "reason": "Missing K1 token"}
|
||||
|
||||
hit = await get_hit(k1)
|
||||
return LnurlErrorResponse(reason="Missing K1 token")
|
||||
if k1 != hit_id:
|
||||
return LnurlErrorResponse(reason="K1 token does not match.")
|
||||
|
||||
hit = await get_hit(hit_id)
|
||||
if not hit:
|
||||
return {
|
||||
"status": "ERROR",
|
||||
"reason": "Record not found for this charge (bad k1)",
|
||||
}
|
||||
return LnurlErrorResponse(reason="LNURL-withdraw record not found.")
|
||||
if hit.spent:
|
||||
return {"status": "ERROR", "reason": "Payment already claimed"}
|
||||
return LnurlErrorResponse(reason="Payment already claimed.")
|
||||
if not pr:
|
||||
return {"status": "ERROR", "reason": "Missing payment request"}
|
||||
return LnurlErrorResponse(reason="Missing payment request.")
|
||||
|
||||
try:
|
||||
invoice = bolt11.decode(pr)
|
||||
except bolt11.Bolt11Exception:
|
||||
return {"status": "ERROR", "reason": "Failed to decode payment request"}
|
||||
|
||||
return LnurlErrorResponse(reason="Failed to decode payment request.")
|
||||
if not invoice.amount_msat:
|
||||
return LnurlErrorResponse(reason="Invoice has no amount.")
|
||||
card = await get_card(hit.card_id)
|
||||
assert card
|
||||
assert invoice.amount_msat, "Invoice amount is missing"
|
||||
if not card:
|
||||
return LnurlErrorResponse(reason="Card not found.")
|
||||
hit = await spend_hit(card_id=hit.id, amount=int(invoice.amount_msat / 1000))
|
||||
assert hit
|
||||
if not hit:
|
||||
return LnurlErrorResponse(reason="Failed to update hit as spent.")
|
||||
try:
|
||||
await pay_invoice(
|
||||
wallet_id=card.wallet,
|
||||
|
|
@ -138,9 +149,9 @@ async def lnurl_callback(
|
|||
max_sat=int(card.tx_limit),
|
||||
extra={"tag": "boltcards", "hit": hit.id},
|
||||
)
|
||||
return {"status": "OK"}
|
||||
return LnurlSuccessResponse()
|
||||
except Exception as exc:
|
||||
return {"status": "ERROR", "reason": f"Payment failed - {exc}"}
|
||||
return LnurlErrorResponse(reason=f"Payment failed - {exc}")
|
||||
|
||||
|
||||
# /boltcards/api/v1/auth?a=00000000000000000000000000000000
|
||||
|
|
@ -179,44 +190,68 @@ async def api_auth(a, request: Request):
|
|||
return response
|
||||
|
||||
|
||||
###############LNURLPAY REFUNDS#################
|
||||
# /boltcards/api/v1/auth?a=00000000000000000000000000000000
|
||||
@boltcards_lnurl_router.post("/api/v1/auth")
|
||||
async def api_auth_post(a: str, request: Request, data: UIDPost, wipe: bool = False):
|
||||
card = None
|
||||
if wipe:
|
||||
card = await get_card_by_otp(a)
|
||||
else:
|
||||
if not data.UID:
|
||||
raise HTTPException(
|
||||
detail="Missing UID.", status_code=HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
|
||||
|
||||
@boltcards_lnurl_router.get(
|
||||
"/api/v1/lnurlp/{hit_id}",
|
||||
response_class=HTMLResponse,
|
||||
name="boltcards.lnurlp_response",
|
||||
)
|
||||
async def lnurlp_response(req: Request, hit_id: str):
|
||||
hit = await get_hit(hit_id)
|
||||
assert hit
|
||||
card = await get_card(hit.card_id)
|
||||
assert card
|
||||
if not hit:
|
||||
return {"status": "ERROR", "reason": "LNURL-pay record not found."}
|
||||
if not card.enable:
|
||||
return {"status": "ERROR", "reason": "Card is disabled."}
|
||||
pay_response = {
|
||||
"tag": "payRequest",
|
||||
"callback": str(req.url_for("boltcards.lnurlp_callback", hit_id=hit_id)),
|
||||
"metadata": LnurlPayMetadata(json.dumps([["text/plain", "Refund"]])),
|
||||
"minSendable": 1 * 1000,
|
||||
"maxSendable": int(card.tx_limit) * 1000,
|
||||
card = await get_card_by_uid(data.UID)
|
||||
if not card:
|
||||
raise HTTPException(
|
||||
detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND
|
||||
)
|
||||
new_otp = secrets.token_hex(16)
|
||||
await update_card_otp(new_otp, card.id)
|
||||
lnurlw_base = (
|
||||
f"{urlparse(str(request.url)).netloc}/boltcards/api/v1/scan/{card.external_id}"
|
||||
)
|
||||
response = {
|
||||
"CARD_NAME": card.card_name,
|
||||
"ID": str(1),
|
||||
"K0": card.k0,
|
||||
"K1": card.k1,
|
||||
"K2": card.k2,
|
||||
"K3": card.k1,
|
||||
"K4": card.k2,
|
||||
"LNURLW_BASE": "LNURLW://" + lnurlw_base,
|
||||
"LNURLW": "LNURLW://" + lnurlw_base,
|
||||
"PROTOCOL_NAME": "NEW_BOLT_CARD_RESPONSE",
|
||||
"PROTOCOL_VERSION": str(1),
|
||||
}
|
||||
return json.dumps(pay_response)
|
||||
if wipe:
|
||||
response["action"] = "wipe"
|
||||
return response
|
||||
|
||||
|
||||
###############LNURLPAY REFUNDS#################
|
||||
@boltcards_lnurl_router.get(
|
||||
"/api/v1/lnurlp/cb/{hit_id}",
|
||||
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)
|
||||
) -> LnurlPayActionResponse | LnurlErrorResponse:
|
||||
hit = await get_hit(hit_id)
|
||||
assert hit
|
||||
card = await get_card(hit.card_id)
|
||||
assert card
|
||||
if not hit:
|
||||
return {"status": "ERROR", "reason": "LNURL-pay record not found."}
|
||||
return LnurlErrorResponse(reason="LNURL-pay record not found.")
|
||||
card = await get_card(hit.card_id)
|
||||
if not card:
|
||||
return LnurlErrorResponse(reason="Card not found.")
|
||||
if not card.enable:
|
||||
return LnurlErrorResponse(reason="Card is disabled.")
|
||||
if not amount:
|
||||
return LnurlErrorResponse(reason="Missing amount.")
|
||||
if int(amount) < 1000:
|
||||
return LnurlErrorResponse(reason="Amount too low.")
|
||||
if int(amount) > int(card.tx_limit) * 1000:
|
||||
return LnurlErrorResponse(reason="Amount too high.")
|
||||
|
||||
payment = await create_invoice(
|
||||
wallet_id=card.wallet,
|
||||
|
|
@ -227,5 +262,135 @@ async def lnurlp_callback(hit_id: str, amount: str = Query(None)):
|
|||
).encode(),
|
||||
extra={"refund": hit_id},
|
||||
)
|
||||
action = MessageAction(message=Max144Str("Refunded!"))
|
||||
invoice = parse_obj_as(LightningInvoice, payment.bolt11)
|
||||
return LnurlPayActionResponse(pr=invoice, successAction=action)
|
||||
|
||||
return {"pr": payment.bolt11, "routes": []}
|
||||
|
||||
@boltcards_lnurl_router.get(
|
||||
"/api/v1/lnurlp/{hit_id}",
|
||||
name="boltcards.lnurlp_response",
|
||||
)
|
||||
async def lnurlp_response(
|
||||
req: Request, hit_id: str
|
||||
) -> LnurlPayResponse | LnurlErrorResponse:
|
||||
hit = await get_hit(hit_id)
|
||||
if not hit:
|
||||
return LnurlErrorResponse(reason="LNURL-pay hit not found.")
|
||||
card = await get_card(hit.card_id)
|
||||
if not card:
|
||||
return LnurlErrorResponse(reason="Card not found.")
|
||||
if not card.enable:
|
||||
return LnurlErrorResponse(reason="Card is disabled.")
|
||||
callback_url = parse_obj_as(
|
||||
CallbackUrl, str(req.url_for("boltcards.lnurlp_callback", hit_id=hit_id))
|
||||
)
|
||||
return LnurlPayResponse(
|
||||
callback=callback_url,
|
||||
minSendable=MilliSatoshi(1000),
|
||||
maxSendable=MilliSatoshi(int(card.tx_limit) * 1000),
|
||||
metadata=LnurlPayMetadata(json.dumps([["text/plain", "Refund"]])),
|
||||
)
|
||||
|
||||
|
||||
###############LNURLPAY TAP-TO-RECEIVE (top-up)#################
|
||||
# Deposit sats to a card's wallet by tapping the card — the receive/cash-in
|
||||
# counterpart of the /scan withdraw. A Bolt Card only emits its lnurlw (a spend
|
||||
# voucher), so the tap is used purely as an authenticated identity: the same
|
||||
# SUN p/c that /scan verifies proves card possession, and we return an
|
||||
# lnurl-PAY response (LUD-06) for the card's own wallet instead of a withdraw
|
||||
# voucher. The single-use `hit` acts as the bearer token for the callback,
|
||||
# mirroring how `k1` bridges the two withdraw steps. Unlike the LUD-19 refund
|
||||
# lnurlp (keyed by a prior scan's hit), this is reachable directly by a tap.
|
||||
|
||||
# The pay metadata MUST be byte-identical between the response below and the
|
||||
# callback's unhashed_description, or the invoice's description_hash won't match
|
||||
# (LUD-06). Keep it static.
|
||||
_TOPUP_METADATA = json.dumps([["text/plain", "Bolt Card top-up"]])
|
||||
|
||||
|
||||
# /boltcards/api/v1/pay/{external_id}?p=<32-hex>&c=<16-hex> (mirrors /scan)
|
||||
@boltcards_lnurl_router.get(
|
||||
"/api/v1/pay/{external_id}",
|
||||
name="boltcards.pay_response",
|
||||
)
|
||||
async def api_pay(
|
||||
p, c, request: Request, external_id: str
|
||||
) -> LnurlPayResponse | LnurlErrorResponse:
|
||||
# Mirror /scan's SUN verification exactly (some wallets lowercase p/c).
|
||||
p = p.upper()
|
||||
c = c.upper()
|
||||
card = await get_card_by_external_id(external_id)
|
||||
if not card:
|
||||
return LnurlErrorResponse(reason="Card not found.")
|
||||
if not card.enable:
|
||||
return LnurlErrorResponse(reason="Card is disabled.")
|
||||
try:
|
||||
card_uid, counter = decrypt_sun(bytes.fromhex(p), bytes.fromhex(card.k1))
|
||||
if card.uid.upper() != card_uid.hex().upper():
|
||||
return LnurlErrorResponse(reason="Card UID mis-match.")
|
||||
if c != get_sun_mac(card_uid, counter, bytes.fromhex(card.k2)).hex().upper():
|
||||
return LnurlErrorResponse(reason="CMAC does not check.")
|
||||
except Exception:
|
||||
return LnurlErrorResponse(reason="Error decrypting card.")
|
||||
|
||||
ctr_int = int.from_bytes(counter, "little")
|
||||
if ctr_int <= card.counter:
|
||||
return LnurlErrorResponse(reason="This link is already used.")
|
||||
await update_card_counter(ctr_int, card.id)
|
||||
|
||||
# Record the tap; the hit id is the single-use bearer for the callback.
|
||||
# (No daily-limit check here — that gates spending, and this only deposits.)
|
||||
if not request.client:
|
||||
return LnurlErrorResponse(reason="Cannot get client info.")
|
||||
ip = request.client.host
|
||||
if "x-real-ip" in request.headers:
|
||||
ip = request.headers["x-real-ip"]
|
||||
elif "x-forwarded-for" in request.headers:
|
||||
ip = request.headers["x-forwarded-for"]
|
||||
agent = request.headers["user-agent"] if "user-agent" in request.headers else ""
|
||||
hit = await create_hit(card.id, ip, agent, card.counter, ctr_int)
|
||||
|
||||
callback_url = parse_obj_as(
|
||||
CallbackUrl, str(request.url_for("boltcards.pay_callback", hit_id=hit.id))
|
||||
)
|
||||
return LnurlPayResponse(
|
||||
callback=callback_url,
|
||||
minSendable=MilliSatoshi(1000),
|
||||
maxSendable=MilliSatoshi(int(card.tx_limit) * 1000),
|
||||
metadata=LnurlPayMetadata(_TOPUP_METADATA),
|
||||
)
|
||||
|
||||
|
||||
@boltcards_lnurl_router.get(
|
||||
"/api/v1/pay/cb/{hit_id}",
|
||||
name="boltcards.pay_callback",
|
||||
)
|
||||
async def pay_callback(
|
||||
hit_id: str, amount: str = Query(None)
|
||||
) -> LnurlPayActionResponse | LnurlErrorResponse:
|
||||
hit = await get_hit(hit_id)
|
||||
if not hit:
|
||||
return LnurlErrorResponse(reason="LNURL-pay record not found.")
|
||||
card = await get_card(hit.card_id)
|
||||
if not card:
|
||||
return LnurlErrorResponse(reason="Card not found.")
|
||||
if not card.enable:
|
||||
return LnurlErrorResponse(reason="Card is disabled.")
|
||||
if not amount:
|
||||
return LnurlErrorResponse(reason="Missing amount.")
|
||||
if int(amount) < 1000:
|
||||
return LnurlErrorResponse(reason="Amount too low.")
|
||||
if int(amount) > int(card.tx_limit) * 1000:
|
||||
return LnurlErrorResponse(reason="Amount too high.")
|
||||
|
||||
payment = await create_invoice(
|
||||
wallet_id=card.wallet,
|
||||
amount=int(int(amount) / 1000),
|
||||
memo=f"Top-up {card.card_name}",
|
||||
unhashed_description=LnurlPayMetadata(_TOPUP_METADATA).encode(),
|
||||
extra={"tag": "boltcards", "topup": hit_id},
|
||||
)
|
||||
action = MessageAction(message=Max144Str("Topped up!"))
|
||||
invoice = parse_obj_as(LightningInvoice, payment.bolt11)
|
||||
return LnurlPayActionResponse(pr=invoice, successAction=action)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue