Compare commits

..

No commits in common. "1cebefcde57d3aa9230e91fe0d2cdad8af51f9b4" and "df6e8e0a22ee5ffab492ba206273471d6734a855" have entirely different histories.

10 changed files with 226 additions and 303 deletions

View file

@ -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

68
crud.py
View file

@ -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:<m> 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,

111
models.py
View file

@ -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": {"<position_str>": {"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:<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.
# 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.
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": {"<pos>": {"denomination", "count"}}}`."""
"""One denomination's payload values in the wire-format
`{"denominations": {"<denom>": {"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_pubkey_hex>`)
- ATM operator (d-tag `bitspire-cassettes-state:<atm_pubkey_hex>`)
Wire shape: `{"positions": {"<pos_str>": {"denomination", "count"}}}`.
Wire shape: `{"denominations": {"<denom_str>": {"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()
}
}

View file

@ -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: { "<pos>": { denomination, count }, ... } }
// The API enforces the position set matches what's stored —
// Build the PublishCassettesPayload shape:
// { denominations: { "<denom>": { 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(

View file

@ -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.

View file

@ -1023,7 +1023,7 @@
<q-table v-if="machineDetail.cassetteEdits.length"
dense flat
:rows="machineDetail.cassetteEdits"
row-key="position"
row-key="denomination"
:columns="cassettesTable.columns"
:pagination="cassettesTable.pagination"
hide-pagination>
@ -1032,15 +1032,10 @@
: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">
<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>
<b v-text="props.row.denomination"></b>
<span :style="{fontSize: '0.85em', opacity: 0.6}"
v-text="' ' + (machineDetail.machine.fiat_code || '')"></span>
</q-td>
<q-td key="count" class="text-right">
<q-input v-model.number="props.row.count" type="number"
@ -1048,15 +1043,16 @@
:style="{width: '120px', display: 'inline-block'}"
@update:model-value="markCassetteDirty(props.row)"></q-input>
</q-td>
<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>
<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>
<span v-else :style="{opacity: 0.4}"></span>
</q-td>
<q-td key="updated_at">
@ -1098,16 +1094,17 @@
<p class="q-mb-sm">Sending to ATM:</p>
<q-list dense bordered>
<q-item v-for="row in machineDetail.cassetteEdits"
:key="row.position">
:key="row.denomination">
<q-item-section>
<q-item-label>
<b v-text="'Bay ' + row.position"></b>
<b v-text="row.denomination + ' ' +
(machineDetail.machine.fiat_code || '')"></b>
</q-item-label>
</q-item-section>
<q-item-section side>
<q-item-label caption>
<b v-text="row.denomination + ' ' +
(machineDetail.machine.fiat_code || '')"></b>
position
<b v-text="row.position"></b>
· count
<b v-text="row.count"></b>
</q-item-label>

View file

@ -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
ATMoperator 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

View file

@ -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 = {

View file

@ -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: {<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

View file

@ -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:<atm_pubkey_hex> and
p=<atm_pubkey_hex>.
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 `<m>` 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: