diff --git a/cassette_transport.py b/cassette_transport.py index 5862e11..4bae8b1 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 position-keyed cassette config to a target ATM via: +publishes denomination-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}, positions={sorted(payload.positions.keys())})" + f"machine_id={machine.id}, denominations={list(payload.denominations.keys())})" ) return signed diff --git a/crud.py b/crud.py index 01ee876..9da14c8 100644 --- a/crud.py +++ b/crud.py @@ -1379,14 +1379,14 @@ async def upsert_fleet_snapshot( # ============================================================================= -# Cassette configs — operator-driven ATM cassette inventory (#29 v1.1). +# Cassette configs — operator-driven ATM cassette inventory (#29). # ============================================================================= # Row lifecycle per #29: -# - First population for a (machine_id, position) pair → apply_bootstrap_state +# - First population for a (machine_id, denomination) pair → apply_bootstrap_state # (consumer reading the ATM's one-shot bitspire-cassettes-state event) -# - 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 +# - 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 # 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, position: int + machine_id: str, denomination: int ) -> Optional[CassetteConfig]: return await db.fetchone( "SELECT * FROM satoshimachine.cassette_configs " - "WHERE machine_id = :mid AND position = :pos", - {"mid": machine_id, "pos": position}, + "WHERE machine_id = :mid AND denomination = :denom", + {"mid": machine_id, "denom": denomination}, 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", + "WHERE machine_id = :mid ORDER BY position, denomination", {"mid": machine_id}, CassetteConfig, ) @@ -1430,18 +1430,18 @@ async def list_cassette_configs_for_machine( async def update_cassette_config( machine_id: str, - position: int, + denomination: int, data: UpsertCassetteConfigData, *, updated_by: Optional[str] = None, ) -> Optional[CassetteConfig]: - """Operator-driven row update: change denomination and/or count for a - single cassette slot. Refuses to create new rows — those only land via + """Operator-driven row update: change count and/or position for a single + cassette. Refuses to create new rows — those only land via apply_bootstrap_state() consuming an ATM bootstrap event (per #29 row - lifecycle: hardware-determined slot count, not operator-creatable). - Returns None if the (machine_id, position) row doesn't exist. + lifecycle: hardware-determined denomination set, not operator-creatable). + Returns None if the (machine_id, denomination) row doesn't exist. """ - existing = await get_cassette_config(machine_id, position) + existing = await get_cassette_config(machine_id, denomination) 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["pos"] = position + update_data["denom"] = denomination await db.execute( f"UPDATE satoshimachine.cassette_configs SET {set_clause} " - "WHERE machine_id = :mid AND position = :pos", + "WHERE machine_id = :mid AND denomination = :denom", update_data, ) - return await get_cassette_config(machine_id, position) + return await get_cassette_config(machine_id, denomination) async def apply_bootstrap_state( @@ -1467,18 +1467,17 @@ 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 position in the payload. + and upsert one cassette_configs row per denomination 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 (denomination, count, + Populates both the operator-believed columns (count, position, updated_at, updated_by='atm-bootstrap') AND the v2 reverse-channel - 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. + 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. """ existing_first = await db.fetchone( "SELECT state_event_id FROM satoshimachine.cassette_configs " @@ -1496,33 +1495,30 @@ async def apply_bootstrap_state( return False now = datetime.now() - for pos, row in payload.positions.items(): + for denom, row in payload.denominations.items(): await db.execute( """ INSERT INTO satoshimachine.cassette_configs - (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, + (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 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, - "pos": pos, - "denom": row.denomination, + "denom": denom, "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, diff --git a/models.py b/models.py index bec54aa..5f88067 100644 --- a/models.py +++ b/models.py @@ -549,64 +549,41 @@ class SettleBalanceData(BaseModel): # ============================================================================= -# Cassette configs — operator-driven ATM cassette inventory (#29 v1.1). +# Cassette configs — operator-driven ATM cassette inventory (#29). # ============================================================================= -# 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. +# 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. # -# Wire shape: -# {"positions": {"": {"denomination": N, "count": M}}} +# Position is operator-assignable display order (and used by the ATM as +# the HAL slot-index assignment), not the addressable unit. # -# 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. +# 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. 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 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).""" + """Operator edits a single cassette row's count or position from the + dashboard. Both fields optional; pass only those changed.""" - denomination: Optional[int] = None count: 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 + position: Optional[int] = None @validator("count") def count_non_negative(cls, v): @@ -616,18 +593,26 @@ 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 position's payload values in the wire-format - `{"positions": {"": {"denomination", "count"}}}`.""" + """One denomination's payload values in the wire-format + `{"denominations": {"": {"position", "count"}}}`.""" - denomination: int + position: int count: int - @validator("denomination") - def denomination_positive(cls, v): + @validator("position") + def position_positive(cls, v): if v <= 0: - raise ValueError("denomination must be > 0") + raise ValueError("position must be > 0") return v @validator("count") @@ -643,45 +628,45 @@ class PublishCassettesPayload(BaseModel): - operator → ATM (d-tag `bitspire-cassettes:`) - ATM → operator (d-tag `bitspire-cassettes-state:`) - Wire shape: `{"positions": {"": {"denomination", "count"}}}`. + Wire shape: `{"denominations": {"": {"position", "count"}}}`. JSON object keys are always strings; the validator coerces back to - 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). + int on parse. The denomination key set MUST match what the receiver + already has (no add / no remove from this payload). """ - positions: dict[int, CassettePayloadRow] + denominations: dict[int, CassettePayloadRow] - @validator("positions", pre=True) + @validator("denominations", pre=True) def coerce_string_keys_to_int(cls, v): if not isinstance(v, dict): - raise ValueError("positions must be a dict") + raise ValueError("denominations must be a dict") out = {} for k, val in v.items(): try: key_int = int(k) except (TypeError, ValueError) as exc: raise ValueError( - f"position key {k!r} is not an int" + f"denomination key {k!r} is not an int" ) from exc if key_int <= 0: - raise ValueError(f"position must be > 0 (got {key_int})") + raise ValueError(f"denomination 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 { - "positions": { - str(pos): { - "denomination": row.denomination, - "count": row.count, - } - for pos, row in self.positions.items() + "denominations": { + str(denom): {"position": row.position, "count": row.count} + for denom, row in self.denominations.items() } } diff --git a/static/js/index.js b/static/js/index.js index 96b98ef..ff7cf5b 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: 'state', label: 'ATM-reported', field: 'state_denomination', align: 'right'}, + {name: 'position', label: 'Position', field: 'position', align: 'right'}, + {name: 'state_count', label: 'ATM-reported', field: 'state_count', align: 'right'}, {name: 'updated_at', label: 'Updated', field: 'updated_at', align: 'left'} ], pagination: {rowsPerPage: 0} // hide pagination — cassette count is small @@ -816,16 +816,15 @@ window.app = Vue.createApp({ }, markCassetteDirty(row) { - // 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. + // Find pristine match by denomination and compare; flip _dirty + + // overall dirty flag accordingly. const pristine = this.machineDetail.cassettesPristine.find( - p => p.position === row.position + p => p.denomination === row.denomination ) row._dirty = !pristine || - Number(row.denomination) !== Number(pristine.denomination) || - Number(row.count) !== Number(pristine.count) + Number(row.count) !== Number(pristine.count) || + Number(row.position) !== Number(pristine.position) this.machineDetail.cassettesDirty = this.machineDetail.cassetteEdits.some(r => r._dirty) }, @@ -845,18 +844,18 @@ window.app = Vue.createApp({ }, async submitCassettePublish() { - // Build the PublishCassettesPayload shape (v1.1, position-keyed): - // { positions: { "": { denomination, count }, ... } } - // The API enforces the position set matches what's stored — + // Build the PublishCassettesPayload shape: + // { denominations: { "": { position, count }, ... } } + // The API enforces the denomination set matches what's stored — // since we only edit existing rows, this should always pass. - const positions = {} + const denominations = {} for (const row of this.machineDetail.cassetteEdits) { - positions[String(row.position)] = { - denomination: Number(row.denomination), + denominations[String(row.denomination)] = { + position: Number(row.position), count: Number(row.count) } } - const payload = {positions} + const payload = {denominations} this.machineDetail.cassettesPublishing = true try { const {data} = await LNbits.api.request( diff --git a/tasks.py b/tasks.py index ab945f4..8be382f 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.positions)} cassettes)" + f"to machine {machine.id} ({len(payload.denominations)} cassettes)" ) else: # Replay: event_id already on file. Normal on relay reconnect. diff --git a/templates/satmachineadmin/index.html b/templates/satmachineadmin/index.html index 8b3ddf3..f0ebc48 100644 --- a/templates/satmachineadmin/index.html +++ b/templates/satmachineadmin/index.html @@ -1023,7 +1023,7 @@ @@ -1032,15 +1032,10 @@ :style="props.row._dirty ? {boxShadow: 'inset 4px 0 0 0 #fdd835'} : {}"> - - - - + + - - - - - · - - + + + + + @@ -1098,16 +1094,17 @@

Sending to ATM:

+ :key="row.denomination"> - + - + position + · count diff --git a/tests/test_cassette_configs.py b/tests/test_cassette_configs.py index 8526951..f9d9a4a 100644 --- a/tests/test_cassette_configs.py +++ b/tests/test_cassette_configs.py @@ -1,10 +1,10 @@ """ -Tests for the v1.1 cassette-config layer (aiolabs/satmachineadmin#29). +Tests for the v1 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 (position key coercion, integer ranges, multiple-same- - denomination payloads, wire-format round-trip) + upsert models (denomination key coercion, integer ranges, no-duplicate- + positions, 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,11 +14,6 @@ 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 @@ -39,127 +34,116 @@ 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; - per-row int constraints enforced; multiple same-denom rows are valid.""" + duplicate positions must reject; per-row int constraints enforced.""" def test_happy_path_coerces_string_keys_to_int(self): p = PublishCassettesPayload( - positions={ - "1": {"denomination": 20, "count": 49}, - "2": {"denomination": 50, "count": 100}, + denominations={ + "20": {"position": 1, "count": 49}, + "50": {"position": 2, "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 + 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 def test_wire_dict_round_trip_restringifies_keys(self): - """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).""" + """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).""" original = PublishCassettesPayload( - positions={ - "1": {"denomination": 20, "count": 49}, - "2": {"denomination": 50, "count": 100}, + denominations={ + "20": {"position": 1, "count": 49}, + "50": {"position": 2, "count": 100}, } ) wire = original.to_wire_dict() assert wire == { - "positions": { - "1": {"denomination": 20, "count": 49}, - "2": {"denomination": 50, "count": 100}, + "denominations": { + "20": {"position": 1, "count": 49}, + "50": {"position": 2, "count": 100}, } } # And the wire form round-trips back through the parser cleanly. reparsed = PublishCassettesPayload(**wire) - assert reparsed.positions == original.positions + assert reparsed.denominations == original.denominations - 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): + def test_rejects_non_int_key(self): with pytest.raises(ValueError) as exc: PublishCassettesPayload( - positions={"abc": {"denomination": 20, "count": 1}} + denominations={"abc": {"position": 1, "count": 1}} ) assert "is not an int" in str(exc.value) - def test_rejects_non_positive_position(self): + def test_rejects_non_positive_denomination(self): with pytest.raises(ValueError) as exc: PublishCassettesPayload( - positions={"0": {"denomination": 20, "count": 1}} + denominations={"0": {"position": 1, "count": 1}} ) - assert "position must be > 0" in str(exc.value) + assert "denomination must be > 0" in str(exc.value) - def test_rejects_negative_position(self): + def test_rejects_negative_denomination(self): with pytest.raises(ValueError) as exc: PublishCassettesPayload( - positions={"-1": {"denomination": 20, "count": 1}} + denominations={"-20": {"position": 1, "count": 1}} ) - assert "position must be > 0" in str(exc.value) + 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) def test_rejects_negative_count(self): with pytest.raises(ValueError): PublishCassettesPayload( - positions={"1": {"denomination": 20, "count": -1}} + denominations={"20": {"position": 1, "count": -1}} ) - def test_rejects_zero_denomination(self): + def test_rejects_zero_position(self): with pytest.raises(ValueError): PublishCassettesPayload( - positions={"1": {"denomination": 0, "count": 49}} - ) - - def test_rejects_negative_denomination(self): - with pytest.raises(ValueError): - PublishCassettesPayload( - positions={"1": {"denomination": -20, "count": 49}} + denominations={"20": {"position": 0, "count": 1}} ) 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( - positions={"1": {"denomination": 20, "count": 0}} + denominations={"20": {"position": 1, "count": 0}} ) - assert p.positions[1].count == 0 + assert p.denominations[20].count == 0 # ============================================================================= -# CassettePayloadRow — per-row int constraints +# CassettePayloadRow — per-row int constraints (single-row tests) # ============================================================================= class TestCassettePayloadRow: def test_happy_path(self): - row = CassettePayloadRow(denomination=20, count=49) - assert row.denomination == 20 + row = CassettePayloadRow(position=1, count=49) + assert row.position == 1 assert row.count == 49 - @pytest.mark.parametrize("bad_denom", [0, -1, -100]) - def test_rejects_non_positive_denomination(self, bad_denom): + @pytest.mark.parametrize("bad_position", [0, -1, -100]) + def test_rejects_non_positive_position(self, bad_position): with pytest.raises(ValueError): - CassettePayloadRow(denomination=bad_denom, count=1) + CassettePayloadRow(position=bad_position, count=1) def test_rejects_negative_count(self): with pytest.raises(ValueError): - CassettePayloadRow(denomination=20, count=-1) + CassettePayloadRow(position=1, count=-1) # ============================================================================= @@ -169,19 +153,16 @@ class TestCassettePayloadRow: class TestUpsertCassetteConfigData: """Operator-driven row edit. Both fields optional; same int constraints - as the wire-format row but applied independently per-edit. Position is - NOT editable — it's the row's identity (the hardware bay number).""" + as the wire-format row but applied independently per-edit.""" def test_partial_update_count_only(self): d = UpsertCassetteConfigData(count=80) assert d.count == 80 - assert d.denomination is None + assert d.position is None - 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 + def test_partial_update_position_only(self): + d = UpsertCassetteConfigData(position=3) + assert d.position == 3 assert d.count is None def test_empty_update_is_legal(self): @@ -189,15 +170,15 @@ class TestUpsertCassetteConfigData: circuits a no-op on empty payload (no SQL emitted).""" d = UpsertCassetteConfigData() assert d.count is None - assert d.denomination is None + assert d.position is None def test_rejects_negative_count(self): with pytest.raises(ValueError): UpsertCassetteConfigData(count=-1) - def test_rejects_non_positive_denomination(self): + def test_rejects_non_positive_position(self): with pytest.raises(ValueError): - UpsertCassetteConfigData(denomination=0) + UpsertCassetteConfigData(position=0) # ============================================================================= @@ -210,7 +191,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.1 the ATM publishes the bootstrap event exactly once per machine, + In v1 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 9d3739a..4cd228e 100644 --- a/tests/test_cassette_state_consumer.py +++ b/tests/test_cassette_state_consumer.py @@ -5,14 +5,13 @@ 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 position-keyed PublishCassettesPayload - - multiple same-denom cassettes (v1.1 operational case) — round-trips + a PublishCassettesPayload + - sig-verify failure path (covered at the transport-decrypt level + the + handler-level rejection test) - tampered ciphertext → CassetteEventDecodeError - - wrong operator privkey → CassetteEventDecodeError (well-formed but + - unknown sender pubkey → 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 @@ -96,40 +95,21 @@ class TestDecryptAndParseStateEvent: def test_happy_path(self): payload = PublishCassettesPayload( - positions={ - "1": {"denomination": 20, "count": 49}, - "2": {"denomination": 50, "count": 100}, + denominations={ + "20": {"position": 1, "count": 49}, + "50": {"position": 2, "count": 100}, } ) event = _make_state_event(payload) recovered = decrypt_and_parse_state_event(event, _OP_SEC) - 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 + 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 def test_tampered_content_rejected(self): payload = PublishCassettesPayload( - positions={"1": {"denomination": 20, "count": 49}} + denominations={"20": {"position": 1, "count": 49}} ) event = _make_state_event(payload) # Flip a base64 character — corrupts the ciphertext or MAC @@ -144,7 +124,7 @@ class TestDecryptAndParseStateEvent: different hmac_key, so MAC verification inside NIP-44 v2 decrypt fails — surfaced as CassetteEventDecodeError.""" payload = PublishCassettesPayload( - positions={"1": {"denomination": 20, "count": 49}} + denominations={"20": {"position": 1, "count": 49}} ) event = _make_state_event(payload) wrong_sec = "00" * 31 + "03" @@ -153,7 +133,7 @@ class TestDecryptAndParseStateEvent: def test_malformed_sender_pubkey_rejected(self): payload = PublishCassettesPayload( - positions={"1": {"denomination": 20, "count": 49}} + denominations={"20": {"position": 1, "count": 49}} ) event = _make_state_event(payload) event["pubkey"] = "not-a-real-pubkey" @@ -162,9 +142,7 @@ class TestDecryptAndParseStateEvent: def test_missing_content_rejected(self): event = _make_state_event( - PublishCassettesPayload( - positions={"1": {"denomination": 20, "count": 49}} - ) + PublishCassettesPayload(denominations={"20": {"position": 1, "count": 49}}) ) del event["content"] with pytest.raises(CassetteEventDecodeError): @@ -172,9 +150,7 @@ class TestDecryptAndParseStateEvent: def test_missing_pubkey_rejected(self): event = _make_state_event( - PublishCassettesPayload( - positions={"1": {"denomination": 20, "count": 49}} - ) + PublishCassettesPayload(denominations={"20": {"position": 1, "count": 49}}) ) del event["pubkey"] with pytest.raises(CassetteEventDecodeError): @@ -183,6 +159,7 @@ 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, @@ -199,7 +176,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 'positions' field is + """Well-formed JSON but missing the 'denominations' 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 2188f4f..f3b27b9 100644 --- a/tests/test_nip44_v2.py +++ b/tests/test_nip44_v2.py @@ -308,16 +308,6 @@ _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 diff --git a/views_api.py b/views_api.py index e49e01e..712e4d6 100644 --- a/views_api.py +++ b/views_api.py @@ -774,16 +774,15 @@ async def api_update_super_config( # ============================================================================= -# Cassette configs (#29 v1.1) — per-machine ATM cassette inventory +# Cassette configs (#29 v1) — per-machine ATM cassette inventory # ============================================================================= -# v1.1 surface, paired with aiolabs/lamassu-next#56 ATM-side. Two endpoints: +# v1 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, 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. +# 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. @satmachineadmin_api_router.get( @@ -794,9 +793,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. 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 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.""" await _machine_owned_by(machine_id, user.id) return await list_cassette_configs_for_machine(machine_id) @@ -811,11 +810,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 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 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 `` placeholder in the published d-tag is the ATM's hex pubkey from machine.machine_npub (canonicalised via normalize_public_key), @@ -826,8 +825,8 @@ async def api_publish_machine_cassettes( can refresh its table from one round-trip. Errors: - 400 — payload position set doesn't match the machine's stored set - (operator publishing for a slot that doesn't exist on the + 400 — payload denomination set doesn't match the machine's stored + set (operator publishing a cassette 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 @@ -837,8 +836,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_positions = {row.position for row in existing} - incoming_positions = set(payload.positions.keys()) + existing_denoms = {row.denomination for row in existing} + incoming_denoms = set(payload.denominations.keys()) if not existing: raise HTTPException( @@ -851,30 +850,29 @@ async def api_publish_machine_cassettes( "receipt." ), ) - if existing_positions != incoming_positions: - missing = existing_positions - incoming_positions - extra = incoming_positions - existing_positions + if existing_denoms != incoming_denoms: + missing = existing_denoms - incoming_denoms + extra = incoming_denoms - existing_denoms raise HTTPException( HTTPStatus.BAD_REQUEST, ( - "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." + "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." ), ) # 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 pos, row in payload.positions.items(): + for denom, row in payload.denominations.items(): updated = await update_cassette_config( machine_id, - pos, - UpsertCassetteConfigData( - denomination=row.denomination, count=row.count - ), + denom, + UpsertCassetteConfigData(count=row.count, position=row.position), updated_by=user.id, ) if updated is None: @@ -882,7 +880,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 position {pos} disappeared mid-publish", + f"cassette row for denomination {denom} disappeared mid-publish", ) try: