Compare commits

..

No commits in common. "main" and "v1.2.0" have entirely different histories.

14 changed files with 22 additions and 523 deletions

View file

@ -17,51 +17,4 @@ withdraw_ext.include_router(withdraw_ext_generic)
withdraw_ext.include_router(withdraw_ext_api) withdraw_ext.include_router(withdraw_ext_api)
withdraw_ext.include_router(withdraw_ext_lnurl) withdraw_ext.include_router(withdraw_ext_lnurl)
__all__ = ["db", "withdraw_ext", "withdraw_static_files"]
def withdraw_start() -> None:
"""
Register this extension's RPCs with the LNbits nostr transport so an
HTTP-allergic client (e.g. lamassu-next ATM) can manage LNURL-withdraw
links without touching the HTTP API. Also wires the link-owner
resolver so subscribe_payments({tag:"withdraw", link_id:...}) can
verify ownership.
No-op if the core transport module isn't present in the LNbits build.
No runtime `if nostr_transport_enabled` guard is needed when
disabled, the relay pool never publishes, so registered RPCs are
simply unreachable.
"""
try:
from lnbits.core.services.nostr_transport.dispatcher import (
AUTH_ACCOUNT,
AUTH_WALLET,
register_rpc,
)
from lnbits.core.services.nostr_transport.subscriptions import (
register_link_owner_resolver,
)
except ImportError:
return
from .transport_rpcs import (
handle_lnurlw_create_link,
handle_lnurlw_delete_link,
handle_lnurlw_get_link,
handle_lnurlw_list_links,
handle_lnurlw_unique_hashes,
handle_lnurlw_update_link,
resolve_withdraw_owner,
)
register_rpc("lnurlw_create_link", handle_lnurlw_create_link, AUTH_WALLET)
register_rpc("lnurlw_get_link", handle_lnurlw_get_link, AUTH_WALLET)
register_rpc("lnurlw_list_links", handle_lnurlw_list_links, AUTH_ACCOUNT)
register_rpc("lnurlw_unique_hashes", handle_lnurlw_unique_hashes, AUTH_WALLET)
register_rpc("lnurlw_update_link", handle_lnurlw_update_link, AUTH_WALLET)
register_rpc("lnurlw_delete_link", handle_lnurlw_delete_link, AUTH_WALLET)
register_link_owner_resolver(
"withdraw", resolve_withdraw_owner, link_extra_key="withdrawal_link_id"
)
__all__ = ["db", "withdraw_ext", "withdraw_start", "withdraw_static_files"]

View file

@ -2,7 +2,7 @@
"name": "Withdraw Links", "name": "Withdraw Links",
"short_description": "Make LNURL withdraw links", "short_description": "Make LNURL withdraw links",
"tile": "/withdraw/static/image/lnurl-withdraw.png", "tile": "/withdraw/static/image/lnurl-withdraw.png",
"version": "1.2.2-aio.2", "version": "1.1.0",
"min_lnbits_version": "1.3.0", "min_lnbits_version": "1.3.0",
"contributors": [ "contributors": [
{ {

View file

@ -32,7 +32,6 @@ async def create_withdraw_link(
webhook_headers=data.webhook_headers, webhook_headers=data.webhook_headers,
webhook_body=data.webhook_body, webhook_body=data.webhook_body,
custom_url=data.custom_url, custom_url=data.custom_url,
extra=data.extra,
number=0, number=0,
) )
await db.insert("withdraw.withdraw_link", withdraw_link) await db.insert("withdraw.withdraw_link", withdraw_link)
@ -109,7 +108,7 @@ async def remove_unique_withdraw_link(link: WithdrawLink, unique_hash: str) -> N
async def increment_withdraw_link(link: WithdrawLink) -> None: async def increment_withdraw_link(link: WithdrawLink) -> None:
link.used = link.used + 1 link.used = link.used + 1
link.open_time = int(datetime.now().timestamp()) link.open_time = int(datetime.now().timestamp()) + link.wait_time
await update_withdraw_link(link) await update_withdraw_link(link)

View file

@ -1,5 +1,4 @@
from fastapi import Request from fastapi import Request
from lnbits.settings import settings
from lnurl import Lnurl from lnurl import Lnurl
from lnurl import encode as lnurl_encode from lnurl import encode as lnurl_encode
from shortuuid import uuid from shortuuid import uuid
@ -27,28 +26,3 @@ def create_lnurl(link: WithdrawLink, req: Request) -> Lnurl:
f"Error creating LNURL with url: `{url!s}`, " f"Error creating LNURL with url: `{url!s}`, "
"check your webserver proxy configuration." "check your webserver proxy configuration."
) from e ) from e
def create_lnurl_from_baseurl(link: WithdrawLink) -> Lnurl:
"""
Same shape as `create_lnurl`, but composes the callback URL from
`settings.lnbits_baseurl` instead of a FastAPI `Request`. Used by
the nostr-transport RPC handlers, which have no HTTP request to
derive a base URL from.
"""
base = settings.lnbits_baseurl.rstrip("/")
if link.is_unique:
usescssv = link.usescsv.split(",")
tohash = link.id + link.unique_hash + usescssv[link.number]
multihash = uuid(name=tohash)
url = f"{base}/withdraw/api/v1/lnurl/{link.unique_hash}/{multihash}"
else:
url = f"{base}/withdraw/api/v1/lnurl/{link.unique_hash}"
try:
return lnurl_encode(url)
except Exception as e:
raise ValueError(
f"Error creating LNURL with url: `{url!s}`, "
"check your `LNBITS_BASEURL` configuration."
) from e

View file

@ -139,9 +139,3 @@ async def m007_add_created_at_timestamp(db):
"ALTER TABLE withdraw.withdraw_link " "ALTER TABLE withdraw.withdraw_link "
f"ADD COLUMN created_at TIMESTAMP DEFAULT {db.timestamp_column_default}" f"ADD COLUMN created_at TIMESTAMP DEFAULT {db.timestamp_column_default}"
) )
async def m008_add_enabled_column(db):
await db.execute(
"ALTER TABLE withdraw.withdraw_link ADD COLUMN enabled BOOLEAN DEFAULT true;"
)

View file

@ -1,44 +0,0 @@
"""
Fork-specific database migrations for the aiolabs withdraw extension.
These migrations are tracked separately under `withdraw_fork` in the
`dbversions` table (loaded by `lnbits/core/helpers.py:migrate_extension_database`),
so they do not collide with upstream's `m{NNN}_*` numbering in
`migrations.py`. Keeping the upstream-tracked file untouched means
`git pull upstream` stays rebase-clean for schema changes.
Conventions:
- Sequential numbering starting from m001.
- Each migration is `async def m{NNN}_<description>(db)`.
- DDL must be idempotent: a fresh install runs every migration; an
install that already carries the column must not crash. Use
`_alter_add_column_safe` so re-runs are no-ops.
"""
async def _alter_add_column_safe(db, sql: str) -> None:
"""ALTER TABLE ADD COLUMN that swallows duplicate-column errors, so a
re-run on a DB that already has the column is a silent no-op."""
try:
await db.execute(sql)
except Exception as exc:
msg = str(exc).lower()
if "duplicate column" in msg or "already exists" in msg:
return
raise
async def m001_aio_withdraw_schema(db):
"""
Apply every aiolabs schema delta on top of upstream withdraw.
`withdraw_link.extra` arbitrary JSON merged into the payout payment's
`extra` when the link is claimed (see views_lnurl). Lets a caller tag the
resulting payment with settlement/attribution metadata an external listener
can key on e.g. bitSpire stamps {source, type, principal_sats, fee_sats,
...} so the spirekeeper cash-in settlement fires off an LNURL-withdraw
payout. Stored as TEXT; (de)serialized to a dict by the WithdrawLink model.
"""
await _alter_add_column_safe(
db, "ALTER TABLE withdraw.withdraw_link ADD COLUMN extra TEXT"
)

View file

