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