diff --git a/__init__.py b/__init__.py index d40a411..39de3d8 100644 --- a/__init__.py +++ b/__init__.py @@ -4,7 +4,6 @@ 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 @@ -14,7 +13,9 @@ 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) @@ -56,12 +57,6 @@ 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__ = [ diff --git a/cashin_transport.py b/cashin_transport.py deleted file mode 100644 index c041f48..0000000 --- a/cashin_transport.py +++ /dev/null @@ -1,135 +0,0 @@ -""" -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'") diff --git a/migrations.py b/migrations.py index 6ede0eb..d3bcece 100644 --- a/migrations.py +++ b/migrations.py @@ -845,18 +845,3 @@ async def m011_machine_npub_nullable(db): "CREATE UNIQUE INDEX IF NOT EXISTS dca_machines_wallet_id_uq " "ON dca_machines (wallet_id)" ) - - -async def m012_add_max_cash_in_sats(db): - """Server-side per-transaction cash-in ceiling (aiolabs/spirekeeper#31). - - The secure `create_withdraw` RPC derives fee/net/attribution server-side, - but `principal_sats` is necessarily ATM-attested (only the hardware knows - how much cash went in). The bunker ACL / usage caps gate call *rate*, not - *sats*, so a single in-rate call could request an arbitrarily large payout. - `max_cash_in_sats` bounds that: the handler rejects a cash-in whose - principal exceeds it. NULL = no cap. - """ - await db.execute( - "ALTER TABLE spirekeeper.super_config ADD COLUMN max_cash_in_sats INTEGER" - ) diff --git a/models.py b/models.py index 99ee552..d779633 100644 --- a/models.py +++ b/models.py @@ -482,10 +482,6 @@ class SuperConfig(BaseModel): super_cash_in_fee_fraction: float = 0.0 super_cash_out_fee_fraction: float = 0.0 super_fee_wallet_id: str | None - # Per-transaction cash-in ceiling in sats (#31). The bunker ACL gates call - # rate, not sats, so this bounds a single ATM-attested principal. NULL = no - # cap. - max_cash_in_sats: int | None = None updated_at: datetime @@ -493,7 +489,6 @@ class UpdateSuperConfigData(BaseModel): super_cash_in_fee_fraction: float | None = None super_cash_out_fee_fraction: float | None = None super_fee_wallet_id: str | None = None - max_cash_in_sats: int | None = None @validator( "super_cash_in_fee_fraction", @@ -506,14 +501,6 @@ class UpdateSuperConfigData(BaseModel): raise ValueError("super fee fraction must be between 0 and 1") return round(float(v), 4) - @validator("max_cash_in_sats") - def _cap_non_negative(cls, v): - if v is None: - return v - if v < 0: - raise ValueError("max_cash_in_sats must be >= 0") - return int(v) - # ============================================================================= # Operator UX action carriers — partial-tx and balance-settlement features. diff --git a/static/js/index.js b/static/js/index.js index 053133d..c9878d0 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -103,8 +103,7 @@ window.app = Vue.createApp({ data: { super_cash_in_fee_fraction: 0, super_cash_out_fee_fraction: 0, - super_fee_wallet_id: '', - max_cash_in_sats: null + super_fee_wallet_id: '' } }, @@ -577,8 +576,7 @@ window.app = Vue.createApp({ this.superConfig?.super_cash_in_fee_fraction ?? 0, super_cash_out_fee_fraction: this.superConfig?.super_cash_out_fee_fraction ?? 0, - super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || '', - max_cash_in_sats: this.superConfig?.max_cash_in_sats ?? null + super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || '' } this.superFeeDialog.show = true }, @@ -606,21 +604,6 @@ window.app = Vue.createApp({ Number(d.super_cash_in_fee_fraction), Number(d.super_cash_out_fee_fraction) )) return - // Blank cap field -> null (the PUT skips null, so the existing value is - // preserved rather than cleared — set 0 to reject every cash-in). - const cap = - d.max_cash_in_sats === '' || - d.max_cash_in_sats === null || - d.max_cash_in_sats === undefined - ? null - : Number(d.max_cash_in_sats) - if (cap !== null && (!Number.isInteger(cap) || cap < 0)) { - Quasar.Notify.create({ - type: 'negative', - message: 'Max cash-in must be a non-negative whole number of sats' - }) - return - } this.superFeeDialog.saving = true try { const {data} = await LNbits.api.request( @@ -628,8 +611,7 @@ window.app = Vue.createApp({ { super_cash_in_fee_fraction: Number(d.super_cash_in_fee_fraction), super_cash_out_fee_fraction: Number(d.super_cash_out_fee_fraction), - super_fee_wallet_id: (d.super_fee_wallet_id || '').trim() || null, - max_cash_in_sats: cap + super_fee_wallet_id: (d.super_fee_wallet_id || '').trim() || null } ) this.superConfig = data diff --git a/templates/spirekeeper/index.html b/templates/spirekeeper/index.html index 01bb973..53c30d1 100644 --- a/templates/spirekeeper/index.html +++ b/templates/spirekeeper/index.html @@ -1381,11 +1381,6 @@ label="Super fee destination wallet_id" hint="LNbits wallet that collects the platform fee" class="q-mb-md" dense outlined> -