diff --git a/migrations.py b/migrations.py
index d3bcece..6ede0eb 100644
--- a/migrations.py
+++ b/migrations.py
@@ -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"
+ )
diff --git a/models.py b/models.py
index d779633..99ee552 100644
--- a/models.py
+++ b/models.py
@@ -482,6 +482,10 @@ 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
@@ -489,6 +493,7 @@ 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",
@@ -501,6 +506,14 @@ 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 c9878d0..053133d 100644
--- a/static/js/index.js
+++ b/static/js/index.js
@@ -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
diff --git a/templates/spirekeeper/index.html b/templates/spirekeeper/index.html
index 53c30d1..01bb973 100644
--- a/templates/spirekeeper/index.html
+++ b/templates/spirekeeper/index.html
@@ -1381,6 +1381,11 @@
label="Super fee destination wallet_id"
hint="LNbits wallet that collects the platform fee"
class="q-mb-md" dense outlined>
+