Compare commits
No commits in common. "622c1be5d3167a001b5ec00b36a294810f805648" and "56ac4a69e9239ed3c7b6c6d63e018f5fee9dc5b9" have entirely different histories.
622c1be5d3
...
56ac4a69e9
6 changed files with 6 additions and 197 deletions
11
__init__.py
11
__init__.py
|
|
@ -4,7 +4,6 @@ from fastapi import APIRouter
|
||||||
from lnbits.tasks import create_permanent_unique_task
|
from lnbits.tasks import create_permanent_unique_task
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from .cashin_transport import register_create_withdraw_rpc
|
|
||||||
from .crud import db
|
from .crud import db
|
||||||
from .nostr_transport_roster import register_with_lnbits as register_roster_with_lnbits
|
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
|
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")
|
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_generic_router)
|
||||||
spirekeeper_ext.include_router(spirekeeper_api_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
|
# wallet, not an auto-created machine wallet. Soft-fails on lnbits
|
||||||
# versions that don't yet expose `register_roster_resolver`.
|
# versions that don't yet expose `register_roster_resolver`.
|
||||||
register_roster_with_lnbits()
|
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__ = [
|
__all__ = [
|
||||||
|
|
|
||||||
|
|
@ -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'")
|
|
||||||
|
|
@ -845,18 +845,3 @@ async def m011_machine_npub_nullable(db):
|
||||||
"CREATE UNIQUE INDEX IF NOT EXISTS dca_machines_wallet_id_uq "
|
"CREATE UNIQUE INDEX IF NOT EXISTS dca_machines_wallet_id_uq "
|
||||||
"ON dca_machines (wallet_id)"
|
"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"
|
|
||||||
)
|
|
||||||
|
|
|
||||||
13
models.py
13
models.py
|
|
@ -482,10 +482,6 @@ class SuperConfig(BaseModel):
|
||||||
super_cash_in_fee_fraction: float = 0.0
|
super_cash_in_fee_fraction: float = 0.0
|
||||||
super_cash_out_fee_fraction: float = 0.0
|
super_cash_out_fee_fraction: float = 0.0
|
||||||
super_fee_wallet_id: str | None
|
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
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -493,7 +489,6 @@ class UpdateSuperConfigData(BaseModel):
|
||||||
super_cash_in_fee_fraction: float | None = None
|
super_cash_in_fee_fraction: float | None = None
|
||||||
super_cash_out_fee_fraction: float | None = None
|
super_cash_out_fee_fraction: float | None = None
|
||||||
super_fee_wallet_id: str | None = None
|
super_fee_wallet_id: str | None = None
|
||||||
max_cash_in_sats: int | None = None
|
|
||||||
|
|
||||||
@validator(
|
@validator(
|
||||||
"super_cash_in_fee_fraction",
|
"super_cash_in_fee_fraction",
|
||||||
|
|
@ -506,14 +501,6 @@ class UpdateSuperConfigData(BaseModel):
|
||||||
raise ValueError("super fee fraction must be between 0 and 1")
|
raise ValueError("super fee fraction must be between 0 and 1")
|
||||||
return round(float(v), 4)
|
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.
|
# Operator UX action carriers — partial-tx and balance-settlement features.
|
||||||
|
|
|
||||||
|
|
@ -103,8 +103,7 @@ window.app = Vue.createApp({
|
||||||
data: {
|
data: {
|
||||||
super_cash_in_fee_fraction: 0,
|
super_cash_in_fee_fraction: 0,
|
||||||
super_cash_out_fee_fraction: 0,
|
super_cash_out_fee_fraction: 0,
|
||||||
super_fee_wallet_id: '',
|
super_fee_wallet_id: ''
|
||||||
max_cash_in_sats: null
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -577,8 +576,7 @@ window.app = Vue.createApp({
|
||||||
this.superConfig?.super_cash_in_fee_fraction ?? 0,
|
this.superConfig?.super_cash_in_fee_fraction ?? 0,
|
||||||
super_cash_out_fee_fraction:
|
super_cash_out_fee_fraction:
|
||||||
this.superConfig?.super_cash_out_fee_fraction ?? 0,
|
this.superConfig?.super_cash_out_fee_fraction ?? 0,
|
||||||
super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || '',
|
super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || ''
|
||||||
max_cash_in_sats: this.superConfig?.max_cash_in_sats ?? null
|
|
||||||
}
|
}
|
||||||
this.superFeeDialog.show = true
|
this.superFeeDialog.show = true
|
||||||
},
|
},
|
||||||
|
|
@ -606,21 +604,6 @@ window.app = Vue.createApp({
|
||||||
Number(d.super_cash_in_fee_fraction),
|
Number(d.super_cash_in_fee_fraction),
|
||||||
Number(d.super_cash_out_fee_fraction)
|
Number(d.super_cash_out_fee_fraction)
|
||||||
)) return
|
)) 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
|
this.superFeeDialog.saving = true
|
||||||
try {
|
try {
|
||||||
const {data} = await LNbits.api.request(
|
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_in_fee_fraction: Number(d.super_cash_in_fee_fraction),
|
||||||
super_cash_out_fee_fraction: Number(d.super_cash_out_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,
|
super_fee_wallet_id: (d.super_fee_wallet_id || '').trim() || null
|
||||||
max_cash_in_sats: cap
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
this.superConfig = data
|
this.superConfig = data
|
||||||
|
|
|
||||||
|
|
@ -1381,11 +1381,6 @@
|
||||||
label="Super fee destination wallet_id"
|
label="Super fee destination wallet_id"
|
||||||
hint="LNbits wallet that collects the platform fee"
|
hint="LNbits wallet that collects the platform fee"
|
||||||
class="q-mb-md" dense outlined></q-input>
|
class="q-mb-md" dense outlined></q-input>
|
||||||
<q-input v-model.number="superFeeDialog.data.max_cash_in_sats"
|
|
||||||
label="Max cash-in per transaction (sats — blank = no cap)"
|
|
||||||
hint="Server-side ceiling on a single cash-in's principal. The ATM attests the amount; this bounds a compromised/buggy machine to one capped tx."
|
|
||||||
type="number" step="1" min="0"
|
|
||||||
class="q-mb-md" dense outlined></q-input>
|
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-actions align="right">
|
<q-card-actions align="right">
|
||||||
<q-btn flat label="Cancel" v-close-popup></q-btn>
|
<q-btn flat label="Cancel" v-close-popup></q-btn>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue