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>
-