Compare commits

...

5 commits

Author SHA1 Message Date
1cebefcde5 test(v2): rewrite cassette tests for v1.1 position-keyed wire shape (#29 v1.1)
Some checks failed
ci.yml / test(v2): rewrite cassette tests for v1.1 position-keyed wire shape (#29 v1.1) (pull_request) Failing after 0s
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) <noreply@anthropic.com>
2026-05-30 22:31:08 +02:00
3014962563 refactor(v2)(ui): denomination editable per slot, position read-only (#29 v1.1)
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 "<denom> <fiat> · ×<count>" 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: {<pos>: {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) <noreply@anthropic.com>
2026-05-30 22:28:37 +02:00
34e324b4c5 refactor(v2): cassette consumer + API endpoint — position-keyed (#29 v1.1)
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) <noreply@anthropic.com>
2026-05-30 22:26:55 +02:00
5dbd7314f4 refactor(v2): cassette CRUD + transport — position-keyed (#29 v1.1)
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) <noreply@anthropic.com>
2026-05-30 22:25:27 +02:00
427cad33de refactor(v2): cassette models — position-keyed wire shape (#29 v1.1)
The wire format flips from
  {"denominations": {"<denom>": {"position", "count"}}}
to
  {"positions": {"<pos>": {"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) <noreply@anthropic.com>
2026-05-30 22:23:51 +02:00
10 changed files with 303 additions and 226 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 denomination-keyed cassette config to a target ATM via:
publishes position-keyed cassette config to a target ATM via:
kind = 30078 (NIP-78, replaceable)
tags = [
@ -304,7 +304,7 @@ async def publish_to_atm(
logger.info(
f"satmachineadmin: published kind-30078 cassette config to ATM "
f"{atm_pubkey_hex[:12]}... (event_id={signed['id'][:12]}..., "
f"machine_id={machine.id}, denominations={list(payload.denominations.keys())})"
f"machine_id={machine.id}, positions={sorted(payload.positions.keys())})"
)
return signed

68
crud.py
View file

@ -1379,14 +1379,14 @@ async def upsert_fleet_snapshot(
# =============================================================================
# Cassette configs — operator-driven ATM cassette inventory (#29).
# Cassette configs — operator-driven ATM cassette inventory (#29 v1.1).
# =============================================================================
# Row lifecycle per #29:
# - First population for a (machine_id, denomination) pair → apply_bootstrap_state
# - First population for a (machine_id, position) pair → apply_bootstrap_state
# (consumer reading the ATM's one-shot bitspire-cassettes-state event)
# - Operator edit of count or position → update_cassette_config (refuses to
# create new rows; the denomination set is hardware-determined)
# - Row creation/deletion for a new denomination → admin only, via ATM
# - Operator edit of denomination or count → update_cassette_config
# (refuses to create new rows; the slot count is hardware-determined)
# - Row creation/deletion for a new position → admin only, via ATM
# re-provisioning + new bootstrap event (not exposed in v1 here)
@ -1407,12 +1407,12 @@ def _should_apply_bootstrap_state(
async def get_cassette_config(
machine_id: str, denomination: int
machine_id: str, position: int
) -> Optional[CassetteConfig]:
return await db.fetchone(
"SELECT * FROM satoshimachine.cassette_configs "
"WHERE machine_id = :mid AND denomination = :denom",
{"mid": machine_id, "denom": denomination},
"WHERE machine_id = :mid AND position = :pos",
{"mid": machine_id, "pos": position},
CassetteConfig,
)
@ -1422,7 +1422,7 @@ async def list_cassette_configs_for_machine(
) -> List[CassetteConfig]:
return await db.fetchall(
"SELECT * FROM satoshimachine.cassette_configs "
"WHERE machine_id = :mid ORDER BY position, denomination",
"WHERE machine_id = :mid ORDER BY position",
{"mid": machine_id},
CassetteConfig,
)
@ -1430,18 +1430,18 @@ async def list_cassette_configs_for_machine(
async def update_cassette_config(
machine_id: str,
denomination: int,
position: int,
data: UpsertCassetteConfigData,
*,
updated_by: Optional[str] = None,
) -> Optional[CassetteConfig]:
"""Operator-driven row update: change count and/or position for a single
cassette. Refuses to create new rows those only land via
"""Operator-driven row update: change denomination and/or count for a
single cassette slot. Refuses to create new rows those only land via
apply_bootstrap_state() consuming an ATM bootstrap event (per #29 row
lifecycle: hardware-determined denomination set, not operator-creatable).
Returns None if the (machine_id, denomination) row doesn't exist.
lifecycle: hardware-determined slot count, not operator-creatable).
Returns None if the (machine_id, position) row doesn't exist.
"""
existing = await get_cassette_config(machine_id, denomination)
existing = await get_cassette_config(machine_id, position)
if existing is None:
return None
update_data: dict = {k: v for k, v in data.dict().items() if v is not None}
@ -1451,13 +1451,13 @@ async def update_cassette_config(
update_data["updated_by"] = updated_by
set_clause = ", ".join(f"{k} = :{k}" for k in update_data)
update_data["mid"] = machine_id
update_data["denom"] = denomination
update_data["pos"] = position
await db.execute(
f"UPDATE satoshimachine.cassette_configs SET {set_clause} "
"WHERE machine_id = :mid AND denomination = :denom",
"WHERE machine_id = :mid AND position = :pos",
update_data,
)
return await get_cassette_config(machine_id, denomination)
return await get_cassette_config(machine_id, position)
async def apply_bootstrap_state(
@ -1467,17 +1467,18 @@ async def apply_bootstrap_state(
payload: PublishCassettesPayload,
) -> bool:
"""Consume an ATM-published kind-30078 bitspire-cassettes-state:<m> event
and upsert one cassette_configs row per denomination in the payload.
and upsert one cassette_configs row per position in the payload.
Returns True if the upsert ran; False if any existing row for this
machine already references this event_id (idempotent on relay
re-delivery / restart).
Populates both the operator-believed columns (count, position,
Populates both the operator-believed columns (denomination, count,
updated_at, updated_by='atm-bootstrap') AND the v2 reverse-channel
columns (state_count, state_at, state_event_id) so the operator's
initial view matches the ATM's reported state. v2 reconciliation UI
will diverge them when continuous reverse-channel events land.
columns (state_denomination, state_count, state_at, state_event_id)
so the operator's initial view matches the ATM's reported state. v2
reconciliation UI will diverge them when continuous reverse-channel
events land + the operator subsequently edits.
"""
existing_first = await db.fetchone(
"SELECT state_event_id FROM satoshimachine.cassette_configs "
@ -1495,30 +1496,33 @@ async def apply_bootstrap_state(
return False
now = datetime.now()
for denom, row in payload.denominations.items():
for pos, row in payload.positions.items():
await db.execute(
"""
INSERT INTO satoshimachine.cassette_configs
(machine_id, denomination, count, position, updated_at,
updated_by, state_count, state_at, state_event_id)
VALUES (:mid, :denom, :count, :pos, :now, :by,
:state_count, :state_at, :event_id)
ON CONFLICT (machine_id, denomination) DO UPDATE SET
(machine_id, position, denomination, count, updated_at,
updated_by, state_denomination, state_count, state_at,
state_event_id)
VALUES (:mid, :pos, :denom, :count, :now, :by,
:state_denom, :state_count, :state_at, :event_id)
ON CONFLICT (machine_id, position) DO UPDATE SET
denomination = excluded.denomination,
count = excluded.count,
position = excluded.position,
updated_at = excluded.updated_at,
updated_by = excluded.updated_by,
state_denomination = excluded.state_denomination,
state_count = excluded.state_count,
state_at = excluded.state_at,
state_event_id = excluded.state_event_id
""",
{
"mid": machine_id,
"denom": denom,
"pos": pos,
"denom": row.denomination,
"count": row.count,
"pos": row.position,
"now": now,
"by": "atm-bootstrap",
"state_denom": row.denomination,
"state_count": row.count,
"state_at": event_created_at,
"event_id": event_id,

111
models.py
View file

@ -549,41 +549,64 @@ class SettleBalanceData(BaseModel):
# =============================================================================
# Cassette configs — operator-driven ATM cassette inventory (#29).
# Cassette configs — operator-driven ATM cassette inventory (#29 v1.1).
# =============================================================================
# Schema is denomination-keyed per the locked design (#29 body + the
# 06:40Z coord-log audit): every ATM-side layer below the wire keys on
# denomination (state-store.ts:54, hal-service.ts:116/189). The
# satmachineadmin schema mirrors this so the operator UI can't author a
# duplicate-denomination payload that the ATM would silently collapse.
# Schema is position-keyed per the coordinated v1.1 redesign at coord-log
# 2026-05-30T18:30Z + 18:45Z. The earlier denomination-keyed shape (m007)
# was wrong: real machines have N cassettes of the same denomination for
# cash-out throughput, and operators need to swap cartridge denominations
# during refill ($20 bay becomes a $50 bay) without re-provisioning.
#
# Position is operator-assignable display order (and used by the ATM as
# the HAL slot-index assignment), not the addressable unit.
# Wire shape:
# {"positions": {"<position_str>": {"denomination": N, "count": M}}}
#
# state_count / state_at / state_event_id are reserved nullable from day 1
# for the v2 reverse-channel reconciliation consumer (bitspire-cassettes-
# state:<atm_pubkey_hex>). v1 populates them on bootstrap-event receipt
# but the UI doesn't render reconciliation.
# Editable surface per row:
# - denomination: yes (operator swaps cartridges during refill)
# - count: yes (refill / decrement)
# Read-only per row:
# - position: hardware bay number; the slot count is fixed by the
# dispenser model (e.g., Tejo has 4 positions).
#
# No "denomination must be unique within payload" constraint: multiple
# same-denom cassettes are operationally valid. The ATM HAL distributes
# a dispense request greedy across all positions matching the requested
# denomination (lamassu-next#56 v1.1 HAL refactor).
#
# state_* columns are reserved nullable for the v2 reverse-channel
# reconciliation consumer (bitspire-cassettes-state:<atm_pubkey_hex>).
# v1 populates them on bootstrap-event receipt but the UI doesn't render
# reconciliation. state_denomination (added in m008) lets v2 highlight
# operator-believed-vs-ATM-reported denomination drift per slot.
class CassetteConfig(BaseModel):
machine_id: str
position: int
denomination: int
count: int
position: int
updated_at: datetime
updated_by: Optional[str]
state_denomination: Optional[int]
state_count: Optional[int]
state_at: Optional[datetime]
state_event_id: Optional[str]
class UpsertCassetteConfigData(BaseModel):
"""Operator edits a single cassette row's count or position from the
dashboard. Both fields optional; pass only those changed."""
"""Operator edits a single cassette row's denomination or count from
the dashboard. Both fields optional; pass only those changed.
Position is not edited it's the row's identity (hardware bay)."""
denomination: Optional[int] = None
count: Optional[int] = None
position: Optional[int] = None
@validator("denomination")
def denomination_positive(cls, v):
if v is None:
return v
if v <= 0:
raise ValueError("denomination must be > 0")
return v
@validator("count")
def count_non_negative(cls, v):
@ -593,26 +616,18 @@ class UpsertCassetteConfigData(BaseModel):
raise ValueError("count must be >= 0")
return v
@validator("position")
def position_positive(cls, v):
if v is None:
return v
if v <= 0:
raise ValueError("position must be > 0")
return v
class CassettePayloadRow(BaseModel):
"""One denomination's payload values in the wire-format
`{"denominations": {"<denom>": {"position", "count"}}}`."""
"""One position's payload values in the wire-format
`{"positions": {"<pos>": {"denomination", "count"}}}`."""
position: int
denomination: int
count: int
@validator("position")
def position_positive(cls, v):
@validator("denomination")
def denomination_positive(cls, v):
if v <= 0:
raise ValueError("position must be > 0")
raise ValueError("denomination must be > 0")
return v
@validator("count")
@ -628,45 +643,45 @@ class PublishCassettesPayload(BaseModel):
- operator ATM (d-tag `bitspire-cassettes:<atm_pubkey_hex>`)
- ATM operator (d-tag `bitspire-cassettes-state:<atm_pubkey_hex>`)
Wire shape: `{"denominations": {"<denom_str>": {"position", "count"}}}`.
Wire shape: `{"positions": {"<pos_str>": {"denomination", "count"}}}`.
JSON object keys are always strings; the validator coerces back to
int on parse. The denomination key set MUST match what the receiver
already has (no add / no remove from this payload).
int on parse. The position key set MUST match what the receiver
already has (slot count is hardware-fixed; no add/remove from this
payload).
No denomination-unique constraint: multiple same-denom cassettes are
operationally valid (cash-out throughput on a popular denom).
"""
denominations: dict[int, CassettePayloadRow]
positions: dict[int, CassettePayloadRow]
@validator("denominations", pre=True)
@validator("positions", pre=True)
def coerce_string_keys_to_int(cls, v):
if not isinstance(v, dict):
raise ValueError("denominations must be a dict")
raise ValueError("positions must be a dict")
out = {}
for k, val in v.items():
try:
key_int = int(k)
except (TypeError, ValueError) as exc:
raise ValueError(
f"denomination key {k!r} is not an int"
f"position key {k!r} is not an int"
) from exc
if key_int <= 0:
raise ValueError(f"denomination must be > 0 (got {key_int})")
raise ValueError(f"position must be > 0 (got {key_int})")
out[key_int] = val
return out
@validator("denominations")
def no_duplicate_positions(cls, v):
positions = [row.position for row in v.values()]
if len(set(positions)) != len(positions):
raise ValueError("duplicate position values in payload")
return v
def to_wire_dict(self) -> dict:
"""Serialise back to the wire format with string keys for JSON
object compatibility. Used by the publisher to build the kind-30078
event content before NIP-44 v2 encryption."""
return {
"denominations": {
str(denom): {"position": row.position, "count": row.count}
for denom, row in self.denominations.items()
"positions": {
str(pos): {
"denomination": row.denomination,
"count": row.count,
}
for pos, row in self.positions.items()
}
}

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: 'position', label: 'Position', field: 'position', align: 'right'},
{name: 'state_count', label: 'ATM-reported', field: 'state_count', align: 'right'},
{name: 'state', label: 'ATM-reported', field: 'state_denomination', align: 'right'},
{name: 'updated_at', label: 'Updated', field: 'updated_at', align: 'left'}
],
pagination: {rowsPerPage: 0} // hide pagination — cassette count is small
@ -816,15 +816,16 @@ window.app = Vue.createApp({
},
markCassetteDirty(row) {
// Find pristine match by denomination and compare; flip _dirty +
// overall dirty flag accordingly.
// Find pristine match by position (the row identity) and compare;
// flip _dirty + overall dirty flag accordingly. Editable fields
// are denomination + count; position is the immutable row key.
const pristine = this.machineDetail.cassettesPristine.find(
p => p.denomination === row.denomination
p => p.position === row.position
)
row._dirty =
!pristine ||
Number(row.count) !== Number(pristine.count) ||
Number(row.position) !== Number(pristine.position)
Number(row.denomination) !== Number(pristine.denomination) ||
Number(row.count) !== Number(pristine.count)
this.machineDetail.cassettesDirty =
this.machineDetail.cassetteEdits.some(r => r._dirty)
},
@ -844,18 +845,18 @@ window.app = Vue.createApp({
},
async submitCassettePublish() {
// Build the PublishCassettesPayload shape:
// { denominations: { "<denom>": { position, count }, ... } }
// The API enforces the denomination set matches what's stored —
// Build the PublishCassettesPayload shape (v1.1, position-keyed):
// { positions: { "<pos>": { denomination, count }, ... } }
// The API enforces the position set matches what's stored —
// since we only edit existing rows, this should always pass.
const denominations = {}
const positions = {}
for (const row of this.machineDetail.cassetteEdits) {
denominations[String(row.denomination)] = {
position: Number(row.position),
positions[String(row.position)] = {
denomination: Number(row.denomination),
count: Number(row.count)
}
}
const payload = {denominations}
const payload = {positions}
this.machineDetail.cassettesPublishing = true
try {
const {data} = await LNbits.api.request(

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.denominations)} cassettes)"
f"to machine {machine.id} ({len(payload.positions)} 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="denomination"
row-key="position"
:columns="cassettesTable.columns"
:pagination="cassettesTable.pagination"
hide-pagination>
@ -1032,10 +1032,15 @@
:style="props.row._dirty
? {boxShadow: 'inset 4px 0 0 0 #fdd835'}
: {}">
<q-td key="position" class="text-right">
<b v-text="'Bay ' + props.row.position"></b>
</q-td>
<q-td key="denomination" class="text-right">
<b v-text="props.row.denomination"></b>
<span :style="{fontSize: '0.85em', opacity: 0.6}"
v-text="' ' + (machineDetail.machine.fiat_code || '')"></span>
<q-input v-model.number="props.row.denomination"
type="number" min="1" step="1" dense outlined
:suffix="machineDetail.machine.fiat_code || ''"
:style="{width: '140px', display: 'inline-block'}"
@update:model-value="markCassetteDirty(props.row)"></q-input>
</q-td>
<q-td key="count" class="text-right">
<q-input v-model.number="props.row.count" type="number"
@ -1043,16 +1048,15 @@
:style="{width: '120px', display: 'inline-block'}"
@update:model-value="markCassetteDirty(props.row)"></q-input>
</q-td>
<q-td key="position" class="text-right">
<q-input v-model.number="props.row.position" type="number"
min="1" step="1" dense outlined
:style="{width: '80px', display: 'inline-block'}"
@update:model-value="markCassetteDirty(props.row)"></q-input>
</q-td>
<q-td key="state_count" class="text-right">
<span v-if="props.row.state_count !== null"
:style="{fontSize: '0.85em', opacity: 0.7}"
v-text="props.row.state_count"></span>
<q-td key="state" class="text-right">
<span v-if="props.row.state_denomination !== null"
:style="{fontSize: '0.85em', opacity: 0.7}">
<span v-text="props.row.state_denomination"></span>
<span :style="{opacity: 0.6}"
v-text="' ' + (machineDetail.machine.fiat_code || '')"></span>
<span :style="{opacity: 0.6}"> · </span>
<span v-text="'×' + props.row.state_count"></span>
</span>
<span v-else :style="{opacity: 0.4}"></span>
</q-td>
<q-td key="updated_at">
@ -1094,17 +1098,16 @@
<p class="q-mb-sm">Sending to ATM:</p>
<q-list dense bordered>
<q-item v-for="row in machineDetail.cassetteEdits"
:key="row.denomination">
:key="row.position">
<q-item-section>
<q-item-label>
<b v-text="row.denomination + ' ' +
(machineDetail.machine.fiat_code || '')"></b>
<b v-text="'Bay ' + row.position"></b>
</q-item-label>
</q-item-section>
<q-item-section side>
<q-item-label caption>
position
<b v-text="row.position"></b>
<b v-text="row.denomination + ' ' +
(machineDetail.machine.fiat_code || '')"></b>
· count
<b v-text="row.count"></b>
</q-item-label>

View file

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

View file

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

View file

@ -308,6 +308,16 @@ _BITSPIRE_FIXTURE = {
}
@pytest.mark.skip(
reason=(
"v1.1 wire-shape pivot (coord-log 2026-05-30T18:30Z + 18:45Z): the "
"13:15Z fixture above is encoded with the old `{denominations: ...}` "
"wire shape; the v1.1 wire shape is `{positions: {<pos>: "
"{denomination, count}}}`. Bitspire will post a fresh fixture against "
"the v1.1 shape; once posted, swap `_BITSPIRE_FIXTURE` for the new "
"JSON and drop this skip (v1.1 commit g on the PR)."
)
)
class TestBitspireCrossTest:
"""Byte-compat cross-test between our hand-rolled NIP-44 v2 (`nip44.py`)
and the nostr-tools NIP-44 v2 impl that bitspire uses on the ATM side

View file

@ -774,15 +774,16 @@ async def api_update_super_config(
# =============================================================================
# Cassette configs (#29 v1) — per-machine ATM cassette inventory
# Cassette configs (#29 v1.1) — per-machine ATM cassette inventory
# =============================================================================
# v1 surface, paired with aiolabs/lamassu-next#56 ATM-side. Two endpoints:
# v1.1 surface, paired with aiolabs/lamassu-next#56 ATM-side. Two endpoints:
# GET /machines/{id}/cassettes — list rows for the operator UI
# POST /machines/{id}/cassettes/publish — apply edits + publish kind-30078
#
# Row creation (new (machine_id, denomination) pairs) is admin-only via the
# bootstrap consumer task — denomination set is hardware-determined.
# Operator-side flow is edit-and-publish over the existing rows only.
# Row creation (new (machine_id, position) pairs) is admin-only via the
# bootstrap consumer task — slot count is hardware-determined. Operator-
# side flow is edit-and-publish over the existing rows only; the editable
# fields per row are denomination and count.
@satmachineadmin_api_router.get(
@ -793,9 +794,9 @@ async def api_list_machine_cassettes(
machine_id: str, user: User = Depends(check_user_exists)
) -> list[CassetteConfig]:
"""List the cassette config rows for one of the operator's machines,
ordered by position then denomination. Empty list = ATM hasn't yet
published its bootstrap event (or the bootstrap consumer hasn't
processed it yet); UI should show a "waiting for ATM" state."""
ordered by position. Empty list = ATM hasn't yet published its
bootstrap event (or the bootstrap consumer hasn't processed it yet);
UI should show a "waiting for ATM" state."""
await _machine_owned_by(machine_id, user.id)
return await list_cassette_configs_for_machine(machine_id)
@ -810,11 +811,11 @@ async def api_publish_machine_cassettes(
user: User = Depends(check_user_exists),
) -> list[CassetteConfig]:
"""Operator submits the full per-machine cassette state for publish to
the ATM. Validates the denomination set matches what's currently in
cassette_configs for the machine (defensive UI prevents add/remove
but API enforces), upserts each row, then encrypts + signs + publishes
a kind-30078 event tagged with d=bitspire-cassettes:<atm_pubkey_hex>
and p=<atm_pubkey_hex>.
the ATM. Validates the position set matches what's currently in
cassette_configs for the machine (slot count is hardware-fixed),
upserts each row, then encrypts + signs + publishes a kind-30078
event tagged with d=bitspire-cassettes:<atm_pubkey_hex> and
p=<atm_pubkey_hex>.
The `<m>` placeholder in the published d-tag is the ATM's hex pubkey
from machine.machine_npub (canonicalised via normalize_public_key),
@ -825,8 +826,8 @@ async def api_publish_machine_cassettes(
can refresh its table from one round-trip.
Errors:
400 payload denomination set doesn't match the machine's stored
set (operator publishing a cassette that doesn't exist on the
400 payload position set doesn't match the machine's stored set
(operator publishing for a slot that doesn't exist on the
ATM; or the bootstrap hasn't landed yet so no rows exist)
400 operator hasn't onboarded a Nostr identity
503 signer offline / client-side-only, or nostrclient extension
@ -836,8 +837,8 @@ async def api_publish_machine_cassettes(
machine = await _machine_owned_by(machine_id, user.id)
existing = await list_cassette_configs_for_machine(machine_id)
existing_denoms = {row.denomination for row in existing}
incoming_denoms = set(payload.denominations.keys())
existing_positions = {row.position for row in existing}
incoming_positions = set(payload.positions.keys())
if not existing:
raise HTTPException(
@ -850,29 +851,30 @@ async def api_publish_machine_cassettes(
"receipt."
),
)
if existing_denoms != incoming_denoms:
missing = existing_denoms - incoming_denoms
extra = incoming_denoms - existing_denoms
if existing_positions != incoming_positions:
missing = existing_positions - incoming_positions
extra = incoming_positions - existing_positions
raise HTTPException(
HTTPStatus.BAD_REQUEST,
(
"Payload denomination set doesn't match the machine's "
f"stored set. Missing from payload: {sorted(missing)}; "
f"extra in payload: {sorted(extra)}. "
"Denomination set is hardware-determined — re-provision "
"the ATM via atm-tui to add/remove cassettes, then "
"re-publish."
"Payload position set doesn't match the machine's stored "
f"set. Missing from payload: {sorted(missing)}; extra in "
f"payload: {sorted(extra)}. Slot count is hardware-fixed "
"— re-provision the ATM via atm-tui to add/remove physical "
"bays, then re-publish."
),
)
# Apply each per-row edit so the operator-believed state on
# satmachineadmin reflects the published payload, even if the ATM
# ack lands later (v2). updated_by audit-stamps the operator user id.
for denom, row in payload.denominations.items():
for pos, row in payload.positions.items():
updated = await update_cassette_config(
machine_id,
denom,
UpsertCassetteConfigData(count=row.count, position=row.position),
pos,
UpsertCassetteConfigData(
denomination=row.denomination, count=row.count
),
updated_by=user.id,
)
if updated is None:
@ -880,7 +882,7 @@ async def api_publish_machine_cassettes(
# concurrent delete could land between. Surface as 500.
raise HTTPException(
HTTPStatus.INTERNAL_SERVER_ERROR,
f"cassette row for denomination {denom} disappeared mid-publish",
f"cassette row for position {pos} disappeared mid-publish",
)
try: