From 427cad33deefe9660b2306550d13b03225e015ec Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 30 May 2026 22:23:51 +0200 Subject: [PATCH 1/5] =?UTF-8?q?refactor(v2):=20cassette=20models=20?= =?UTF-8?q?=E2=80=94=20position-keyed=20wire=20shape=20(#29=20v1.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wire format flips from {"denominations": {"": {"position", "count"}}} to {"positions": {"": {"denomination", "count"}}} per coord-log 2026-05-30T18:30Z + 18:45Z. Per-row editable surface changes: - denomination becomes mutable (operator swaps cartridges during refill) - count remains mutable - position becomes the row identity (hardware bay number) Removed: the no_duplicate_positions validator (no longer relevant — position is the key, dups are impossible at the dict level) and the implicit "denomination unique" assumption. Multiple positions with the same denomination are now operationally valid per bitspire 18:45Z. CassetteConfig model adds state_denomination (Optional[int]) for v2 reverse-channel reconciliation diff highlighting. Tests under tests/test_cassette_configs.py and test_cassette_state_consumer.py will fail at this commit — they reference the old denomination-keyed shape. They get rewritten in v1.1 commit f. Branch tip is green only after commit f lands; this and the next 3 commits are intermediate states. Co-Authored-By: Claude Opus 4.7 (1M context) --- models.py | 111 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 63 insertions(+), 48 deletions(-) diff --git a/models.py b/models.py index 5f88067..bec54aa 100644 --- a/models.py +++ b/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": {"": {"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:). 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:). +# 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": {"": {"position", "count"}}}`.""" + """One position's payload values in the wire-format + `{"positions": {"": {"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 → operator (d-tag `bitspire-cassettes-state:`) - Wire shape: `{"denominations": {"": {"position", "count"}}}`. + Wire shape: `{"positions": {"": {"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() } } From 5dbd7314f4fa8fd4ce213b491b619407de5b4d49 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 30 May 2026 22:25:27 +0200 Subject: [PATCH 2/5] =?UTF-8?q?refactor(v2):=20cassette=20CRUD=20+=20trans?= =?UTF-8?q?port=20=E2=80=94=20position-keyed=20(#29=20v1.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRUD layer flips: - get_cassette_config(machine_id, position) — was (..., denomination) - list_cassette_configs_for_machine returns ORDER BY position alone (no secondary denomination ordering — position is the unique key) - update_cassette_config(machine_id, position, data, updated_by) — operator edits denomination + count for a fixed slot - apply_bootstrap_state upserts ON CONFLICT(machine_id, position) iterating payload.positions; populates new state_denomination column from row.denomination alongside state_count cassette_transport.py needs almost no functional change — the wire shape is implicit via PublishCassettesPayload.to_wire_dict (now emits {"positions": {...}}) and decrypt_and_parse_state_event accepts what the model parses. Just the module docstring + the publish log line get updated to reference positions rather than denominations. Tests still red until commit f rewrites them. Co-Authored-By: Claude Opus 4.7 (1M context) --- cassette_transport.py | 4 +-- crud.py | 68 +++++++++++++++++++++++-------------------- 2 files changed, 38 insertions(+), 34 deletions(-) diff --git a/cassette_transport.py b/cassette_transport.py index 4bae8b1..5862e11 100644 --- a/cassette_transport.py +++ b/cassette_transport.py @@ -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 diff --git a/crud.py b/crud.py index 9da14c8..01ee876 100644 --- a/crud.py +++ b/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: 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, From 34e324b4c5e3009a57bf17631b20eb88d8ff85f2 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 30 May 2026 22:26:55 +0200 Subject: [PATCH 3/5] =?UTF-8?q?refactor(v2):=20cassette=20consumer=20+=20A?= =?UTF-8?q?PI=20endpoint=20=E2=80=94=20position-keyed=20(#29=20v1.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API endpoint: - api_publish_machine_cassettes validates incoming payload.positions set matches stored cassette_configs.position set (was: denomination set match). Error message updated to "slot count is hardware-fixed — re-provision the ATM via atm-tui to add/remove physical bays." - Per-row upsert loop iterates payload.positions and passes UpsertCassetteConfigData(denomination=, count=) — operator edits denomination + count for a fixed slot. Bootstrap consumer task: just a log-message field rename (now reports "N cassettes" from len(payload.positions) instead of len(payload. denominations)). Per-event handler already routes through the transport's decrypt_and_parse_state_event, which returns a PublishCassettesPayload that's now position-keyed via the model. Tests still red until commit f. Co-Authored-By: Claude Opus 4.7 (1M context) --- tasks.py | 2 +- views_api.py | 62 +++++++++++++++++++++++++++------------------------- 2 files changed, 33 insertions(+), 31 deletions(-) diff --git a/tasks.py b/tasks.py index 8be382f..ab945f4 100644 --- a/tasks.py +++ b/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. diff --git a/views_api.py b/views_api.py index 712e4d6..e49e01e 100644 --- a/views_api.py +++ b/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: - and p=. + 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: and + p=. The `` 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: From 3014962563e8683bb85efddd0c37246f3dabdc83 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 30 May 2026 22:28:37 +0200 Subject: [PATCH 4/5] refactor(v2)(ui): denomination editable per slot, position read-only (#29 v1.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI flips to position-keyed per the v1.1 redesign: - Column order: Bay | Denomination | Count | ATM-reported | Updated (position first, since it's the row identity) - Position becomes read-only: rendered as "Bay N" label - Denomination becomes an editable q-input (with the fiat code as a suffix on the input) - Count remains editable - ATM-reported column now shows " · ×" combining state_denomination + state_count for at-a-glance reconciliation (still v1: only the bootstrap snapshot; v2 reverse-channel makes this live) - Confirm-modal preview list: header is "Bay N", side shows the denomination + count being sent JS: - cassettesTable.columns reordered to put position first - markCassetteDirty pivots on position (the immutable identity) and compares denomination + count against pristine - submitCassettePublish builds {positions: {: {denomination, count}}} payload instead of {denominations: ...} No "lock icon" on denomination — the previous instinct to add one was based on the m007 misinterpretation. v1.1 design correctly makes denomination operator-editable. Tests still red until commit f. Co-Authored-By: Claude Opus 4.7 (1M context) --- static/js/index.js | 29 ++++++++++---------- templates/satmachineadmin/index.html | 41 +++++++++++++++------------- 2 files changed, 37 insertions(+), 33 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index ff7cf5b..96b98ef 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -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: { "": { position, count }, ... } } - // The API enforces the denomination set matches what's stored — + // Build the PublishCassettesPayload shape (v1.1, position-keyed): + // { positions: { "": { 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( diff --git a/templates/satmachineadmin/index.html b/templates/satmachineadmin/index.html index f0ebc48..8b3ddf3 100644 --- a/templates/satmachineadmin/index.html +++ b/templates/satmachineadmin/index.html @@ -1023,7 +1023,7 @@ @@ -1032,10 +1032,15 @@ :style="props.row._dirty ? {boxShadow: 'inset 4px 0 0 0 #fdd835'} : {}"> + + + - - + - - - - - + + + + + · + + @@ -1094,17 +1098,16 @@

Sending to ATM:

+ :key="row.position"> - + - position - + · count From 1cebefcde57d3aa9230e91fe0d2cdad8af51f9b4 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 30 May 2026 22:31:08 +0200 Subject: [PATCH 5/5] test(v2): rewrite cassette tests for v1.1 position-keyed wire shape (#29 v1.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wire-shape pivot (m007 denomination-keyed → m008 position-keyed) needs the unit test surface re-written to match: test_cassette_configs.py - PublishCassettesPayload tests pivot to positions-keyed input. Validators reject non-int / non-positive position keys, negative denom, negative count. Zero count allowed (empty cassette). - NEW: test_accepts_multiple_same_denomination_cassettes — pins the v1.1 operational requirement (real machines load 4×$20 for cash-out throughput) per coord-log 18:45Z. No denom-unique validator. - CassettePayloadRow tests pivot to the new field shape (denomination + count, no position). - UpsertCassetteConfigData tests cover edit-denomination (the v1.1 "operator swaps a cartridge during refill" scenario) and edit-count. Position no longer in the model. test_cassette_state_consumer.py - _make_state_event helper builds {"positions": {...}} ciphertext. - Happy-path assertion checks p.positions keys + denomination/count per row. - NEW: test_round_trips_multiple_same_denomination — covers the v1.1 four-of-the-same case through encrypt → decrypt → parse. - All negative paths (tamper, wrong privkey, malformed pubkey, missing fields, garbage JSON, wrong shape) carry over with the new payload shape. - d-tag tests unchanged (position vs denomination isn't on the d-tag). test_nip44_v2.py - TestBitspireCrossTest temporarily re-skipped at the class level: the 13:15Z fixture is encoded with the v1 denomination-keyed shape; bitspire's posting a v1.1 fixture and commit g will swap + unskip. Total: 148 passed, 3 skipped (bitspire cross-test pending the v1.1 fixture from bitspire), 1 pre-existing async-plugin failure unchanged. Branch tip is now functionally green (the pre-existing async failure predates this PR + can't be addressed without a pytest plugin install). Pending commit g for the cross-test fixture re-wire when bitspire posts. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_cassette_configs.py | 147 +++++++++++++++----------- tests/test_cassette_state_consumer.py | 59 +++++++---- tests/test_nip44_v2.py | 10 ++ 3 files changed, 134 insertions(+), 82 deletions(-) diff --git a/tests/test_cassette_configs.py b/tests/test_cassette_configs.py index f9d9a4a..8526951 100644 --- a/tests/test_cassette_configs.py +++ b/tests/test_cassette_configs.py @@ -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 diff --git a/tests/test_cassette_state_consumer.py b/tests/test_cassette_state_consumer.py index 4cd228e..9d3739a 100644 --- a/tests/test_cassette_state_consumer.py +++ b/tests/test_cassette_state_consumer.py @@ -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 = { diff --git a/tests/test_nip44_v2.py b/tests/test_nip44_v2.py index f3b27b9..2188f4f 100644 --- a/tests/test_nip44_v2.py +++ b/tests/test_nip44_v2.py @@ -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: {: " + "{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