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
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:
parent
607b71e796
commit
9abf695fd5
4 changed files with 54 additions and 3 deletions
|
|
@ -845,3 +845,18 @@ 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,6 +482,10 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -489,6 +493,7 @@ 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",
|
||||||
|
|
@ -501,6 +506,14 @@ 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,7 +103,8 @@ 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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -576,7 +577,8 @@ 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
|
||||||
},
|
},
|
||||||
|
|
@ -604,6 +606,21 @@ 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(
|
||||||
|
|
@ -611,7 +628,8 @@ 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,6 +1381,11 @@
|
||||||
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