@ -15,13 +15,6 @@ class CreateWithdrawData(BaseModel):
webhook_headers: str = Query(None) webhook_headers: str = Query(None)
webhook_body: str = Query(None) webhook_body: str = Query(None)
custom_url: str = Query(None) custom_url: str = Query(None)
enabled: bool = Query(True)
# Arbitrary JSON merged into the payout payment's `extra` when this link is
# claimed (see views_lnurl). Lets a caller tag the resulting payment with
# settlement/attribution metadata an external listener can key on — e.g.
# bitSpire stamps {source, type, principal_sats, fee_sats, ...} so the
# spirekeeper cash-in settlement fires off an LNURL-withdraw payout.
extra: dict | None = None
class WithdrawLink(BaseModel): class WithdrawLink(BaseModel):
@ -43,27 +36,7 @@ class WithdrawLink(BaseModel):
webhook_headers: str = Query(None) webhook_headers: str = Query(None)
webhook_body: str = Query(None) webhook_body: str = Query(None)
custom_url: str = Query(None) custom_url: str = Query(None)
# Persisted as TEXT (JSON); merged into the payout payment's `extra` on
# claim. LNbits' db layer (de)serializes dict-typed columns to/from JSON
# natively (same as Payment.extra) — no per-field validator needed.
extra: dict | None = None
created_at: datetime created_at: datetime
enabled: bool = Query(True)
lnurl: str | None = Field(
default=None,
no_database=True,
deprecated=True,
description=(
"Deprecated: Instead of using this bech32 encoded string, dynamically "
"generate your own static link (lud17/bech32) on the client side. "
"Example: lnurlw://${window.location.hostname}/lnurlw/${id}"
),
)
lnurl_url: str | None = Field(
default=None,
no_database=True,
description="The raw LNURL callback URL (use for QR code generation)",
)
@property @property
def is_spent(self) -> bool: def is_spent(self) -> bool:

View file

@ -51,7 +51,9 @@ window.app = Vue.createApp({
align: 'right', align: 'right',
label: 'Max (sat)', label: 'Max (sat)',
field: 'max_withdrawable', field: 'max_withdrawable',
format: LNbits.utils.formatSat format: v => {
return new Intl.NumberFormat(LOCALE).format(v)
}
} }
], ],
pagination: { pagination: {
@ -68,8 +70,7 @@ window.app = Vue.createApp({
data: { data: {
is_unique: false, is_unique: false,
use_custom: false, use_custom: false,
has_webhook: false, has_webhook: false
enabled: true
} }
}, },
simpleformDialog: { simpleformDialog: {
@ -79,8 +80,7 @@ window.app = Vue.createApp({
use_custom: false, use_custom: false,
title: 'Vouchers', title: 'Vouchers',
min_withdrawable: 0, min_withdrawable: 0,
wait_time: 1, wait_time: 1
enabled: true
} }
}, },
qrCodeDialog: { qrCodeDialog: {
@ -127,22 +127,20 @@ window.app = Vue.createApp({
this.formDialog.data = { this.formDialog.data = {
is_unique: false, is_unique: false,
use_custom: false, use_custom: false,
has_webhook: false, has_webhook: false
enabled: true
} }
}, },
simplecloseFormDialog() { simplecloseFormDialog() {
this.simpleformDialog.data = { this.simpleformDialog.data = {
is_unique: false, is_unique: false,
use_custom: false, use_custom: false
enabled: true
} }
}, },
openQrCodeDialog(linkId) { openQrCodeDialog(linkId) {
const link = _.findWhere(this.withdrawLinks, {id: linkId}) const link = _.findWhere(this.withdrawLinks, {id: linkId})
this.qrCodeDialog.data = _.clone(link) this.qrCodeDialog.data = _.clone(link)
this.qrCodeDialog.show = true this.qrCodeDialog.show = true
this.activeUrl = link.lnurl_url this.activeUrl = `${window.location.origin}/withdraw/api/v1/lnurl/${link.unique_hash}`
}, },
openUpdateDialog(linkId) { openUpdateDialog(linkId) {
let link = _.findWhere(this.withdrawLinks, {id: linkId}) let link = _.findWhere(this.withdrawLinks, {id: linkId})

View file

@ -7,12 +7,6 @@
<q-badge v-if="spent" color="red" class="q-mb-md" <q-badge v-if="spent" color="red" class="q-mb-md"
>Withdraw is spent.</q-badge >Withdraw is spent.</q-badge
> >
<q-badge v-if="spent" color="red" class="q-mb-md"
>Withdraw is spent.</q-badge
>
<q-badge v-else-if="!enabled" color="grey" class="q-mb-md"
>Withdraw is disabled.</q-badge
>
<a v-else class="text-secondary" :href="link"> <a v-else class="text-secondary" :href="link">
<lnbits-qrcode-lnurl <lnbits-qrcode-lnurl
prefix="lnurlw" prefix="lnurlw"
@ -61,10 +55,9 @@
data() { data() {
return { return {
spent: {{ 'true' if spent else 'false' }}, spent: {{ 'true' if spent else 'false' }},
url: '{{ lnurl_url }}', url: `${window.location.origin}/withdraw/api/v1/lnurl/{{ unique_hash }}`,
lnurl: '', lnurl: '',
nfcTagWriting: false, nfcTagWriting: false
enabled: {{ 'true' if enabled else 'false' }}
} }
} }
}) })

View file

@ -38,7 +38,6 @@
> >
<template v-slot:header="props"> <template v-slot:header="props">
<q-tr :props="props"> <q-tr :props="props">
<q-th auto-width></q-th>
<q-th auto-width></q-th> <q-th auto-width></q-th>
<q-th auto-width></q-th> <q-th auto-width></q-th>
<q-th <q-th
@ -52,19 +51,6 @@
</template> </template>
<template v-slot:body="props"> <template v-slot:body="props">
<q-tr :props="props"> <q-tr :props="props">
<q-td auto-width>
<q-icon
name="power_settings_new"
:color="props.row.enabled ? 'green' : 'red'"
size="xs"
>
<q-tooltip>
<span
v-text="props.row.enabled ? 'Withdraw link is enabled' : 'Withdraw link is disabled'"
></span>
</q-tooltip>
</q-icon>
</q-td>
<q-td auto-width> <q-td auto-width>
<q-btn <q-btn
unelevated unelevated
@ -252,20 +238,6 @@
hint="Custom data as JSON string, will get posted along with webhook 'body' field." hint="Custom data as JSON string, will get posted along with webhook 'body' field."
></q-input> ></q-input>
<q-list> <q-list>
<q-item tag="label" class="rounded-borders">
<q-item-section avatar>
<q-checkbox
v-model="formDialog.data.enabled"
color="primary"
></q-checkbox>
</q-item-section>
<q-item-section>
<q-item-label>Enable / Disable </q-item-label>
<q-item-label caption
>You can enable or disable these vouchers</q-item-label
>
</q-item-section>
</q-item>
<q-item tag="label" class="rounded-borders"> <q-item tag="label" class="rounded-borders">
<q-item-section avatar> <q-item-section avatar>
<q-checkbox <q-checkbox
@ -378,20 +350,6 @@
label="Number of vouchers" label="Number of vouchers"
></q-input> ></q-input>
<q-list> <q-list>
<q-item tag="label" class="rounded-borders">
<q-item-section avatar>
<q-checkbox
v-model="simpleformDialog.data.enabled"
color="primary"
></q-checkbox>
</q-item-section>
<q-item-section>
<q-item-label>Enable / Disable </q-item-label>
<q-item-label caption
>You can enable or disable these vouchers</q-item-label
>
</q-item-section>
</q-item>
<q-item tag="label" class="rounded-borders"> <q-item tag="label" class="rounded-borders">
<q-item-section avatar> <q-item-section avatar>
<q-checkbox <q-checkbox

View file

@ -1,225 +0,0 @@
"""
Nostr-transport RPC handlers for the withdraw (LNURL-withdraw) extension.
Names mirror the Lightning.Pub `withdraw.*` contract that the lamassu-next
ATM consumes (see ~/dev/shocknet/lamassu-next/packages/lightning/src/client.ts
lines ~301351). That keeps the lamassu-next-side adapter a pure name
translation no semantic reshaping.
Auth model (set in `__init__.py:withdraw_start`):
- create / get / update / delete AUTH_WALLET; the calling pubkey must
own the wallet the link is scoped to. *_get / *_update / *_delete also
verify the link's stored `wallet` matches the caller's wallet id.
`resolve_withdraw_owner` is registered with the core subscription module
under tag `"withdraw"` and extras-key `"withdrawal_link_id"` (matching
where the extension stamps the link id on settlement see
`views_lnurl.py:144`). That lets `subscribe_payments({tag:"withdraw",
link_id:...})` enforce ownership without core importing this module.
"""
from __future__ import annotations
from lnbits.core.crud.wallets import get_wallets
from lnbits.core.models import Account
from lnbits.core.models.wallets import WalletTypeInfo
from lnbits.core.services.nostr_transport.models import NostrRpcRequest
from shortuuid import uuid
from .crud import (
create_withdraw_link,
delete_withdraw_link,
get_withdraw_link,
get_withdraw_links,
update_withdraw_link,
)
from .helpers import create_lnurl_from_baseurl
from .models import CreateWithdrawData, WithdrawLink
async def handle_lnurlw_create_link(
auth: WalletTypeInfo, request: NostrRpcRequest
) -> dict:
body = request.body or {}
data = CreateWithdrawData(**body)
link = await create_withdraw_link(data, auth.wallet.id)
return _to_dict(_populate_lnurl(link))
async def handle_lnurlw_get_link(
auth: WalletTypeInfo, request: NostrRpcRequest
) -> dict:
link_id = _require_id(request)
link = await _require_owned_link(link_id, auth.wallet.id)
return _to_dict(_populate_lnurl(link))
async def handle_lnurlw_update_link(
auth: WalletTypeInfo, request: NostrRpcRequest
) -> dict:
link_id = _require_id(request)
link = await _require_owned_link(link_id, auth.wallet.id)
body = request.body or {}
_MUTABLE = {
"title",
"min_withdrawable",
"max_withdrawable",
"uses",
"wait_time",
"is_unique",
"webhook_url",
"webhook_headers",
"webhook_body",
"custom_url",
"enabled",
}
for k, v in body.items():
if k in _MUTABLE:
setattr(link, k, v)
updated = await update_withdraw_link(link)
return _to_dict(_populate_lnurl(updated))
async def handle_lnurlw_delete_link(
auth: WalletTypeInfo, request: NostrRpcRequest
) -> dict:
link_id = _require_id(request)
await _require_owned_link(link_id, auth.wallet.id)
await delete_withdraw_link(link_id)
return {"ok": True}
async def handle_lnurlw_list_links(auth: Account, request: NostrRpcRequest) -> dict:
"""List withdraw links across all wallets owned by the calling account.
Useful for ATMs to re-discover their links after a reconnect.
Body fields:
- limit: int (0 means no limit; default 0)
- offset: int (default 0)
If `request.wallet_id` is set and is one of the caller's wallets,
narrow to just that wallet.
"""
body = request.body or {}
limit = int(body.get("limit") or 0)
offset = int(body.get("offset") or 0)
wallets = await get_wallets(auth.id)
wallet_ids = [w.id for w in wallets]
if not wallet_ids:
return {"data": [], "total": 0}
if request.wallet_id and request.wallet_id in wallet_ids:
wallet_ids = [request.wallet_id]
page = await get_withdraw_links(wallet_ids, limit, offset)
return {
"data": [_to_dict(_populate_lnurl(link)) for link in page.data],
"total": page.total,
}
async def handle_lnurlw_unique_hashes(
auth: WalletTypeInfo, request: NostrRpcRequest
) -> dict:
"""
For a `is_unique=True` link, return the per-use `id_unique_hash`
values that the ATM uses to generate distinct QR codes one per
unredeemed slot. Mirrors the formula in `helpers.py:create_lnurl`
exactly so an ATM never has to re-implement the derivation:
id_unique_hash = shortuuid.uuid(name=link.id + link.unique_hash + index)
`link.usescsv` is the canonical list of *unredeemed* slot indexes;
after a customer claims a slot it gets removed there (see
`crud.remove_unique_withdraw_link`). The hashes returned here are
therefore exactly the ones still claimable.
Response:
{
"link_id": str,
"unique_hash": str, # base hash
"is_unique": bool,
"unredeemed_hashes": [ # one entry per remaining slot
{"index": str, "id_unique_hash": str}, ...
]
}
For `is_unique=False` links the list is empty and `unique_hash`
alone identifies the callback path
(`/withdraw/api/v1/lnurl/<unique_hash>`). For `is_unique=True`
each callback path is
`/withdraw/api/v1/lnurl/<unique_hash>/<id_unique_hash>`.
"""
link_id = _require_id(request)
link = await _require_owned_link(link_id, auth.wallet.id)
unredeemed = []
if link.is_unique:
# usescsv is comma-separated; split and skip empties (after the
# last slot is consumed it becomes the empty string).
for index_str in [s for s in link.usescsv.split(",") if s.strip()]:
tohash = link.id + link.unique_hash + index_str
unredeemed.append(
{
"index": index_str.strip(),
"id_unique_hash": uuid(name=tohash),
}
)
return {
"link_id": link.id,
"unique_hash": link.unique_hash,
"is_unique": link.is_unique,
"unredeemed_hashes": unredeemed,
}
async def resolve_withdraw_owner(link_id: str) -> str | None:
"""For the core subscription module: link_id -> wallet_id (or None)."""
link = await get_withdraw_link(link_id)
return link.wallet if link else None
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _require_id(request: NostrRpcRequest) -> str:
body = request.body or {}
link_id = body.get("id")
if not link_id:
raise ValueError("withdraw: body.id is required")
return str(link_id)
async def _require_owned_link(link_id: str, wallet_id: str):
link = await get_withdraw_link(link_id)
if link is None:
raise ValueError(f"withdraw: link not found: {link_id}")
if link.wallet != wallet_id:
raise PermissionError("withdraw: link does not belong to caller's wallet")
return link
def _populate_lnurl(link: WithdrawLink) -> WithdrawLink:
"""
Compose `lnurl` / `lnurl_url` from `settings.lnbits_baseurl` so
nostr-transport responses match the HTTP `views_api` shape, where
these fields are populated from `request.url_for(...)`. Without
this, consumers (ATMs, etc.) would have to re-derive the callback
URL themselves from a separately-provisioned LNbits HTTPS URL
duplicating state LNbits already knows. See aiolabs/withdraw#1.
"""
try:
encoded = create_lnurl_from_baseurl(link)
link.lnurl = str(encoded.bech32)
link.lnurl_url = str(encoded.url)
except ValueError:
pass
return link
def _to_dict(link) -> dict:
import json
return json.loads(link.json())

View file

@ -33,21 +33,12 @@ async def display(request: Request, link_id):
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
) )
try:
lnurl = create_lnurl(link, request)
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=str(exc),
) from exc
return withdraw_renderer().TemplateResponse( return withdraw_renderer().TemplateResponse(
"withdraw/display.html", "withdraw/display.html",
{ {
"request": request, "request": request,
"spent": link.is_spent, "spent": link.is_spent,
"lnurl_url": str(lnurl.url), "unique_hash": link.unique_hash,
"enabled": link.enabled,
}, },
) )
@ -61,6 +52,7 @@ async def print_qr(request: Request, link_id):
) )
if link.uses == 0: if link.uses == 0:
return withdraw_renderer().TemplateResponse( return withdraw_renderer().TemplateResponse(
"withdraw/print_qr.html", "withdraw/print_qr.html",
{"request": request, "link": link.json(), "unique": False}, {"request": request, "link": link.json(), "unique": False},

View file

@ -1,7 +1,7 @@
import json import json
from http import HTTPStatus from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query
from lnbits.core.crud import get_user from lnbits.core.crud import get_user
from lnbits.core.models import SimpleStatus, WalletTypeInfo from lnbits.core.models import SimpleStatus, WalletTypeInfo
from lnbits.decorators import require_admin_key, require_invoice_key from lnbits.decorators import require_admin_key, require_invoice_key
@ -14,7 +14,6 @@ from .crud import (
get_withdraw_links, get_withdraw_links,
update_withdraw_link, update_withdraw_link,
) )
from .helpers import create_lnurl
from .models import CreateWithdrawData, HashCheck, PaginatedWithdraws, WithdrawLink from .models import CreateWithdrawData, HashCheck, PaginatedWithdraws, WithdrawLink
withdraw_ext_api = APIRouter(prefix="/api/v1") withdraw_ext_api = APIRouter(prefix="/api/v1")
@ -22,7 +21,6 @@ withdraw_ext_api = APIRouter(prefix="/api/v1")
@withdraw_ext_api.get("/links", status_code=HTTPStatus.OK) @withdraw_ext_api.get("/links", status_code=HTTPStatus.OK)
async def api_links( async def api_links(
request: Request,
key_info: WalletTypeInfo = Depends(require_invoice_key), key_info: WalletTypeInfo = Depends(require_invoice_key),
all_wallets: bool = Query(False), all_wallets: bool = Query(False),
offset: int = Query(0), offset: int = Query(0),
@ -34,27 +32,12 @@ async def api_links(
user = await get_user(key_info.wallet.user) user = await get_user(key_info.wallet.user)
wallet_ids = user.wallet_ids if user else [] wallet_ids = user.wallet_ids if user else []
links = await get_withdraw_links(wallet_ids, limit, offset) return await get_withdraw_links(wallet_ids, limit, offset)
for linkk in links.data:
try:
lnurl = create_lnurl(linkk, request)
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=str(exc),
) from exc
linkk.lnurl = str(lnurl.bech32)
linkk.lnurl_url = str(lnurl.url)
return links
@withdraw_ext_api.get("/links/{link_id}", status_code=HTTPStatus.OK) @withdraw_ext_api.get("/links/{link_id}", status_code=HTTPStatus.OK)
async def api_link_retrieve( async def api_link_retrieve(
request: Request, link_id: str, key_info: WalletTypeInfo = Depends(require_invoice_key)
link_id: str,
key_info: WalletTypeInfo = Depends(require_invoice_key),
) -> WithdrawLink: ) -> WithdrawLink:
link = await get_withdraw_link(link_id, 0) link = await get_withdraw_link(link_id, 0)
@ -67,23 +50,12 @@ async def api_link_retrieve(
raise HTTPException( raise HTTPException(
detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN
) )
try:
lnurl = create_lnurl(link, request)
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=str(exc),
) from exc
link.lnurl = str(lnurl.bech32)
link.lnurl_url = str(lnurl.url)
return link return link
@withdraw_ext_api.post("/links", status_code=HTTPStatus.CREATED) @withdraw_ext_api.post("/links", status_code=HTTPStatus.CREATED)
@withdraw_ext_api.put("/links/{link_id}") @withdraw_ext_api.put("/links/{link_id}")
async def api_link_create_or_update( async def api_link_create_or_update(
request: Request,
data: CreateWithdrawData, data: CreateWithdrawData,
link_id: str | None = None, link_id: str | None = None,
key_info: WalletTypeInfo = Depends(require_admin_key), key_info: WalletTypeInfo = Depends(require_admin_key),
@ -159,16 +131,6 @@ async def api_link_create_or_update(
link = await update_withdraw_link(link) link = await update_withdraw_link(link)
else: else:
link = await create_withdraw_link(wallet_id=key_info.wallet.id, data=data) link = await create_withdraw_link(wallet_id=key_info.wallet.id, data=data)
try:
lnurl = create_lnurl(link, request)
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=str(exc),
) from exc
link.lnurl = str(lnurl.bech32)
link.lnurl_url = str(lnurl.url)
return link return link

View file

@ -3,7 +3,6 @@ from datetime import datetime
import httpx import httpx
import shortuuid import shortuuid
from bolt11 import decode as decode_bolt11
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from lnbits.core.crud import update_payment from lnbits.core.crud import update_payment
@ -44,9 +43,6 @@ async def api_lnurl_response(
if not link: if not link:
return LnurlErrorResponse(reason="Withdraw link does not exist.") return LnurlErrorResponse(reason="Withdraw link does not exist.")
if not link.enabled:
return LnurlErrorResponse(reason="Withdraw link is disabled.")
if link.is_spent: if link.is_spent:
return LnurlErrorResponse(reason="Withdraw is spent.") return LnurlErrorResponse(reason="Withdraw is spent.")
@ -90,23 +86,11 @@ async def api_lnurl_callback(
pr: str, pr: str,
id_unique_hash: str | None = None, id_unique_hash: str | None = None,
) -> LnurlErrorResponse | LnurlSuccessResponse: ) -> LnurlErrorResponse | LnurlSuccessResponse:
link = await get_withdraw_link_by_hash(unique_hash) link = await get_withdraw_link_by_hash(unique_hash)
if not link: if not link:
return LnurlErrorResponse(reason="withdraw link not found.") return LnurlErrorResponse(reason="withdraw link not found.")
if not link.enabled:
return LnurlErrorResponse(reason="Withdraw link is disabled.")
bolt11 = decode_bolt11(pr)
if not bolt11.amount_msat:
return LnurlErrorResponse(reason="0 amount invoices are not supported.")
if (
link.min_withdrawable * 1000 > bolt11.amount_msat
or bolt11.amount_msat > link.max_withdrawable * 1000
):
return LnurlErrorResponse(reason="Amount not within limits.")
if link.is_spent: if link.is_spent:
return LnurlErrorResponse(reason="withdraw is spent.") return LnurlErrorResponse(reason="withdraw is spent.")
@ -115,9 +99,9 @@ async def api_lnurl_callback(
now = int(datetime.now().timestamp()) now = int(datetime.now().timestamp())
if now < link.open_time + link.wait_time: if now < link.open_time:
return LnurlErrorResponse( return LnurlErrorResponse(
reason=f"Wait {link.open_time + link.wait_time - now} seconds." reason=f"wait link open_time {link.open_time - now} seconds."
) )
if not id_unique_hash and link.is_unique: if not id_unique_hash and link.is_unique:
@ -141,16 +125,7 @@ async def api_lnurl_callback(
wallet_id=link.wallet, wallet_id=link.wallet,
payment_request=pr, payment_request=pr,
max_sat=link.max_withdrawable, max_sat=link.max_withdrawable,
# Merge the link's caller-supplied `extra` onto the payout so an extra={"tag": "withdraw", "withdrawal_link_id": link.id},
# external listener can key on it (e.g. bitSpire cash-in
# settlements via spirekeeper). The withdraw extension's own
# `tag`/`withdrawal_link_id` are written last so a caller cannot
# clobber them.
extra={
**(link.extra or {}),
"tag": "withdraw",
"withdrawal_link_id": link.id,
},
) )
await increment_withdraw_link(link) await increment_withdraw_link(link)
# If the payment succeeds, delete the record with the unique_hash. # If the payment succeeds, delete the record with the unique_hash.
@ -219,9 +194,6 @@ async def api_lnurl_multi_response(
if not link: if not link:
return LnurlErrorResponse(reason="Withdraw link does not exist.") return LnurlErrorResponse(reason="Withdraw link does not exist.")
if not link.enabled:
return LnurlErrorResponse(reason="Withdraw link is disabled.")
if link.is_spent: if link.is_spent:
return LnurlErrorResponse(reason="Withdraw is spent.") return LnurlErrorResponse(reason="Withdraw is spent.")