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
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:

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
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__ = [
"db",
"boltcards_ext",
"boltcards_static_files",
"boltcards_start",
"boltcards_static_files",
"boltcards_stop",
"db",
]

View file

@ -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
View file

@ -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},

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 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

File diff suppressed because it is too large Load diff

View file

@ -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

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: {
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,

View file

@ -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>

View file

@ -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>

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 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]

View file

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

View file

@ -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_lnurl_router.get(
"/api/v1/lnurlp/{hit_id}",
response_class=HTMLResponse,
name="boltcards.lnurlp_response",
# /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
)
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)