Compare commits
2 commits
f518b15f5b
...
607b71e796
| Author | SHA1 | Date | |
|---|---|---|---|
| 607b71e796 | |||
| 56ac4a69e9 |
2 changed files with 140 additions and 3 deletions
11
__init__.py
11
__init__.py
|
|
@ -4,6 +4,7 @@ from fastapi import APIRouter
|
|||
from lnbits.tasks import create_permanent_unique_task
|
||||
from loguru import logger
|
||||
|
||||
from .cashin_transport import register_create_withdraw_rpc
|
||||
from .crud import db
|
||||
from .nostr_transport_roster import register_with_lnbits as register_roster_with_lnbits
|
||||
from .tasks import wait_for_cassette_state_events, wait_for_paid_invoices
|
||||
|
|
@ -13,9 +14,7 @@ from .views_api import spirekeeper_api_router
|
|||
logger.info("spirekeeper v2 loaded")
|
||||
|
||||
|
||||
spirekeeper_ext: APIRouter = APIRouter(
|
||||
prefix="/spirekeeper", tags=["DCA Admin"]
|
||||
)
|
||||
spirekeeper_ext: APIRouter = APIRouter(prefix="/spirekeeper", tags=["DCA Admin"])
|
||||
spirekeeper_ext.include_router(spirekeeper_generic_router)
|
||||
spirekeeper_ext.include_router(spirekeeper_api_router)
|
||||
|
||||
|
|
@ -57,6 +56,12 @@ def spirekeeper_start():
|
|||
# wallet, not an auto-created machine wallet. Soft-fails on lnbits
|
||||
# versions that don't yet expose `register_roster_resolver`.
|
||||
register_roster_with_lnbits()
|
||||
# Secure cash-in (#31): register the `create_withdraw` nostr-transport RPC
|
||||
# so the ATM requests a server-priced, server-stamped cash-in withdraw link
|
||||
# over the bunker-signed transport — amount/fee/attribution derived
|
||||
# server-side, never client-supplied. Soft-fails if `register_rpc` isn't
|
||||
# exposed by this lnbits.
|
||||
register_create_withdraw_rpc()
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
|
|
|||
132
cashin_transport.py
Normal file
132
cashin_transport.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
"""
|
||||
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(lnurl),
|
||||
"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'")
|
||||
Loading…
Add table
Add a link
Reference in a new issue