feat(cash-in): super_config.max_cash_in_sats per-tx cap + UI (#31)
Some checks failed
ci.yml / feat(cash-in): super_config.max_cash_in_sats per-tx cap + UI (#31) (pull_request) Failing after 0s

Wires the server-side per-transaction cash-in ceiling the `create_withdraw`
handler already enforces (it read the value defensively via getattr; this
makes it a first-class config field).

- migrations.py m012: ADD COLUMN super_config.max_cash_in_sats INTEGER (NULL
  = no cap).
- models.py: SuperConfig.max_cash_in_sats + UpdateSuperConfigData field with a
  >= 0 validator.
- super-fee dialog: a "Max cash-in per transaction (sats)" input; blank sends
  null (the PUT skips null, preserving the current value — set 0 to reject
  every cash-in). crud `update_super_config` and the PUT endpoint flow the
  field through automatically (dynamic dict update; check_super_user gated).

Why a sats cap and not the bunker ACL: the ACL / usage caps (#28) gate call
*rate*, not *sats*, and `principal_sats` is necessarily ATM-attested — so a
single in-rate call could request an arbitrarily large payout. This bounds a
compromised/buggy machine to one capped transaction.

Verified on the dev stack: m012 runs, the model round-trips the column
(GET returns the set value), and a negative value is rejected.
This commit is contained in:
Padreug 2026-06-22 12:51:59 +02:00
commit 9abf695fd5
4 changed files with 54 additions and 3 deletions

View file

@ -845,3 +845,18 @@ 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"
)