spirekeeper/cashin_transport.py
Padreug f67cb49bc3
Some checks failed
ci.yml / fix(cash-in): return bech32 LNURL, not the raw URL (pull_request) Failing after 0s
fix(cash-in): return bech32 LNURL, not the raw URL
`Lnurl.__str__` is the underlying URL, so `str(lnurl)` returned
`http://<baseurl>/withdraw/...` instead of the bech32 `LNURL1…` — wallets
need the encoded LNURL-withdraw (lud01). Use `str(lnurl.bech32)` and add
`lnurl_url` (the raw URL) alongside, mirroring withdraw's _populate_lnurl
field convention. (Note: the encoded URL still derives from LNBITS_BASEURL —
that must be an externally reachable https URL for a real wallet to claim.)
2026-06-22 15:32:44 +02:00

135 lines
5.6 KiB
Python

"""
Secure cash-in: a `create_withdraw` nostr-transport RPC (aiolabs/spirekeeper#31).
Mirrors withdraw's `lnurlw_create_link`, but cash-in-semantic. The ATM sends the
gross `principal_sats` (the hardware-attested fiat value) over the bunker-signed
kind-21000 transport; the operator side derives the fee, the NET withdraw
amount, and the attribution **server-side** — none of it client-supplied. The
customer claims the NET link; the payout carries the stamped `extra`
(aiolabs/withdraw#3) so `_handle_payment` records the `cash_in` settlement with
cryptographic attribution (the verified transport sender), exactly like the
cash-out path.
Why this exists: `lnurlw_create_link` takes `min/max_withdrawable` and `extra`
straight from the client body, so an authenticated-but-malicious/buggy ATM could
set the gross amount (no fee), forge `nostr_sender_pubkey`, or request an
arbitrary amount. This RPC closes that by computing everything server-side.
"""
from __future__ import annotations
from loguru import logger
from .crud import get_machine_by_atm_pubkey_hex, get_super_config
_RPC_NAME = "create_withdraw"
async def handle_create_withdraw(auth, request) -> dict:
"""nostr-transport RPC handler. `auth` is a WalletTypeInfo (the operator
wallet, roster-resolved from the verified sender); `request` is a
NostrRpcRequest with `body`, `sender_pubkey` (verified), and `event_id`."""
# Import withdraw lazily so registration never hard-depends on the withdraw
# extension being importable at startup; a missing dep fails the request,
# not the daemon.
try:
from withdraw.crud import create_withdraw_link
from withdraw.helpers import create_lnurl_from_baseurl
from withdraw.models import CreateWithdrawData
except ImportError as exc:
raise ValueError(
"withdraw extension unavailable; cannot mint a cash-in link"
) from exc
body = request.body or {}
principal_sats = body.get("principal_sats")
if not isinstance(principal_sats, int) or principal_sats <= 0:
raise ValueError("principal_sats must be a positive integer")
# Attribution is the VERIFIED transport sender — never read it from the body.
sender = (request.sender_pubkey or "").lower()
if not sender:
raise ValueError("missing verified sender_pubkey")
machine = await get_machine_by_atm_pubkey_hex(sender)
if machine is None:
raise ValueError("no active machine for this signer")
# Defence in depth: the roster already resolved `auth.wallet` from `sender`;
# confirm it's the machine's own wallet before minting a payout link on it.
if machine.wallet_id != auth.wallet.id:
raise ValueError("machine wallet does not match the authenticated wallet")
super_config = await get_super_config()
if super_config is None:
raise ValueError("super_config not initialised")
# Per-tx cap (server-side; the bunker ACL / usage caps cannot see sats).
cap = getattr(super_config, "max_cash_in_sats", None)
if cap is not None and principal_sats > cap:
raise ValueError(
f"principal_sats {principal_sats} exceeds max_cash_in_sats {cap}"
)
# Round each leg independently so the split matches parse_settlement exactly
# (platform_fee + operator_fee == fee_sats → fee_mismatch_sats = 0).
platform_fee = round(
principal_sats * float(super_config.super_cash_in_fee_fraction)
)
operator_fee = round(principal_sats * float(machine.operator_cash_in_fee_fraction))
fee_sats = platform_fee + operator_fee
net_sats = principal_sats - fee_sats
if net_sats <= 0:
raise ValueError("fee >= principal; nothing to withdraw")
data = CreateWithdrawData(
title=body.get("title") or f"bitSpire Cash-In {machine.id}",
min_withdrawable=net_sats,
max_withdrawable=net_sats,
uses=1,
wait_time=int(body.get("wait_time") or 1),
is_unique=False,
extra={
"source": "bitspire",
"type": "cash_in",
"principal_sats": principal_sats,
"fee_sats": fee_sats,
"nostr_sender_pubkey": sender,
"nostr_event_id": body.get("client_ref") or request.event_id,
},
)
link = await create_withdraw_link(data, auth.wallet.id)
lnurl = create_lnurl_from_baseurl(link)
logger.info(
f"spirekeeper: create_withdraw machine={machine.id} "
f"principal={principal_sats} fee={fee_sats} (super={platform_fee} "
f"op={operator_fee}) net={net_sats} link={link.id}"
)
return {
"link_id": link.id,
# `Lnurl.__str__` is the raw URL — wallets need the bech32 LNURL1…
# (lud01). Mirror withdraw's `_populate_lnurl` field convention.
"lnurl": str(lnurl.bech32),
"lnurl_url": str(lnurl.url),
"net_sats": net_sats,
"principal_sats": principal_sats,
"fee_sats": fee_sats,
}
def register_create_withdraw_rpc() -> None:
"""Register `create_withdraw` with the lnbits nostr transport. Soft-fails if
the transport doesn't expose `register_rpc` (older lnbits)."""
try:
from lnbits.core.services.nostr_transport.dispatcher import ( # type: ignore
AUTH_WALLET,
register_rpc,
)
except ImportError:
logger.warning(
"spirekeeper: nostr-transport register_rpc unavailable; "
"'create_withdraw' not registered (secure cash-in over RPC disabled)"
)
return
register_rpc(_RPC_NAME, handle_create_withdraw, AUTH_WALLET)
logger.info("spirekeeper: registered nostr-transport RPC 'create_withdraw'")