Compare commits
No commits in common. "f1bb3a1813b497fdaad43c4230865b1b1854e43a" and "8fe5bebfadec4e499b99c385ccb9c57ccc2101c1" have entirely different histories.
f1bb3a1813
...
8fe5bebfad
16 changed files with 2776 additions and 2749 deletions
24
Makefile
24
Makefile
|
|
@ -5,27 +5,27 @@ format: prettier black ruff
|
||||||
check: mypy pyright checkblack checkruff checkprettier
|
check: mypy pyright checkblack checkruff checkprettier
|
||||||
|
|
||||||
prettier:
|
prettier:
|
||||||
uv run ./node_modules/.bin/prettier --write .
|
poetry run ./node_modules/.bin/prettier --write .
|
||||||
pyright:
|
pyright:
|
||||||
uv run ./node_modules/.bin/pyright
|
poetry run ./node_modules/.bin/pyright
|
||||||
|
|
||||||
mypy:
|
mypy:
|
||||||
uv run mypy .
|
poetry run mypy .
|
||||||
|
|
||||||
black:
|
black:
|
||||||
uv run black .
|
poetry run black .
|
||||||
|
|
||||||
ruff:
|
ruff:
|
||||||
uv run ruff check . --fix
|
poetry run ruff check . --fix
|
||||||
|
|
||||||
checkruff:
|
checkruff:
|
||||||
uv run ruff check .
|
poetry run ruff check .
|
||||||
|
|
||||||
checkprettier:
|
checkprettier:
|
||||||
uv run ./node_modules/.bin/prettier --check .
|
poetry run ./node_modules/.bin/prettier --check .
|
||||||
|
|
||||||
checkblack:
|
checkblack:
|
||||||
uv run black --check .
|
poetry run black --check .
|
||||||
|
|
||||||
checkeditorconfig:
|
checkeditorconfig:
|
||||||
editorconfig-checker
|
editorconfig-checker
|
||||||
|
|
@ -33,14 +33,14 @@ checkeditorconfig:
|
||||||
test:
|
test:
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
DEBUG=true \
|
DEBUG=true \
|
||||||
uv run pytest
|
poetry 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 uv run pre-commit uninstall"
|
@echo "Uninstall the hook with poetry run pre-commit uninstall"
|
||||||
uv run pre-commit install
|
poetry run pre-commit install
|
||||||
|
|
||||||
pre-commit:
|
pre-commit:
|
||||||
uv run pre-commit run --all-files
|
poetry run pre-commit run --all-files
|
||||||
|
|
||||||
|
|
||||||
checkbundle:
|
checkbundle:
|
||||||
|
|
|
||||||
30
README.md
30
README.md
|
|
@ -97,33 +97,3 @@ 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`.
|
|
||||||
|
|
|
||||||
|
|
@ -40,9 +40,9 @@ def boltcards_start():
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"boltcards_ext",
|
|
||||||
"boltcards_start",
|
|
||||||
"boltcards_static_files",
|
|
||||||
"boltcards_stop",
|
|
||||||
"db",
|
"db",
|
||||||
|
"boltcards_ext",
|
||||||
|
"boltcards_static_files",
|
||||||
|
"boltcards_start",
|
||||||
|
"boltcards_stop",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,8 @@
|
||||||
"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",
|
||||||
"version": "1.1.0-aio.1",
|
"min_lnbits_version": "1.0.0",
|
||||||
"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
21
crud.py
|
|
@ -1,5 +1,6 @@
|
||||||
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
|
||||||
|
|
@ -56,7 +57,11 @@ async def create_card(data: CreateCardData, wallet_id: str) -> Card:
|
||||||
return card
|
return card
|
||||||
|
|
||||||
|
|
||||||
async def update_card(card: Card) -> Card:
|
async def update_card(card_id: str, data: CreateCardData) -> Card:
|
||||||
|
card = Card(
|
||||||
|
id=card_id,
|
||||||
|
**data.dict(),
|
||||||
|
)
|
||||||
await db.update("boltcards.cards", card)
|
await db.update("boltcards.cards", card)
|
||||||
return card
|
return card
|
||||||
|
|
||||||
|
|
@ -71,7 +76,7 @@ async def get_cards(wallet_ids: list[str]) -> list[Card]:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_card(card_id: str) -> Card | None:
|
async def get_card(card_id: str) -> Optional[Card]:
|
||||||
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},
|
||||||
|
|
@ -79,7 +84,7 @@ async def get_card(card_id: str) -> Card | None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_card_by_uid(card_uid: str) -> Card | None:
|
async def get_card_by_uid(card_uid: str) -> Optional[Card]:
|
||||||
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()},
|
||||||
|
|
@ -87,7 +92,7 @@ async def get_card_by_uid(card_uid: str) -> Card | None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_card_by_external_id(external_id: str) -> Card | None:
|
async def get_card_by_external_id(external_id: str) -> Optional[Card]:
|
||||||
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()},
|
||||||
|
|
@ -95,7 +100,7 @@ async def get_card_by_external_id(external_id: str) -> Card | None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_card_by_otp(otp: str) -> Card | None:
|
async def get_card_by_otp(otp: str) -> Optional[Card]:
|
||||||
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},
|
||||||
|
|
@ -125,7 +130,7 @@ async def update_card_counter(counter: int, card_id: str):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def enable_disable_card(enable: bool, card_id: str) -> Card | None:
|
async def enable_disable_card(enable: bool, card_id: str) -> Optional[Card]:
|
||||||
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},
|
||||||
|
|
@ -140,7 +145,7 @@ async def update_card_otp(otp: str, card_id: str):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_hit(hit_id: str) -> Hit | None:
|
async def get_hit(hit_id: str) -> Optional[Hit]:
|
||||||
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},
|
||||||
|
|
@ -235,7 +240,7 @@ async def create_refund(hit_id, refund_amount) -> Refund:
|
||||||
return refund
|
return refund
|
||||||
|
|
||||||
|
|
||||||
async def get_refund(refund_id: str) -> Refund | None:
|
async def get_refund(refund_id: str) -> Optional[Refund]:
|
||||||
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},
|
||||||
|
|
|
||||||
|
|
@ -55,76 +55,3 @@ 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 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, Field
|
from pydantic import BaseModel
|
||||||
|
|
||||||
ZERO_KEY = "00000000000000000000000000000000"
|
ZERO_KEY = "00000000000000000000000000000000"
|
||||||
|
|
||||||
|
|
@ -17,8 +17,10 @@ class Card(BaseModel):
|
||||||
uid: str
|
uid: str
|
||||||
external_id: str
|
external_id: str
|
||||||
counter: int
|
counter: int
|
||||||
tx_limit: int
|
# TODO: database column is TEXT should be INT
|
||||||
daily_limit: int
|
tx_limit: str
|
||||||
|
# TODO: database column is TEXT should be INT
|
||||||
|
daily_limit: str
|
||||||
enable: bool
|
enable: bool
|
||||||
k0: str
|
k0: str
|
||||||
k1: str
|
k1: str
|
||||||
|
|
@ -71,8 +73,3 @@ 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
Normal file
2616
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,34 +1,39 @@
|
||||||
[project]
|
[tool.poetry]
|
||||||
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 = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
|
authors = ["Alan Bits <alan@lnbits.com>"]
|
||||||
urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/bitcoinswitch_extension" }
|
|
||||||
dependencies = [ "lnbits>1" ]
|
|
||||||
|
|
||||||
[tool.poetry]
|
[tool.poetry.dependencies]
|
||||||
package-mode = false
|
python = "^3.10 | ^3.9"
|
||||||
|
lnbits = {version = "*", allow-prereleases = true}
|
||||||
|
|
||||||
[tool.uv]
|
[tool.poetry.group.dev.dependencies]
|
||||||
dev-dependencies = [
|
black = "^24.3.0"
|
||||||
"black",
|
pytest-asyncio = "^0.21.0"
|
||||||
"pytest-asyncio",
|
pytest = "^7.3.2"
|
||||||
"pytest",
|
mypy = "^1.5.1"
|
||||||
"mypy",
|
pre-commit = "^3.2.2"
|
||||||
"pre-commit",
|
ruff = "^0.6.3"
|
||||||
"ruff",
|
|
||||||
"pytest-md",
|
[build-system]
|
||||||
]
|
requires = ["poetry-core>=1.0.0"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
plugins = ["pydantic.mypy"]
|
exclude = "(nostr/*)"
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
[tool.pydantic-mypy]
|
module = [
|
||||||
init_forbid_extra = true
|
"lnbits.*",
|
||||||
init_typed = true
|
"lnurl.*",
|
||||||
warn_required_dynamic_aliases = true
|
"loguru.*",
|
||||||
warn_untyped_fields = true
|
"fastapi.*",
|
||||||
|
"pydantic.*",
|
||||||
|
"pyqrcode.*",
|
||||||
|
"shortuuid.*",
|
||||||
|
"httpx.*",
|
||||||
|
]
|
||||||
|
ignore_missing_imports = "True"
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
log_cli = false
|
log_cli = false
|
||||||
|
|
|
||||||
|
|
@ -148,15 +148,6 @@ 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()
|
||||||
|
|
@ -238,9 +229,6 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -56,16 +56,12 @@ 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 class="overflow-hidden">
|
<q-item-section>
|
||||||
<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}
|
'(Refunded)' : null}</q-item-label
|
||||||
<q-tooltip><span v-text="hit.id"></span></q-tooltip>
|
>
|
||||||
</q-item-label>
|
<q-item-label caption lines="1">IP: ${hit.ip}</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>
|
||||||
|
|
|
||||||
|
|
@ -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 ({{LNBITS_DENOMINATION}})"
|
label="Max transaction (sats)"
|
||||||
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 ({{LNBITS_DENOMINATION}})"
|
label="Daily limit (sats)"
|
||||||
></q-input>
|
></q-input>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -381,6 +381,7 @@
|
||||||
<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">
|
||||||
|
|
@ -395,6 +396,7 @@
|
||||||
</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">
|
||||||
|
|
@ -461,7 +463,6 @@
|
||||||
<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"
|
||||||
|
|
@ -472,17 +473,6 @@
|
||||||
>
|
>
|
||||||
<q-tooltip>Backup the keys, or wipe the card first!</q-tooltip>
|
<q-tooltip>Backup the keys, or wipe the card first!</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
<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>
|
||||||
|
|
|
||||||
15
views.py
15
views.py
|
|
@ -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, optional_user_id
|
from lnbits.decorators import check_user_exists
|
||||||
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,26 +24,15 @@ 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(
|
async def display(request: Request, card_id: str):
|
||||||
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]
|
||||||
|
|
|
||||||
31
views_api.py
31
views_api.py
|
|
@ -71,6 +71,7 @@ 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(
|
||||||
|
|
@ -84,9 +85,8 @@ 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,
|
||||||
)
|
)
|
||||||
for key, value in data.dict().items():
|
card = await update_card(card_id, **data.dict())
|
||||||
setattr(card, key, value)
|
assert card, "update_card should always return a card"
|
||||||
await update_card(card)
|
|
||||||
return card
|
return card
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -106,11 +106,7 @@ 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)
|
||||||
if not card:
|
assert card, "create_card should always return a card"
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Could not create card.",
|
|
||||||
)
|
|
||||||
return card
|
return card
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -118,28 +114,22 @@ 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: str,
|
card_id,
|
||||||
enable: bool,
|
enable,
|
||||||
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)
|
||||||
if not card:
|
assert card
|
||||||
raise HTTPException(
|
return card.dict()
|
||||||
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(
|
async def api_card_delete(card_id, wallet: WalletTypeInfo = Depends(require_admin_key)):
|
||||||
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:
|
||||||
|
|
@ -151,6 +141,7 @@ async def api_card_delete(
|
||||||
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")
|
||||||
|
|
|
||||||
325
views_lnurl.py
325
views_lnurl.py
|
|
@ -6,34 +6,22 @@ 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 (
|
from lnurl import encode as lnurl_encode
|
||||||
CallbackUrl,
|
from lnurl.types import LnurlPayMetadata
|
||||||
LightningInvoice,
|
from loguru import logger
|
||||||
LnurlErrorResponse,
|
from starlette.responses import HTMLResponse
|
||||||
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()
|
||||||
|
|
@ -41,9 +29,7 @@ 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(
|
async def api_scan(p, c, request: Request, external_id: str):
|
||||||
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()
|
||||||
|
|
@ -51,28 +37,27 @@ async def api_scan(
|
||||||
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 LnurlErrorResponse(reason="Card not found.")
|
return {"status": "ERROR", "reason": "No card."}
|
||||||
if not card.enable:
|
if not card.enable:
|
||||||
return LnurlErrorResponse(reason="Card is disabled.")
|
return {"status": "ERROR", "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 LnurlErrorResponse(reason="Card UID mis-match.")
|
return {"status": "ERROR", "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 LnurlErrorResponse(reason="CMAC does not check.")
|
return {"status": "ERROR", "reason": "CMAC does not check."}
|
||||||
except Exception:
|
except Exception:
|
||||||
return LnurlErrorResponse(reason="Error decrypting card.")
|
return {"status": "ERROR", "reason": "Error decrypting card."}
|
||||||
|
|
||||||
ctr_int = int.from_bytes(counter, "little")
|
ctr_int = int.from_bytes(counter, "little")
|
||||||
|
|
||||||
if ctr_int <= card.counter:
|
if ctr_int <= card.counter:
|
||||||
return LnurlErrorResponse(reason="This link is already used.")
|
return {"status": "ERROR", "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
|
||||||
if not request.client:
|
assert 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"]
|
||||||
|
|
@ -86,26 +71,28 @@ async def api_scan(
|
||||||
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 LnurlErrorResponse(reason="Max daily limit spent.")
|
return {"status": "ERROR", "reason": "Max daily limit spent."}
|
||||||
hit = await create_hit(card.id, ip, agent, card.counter, ctr_int)
|
hit = await create_hit(card.id, ip, agent, card.counter, ctr_int)
|
||||||
|
|
||||||
|
# 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_url = str(request.url_for("boltcards.lnurlp_response", hit_id=hit.id))
|
lnurlpay_nonbech32_lud17 = lnurlpay_raw.replace("https://", "lnurlp://").replace(
|
||||||
pay_link = lnurlpay_url.replace("http://", "lnurlp://").replace(
|
"http://", "lnurlp://"
|
||||||
"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(
|
||||||
"/api/v1/lnurl/cb/{hit_id}",
|
"/api/v1/lnurl/cb/{hit_id}",
|
||||||
|
|
@ -116,32 +103,34 @@ 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 LnurlErrorResponse(reason="Missing K1 token")
|
return {"status": "ERROR", "reason": "Missing K1 token"}
|
||||||
if k1 != hit_id:
|
|
||||||
return LnurlErrorResponse(reason="K1 token does not match.")
|
hit = await get_hit(k1)
|
||||||
|
|
||||||
hit = await get_hit(hit_id)
|
|
||||||
if not hit:
|
if not hit:
|
||||||
return LnurlErrorResponse(reason="LNURL-withdraw record not found.")
|
return {
|
||||||
|
"status": "ERROR",
|
||||||
|
"reason": "Record not found for this charge (bad k1)",
|
||||||
|
}
|
||||||
if hit.spent:
|
if hit.spent:
|
||||||
return LnurlErrorResponse(reason="Payment already claimed.")
|
return {"status": "ERROR", "reason": "Payment already claimed"}
|
||||||
if not pr:
|
if not pr:
|
||||||
return LnurlErrorResponse(reason="Missing payment request.")
|
return {"status": "ERROR", "reason": "Missing payment request"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
invoice = bolt11.decode(pr)
|
invoice = bolt11.decode(pr)
|
||||||
except bolt11.Bolt11Exception:
|
except bolt11.Bolt11Exception:
|
||||||
return LnurlErrorResponse(reason="Failed to decode payment request.")
|
return {"status": "ERROR", "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)
|
||||||
if not card:
|
assert card
|
||||||
return LnurlErrorResponse(reason="Card not found.")
|
assert invoice.amount_msat, "Invoice amount is missing"
|
||||||
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))
|
||||||
if not hit:
|
assert 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,
|
||||||
|
|
@ -149,9 +138,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 LnurlSuccessResponse()
|
return {"status": "OK"}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return LnurlErrorResponse(reason=f"Payment failed - {exc}")
|
return {"status": "ERROR", "reason": f"Payment failed - {exc}"}
|
||||||
|
|
||||||
|
|
||||||
# /boltcards/api/v1/auth?a=00000000000000000000000000000000
|
# /boltcards/api/v1/auth?a=00000000000000000000000000000000
|
||||||
|
|
@ -190,68 +179,44 @@ async def api_auth(a, request: Request):
|
||||||
return response
|
return 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
|
|
||||||
)
|
|
||||||
|
|
||||||
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),
|
|
||||||
}
|
|
||||||
if wipe:
|
|
||||||
response["action"] = "wipe"
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
###############LNURLPAY REFUNDS#################
|
###############LNURLPAY REFUNDS#################
|
||||||
|
|
||||||
|
|
||||||
|
@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,
|
||||||
|
}
|
||||||
|
return json.dumps(pay_response)
|
||||||
|
|
||||||
|
|
||||||
@boltcards_lnurl_router.get(
|
@boltcards_lnurl_router.get(
|
||||||
"/api/v1/lnurlp/cb/{hit_id}",
|
"/api/v1/lnurlp/cb/{hit_id}",
|
||||||
name="boltcards.lnurlp_callback",
|
name="boltcards.lnurlp_callback",
|
||||||
)
|
)
|
||||||
async def lnurlp_callback(
|
async def lnurlp_callback(hit_id: str, amount: str = Query(None)):
|
||||||
hit_id: str, amount: str = Query(None)
|
|
||||||
) -> LnurlPayActionResponse | LnurlErrorResponse:
|
|
||||||
hit = await get_hit(hit_id)
|
hit = await get_hit(hit_id)
|
||||||
if not hit:
|
assert hit
|
||||||
return LnurlErrorResponse(reason="LNURL-pay record not found.")
|
|
||||||
card = await get_card(hit.card_id)
|
card = await get_card(hit.card_id)
|
||||||
if not card:
|
assert card
|
||||||
return LnurlErrorResponse(reason="Card not found.")
|
if not hit:
|
||||||
if not card.enable:
|
return {"status": "ERROR", "reason": "LNURL-pay record not found."}
|
||||||
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,
|
||||||
|
|
@ -262,135 +227,5 @@ async def lnurlp_callback(
|
||||||
).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)
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue