Compare commits
5 commits
df6e8e0a22
...
1cebefcde5
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cebefcde5 | |||
| 3014962563 | |||
| 34e324b4c5 | |||
| 5dbd7314f4 | |||
| 427cad33de |
10 changed files with 303 additions and 226 deletions
|
|
@ -3,7 +3,7 @@ Cassette-config Nostr transport — operator ↔ ATM kind-30078 publish + consum
|
|||
|
||||
Per the locked design at aiolabs/satmachineadmin#29 (paired with
|
||||
lamassu-next#56) and the dcd0874 privacy-by-default pivot, the operator
|
||||
publishes denomination-keyed cassette config to a target ATM via:
|
||||
publishes position-keyed cassette config to a target ATM via:
|
||||
|
||||
kind = 30078 (NIP-78, replaceable)
|
||||
tags = [
|
||||
|
|
@ -304,7 +304,7 @@ async def publish_to_atm(
|
|||
logger.info(
|
||||
f"satmachineadmin: published kind-30078 cassette config to ATM "
|
||||
f"{atm_pubkey_hex[:12]}... (event_id={signed['id'][:12]}..., "
|
||||
f"machine_id={machine.id}, denominations={list(payload.denominations.keys())})"
|
||||
f"machine_id={machine.id}, positions={sorted(payload.positions.keys())})"
|
||||
)
|
||||
return signed
|
||||
|
||||
|
|
|
|||
68
crud.py
68
crud.py
|
|
@ -1379,14 +1379,14 @@ async def upsert_fleet_snapshot(
|
|||
|
||||
|
||||
# =============================================================================
|
||||
# Cassette configs — operator-driven ATM cassette inventory (#29).
|
||||
# Cassette configs — operator-driven ATM cassette inventory (#29 v1.1).
|
||||
# =============================================================================
|
||||
# Row lifecycle per #29:
|
||||
# - First population for a (machine_id, denomination) pair → apply_bootstrap_state
|
||||
# - First population for a (machine_id, position) pair → apply_bootstrap_state
|
||||
# (consumer reading the ATM's one-shot bitspire-cassettes-state event)
|
||||
# - Operator edit of count or position → update_cassette_config (refuses to
|
||||
# create new rows; the denomination set is hardware-determined)
|
||||
# - Row creation/deletion for a new denomination → admin only, via ATM
|
||||
# - Operator edit of denomination or count → update_cassette_config
|
||||
# (refuses to create new rows; the slot count is hardware-determined)
|
||||
# - Row creation/deletion for a new position → admin only, via ATM
|
||||
# re-provisioning + new bootstrap event (not exposed in v1 here)
|
||||
|
||||
|
||||
|
|
@ -1407,12 +1407,12 @@ def _should_apply_bootstrap_state(
|
|||
|
||||
|
||||
async def get_cassette_config(
|
||||
machine_id: str, denomination: int
|
||||
machine_id: str, position: int
|
||||
) -> Optional[CassetteConfig]:
|
||||
return await db.fetchone(
|
||||
"SELECT * FROM satoshimachine.cassette_configs "
|
||||
"WHERE machine_id = :mid AND denomination = :denom",
|
||||
{"mid": machine_id, "denom": denomination},
|
||||
"WHERE machine_id = :mid AND position = :pos",
|
||||
{"mid": machine_id, "pos": position},
|
||||
CassetteConfig,
|
||||
)
|
||||
|
||||
|
|
@ -1422,7 +1422,7 @@ async def list_cassette_configs_for_machine(
|
|||
) -> List[CassetteConfig]:
|
||||
return await db.fetchall(
|
||||
"SELECT * FROM satoshimachine.cassette_configs "
|
||||
"WHERE machine_id = :mid ORDER BY position, denomination",
|
||||
"WHERE machine_id = :mid ORDER BY position",
|
||||
{"mid": machine_id},
|
||||
CassetteConfig,
|
||||
)
|
||||
|
|
@ -1430,18 +1430,18 @@ async def list_cassette_configs_for_machine(
|
|||
|
||||
async def update_cassette_config(
|
||||
machine_id: str,
|
||||
denomination: int,
|
||||
position: int,
|
||||
data: UpsertCassetteConfigData,
|
||||
*,
|
||||
updated_by: Optional[str] = None,
|
||||
) -> Optional[CassetteConfig]:
|
||||
"""Operator-driven row update: change count and/or position for a single
|
||||
cassette. Refuses to create new rows — those only land via
|
||||
"""Operator-driven row update: change denomination and/or count for a
|
||||
single cassette slot. Refuses to create new rows — those only land via
|
||||
apply_bootstrap_state() consuming an ATM bootstrap event (per #29 row
|
||||
lifecycle: hardware-determined denomination set, not operator-creatable).
|
||||
Returns None if the (machine_id, denomination) row doesn't exist.
|
||||
lifecycle: hardware-determined slot count, not operator-creatable).
|
||||
Returns None if the (machine_id, position) row doesn't exist.
|
||||
"""
|
||||
existing = await get_cassette_config(machine_id, denomination)
|
||||
existing = await get_cassette_config(machine_id, position)
|
||||
if existing is None:
|
||||
return None
|
||||
update_data: dict = {k: v for k, v in data.dict().items() if v is not None}
|
||||
|
|
@ -1451,13 +1451,13 @@ async def update_cassette_config(
|
|||
update_data["updated_by"] = updated_by
|
||||
set_clause = ", ".join(f"{k} = :{k}" for k in update_data)
|
||||
update_data["mid"] = machine_id
|
||||
update_data["denom"] = denomination
|
||||
update_data["pos"] = position
|
||||
await db.execute(
|
||||
f"UPDATE satoshimachine.cassette_configs SET {set_clause} "
|
||||
"WHERE machine_id = :mid AND denomination = :denom",
|
||||
"WHERE machine_id = :mid AND position = :pos",
|
||||
update_data,
|
||||
)
|
||||
return await get_cassette_config(machine_id, denomination)
|
||||
return await get_cassette_config(machine_id, position)
|
||||
|
||||
|
||||
async def apply_bootstrap_state(
|
||||
|
|
@ -1467,17 +1467,18 @@ async def apply_bootstrap_state(
|
|||
payload: PublishCassettesPayload,
|
||||
) -> bool:
|
||||
"""Consume an ATM-published kind-30078 bitspire-cassettes-state:<m> event
|
||||
and upsert one cassette_configs row per denomination in the payload.
|
||||
and upsert one cassette_configs row per position in the payload.
|
||||
|
||||
Returns True if the upsert ran; False if any existing row for this
|
||||
machine already references this event_id (idempotent on relay
|
||||
re-delivery / restart).
|
||||
|
||||
Populates both the operator-believed columns (count, position,
|
||||
Populates both the operator-believed columns (denomination, count,
|
||||
updated_at, updated_by='atm-bootstrap') AND the v2 reverse-channel
|
||||
columns (state_count, state_at, state_event_id) so the operator's
|
||||
initial view matches the ATM's reported state. v2 reconciliation UI
|
||||
will diverge them when continuous reverse-channel events land.
|
||||
columns (state_denomination, state_count, state_at, state_event_id)
|
||||
so the operator's initial view matches the ATM's reported state. v2
|
||||
reconciliation UI will diverge them when continuous reverse-channel
|
||||
events land + the operator subsequently edits.
|
||||
"""
|
||||
existing_first = await db.fetchone(
|
||||
"SELECT state_event_id FROM satoshimachine.cassette_configs "
|
||||
|
|
@ -1495,30 +1496,33 @@ async def apply_bootstrap_state(
|
|||
return False
|
||||
|
||||
now = datetime.now()
|
||||
for denom, row in payload.denominations.items():
|
||||
for pos, row in payload.positions.items():
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO satoshimachine.cassette_configs
|
||||
(machine_id, denomination, count, position, updated_at,
|
||||
updated_by, state_count, state_at, state_event_id)
|
||||
VALUES (:mid, :denom, :count, :pos, :now, :by,
|
||||
:state_count, :state_at, :event_id)
|
||||
ON CONFLICT (machine_id, denomination) DO UPDATE SET
|
||||
(machine_id, position, denomination, count, updated_at,
|
||||
updated_by, state_denomination, state_count, state_at,
|
||||
state_event_id)
|
||||
VALUES (:mid, :pos, :denom, :count, :now, :by,
|
||||
:state_denom, :state_count, :state_at, :event_id)
|
||||
ON CONFLICT (machine_id, position) DO UPDATE SET
|
||||
denomination = excluded.denomination,
|
||||
count = excluded.count,
|
||||
position = excluded.position,
|
||||
updated_at = excluded.updated_at,
|
||||
updated_by = excluded.updated_by,
|
||||
state_denomination = excluded.state_denomination,
|
||||
state_count = excluded.state_count,
|
||||
state_at = excluded.state_at,
|
||||
state_event_id = excluded.state_event_id
|
||||
""",
|
||||
{
|
||||
"mid": machine_id,
|
||||
"denom": denom,
|
||||
"pos": pos,
|
||||
"denom": row.denomination,
|
||||
"count": row.count,
|
||||
"pos": row.position,
|
||||
"now": now,
|
||||
"by": "atm-bootstrap",
|
||||
"state_denom": row.denomination,
|
||||
"state_count": row.count,
|
||||
"state_at": event_created_at,
|
||||
"event_id": event_id,
|
||||
|
|
|
|||
111
models.py
111
models.py
|
|
@ -549,41 +549,64 @@ class SettleBalanceData(BaseModel):
|
|||
|
||||
|
||||
# =============================================================================
|
||||
# Cassette configs — operator-driven ATM cassette inventory (#29).
|
||||
# Cassette configs — operator-driven ATM cassette inventory (#29 v1.1).
|
||||
# =============================================================================
|
||||
# Schema is denomination-keyed per the locked design (#29 body + the
|
||||
# 06:40Z coord-log audit): every ATM-side layer below the wire keys on
|
||||
# denomination (state-store.ts:54, hal-service.ts:116/189). The
|
||||
# satmachineadmin schema mirrors this so the operator UI can't author a
|
||||
# duplicate-denomination payload that the ATM would silently collapse.
|
||||
# Schema is position-keyed per the coordinated v1.1 redesign at coord-log
|
||||
# 2026-05-30T18:30Z + 18:45Z. The earlier denomination-keyed shape (m007)
|
||||
# was wrong: real machines have N cassettes of the same denomination for
|
||||
# cash-out throughput, and operators need to swap cartridge denominations
|
||||
# during refill ($20 bay becomes a $50 bay) without re-provisioning.
|
||||
#
|
||||
# Position is operator-assignable display order (and used by the ATM as
|
||||
# the HAL slot-index assignment), not the addressable unit.
|
||||
# Wire shape:
|
||||
# {"positions": {"<position_str>": {"denomination": N, "count": M}}}
|
||||
#
|
||||
# state_count / state_at / state_event_id are reserved nullable from day 1
|
||||
# for the v2 reverse-channel reconciliation consumer (bitspire-cassettes-
|
||||
# state:<atm_pubkey_hex>). v1 populates them on bootstrap-event receipt
|
||||
# but the UI doesn't render reconciliation.
|
||||
# Editable surface per row:
|
||||
# - denomination: yes (operator swaps cartridges during refill)
|
||||
# - count: yes (refill / decrement)
|
||||
# Read-only per row:
|
||||
# - position: hardware bay number; the slot count is fixed by the
|
||||
# dispenser model (e.g., Tejo has 4 positions).
|
||||
#
|
||||
# No "denomination must be unique within payload" constraint: multiple
|
||||
# same-denom cassettes are operationally valid. The ATM HAL distributes
|
||||
# a dispense request greedy across all positions matching the requested
|
||||
# denomination (lamassu-next#56 v1.1 HAL refactor).
|
||||
#
|
||||
# state_* columns are reserved nullable for the v2 reverse-channel
|
||||
# reconciliation consumer (bitspire-cassettes-state:<atm_pubkey_hex>).
|
||||
# v1 populates them on bootstrap-event receipt but the UI doesn't render
|
||||
# reconciliation. state_denomination (added in m008) lets v2 highlight
|
||||
# operator-believed-vs-ATM-reported denomination drift per slot.
|
||||
|
||||
|
||||
class CassetteConfig(BaseModel):
|
||||
machine_id: str
|
||||
position: int
|
||||
denomination: int
|
||||
count: int
|
||||
position: int
|
||||
updated_at: datetime
|
||||
updated_by: Optional[str]
|
||||
state_denomination: Optional[int]
|
||||
state_count: Optional[int]
|
||||
state_at: Optional[datetime]
|
||||
state_event_id: Optional[str]
|
||||
|
||||
|
||||
class UpsertCassetteConfigData(BaseModel):
|
||||
"""Operator edits a single cassette row's count or position from the
|
||||
dashboard. Both fields optional; pass only those changed."""
|
||||
"""Operator edits a single cassette row's denomination or count from
|
||||
the dashboard. Both fields optional; pass only those changed.
|
||||
Position is not edited — it's the row's identity (hardware bay)."""
|
||||
|
||||
denomination: Optional[int] = None
|
||||
count: Optional[int] = None
|
||||
position: Optional[int] = None
|
||||
|
||||
@validator("denomination")
|
||||
def denomination_positive(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if v <= 0:
|
||||
raise ValueError("denomination must be > 0")
|
||||
return v
|
||||
|
||||
@validator("count")
|
||||
def count_non_negative(cls, v):
|
||||
|
|
@ -593,26 +616,18 @@ class UpsertCassetteConfigData(BaseModel):
|
|||
raise ValueError("count must be >= 0")
|
||||
return v
|
||||
|
||||
@validator("position")
|
||||
def position_positive(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if v <= 0:
|
||||
raise ValueError("position must be > 0")
|
||||
return v
|
||||
|
||||
|
||||
class CassettePayloadRow(BaseModel):
|
||||
"""One denomination's payload values in the wire-format
|
||||
`{"denominations": {"<denom>": {"position", "count"}}}`."""
|
||||
"""One position's payload values in the wire-format
|
||||
`{"positions": {"<pos>": {"denomination", "count"}}}`."""
|
||||
|
||||
position: int
|
||||
denomination: int
|
||||
count: int
|
||||
|
||||
@validator("position")
|
||||
def position_positive(cls, v):
|
||||
@validator("denomination")
|
||||
def denomination_positive(cls, v):
|
||||
if v <= 0:
|
||||
raise ValueError("position must be > 0")
|
||||
raise ValueError("denomination must be > 0")
|
||||
return v
|
||||
|
||||
@validator("count")
|
||||
|
|
@ -628,45 +643,45 @@ class PublishCassettesPayload(BaseModel):
|
|||
- operator → ATM (d-tag `bitspire-cassettes:<atm_pubkey_hex>`)
|
||||
- ATM → operator (d-tag `bitspire-cassettes-state:<atm_pubkey_hex>`)
|
||||
|
||||
Wire shape: `{"denominations": {"<denom_str>": {"position", "count"}}}`.
|
||||
Wire shape: `{"positions": {"<pos_str>": {"denomination", "count"}}}`.
|
||||
JSON object keys are always strings; the validator coerces back to
|
||||
int on parse. The denomination key set MUST match what the receiver
|
||||
already has (no add / no remove from this payload).
|
||||
int on parse. The position key set MUST match what the receiver
|
||||
already has (slot count is hardware-fixed; no add/remove from this
|
||||
payload).
|
||||
|
||||
No denomination-unique constraint: multiple same-denom cassettes are
|
||||
operationally valid (cash-out throughput on a popular denom).
|
||||
"""
|
||||
|
||||
denominations: dict[int, CassettePayloadRow]
|
||||
positions: dict[int, CassettePayloadRow]
|
||||
|
||||
@validator("denominations", pre=True)
|
||||
@validator("positions", pre=True)
|
||||
def coerce_string_keys_to_int(cls, v):
|
||||
if not isinstance(v, dict):
|
||||
raise ValueError("denominations must be a dict")
|
||||
raise ValueError("positions must be a dict")
|
||||
out = {}
|
||||
for k, val in v.items():
|
||||
try:
|
||||
key_int = int(k)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(
|
||||
f"denomination key {k!r} is not an int"
|
||||
f"position key {k!r} is not an int"
|
||||
) from exc
|
||||
if key_int <= 0:
|
||||
raise ValueError(f"denomination must be > 0 (got {key_int})")
|
||||
raise ValueError(f"position must be > 0 (got {key_int})")
|
||||
out[key_int] = val
|
||||
return out
|
||||
|
||||
@validator("denominations")
|
||||
def no_duplicate_positions(cls, v):
|
||||
positions = [row.position for row in v.values()]
|
||||
if len(set(positions)) != len(positions):
|
||||
raise ValueError("duplicate position values in payload")
|
||||
return v
|
||||
|
||||
def to_wire_dict(self) -> dict:
|
||||
"""Serialise back to the wire format with string keys for JSON
|
||||
object compatibility. Used by the publisher to build the kind-30078
|
||||
event content before NIP-44 v2 encryption."""
|
||||
return {
|
||||
"denominations": {
|
||||
str(denom): {"position": row.position, "count": row.count}
|
||||
for denom, row in self.denominations.items()
|
||||
"positions": {
|
||||
str(pos): {
|
||||
"denomination": row.denomination,
|
||||
"count": row.count,
|
||||
}
|
||||
for pos, row in self.positions.items()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -205,10 +205,10 @@ window.app = Vue.createApp({
|
|||
},
|
||||
cassettesTable: {
|
||||
columns: [
|
||||
{name: 'position', label: 'Bay', field: 'position', align: 'right'},
|
||||
{name: 'denomination', label: 'Denomination', field: 'denomination', align: 'right'},
|
||||
{name: 'count', label: 'Count', field: 'count', align: 'right'},
|
||||
{name: 'position', label: 'Position', field: 'position', align: 'right'},
|
||||
{name: 'state_count', label: 'ATM-reported', field: 'state_count', align: 'right'},
|
||||
{name: 'state', label: 'ATM-reported', field: 'state_denomination', align: 'right'},
|
||||
{name: 'updated_at', label: 'Updated', field: 'updated_at', align: 'left'}
|
||||
],
|
||||
pagination: {rowsPerPage: 0} // hide pagination — cassette count is small
|
||||
|
|
@ -816,15 +816,16 @@ window.app = Vue.createApp({
|
|||
},
|
||||
|
||||
markCassetteDirty(row) {
|
||||
// Find pristine match by denomination and compare; flip _dirty +
|
||||
// overall dirty flag accordingly.
|
||||
// Find pristine match by position (the row identity) and compare;
|
||||
// flip _dirty + overall dirty flag accordingly. Editable fields
|
||||
// are denomination + count; position is the immutable row key.
|
||||
const pristine = this.machineDetail.cassettesPristine.find(
|
||||
p => p.denomination === row.denomination
|
||||
p => p.position === row.position
|
||||
)
|
||||
row._dirty =
|
||||
!pristine ||
|
||||
Number(row.count) !== Number(pristine.count) ||
|
||||
Number(row.position) !== Number(pristine.position)
|
||||
Number(row.denomination) !== Number(pristine.denomination) ||
|
||||
Number(row.count) !== Number(pristine.count)
|
||||
this.machineDetail.cassettesDirty =
|
||||
this.machineDetail.cassetteEdits.some(r => r._dirty)
|
||||
},
|
||||
|
|
@ -844,18 +845,18 @@ window.app = Vue.createApp({
|
|||
},
|
||||
|
||||
async submitCassettePublish() {
|
||||
// Build the PublishCassettesPayload shape:
|
||||
// { denominations: { "<denom>": { position, count }, ... } }
|
||||
// The API enforces the denomination set matches what's stored —
|
||||
// Build the PublishCassettesPayload shape (v1.1, position-keyed):
|
||||
// { positions: { "<pos>": { denomination, count }, ... } }
|
||||
// The API enforces the position set matches what's stored —
|
||||
// since we only edit existing rows, this should always pass.
|
||||
const denominations = {}
|
||||
const positions = {}
|
||||
for (const row of this.machineDetail.cassetteEdits) {
|
||||
denominations[String(row.denomination)] = {
|
||||
position: Number(row.position),
|
||||
positions[String(row.position)] = {
|
||||
denomination: Number(row.denomination),
|
||||
count: Number(row.count)
|
||||
}
|
||||
}
|
||||
const payload = {denominations}
|
||||
const payload = {positions}
|
||||
this.machineDetail.cassettesPublishing = true
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
|
|
|
|||
2
tasks.py
2
tasks.py
|
|
@ -453,7 +453,7 @@ async def _handle_cassette_state_event(
|
|||
if applied:
|
||||
logger.info(
|
||||
f"satmachineadmin: applied bootstrap state event {event_id[:12]}... "
|
||||
f"to machine {machine.id} ({len(payload.denominations)} cassettes)"
|
||||
f"to machine {machine.id} ({len(payload.positions)} cassettes)"
|
||||
)
|
||||
else:
|
||||
# Replay: event_id already on file. Normal on relay reconnect.
|
||||
|
|
|
|||
|
|
@ -1023,7 +1023,7 @@
|
|||
<q-table v-if="machineDetail.cassetteEdits.length"
|
||||
dense flat
|
||||
:rows="machineDetail.cassetteEdits"
|
||||
row-key="denomination"
|
||||
row-key="position"
|
||||
:columns="cassettesTable.columns"
|
||||
:pagination="cassettesTable.pagination"
|
||||
hide-pagination>
|
||||
|
|
@ -1032,10 +1032,15 @@
|
|||
:style="props.row._dirty
|
||||
? {boxShadow: 'inset 4px 0 0 0 #fdd835'}
|
||||
: {}">
|
||||
<q-td key="position" class="text-right">
|
||||
<b v-text="'Bay ' + props.row.position"></b>
|
||||
</q-td>
|
||||
<q-td key="denomination" class="text-right">
|
||||
<b v-text="props.row.denomination"></b>
|
||||
<span :style="{fontSize: '0.85em', opacity: 0.6}"
|
||||
v-text="' ' + (machineDetail.machine.fiat_code || '')"></span>
|
||||
<q-input v-model.number="props.row.denomination"
|
||||
type="number" min="1" step="1" dense outlined
|
||||
:suffix="machineDetail.machine.fiat_code || ''"
|
||||
:style="{width: '140px', display: 'inline-block'}"
|
||||
@update:model-value="markCassetteDirty(props.row)"></q-input>
|
||||
</q-td>
|
||||
<q-td key="count" class="text-right">
|
||||
<q-input v-model.number="props.row.count" type="number"
|
||||
|
|
@ -1043,16 +1048,15 @@
|
|||
:style="{width: '120px', display: 'inline-block'}"
|
||||
@update:model-value="markCassetteDirty(props.row)"></q-input>
|
||||
</q-td>
|
||||
<q-td key="position" class="text-right">
|
||||
<q-input v-model.number="props.row.position" type="number"
|
||||
min="1" step="1" dense outlined
|
||||
:style="{width: '80px', display: 'inline-block'}"
|
||||
@update:model-value="markCassetteDirty(props.row)"></q-input>
|
||||
</q-td>
|
||||
<q-td key="state_count" class="text-right">
|
||||
<span v-if="props.row.state_count !== null"
|
||||
:style="{fontSize: '0.85em', opacity: 0.7}"
|
||||
v-text="props.row.state_count"></span>
|
||||
<q-td key="state" class="text-right">
|
||||
<span v-if="props.row.state_denomination !== null"
|
||||
:style="{fontSize: '0.85em', opacity: 0.7}">
|
||||
<span v-text="props.row.state_denomination"></span>
|
||||
<span :style="{opacity: 0.6}"
|
||||
v-text="' ' + (machineDetail.machine.fiat_code || '')"></span>
|
||||
<span :style="{opacity: 0.6}"> · </span>
|
||||
<span v-text="'×' + props.row.state_count"></span>
|
||||
</span>
|
||||
<span v-else :style="{opacity: 0.4}">—</span>
|
||||
</q-td>
|
||||
<q-td key="updated_at">
|
||||
|
|
@ -1094,17 +1098,16 @@
|
|||
<p class="q-mb-sm">Sending to ATM:</p>
|
||||
<q-list dense bordered>
|
||||
<q-item v-for="row in machineDetail.cassetteEdits"
|
||||
:key="row.denomination">
|
||||
:key="row.position">
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<b v-text="row.denomination + ' ' +
|
||||
(machineDetail.machine.fiat_code || '')"></b>
|
||||
<b v-text="'Bay ' + row.position"></b>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-item-label caption>
|
||||
position
|
||||
<b v-text="row.position"></b>
|
||||
<b v-text="row.denomination + ' ' +
|
||||
(machineDetail.machine.fiat_code || '')"></b>
|
||||
· count
|
||||
<b v-text="row.count"></b>
|
||||
</q-item-label>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
"""
|
||||
Tests for the v1 cassette-config layer (aiolabs/satmachineadmin#29).
|
||||
Tests for the v1.1 cassette-config layer (aiolabs/satmachineadmin#29).
|
||||
|
||||
Covers the pure pieces that don't need a live DB:
|
||||
- Pydantic validator behaviour on PublishCassettesPayload + the row /
|
||||
upsert models (denomination key coercion, integer ranges, no-duplicate-
|
||||
positions, wire-format round-trip)
|
||||
upsert models (position key coercion, integer ranges, multiple-same-
|
||||
denomination payloads, wire-format round-trip)
|
||||
- _should_apply_bootstrap_state dedup helper (extracted from
|
||||
apply_bootstrap_state so the relay-re-delivery decision is testable
|
||||
without a database round-trip)
|
||||
|
|
@ -14,6 +14,11 @@ machine ordering, etc.) follow the project convention from
|
|||
test_deposit_currency.py: "Layer 2 is an endpoint-level behaviour better
|
||||
covered by an integration test against a running LNbits; tracked in #26
|
||||
as a follow-up." Smoke-tested manually via the dev container.
|
||||
|
||||
Wire shape pivot from m007 → m008 is the v1.1 coordination point per
|
||||
coord-log 2026-05-30T18:30Z + 18:45Z: position is the row identity,
|
||||
denomination + count are operator-editable per row, multiple same-denom
|
||||
cassettes are valid.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
|
@ -34,116 +39,127 @@ from ..models import (
|
|||
class TestPublishCassettesPayload:
|
||||
"""The kind-30078 content payload, bidirectional (operator→ATM and
|
||||
ATM→operator share the shape). String JSON keys must coerce to int;
|
||||
duplicate positions must reject; per-row int constraints enforced."""
|
||||
per-row int constraints enforced; multiple same-denom rows are valid."""
|
||||
|
||||
def test_happy_path_coerces_string_keys_to_int(self):
|
||||
p = PublishCassettesPayload(
|
||||
denominations={
|
||||
"20": {"position": 1, "count": 49},
|
||||
"50": {"position": 2, "count": 100},
|
||||
positions={
|
||||
"1": {"denomination": 20, "count": 49},
|
||||
"2": {"denomination": 50, "count": 100},
|
||||
}
|
||||
)
|
||||
assert set(p.denominations.keys()) == {20, 50}
|
||||
assert p.denominations[20].position == 1
|
||||
assert p.denominations[20].count == 49
|
||||
assert p.denominations[50].count == 100
|
||||
assert set(p.positions.keys()) == {1, 2}
|
||||
assert p.positions[1].denomination == 20
|
||||
assert p.positions[1].count == 49
|
||||
assert p.positions[2].denomination == 50
|
||||
assert p.positions[2].count == 100
|
||||
|
||||
def test_wire_dict_round_trip_restringifies_keys(self):
|
||||
"""to_wire_dict() must restringify denomination keys so the
|
||||
resulting JSON is parseable by clients (including the ATM-side
|
||||
nostr-tools NIP-44 v2 consumer per the byte-compat cross-test)."""
|
||||
"""to_wire_dict() must restringify position keys so the resulting
|
||||
JSON is parseable by clients (including the ATM-side nostr-tools
|
||||
NIP-44 v2 consumer per the byte-compat cross-test)."""
|
||||
original = PublishCassettesPayload(
|
||||
denominations={
|
||||
"20": {"position": 1, "count": 49},
|
||||
"50": {"position": 2, "count": 100},
|
||||
positions={
|
||||
"1": {"denomination": 20, "count": 49},
|
||||
"2": {"denomination": 50, "count": 100},
|
||||
}
|
||||
)
|
||||
wire = original.to_wire_dict()
|
||||
assert wire == {
|
||||
"denominations": {
|
||||
"20": {"position": 1, "count": 49},
|
||||
"50": {"position": 2, "count": 100},
|
||||
"positions": {
|
||||
"1": {"denomination": 20, "count": 49},
|
||||
"2": {"denomination": 50, "count": 100},
|
||||
}
|
||||
}
|
||||
# And the wire form round-trips back through the parser cleanly.
|
||||
reparsed = PublishCassettesPayload(**wire)
|
||||
assert reparsed.denominations == original.denominations
|
||||
assert reparsed.positions == original.positions
|
||||
|
||||
def test_rejects_non_int_key(self):
|
||||
def test_accepts_multiple_same_denomination_cassettes(self):
|
||||
"""v1.1 operational case: real machines have N cassettes loaded
|
||||
with the same denomination for cash-out throughput. The wire shape
|
||||
must accept this, and we explicitly do NOT validate uniqueness on
|
||||
denomination. Coord-log 2026-05-30T18:45Z bitspire response."""
|
||||
p = PublishCassettesPayload(
|
||||
positions={
|
||||
"1": {"denomination": 20, "count": 100},
|
||||
"2": {"denomination": 20, "count": 100},
|
||||
"3": {"denomination": 50, "count": 50},
|
||||
"4": {"denomination": 100, "count": 25},
|
||||
}
|
||||
)
|
||||
assert len(p.positions) == 4
|
||||
denoms = [row.denomination for row in p.positions.values()]
|
||||
assert denoms.count(20) == 2 # two $20 cassettes
|
||||
assert sorted(denoms) == [20, 20, 50, 100]
|
||||
|
||||
def test_rejects_non_int_position_key(self):
|
||||
with pytest.raises(ValueError) as exc:
|
||||
PublishCassettesPayload(
|
||||
denominations={"abc": {"position": 1, "count": 1}}
|
||||
positions={"abc": {"denomination": 20, "count": 1}}
|
||||
)
|
||||
assert "is not an int" in str(exc.value)
|
||||
|
||||
def test_rejects_non_positive_denomination(self):
|
||||
def test_rejects_non_positive_position(self):
|
||||
with pytest.raises(ValueError) as exc:
|
||||
PublishCassettesPayload(
|
||||
denominations={"0": {"position": 1, "count": 1}}
|
||||
positions={"0": {"denomination": 20, "count": 1}}
|
||||
)
|
||||
assert "denomination must be > 0" in str(exc.value)
|
||||
assert "position must be > 0" in str(exc.value)
|
||||
|
||||
def test_rejects_negative_denomination(self):
|
||||
def test_rejects_negative_position(self):
|
||||
with pytest.raises(ValueError) as exc:
|
||||
PublishCassettesPayload(
|
||||
denominations={"-20": {"position": 1, "count": 1}}
|
||||
positions={"-1": {"denomination": 20, "count": 1}}
|
||||
)
|
||||
assert "denomination must be > 0" in str(exc.value)
|
||||
|
||||
def test_rejects_duplicate_position(self):
|
||||
"""Two cassettes can't occupy the same physical slot. The schema
|
||||
PK is (machine_id, denomination), so duplicates land via the
|
||||
payload; reject at the validator layer before the publish path
|
||||
builds an event the ATM will misinterpret."""
|
||||
with pytest.raises(ValueError) as exc:
|
||||
PublishCassettesPayload(
|
||||
denominations={
|
||||
"20": {"position": 1, "count": 49},
|
||||
"50": {"position": 1, "count": 100},
|
||||
}
|
||||
)
|
||||
assert "duplicate position" in str(exc.value)
|
||||
assert "position must be > 0" in str(exc.value)
|
||||
|
||||
def test_rejects_negative_count(self):
|
||||
with pytest.raises(ValueError):
|
||||
PublishCassettesPayload(
|
||||
denominations={"20": {"position": 1, "count": -1}}
|
||||
positions={"1": {"denomination": 20, "count": -1}}
|
||||
)
|
||||
|
||||
def test_rejects_zero_position(self):
|
||||
def test_rejects_zero_denomination(self):
|
||||
with pytest.raises(ValueError):
|
||||
PublishCassettesPayload(
|
||||
denominations={"20": {"position": 0, "count": 1}}
|
||||
positions={"1": {"denomination": 0, "count": 49}}
|
||||
)
|
||||
|
||||
def test_rejects_negative_denomination(self):
|
||||
with pytest.raises(ValueError):
|
||||
PublishCassettesPayload(
|
||||
positions={"1": {"denomination": -20, "count": 49}}
|
||||
)
|
||||
|
||||
def test_allows_zero_count(self):
|
||||
"""An empty cassette is a legal state — operator must be able to
|
||||
record `count=0` after a dispatcher pulled the cassette mid-day."""
|
||||
p = PublishCassettesPayload(
|
||||
denominations={"20": {"position": 1, "count": 0}}
|
||||
positions={"1": {"denomination": 20, "count": 0}}
|
||||
)
|
||||
assert p.denominations[20].count == 0
|
||||
assert p.positions[1].count == 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CassettePayloadRow — per-row int constraints (single-row tests)
|
||||
# CassettePayloadRow — per-row int constraints
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCassettePayloadRow:
|
||||
def test_happy_path(self):
|
||||
row = CassettePayloadRow(position=1, count=49)
|
||||
assert row.position == 1
|
||||
row = CassettePayloadRow(denomination=20, count=49)
|
||||
assert row.denomination == 20
|
||||
assert row.count == 49
|
||||
|
||||
@pytest.mark.parametrize("bad_position", [0, -1, -100])
|
||||
def test_rejects_non_positive_position(self, bad_position):
|
||||
@pytest.mark.parametrize("bad_denom", [0, -1, -100])
|
||||
def test_rejects_non_positive_denomination(self, bad_denom):
|
||||
with pytest.raises(ValueError):
|
||||
CassettePayloadRow(position=bad_position, count=1)
|
||||
CassettePayloadRow(denomination=bad_denom, count=1)
|
||||
|
||||
def test_rejects_negative_count(self):
|
||||
with pytest.raises(ValueError):
|
||||
CassettePayloadRow(position=1, count=-1)
|
||||
CassettePayloadRow(denomination=20, count=-1)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
@ -153,16 +169,19 @@ class TestCassettePayloadRow:
|
|||
|
||||
class TestUpsertCassetteConfigData:
|
||||
"""Operator-driven row edit. Both fields optional; same int constraints
|
||||
as the wire-format row but applied independently per-edit."""
|
||||
as the wire-format row but applied independently per-edit. Position is
|
||||
NOT editable — it's the row's identity (the hardware bay number)."""
|
||||
|
||||
def test_partial_update_count_only(self):
|
||||
d = UpsertCassetteConfigData(count=80)
|
||||
assert d.count == 80
|
||||
assert d.position is None
|
||||
assert d.denomination is None
|
||||
|
||||
def test_partial_update_position_only(self):
|
||||
d = UpsertCassetteConfigData(position=3)
|
||||
assert d.position == 3
|
||||
def test_partial_update_denomination_only(self):
|
||||
"""v1.1 operational case: operator records a cartridge swap at
|
||||
refill — slot 1 was $20, dispatcher replaced with $50."""
|
||||
d = UpsertCassetteConfigData(denomination=50)
|
||||
assert d.denomination == 50
|
||||
assert d.count is None
|
||||
|
||||
def test_empty_update_is_legal(self):
|
||||
|
|
@ -170,15 +189,15 @@ class TestUpsertCassetteConfigData:
|
|||
circuits a no-op on empty payload (no SQL emitted)."""
|
||||
d = UpsertCassetteConfigData()
|
||||
assert d.count is None
|
||||
assert d.position is None
|
||||
assert d.denomination is None
|
||||
|
||||
def test_rejects_negative_count(self):
|
||||
with pytest.raises(ValueError):
|
||||
UpsertCassetteConfigData(count=-1)
|
||||
|
||||
def test_rejects_non_positive_position(self):
|
||||
def test_rejects_non_positive_denomination(self):
|
||||
with pytest.raises(ValueError):
|
||||
UpsertCassetteConfigData(position=0)
|
||||
UpsertCassetteConfigData(denomination=0)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
@ -191,7 +210,7 @@ class TestShouldApplyBootstrapState:
|
|||
decision is testable without a DB. Logic: apply if-and-only-if the
|
||||
existing row's state_event_id differs from the incoming event_id.
|
||||
|
||||
In v1 the ATM publishes the bootstrap event exactly once per machine,
|
||||
In v1.1 the ATM publishes the bootstrap event exactly once per machine,
|
||||
so this is sufficient for replay protection. v2 will need a
|
||||
`last_state_created_at` watermark in addition (per bitspire's
|
||||
`meta.lastKnownConfigCreatedAt` on the ATM side) — flagged in #29's
|
||||
|
|
|
|||
|
|
@ -5,13 +5,14 @@ and `cassette_transport.decrypt_and_parse_state_event`).
|
|||
Covers the consumer-side validation path end-to-end without standing up
|
||||
the full nostrclient relay subscription:
|
||||
- happy path: signed event from a known ATM → decrypt → parse → returns
|
||||
a PublishCassettesPayload
|
||||
- sig-verify failure path (covered at the transport-decrypt level + the
|
||||
handler-level rejection test)
|
||||
a position-keyed PublishCassettesPayload
|
||||
- multiple same-denom cassettes (v1.1 operational case) — round-trips
|
||||
- tampered ciphertext → CassetteEventDecodeError
|
||||
- unknown sender pubkey → CassetteEventDecodeError (well-formed but
|
||||
- wrong operator privkey → CassetteEventDecodeError (well-formed but
|
||||
decrypt fails because conversation key is wrong)
|
||||
- malformed pubkey → CassetteEventDecodeError
|
||||
- missing fields → CassetteEventDecodeError
|
||||
- decrypted garbage / wrong-shape JSON → CassetteEventDecodeError
|
||||
|
||||
Full handler tests (the dispatch through verify_event → get_machine_by_atm_
|
||||
pubkey_hex → apply_bootstrap_state) need a live LNbits DB; they're
|
||||
|
|
@ -95,21 +96,40 @@ class TestDecryptAndParseStateEvent:
|
|||
|
||||
def test_happy_path(self):
|
||||
payload = PublishCassettesPayload(
|
||||
denominations={
|
||||
"20": {"position": 1, "count": 49},
|
||||
"50": {"position": 2, "count": 100},
|
||||
positions={
|
||||
"1": {"denomination": 20, "count": 49},
|
||||
"2": {"denomination": 50, "count": 100},
|
||||
}
|
||||
)
|
||||
event = _make_state_event(payload)
|
||||
recovered = decrypt_and_parse_state_event(event, _OP_SEC)
|
||||
assert sorted(recovered.denominations.keys()) == [20, 50]
|
||||
assert recovered.denominations[20].position == 1
|
||||
assert recovered.denominations[20].count == 49
|
||||
assert recovered.denominations[50].count == 100
|
||||
assert sorted(recovered.positions.keys()) == [1, 2]
|
||||
assert recovered.positions[1].denomination == 20
|
||||
assert recovered.positions[1].count == 49
|
||||
assert recovered.positions[2].denomination == 50
|
||||
assert recovered.positions[2].count == 100
|
||||
|
||||
def test_round_trips_multiple_same_denomination(self):
|
||||
"""v1.1 operational case from coord-log 18:45Z: real machines
|
||||
load multiple cassettes with the same denomination."""
|
||||
payload = PublishCassettesPayload(
|
||||
positions={
|
||||
"1": {"denomination": 20, "count": 100},
|
||||
"2": {"denomination": 20, "count": 100},
|
||||
"3": {"denomination": 20, "count": 100},
|
||||
"4": {"denomination": 20, "count": 100},
|
||||
}
|
||||
)
|
||||
event = _make_state_event(payload)
|
||||
recovered = decrypt_and_parse_state_event(event, _OP_SEC)
|
||||
assert len(recovered.positions) == 4
|
||||
for pos in (1, 2, 3, 4):
|
||||
assert recovered.positions[pos].denomination == 20
|
||||
assert recovered.positions[pos].count == 100
|
||||
|
||||
def test_tampered_content_rejected(self):
|
||||
payload = PublishCassettesPayload(
|
||||
denominations={"20": {"position": 1, "count": 49}}
|
||||
positions={"1": {"denomination": 20, "count": 49}}
|
||||
)
|
||||
event = _make_state_event(payload)
|
||||
# Flip a base64 character — corrupts the ciphertext or MAC
|
||||
|
|
@ -124,7 +144,7 @@ class TestDecryptAndParseStateEvent:
|
|||
different hmac_key, so MAC verification inside NIP-44 v2 decrypt
|
||||
fails — surfaced as CassetteEventDecodeError."""
|
||||
payload = PublishCassettesPayload(
|
||||
denominations={"20": {"position": 1, "count": 49}}
|
||||
positions={"1": {"denomination": 20, "count": 49}}
|
||||
)
|
||||
event = _make_state_event(payload)
|
||||
wrong_sec = "00" * 31 + "03"
|
||||
|
|
@ -133,7 +153,7 @@ class TestDecryptAndParseStateEvent:
|
|||
|
||||
def test_malformed_sender_pubkey_rejected(self):
|
||||
payload = PublishCassettesPayload(
|
||||
denominations={"20": {"position": 1, "count": 49}}
|
||||
positions={"1": {"denomination": 20, "count": 49}}
|
||||
)
|
||||
event = _make_state_event(payload)
|
||||
event["pubkey"] = "not-a-real-pubkey"
|
||||
|
|
@ -142,7 +162,9 @@ class TestDecryptAndParseStateEvent:
|
|||
|
||||
def test_missing_content_rejected(self):
|
||||
event = _make_state_event(
|
||||
PublishCassettesPayload(denominations={"20": {"position": 1, "count": 49}})
|
||||
PublishCassettesPayload(
|
||||
positions={"1": {"denomination": 20, "count": 49}}
|
||||
)
|
||||
)
|
||||
del event["content"]
|
||||
with pytest.raises(CassetteEventDecodeError):
|
||||
|
|
@ -150,7 +172,9 @@ class TestDecryptAndParseStateEvent:
|
|||
|
||||
def test_missing_pubkey_rejected(self):
|
||||
event = _make_state_event(
|
||||
PublishCassettesPayload(denominations={"20": {"position": 1, "count": 49}})
|
||||
PublishCassettesPayload(
|
||||
positions={"1": {"denomination": 20, "count": 49}}
|
||||
)
|
||||
)
|
||||
del event["pubkey"]
|
||||
with pytest.raises(CassetteEventDecodeError):
|
||||
|
|
@ -159,7 +183,6 @@ class TestDecryptAndParseStateEvent:
|
|||
def test_decrypted_garbage_json_rejected(self):
|
||||
"""If the plaintext decrypts but isn't JSON, we surface an error
|
||||
rather than crashing the consumer loop."""
|
||||
# Encrypt something that isn't JSON
|
||||
ck = get_conversation_key(_ATM_SEC, _OP_PUB)
|
||||
bad_plaintext_event = {
|
||||
"kind": 30078,
|
||||
|
|
@ -176,7 +199,7 @@ class TestDecryptAndParseStateEvent:
|
|||
assert "JSON" in str(exc.value) or "didn't validate" in str(exc.value)
|
||||
|
||||
def test_decrypted_json_with_wrong_shape_rejected(self):
|
||||
"""Well-formed JSON but missing the 'denominations' field is
|
||||
"""Well-formed JSON but missing the 'positions' field is
|
||||
a payload-shape failure, not a decrypt failure."""
|
||||
ck = get_conversation_key(_ATM_SEC, _OP_PUB)
|
||||
bad_shape_event = {
|
||||
|
|
|
|||
|
|
@ -308,6 +308,16 @@ _BITSPIRE_FIXTURE = {
|
|||
}
|
||||
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason=(
|
||||
"v1.1 wire-shape pivot (coord-log 2026-05-30T18:30Z + 18:45Z): the "
|
||||
"13:15Z fixture above is encoded with the old `{denominations: ...}` "
|
||||
"wire shape; the v1.1 wire shape is `{positions: {<pos>: "
|
||||
"{denomination, count}}}`. Bitspire will post a fresh fixture against "
|
||||
"the v1.1 shape; once posted, swap `_BITSPIRE_FIXTURE` for the new "
|
||||
"JSON and drop this skip (v1.1 commit g on the PR)."
|
||||
)
|
||||
)
|
||||
class TestBitspireCrossTest:
|
||||
"""Byte-compat cross-test between our hand-rolled NIP-44 v2 (`nip44.py`)
|
||||
and the nostr-tools NIP-44 v2 impl that bitspire uses on the ATM side
|
||||
|
|
|
|||
62
views_api.py
62
views_api.py
|
|
@ -774,15 +774,16 @@ async def api_update_super_config(
|
|||
|
||||
|
||||
# =============================================================================
|
||||
# Cassette configs (#29 v1) — per-machine ATM cassette inventory
|
||||
# Cassette configs (#29 v1.1) — per-machine ATM cassette inventory
|
||||
# =============================================================================
|
||||
# v1 surface, paired with aiolabs/lamassu-next#56 ATM-side. Two endpoints:
|
||||
# v1.1 surface, paired with aiolabs/lamassu-next#56 ATM-side. Two endpoints:
|
||||
# GET /machines/{id}/cassettes — list rows for the operator UI
|
||||
# POST /machines/{id}/cassettes/publish — apply edits + publish kind-30078
|
||||
#
|
||||
# Row creation (new (machine_id, denomination) pairs) is admin-only via the
|
||||
# bootstrap consumer task — denomination set is hardware-determined.
|
||||
# Operator-side flow is edit-and-publish over the existing rows only.
|
||||
# Row creation (new (machine_id, position) pairs) is admin-only via the
|
||||
# bootstrap consumer task — slot count is hardware-determined. Operator-
|
||||
# side flow is edit-and-publish over the existing rows only; the editable
|
||||
# fields per row are denomination and count.
|
||||
|
||||
|
||||
@satmachineadmin_api_router.get(
|
||||
|
|
@ -793,9 +794,9 @@ async def api_list_machine_cassettes(
|
|||
machine_id: str, user: User = Depends(check_user_exists)
|
||||
) -> list[CassetteConfig]:
|
||||
"""List the cassette config rows for one of the operator's machines,
|
||||
ordered by position then denomination. Empty list = ATM hasn't yet
|
||||
published its bootstrap event (or the bootstrap consumer hasn't
|
||||
processed it yet); UI should show a "waiting for ATM" state."""
|
||||
ordered by position. Empty list = ATM hasn't yet published its
|
||||
bootstrap event (or the bootstrap consumer hasn't processed it yet);
|
||||
UI should show a "waiting for ATM" state."""
|
||||
await _machine_owned_by(machine_id, user.id)
|
||||
return await list_cassette_configs_for_machine(machine_id)
|
||||
|
||||
|
|
@ -810,11 +811,11 @@ async def api_publish_machine_cassettes(
|
|||
user: User = Depends(check_user_exists),
|
||||
) -> list[CassetteConfig]:
|
||||
"""Operator submits the full per-machine cassette state for publish to
|
||||
the ATM. Validates the denomination set matches what's currently in
|
||||
cassette_configs for the machine (defensive — UI prevents add/remove
|
||||
but API enforces), upserts each row, then encrypts + signs + publishes
|
||||
a kind-30078 event tagged with d=bitspire-cassettes:<atm_pubkey_hex>
|
||||
and p=<atm_pubkey_hex>.
|
||||
the ATM. Validates the position set matches what's currently in
|
||||
cassette_configs for the machine (slot count is hardware-fixed),
|
||||
upserts each row, then encrypts + signs + publishes a kind-30078
|
||||
event tagged with d=bitspire-cassettes:<atm_pubkey_hex> and
|
||||
p=<atm_pubkey_hex>.
|
||||
|
||||
The `<m>` placeholder in the published d-tag is the ATM's hex pubkey
|
||||
from machine.machine_npub (canonicalised via normalize_public_key),
|
||||
|
|
@ -825,8 +826,8 @@ async def api_publish_machine_cassettes(
|
|||
can refresh its table from one round-trip.
|
||||
|
||||
Errors:
|
||||
400 — payload denomination set doesn't match the machine's stored
|
||||
set (operator publishing a cassette that doesn't exist on the
|
||||
400 — payload position set doesn't match the machine's stored set
|
||||
(operator publishing for a slot that doesn't exist on the
|
||||
ATM; or the bootstrap hasn't landed yet so no rows exist)
|
||||
400 — operator hasn't onboarded a Nostr identity
|
||||
503 — signer offline / client-side-only, or nostrclient extension
|
||||
|
|
@ -836,8 +837,8 @@ async def api_publish_machine_cassettes(
|
|||
machine = await _machine_owned_by(machine_id, user.id)
|
||||
|
||||
existing = await list_cassette_configs_for_machine(machine_id)
|
||||
existing_denoms = {row.denomination for row in existing}
|
||||
incoming_denoms = set(payload.denominations.keys())
|
||||
existing_positions = {row.position for row in existing}
|
||||
incoming_positions = set(payload.positions.keys())
|
||||
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
|
|
@ -850,29 +851,30 @@ async def api_publish_machine_cassettes(
|
|||
"receipt."
|
||||
),
|
||||
)
|
||||
if existing_denoms != incoming_denoms:
|
||||
missing = existing_denoms - incoming_denoms
|
||||
extra = incoming_denoms - existing_denoms
|
||||
if existing_positions != incoming_positions:
|
||||
missing = existing_positions - incoming_positions
|
||||
extra = incoming_positions - existing_positions
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
(
|
||||
"Payload denomination set doesn't match the machine's "
|
||||
f"stored set. Missing from payload: {sorted(missing)}; "
|
||||
f"extra in payload: {sorted(extra)}. "
|
||||
"Denomination set is hardware-determined — re-provision "
|
||||
"the ATM via atm-tui to add/remove cassettes, then "
|
||||
"re-publish."
|
||||
"Payload position set doesn't match the machine's stored "
|
||||
f"set. Missing from payload: {sorted(missing)}; extra in "
|
||||
f"payload: {sorted(extra)}. Slot count is hardware-fixed "
|
||||
"— re-provision the ATM via atm-tui to add/remove physical "
|
||||
"bays, then re-publish."
|
||||
),
|
||||
)
|
||||
|
||||
# Apply each per-row edit so the operator-believed state on
|
||||
# satmachineadmin reflects the published payload, even if the ATM
|
||||
# ack lands later (v2). updated_by audit-stamps the operator user id.
|
||||
for denom, row in payload.denominations.items():
|
||||
for pos, row in payload.positions.items():
|
||||
updated = await update_cassette_config(
|
||||
machine_id,
|
||||
denom,
|
||||
UpsertCassetteConfigData(count=row.count, position=row.position),
|
||||
pos,
|
||||
UpsertCassetteConfigData(
|
||||
denomination=row.denomination, count=row.count
|
||||
),
|
||||
updated_by=user.id,
|
||||
)
|
||||
if updated is None:
|
||||
|
|
@ -880,7 +882,7 @@ async def api_publish_machine_cassettes(
|
|||
# concurrent delete could land between. Surface as 500.
|
||||
raise HTTPException(
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
f"cassette row for denomination {denom} disappeared mid-publish",
|
||||
f"cassette row for position {pos} disappeared mid-publish",
|
||||
)
|
||||
|
||||
try:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue