feat(v2): CRUD + per-direction fee cap validation (#38 2/5)
Wires the new directional fee fields through the write path and adds the 15%-per-direction cap guard at the API boundary. CRUD: - create_machine INSERT includes operator_cash_in_fee_fraction + operator_cash_out_fee_fraction (Pydantic default 0 covers existing callers). - update_machine + update_super_config already use generic update_data dict, so the new fields flow through without per-call changes. API boundary (views_api.py): - _assert_machine_fee_cap_safe(operator_in, operator_out) — pairs candidates against current super-config, rejects if (super_X + operator_X) > 0.15 for either direction. Called from api_create_machine + api_update_machine (with partial-PATCH semantics: unset fields keep the machine's current value). - _assert_super_config_cap_safe(new_super_in, new_super_out) — fetches every active machine; rejects with offending-machine name in the 400 detail if any (effective_super + operator) > cap. Called from api_update_super_config. Cap rounding: float arithmetic rounds (super + operator) to 4 decimals (DECIMAL(10,4) precision) before comparing, so the IEEE 754 surprise 0.10 + 0.05 = 0.15000000000000002 doesn't trip the cap. Tests (13 cases, all green): both directions hit the cap, exact-cap acceptance, no-super-config degenerate path, partial PATCH on super-config, offending-machine name in error detail, empty-fleet vacuous safety. Refs: aiolabs/satmachineadmin#38 (Layer 1), coord-log §2026-06-01T07:22Z (cap lock at 15% per direction, defense in depth). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
d87d0db324
commit
4cd0041923
3 changed files with 342 additions and 2 deletions
126
views_api.py
126
views_api.py
|
|
@ -14,6 +14,7 @@ from lnbits.core.models import User
|
|||
from lnbits.decorators import check_super_user, check_user_exists
|
||||
from lnbits.utils.nostr import normalize_public_key
|
||||
|
||||
from .calculations import MAX_FEE_FRACTION_PER_DIRECTION
|
||||
from .cassette_transport import (
|
||||
CassetteTransportError,
|
||||
OperatorIdentityMissing,
|
||||
|
|
@ -48,6 +49,7 @@ from .crud import (
|
|||
get_settlements_for_operator,
|
||||
get_stuck_settlements_for_operator,
|
||||
get_super_config,
|
||||
list_all_active_machines,
|
||||
list_cassette_configs_for_machine,
|
||||
lp_is_onboarded,
|
||||
replace_commission_splits,
|
||||
|
|
@ -147,6 +149,105 @@ async def _assert_no_pubkey_collision(machine_npub: str) -> None:
|
|||
)
|
||||
|
||||
|
||||
async def _assert_machine_fee_cap_safe(
|
||||
operator_in: float,
|
||||
operator_out: float,
|
||||
) -> None:
|
||||
"""Reject create/update if (super_X + operator_X) > 0.15 for either
|
||||
direction. Locked at 15% per coord-log §2026-06-01T07:22Z; defense in
|
||||
depth — the bitspire consumer enforces the same cap on the wire-format
|
||||
side (aiolabs/lamassu-next#57).
|
||||
|
||||
Fetches the current super-config singleton to pair against the
|
||||
candidate per-machine fractions. NULL super-config (uninitialised
|
||||
instance) treats super contribution as 0 — the cap then degenerates
|
||||
to a pure operator-fee check.
|
||||
"""
|
||||
super_config = await get_super_config()
|
||||
super_in = (
|
||||
float(super_config.super_cash_in_fee_fraction) if super_config else 0.0
|
||||
)
|
||||
super_out = (
|
||||
float(super_config.super_cash_out_fee_fraction) if super_config else 0.0
|
||||
)
|
||||
# Fields are stored as DECIMAL(10,4) and Pydantic validators round to
|
||||
# 4 decimals on the way in, so the source-of-truth precision is 1e-4.
|
||||
# Round the float-arithmetic sum to that precision before comparison so
|
||||
# `0.10 + 0.05 = 0.15000000000000002` (IEEE 754) doesn't trip the cap.
|
||||
total_in = round(super_in + operator_in, 4)
|
||||
total_out = round(super_out + operator_out, 4)
|
||||
if total_in > MAX_FEE_FRACTION_PER_DIRECTION:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
(
|
||||
f"cash-in fee cap exceeded: super {super_in:.4f} + operator "
|
||||
f"{operator_in:.4f} = {total_in:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}"
|
||||
),
|
||||
)
|
||||
if total_out > MAX_FEE_FRACTION_PER_DIRECTION:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
(
|
||||
f"cash-out fee cap exceeded: super {super_out:.4f} + operator "
|
||||
f"{operator_out:.4f} = {total_out:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _assert_super_config_cap_safe(
|
||||
new_super_in: float | None,
|
||||
new_super_out: float | None,
|
||||
) -> None:
|
||||
"""Reject super-config update if any active machine's
|
||||
(new_super + operator) > 0.15 for either direction. Same cap policy
|
||||
as _assert_machine_fee_cap_safe but checked across the fleet because
|
||||
a super update affects every machine.
|
||||
|
||||
`None` for a direction means "no change" — pulls the current value
|
||||
from super-config so the cap check still runs against the resulting
|
||||
post-update state.
|
||||
"""
|
||||
current = await get_super_config()
|
||||
effective_in = (
|
||||
float(new_super_in)
|
||||
if new_super_in is not None
|
||||
else (float(current.super_cash_in_fee_fraction) if current else 0.0)
|
||||
)
|
||||
effective_out = (
|
||||
float(new_super_out)
|
||||
if new_super_out is not None
|
||||
else (float(current.super_cash_out_fee_fraction) if current else 0.0)
|
||||
)
|
||||
machines = await list_all_active_machines()
|
||||
for m in machines:
|
||||
op_in = float(m.operator_cash_in_fee_fraction)
|
||||
op_out = float(m.operator_cash_out_fee_fraction)
|
||||
# Round to DECIMAL(10,4) precision — see _assert_machine_fee_cap_safe
|
||||
# for the IEEE 754 motivation.
|
||||
total_in = round(effective_in + op_in, 4)
|
||||
total_out = round(effective_out + op_out, 4)
|
||||
if total_in > MAX_FEE_FRACTION_PER_DIRECTION:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
(
|
||||
f"super cash-in fee {effective_in:.4f} would exceed cap "
|
||||
f"on machine {m.id} ({m.name or m.machine_npub[:12]}): "
|
||||
f"+ operator {op_in:.4f} = "
|
||||
f"{total_in:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}"
|
||||
),
|
||||
)
|
||||
if total_out > MAX_FEE_FRACTION_PER_DIRECTION:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
(
|
||||
f"super cash-out fee {effective_out:.4f} would exceed cap "
|
||||
f"on machine {m.id} ({m.name or m.machine_npub[:12]}): "
|
||||
f"+ operator {op_out:.4f} = "
|
||||
f"{total_out:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Machines
|
||||
# =============================================================================
|
||||
|
|
@ -158,6 +259,10 @@ async def api_create_machine(
|
|||
) -> Machine:
|
||||
await _assert_wallet_owned_by(data.wallet_id, user.id)
|
||||
await _assert_no_pubkey_collision(data.machine_npub)
|
||||
await _assert_machine_fee_cap_safe(
|
||||
data.operator_cash_in_fee_fraction,
|
||||
data.operator_cash_out_fee_fraction,
|
||||
)
|
||||
machine = await create_machine(user.id, data)
|
||||
return machine
|
||||
|
||||
|
|
@ -194,6 +299,23 @@ async def api_update_machine(
|
|||
raise HTTPException(HTTPStatus.NOT_FOUND, "Machine not found")
|
||||
if data.wallet_id is not None:
|
||||
await _assert_wallet_owned_by(data.wallet_id, user.id)
|
||||
# Cap check against post-update state — partial PATCH semantics:
|
||||
# unset directional fields keep the machine's current value.
|
||||
if (
|
||||
data.operator_cash_in_fee_fraction is not None
|
||||
or data.operator_cash_out_fee_fraction is not None
|
||||
):
|
||||
candidate_in = (
|
||||
data.operator_cash_in_fee_fraction
|
||||
if data.operator_cash_in_fee_fraction is not None
|
||||
else float(machine.operator_cash_in_fee_fraction)
|
||||
)
|
||||
candidate_out = (
|
||||
data.operator_cash_out_fee_fraction
|
||||
if data.operator_cash_out_fee_fraction is not None
|
||||
else float(machine.operator_cash_out_fee_fraction)
|
||||
)
|
||||
await _assert_machine_fee_cap_safe(candidate_in, candidate_out)
|
||||
updated = await update_machine(machine_id, data)
|
||||
if updated is None:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Machine not found")
|
||||
|
|
@ -808,6 +930,10 @@ async def api_update_super_config(
|
|||
commission, plus the destination wallet for collecting it. The fee is
|
||||
enforced before the operator's own commission_splits ruleset fires
|
||||
(see distribution.process_settlement)."""
|
||||
await _assert_super_config_cap_safe(
|
||||
data.super_cash_in_fee_fraction,
|
||||
data.super_cash_out_fee_fraction,
|
||||
)
|
||||
config = await update_super_config(data)
|
||||
if config is None:
|
||||
raise HTTPException(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue