feat(v2): operator-side cassette inventory v1.1 + signer.nip44_* migration (#29) #30
2 changed files with 38 additions and 34 deletions
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>
commit
5dbd7314f4
|
|
@ -3,7 +3,7 @@ Cassette-config Nostr transport — operator ↔ ATM kind-30078 publish + consum
|
||||||
|
|
||||||
Per the locked design at aiolabs/satmachineadmin#29 (paired with
|
Per the locked design at aiolabs/satmachineadmin#29 (paired with
|
||||||
lamassu-next#56) and the dcd0874 privacy-by-default pivot, the operator
|
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)
|
kind = 30078 (NIP-78, replaceable)
|
||||||
tags = [
|
tags = [
|
||||||
|
|
@ -304,7 +304,7 @@ async def publish_to_atm(
|
||||||
logger.info(
|
logger.info(
|
||||||
f"satmachineadmin: published kind-30078 cassette config to ATM "
|
f"satmachineadmin: published kind-30078 cassette config to ATM "
|
||||||
f"{atm_pubkey_hex[:12]}... (event_id={signed['id'][:12]}..., "
|
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
|
return signed
|
||||||
|
|
||||||
|
|
|
||||||
68
crud.py
68
crud.py
|
|
@ -1379,14 +1379,14 @@ async def upsert_fleet_snapshot(
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Cassette configs — operator-driven ATM cassette inventory (#29).
|
# Cassette configs — operator-driven ATM cassette inventory (#29 v1.1).
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Row lifecycle per #29:
|
# 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)
|
# (consumer reading the ATM's one-shot bitspire-cassettes-state event)
|
||||||
# - Operator edit of count or position → update_cassette_config (refuses to
|
# - Operator edit of denomination or count → update_cassette_config
|
||||||
# create new rows; the denomination set is hardware-determined)
|
# (refuses to create new rows; the slot count is hardware-determined)
|
||||||
# - Row creation/deletion for a new denomination → admin only, via ATM
|
# - Row creation/deletion for a new position → admin only, via ATM
|
||||||
# re-provisioning + new bootstrap event (not exposed in v1 here)
|
# re-provisioning + new bootstrap event (not exposed in v1 here)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1407,12 +1407,12 @@ def _should_apply_bootstrap_state(
|
||||||
|
|
||||||
|
|
||||||
async def get_cassette_config(
|
async def get_cassette_config(
|
||||||
machine_id: str, denomination: int
|
machine_id: str, position: int
|
||||||
) -> Optional[CassetteConfig]:
|
) -> Optional[CassetteConfig]:
|
||||||
return await db.fetchone(
|
return await db.fetchone(
|
||||||
"SELECT * FROM satoshimachine.cassette_configs "
|
"SELECT * FROM satoshimachine.cassette_configs "
|
||||||
"WHERE machine_id = :mid AND denomination = :denom",
|
"WHERE machine_id = :mid AND position = :pos",
|
||||||
{"mid": machine_id, "denom": denomination},
|
{"mid": machine_id, "pos": position},
|
||||||
CassetteConfig,
|
CassetteConfig,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1422,7 +1422,7 @@ async def list_cassette_configs_for_machine(
|
||||||
) -> List[CassetteConfig]:
|
) -> List[CassetteConfig]:
|
||||||
return await db.fetchall(
|
return await db.fetchall(
|
||||||
"SELECT * FROM satoshimachine.cassette_configs "
|
"SELECT * FROM satoshimachine.cassette_configs "
|
||||||
"WHERE machine_id = :mid ORDER BY position, denomination",
|
"WHERE machine_id = :mid ORDER BY position",
|
||||||
{"mid": machine_id},
|
{"mid": machine_id},
|
||||||
CassetteConfig,
|
CassetteConfig,
|
||||||
)
|
)
|
||||||
|
|
@ -1430,18 +1430,18 @@ async def list_cassette_configs_for_machine(
|
||||||
|
|
||||||
async def update_cassette_config(
|
async def update_cassette_config(
|
||||||
machine_id: str,
|
machine_id: str,
|
||||||
denomination: int,
|
position: int,
|
||||||
data: UpsertCassetteConfigData,
|
data: UpsertCassetteConfigData,
|
||||||
*,
|
*,
|
||||||
updated_by: Optional[str] = None,
|
updated_by: Optional[str] = None,
|
||||||
) -> Optional[CassetteConfig]:
|
) -> Optional[CassetteConfig]:
|
||||||
"""Operator-driven row update: change count and/or position for a single
|
"""Operator-driven row update: change denomination and/or count for a
|
||||||
cassette. Refuses to create new rows — those only land via
|
single cassette slot. Refuses to create new rows — those only land via
|
||||||
apply_bootstrap_state() consuming an ATM bootstrap event (per #29 row
|
apply_bootstrap_state() consuming an ATM bootstrap event (per #29 row
|
||||||
lifecycle: hardware-determined denomination set, not operator-creatable).
|
lifecycle: hardware-determined slot count, not operator-creatable).
|
||||||
Returns None if the (machine_id, denomination) row doesn't exist.
|
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:
|
if existing is None:
|
||||||
return None
|
return None
|
||||||
update_data: dict = {k: v for k, v in data.dict().items() if v is not 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
|
update_data["updated_by"] = updated_by
|
||||||
set_clause = ", ".join(f"{k} = :{k}" for k in update_data)
|
set_clause = ", ".join(f"{k} = :{k}" for k in update_data)
|
||||||
update_data["mid"] = machine_id
|
update_data["mid"] = machine_id
|
||||||
update_data["denom"] = denomination
|
update_data["pos"] = position
|
||||||
await db.execute(
|
await db.execute(
|
||||||
f"UPDATE satoshimachine.cassette_configs SET {set_clause} "
|
f"UPDATE satoshimachine.cassette_configs SET {set_clause} "
|
||||||
"WHERE machine_id = :mid AND denomination = :denom",
|
"WHERE machine_id = :mid AND position = :pos",
|
||||||
update_data,
|
update_data,
|
||||||
)
|
)
|
||||||
return await get_cassette_config(machine_id, denomination)
|
return await get_cassette_config(machine_id, position)
|
||||||
|
|
||||||
|
|
||||||
async def apply_bootstrap_state(
|
async def apply_bootstrap_state(
|
||||||
|
|
@ -1467,17 +1467,18 @@ async def apply_bootstrap_state(
|
||||||
payload: PublishCassettesPayload,
|
payload: PublishCassettesPayload,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Consume an ATM-published kind-30078 bitspire-cassettes-state:<m> event
|
"""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
|
Returns True if the upsert ran; False if any existing row for this
|
||||||
machine already references this event_id (idempotent on relay
|
machine already references this event_id (idempotent on relay
|
||||||
re-delivery / restart).
|
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
|
updated_at, updated_by='atm-bootstrap') AND the v2 reverse-channel
|
||||||
columns (state_count, state_at, state_event_id) so the operator's
|
columns (state_denomination, state_count, state_at, state_event_id)
|
||||||
initial view matches the ATM's reported state. v2 reconciliation UI
|
so the operator's initial view matches the ATM's reported state. v2
|
||||||
will diverge them when continuous reverse-channel events land.
|
reconciliation UI will diverge them when continuous reverse-channel
|
||||||
|
events land + the operator subsequently edits.
|
||||||
"""
|
"""
|
||||||
existing_first = await db.fetchone(
|
existing_first = await db.fetchone(
|
||||||
"SELECT state_event_id FROM satoshimachine.cassette_configs "
|
"SELECT state_event_id FROM satoshimachine.cassette_configs "
|
||||||
|
|
@ -1495,30 +1496,33 @@ async def apply_bootstrap_state(
|
||||||
return False
|
return False
|
||||||
|
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
for denom, row in payload.denominations.items():
|
for pos, row in payload.positions.items():
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO satoshimachine.cassette_configs
|
INSERT INTO satoshimachine.cassette_configs
|
||||||
(machine_id, denomination, count, position, updated_at,
|
(machine_id, position, denomination, count, updated_at,
|
||||||
updated_by, state_count, state_at, state_event_id)
|
updated_by, state_denomination, state_count, state_at,
|
||||||
VALUES (:mid, :denom, :count, :pos, :now, :by,
|
state_event_id)
|
||||||
:state_count, :state_at, :event_id)
|
VALUES (:mid, :pos, :denom, :count, :now, :by,
|
||||||
ON CONFLICT (machine_id, denomination) DO UPDATE SET
|
:state_denom, :state_count, :state_at, :event_id)
|
||||||
|
ON CONFLICT (machine_id, position) DO UPDATE SET
|
||||||
|
denomination = excluded.denomination,
|
||||||
count = excluded.count,
|
count = excluded.count,
|
||||||
position = excluded.position,
|
|
||||||
updated_at = excluded.updated_at,
|
updated_at = excluded.updated_at,
|
||||||
updated_by = excluded.updated_by,
|
updated_by = excluded.updated_by,
|
||||||
|
state_denomination = excluded.state_denomination,
|
||||||
state_count = excluded.state_count,
|
state_count = excluded.state_count,
|
||||||
state_at = excluded.state_at,
|
state_at = excluded.state_at,
|
||||||
state_event_id = excluded.state_event_id
|
state_event_id = excluded.state_event_id
|
||||||
""",
|
""",
|
||||||
{
|
{
|
||||||
"mid": machine_id,
|
"mid": machine_id,
|
||||||
"denom": denom,
|
"pos": pos,
|
||||||
|
"denom": row.denomination,
|
||||||
"count": row.count,
|
"count": row.count,
|
||||||
"pos": row.position,
|
|
||||||
"now": now,
|
"now": now,
|
||||||
"by": "atm-bootstrap",
|
"by": "atm-bootstrap",
|
||||||
|
"state_denom": row.denomination,
|
||||||
"state_count": row.count,
|
"state_count": row.count,
|
||||||
"state_at": event_created_at,
|
"state_at": event_created_at,
|
||||||
"event_id": event_id,
|
"event_id": event_id,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue