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

@ -103,7 +103,8 @@ window.app = Vue.createApp({
data: {
super_cash_in_fee_fraction: 0,
super_cash_out_fee_fraction: 0,
super_fee_wallet_id: ''
super_fee_wallet_id: '',
max_cash_in_sats: null
}
},
@ -576,7 +577,8 @@ 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 || ''
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
},
@ -604,6 +606,21 @@ 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(
@ -611,7 +628,8 @@ 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
super_fee_wallet_id: (d.super_fee_wallet_id || '').trim() || null,
max_cash_in_sats: cap
}
)
this.superConfig = data