Compare commits

...

10 commits

Author SHA1 Message Date
Patrick Mulligan
f1bb3a1813 feat(lnurl): tap-to-receive (top-up) endpoint
Some checks failed
lint.yml / feat(lnurl): tap-to-receive (top-up) endpoint (push) Failing after 0s
Add a deposit counterpart to /scan so a Bolt Card can be tapped to
RECEIVE sats, not only spend. The card only emits its lnurlw (a spend
voucher), so the tap is used as an authenticated identity: the same SUN
p/c that /scan verifies proves possession, and we return an lnurl-PAY
(LUD-06) response for the card's own wallet.

- GET /api/v1/pay/{external_id}?p=&c= — SUN-verified, returns a
  payRequest; the single-use hit is the callback bearer (like k1 for
  withdraw). No daily-limit check (that gates spending); per-deposit max
  is tx_limit.
- GET /api/v1/pay/cb/{hit_id}?amount= — invoices the card wallet.
- Static pay metadata so the LUD-06 description_hash matches.
- Distinct from the LUD-19 refund lnurlp (keyed by a prior scan's hit);
  this is reachable directly by a tap via external_id.
- config.json → 1.1.0-aio.1 (aiolabs fork); README documents the endpoint.

Consumed by aiolabs/bitspire #84 (Bolt Card tap-to-receive on cash-in).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 00:48:02 +02:00
Tiago Vasconcelos
407506ad9f
fix: models for deeplink (#63)
* fix models for deeplink

on wipe data is empty

* fix optional str
2025-12-16 10:57:11 +00:00
dni ⚡
f08060d0f4
feat: use lnurl lib (#60)
* feat: use lnurl lib
use new lnurl lib from v1.3.0
* mobile display page fix
---------

Co-authored-by: Tiago Vasconcelos <talvasconcelos@gmail.com>
2025-09-09 10:53:56 +02:00
dni ⚡
7bdf7db2c4
feat: use uv (#59)
* feat: use uv
* fix make
2025-09-04 07:57:33 +02:00
dni ⚡
c060f10e65
chore: bump lnbits version 2025-07-22 10:26:31 +02:00
Tiago Vasconcelos
b86263cefa
feat: deeplinks support (#58)
* feat: deeplinks support

* fix: move href logic into compute

* chore: fix linting
2025-06-16 14:17:08 +01:00
iwarp
74e746c9e9
Fix boolean parsing in enable_card endpoint (#54) 2025-05-05 08:27:51 +02:00
Tiago Vasconcelos
79c74708e1
fix: wrong typing in DB columns (#51)
* fix: wrong typing in DB columns
* fix: update
* fix: migrations
* chore: lint
2025-04-15 12:29:34 +01:00
Tiago Vasconcelos
3edd81d85e
fix: only show stats if authenticated user (#50)
* fix: only show stats if authenticated user
* chore: lint
2025-04-15 12:28:58 +01:00
arcbtc
8da3127e29 Tweak for fakewallet compatibility 2025-02-21 16:40:31 +00:00
16 changed files with 2749 additions and 2776 deletions

View file

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

View file

@ -97,3 +97,33 @@ Then fill up the card parameters in the extension. Card Auth key (K0) can be fil
- Scan with compatible Wallet - 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. 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`.

View file

@ -40,9 +40,9 @@ def boltcards_start():
__all__ = [ __all__ = [
"db",
"boltcards_ext", "boltcards_ext",
"boltcards_static_files",
"boltcards_start", "boltcards_start",
"boltcards_static_files",
"boltcards_stop", "boltcards_stop",
"db",
] ]

View file

@ -2,8 +2,14 @@
"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": "1.0.0", "version": "1.1.0-aio.1",
"min_lnbits_version": "1.3.0",
"contributors": [ "contributors": [
{
"name": "aiolabs",
"uri": "https://git.atitlan.io/aiolabs",
"role": "Fork maintainer (tap-to-receive)"
},
{ {
"name": "dni", "name": "dni",
"uri": "https://github.com/dni", "uri": "https://github.com/dni",

21
crud.py
View file

@ -1,6 +1,5 @@
import secrets import secrets
from datetime import datetime from datetime import datetime
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
@ -57,11 +56,7 @@ async def create_card(data: CreateCardData, wallet_id: str) -> Card:
return card return card
async def update_card(card_id: str, data: CreateCardData) -> Card: async def update_card(card: Card) -> Card:
card = Card(
id=card_id,
**data.dict(),
)
await db.update("boltcards.cards", card) await db.update("boltcards.cards", card)
return 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( return await db.fetchone(
"SELECT * FROM boltcards.cards WHERE id = :id", "SELECT * FROM boltcards.cards WHERE id = :id",
{"id": card_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( return await db.fetchone(
"SELECT * FROM boltcards.cards WHERE uid = :uid", "SELECT * FROM boltcards.cards WHERE uid = :uid",
{"uid": card_uid.upper()}, {"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( return await db.fetchone(
"SELECT * FROM boltcards.cards WHERE external_id = :ext_id", "SELECT * FROM boltcards.cards WHERE external_id = :ext_id",
{"ext_id": external_id.lower()}, {"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( return await db.fetchone(
"SELECT * FROM boltcards.cards WHERE otp = :otp", "SELECT * FROM boltcards.cards WHERE otp = :otp",
{"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( await db.execute(
"UPDATE boltcards.cards SET enable = :enable WHERE id = :id", "UPDATE boltcards.cards SET enable = :enable WHERE id = :id",
{"enable": enable, "id": card_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( return await db.fetchone(
"SELECT * FROM boltcards.hits WHERE id = :id", "SELECT * FROM boltcards.hits WHERE id = :id",
{"id": hit_id}, {"id": hit_id},
@ -240,7 +235,7 @@ async def create_refund(hit_id, refund_amount) -> Refund:
return 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( return await db.fetchone(
"SELECT * FROM boltcards.refunds WHERE id = :id", "SELECT * FROM boltcards.refunds WHERE id = :id",
{"id": refund_id}, {"id": refund_id},

View file

@ -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;")

View file

@ -5,7 +5,7 @@ from fastapi import Query, Request
from lnurl import Lnurl from lnurl import Lnurl
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 pydantic import BaseModel from pydantic import BaseModel, Field
ZERO_KEY = "00000000000000000000000000000000" ZERO_KEY = "00000000000000000000000000000000"
@ -17,10 +17,8 @@ class Card(BaseModel):
uid: str uid: str
external_id: str external_id: str
counter: int counter: int
# TODO: database column is TEXT should be INT tx_limit: int
tx_limit: str daily_limit: int
# TODO: database column is TEXT should be INT
daily_limit: str
enable: bool enable: bool
k0: str k0: str
k1: str k1: str
@ -73,3 +71,8 @@ class Refund(BaseModel):
hit_id: str hit_id: str
refund_amount: int refund_amount: int
time: datetime 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

File diff suppressed because it is too large Load diff

View file

@ -1,39 +1,34 @@
[tool.poetry] [project]
name = "lnbits-boltcards" name = "lnbits-boltcards"
version = "0.0.0" version = "0.0.0"
requires-python = ">=3.10,<3.13"
description = "LNbits, free and open-source Lightning wallet and accounts system." 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] [tool.poetry]
python = "^3.10 | ^3.9" package-mode = false
lnbits = {version = "*", allow-prereleases = true}
[tool.poetry.group.dev.dependencies] [tool.uv]
black = "^24.3.0" dev-dependencies = [
pytest-asyncio = "^0.21.0" "black",
pytest = "^7.3.2" "pytest-asyncio",
mypy = "^1.5.1" "pytest",
pre-commit = "^3.2.2" "mypy",
ruff = "^0.6.3" "pre-commit",
"ruff",
[build-system] "pytest-md",
requires = ["poetry-core>=1.0.0"] ]
build-backend = "poetry.core.masonry.api"
[tool.mypy] [tool.mypy]
exclude = "(nostr/*)" plugins = ["pydantic.mypy"]
[[tool.mypy.overrides]]
module = [ [tool.pydantic-mypy]
"lnbits.*", init_forbid_extra = true
"lnurl.*", init_typed = true
"loguru.*", warn_required_dynamic_aliases = true
"fastapi.*", warn_untyped_fields = true
"pydantic.*",
"pyqrcode.*",
"shortuuid.*",
"httpx.*",
]
ignore_missing_imports = "True"
[tool.pytest.ini_options] [tool.pytest.ini_options]
log_cli = false log_cli = false

View file

@ -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: { methods: {
readNfcTag() { readNfcTag() {
const ndef = new NDEFReader() const ndef = new NDEFReader()
@ -229,6 +238,9 @@ window.app = Vue.createApp({
this.qrCodeDialog.data = { this.qrCodeDialog.data = {
id: card.id, id: card.id,
link: window.location.origin + '/boltcards/api/v1/auth?a=' + card.otp, 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, name: card.card_name,
uid: card.uid, uid: card.uid,
external_id: card.external_id, external_id: card.external_id,

View file

@ -56,12 +56,16 @@ card.card_name }{% endblock %} {% block page %}
:color="hit.spent > 0 ? 'green' : 'grey'" :color="hit.spent > 0 ? 'green' : 'grey'"
/> />
</q-item-section> </q-item-section>
<q-item-section> <q-item-section class="overflow-hidden">
<q-item-label <q-item-label
>ID: ${hit.id} ${refunds.some(r => r.hit_id == hit.id) ? >ID: ${hit.id} ${refunds.some(r => r.hit_id == hit.id) ?
'(Refunded)' : null}</q-item-label '(Refunded)' : null}
> <q-tooltip><span v-text="hit.id"></span></q-tooltip>
<q-item-label caption lines="1">IP: ${hit.ip}</q-item-label> </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>
<q-item-section side top> <q-item-section side top>

View file

@ -254,7 +254,7 @@
emit-value emit-value
v-model="cardDialog.data.tx_limit" v-model="cardDialog.data.tx_limit"
type="number" type="number"
label="Max transaction (sats)" label="Max transaction ({{LNBITS_DENOMINATION}})"
class="q-pr-sm" class="q-pr-sm"
></q-input> ></q-input>
</div> </div>
@ -265,7 +265,7 @@
emit-value emit-value
v-model="cardDialog.data.daily_limit" v-model="cardDialog.data.daily_limit"
type="number" type="number"
label="Daily limit (sats)" label="Daily limit ({{LNBITS_DENOMINATION}})"
></q-input> ></q-input>
</div> </div>
</div> </div>
@ -381,7 +381,6 @@
<div class="col q-mt-lg text-center"> <div class="col q-mt-lg text-center">
<lnbits-qrcode <lnbits-qrcode
:value="qrCodeDialog.data.link" :value="qrCodeDialog.data.link"
class="rounded-borders"
v-show="!qrCodeDialog.wipe" v-show="!qrCodeDialog.wipe"
></lnbits-qrcode> ></lnbits-qrcode>
<p class="text-center" v-show="!qrCodeDialog.wipe"> <p class="text-center" v-show="!qrCodeDialog.wipe">
@ -396,7 +395,6 @@
</p> </p>
<lnbits-qrcode <lnbits-qrcode
:value="qrCodeDialog.data_wipe" :value="qrCodeDialog.data_wipe"
class="rounded-borders"
v-show="qrCodeDialog.wipe" v-show="qrCodeDialog.wipe"
></lnbits-qrcode> ></lnbits-qrcode>
<p class="text-center" v-show="qrCodeDialog.wipe"> <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-tooltip>Click to copy, then paste to NFC Card Creator</q-tooltip>
</q-btn> </q-btn>
<q-btn <q-btn
class="q-ml-sm"
unelevated unelevated
outline outline
color="red" color="red"
@ -473,6 +472,17 @@
> >
<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>
<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"> <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>

2276
uv.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from lnbits.core.crud import get_wallet from lnbits.core.crud import get_wallet
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, optional_user_id
from lnbits.helpers import template_renderer from lnbits.helpers import template_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
@ -24,15 +24,26 @@ async def index(request: Request, user: User = Depends(check_user_exists)):
@boltcards_generic_router.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, 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) card = await get_card_by_external_id(card_id)
if not card: if not card:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Card does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Card does not exist."
) )
wallet = await get_wallet(card.wallet) wallet = await get_wallet(card.wallet)
wallet_balance = 0 wallet_balance = 0
if wallet: if wallet:
if wallet.user != user_id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="Card does not belong to user."
)
wallet_balance = wallet.balance wallet_balance = wallet.balance
hits = await get_hits([card.id]) hits = await get_hits([card.id])
hits_json = [hit.json() for hit in hits] hits_json = [hit.json() for hit in hits]

View file

@ -71,7 +71,6 @@ async def api_card_update(
card_id: str, card_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key), wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Card: ) -> Card:
card = await get_card(card_id) card = await get_card(card_id)
if not card: if not card:
raise HTTPException( raise HTTPException(
@ -85,8 +84,9 @@ async def api_card_update(
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,
) )
card = await update_card(card_id, **data.dict()) for key, value in data.dict().items():
assert card, "update_card should always return a card" setattr(card, key, value)
await update_card(card)
return card return card
@ -106,7 +106,11 @@ async def api_card_create(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
) )
card = await create_card(wallet_id=wallet.wallet.id, data=data) 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 return card
@ -114,22 +118,28 @@ async def api_card_create(
"/api/v1/cards/enable/{card_id}/{enable}", status_code=HTTPStatus.OK "/api/v1/cards/enable/{card_id}/{enable}", status_code=HTTPStatus.OK
) )
async def enable_card( async def enable_card(
card_id, card_id: str,
enable, enable: bool,
wallet: WalletTypeInfo = Depends(require_admin_key), wallet: WalletTypeInfo = Depends(require_admin_key),
): ) -> Card:
card = await get_card(card_id) card = await get_card(card_id)
if not card: if not 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, card_id=card_id) card = await enable_disable_card(enable=enable, card_id=card_id)
assert card if not card:
return card.dict() raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Could not update card.",
)
return card
@boltcards_api_router.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)
) -> None:
card = await get_card(card_id) card = await get_card(card_id)
if not card: 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) raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN)
await delete_card(card_id) await delete_card(card_id)
return "", HTTPStatus.NO_CONTENT
@boltcards_api_router.get("/api/v1/hits") @boltcards_api_router.get("/api/v1/hits")

View file

@ -6,22 +6,34 @@ from urllib.parse import urlparse
import bolt11 import bolt11
from fastapi import APIRouter, HTTPException, Query, Request from fastapi import APIRouter, HTTPException, Query, Request
from lnbits.core.services import create_invoice, pay_invoice from lnbits.core.services import create_invoice, pay_invoice
from lnurl import encode as lnurl_encode from lnurl import (
from lnurl.types import LnurlPayMetadata CallbackUrl,
from loguru import logger LightningInvoice,
from starlette.responses import HTMLResponse LnurlErrorResponse,
LnurlPayActionResponse,
LnurlPayMetadata,
LnurlPayResponse,
LnurlSuccessResponse,
LnurlWithdrawResponse,
Max144Str,
MessageAction,
MilliSatoshi,
)
from pydantic import parse_obj_as
from .crud import ( from .crud import (
create_hit, create_hit,
get_card, get_card,
get_card_by_external_id, get_card_by_external_id,
get_card_by_otp, get_card_by_otp,
get_card_by_uid,
get_hit, get_hit,
get_hits_today, get_hits_today,
spend_hit, spend_hit,
update_card_counter, update_card_counter,
update_card_otp, update_card_otp,
) )
from .models import UIDPost
from .nxp424 import decrypt_sun, get_sun_mac from .nxp424 import decrypt_sun, get_sun_mac
boltcards_lnurl_router = APIRouter() boltcards_lnurl_router = APIRouter()
@ -29,7 +41,9 @@ boltcards_lnurl_router = APIRouter()
# /boltcards/api/v1/scan?p=00000000000000000000000000000000&c=0000000000000000 # /boltcards/api/v1/scan?p=00000000000000000000000000000000&c=0000000000000000
@boltcards_lnurl_router.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
) -> LnurlWithdrawResponse | LnurlErrorResponse:
# some wallets send everything as lower case, no bueno # some wallets send everything as lower case, no bueno
p = p.upper() p = p.upper()
c = c.upper() c = c.upper()
@ -37,27 +51,28 @@ async def api_scan(p, c, request: Request, external_id: str):
counter = b"" counter = b""
card = await get_card_by_external_id(external_id) card = await get_card_by_external_id(external_id)
if not card: if not card:
return {"status": "ERROR", "reason": "No card."} return LnurlErrorResponse(reason="Card not found.")
if not card.enable: if not card.enable:
return {"status": "ERROR", "reason": "Card is disabled."} return LnurlErrorResponse(reason="Card is disabled.")
try: try:
card_uid, counter = decrypt_sun(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 LnurlErrorResponse(reason="Card UID mis-match.")
if c != get_sun_mac(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 LnurlErrorResponse(reason="CMAC does not check.")
except Exception: except Exception:
return {"status": "ERROR", "reason": "Error decrypting card."} return LnurlErrorResponse(reason="Error decrypting card.")
ctr_int = int.from_bytes(counter, "little") ctr_int = int.from_bytes(counter, "little")
if ctr_int <= card.counter: 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) await update_card_counter(ctr_int, card.id)
# gathering some info for hit record # gathering some info for hit record
assert request.client if not request.client:
return LnurlErrorResponse(reason="Cannot get client info.")
ip = request.client.host ip = request.client.host
if "x-real-ip" in request.headers: if "x-real-ip" in request.headers:
ip = request.headers["x-real-ip"] 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: for hit in todays_hits:
hits_amount += hit.amount hits_amount += hit.amount
if hits_amount > int(card.daily_limit): 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) 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 # create a lud17 lnurlp to support lud19, add payLink field of the withdrawRequest
lnurlpay_nonbech32_lud17 = lnurlpay_raw.replace("https://", "lnurlp://").replace( lnurlpay_url = str(request.url_for("boltcards.lnurlp_response", hit_id=hit.id))
"http://", "lnurlp://" 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( @boltcards_lnurl_router.get(
@ -103,34 +116,32 @@ async def lnurl_callback(
hit_id: str, hit_id: str,
k1: str = Query(None), k1: str = Query(None),
pr: str = Query(None), pr: str = Query(None),
): ) -> LnurlErrorResponse | LnurlSuccessResponse:
# 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 LnurlErrorResponse(reason="Missing K1 token")
if k1 != hit_id:
hit = await get_hit(k1) return LnurlErrorResponse(reason="K1 token does not match.")
hit = await get_hit(hit_id)
if not hit: if not hit:
return { return LnurlErrorResponse(reason="LNURL-withdraw record not found.")
"status": "ERROR",
"reason": "Record not found for this charge (bad k1)",
}
if hit.spent: if hit.spent:
return {"status": "ERROR", "reason": "Payment already claimed"} return LnurlErrorResponse(reason="Payment already claimed.")
if not pr: if not pr:
return {"status": "ERROR", "reason": "Missing payment request"} return LnurlErrorResponse(reason="Missing payment request.")
try: try:
invoice = bolt11.decode(pr) invoice = bolt11.decode(pr)
except bolt11.Bolt11Exception: 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) card = await get_card(hit.card_id)
assert card if not card:
assert invoice.amount_msat, "Invoice amount is missing" return LnurlErrorResponse(reason="Card not found.")
hit = await spend_hit(card_id=hit.id, amount=int(invoice.amount_msat / 1000)) 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: try:
await pay_invoice( await pay_invoice(
wallet_id=card.wallet, wallet_id=card.wallet,
@ -138,9 +149,9 @@ async def lnurl_callback(
max_sat=int(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 LnurlSuccessResponse()
except Exception as exc: 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 # /boltcards/api/v1/auth?a=00000000000000000000000000000000
@ -179,44 +190,68 @@ async def api_auth(a, request: Request):
return response 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):
@boltcards_lnurl_router.get( card = None
"/api/v1/lnurlp/{hit_id}", if wipe:
response_class=HTMLResponse, card = await get_card_by_otp(a)
name="boltcards.lnurlp_response", else:
if not data.UID:
raise HTTPException(
detail="Missing UID.", status_code=HTTPStatus.BAD_REQUEST
) )
async def lnurlp_response(req: Request, hit_id: str):
hit = await get_hit(hit_id) card = await get_card_by_uid(data.UID)
assert hit if not card:
card = await get_card(hit.card_id) raise HTTPException(
assert card detail="Card does not exist.", status_code=HTTPStatus.NOT_FOUND
if not hit: )
return {"status": "ERROR", "reason": "LNURL-pay record not found."} new_otp = secrets.token_hex(16)
if not card.enable: await update_card_otp(new_otp, card.id)
return {"status": "ERROR", "reason": "Card is disabled."} lnurlw_base = (
pay_response = { f"{urlparse(str(request.url)).netloc}/boltcards/api/v1/scan/{card.external_id}"
"tag": "payRequest", )
"callback": str(req.url_for("boltcards.lnurlp_callback", hit_id=hit_id)), response = {
"metadata": LnurlPayMetadata(json.dumps([["text/plain", "Refund"]])), "CARD_NAME": card.card_name,
"minSendable": 1 * 1000, "ID": str(1),
"maxSendable": int(card.tx_limit) * 1000, "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( @boltcards_lnurl_router.get(
"/api/v1/lnurlp/cb/{hit_id}", "/api/v1/lnurlp/cb/{hit_id}",
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)
) -> LnurlPayActionResponse | LnurlErrorResponse:
hit = await get_hit(hit_id) hit = await get_hit(hit_id)
assert hit
card = await get_card(hit.card_id)
assert card
if not hit: 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( payment = await create_invoice(
wallet_id=card.wallet, wallet_id=card.wallet,
@ -227,5 +262,135 @@ async def lnurlp_callback(hit_id: str, amount: str = Query(None)):
).encode(), ).encode(),
extra={"refund": hit_id}, 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)