Compare commits

...

23 commits

Author SHA1 Message Date
98bec9b4b2 chore(release): v0.1.8
Some checks failed
ci.yml / chore(release): v0.1.8 (push) Failing after 0s
/ release (push) Waiting to run
/ pullrequest (push) Blocked by required conditions
2026-10-10 22:31:17 +02:00
3fa2de8c5b Merge pull request 'ADR-005 slice 2: operator alert, settle off-machine, glossary + guide' (#50) from feat/dispense-outcome-slice2 into main
Some checks failed
ci.yml / Merge pull request 'ADR-005 slice 2: operator alert, settle off-machine, glossary + guide' (#50) from feat/dispense-outcome-slice2 into main (push) Failing after 0s
Reviewed-on: #50
2026-10-10 20:30:36 +00:00
1e235e3e42 test: slice-2 coverage — NIP-17 round trip, alert policy, settle path, op shape
Some checks failed
ci.yml / test: slice-2 coverage — NIP-17 round trip, alert policy, settle path, op shape (pull_request) Failing after 0s
2026-10-10 22:27:06 +02:00
9eeb7f29b4 docs: dispenser error glossary and operator guide
Served from the extension's static dir and linked from the dashboard
header. The glossary is keyed by raw code (78 42, 82 00, …) with the
class, what it means, and what to do inside the unit; the guide covers
bays/ops ownership, the authorize/capture lifecycle, the cash-out hold,
owed-cash resolution, and alerts.
2026-10-10 22:27:05 +02:00
cdb1fe71cc feat(ui): settle off-machine, glossary links, alerts pubkey
Settle action on the cash_owed/partial_pending worklist buckets and the
settlements table; the worklist's error cell links the raw code to the
glossary entry; the platform settings dialog gains the alerts pubkey.
2026-10-10 22:26:45 +02:00
7375112638 feat(api): settle a cash_owed / partial_pending settlement off-machine
POST /api/v1/dca/settlements/{id}/settle-cash-owed: the operator paid
the customer by hand. Appends the provenance note, distributes at the
full amount (the customer is whole), and publishes a settle_transaction
op so the machine flips its own dispense_error/partial row to
remediated. Until now the only way to close an owed row was to dispense
more cash from the machine that had just jammed.
2026-10-10 22:26:45 +02:00
8d19da43c2 feat(notify): alert the operator over Nostr when a customer is owed cash
cash_owed / partial_pending now sends a NIP-17 gift-wrapped DM (kind 14
rumor → kind 13 seal signed through the operator's signer → kind 1059
wrap from a throwaway key) to the operator's own account pubkey, or to
super_config.alerts_pubkey when set. Best-effort: a failed publish is
logged and the dispense report is still acked; the worklist is the
durable record. No email channel, no NIP-04.
2026-10-10 22:26:45 +02:00
786857f568 feat(models): settle_transaction op, alerts_pubkey, operator_notified_at (m017)
ADR-005 §6 groundwork. settle_transaction is a machine-wide op like
resume_cash_out; it carries the machine txid it closes and the operator's
provenance note. super_config.alerts_pubkey overrides where owed-cash
alerts go; dca_settlements.operator_notified_at stops a report resend
from re-alerting.
2026-10-10 22:26:45 +02:00
3f2c46c550 chore(release): v0.1.7
Some checks failed
ci.yml / chore(release): v0.1.7 (push) Failing after 0s
/ release (push) Waiting to run
/ pullrequest (push) Blocked by required conditions
2026-10-10 21:58:51 +02:00
784d41f946 Merge pull request 'ADR-005 rollout step 2 (slice 1): capture cash-out settlements on the machine's dispense report' (#49) from feat/dispense-outcome into main
Some checks failed
ci.yml / Merge pull request 'ADR-005 rollout step 2 (slice 1): capture cash-out settlements on the machine's dispense report' (#49) from feat/dispense-outcome into main (push) Failing after 0s
Reviewed-on: #49
2026-10-10 19:55:09 +00:00
79413dc06d test: dispense outcome capture — handler transitions, payment gate, resume op, hold mirror
Some checks failed
ci.yml / test: dispense outcome capture — handler transitions, payment gate, resume op, hold mirror (pull_request) Failing after 0s
Twenty tests in the project's style (asyncio.run, monkeypatched crud, no
DB): confirmed → pending + distribution; nothing out → cash_owed; some
out → partial_pending with nothing spawned; already-captured recorded
not moved; identical resend acked without a row; report-before-payment
stored unlinked and adopted when the payment lands; remediation moves
the owed settlement; a remediation that did not confirm leaves it;
unpaired sender / malformed body refused. The payment gate: cash_out →
awaiting_dispense with no distribution, cash_in unchanged. The
resume_cash_out op's position rule and wire shape, the worklist model's
new buckets, and the state-document hold mirror set-and-clear. The
existing nulls-never-reach-the-wire test learns the machine-wide op.
2026-10-10 21:51:52 +02:00
fdba2d363f feat(dashboard): owed-cash worklist buckets, prefilled partial dispense, resume cash-out (ADR-005 §5–§6)
Three buckets render first on the worklist — cash_owed, partial_pending,
dispense_unreported (awaiting_dispense older than the threshold) — the
only ones whose meaning is "a customer is owed money". partial_pending
rows open the partial-dispense dialog pre-filled from the machine's
report: the fraction from dispensed_fiat_cents / fiat_amount and the
dispenser's error in the note, so the operator confirms a number the
hardware produced rather than typing one.

Machine detail shows a held-cash-out banner (code, time, reason) with a
Resume button; POST /machines/{id}/resume-cash-out records a
resume_cash_out op and publishes the window. The machine clears the hold
on receipt and the banner clears on its next state report.
2026-10-10 21:51:52 +02:00
44c2afa5bb feat(transport): report_dispense RPC — capture a cash-out on the machine's report, not on payment (ADR-005 §1–§2)
The structural fix for bitspire#122. _handle_payment used to spawn
process_settlement the instant a cash_out payment landed — before the
machine had begun to dispense — so a jam two seconds later found the
legs already paid and `processed` was the honest answer. Payment is now
the authorization; the machine's report is the capture.

A cash_out lands as awaiting_dispense and is not distributed. The new
`report_dispense` handler (identity from the VERIFIED transport sender,
same as create_withdraw / get_machine_config) stores every report
append-only and moves the settlement: dispense_confirmed → pending and
distribution runs; some notes out → partial_pending, held whole
(ADR-005 Decision 1, one distribution when the shortfall is resolved);
nothing out → cash_owed, first on the worklist. A report naming
remediates_txid moves the owed settlement it names to pending in full.
Already-captured settlements are recorded but never moved — a report
cannot un-pay legs. A byte-identical resend is acked without a new row.

Both orders of arrival are handled: a report that precedes its payment
(hold invoices settle after the dispense; the invoice listener can lag)
is stored unlinked and adopted when the settlement is inserted, through
the same transition. counts_uncertain on a report mirrors onto the
machine immediately rather than at the next heartbeat. The state-event
consumer mirrors cash_out_held_* onto dca_machines, including clearing it.

Soft-fails like the other RPCs: without register_rpc the settlements sit
in awaiting_dispense and surface as dispense_unreported — the honest state.
2026-10-10 21:51:51 +02:00
b8a5e6352a feat(schema): dispense outcome on settlements, dispense_reports, cash-out hold mirror (ADR-005)
m016: an append-only `dispense_reports` table (lamassu-server's
cash_out_actions shape — one row per report the machine sent, so a
retry, a late report and a remediation report stay distinct);
dispense_confirmed / dispense_error / dispense_error_code /
dispense_raw_code / dispense_error_class / dispense_reported_at /
dispensed_fiat_cents on dca_settlements; cash_out_held_since / _reason /
_code on dca_machines beside counts_uncertain_since.

Settlement lifecycle gains awaiting_dispense (cash_out at insert — paid,
waiting for the machine's report), partial_pending (some notes out,
value short; held whole until the operator records the resolution) and
cash_owed (nothing out; legs never run). dispense_unreported is derived
by the worklist, not stored.

resume_cash_out joins CASSETTE_OP_TYPES as a machine-wide op: position 0,
no position on the wire, no bay fields. It rides the operator channel the
machine already consumes; the machine honours it only if stamped after
the hold began. A recount releases the hold too.

crud: get_settlement_by_txid (the join the machine's extra.txid already
provides), apply_dispense_outcome (copies the report onto the settlement
and finally writes bills_json / cassettes_json with what actually came
out), the dispense_reports accessors incl. adopting a report that
arrived before its payment, set_machine_cash_out_hold, and the three
new worklist buckets.
2026-10-10 21:51:51 +02:00
d09c51f277 Merge pull request 'Publish cassette operations instead of counts' (#46) from feat/cassette-ops-publisher into main
Some checks failed
ci.yml / Merge pull request 'Publish cassette operations instead of counts' (#46) from feat/cassette-ops-publisher into main (push) Failing after 0s
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
Reviewed-on: #46
2026-09-23 21:16:33 +00:00
5f60b3fe31 fix(cassettes): break the same-second tie with the machine's counter
Some checks failed
ci.yml / fix(cassettes): break the same-second tie with the machine's counter (pull_request) Failing after 0s
The ordering gate compares created_at, which NIP-01 defines at one-second
granularity. A dispense and the publish that follows it land inside one
second routinely, so the report was dropped and the operator kept the
pre-dispense count until the next heartbeat five minutes later.

The machine bumps a counter on every local change to a bay count and
carries it in its state document. m015 stores it per row, and the gate
consults it only when the stamps are equal, where created_at carries no
information at all.

Only on equality, deliberately. A machine whose state.db was replaced
restarts its counter at zero while its wall clock keeps moving forward;
gating on the counter across different stamps would lock that machine out
for good. Equal stamps with no counter on either side stay closed, which
costs one heartbeat and risks nothing.
2026-09-23 12:58:04 +02:00
79a4f83293 feat(cassettes): record operations from the dashboard instead of counts
The cassettes tab no longer has editable count fields, because there is
no longer an endpoint that would accept them. The bays render read-only
from the machine's own report, and a Record-operation dialog captures
what the operator did: a refill in notes added, an empty, a recount, a
denomination change.

This removes the failure the tab used to invite. A form loaded before a
dispense held a count that was already wrong, and publishing it
overwrote the dispense with no error on either side. Recording a delta
instead means a dispense that happened while the dialog was open is kept
rather than discarded, and a recount is now an explicit act — what an
operator opening a bay and counting actually does — rather than being
indistinguishable from a stale form.

A recent-operations list shows each one as Applied or Pending from
acked_at, which is the machine echoing the id back. Pending needs no
retry button: every publish carries the recent window, so an operation
that missed its own publish keeps being re-offered until it lands, and
saying so in the panel is more useful than a button that would do
nothing new.

The counts-uncertain banner tells the operator when the machine cannot
vouch for its own numbers and asks for the recount that clears it. The
machine row is re-read on every cassette refresh, since that flag is set
by the consumer while the dialog is open.
2026-09-23 12:50:08 +02:00
c76a1bb125 feat(cassettes): persist the machine's counts-uncertain marker
The machine has been publishing counts_uncertain_since since v1 of the
state document and spirekeeper has been parsing it into a field nobody
read. That defeats the point of the marker: it exists so a human opens
the bay and recounts.

A dispenser can throw, or time out, after notes have physically moved.
The machine cannot know how many left, so rather than decrement a number
it would be guessing at, it stamps the moment (bitspire ADR-004,
decision 3). m014 gives that stamp a home on dca_machines, and the
consumer mirrors it on every state event.

Stored on the machine rather than the bay because the uncertainty is
about the dispense as a whole; a multi-bay dispense that fails midway
gives no reliable way to attribute it to one position.

Written through even when the machine reports None. The machine clearing
the marker is as important as setting it — the operator recounted, the
bay is trustworthy again — and a banner that never goes away is a banner
nobody reads.
2026-09-23 12:47:30 +02:00
2ac3e2064e feat(cassettes): swap the count-publish endpoint for operation endpoints
The operator can no longer write a count. POST .../cassettes/ops records
one operation — refill, empty, recount, set_denomination — and publishes
the machine's recent window; GET .../cassettes/ops lists them newest
first with acked_at, so the dashboard can tell a delivered operation from
one merely sent.

POST .../cassettes/publish is gone, along with update_cassette_config and
UpsertCassetteConfigData. Nothing in the operator can now set a count,
which is the point: a value with one writer cannot be clobbered. Under
the old endpoint a dashboard form loaded before a dispense silently
discarded that dispense on publish, and neither side could detect it —
addressable events order by created_at at second granularity and a relay
returns OK for an event it then drops, so the losing writer is never told.

The op is recorded before the publish and is deliberately not rolled back
when the publish fails. It records something that physically happened;
notes went into a bay whether or not a relay was reachable. The window
carries recent operations rather than just the newest, so an op that
missed its own publish rides out with the next one.

Validation rejects an unpaired machine and a position the machine has not
reported. Bay count stays hardware-determined.
2026-09-23 12:44:18 +02:00
3d8368bcc4 feat(cassettes): consume the machine's operation acknowledgements
The machine echoes the operation ids it has applied in its state
document, and this records them. That echo is the only acknowledgement
this transport can carry: an addressable event gives its publisher no
failure signal at all, since the relay returns OK for an event it then
discards. Without it the dashboard could only ever show an operation as
sent, never as delivered.

Deliberately not gated on whether the state event advanced the counts.
The machine echoes its applied ids on every publish, heartbeats included,
so an event carrying nothing new about the counts can still be the first
one to tell us an operation landed.

The consumer goes in before the producer on purpose. The machine does not
send applied_ops yet, and every new field on the state payload defaults to
a value meaning "this machine does not report that yet" rather than to
one that would be wrong — an absent list reads as nothing acknowledged,
which is exactly right for a machine that has applied nothing.

Also picks up seq and counts_uncertain_since, which the machine already
publishes and this side was dropping on the floor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 12:38:12 +02:00
90bd43d6da feat(cassettes): publish operations to the ATM
The v2 operator to ATM wire. Same kind-30078 document and the same d-tag
the counts wire used, because the machine subscribes by that tag and the
document is addressable, so v2 replaces v1 in place.

Sends a WINDOW of recent operations, oldest-first, not just the newest
change. Each publish replaces the last, so a machine that was offline for
one of them would otherwise never see that operation again; carrying the
recent history means the channel heals itself without anyone noticing it
broke. Re-delivery costs nothing because every op carries an id the
machine dedups on.

Tests pin the contract rather than the implementation: the d-tag, that
the payload declares v2 and carries no positions key, that window order
survives the publisher untouched, that an empty window still ships a
well-formed document so a machine can tell "no operations" from "operator
still on v1", and that an npub entered in the UI is normalised to hex —
get that last one wrong and the machine's subscription filter silently
never matches.

Additive. The endpoints still publish counts until the next commit, so
the tree is not left half-switched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 12:35:42 +02:00
b2157db219 feat(cassettes): record and read operator operations
Append-only, and deliberately nothing here writes cassette_configs. That
table now holds only what the machine has reported; letting an operation
write it would put back the second writer this whole design exists to
remove.

get_cassette_ops_window returns oldest-first because order is meaning: a
recount followed by a refill is not the same as the reverse. It takes the
most recent N and reverses, so the window slides without the machine ever
seeing them out of sequence.

The window is what makes the channel self-healing, so it has to cover a
plausible outage rather than just the newest change — a machine that
missed one event still sees the operation in the next.

_should_ack_op is extracted pure, the same way the state-event gate is,
because three of its rules are easy to get wrong and none need a database
to test: an unknown id closes out nothing, one machine must never be able
to ack another machine's operation, and the FIRST acknowledgement is the
one worth keeping. That last one matters because the machine echoes a
window, so every id comes back many times over; overwriting would keep
sliding the timestamp forward and lose when the operation actually landed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 12:33:46 +02:00
d8190375a6 feat(cassettes): schema and models for operator operations
First piece of the v2 wire (bitspire ADR-004). The operator stops
publishing counts and starts publishing what it DID; the machine, which
holds the notes, keeps the running total. A value with one writer cannot
be clobbered, which is the whole point: the absolute-count wire let a
form loaded before a dispense discard that dispense when published, and
nothing in an addressable event can tell the loser it lost.

m013 adds cassette_ops, append-only. The id is minted here and is the
idempotency key the machine dedups on, because a delta applied twice is
wrong and addressable events are re-delivered on reconnect. acked_at is
set when the machine reports that id back, which is the only
acknowledgement this transport can carry.

The models enforce that an op carries exactly the one field its type
means, so an instance is publishable by construction — the same contract
FeeConfigPayload has — and nulls never reach the wire for the machine to
disambiguate. recount is the only absolute, deliberately: it is what an
operator opening a bay and counting actually does, and it stays
auditable as its own act rather than looking like a stale form.

Vocabulary mirrors lamassu-server's cash_unit_operation_type.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 09:50:47 +02:00
19 changed files with 4018 additions and 397 deletions

View file

@ -6,6 +6,7 @@ from loguru import logger
from .cashin_transport import register_create_withdraw_rpc
from .crud import db
from .dispense_transport import register_dispense_report_rpc
from .machine_config_transport import register_machine_config_rpc
from .nostr_transport_roster import register_with_lnbits as register_roster_with_lnbits
from .tasks import wait_for_cassette_state_events, wait_for_paid_invoices
@ -68,6 +69,11 @@ def spirekeeper_start():
# config over the transport, leaving "awaiting configuration" with no
# per-machine env provisioning. Soft-fails if register_rpc isn't exposed.
register_machine_config_rpc()
# Dispense outcome capture (bitspire ADR-005 §2 / #122): register the
# report_dispense RPC. A cash-out settlement now waits in awaiting_dispense
# until the machine reports; the success report is what distributes it,
# a failure report puts the customer on the owed-cash worklist.
register_dispense_report_rpc()
__all__ = [

View file

@ -17,8 +17,14 @@ publishes position-keyed cassette config to a target ATM via:
The ATM-side consumer (lamassu-next#56) subscribes by the d-tag + its own
npub, decrypts, validates, applies, hot-reloads HAL.
Reverse direction (ATM → operator, v1 = one-shot bootstrap on first boot,
v2 = continuous reverse channel for reconciliation):
The operator → ATM direction carries OPERATIONS as of v2 (bitspire ADR-004):
refill, empty, recount, set_denomination, each with an id the machine dedups
on. It used to carry absolute counts, which meant the operator and the machine
both wrote the same value over a transport that never tells a writer it lost —
so a form loaded before a dispense discarded that dispense when published.
Reverse direction (ATM → operator, continuous: the machine publishes on
startup, after every change to its bays, and on a heartbeat):
kind = 30078
tags = [
@ -30,7 +36,7 @@ v2 = continuous reverse channel for reconciliation):
This module owns the wire-format side of both directions. The consumer
task (tasks.py) calls `decrypt_and_parse_state_event` per incoming event;
the API endpoint (views_api.py) calls `publish_to_atm` per operator submit.
the API endpoint (views_api.py) calls `publish_ops_to_atm` per operation.
The `<m>` placeholder semantics (load-bearing per the 2026-05-30T11:50Z
coord-log entry): always the ATM's hex pubkey, NEVER spirekeeper's
@ -52,7 +58,12 @@ from lnbits.core.signers.base import (
)
from lnbits.utils.nostr import normalize_public_key
from .models import Machine, PublishCassettesPayload
from .models import (
CassetteOp,
Machine,
PublishCassetteOpsPayload,
PublishCassettesPayload,
)
from .nip44 import Nip44Error
from .nostr_publish import (
NostrPublishError,
@ -75,7 +86,7 @@ __all__ = [
"RelayUnavailable",
"build_state_d_tags_for_machines",
"decrypt_and_parse_state_event",
"publish_to_atm",
"publish_ops_to_atm",
]
_D_TAG_CONFIG_PREFIX = "bitspire-cassettes:" # operator → ATM
@ -152,35 +163,41 @@ def build_state_d_tags_for_machines(machines: list[Machine]) -> list[str]:
# =============================================================================
async def publish_to_atm(
async def publish_ops_to_atm(
machine: Machine,
payload: PublishCassettesPayload,
ops: list[CassetteOp],
operator_user_id: str,
) -> dict:
"""Build, encrypt, sign, and publish a kind-30078 cassette config event
from the operator to the target ATM.
"""Publish the operator's recent cassette OPERATIONS to the target ATM.
Returns the signed event dict on success (caller may log event.id for
audit). Raises NostrPublishError subclasses (re-exported here as
CassetteTransportError, OperatorIdentityMissing, SignerUnavailable,
RelayUnavailable) on hard failures.
The v2 wire (bitspire ADR-004). Replaces sending absolute counts, which
let a dashboard form loaded before a dispense silently discard that
dispense — the operator and the machine were both writing the same value
over a transport that never tells a writer it lost.
`ops` is a WINDOW, oldest-first, not just the newest change. The event is
addressable, so each publish replaces the last, and a machine that was
offline for one of them would otherwise never see that operation again.
Carrying the recent history means the channel heals itself without anyone
noticing it broke. Re-delivery is harmless because each op carries an id
the machine dedups on.
"""
atm_pubkey_hex = _atm_hex_pubkey(machine)
payload = PublishCassetteOpsPayload(ops=ops)
signed = await publish_encrypted_kind_30078(
operator_user_id=operator_user_id,
recipient_pubkey_hex=atm_pubkey_hex,
d_tag=_config_d_tag(atm_pubkey_hex),
payload=payload.to_wire_dict(),
log_context=(
f"cassette config (machine={machine.id}, "
f"positions={sorted(payload.positions.keys())})"
f"cassette ops (machine={machine.id}, ops={[o.op_type for o in ops]})"
),
)
return signed
# =============================================================================
# Consume — ATM → operator (the bootstrap consumer task)
# Consume — ATM → operator (the machine's state reports)
# =============================================================================

View file

@ -1,5 +1,6 @@
{
"name": "spirekeeper",
"version": "0.1.8",
"short_description": "Operator admin for bitSpire ATMs — DCA distribution over Nostr",
"tile": "/spirekeeper/static/image/aio.png",
"min_lnbits_version": "1.0.0",

507
crud.py
View file

@ -12,9 +12,11 @@ from lnbits.helpers import urlsafe_short_hash
from .models import (
CassetteConfig,
CassetteOp,
ClientBalanceSummary,
CommissionSplit,
CommissionSplitLeg,
CreateCassetteOpData,
CreateDcaClientData,
CreateDcaPaymentData,
CreateDcaSettlementData,
@ -25,6 +27,8 @@ from .models import (
DcaLpPreferences,
DcaPayment,
DcaSettlement,
DispenseReport,
DispenseReportIn,
Machine,
PublishCassettesPayload,
SuperConfig,
@ -34,7 +38,6 @@ from .models import (
UpdateDepositStatusData,
UpdateMachineData,
UpdateSuperConfigData,
UpsertCassetteConfigData,
UpsertDcaLpData,
)
@ -256,6 +259,51 @@ async def set_machine_unpaired(machine_id: str) -> Machine | None:
return await get_machine(machine_id)
async def set_machine_cash_out_hold(
machine_id: str,
since: datetime | None,
reason: str | None,
code: str | None,
) -> None:
"""Mirror the machine's cash-out hold (ADR-005 §5) onto its registry row.
Written on every state event, including when it is None: the machine
clearing the hold — after an operator recount or resume_cash_out — is as
important as it setting one. `updated_at` is left alone for the same
reason as counts_uncertain_since: this is the machine reporting about
itself on a heartbeat, not an operator editing the machine.
"""
await db.execute(
"""
UPDATE spirekeeper.dca_machines
SET cash_out_held_since = :since,
cash_out_held_reason = :reason,
cash_out_held_code = :code
WHERE id = :id
""",
{"id": machine_id, "since": since, "reason": reason, "code": code},
)
async def set_machine_counts_uncertain(machine_id: str, since: datetime | None) -> None:
"""Record (or clear) the machine's own "I can't vouch for these counts"
marker, straight from its state document.
`updated_at` is deliberately left alone. This is the machine reporting
about itself on a five-minute heartbeat, not an operator editing the
machine, and touching the timestamp on every heartbeat would make the
registry look perpetually just-modified.
"""
await db.execute(
"""
UPDATE spirekeeper.dca_machines
SET counts_uncertain_since = :since
WHERE id = :id
""",
{"since": since, "id": machine_id},
)
async def delete_machine(machine_id: str) -> None:
await db.execute(
"DELETE FROM spirekeeper.dca_machines WHERE id = :id",
@ -691,6 +739,180 @@ async def create_settlement_idempotent(
return await get_settlement(settlement_id)
async def mark_settlement_notified(settlement_id: str) -> None:
"""The operator alert for this settlement went out (ADR-005 §6)."""
await db.execute(
"UPDATE spirekeeper.dca_settlements SET operator_notified_at = :now "
"WHERE id = :id AND operator_notified_at IS NULL",
{"id": settlement_id, "now": datetime.now()},
)
async def get_settlement_by_txid(
machine_id: str, bitspire_txid: str
) -> DcaSettlement | None:
"""The settlement a machine report refers to. `bitspire_txid` comes from
the invoice's extra.txid, stamped by the machine at create_invoice time,
so it is the natural join for a report that names the same txid."""
return await db.fetchone(
"SELECT * FROM spirekeeper.dca_settlements "
"WHERE machine_id = :mid AND bitspire_txid = :txid",
{"mid": machine_id, "txid": bitspire_txid},
DcaSettlement,
)
async def apply_dispense_outcome(
settlement_id: str,
report: DispenseReportIn,
new_status: str,
reported_at: datetime,
) -> DcaSettlement | None:
"""Copy the machine's report onto the settlement and move it (ADR-005 §1).
Fills the never-before-written bills_json / cassettes_json with what
actually came out, not what was provisioned. `error_message` is left to
the distribution path; the dispense error lives in its own columns.
"""
import json as _json
await db.execute(
"""
UPDATE spirekeeper.dca_settlements
SET status = :status,
dispense_confirmed = :confirmed,
dispense_error = :error,
dispense_error_code = :error_code,
dispense_raw_code = :raw_code,
dispense_error_class = :error_class,
dispense_reported_at = :reported_at,
dispensed_fiat_cents = :dispensed_fiat_cents,
bills_json = :bills_json,
cassettes_json = :cassettes_json,
processing_claim = NULL
WHERE id = :id
""",
{
"id": settlement_id,
"status": new_status,
"confirmed": report.dispense_confirmed,
"error": report.error,
"error_code": report.error_code,
"raw_code": report.raw_code,
"error_class": report.error_class,
"reported_at": reported_at,
"dispensed_fiat_cents": report.dispensed_fiat_cents,
"bills_json": _json.dumps([b.dict() for b in report.bills]),
"cassettes_json": _json.dumps([c.dict() for c in report.cassettes]),
},
)
return await get_settlement(settlement_id)
# ---------------------------------------------------------------------------
# Dispense reports (ADR-005 §2) — append-only
# ---------------------------------------------------------------------------
async def get_dispense_report(
machine_id: str, txid: str, reported_at: int
) -> DispenseReport | None:
"""A report is identified by (machine, txid, at): the machine resends the
same report until acked, and a byte-identical resend must not grow the
log. A remediation report for the same txid carries a later `at`."""
return await db.fetchone(
"SELECT * FROM spirekeeper.dispense_reports "
"WHERE machine_id = :mid AND txid = :txid AND reported_at = :at",
{"mid": machine_id, "txid": txid, "at": datetime.fromtimestamp(reported_at)},
DispenseReport,
)
async def insert_dispense_report(
machine_id: str, settlement_id: str | None, report: DispenseReportIn
) -> DispenseReport:
import json as _json
report_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO spirekeeper.dispense_reports
(id, machine_id, settlement_id, txid, payment_hash, dispense_confirmed,
error, error_code, raw_code, error_class, fiat_cents, currency,
bills_json, cassettes_json, counts_uncertain, remediates_txid,
reported_at, received_at)
VALUES (:id, :machine_id, :settlement_id, :txid, :payment_hash,
:confirmed, :error, :error_code, :raw_code, :error_class,
:fiat_cents, :currency, :bills_json, :cassettes_json,
:counts_uncertain, :remediates_txid, :reported_at, :received_at)
""",
{
"id": report_id,
"machine_id": machine_id,
"settlement_id": settlement_id,
"txid": report.txid,
"payment_hash": report.payment_hash,
"confirmed": report.dispense_confirmed,
"error": report.error,
"error_code": report.error_code,
"raw_code": report.raw_code,
"error_class": report.error_class,
"fiat_cents": report.fiat_cents,
"currency": report.currency,
"bills_json": _json.dumps([b.dict() for b in report.bills]),
"cassettes_json": _json.dumps([c.dict() for c in report.cassettes]),
"counts_uncertain": report.counts_uncertain,
"remediates_txid": report.remediates_txid,
"reported_at": datetime.fromtimestamp(report.at),
"received_at": datetime.now(),
},
)
row = await db.fetchone(
"SELECT * FROM spirekeeper.dispense_reports WHERE id = :id",
{"id": report_id},
DispenseReport,
)
assert row is not None, "Newly inserted dispense report couldn't be retrieved"
return row
async def link_dispense_reports_to_settlement(
machine_id: str, txid: str, settlement_id: str
) -> int:
"""A report can arrive before its payment lands (hold invoices settle
after the dispense; the invoice listener can lag). When the settlement is
finally inserted, adopt the orphan rows."""
result = await db.execute(
"UPDATE spirekeeper.dispense_reports SET settlement_id = :sid "
"WHERE machine_id = :mid AND txid = :txid AND settlement_id IS NULL",
{"sid": settlement_id, "mid": machine_id, "txid": txid},
)
return getattr(result, "rowcount", 0) or 0
async def get_latest_unlinked_dispense_report(
machine_id: str, txid: str
) -> DispenseReport | None:
return await db.fetchone(
"SELECT * FROM spirekeeper.dispense_reports "
"WHERE machine_id = :mid AND txid = :txid AND settlement_id IS NULL "
"ORDER BY reported_at DESC LIMIT 1",
{"mid": machine_id, "txid": txid},
DispenseReport,
)
async def get_dispense_reports_for_settlement(
settlement_id: str,
) -> list[DispenseReport]:
return await db.fetchall(
"SELECT * FROM spirekeeper.dispense_reports WHERE settlement_id = :sid "
"ORDER BY reported_at ASC",
{"sid": settlement_id},
DispenseReport,
)
async def get_settlement(settlement_id: str) -> DcaSettlement | None:
return await db.fetchone(
"SELECT * FROM spirekeeper.dca_settlements WHERE id = :id",
@ -732,7 +954,14 @@ async def get_stuck_settlements_for_operator(
) -> dict:
"""Operator worklist of settlements that didn't process cleanly.
Returns a dict with four keyed lists:
Returns a dict with seven keyed lists. The first three are ADR-005 §6 —
the only ones whose meaning is "a customer is owed money":
- 'cash_owed': the machine reported nothing dispensed; legs never ran.
- 'partial_pending': some notes out, value short; held whole until the
operator records the resolution.
- 'dispense_unreported': awaiting_dispense older than the threshold —
the machine never reported (crashed, offline, or an old build).
Then the original four:
- 'rejected': any status='rejected' (Nostr attribution cross-check
failed — signer didn't match the machine identity). Distinct
from 'errored' because retry is wrong: the row was misrouted,
@ -797,7 +1026,48 @@ async def get_stuck_settlements_for_operator(
{"uid": operator_user_id, "threshold": threshold_at},
DcaSettlement,
)
# ADR-005 §6 — the owed-cash buckets. cash_owed / partial_pending are
# stored statuses; dispense_unreported is derived: a cash-out that landed
# and never heard from its machine within the threshold.
cash_owed = await db.fetchall(
"""
SELECT s.*
FROM spirekeeper.dca_settlements s
JOIN spirekeeper.dca_machines m ON m.id = s.machine_id
WHERE m.operator_user_id = :uid AND s.status = 'cash_owed'
ORDER BY s.created_at DESC
""",
{"uid": operator_user_id},
DcaSettlement,
)
partial_pending = await db.fetchall(
"""
SELECT s.*
FROM spirekeeper.dca_settlements s
JOIN spirekeeper.dca_machines m ON m.id = s.machine_id
WHERE m.operator_user_id = :uid AND s.status = 'partial_pending'
ORDER BY s.created_at DESC
""",
{"uid": operator_user_id},
DcaSettlement,
)
dispense_unreported = await db.fetchall(
"""
SELECT s.*
FROM spirekeeper.dca_settlements s
JOIN spirekeeper.dca_machines m ON m.id = s.machine_id
WHERE m.operator_user_id = :uid
AND s.status = 'awaiting_dispense'
AND s.created_at < :threshold
ORDER BY s.created_at ASC
""",
{"uid": operator_user_id, "threshold": threshold_at},
DcaSettlement,
)
return {
"cash_owed": cash_owed,
"partial_pending": partial_pending,
"dispense_unreported": dispense_unreported,
"rejected": rejected,
"errored": errored,
"stuck_pending": stuck_pending,
@ -850,9 +1120,10 @@ async def mark_settlement_status(
status: str,
error_message: str | None = None,
) -> DcaSettlement | None:
"""Status: 'pending' | 'processing' | 'processed' | 'partial' |
'refunded' | 'errored'. Clears processing_claim on terminal states so a
fresh claim attempt won't see a stale token."""
"""Status: 'awaiting_dispense' | 'pending' | 'processing' | 'processed' |
'partial_pending' | 'cash_owed' | 'partial' | 'refunded' | 'errored'.
Clears processing_claim on terminal states so a fresh claim attempt won't
see a stale token."""
await db.execute(
"""
UPDATE spirekeeper.dca_settlements
@ -1444,10 +1715,11 @@ async def upsert_fleet_snapshot(
# Row lifecycle per #29:
# - First population for a (machine_id, position) pair → apply_reported_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
# re-provisioning + new bootstrap event (not exposed in v1 here)
# - The operator does NOT write these rows. It records operations
# (cassette_ops) and the machine keeps the running count; these columns
# hold what the machine last reported.
# - Rows appear and disappear only as the machine reports its bay set —
# the slot count is hardware-determined.
def _as_unix(value) -> float | None:
@ -1470,13 +1742,23 @@ def _as_unix(value) -> float | None:
return None
def _should_apply_state_event(oldest_state_at, incoming_created_at) -> bool:
def _row_field(row, name):
"""Read one column from a row the data layer may hand back as either a
mapping or an object, depending on the driver."""
if isinstance(row, dict):
return row.get(name)
return getattr(row, name, None)
def _should_apply_state_event(
oldest_state_at, incoming_created_at, oldest_seq=None, incoming_seq=None
) -> bool:
"""Ordering gate for apply_reported_state.
Applies only when the incoming event is strictly newer than the OLDEST
state stamp on file for the machine.
Two deliberate choices:
Three deliberate choices:
- Compare created_at, not event ids. The old gate asked only whether the
incoming id differed from one stored row, which is a one-event memory:
@ -1489,6 +1771,14 @@ def _should_apply_state_event(oldest_state_at, incoming_created_at) -> bool:
some rows advanced and some not. Gating on the oldest means a partial
apply is re-applied on the next event rather than being mistaken for a
complete one, and the ATM republishes on a heartbeat, so it converges.
- `seq` breaks a same-second tie, and only that. NIP-01 stamps at
one-second granularity, so a dispense and the publish that follows it
can share a stamp and the later report would be dropped. The machine
bumps seq on every local count change, so a higher seq at an equal stamp
is strictly newer. It is consulted ONLY on equality: a machine whose
state.db was replaced restarts its counter at zero, and gating on seq
across different stamps would lock that machine out permanently while
its wall clock kept moving forward.
"""
oldest = _as_unix(oldest_state_at)
if oldest is None:
@ -1496,7 +1786,11 @@ def _should_apply_state_event(oldest_state_at, incoming_created_at) -> bool:
incoming = _as_unix(incoming_created_at)
if incoming is None:
return False
if incoming != oldest:
return incoming > oldest
if incoming_seq is None or oldest_seq is None:
return False
return incoming_seq > oldest_seq
async def get_cassette_config(machine_id: str, position: int) -> CassetteConfig | None:
@ -1519,38 +1813,6 @@ async def list_cassette_configs_for_machine(
)
async def update_cassette_config(
machine_id: str,
position: int,
data: UpsertCassetteConfigData,
*,
updated_by: str | None = None,
) -> CassetteConfig | None:
"""Operator-driven row update: change denomination and/or count for a
single cassette slot. Refuses to create new rows — those only land via
apply_reported_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.
"""
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}
if not update_data:
return existing
update_data["updated_at"] = datetime.now()
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
await db.execute(
f"UPDATE spirekeeper.cassette_configs SET {set_clause} "
"WHERE machine_id = :mid AND position = :pos",
update_data,
)
return await get_cassette_config(machine_id, position)
async def apply_reported_state(
machine_id: str,
event_id: str,
@ -1576,19 +1838,19 @@ async def apply_reported_state(
against believed.
"""
oldest: dict | None = await db.fetchone(
"SELECT state_at FROM spirekeeper.cassette_configs "
"SELECT state_at, state_seq FROM spirekeeper.cassette_configs "
"WHERE machine_id = :mid AND state_at IS NOT NULL "
"ORDER BY state_at ASC LIMIT 1",
"ORDER BY state_at ASC, state_seq ASC LIMIT 1",
{"mid": machine_id},
)
oldest_state_at = None
oldest_seq = None
if oldest is not None:
oldest_state_at = (
oldest.get("state_at")
if isinstance(oldest, dict)
else getattr(oldest, "state_at", None)
)
if not _should_apply_state_event(oldest_state_at, event_created_at):
oldest_state_at = _row_field(oldest, "state_at")
oldest_seq = _row_field(oldest, "state_seq")
if not _should_apply_state_event(
oldest_state_at, event_created_at, oldest_seq, payload.seq
):
return False
# Drop bays the machine no longer reports, before writing the rest. A crash
@ -1612,9 +1874,10 @@ async def apply_reported_state(
INSERT INTO spirekeeper.cassette_configs
(machine_id, position, denomination, count, updated_at,
updated_by, state_denomination, state_count, state_at,
state_event_id)
state_event_id, state_seq)
VALUES (:mid, :pos, :denom, :count, :now, :by,
:state_denom, :state_count, :state_at, :event_id)
:state_denom, :state_count, :state_at, :event_id,
:state_seq)
ON CONFLICT (machine_id, position) DO UPDATE SET
denomination = excluded.denomination,
count = excluded.count,
@ -1623,7 +1886,8 @@ async def apply_reported_state(
state_denomination = excluded.state_denomination,
state_count = excluded.state_count,
state_at = excluded.state_at,
state_event_id = excluded.state_event_id
state_event_id = excluded.state_event_id,
state_seq = excluded.state_seq
""",
{
"mid": machine_id,
@ -1636,6 +1900,141 @@ async def apply_reported_state(
"state_count": row.count,
"state_at": event_created_at,
"event_id": event_id,
"state_seq": payload.seq,
},
)
return True
# ---------------------------------------------------------------------------
# Cassette operations — the v2 operator → ATM wire (bitspire ADR-004)
# ---------------------------------------------------------------------------
# Append-only. The operator records what it DID to a bay; the machine keeps the
# running count. Nothing here ever updates cassette_configs: that table now
# holds only what the machine has reported, and letting an operation write it
# would reintroduce the second writer this design exists to remove.
# How many operations ride along in each published event. The window is what
# makes the channel self-healing — a machine that missed one event still sees
# the operation in the next — so it needs to cover a plausible outage, not just
# the newest change. Twenty is roughly a fortnight of refills on a busy fleet
# and still a small payload.
CASSETTE_OPS_WINDOW = 20
async def create_cassette_op(
machine_id: str, data: CreateCassetteOpData, created_by: str | None
) -> CassetteOp:
"""Record one operator intent against one bay.
The id minted here is the idempotency key: it travels on the wire, the
machine records the ones it has applied, and a re-delivered event is
therefore free rather than double-counted.
"""
op_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO spirekeeper.cassette_ops
(id, machine_id, position, op_type, bills, count, denomination,
txid, note, created_at, created_by)
VALUES (:id, :machine_id, :position, :op_type, :bills, :count,
:denomination, :txid, :note, :created_at, :created_by)
""",
{
"id": op_id,
"machine_id": machine_id,
"position": data.position,
"op_type": data.op_type,
"bills": data.bills,
"count": data.count,
"denomination": data.denomination,
"txid": data.txid,
"note": data.note,
"created_at": datetime.now(),
"created_by": created_by,
},
)
op = await get_cassette_op(op_id)
assert op is not None, "Newly recorded cassette op couldn't be retrieved"
return op
async def get_cassette_op(op_id: str) -> CassetteOp | None:
return await db.fetchone(
"SELECT * FROM spirekeeper.cassette_ops WHERE id = :id",
{"id": op_id},
CassetteOp,
)
async def get_cassette_ops_window(
machine_id: str, limit: int = CASSETTE_OPS_WINDOW
) -> list[CassetteOp]:
"""The operations a publish carries, oldest first.
Selected newest-first to take the most recent `limit`, then reversed so the
machine applies them in the order they happened. That ordering matters:
a recount followed by a refill is not the same as the reverse.
"""
rows = await db.fetchall(
"SELECT * FROM spirekeeper.cassette_ops WHERE machine_id = :mid "
"ORDER BY created_at DESC, id DESC LIMIT :limit",
{"mid": machine_id, "limit": limit},
CassetteOp,
)
return list(reversed(rows))
async def list_cassette_ops(
machine_id: str, limit: int = CASSETTE_OPS_WINDOW
) -> list[CassetteOp]:
"""Newest-first, for the dashboard's history panel."""
return await db.fetchall(
"SELECT * FROM spirekeeper.cassette_ops WHERE machine_id = :mid "
"ORDER BY created_at DESC, id DESC LIMIT :limit",
{"mid": machine_id, "limit": limit},
CassetteOp,
)
def _should_ack_op(existing: CassetteOp | None, machine_id: str) -> bool:
"""Pure decision behind mark_cassette_ops_acked, extracted so it is
testable without a database — same approach as the state-event gate.
Three reasons not to ack, and each matters:
- the id is unknown, so there is nothing to close out;
- it belongs to another machine, and one machine reporting an id must
never be able to close out another machine's operation;
- it is already acked, and the first acknowledgement is the interesting
one. The machine echoes a WINDOW, so every id arrives many times over;
overwriting would keep sliding the timestamp forward and lose when the
operation actually landed.
"""
if existing is None:
return False
if existing.machine_id != machine_id:
return False
return existing.acked_at is None
async def mark_cassette_ops_acked(machine_id: str, op_ids: list[str]) -> int:
"""Record that the machine reported these operation ids as applied.
Returns how many rows moved from unacked to acked. See _should_ack_op for
which reports are ignored and why.
"""
if not op_ids:
return 0
acked = 0
now = datetime.now()
for op_id in op_ids:
existing = await get_cassette_op(op_id)
if not _should_ack_op(existing, machine_id):
continue
await db.execute(
"UPDATE spirekeeper.cassette_ops SET acked_at = :now "
"WHERE id = :id AND machine_id = :mid",
{"now": now, "id": op_id, "mid": machine_id},
)
acked += 1
return acked

309
dispense_transport.py Normal file
View file

@ -0,0 +1,309 @@
"""
Dispense outcome capture: the `report_dispense` nostr-transport RPC
(bitspire ADR-005 §1-§2, aiolabs/bitspire#122).
A cash-out used to be captured the instant its payment landed: `_handle_payment`
spawned distribution in the same breath, so when a dispenser jammed two seconds
later the legs were already paid and `processed` was the honest answer. Payment
is now the authorization and the machine's dispense report is the capture. A
`cash_out` settlement lands as `awaiting_dispense` and this handler moves it:
dispense_confirmed → pending → distribution runs
some notes out, value short → partial_pending (held whole — ADR-005
Decision 1: one distribution when the
shortfall is resolved)
nothing out → cash_owed (legs never run; the customer
is owed; first on the worklist)
report carries remediates_txid → the owed/partial settlement it names
goes to pending and distributes in full
Every report is stored append-only in `dispense_reports` (lamassu-server's
`cash_out_actions` shape), including the success ones — the success report is
what captures. Identity is the VERIFIED transport sender, never the body, same
as `create_withdraw` / `get_machine_config`. Idempotent on (txid, at): the
machine resends until it gets an OK, and a byte-identical resend is acked
without a new row or a second transition.
A report can also arrive BEFORE its payment lands (hold invoices settle after
the dispense; the invoice listener can lag). It is stored with no settlement;
`_handle_payment` adopts it when the settlement is inserted and applies the
same transition. See `apply_report_to_settlement`.
"""
from __future__ import annotations
import asyncio
from datetime import datetime
from loguru import logger
from .crud import (
apply_dispense_outcome,
get_dispense_report,
get_machine_by_atm_pubkey_hex,
get_settlement_by_txid,
insert_dispense_report,
link_dispense_reports_to_settlement,
set_machine_counts_uncertain,
)
from .models import DcaSettlement, DispenseReport, DispenseReportIn, Machine
_RPC_NAME = "report_dispense"
# Statuses a first report may move. Anything else (processed, pending,
# processing, errored, rejected, partial, refunded) is recorded but not moved —
# a report cannot un-pay legs, and a late report for an already-captured sale
# is information, not an instruction.
_CAPTURABLE = ("awaiting_dispense",)
# Statuses a remediation report may close out.
_OWED = ("cash_owed", "partial_pending")
# Strong references to in-flight distribution tasks, same reason as tasks.py.
_inflight: set[asyncio.Task] = set()
def _outcome_status(report: DispenseReportIn) -> str:
if report.dispense_confirmed:
return "pending"
if report.total_dispensed_notes > 0:
return "partial_pending"
return "cash_owed"
def _spawn_distribution(settlement_id: str) -> None:
# Lazy import: distribution imports crud, and crud is what this module
# already depends on; importing at module load would make a cycle.
from .distribution import process_settlement
task = asyncio.create_task(process_settlement(settlement_id))
_inflight.add(task)
task.add_done_callback(_inflight.discard)
async def _alert_operator(settlement, machine: Machine, report: DispenseReportIn, status: str) -> None:
"""ADR-005 §6: a human is now owed money — tell the operator over Nostr.
Best-effort; the report is acked regardless (see notify.py)."""
from .notify import notify_cash_outcome
await notify_cash_outcome(settlement, machine, report, status)
async def apply_report_to_settlement(
settlement: DcaSettlement,
report: DispenseReportIn,
machine: Machine,
) -> str:
"""Move `settlement` according to `report`. Returns the resulting status.
Shared by the RPC handler (report after payment) and `_handle_payment`
(payment after report), so both orders of arrival take the same path.
"""
reported_at = datetime.fromtimestamp(report.at)
if report.remediates_txid:
# Handled by the caller against the settlement the remediation names;
# for the remediation's OWN txid there is nothing to capture.
return settlement.status
if settlement.status not in _CAPTURABLE:
logger.info(
f"spirekeeper: report_dispense for settlement {settlement.id} in status "
f"{settlement.status!r} — recorded, not moved (txid={report.txid})"
)
return settlement.status
new_status = _outcome_status(report)
updated = await apply_dispense_outcome(
settlement.id, report, new_status, reported_at
)
status = updated.status if updated else new_status
if new_status == "pending":
_spawn_distribution(settlement.id)
logger.info(
f"spirekeeper: dispense CONFIRMED for settlement {settlement.id} "
f"(machine={machine.id}, txid={report.txid}, "
f"{report.fiat_cents / 100:.2f} {report.currency}) — distributing"
)
elif new_status == "partial_pending":
await _alert_operator(updated or settlement, machine, report, new_status)
logger.warning(
f"spirekeeper: PARTIAL dispense for settlement {settlement.id} "
f"(machine={machine.id}, txid={report.txid}): "
f"{report.dispensed_fiat_cents / 100:.2f} of "
f"{report.fiat_cents / 100:.2f} "
f"{report.currency} left the machine; {report.error_code or 'no error'} "
f"{report.raw_code or ''}. Held until the operator resolves the shortfall."
)
else:
await _alert_operator(updated or settlement, machine, report, new_status)
logger.error(
f"spirekeeper: CASH OWED — settlement {settlement.id} "
f"(machine={machine.id}, txid={report.txid}): customer paid "
f"{report.fiat_cents / 100:.2f} {report.currency}, nothing dispensed; "
f"{report.error_code or 'no error'} {report.raw_code or ''}: "
f"{report.error or ''}"
)
return status
async def _apply_remediation(
machine: Machine, report: DispenseReportIn
) -> tuple[str | None, str | None]:
"""A manual dispense closed out an earlier failed txid. Returns
(settlement_id, status) of the remediated settlement, or (None, None)."""
assert report.remediates_txid
target = await get_settlement_by_txid(machine.id, report.remediates_txid)
if target is None:
logger.warning(
f"spirekeeper: remediation report {report.txid} names txid "
f"{report.remediates_txid} with no settlement on this server"
)
return None, None
if target.status not in _OWED:
logger.info(
f"spirekeeper: remediation report {report.txid} for settlement "
f"{target.id} in status {target.status!r} — recorded, not moved"
)
return target.id, target.status
if not report.dispense_confirmed:
logger.warning(
f"spirekeeper: remediation report {report.txid} for settlement "
f"{target.id} did not itself confirm — settlement stays {target.status}"
)
return target.id, target.status
# The customer is whole: the sale is the full amount. Keep the ORIGINAL
# report's columns on the settlement (that is what happened at the sale);
# the remediation is its own dispense_reports row.
from .crud import mark_settlement_status
await mark_settlement_status(target.id, "pending", None)
_spawn_distribution(target.id)
logger.info(
f"spirekeeper: settlement {target.id} remediated by manual dispense "
f"{report.txid} — distributing in full"
)
return target.id, "pending"
async def handle_report_dispense(auth, request) -> dict:
"""nostr-transport RPC handler. `auth` is the roster-resolved auth context
(unused — the machine is identified from the signature); `request` is a
NostrRpcRequest with `body` and `sender_pubkey` (verified).
Returns `{txid, received, settlement_status}`; raises ValueError (→ transport
ERROR reply) for an unpaired sender or a malformed body. The machine treats
anything but OK as "resend later", so a malformed report is retried — which
is right: the bug is on one side or the other and the row must not be lost.
"""
sender = (request.sender_pubkey or "").lower()
if not sender:
raise ValueError("missing verified sender_pubkey")
machine = await get_machine_by_atm_pubkey_hex(sender)
if machine is None:
raise ValueError("sender pubkey is not a paired machine")
try:
report = DispenseReportIn(**(request.body or {}))
except Exception as exc: # pydantic ValidationError, TypeError
raise ValueError(f"invalid report_dispense body: {exc}") from exc
# Idempotency: the machine resends until acked.
existing = await get_dispense_report(machine.id, report.txid, report.at)
if existing is not None:
settlement = (
await get_settlement_by_txid(machine.id, report.txid)
if existing.settlement_id
else None
)
return {
"txid": report.txid,
"received": True,
"settlement_status": settlement.status if settlement else None,
"duplicate": True,
}
if report.counts_uncertain:
# The state document carries this too; mirroring it here means the
# dashboard learns at report time rather than at the next heartbeat.
await set_machine_counts_uncertain(machine.id, datetime.now())
settlement = await get_settlement_by_txid(machine.id, report.txid)
stored: DispenseReport = await insert_dispense_report(
machine.id, settlement.id if settlement else None, report
)
status: str | None
if report.remediates_txid:
_, status = await _apply_remediation(machine, report)
elif settlement is None:
# Payment not landed yet (or never will). Kept unlinked;
# _handle_payment adopts it when the settlement is inserted.
logger.warning(
f"spirekeeper: report_dispense {report.txid} from machine {machine.id} "
f"has no settlement yet (confirmed={report.dispense_confirmed}) — "
f"stored unlinked, will attach when the payment lands"
)
status = None
else:
status = await apply_report_to_settlement(settlement, report, machine)
logger.info(
f"spirekeeper: report_dispense stored id={stored.id} machine={machine.id} "
f"txid={report.txid} confirmed={report.dispense_confirmed} → {status}"
)
return {"txid": report.txid, "received": True, "settlement_status": status}
async def adopt_unlinked_report(
settlement: DcaSettlement, machine: Machine, report_row: DispenseReport
) -> str:
"""`_handle_payment` found a report that arrived before the payment: link
it and apply the same transition the live handler would have."""
import json as _json
await link_dispense_reports_to_settlement(
machine.id, report_row.txid, settlement.id
)
report = DispenseReportIn(
txid=report_row.txid,
payment_hash=report_row.payment_hash,
dispense_confirmed=report_row.dispense_confirmed,
error=report_row.error,
error_code=report_row.error_code,
raw_code=report_row.raw_code,
error_class=report_row.error_class,
fiat_cents=report_row.fiat_cents,
currency=report_row.currency,
bills=_json.loads(report_row.bills_json or "[]"),
cassettes=_json.loads(report_row.cassettes_json or "[]"),
counts_uncertain=report_row.counts_uncertain,
remediates_txid=report_row.remediates_txid,
at=int(report_row.reported_at.timestamp()),
)
logger.info(
f"spirekeeper: adopting early dispense report {report_row.id} for "
f"settlement {settlement.id} (report preceded the payment)"
)
return await apply_report_to_settlement(settlement, report, machine)
def register_dispense_report_rpc() -> None:
"""Register `report_dispense` with the lnbits nostr transport. Soft-fails
if the transport doesn't expose `register_rpc` (older lnbits) — then
cash-out settlements wait in awaiting_dispense and surface on the
worklist as dispense_unreported, which is the honest state."""
try:
from lnbits.core.services.nostr_transport.dispatcher import ( # type: ignore
AUTH_ACCOUNT,
register_rpc,
)
except ImportError:
logger.warning(
"spirekeeper: nostr-transport register_rpc unavailable; "
"'report_dispense' not registered (ADR-005 capture disabled — "
"cash-out settlements will sit in awaiting_dispense)"
)
return
register_rpc(_RPC_NAME, handle_report_dispense, AUTH_ACCOUNT)
logger.info("spirekeeper: registered nostr-transport RPC 'report_dispense'")

View file

@ -359,6 +359,31 @@ async def apply_partial_dispense_and_redistribute(
return after if after is not None else updated
async def settle_cash_owed_settlement(
settlement: DcaSettlement, note: str, author_user_id: str
) -> DcaSettlement:
"""ADR-005 §6 — operator paid the customer by hand. The sale is whole, so
the settlement distributes at the full amount it landed with. The note is
the provenance; `remediated` on the machine side comes from the
settle_transaction op the endpoint publishes."""
from .crud import append_settlement_note
if settlement.status not in ("cash_owed", "partial_pending"):
raise ValueError(f"settlement {settlement.id} is {settlement.status!r}")
await append_settlement_note(
settlement.id, f"Settled off-machine (full amount): {note}", author_user_id
)
await mark_settlement_status(settlement.id, "pending", None)
logger.info(
f"distribution: settlement {settlement.id} settled off-machine by "
f"{author_user_id[:8]}… — distributing in full"
)
await process_settlement(settlement.id)
after = await get_settlement(settlement.id)
assert after is not None
return after
async def process_settlement(settlement_id: str) -> None:
"""Process a pending settlement end-to-end.

View file

@ -860,3 +860,189 @@ async def m012_add_max_cash_in_sats(db):
await db.execute(
"ALTER TABLE spirekeeper.super_config ADD COLUMN max_cash_in_sats INTEGER"
)
async def m013_add_cassette_ops(db):
"""Cassette operations — the operator→ATM v2 wire (aiolabs/bitspire ADR-004).
Until now the operator published absolute counts and the ATM applied them
outright. Both sides wrote the same value over a transport that never tells
a writer it lost, so a dashboard form loaded before a dispense would
silently discard that dispense when published. The fix is to stop the
operator writing counts at all: it publishes OPERATIONS and the machine,
which holds the physical notes, owns the running count.
Each row here is one operator intent — a refill, an empty, a recount, a
denomination change. `id` is minted here and is the idempotency key the ATM
dedups on, because a delta applied twice is wrong and addressable events
are re-delivered on reconnect. `acked_at` is set when the machine reports
the id back in its state document, which is the only acknowledgement this
transport can carry.
Kept append-only on purpose: the published window is a slice of this table,
and an operation the machine has not yet acknowledged must stay publishable.
"""
await db.execute(f"""
CREATE TABLE IF NOT EXISTS spirekeeper.cassette_ops (
id TEXT PRIMARY KEY,
machine_id TEXT NOT NULL,
position INTEGER NOT NULL,
op_type TEXT NOT NULL,
bills INTEGER,
count INTEGER,
denomination INTEGER,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
created_by TEXT,
acked_at TIMESTAMP
);
""")
# The publisher reads the most recent N for a machine on every publish.
await db.execute(
"CREATE INDEX IF NOT EXISTS cassette_ops_machine_idx "
"ON cassette_ops (machine_id, created_at DESC)"
)
async def m014_add_counts_uncertain_since(db):
"""Surface the machine's "I don't know what left the bay" marker.
A dispenser can throw, or time out, after notes have physically moved. The
machine cannot know how many left, so rather than decrement a number it
would be guessing at, it stamps the moment and reports it (bitspire
ADR-004, decision 3). It has been publishing this field since v1 of the
state document and the operator has been discarding it, which defeats the
point: the marker exists to tell a human to open the bay and recount.
Stored on the machine, not the bay, because the uncertainty is about the
dispense as a whole — a multi-bay dispense that fails midway leaves no
reliable way to attribute it to one position. Cleared to NULL by the
machine's own report once it is confident again.
"""
await db.execute(
"ALTER TABLE spirekeeper.dca_machines "
"ADD COLUMN counts_uncertain_since TIMESTAMP"
)
async def m015_add_cassette_state_seq(db):
"""Break the same-second tie in the state-event ordering gate.
The gate compares `created_at`, which NIP-01 defines at one-second
granularity — so two reports from the same second are indistinguishable to
it, and the later one is dropped. A dispense and the publish that follows
it land inside one second routinely.
The machine bumps `seq` on every local change to a bay count, whatever
caused it, and carries it in the state document. Stored per row alongside
`state_at` and consulted ONLY when the stamps are equal, so a machine whose
state.db was replaced — seq back to zero, wall clock still moving forward —
is not locked out by its own counter.
"""
await db.execute(
"ALTER TABLE spirekeeper.cassette_configs ADD COLUMN state_seq INTEGER"
)
async def m016_dispense_outcome(db):
"""The dispense outcome becomes a first-class fact (bitspire ADR-005 §2).
Until now a cash-out settlement was captured the instant the payment
landed: `_handle_payment` spawned distribution in the same breath, so by
the time a dispenser jammed two seconds later the legs were already paid
and the dashboard honestly reported `processed`. The machine now reports
every cash-out's outcome over a `report_dispense` RPC and the settlement
waits for it (`awaiting_dispense`) before anything moves.
`dispense_reports` is append-only, one row per report the machine sent —
lamassu-server's `cash_out_actions` shape — so a retry, a late report and
a remediation report are all visible as distinct rows. `settlement_id` is
NULL for a report whose payment this server never saw.
The settlement carries the three lamassu fields (dispense_confirmed,
error, error_code) plus raw_code / error_class and the fiat value that
actually left the machine, so the partial-dispense dialog can be
pre-filled with the hardware's own number instead of a typed one.
The machine's cash-out hold is mirrored onto its registry row beside
counts_uncertain_since: a latched machine refuses cash-out until an
operator recounts or publishes `resume_cash_out`, and the dashboard needs
to show that and offer the button.
"""
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS spirekeeper.dispense_reports (
id TEXT PRIMARY KEY,
machine_id TEXT NOT NULL,
settlement_id TEXT,
txid TEXT NOT NULL,
payment_hash TEXT,
dispense_confirmed BOOLEAN NOT NULL,
error TEXT,
error_code TEXT,
raw_code TEXT,
error_class TEXT,
fiat_cents INTEGER NOT NULL,
currency TEXT NOT NULL,
bills_json TEXT NOT NULL,
cassettes_json TEXT NOT NULL,
counts_uncertain BOOLEAN NOT NULL DEFAULT false,
remediates_txid TEXT,
reported_at TIMESTAMP NOT NULL,
received_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
await db.execute(
"CREATE INDEX IF NOT EXISTS dispense_reports_txid_idx "
"ON dispense_reports (machine_id, txid)"
)
await db.execute(
"CREATE INDEX IF NOT EXISTS dispense_reports_settlement_idx "
"ON dispense_reports (settlement_id)"
)
for col, typ in (
("dispense_confirmed", "BOOLEAN"),
("dispense_error", "TEXT"),
("dispense_error_code", "TEXT"),
("dispense_raw_code", "TEXT"),
("dispense_error_class", "TEXT"),
("dispense_reported_at", "TIMESTAMP"),
("dispensed_fiat_cents", "INTEGER"),
):
await db.execute(
f"ALTER TABLE spirekeeper.dca_settlements ADD COLUMN {col} {typ}"
)
for col, typ in (
("cash_out_held_since", "TIMESTAMP"),
("cash_out_held_reason", "TEXT"),
("cash_out_held_code", "TEXT"),
):
await db.execute(
f"ALTER TABLE spirekeeper.dca_machines ADD COLUMN {col} {typ}"
)
async def m017_owed_cash_closure(db):
"""ADR-005 §6 — closing an owed-cash settlement, and telling the operator.
`operator_notified_at`: when the Nostr alert for a cash_owed /
partial_pending settlement went out (NIP-17 DM to the operator). Kept so
a report resend never re-alerts and the dashboard can show that the
operator was told.
`super_config.alerts_pubkey`: optional recipient override. By default the
alert goes to the operator's own LNbits-account pubkey (a note to self,
readable in any NIP-46 client); operators who want it on a phone identity
set this.
`cassette_ops.txid` / `.note`: the machine-wide `settle_transaction` op —
the operator paid the customer by hand, outside the machine — carries the
machine txid it closes and the provenance note, so the machine can flip its
own row to `remediated` and both ledgers close on one act.
"""
await db.execute(
"ALTER TABLE spirekeeper.dca_settlements ADD COLUMN operator_notified_at TIMESTAMP"
)
await db.execute("ALTER TABLE spirekeeper.super_config ADD COLUMN alerts_pubkey TEXT")
await db.execute("ALTER TABLE spirekeeper.cassette_ops ADD COLUMN txid TEXT")
await db.execute("ALTER TABLE spirekeeper.cassette_ops ADD COLUMN note TEXT")

472
models.py
View file

@ -7,7 +7,7 @@
from datetime import datetime
from pydantic import BaseModel, validator
from pydantic import BaseModel, root_validator, validator
# =============================================================================
# Machines — one row per bitSpire ATM, owned by exactly one operator.
@ -63,6 +63,17 @@ class Machine(BaseModel):
# NIP-46 bunker pairing (S0 / #9). NULL until the spire is first paired.
bunker_spire_key_name: str | None = None
paired_at: datetime | None = None
# Set when the machine reports that a dispense ended without a reliable
# count of what left the bay; cleared by the machine's own report. The
# dashboard turns this into a prompt to open the bay and recount.
counts_uncertain_since: datetime | None = None
# ADR-005 §5: the machine has latched cash-out off after a terminal
# dispenser fault. Mirrored from its state document. Cleared when the
# machine reports the hold released (an operator recount or a
# resume_cash_out op). The dashboard shows it and offers the button.
cash_out_held_since: datetime | None = None
cash_out_held_reason: str | None = None
cash_out_held_code: str | None = None
created_at: datetime
updated_at: datetime
@ -307,19 +318,42 @@ class DcaSettlement(BaseModel):
fee_mismatch_sats: int | None = None
bills_json: str | None
cassettes_json: str | None
# 'pending' (default at insert)
# Lifecycle (bitspire ADR-005 §1 — payment is authorization, the
# machine's dispense report is capture; distribution waits for capture):
# 'awaiting_dispense' (cash_out at insert: paid, waiting for the report)
# 'pending' (cash_in at insert; cash_out once dispense_confirmed)
# 'processing' (claim taken by distribution processor)
# 'processed' (all legs paid)
# 'partial' (operator marked partial-dispense after the fact)
# 'partial_pending' (report says some notes out, value short — holds
# EVERYTHING until the operator records how the shortfall
# was resolved; then one distribution at the final amount)
# 'cash_owed' (report says nothing out — legs never run, funds stay in
# the machine wallet, the customer is owed; worklist first)
# 'partial' (operator confirmed a partial amount; distributed scaled)
# 'refunded' (operator-initiated refund)
# 'errored' (operational distribution failure — retry path applies)
# 'rejected' (Nostr attribution cross-check failed at land time;
# never went near distribution. error_message holds the
# reason. Retry is wrong — investigate the machine.)
# 'dispense_unreported' is NOT stored: the worklist derives it from
# awaiting_dispense rows older than its threshold.
status: str
error_message: str | None
processed_at: datetime | None
created_at: datetime
# ADR-005 §2 — copied from the machine's report. The three lamassu
# fields plus raw_code / error_class; dispensed_fiat_cents is what the
# hardware says physically left, which pre-fills partial-dispense.
dispense_confirmed: bool | None = None
dispense_error: str | None = None
dispense_error_code: str | None = None
dispense_raw_code: str | None = None
dispense_error_class: str | None = None
dispense_reported_at: datetime | None = None
dispensed_fiat_cents: int | None = None
# ADR-005 §6: when the operator was alerted (NIP-17 DM) that this
# settlement needs a human. NULL = no alert sent (or none was due).
operator_notified_at: datetime | None = None
# Append-only audit memo. Populated when an operator triggers an in-place
# adjustment (partial-dispense, manual reconciliation override). Each
# entry timestamped + records original values so the overwrite is
@ -332,6 +366,110 @@ class DcaSettlement(BaseModel):
processing_claim: str | None = None
# =============================================================================
# Dispense outcome (bitspire ADR-005 §2) — machine → spirekeeper `report_dispense`
# =============================================================================
class DispenseReportBill(BaseModel):
denomination: int
requested: int
dispensed: int
rejected: int
class DispenseReportCassette(BaseModel):
position: int
denomination: int
provisioned: int
dispensed: int
rejected: int
class DispenseReportIn(BaseModel):
"""The RPC body as the machine sends it. Mirrors @bitSpire/lnbits
DispenseReportBody. Field names follow lamassu-server's cash_out_txs /
cash_out_actions (dispense_confirmed, error, error_code). Idempotent on
(txid, at): the machine resends until acked; a byte-identical resend is
acknowledged without a new row."""
txid: str
payment_hash: str | None = None
tx_type: str = "cash_out"
dispense_confirmed: bool
error: str | None = None
error_code: str | None = None
raw_code: str | None = None
error_class: str | None = None # 'terminal' | 'recoverable' | 'inventory'
fiat_cents: int
currency: str
bills: list[DispenseReportBill] = []
cassettes: list[DispenseReportCassette] = []
counts_uncertain: bool = False
remediates_txid: str | None = None
at: int
@validator("txid")
def _txid_present(cls, v):
if not v or not v.strip():
raise ValueError("txid is required")
return v.strip()
@validator("tx_type")
def _cash_out_only(cls, v):
if v != "cash_out":
raise ValueError("report_dispense is for cash_out transactions only")
return v
@validator("error_class")
def _known_class(cls, v):
if v is not None and v not in ("terminal", "recoverable", "inventory"):
raise ValueError(
f"error_class must be terminal|recoverable|inventory, got {v!r}"
)
return v
@validator("fiat_cents")
def _fiat_non_negative(cls, v):
if v < 0:
raise ValueError("fiat_cents must be >= 0")
return v
@property
def dispensed_fiat_cents(self) -> int:
"""What the hardware says physically left, in cents."""
return sum(b.denomination * b.dispensed for b in self.bills) * 100
@property
def total_dispensed_notes(self) -> int:
return sum(b.dispensed for b in self.bills)
class DispenseReport(BaseModel):
"""One stored report (append-only — a retry, a late report and a
remediation report are distinct rows). `settlement_id` is NULL when the
payment this report refers to was never seen by this server."""
id: str
machine_id: str
settlement_id: str | None
txid: str
payment_hash: str | None
dispense_confirmed: bool
error: str | None
error_code: str | None
raw_code: str | None
error_class: str | None
fiat_cents: int
currency: str
bills_json: str
cassettes_json: str
counts_uncertain: bool = False
remediates_txid: str | None = None
reported_at: datetime
received_at: datetime
# =============================================================================
# Commission splits — operator-defined remainder allocation per machine.
# =============================================================================
@ -488,6 +626,9 @@ class SuperConfig(BaseModel):
# rate, not sats, so this bounds a single ATM-attested principal. NULL = no
# cap.
max_cash_in_sats: int | None = None
# ADR-005 §6: where owed-cash alerts go. NULL/empty = the operator's own
# LNbits-account pubkey (a note to self). 64-char hex.
alerts_pubkey: str | None = None
updated_at: datetime
@ -496,6 +637,23 @@ class UpdateSuperConfigData(BaseModel):
super_cash_out_fee_fraction: float | None = None
super_fee_wallet_id: str | None = None
max_cash_in_sats: int | None = None
# Empty string clears it (None means "not touching this field").
alerts_pubkey: str | None = None
@validator("alerts_pubkey")
def _alerts_pubkey_hex(cls, v):
if v is None:
return v
v = v.strip()
if v == "":
return ""
if v.startswith("npub1"):
from lnbits.utils.nostr import normalize_public_key
v = normalize_public_key(v)
if len(v) != 64 or any(c not in "0123456789abcdefABCDEF" for c in v):
raise ValueError("alerts_pubkey must be a 64-char hex pubkey or an npub")
return v.lower()
@validator(
"super_cash_in_fee_fraction",
@ -566,12 +724,40 @@ class StuckSettlementsResponse(BaseModel):
"""
threshold_minutes: int
# ADR-005 §6 — the only buckets whose meaning is "a customer is owed
# money"; rendered first.
cash_owed: list = [] # list[DcaSettlement]
partial_pending: list = []
# awaiting_dispense older than the threshold: the machine never reported.
# A machine on an old build lands here too — that is the upgrade path.
dispense_unreported: list = []
rejected: list # list[DcaSettlement]
errored: list
stuck_pending: list
stuck_processing: list
class SettleCashOwedData(BaseModel):
"""ADR-005 §6 — the operator paid the customer outside the machine.
Closes a cash_owed / partial_pending settlement: the provenance note is
appended, the settlement distributes at the FULL amount (the customer is
whole), and a `settle_transaction` op is published so the machine flips
its own row to `remediated`. Nothing is dispensed.
"""
note: str
@validator("note")
def non_empty(cls, v):
v = v.strip() if isinstance(v, str) else v
if not v:
raise ValueError("note cannot be empty — say how the customer was paid")
if len(v) > 500:
raise ValueError("note too long (max 500 chars)")
return v
class AppendSettlementNoteData(BaseModel):
"""Operator-authored free-form note on a settlement.
@ -673,31 +859,9 @@ class CassetteConfig(BaseModel):
state_count: int | None
state_at: datetime | None
state_event_id: str | None
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)."""
denomination: int | None = None
count: int | None = 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):
if v is None:
return v
if v < 0:
raise ValueError("count must be >= 0")
return v
# The machine's own counter, bumped on every local count change. Breaks a
# same-second tie in the ordering gate, where created_at cannot.
state_seq: int | None = None
class CassettePayloadRow(BaseModel):
@ -721,22 +885,46 @@ class CassettePayloadRow(BaseModel):
class PublishCassettesPayload(BaseModel):
"""The decrypted JSON content of a kind-30078 cassette event, both
directions:
- operator → ATM (d-tag `bitspire-cassettes:<atm_pubkey_hex>`)
- ATM → operator (d-tag `bitspire-cassettes-state:<atm_pubkey_hex>`)
"""The decrypted content of the ATM → operator state document
(d-tag `bitspire-cassettes-state:<atm_pubkey_hex>`).
Wire shape: `{"positions": {"<pos_str>": {"denomination", "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).
It carried the operator → ATM direction too until v2 moved that to
PublishCassetteOpsPayload. This is now the machine reporting what it
holds, and the machine is the only writer of those counts.
Wire shape: `{"positions": {"<pos_str>": {"denomination", "count"}}}`
plus the optional fields below. JSON object keys are always strings; the
validator coerces back to int on parse.
No denomination-unique constraint: multiple same-denom cassettes are
operationally valid (cash-out throughput on a popular denom).
The optional fields are all absent on older machines, so every one of them
defaults to a value meaning "this machine does not report that yet" rather
than to a value that would be wrong:
- `applied_ops`: operation ids the machine has applied. This is the
acknowledgement, and the only one an addressable event can carry — a
relay returns OK for an event it then discards, so the publisher is
never told anything. An empty list reads as "nothing acknowledged",
which is correct for a machine that has not yet applied any.
- `seq`: the machine's own monotonic counter, bumped on every local count
change. Regression detection independent of created_at, which is only
second-granular and can be forced by a bad clock.
- `counts_uncertain_since`: set when a dispense ended without the
dispenser reporting what it moved, so the counts above are the
machine's best guess rather than a measurement.
"""
positions: dict[int, CassettePayloadRow]
applied_ops: list[str] = []
seq: int | None = None
counts_uncertain_since: int | None = None
# ADR-005 §5 — additive like counts_uncertain_since. Present while the
# machine refuses cash-out after a terminal dispenser fault.
cash_out_held_since: int | None = None
cash_out_held_reason: str | None = None
cash_out_held_code: str | None = None
@validator("positions", pre=True)
def coerce_string_keys_to_int(cls, v):
@ -768,6 +956,216 @@ class PublishCassettesPayload(BaseModel):
}
# =============================================================================
# Cassette operations — operator → ATM v2 (bitspire ADR-004)
# =============================================================================
# The operator no longer publishes counts. It publishes what it DID, and the
# machine — which holds the notes — keeps the running total. A value with one
# writer cannot be clobbered, which is the point: the old absolute-count wire
# let a form loaded before a dispense discard that dispense when published, and
# nothing in an addressable event can tell the loser it lost.
#
# Wire shape (kind-30078 content, NIP-44 v2 encrypted, schema_version 2):
# {
# "schema_version": 2,
# "ops": [
# {"id": "<uuid>", "at": 1790106060, "type": "refill",
# "position": 2, "bills": 100},
# {"id": "<uuid>", "at": 1790106061, "type": "empty", "position": 3},
# {"id": "<uuid>", "at": 1790106062, "type": "recount",
# "position": 1, "count": 37},
# {"id": "<uuid>", "at": 1790106063, "type": "set_denomination",
# "position": 1, "denomination": 50}
# ]
# }
#
# `ops` is a WINDOW of recent operations, not just the newest. An event the
# machine missed is carried again by the next one, so the channel heals itself
# without the operator noticing. `id` is the idempotency key: deltas are not
# idempotent and addressable events are re-delivered on reconnect, so the
# machine records what it applied and ignores repeats.
#
# The vocabulary mirrors lamassu-server's cash_unit_operation_type
# (refill / empty / count-change), which is where the ancestor of this fleet
# landed after the same problem.
CASSETTE_OP_TYPES = (
"refill",
"empty",
"recount",
"set_denomination",
"resume_cash_out",
"settle_transaction",
)
# ADR-005 §5: `resume_cash_out` is not a cassette operation — it releases the
# machine's cash-out hold without touching a bay — but it rides the same
# operator event, with the same id/at dedup shape, because that channel is the
# one the machine already consumes. It is machine-wide, so its position is 0
# and its wire form carries no position at all. A recount also releases the
# hold (same "operator at the open machine" gesture).
MACHINE_WIDE_OP_TYPES = ("resume_cash_out", "settle_transaction")
class CassetteOp(BaseModel):
"""One operator intent against one bay, as stored and as published.
Exactly one of bills/count/denomination is meaningful, decided by op_type:
- refill → bills, the number of notes ADDED (a delta)
- empty → none; the bay was emptied
- recount → count, an absolute the operator physically counted
- set_denomination → denomination, what is now loaded in that bay
`recount` is the only absolute, and deliberately so: it is what an operator
opening a bay and counting actually does, and it is auditable as a distinct
act rather than being indistinguishable from a stale form.
"""
id: str
machine_id: str
position: int
op_type: str
bills: int | None = None
count: int | None = None
denomination: int | None = None
# settle_transaction only (ADR-005 §6): the machine txid this closes and
# the operator's provenance note ("paid 20 EUR by hand, 2026-10-09").
txid: str | None = None
note: str | None = None
created_at: datetime
created_by: str | None = None
acked_at: datetime | None = None
@validator("op_type")
def _known_op_type(cls, v):
if v not in CASSETTE_OP_TYPES:
raise ValueError(f"op_type must be one of {CASSETTE_OP_TYPES}, got {v!r}")
return v
@root_validator(skip_on_failure=True)
def _position_matches_scope(cls, values):
pos, typ = values.get("position"), values.get("op_type")
if typ in MACHINE_WIDE_OP_TYPES:
if pos != 0:
raise ValueError(
f"{typ} is machine-wide; position must be 0, got {pos}"
)
elif pos is None or pos <= 0:
raise ValueError(f"position must be > 0, got {pos}")
return values
def to_wire_dict(self) -> dict:
"""The published form. Drops the fields this op_type does not use, so
the machine never has to guess which of three nullable columns applies."""
out: dict = {
"id": self.id,
"at": int(self.created_at.timestamp()),
"type": self.op_type,
}
if self.op_type == "settle_transaction":
out["txid"] = self.txid
out["note"] = self.note or ""
return out
if self.op_type in MACHINE_WIDE_OP_TYPES:
return out
out["position"] = self.position
if self.op_type == "refill":
out["bills"] = self.bills
elif self.op_type == "recount":
out["count"] = self.count
elif self.op_type == "set_denomination":
out["denomination"] = self.denomination
return out
class CreateCassetteOpData(BaseModel):
"""Operator submits one operation from the dashboard.
Validated per type here rather than at the endpoint so an instance is
always publishable, matching FeeConfigPayload's contract.
"""
position: int
op_type: str
bills: int | None = None
count: int | None = None
denomination: int | None = None
txid: str | None = None
note: str | None = None
@validator("op_type")
def _known_op_type(cls, v):
if v not in CASSETTE_OP_TYPES:
raise ValueError(f"op_type must be one of {CASSETTE_OP_TYPES}, got {v!r}")
return v
@root_validator(skip_on_failure=True)
def _position_matches_scope(cls, values):
pos, typ = values.get("position"), values.get("op_type")
if typ in MACHINE_WIDE_OP_TYPES:
if pos != 0:
raise ValueError(
f"{typ} is machine-wide; position must be 0, got {pos}"
)
elif pos is None or pos <= 0:
raise ValueError(f"position must be > 0, got {pos}")
return values
@validator("bills")
def _bills_positive(cls, v):
if v is not None and v <= 0:
raise ValueError("bills must be > 0 (a refill adds notes)")
return v
@validator("count")
def _count_non_negative(cls, v):
if v is not None and v < 0:
raise ValueError("count must be >= 0")
return v
@validator("denomination")
def _denomination_positive(cls, v):
if v is not None and v <= 0:
raise ValueError("denomination must be > 0")
return v
@root_validator(skip_on_failure=True)
def _field_matches_type(cls, values):
required = {
"refill": "bills",
"recount": "count",
"set_denomination": "denomination",
"empty": None,
"resume_cash_out": None,
"settle_transaction": "txid",
}[values.get("op_type")]
if required is not None:
val = values.get(required)
# count=0 is a real observation (an empty bay); only txid must be non-empty
if val is None or (required == "txid" and not str(val).strip()):
raise ValueError(f"{values['op_type']} requires `{required}`")
for field in ("bills", "count", "denomination", "txid"):
if field != required and values.get(field) is not None:
raise ValueError(f"{values['op_type']} must not carry `{field}`")
if values.get("note") is not None and values.get("op_type") != "settle_transaction":
raise ValueError(f"{values['op_type']} must not carry `note`")
return values
class PublishCassetteOpsPayload(BaseModel):
"""The decrypted content of a v2 operator → ATM cassette event."""
schema_version: int = 2
ops: list[CassetteOp]
def to_wire_dict(self) -> dict:
return {
"schema_version": self.schema_version,
"ops": [op.to_wire_dict() for op in self.ops],
}
# =============================================================================
# Fee-config Nostr payload — operator → ATM (aiolabs/satmachineadmin#39)
# =============================================================================

202
notify.py Normal file
View file

@ -0,0 +1,202 @@
"""
Operator alerts over Nostr (bitspire ADR-005 §6 / Future directions).
When a cash-out settlement lands in `cash_owed` or `partial_pending` — a
customer has paid and is owed cash — the operator is told, immediately, as a
Nostr event addressed to their pubkey. Not email, not SMS (lamassu's
`notifyOperator` channel). The pieces this reuses:
- the recipient is the operator's own LNbits-account pubkey (`account.pubkey`,
which `get_machine_config` already refuses to run without), or
`super_config.alerts_pubkey` when set — a phone identity, say;
- the sender signs and NIP-44-encrypts through the operator's signer
(bunker-backed where the operator has moved to one), so no key is at rest
here. For the default recipient this is a note to self, which every NIP-46
client renders as a DM thread with yourself.
Wire: NIP-17. A kind-14 rumor (unsigned) is sealed (kind 13, NIP-44 to the
recipient, signed by the sender) and gift-wrapped (kind 1059, NIP-44 to the
recipient, signed by a throwaway key, `p`-tagged to the recipient, stamped at
a random moment in the last two days). The relay sees a throwaway pubkey and
ciphertext; only the recipient can open it. NIP-04 is not used anywhere in
this stack.
Best-effort by contract: a failed alert is logged and never fails the RPC
that triggered it — the dispense report must still be acknowledged, and the
worklist is the durable record. `operator_notified_at` is set only on a
successful publish, so a report resend cannot re-alert.
"""
from __future__ import annotations
import hashlib
import json
import random
import time
from loguru import logger
from .crud import get_super_config, mark_settlement_notified
from .models import DcaSettlement, DispenseReportIn, Machine
from .nostr_publish import (
NostrPublishError,
nip44_encrypt_via_signer,
publish_signed_event,
resolve_operator_signer,
sign_as_operator,
)
KIND_DM = 14
KIND_SEAL = 13
KIND_GIFT_WRAP = 1059
# NIP-17: randomise outer timestamps up to two days in the past so the wrap
# does not reveal when the conversation happened.
_MAX_BACKDATE_S = 2 * 24 * 60 * 60
def _canonical_id(
pubkey: str, created_at: int, kind: int, tags: list, content: str
) -> str:
from lnbits.utils.nostr import json_dumps
return hashlib.sha256(
json_dumps([0, pubkey, created_at, kind, tags, content]).encode()
).hexdigest()
def _backdated_now() -> int:
return int(time.time()) - random.randint(0, _MAX_BACKDATE_S) # noqa: S311
def _wrap_with_ephemeral(plaintext: str, recipient: str, created_at: int) -> dict:
"""Kind-1059 gift wrap: NIP-44-encrypt `plaintext` to `recipient` from a
freshly generated key, sign with that key, and forget it."""
import coincurve
from lnbits.utils.nostr import generate_keypair, sign_event
from .nip44 import encrypt_for
priv_hex, pub_hex = generate_keypair()
wrap = {
"kind": KIND_GIFT_WRAP,
"created_at": created_at,
"tags": [["p", recipient]],
"content": encrypt_for(plaintext, priv_hex, recipient),
}
return sign_event(wrap, pub_hex, coincurve.PrivateKey(bytes.fromhex(priv_hex)))
async def build_gift_wrapped_dm(
operator_user_id: str, recipient_pubkey_hex: str, text: str
) -> dict:
"""Build a NIP-17 gift-wrapped DM from the operator's identity to
`recipient_pubkey_hex`. Returns the signed kind-1059 event, ready to publish.
Raises the typed NostrPublishError subclasses from nostr_publish on a hard
failure (no pubkey on file, signer unavailable, …)."""
account, signer = await resolve_operator_signer(operator_user_id)
sender = account.pubkey
recipient = recipient_pubkey_hex.lower()
# 1. rumor — unsigned kind 14, id computed so clients can thread it
rumor_created = int(time.time())
rumor_tags = [["p", recipient]]
rumor = {
"id": _canonical_id(sender, rumor_created, KIND_DM, rumor_tags, text),
"pubkey": sender,
"created_at": rumor_created,
"kind": KIND_DM,
"tags": rumor_tags,
"content": text,
}
# 2. seal — NIP-44 to the recipient, signed by the sender (the bunker
# path never sees the operator's key here)
sealed_content = await nip44_encrypt_via_signer(
account, signer, json.dumps(rumor, separators=(",", ":")), recipient
)
seal = {"kind": KIND_SEAL, "tags": [], "content": sealed_content}
seal = await sign_as_operator(operator_user_id, seal)
# 3. gift wrap — NIP-44 from a throwaway key to the recipient, p-tagged,
# backdated, signed by that same throwaway key, which is then forgotten
return _wrap_with_ephemeral(
json.dumps(seal, separators=(",", ":")), recipient, _backdated_now()
)
async def resolve_alert_recipient(operator_user_id: str) -> str:
"""`super_config.alerts_pubkey` when set, else the operator's own pubkey."""
cfg = await get_super_config()
override = (getattr(cfg, "alerts_pubkey", None) or "").strip() if cfg else ""
if override:
return override.lower()
account, _signer = await resolve_operator_signer(operator_user_id)
return account.pubkey
def format_cash_outcome_alert(
settlement: DcaSettlement, machine: Machine, report: DispenseReportIn, status: str
) -> str:
"""The message the operator reads on their phone. Plain text, the facts
they need to act, no raw dispenser code (that is in the dashboard)."""
name = machine.name or machine.id
paid = f"{report.fiat_cents / 100:.2f} {report.currency}"
out = f"{report.dispensed_fiat_cents / 100:.2f} {report.currency}"
if status == "cash_owed":
head = f"⚠️ CASH OWED — {name}"
body = f"A customer paid {paid} and received nothing."
else:
head = f"⚠️ PARTIAL DISPENSE — {name}"
body = f"A customer paid {paid} and received {out}."
lines = [
head,
body,
f"Cause: {report.error or 'no dispenser error reported'}"
+ (f" ({report.error_code})" if report.error_code else ""),
f"Transaction: {report.txid}",
f"Settlement: {settlement.id}",
"Nothing has been distributed. Open spirekeeper → Worklist to resolve "
"(remediate at the machine, or settle off-machine).",
]
if report.error_class == "terminal":
lines.append(
"The machine has taken cash-out out of service until you recount "
"or resume it."
)
return "\n".join(lines)
async def notify_cash_outcome(
settlement: DcaSettlement,
machine: Machine,
report: DispenseReportIn,
status: str,
) -> bool:
"""Alert the operator that `settlement` needs a human. Best-effort:
returns True if the DM was published, False (after logging) otherwise.
Never raises — the caller is the dispense-report RPC and must ack."""
if getattr(settlement, "operator_notified_at", None):
return False
try:
recipient = await resolve_alert_recipient(machine.operator_user_id)
text = format_cash_outcome_alert(settlement, machine, report, status)
wrapped = await build_gift_wrapped_dm(machine.operator_user_id, recipient, text)
await publish_signed_event(wrapped)
await mark_settlement_notified(settlement.id)
logger.info(
f"spirekeeper: operator alerted ({status}) for settlement {settlement.id} "
f"→ {recipient[:12]}… (gift wrap {wrapped['id'][:12]}…)"
)
return True
except NostrPublishError as exc:
logger.warning(
f"spirekeeper: could not alert operator for settlement {settlement.id}: {exc} "
"— the worklist still has it"
)
except Exception as exc: # never let an alert break the RPC
logger.error(
f"spirekeeper: unexpected error alerting operator for settlement "
f"{settlement.id}: {exc!r}"
)
return False

98
static/docs/errors.html Normal file
View file

@ -0,0 +1,98 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>bitSpire — Dispenser error glossary</title>
<style>
:root { color-scheme: light dark; }
body { font: 15px/1.5 system-ui, sans-serif; max-width: 52rem; margin: 2rem auto; padding: 0 1rem; }
h1 { font-size: 1.6rem; } h2 { font-size: 1.2rem; margin-top: 2.2rem; border-bottom: 1px solid #8884; padding-bottom: .3rem; }
code { font-family: ui-monospace, monospace; background: #8882; padding: .05em .35em; border-radius: 3px; }
.cls { font-weight: 600; } .terminal { color: #c62828; } .recoverable { color: #ef6c00; } .inventory { color: #2e7d32; }
dl { margin: .5rem 0 0; } dt { font-weight: 600; margin-top: .6rem; } dd { margin: .2rem 0 0 1.2rem; }
.entry { margin-top: 1.4rem; padding: .6rem .9rem; border-left: 3px solid #8884; }
.entry:target { border-left-color: #1976d2; background: #1976d20d; }
small { color: #777; }
</style>
</head>
<body>
<h1>Dispenser error glossary</h1>
<p>Every dispense failure a machine reports carries an <strong>error code</strong> (the family — which driver), a <strong>raw code</strong> (what the hardware said), and a <strong>class</strong> that decides what the machine did next:</p>
<dl>
<dt><span class="cls terminal">terminal</span></dt>
<dd>The transport path is compromised. The machine has <strong>taken cash-out out of service</strong> and will stay that way until you open it. Clear the path, then record a <em>Recount</em> (which also fixes the bay count) or press <em>Resume cash-out</em>. Re-initialising the dispenser does not move a stuck note, so a restart will not clear this.</dd>
<dt><span class="cls recoverable">recoverable</span></dt>
<dd>This bay or this note. The customer still sees a fault screen if they were short-changed, but the machine stays in service. Check the bay named in the report and the reject tray.</dd>
<dt><span class="cls inventory">inventory</span></dt>
<dd>Nothing was asked of the hardware — the request could not be met from the bays. Not a hardware fault. If this happens after payment, the customer is still owed (see the worklist).</dd>
</dl>
<p><strong>Whatever the class, a report that is not <code>dispense_confirmed</code> after a payment means a customer is owed money.</strong> The worklist shows it as <em>Cash owed</em> (nothing dispensed) or <em>Partial dispense</em> (some notes out). Nothing has been distributed; the funds are in the machine wallet. Resolve by dispensing the shortfall at the machine (the machine reports the remediation and the settlement completes) or by paying the customer by hand and recording it with <em>Settle off-machine</em>.</p>
<h2>Fujitsu F53 / F56 (error code <code>F56DispenseError</code>)</h2>
<p><small>Codes are the two bytes the BDU returns after a failed bill count. Source: Fujitsu Frontech F56-BDU Error Code List (K3KD03234–K3KD03236-0001, ed. E02) and field observations. An unknown code is treated as terminal until it has been decoded.</small></p>
<div class="entry" id="78-42">
<h3><code>78 42</code> — note stopped at the cassette exit <span class="cls terminal">terminal</span></h3>
<p>A note left the bay and stopped in the transport just past the cassette. The counters report <em>0 dispensed, 0 rejected</em> because the note completed neither path — so the bay count is also one high until you recount.</p>
<p><strong>What to do:</strong> open the unit, remove the note from the transport path, check the cassette is seated, then <em>Recount</em> the bay (this releases the cash-out hold and corrects the count). First seen: sintra, 2026-10-09 — a 20 EUR note, customer paid 40 EUR and received nothing.</p>
</div>
<div class="entry" id="82-00">
<h3><code>82 00</code> — bill length check failed (long) <span class="cls recoverable">recoverable</span></h3>
<p>The BDU measured a note longer than the accept window configured for that denomination and rejected it. Repeated on every pick from one bay, it is almost always the <em>configured window</em>, not the notes: a GTQ Tejo rejected 5 of 5 on every pick in 2026-09 because the window was ±5 mm where the identical-size USD note has ±10.</p>
<p><strong>What to do:</strong> empty the reject tray and look at the notes. Single notes → window too narrow; report it. Pairs → the separator is worn (offset double-pick) and the narrow window is doing its job.</p>
</div>
<div class="entry" id="83-00">
<h3><code>83 00</code> — bill length check failed (short) <span class="cls recoverable">recoverable</span></h3>
<p>As above, measured short. Torn or folded notes, or the wrong denomination loaded in the bay.</p>
</div>
<div class="entry" id="84-00">
<h3><code>84 00</code> — bill thickness check failed <span class="cls recoverable">recoverable</span></h3>
<p>Two notes stuck together, or a taped/damaged note. Check the reject tray.</p>
</div>
<div class="entry" id="85">
<h3><code>85 0n</code> — pick from another safe <span class="cls recoverable">recoverable</span></h3>
<p>A note arrived from a bay other than the one commanded (<code>n</code> = which). Usually a cassette not fully latched.</p>
</div>
<div class="entry" id="86-00">
<h3><code>86 00</code> — bill spacing error <span class="cls recoverable">recoverable</span></h3>
<p>Notes too close together on the transport. Often follows a worn feed roller.</p>
</div>
<div class="entry" id="b5">
<h3><code>B5 ..</code> — reject box overflow <span class="cls terminal">terminal</span></h3>
<p>The reject tray is full; nothing more can be rejected, so nothing more can be dispensed safely.</p>
<p><strong>What to do:</strong> empty the reject tray, count what is in it (those notes left a bay), <em>Recount</em>.</p>
</div>
<div class="entry" id="f56dispenseerror">
<h3>Unrecognised code <span class="cls terminal">terminal</span></h3>
<p>A code not yet in this table. The machine treats it as a jam — cash-out held — until someone decodes it. Please report the raw code with what you found inside the unit so it can be added.</p>
</div>
<div class="entry" id="dispensetimeout">
<h3><code>DispenseTimeout</code> — dispenser did not answer <span class="cls terminal">terminal</span></h3>
<p>No frame came back within the dispense timeout (serial timeout, port closed, framing error). The transport state is unknown; the bay counts are flagged unverified.</p>
</div>
<h2>Puloon LCDM (error code <code>PuloonDispenseError</code>)</h2>
<p>No decode table yet; every Puloon fault is treated as <span class="cls terminal">terminal</span>. The raw code is whatever the driver returned — report it.</p>
<h2>Software-side codes</h2>
<div class="entry" id="insufficientinventory">
<h3><code>InsufficientInventory</code> / <code>NoCassetteForDenomination</code> <span class="cls inventory">inventory</span></h3>
<p>The request could not be met from the bays as the machine believes them to be. After a payment this still leaves the customer owed; before one, the sale is simply refused. If the bays physically hold more than the machine thinks, <em>Recount</em>.</p>
</div>
<div class="entry" id="dispenseshort">
<h3><code>DispenseShort</code> <span class="cls inventory">inventory</span></h3>
<p>The dispenser returned fewer notes than requested and reported no error. The customer is owed the difference.</p>
</div>
<p><small>Reference: bitspire <code>docs/adr/005-cash-out-dispense-outcome.md</code>, Decisions 3–7.</small></p>
</body>
</html>

View file

@ -0,0 +1,75 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>spirekeeper — Operator guide</title>
<style>
:root { color-scheme: light dark; }
body { font: 15px/1.5 system-ui, sans-serif; max-width: 52rem; margin: 2rem auto; padding: 0 1rem; }
h1 { font-size: 1.6rem; } h2 { font-size: 1.2rem; margin-top: 2.2rem; border-bottom: 1px solid #8884; padding-bottom: .3rem; }
code { font-family: ui-monospace, monospace; background: #8882; padding: .05em .35em; border-radius: 3px; }
table { border-collapse: collapse; margin: .6rem 0; } td, th { border: 1px solid #8884; padding: .3rem .6rem; text-align: left; vertical-align: top; }
.warn { border-left: 3px solid #ef6c00; padding: .4rem .8rem; background: #ef6c000d; }
small { color: #777; }
</style>
</head>
<body>
<h1>spirekeeper — operator guide</h1>
<p>spirekeeper is the operator side of a bitSpire ATM: it pairs machines, publishes their fee and cassette configuration, receives every cash-out's outcome, and distributes each settlement. Everything between you and a machine travels over Nostr relays — there is no HTTP endpoint on the machine and none it needs on you.</p>
<h2>1. Setting up a machine</h2>
<ol>
<li><strong>Register</strong> it under <em>Machines</em> with a name, location and fiat currency, and pick the LNbits wallet that receives its payments.</li>
<li><strong>Pair</strong> it: <em>Pair</em> mints a one-shot <code>spire-seed</code> QR. Show it to the machine's camera (an unpaired machine boots into the pairing wizard). Pairing gives the machine its signing identity through the bunker; no key is ever typed into the machine.</li>
<li><strong>Fees</strong>: your per-machine cash-in / cash-out commission plus the platform fee are published to the machine automatically whenever either changes, and delivered again on every boot.</li>
</ol>
<h2>2. Bays and cassettes — who decides what</h2>
<p><strong>The machine owns its bay layout and its running counts.</strong> How many bays it has is hardware; which denomination sits in each and how many notes are there is something only the person at the machine knows. So:</p>
<table>
<tr><th>Fact</th><th>Where it comes from</th><th>How you change it</th></tr>
<tr><td>Number of bays</td><td>The machine's installation (<code>VITE_BITSPIRE_CASSETTES</code> in its <code>.env</code> on first boot, then its own database). spirekeeper adopts whatever the machine reports and <em>deletes</em> bays it stops reporting.</td><td>Re-provision the machine. There is no dashboard control for bay count.</td></tr>
<tr><td>Denomination per bay</td><td>You.</td><td><em>Set denomination</em> op.</td></tr>
<tr><td>Count per bay</td><td>The machine's running total: refills add, dispenses subtract.</td><td>You publish <em>operations</em>, never counts: <em>Refill +N</em>, <em>Empty</em>, <em>Recount</em>. A recount is the only absolute — it is what you do when you open the bay and count it.</td></tr>
</table>
<p class="warn">Never set a count from a form you loaded before a dispense happened — that is exactly why counts are not editable. If the number on screen is wrong, open the bay, count, and record a <em>Recount</em>.</p>
<p>Each operation carries an id the machine deduplicates on, and the machine echoes the ids it has applied back in its state report; an op shows as <em>acknowledged</em> only when the machine says so. The machine republishes its state on every change and on a heartbeat, so a dashboard that disagrees with a machine heals itself within minutes.</p>
<h2>3. What a cash-out looks like now</h2>
<p>Payment is the <em>authorization</em>; the machine's dispense report is the <em>capture</em>. A customer's payment lands as <code>awaiting_dispense</code> and <strong>nothing is distributed</strong> until the machine reports what physically came out:</p>
<table>
<tr><th>Machine reports</th><th>Settlement</th><th>What happens</th></tr>
<tr><td>Everything dispensed</td><td><code>processed</code></td><td>Distribution runs — platform fee, your splits, DCA.</td></tr>
<tr><td>Some notes out, value short</td><td><code>partial_pending</code></td><td>Held whole until you record how the shortfall was resolved.</td></tr>
<tr><td>Nothing out</td><td><code>cash_owed</code></td><td>Nothing moves. The customer is owed. You are alerted.</td></tr>
<tr><td>No report within 30 min</td><td><code>dispense_unreported</code></td><td>The machine never said. Check it.</td></tr>
</table>
<p>Those last three are the first thing on the <em>Worklist</em>. Every one means a human is owed money or a machine needs eyes.</p>
<h2>4. When the machine takes itself out of service</h2>
<p>A <em>terminal</em> dispenser fault — a jam, a motor or sensor error, a dispense that never answered — makes the machine refuse further cash-out and show the customer that it is temporarily unavailable. The machine's card in spirekeeper shows a red <strong>Cash-out is held</strong> banner with the code and the reason. It stays held until:</p>
<ul>
<li>you record a <em>Recount</em> on any bay (you opened the machine — that also fixes the count), or</li>
<li>you press <em>Resume cash-out</em> on the banner (you cleared the jam without touching a count).</li>
</ul>
<p>Restarting the machine does not clear it: re-initialising a dispenser does not move a stuck note. See the <a href="errors.html">error glossary</a> for what each code means and what you will find inside.</p>
<h2>5. Resolving owed cash</h2>
<p>Two ways, both audited, both close the machine's ledger as well as this one:</p>
<ul>
<li><strong>At the machine</strong> — dispense the shortfall through the machine's manual-dispense command against the original transaction. The machine reports the remediation and the settlement completes on its own.</li>
<li><strong>Off-machine</strong> — you paid the customer by hand. Open the settlement (worklist or table) → <em>Settle off-machine</em> → write how (<em>"handed 40 EUR to customer, 2026-10-09"</em>). The settlement distributes at the full amount and the machine marks its own row remediated.</li>
</ul>
<p>For a <em>partial</em>, <em>Record the resolution</em> opens the partial-dispense dialog pre-filled from the machine's own count of what came out: confirm it if you are writing the shortfall off (the settlement distributes the scaled amount), or use one of the two routes above if you made the customer whole.</p>
<h2>6. Alerts</h2>
<!-- # pragma: allowlist secret -->
<p>When a settlement lands in <code>cash_owed</code> or <code>partial_pending</code> you receive a Nostr direct message (NIP-17) addressed to the pubkey on your LNbits account — a note to self, readable in any client that speaks NIP-46 (the aiolabs webapp, Amber, nsec.app). You do not need your private key for this. To receive alerts on a different identity, set <em>Alerts pubkey</em> in the platform settings.</p>
<h2>7. Reconciling a machine</h2>
<p>On the machine, <code>atm-reconcile</code> re-derives each bay from its last recount plus refills minus dispenses and reports any gap. A bay that has never been recounted cannot be reconciled — its starting number is unrecorded. Recount every bay once after installation; after that, any gap is real.</p>
<p><small>Design record: bitspire <code>docs/adr/004-cassette-state-synchronization.md</code> and <code>docs/adr/005-cash-out-dispense-outcome.md</code>.</small></p>
</body>
</html>

View file

@ -70,6 +70,10 @@ window.app = Vue.createApp({
// Worklist (P9g)
worklist: {
// ADR-005 §6 — owed-cash buckets first
cash_owed: [],
partial_pending: [],
dispense_unreported: [],
rejected: [],
errored: [],
stuck_pending: [],
@ -104,7 +108,8 @@ window.app = Vue.createApp({
super_cash_in_fee_fraction: 0,
super_cash_out_fee_fraction: 0,
super_fee_wallet_id: '',
max_cash_in_sats: null
max_cash_in_sats: null,
alerts_pubkey: ''
}
},
@ -212,15 +217,14 @@ window.app = Vue.createApp({
loading: false,
machine: null,
settlements: [],
// Cassettes sub-tab state (#29 v1) — see openCassettePublishConfirm /
// submitCassettePublish methods + the cassettes panel in
// templates/spirekeeper/index.html.
// Cassettes sub-tab state (v2, bitspire ADR-004) — see
// openCassetteOpDialog / submitCassetteOp + the cassettes panel in
// templates/spirekeeper/index.html. Read-only by design: the
// operator records operations, the machine owns the counts.
activeTab: 'settlements',
cassetteEdits: [], // editable working copy of cassette_configs rows
cassettesPristine: [], // last-known-clean snapshot for revert
cassettes: [], // machine-reported rows, not editable
cassetteOps: [], // recent operations, newest first
cassettesLoading: false,
cassettesPublishing: false,
cassettesDirty: false,
cassettesError: null
},
cassettesTable: {
@ -228,14 +232,27 @@ window.app = Vue.createApp({
{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: 'updated_at', label: 'Updated', field: 'updated_at', align: 'left'}
{name: 'state_at', label: 'Machine reported', field: 'state_at', align: 'left'},
{name: 'actions', label: '', field: 'position', align: 'right'}
],
pagination: {rowsPerPage: 0} // hide pagination — cassette count is small
},
cassettePublishConfirm: {
show: false
cassetteOpDialog: {
show: false,
saving: false,
error: null,
position: null,
op_type: 'refill',
bills: null,
count: null,
denomination: null
},
cassetteOpTypeOptions: [
{label: 'Refill — notes added', value: 'refill'},
{label: 'Empty — bay emptied', value: 'empty'},
{label: 'Recount — notes counted', value: 'recount'},
{label: 'Set denomination', value: 'set_denomination'}
],
partialDispenseDialog: {
show: false,
saving: false,
@ -245,6 +262,13 @@ window.app = Vue.createApp({
dispensed_sats: null,
notes: ''
},
// ADR-005 §6 — settle an owed-cash settlement off-machine
settleDialog: {
show: false,
settlement: null,
note: '',
saving: false
},
noteDialog: {
show: false,
saving: false,
@ -286,6 +310,28 @@ window.app = Vue.createApp({
},
computed: {
cassetteBayOptions() {
return this.machineDetail.cassettes.map(row => ({
label: `Bay ${row.position} — ${row.denomination} ${
(this.machineDetail.machine || {}).fiat_code || ''
} ×${row.count}`,
value: row.position
}))
},
cassetteOpIsComplete() {
// Mirrors CreateCassetteOpData's root validator: exactly one value
// field, decided by the type. Enforced here only to keep the button
// honest — the server rejects a malformed op regardless.
const d = this.cassetteOpDialog
if (!d.position) return false
if (d.op_type === 'empty') return true
if (d.op_type === 'refill') return Number(d.bills) > 0
if (d.op_type === 'recount') return d.count !== null && Number(d.count) >= 0
if (d.op_type === 'set_denomination') return Number(d.denomination) > 0
return false
},
superAnyFee() {
// Banner styling key — true when either directional super fee is
// non-zero, so the banner reads as "active platform fee" instead
@ -338,6 +384,33 @@ window.app = Vue.createApp({
},
worklistBuckets() {
return [
{
key: 'cash_owed',
label:
'Cash owed — customer paid, machine dispensed nothing. ' +
'Legs never ran; funds are in the machine wallet.',
icon: 'money_off',
color: 'negative',
rows: this.worklist.cash_owed
},
{
key: 'partial_pending',
label:
'Partial dispense — some notes out, value short. Held whole ' +
'until you record how the shortfall was resolved.',
icon: 'call_split',
color: 'deep-orange',
rows: this.worklist.partial_pending
},
{
key: 'dispense_unreported',
label:
'Unreported — cash-out paid, machine never reported the dispense. ' +
'Check the machine; an old build lands here too.',
icon: 'help_outline',
color: 'amber',
rows: this.worklist.dispense_unreported
},
{
key: 'rejected',
label: 'Rejected — Nostr attribution failed; investigate machine',
@ -526,6 +599,9 @@ window.app = Vue.createApp({
try {
const {data} = await LNbits.api.request('GET', STUCK_PATH)
this.worklistCount =
(data?.cash_owed?.length || 0) +
(data?.partial_pending?.length || 0) +
(data?.dispense_unreported?.length || 0) +
(data?.rejected?.length || 0) +
(data?.errored?.length || 0) +
(data?.stuck_pending?.length || 0) +
@ -541,11 +617,17 @@ window.app = Vue.createApp({
const {data} = await LNbits.api.request(
'GET', `${STUCK_PATH}?threshold_minutes=${this.worklistThreshold}`
)
this.worklist.cash_owed = data?.cash_owed || []
this.worklist.partial_pending = data?.partial_pending || []
this.worklist.dispense_unreported = data?.dispense_unreported || []
this.worklist.rejected = data?.rejected || []
this.worklist.errored = data?.errored || []
this.worklist.stuck_pending = data?.stuck_pending || []
this.worklist.stuck_processing = data?.stuck_processing || []
this.worklist.totalCount =
this.worklist.cash_owed.length +
this.worklist.partial_pending.length +
this.worklist.dispense_unreported.length +
this.worklist.rejected.length +
this.worklist.errored.length +
this.worklist.stuck_pending.length +
@ -585,7 +667,8 @@ window.app = Vue.createApp({
super_cash_out_fee_fraction:
this.superConfig?.super_cash_out_fee_fraction ?? 0,
super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || '',
max_cash_in_sats: this.superConfig?.max_cash_in_sats ?? null
max_cash_in_sats: this.superConfig?.max_cash_in_sats ?? null,
alerts_pubkey: this.superConfig?.alerts_pubkey || ''
}
this.superFeeDialog.show = true
},
@ -943,9 +1026,8 @@ window.app = Vue.createApp({
async viewMachine(machine) {
this.machineDetail.machine = machine
this.machineDetail.settlements = []
this.machineDetail.cassetteEdits = []
this.machineDetail.cassettesPristine = []
this.machineDetail.cassettesDirty = false
this.machineDetail.cassettes = []
this.machineDetail.cassetteOps = []
this.machineDetail.cassettesError = null
this.machineDetail.activeTab = 'settlements'
this.machineDetail.show = true
@ -972,21 +1054,25 @@ window.app = Vue.createApp({
},
// -----------------------------------------------------------------
// Cassette inventory (#29 v1)
// Cassette inventory + operations (v2, bitspire ADR-004)
// -----------------------------------------------------------------
async loadMachineCassettes() {
if (!this.machineDetail.machine) return
this.machineDetail.cassettesLoading = true
this.machineDetail.cassettesError = null
const base = `${MACHINES_PATH}/${this.machineDetail.machine.id}`
try {
const {data} = await LNbits.api.request(
'GET',
`${MACHINES_PATH}/${this.machineDetail.machine.id}/cassettes`
)
const rows = (data || []).map(row => ({...row, _dirty: false}))
this.machineDetail.cassetteEdits = rows
this.machineDetail.cassettesPristine = JSON.parse(JSON.stringify(rows))
this.machineDetail.cassettesDirty = false
// The machine row is re-read too: counts_uncertain_since lives on it
// and is set by the consumer as state events land, so the row the
// machines table handed us goes stale while this dialog is open.
const [machine, bays, ops] = await Promise.all([
LNbits.api.request('GET', base),
LNbits.api.request('GET', `${base}/cassettes`),
LNbits.api.request('GET', `${base}/cassettes/ops`)
])
if (machine.data) this.machineDetail.machine = machine.data
this.machineDetail.cassettes = bays.data || []
this.machineDetail.cassetteOps = ops.data || []
} catch (e) {
this._notifyError(e, 'Failed to load cassettes')
} finally {
@ -994,73 +1080,91 @@ 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.
const pristine = this.machineDetail.cassettesPristine.find(
p => p.position === row.position
cassetteOpIcon(opType) {
return (
{
refill: 'add_circle_outline',
empty: 'remove_circle_outline',
recount: 'fact_check',
set_denomination: 'sell'
}[opType] || 'help_outline'
)
row._dirty =
!pristine ||
Number(row.denomination) !== Number(pristine.denomination) ||
Number(row.count) !== Number(pristine.count)
this.machineDetail.cassettesDirty =
this.machineDetail.cassetteEdits.some(r => r._dirty)
},
revertCassetteEdits() {
this.machineDetail.cassetteEdits = JSON.parse(
JSON.stringify(this.machineDetail.cassettesPristine)
)
this.machineDetail.cassettesDirty = false
this.machineDetail.cassettesError = null
},
openCassettePublishConfirm() {
if (!this.machineDetail.cassettesDirty) return
this.machineDetail.cassettesError = null
this.cassettePublishConfirm.show = true
},
async submitCassettePublish() {
// 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 positions = {}
for (const row of this.machineDetail.cassetteEdits) {
positions[String(row.position)] = {
denomination: Number(row.denomination),
count: Number(row.count)
cassetteOpSummary(op) {
const fiat = (this.machineDetail.machine || {}).fiat_code || ''
const bay = `Bay ${op.position}`
if (op.op_type === 'refill') return `${bay} — added ${op.bills} notes`
if (op.op_type === 'empty') return `${bay} — emptied`
if (op.op_type === 'recount') return `${bay} — recounted to ${op.count}`
if (op.op_type === 'set_denomination') {
return `${bay} — denomination set to ${op.denomination} ${fiat}`.trim()
}
return `${bay} — ${op.op_type}`
},
openCassetteOpDialog(position) {
const bays = this.machineDetail.cassettes
if (!bays.length) return
Object.assign(this.cassetteOpDialog, {
show: true,
saving: false,
error: null,
position: position || bays[0].position,
op_type: 'refill',
bills: null,
count: null,
denomination: null
})
},
resetCassetteOpValue() {
// Each type carries exactly one value field and the server rejects an
// op that carries a foreign one. Clearing on every type switch means a
// number typed under the previous type can't ride along invisibly.
Object.assign(this.cassetteOpDialog, {
bills: null,
count: null,
denomination: null,
error: null
})
},
async submitCassetteOp() {
const d = this.cassetteOpDialog
if (!this.cassetteOpIsComplete) return
const payload = {position: Number(d.position), op_type: d.op_type}
if (d.op_type === 'refill') payload.bills = Number(d.bills)
if (d.op_type === 'recount') payload.count = Number(d.count)
if (d.op_type === 'set_denomination') {
payload.denomination = Number(d.denomination)
}
const payload = {positions}
this.machineDetail.cassettesPublishing = true
d.saving = true
d.error = null
try {
const {data} = await LNbits.api.request(
await LNbits.api.request(
'POST',
`${MACHINES_PATH}/${this.machineDetail.machine.id}/cassettes/publish`,
`${MACHINES_PATH}/${this.machineDetail.machine.id}/cassettes/ops`,
null,
payload
)
const fresh = (data || []).map(r => ({...r, _dirty: false}))
this.machineDetail.cassetteEdits = fresh
this.machineDetail.cassettesPristine = JSON.parse(JSON.stringify(fresh))
this.machineDetail.cassettesDirty = false
this.cassettePublishConfirm.show = false
d.show = false
Quasar.Notify.create({
type: 'positive',
message: 'Cassette config published to ATM'
message: 'Operation recorded and published to the ATM'
})
await this.loadMachineCassettes()
} catch (e) {
const detail =
(e && e.response && e.response.data && e.response.data.detail) ||
'Publish failed'
this.machineDetail.cassettesError = detail
this._notifyError(e, 'Publish failed')
'Could not record the operation'
// A 503 means the op IS recorded and will ride out with the next
// publish, so reload either way — the list should show it pending.
d.error = detail
this._notifyError(e, 'Operation failed')
await this.loadMachineCassettes()
} finally {
this.machineDetail.cassettesPublishing = false
d.saving = false
}
},
@ -1153,12 +1257,89 @@ window.app = Vue.createApp({
openPartialDispense(settlement) {
this.partialDispenseDialog.settlement = settlement
this.partialDispenseDialog.mode = 'fraction'
this.partialDispenseDialog.dispensed_fraction = null
// ADR-005: pre-fill from the machine's report — the hardware's own count
// of what left — so the operator confirms a number rather than typing one.
const dispensedCents = settlement.dispensed_fiat_cents
const fiat = Number(settlement.fiat_amount)
this.partialDispenseDialog.dispensed_fraction =
dispensedCents != null && fiat > 0
? Math.round((dispensedCents / 100 / fiat) * 10000) / 10000
: null
this.partialDispenseDialog.dispensed_sats = null
this.partialDispenseDialog.notes = ''
this.partialDispenseDialog.notes = settlement.dispense_error
? `Machine reported: ${settlement.dispense_error_code || ''} ${settlement.dispense_raw_code || ''} — ${settlement.dispense_error}`.trim()
: ''
this.partialDispenseDialog.show = true
},
// ADR-005 §6 — the operator paid the customer by hand; close both ledgers.
openSettleCashOwed(settlement) {
this.settleDialog.settlement = settlement
this.settleDialog.note = ''
this.settleDialog.show = true
},
async submitSettleCashOwed() {
const d = this.settleDialog
d.saving = true
try {
const {data} = await LNbits.api.request(
'POST',
`${SETTLEMENTS_PATH}/${d.settlement.id}/settle-cash-owed`,
null,
{note: d.note}
)
this._replaceSettlement(data)
d.show = false
Quasar.Notify.create({
type: 'positive',
message:
'Settled — distributing the full amount; the machine will mark its row remediated'
})
if (this.worklist.totalCount) await this.loadWorklist()
} catch (e) {
this._notifyError(e, 'Settle failed')
} finally {
d.saving = false
}
},
// Deep link into the error glossary for a settlement's dispenser error.
errorGlossaryHref(settlement) {
const code = settlement.dispense_raw_code || settlement.dispense_error_code || ''
const anchor = code.toLowerCase().replace(/[^a-z0-9]+/g, '-')
return `/spirekeeper/static/docs/errors.html${anchor ? '#' + anchor : ''}`
},
// ADR-005 §5 — release a machine's cash-out hold after a terminal
// dispenser fault, when the jam was cleared without a recount.
confirmResumeCashOut(machine) {
Quasar.Dialog.create({
title: 'Resume cash-out?',
message:
'The machine latched cash-out off after a dispenser fault' +
(machine.cash_out_held_code ? ` (${machine.cash_out_held_code})` : '') +
'. Only do this after the transport path has been physically cleared. ' +
'A recount releases the hold too, and also fixes the bay count.',
cancel: true,
persistent: true
}).onOk(async () => {
try {
await LNbits.api.request(
'POST',
`/spirekeeper/api/v1/dca/machines/${machine.id}/resume-cash-out`
)
Quasar.Notify.create({
type: 'positive',
message: 'Resume published — the machine clears the hold on receipt'
})
if (this.machineDetail && this.machineDetail.machine) await this.reloadMachineDetail()
} catch (e) {
this._notifyError(e, 'Resume cash-out failed')
}
})
},
async submitPartialDispense() {
const d = this.partialDispenseDialog
const body = {notes: d.notes || null}

133
tasks.py
View file

@ -169,8 +169,17 @@ async def _handle_payment(payment: Payment) -> None:
if isinstance(nostr_event_id, str) and nostr_event_id:
data.bitspire_event_id = nostr_event_id
# 3) Insert + distribute.
settlement = await create_settlement_idempotent(data, initial_status="pending")
# 3) Insert. ADR-005 §1: payment is authorization, the machine's dispense
# report is capture. A cash_out waits in `awaiting_dispense` for that
# report (handled in dispense_transport); distribution runs only once it
# says dispense_confirmed. A cash_in has no dispense and proceeds as
# before. Before this gate the legs were paid sub-second, before the
# machine had even begun to dispense — which is how a jam read
# `processed` on 2026-10-09 (bitspire#122).
is_cash_out = data.tx_type == "cash_out"
settlement = await create_settlement_idempotent(
data, initial_status="awaiting_dispense" if is_cash_out else "pending"
)
if settlement is None:
logger.error(
f"spirekeeper: failed to insert settlement for "
@ -185,6 +194,10 @@ async def _handle_payment(payment: Payment) -> None:
f"(super_fee={data.platform_fee_sats} "
f"operator_fee={data.operator_fee_sats})"
)
if is_cash_out:
await _await_dispense_or_adopt(settlement, machine, data)
return
# Spawn distribution on a background task so the LNbits invoice queue
# (shared across all extensions) keeps draining while we move sats.
# Concurrency-safe: process_settlement uses claim_settlement_for_processing
@ -196,6 +209,23 @@ async def _handle_payment(payment: Payment) -> None:
task.add_done_callback(_inflight_distributions.discard)
async def _await_dispense_or_adopt(
settlement, machine: Machine, data: CreateDcaSettlementData
) -> None:
"""A cash_out waits for the machine's dispense report (ADR-005 §1) — unless
the report is already here. Under hold invoices the payment settles AFTER
the dispense, and the invoice listener can lag the transport, so an orphan
report for this txid is adopted and applied now."""
if settlement.status != "awaiting_dispense" or not data.bitspire_txid:
return
from .crud import get_latest_unlinked_dispense_report
from .dispense_transport import adopt_unlinked_report
early = await get_latest_unlinked_dispense_report(machine.id, data.bitspire_txid)
if early is not None:
await adopt_unlinked_report(settlement, machine, early)
async def _record_rejected(payment: Payment, machine: Machine, exc: Exception) -> None:
"""Insert a minimal `dca_settlements` row with `status='rejected'` and
the exception message for operator forensics.
@ -338,6 +368,8 @@ async def _cassette_consumer_tick(current_filter_key: str | None) -> str:
apply_reported_state,
get_machine_by_atm_pubkey_hex,
list_all_active_machines,
mark_cassette_ops_acked,
set_machine_counts_uncertain,
)
machines = await list_all_active_machines()
@ -373,6 +405,8 @@ async def _cassette_consumer_tick(current_filter_key: str | None) -> str:
event_message,
get_machine_by_atm_pubkey_hex,
apply_reported_state,
mark_cassette_ops_acked,
set_machine_counts_uncertain,
)
except Exception as exc:
logger.warning(
@ -383,10 +417,89 @@ async def _cassette_consumer_tick(current_filter_key: str | None) -> str:
return filter_key
async def _record_op_acknowledgements(
machine_id: str, payload, mark_cassette_ops_acked
) -> None:
"""Mark the operations a machine reports as applied.
Deliberately not gated on whether the state event advanced the counts. The
machine echoes its applied-op ids on EVERY state publish, so an event
carrying nothing new about the counts can still be the first one to tell us
an operation landed; gating on that would lose the acknowledgement.
This echo is the only acknowledgement the transport can carry. An
addressable event gives its publisher no failure signal at all — the relay
returns OK for an event it then discards — so without it the dashboard
could only ever show an operation as sent, never as delivered.
"""
if not payload.applied_ops:
return
newly_acked = await mark_cassette_ops_acked(machine_id, payload.applied_ops)
if newly_acked:
logger.info(
f"spirekeeper: machine {machine_id} acknowledged "
f"{newly_acked} cassette operation(s)"
)
async def _record_counts_uncertainty(
machine_id: str, payload, set_machine_counts_uncertain
) -> None:
"""Mirror the machine's counts-uncertain marker onto its registry row.
The machine sets this when a dispense ended without a reliable count of
what physically left the bay — a dispenser throw, or a timeout. It cannot
know how many notes moved, so it says so instead of decrementing a number
it would be guessing at.
Written on every state event, including when it is None, because the
machine clearing the marker is exactly as important as setting it: the
operator has recounted, the bay is trustworthy again, and a banner that
never goes away is a banner nobody reads.
"""
from datetime import datetime as _datetime
from datetime import timezone as _timezone
since = None
if payload.counts_uncertain_since is not None:
since = _datetime.fromtimestamp(
int(payload.counts_uncertain_since), tz=_timezone.utc
)
await set_machine_counts_uncertain(machine_id, since)
async def _record_cash_out_hold(
machine_id: str, payload, set_machine_cash_out_hold
) -> None:
"""Mirror the machine's cash-out hold (ADR-005 §5) onto its registry row.
Written on every state event, including when absent, because the machine
clearing the hold — after an operator recount or resume_cash_out — matters
exactly as much as it setting one.
"""
from datetime import datetime as _datetime
from datetime import timezone as _timezone
since = None
if payload.cash_out_held_since is not None:
since = _datetime.fromtimestamp(
int(payload.cash_out_held_since), tz=_timezone.utc
)
await set_machine_cash_out_hold(
machine_id,
since,
payload.cash_out_held_reason if since else None,
payload.cash_out_held_code if since else None,
)
async def _handle_cassette_state_event(
event_message,
get_machine_by_atm_pubkey_hex,
apply_reported_state,
mark_cassette_ops_acked,
set_machine_counts_uncertain,
set_machine_cash_out_hold=None,
) -> None:
"""Verify signature, resolve the operator's signer, decrypt via the
signer abstraction (bunker round-trip for RemoteBunkerSigner; direct
@ -487,12 +600,22 @@ async def _handle_cassette_state_event(
)
if applied:
logger.info(
f"spirekeeper: applied bootstrap state event {event_id[:12]}... "
f"spirekeeper: applied reported state event {event_id[:12]}... "
f"to machine {machine.id} ({len(payload.positions)} cassettes)"
)
else:
# Replay: event_id already on file. Normal on relay reconnect.
# Replay or an older event. Normal on relay reconnect.
logger.debug(
f"spirekeeper: cassette state event {event_id[:12]}... "
f"already applied to machine {machine.id} (replay no-op)"
f"not newer than stored state for machine {machine.id} (no-op)"
)
# Acknowledgement runs regardless of whether the counts were newer — see
# _record_op_acknowledgements for why. Same for the uncertainty marker.
await _record_op_acknowledgements(machine.id, payload, mark_cassette_ops_acked)
await _record_counts_uncertainty(machine.id, payload, set_machine_counts_uncertain)
if set_machine_cash_out_hold is None:
from .crud import set_machine_cash_out_hold as _default_set_hold
set_machine_cash_out_hold = _default_set_hold
await _record_cash_out_hold(machine.id, payload, set_machine_cash_out_hold)

View file

@ -16,6 +16,9 @@
<h4 class="q-my-none">Satoshi Machine — Operator</h4>
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
Manage your bitSpire fleet, liquidity providers, and commission distribution.
<a href="/spirekeeper/static/docs/operator-guide.html" target="_blank" rel="noopener"
:style="{marginLeft: '8px'}">Operator guide</a> ·
<a href="/spirekeeper/static/docs/errors.html" target="_blank" rel="noopener">Error glossary</a>
</p>
</div>
<div class="col-auto">
@ -654,6 +657,13 @@
<q-td key="error_message">
<span :style="{fontSize: '0.85em', opacity: 0.8}"
v-text="props.row.error_message || '—'"></span>
<a v-if="props.row.dispense_error_code"
:href="errorGlossaryHref(props.row)" target="_blank" rel="noopener"
:style="{fontSize: '0.8em', marginLeft: '6px', whiteSpace: 'nowrap'}">
<code v-text="props.row.dispense_raw_code || props.row.dispense_error_code"></code>
<q-icon name="help_outline" size="xs"></q-icon>
<q-tooltip>What this code means and what to do</q-tooltip>
</a>
</q-td>
<q-td key="actions" auto-width>
<q-btn flat dense size="sm" icon="open_in_new"
@ -661,6 +671,18 @@
@click="viewMachineFromWorklist(props.row)">
<q-tooltip>Open machine detail</q-tooltip>
</q-btn>
<q-btn v-if="bucket.key === 'cash_owed' || bucket.key === 'partial_pending'"
flat dense size="sm" icon="handshake"
color="positive"
@click="openSettleCashOwed(props.row)">
<q-tooltip>Settle off-machine — you paid the customer by hand</q-tooltip>
</q-btn>
<q-btn v-if="bucket.key === 'partial_pending'"
flat dense size="sm" icon="call_split"
color="deep-orange"
@click="openPartialDispense(props.row)">
<q-tooltip>Record the resolution (pre-filled from the machine's report)</q-tooltip>
</q-btn>
<q-btn v-if="bucket.key === 'errored'"
flat dense size="sm" icon="restart_alt"
color="primary"
@ -1113,6 +1135,15 @@
</q-item-section>
<q-item-section>Retry distribution</q-item-section>
</q-item>
<q-item
v-if="['cash_owed','partial_pending'].includes(props.row.status)"
clickable v-close-popup
@click="openSettleCashOwed(props.row)">
<q-item-section avatar>
<q-icon name="handshake" color="positive"></q-icon>
</q-item-section>
<q-item-section>Settle off-machine…</q-item-section>
</q-item>
<q-item
v-if="['pending','errored'].includes(props.row.status)"
clickable v-close-popup
@ -1151,23 +1182,22 @@
<div class="col">
<h6 class="q-my-none">Cassettes</h6>
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
Per-cassette count and physical bay position. Denomination
set is hardware-determined (re-provision via atm-tui to
change). "Publish to ATM" encrypts + signs + sends the new
config to the machine via Nostr.
The machine owns these counts — it holds the notes. You
record what you <i>did</i> to a bay and it keeps the
running total. Bay count and denomination set are
hardware-determined (re-provision via atm-tui to change
the bays themselves).
</p>
</div>
<div class="col-auto">
<q-btn flat dense icon="undo" label="Revert"
:disable="!machineDetail.cassettesDirty"
@click="revertCassetteEdits">
<q-tooltip>Discard unsaved edits</q-tooltip>
<q-btn flat dense icon="refresh" label="Refresh"
:loading="machineDetail.cassettesLoading"
@click="loadMachineCassettes">
<q-tooltip>Re-read the machine's latest report</q-tooltip>
</q-btn>
<q-btn color="primary" icon="cloud_upload"
label="Publish to ATM"
:disable="!machineDetail.cassettesDirty"
:loading="machineDetail.cassettesPublishing"
@click="openCassettePublishConfirm"></q-btn>
<q-btn color="primary" icon="add" label="Record operation"
:disable="!machineDetail.cassettes.length"
@click="openCassetteOpDialog()"></q-btn>
</div>
</div>
@ -1179,64 +1209,127 @@
<span v-text="machineDetail.cassettesError"></span>
</q-banner>
<q-banner v-if="!machineDetail.cassetteEdits.length
<q-banner v-if="machineDetail.machine
&& machineDetail.machine.cash_out_held_since"
class="bg-red-1 text-grey-9 q-mb-md">
<template v-slot:avatar>
<q-icon name="block" color="negative"></q-icon>
</template>
<b>Cash-out is held.</b>
The machine latched cash-out off after a terminal dispenser fault
(<span v-text="machineDetail.machine.cash_out_held_code || 'fault'"></span>
at <span v-text="formatTime(machineDetail.machine.cash_out_held_since)"></span>):
<span v-text="machineDetail.machine.cash_out_held_reason"></span>.
Clear the transport path, then either record a <b>Recount</b>
(which also fixes the count) or release it here.
<template v-slot:action>
<q-btn flat color="negative" label="Resume cash-out"
@click="confirmResumeCashOut(machineDetail.machine)"></q-btn>
</template>
</q-banner>
<q-banner v-if="machineDetail.machine
&& machineDetail.machine.counts_uncertain_since"
class="bg-orange-1 text-grey-9 q-mb-md">
<template v-slot:avatar>
<q-icon name="help_outline" color="warning"></q-icon>
</template>
<b>The machine can't vouch for these counts.</b>
A dispense ended without a reliable count of what left the bay
(<span v-text="formatTime(machineDetail.machine.counts_uncertain_since)"></span>).
Open the bay, count it, and record a <b>Recount</b> — the
machine clears this on its own once you do.
</q-banner>
<q-banner v-if="!machineDetail.cassettes.length
&& !machineDetail.cassettesLoading"
class="bg-blue-1 text-grey-9">
<template v-slot:avatar>
<q-icon name="hourglass_empty" color="blue"></q-icon>
</template>
Waiting for the ATM's bootstrap state event. Power on the ATM
Waiting for the ATM's state event. Power on the ATM
and confirm it has reached the configured relay; cassette
rows will auto-populate on receipt.
</q-banner>
<q-table v-if="machineDetail.cassetteEdits.length"
<q-table v-if="machineDetail.cassettes.length"
dense flat
:rows="machineDetail.cassetteEdits"
:rows="machineDetail.cassettes"
row-key="position"
:columns="cassettesTable.columns"
:pagination="cassettesTable.pagination"
hide-pagination>
<template v-slot:body="props">
<q-tr :props="props"
:style="props.row._dirty
? {boxShadow: 'inset 4px 0 0 0 #fdd835'}
: {}">
<q-tr :props="props">
<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>
</q-td>
<q-td key="count" class="text-right">
<q-input v-model.number="props.row.count" type="number"
min="0" step="1" dense outlined
: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 v-text="props.row.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">
<q-td key="count" class="text-right">
<b v-text="props.row.count"></b>
<span :style="{opacity: 0.6}"> notes</span>
</q-td>
<q-td key="state_at">
<span :style="{fontSize: '0.85em', opacity: 0.7}"
v-text="formatTime(props.row.updated_at)"></span>
v-text="props.row.state_at
? formatTime(props.row.state_at)
: 'never'"></span>
</q-td>
<q-td key="actions" class="text-right">
<q-btn flat dense size="sm" icon="edit_note"
@click="openCassetteOpDialog(props.row.position)">
<q-tooltip>Record an operation on this bay</q-tooltip>
</q-btn>
</q-td>
</q-tr>
</template>
</q-table>
<div v-if="machineDetail.cassettes.length" class="q-mt-lg">
<div class="text-subtitle2 q-mb-xs">Recent operations</div>
<p class="text-caption q-mt-none q-mb-sm" :style="{opacity: 0.7}">
"Pending" means sent but not yet echoed back by the machine.
Every publish carries the recent window, so a pending
operation keeps being re-offered until it lands — there is
nothing to retry by hand.
</p>
<q-banner v-if="!machineDetail.cassetteOps.length"
class="bg-grey-3 text-grey-9">
No operations recorded yet.
</q-banner>
<q-list v-else dense bordered separator>
<q-item v-for="op in machineDetail.cassetteOps" :key="op.id">
<q-item-section avatar>
<q-icon :name="cassetteOpIcon(op.op_type)"
:color="op.acked_at ? 'positive' : 'grey'"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label v-text="cassetteOpSummary(op)"></q-item-label>
<q-item-label caption
v-text="formatTime(op.created_at)"></q-item-label>
</q-item-section>
<q-item-section side>
<q-chip dense size="sm"
:color="op.acked_at ? 'green-1' : 'orange-1'"
text-color="grey-9"
:label="op.acked_at ? 'Applied' : 'Pending'">
<q-tooltip v-if="op.acked_at">
Machine confirmed at
<span v-text="formatTime(op.acked_at)"></span>
</q-tooltip>
<q-tooltip v-else>
Sent; waiting for the machine to echo this id back
</q-tooltip>
</q-chip>
</q-item-section>
</q-item>
</q-list>
</div>
</q-tab-panel>
</q-tab-panels>
</q-card-section>
@ -1244,53 +1337,82 @@
</q-dialog>
<!-- =============================================================== -->
<!-- CASSETTE PUBLISH CONFIRM DIALOG -->
<!-- RECORD CASSETTE OPERATION DIALOG -->
<!-- =============================================================== -->
<q-dialog v-model="cassettePublishConfirm.show" persistent>
<q-dialog v-model="cassetteOpDialog.show" persistent>
<q-card :style="{width: '480px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Publish cassette config to ATM</div>
<div class="text-h6">Record cassette operation</div>
<q-space ></q-space>
<q-btn icon="close" flat round dense v-close-popup></q-btn>
</q-card-section>
<q-card-section>
<q-banner class="bg-orange-1 text-grey-9 q-mb-md">
<q-banner class="bg-blue-1 text-grey-9 q-mb-md">
<template v-slot:avatar>
<q-icon name="warning" color="warning"></q-icon>
<q-icon name="info" color="blue"></q-icon>
</template>
<b>This publish will overwrite the ATM's currently-tracked
counts.</b> If the ATM has dispensed cash since your last
refill or count baseline, those decrements will be lost.
Publish only after a physical refill (a known total), not to
"tweak" counts mid-day. v2 reconciliation will replace this
modal with reconciled state display.
Record what you did to the bay. The machine applies it to the
count it already holds, so a dispense that happened while this
dialog was open is kept, not overwritten.
</q-banner>
<q-select v-model.number="cassetteOpDialog.position"
:options="cassetteBayOptions"
emit-value map-options
label="Bay" dense outlined
class="q-mb-md"></q-select>
<q-select v-model="cassetteOpDialog.op_type"
:options="cassetteOpTypeOptions"
emit-value map-options
label="Operation" dense outlined
class="q-mb-md"
@update:model-value="resetCassetteOpValue"></q-select>
<q-input v-if="cassetteOpDialog.op_type === 'refill'"
v-model.number="cassetteOpDialog.bills"
type="number" min="1" step="1" dense outlined
label="Notes added"
hint="How many notes you put IN — a delta, not a total."
></q-input>
<q-input v-if="cassetteOpDialog.op_type === 'recount'"
v-model.number="cassetteOpDialog.count"
type="number" min="0" step="1" dense outlined
label="Notes counted"
hint="What you physically counted in the bay, right now."
></q-input>
<q-input v-if="cassetteOpDialog.op_type === 'set_denomination'"
v-model.number="cassetteOpDialog.denomination"
type="number" min="1" step="1" dense outlined
label="Denomination"
:suffix="machineDetail.machine
? (machineDetail.machine.fiat_code || '') : ''"
hint="What is now loaded in that bay. The machine can't
know this — only you can."
></q-input>
<q-banner v-if="cassetteOpDialog.op_type === 'empty'"
class="bg-grey-3 text-grey-9">
The bay is now empty. Nothing else to enter.
</q-banner>
<q-banner v-if="cassetteOpDialog.error"
class="bg-red-1 text-grey-9 q-mt-md">
<template v-slot:avatar>
<q-icon name="warning" color="negative"></q-icon>
</template>
<span v-text="cassetteOpDialog.error"></span>
</q-banner>
<p class="q-mb-sm">Sending to ATM:</p>
<q-list dense bordered>
<q-item v-for="row in machineDetail.cassetteEdits"
:key="row.position">
<q-item-section>
<q-item-label>
<b v-text="'Bay ' + row.position"></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>
· count
<b v-text="row.count"></b>
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn color="primary"
label="Publish to ATM"
:loading="machineDetail.cassettesPublishing"
@click="submitCassettePublish"></q-btn>
label="Record + publish"
:disable="!cassetteOpIsComplete"
:loading="cassetteOpDialog.saving"
@click="submitCassetteOp"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
@ -1357,6 +1479,41 @@
<!-- =============================================================== -->
<!-- ADD-NOTE DIALOG -->
<!-- =============================================================== -->
<!-- ADR-005 §6: settle an owed-cash settlement off-machine -->
<q-dialog v-model="settleDialog.show" persistent>
<q-card :style="{width: '460px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Settle off-machine</div>
<q-space></q-space>
<q-btn icon="close" flat round dense v-close-popup></q-btn>
</q-card-section>
<q-card-section v-if="settleDialog.settlement">
<p class="q-mb-sm">
The customer paid
<strong v-text="formatFiat(settleDialog.settlement.fiat_amount, settleDialog.settlement.fiat_code)"></strong>
and the machine reports
<strong v-text="settleDialog.settlement.status === 'cash_owed' ? 'nothing dispensed' : 'a partial dispense'"></strong>.
</p>
<p class="text-caption q-mb-md" :style="{opacity: 0.8}">
Use this when you have made the customer whole <em>outside</em> the machine.
The settlement distributes at the full amount and the machine marks its own
record remediated. Nothing is dispensed. If you dispensed the shortfall at the
machine instead, do nothing — it reports that itself.
</p>
<q-input v-model="settleDialog.note" type="textarea" autogrow outlined dense
label="How was the customer paid? (required)"
hint="e.g. handed 40 EUR to the customer in person, 2026-10-09"
maxlength="500" counter></q-input>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn color="positive" label="Settle at full amount"
:disable="!settleDialog.note.trim()" :loading="settleDialog.saving"
@click="submitSettleCashOwed"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="noteDialog.show" persistent>
<q-card :style="{width: '420px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
@ -1421,6 +1578,10 @@
hint="Server-side ceiling on a single cash-in's principal. The ATM attests the amount; this bounds a compromised/buggy machine to one capped tx."
type="number" step="1" min="0"
class="q-mb-md" dense outlined></q-input>
<q-input v-model="superFeeDialog.data.alerts_pubkey"
label="Alerts pubkey (hex or npub — blank = each operator's own account key)"
hint="Where owed-cash alerts are delivered as Nostr DMs (NIP-17). Blank sends each operator a note to self on their LNbits account identity."
class="q-mb-md" dense outlined></q-input>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup></q-btn>

View file

@ -2,9 +2,9 @@
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 (position key coercion, integer ranges, multiple-same-
denomination payloads, wire-format round-trip)
- Pydantic validator behaviour on PublishCassettesPayload + the row model
(position key coercion, integer ranges, multiple-same-denomination
payloads, wire-format round-trip)
- _should_apply_state_event ordering gate (extracted from
apply_reported_state so the decision is testable without a database
round-trip)
@ -29,7 +29,6 @@ from ..crud import _as_unix, _should_apply_state_event
from ..models import (
CassettePayloadRow,
PublishCassettesPayload,
UpsertCassetteConfigData,
)
# =============================================================================
@ -149,44 +148,6 @@ class TestCassettePayloadRow:
CassettePayloadRow(denomination=20, count=-1)
# =============================================================================
# UpsertCassetteConfigData — operator-edit form
# =============================================================================
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)."""
def test_partial_update_count_only(self):
d = UpsertCassetteConfigData(count=80)
assert d.count == 80
assert d.denomination 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
assert d.count is None
def test_empty_update_is_legal(self):
"""An empty UpsertCassetteConfigData parses fine; the CRUD short-
circuits a no-op on empty payload (no SQL emitted)."""
d = UpsertCassetteConfigData()
assert d.count 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_denomination(self):
with pytest.raises(ValueError):
UpsertCassetteConfigData(denomination=0)
# =============================================================================
# _should_apply_state_event — ordering gate
# =============================================================================
@ -247,3 +208,31 @@ class TestShouldApplyStateEvent:
"""Fail closed: an unparseable incoming stamp must not overwrite
state that is known-good."""
assert _should_apply_state_event(NOW, "nonsense") is False
def test_seq_breaks_a_same_second_tie(self):
"""NIP-01 stamps at one-second granularity, so a dispense and the
publish that follows it share a stamp routinely. Without the counter
the later report is dropped and the operator keeps the pre-dispense
count until the next heartbeat."""
assert _should_apply_state_event(NOW, NOW, 4, 5) is True
assert _should_apply_state_event(NOW, NOW, 5, 5) is False
assert _should_apply_state_event(NOW, NOW, 5, 4) is False
def test_seq_is_ignored_when_the_stamps_differ(self):
"""A machine whose state.db was replaced restarts its counter at zero
while its wall clock keeps moving forward. Gating on the counter
across different stamps would lock that machine out for good."""
assert (
_should_apply_state_event(NOW, NOW + timedelta(seconds=1), 900, 0) is True
)
assert (
_should_apply_state_event(NOW, NOW - timedelta(seconds=1), 0, 900) is False
)
def test_same_second_without_a_counter_stays_closed(self):
"""An older machine sends no seq at all. Equal stamps then carry no
evidence the report is newer, and fail-closed is the safe read: the
machine republishes on its heartbeat a second later."""
assert _should_apply_state_event(NOW, NOW, None, None) is False
assert _should_apply_state_event(NOW, NOW, None, 5) is False
assert _should_apply_state_event(NOW, NOW, 5, None) is False

360
tests/test_cassette_ops.py Normal file
View file

@ -0,0 +1,360 @@
"""
Tests for the v2 cassette-operations models (bitspire ADR-004).
The operator no longer publishes counts; it publishes operations and the
machine keeps the running total. These cover the pure pieces: per-type field
validation, and the wire shape the publisher ships.
A CreateCassetteOpData instance is meant to be publishable by construction —
same contract as FeeConfigPayload — so the type/field agreement is enforced in
the model rather than at the endpoint.
"""
from datetime import datetime, timezone
import pytest
from pydantic import ValidationError
from .. import cassette_transport, tasks
from ..crud import _should_ack_op
from ..models import (
CASSETTE_OP_TYPES,
CassetteOp,
CreateCassetteOpData,
Machine,
PublishCassetteOpsPayload,
PublishCassettesPayload,
)
AT = datetime.fromtimestamp(1790106060, timezone.utc)
def op(**kw) -> CassetteOp:
base = {"id": "op-1", "machine_id": "m1", "position": 2, "created_at": AT}
return CassetteOp(**{**base, **kw})
class TestCreateCassetteOpData:
def test_accepts_one_of_each_type(self):
CreateCassetteOpData(position=2, op_type="refill", bills=100)
CreateCassetteOpData(position=3, op_type="empty")
CreateCassetteOpData(position=1, op_type="recount", count=37)
CreateCassetteOpData(position=1, op_type="set_denomination", denomination=50)
@pytest.mark.parametrize(
"kwargs",
[
{"position": 2, "op_type": "refill"},
{"position": 1, "op_type": "recount"},
{"position": 1, "op_type": "set_denomination"},
],
)
def test_rejects_a_type_missing_its_field(self, kwargs):
with pytest.raises(ValidationError):
CreateCassetteOpData(**kwargs)
@pytest.mark.parametrize(
"kwargs",
[
{"position": 2, "op_type": "refill", "bills": 1, "count": 5},
{"position": 3, "op_type": "empty", "bills": 1},
{"position": 1, "op_type": "recount", "count": 1, "denomination": 50},
],
)
def test_rejects_a_type_carrying_a_foreign_field(self, kwargs):
"""An op that carries two meanings is ambiguous on the wire, and the
machine would have to guess which one to apply."""
with pytest.raises(ValidationError):
CreateCassetteOpData(**kwargs)
def test_rejects_a_refill_of_zero_or_fewer_notes(self):
"""A refill is a delta that adds notes. Zero is a no-op an operator
did not mean, and negative is a withdrawal wearing a refill's name."""
for bills in (0, -5):
with pytest.raises(ValidationError):
CreateCassetteOpData(position=2, op_type="refill", bills=bills)
def test_allows_a_recount_to_zero(self):
"""Distinct from refill: counting a bay and finding it empty is a real
and important observation."""
assert CreateCassetteOpData(position=2, op_type="recount", count=0).count == 0
def test_rejects_a_negative_recount_and_a_non_positive_denomination(self):
with pytest.raises(ValidationError):
CreateCassetteOpData(position=2, op_type="recount", count=-1)
with pytest.raises(ValidationError):
CreateCassetteOpData(position=2, op_type="set_denomination", denomination=0)
def test_rejects_an_unknown_type_and_a_non_positive_position(self):
with pytest.raises(ValidationError):
CreateCassetteOpData(position=1, op_type="drain")
with pytest.raises(ValidationError):
CreateCassetteOpData(position=0, op_type="empty")
class TestWireShape:
def test_each_type_ships_only_its_own_field(self):
assert op(op_type="refill", bills=100).to_wire_dict() == {
"id": "op-1",
"at": 1790106060,
"type": "refill",
"position": 2,
"bills": 100,
}
assert op(op_type="empty").to_wire_dict() == {
"id": "op-1",
"at": 1790106060,
"type": "empty",
"position": 2,
}
assert op(op_type="recount", count=37).to_wire_dict()["count"] == 37
assert (
op(op_type="set_denomination", denomination=50).to_wire_dict()[
"denomination"
]
== 50
)
def test_nulls_never_reach_the_wire(self):
"""The row has three nullable columns and one op only ever means one
of them. Shipping the other two as null would make the machine guess."""
for op_type in CASSETTE_OP_TYPES:
kw = {
"refill": {"bills": 1},
"recount": {"count": 1},
"set_denomination": {"denomination": 1},
"empty": {},
# machine-wide (ADR-005 §5): position 0, no position on the wire
"resume_cash_out": {"position": 0},
# machine-wide too (ADR-005 §6): carries the txid it closes
"settle_transaction": {"position": 0, "txid": "tx_1"},
}[op_type]
wire = op(op_type=op_type, **kw).to_wire_dict()
assert None not in wire.values()
def test_payload_declares_v2_and_preserves_order(self):
ops = [
op(id="a", op_type="refill", bills=1),
op(id="b", op_type="empty"),
]
wire = PublishCassetteOpsPayload(ops=ops).to_wire_dict()
assert wire["schema_version"] == 2
assert [o["id"] for o in wire["ops"]] == ["a", "b"]
def test_an_empty_window_is_representable(self):
"""A machine with no operator history still gets a well-formed
payload rather than the publisher having to special-case it."""
assert PublishCassetteOpsPayload(ops=[]).to_wire_dict() == {
"schema_version": 2,
"ops": [],
}
class TestShouldAckOp:
"""The pure decision behind mark_cassette_ops_acked.
The machine echoes a WINDOW of applied ids on every state publish, so the
same id arrives repeatedly and from a machine that may not own it.
"""
def test_acks_an_unacked_op_for_the_reporting_machine(self):
assert _should_ack_op(op(op_type="empty"), "m1") is True
def test_ignores_an_unknown_id(self):
assert _should_ack_op(None, "m1") is False
def test_ignores_an_op_belonging_to_another_machine(self):
"""Ids are unique, but a report from one machine must never close out
another machine's operation."""
assert _should_ack_op(op(op_type="empty", machine_id="m2"), "m1") is False
def test_keeps_the_first_acknowledgement(self):
"""Every subsequent window carries the id again. Re-acking would slide
the timestamp forward and lose when the operation actually landed."""
already = op(op_type="empty", acked_at=AT)
assert _should_ack_op(already, "m1") is False
# =============================================================================
# publish_ops_to_atm — the v2 wire contract
# =============================================================================
ATM_HEX = "df2003343784b69cb813b2a4fd231f83ae81133279251c735414f9909baa7ac6"
def machine() -> Machine:
return Machine(
id="m1",
operator_user_id="op1",
machine_npub=ATM_HEX,
wallet_id="w1",
name="Cinderella",
location=None,
fiat_code="EUR",
is_active=True,
created_at=AT,
updated_at=AT,
)
@pytest.fixture
def captured(monkeypatch):
"""Capture what the transport would publish, without a relay or signer."""
seen: dict = {}
async def fake_publish(**kwargs):
seen.update(kwargs)
return {"id": "event-id"}
monkeypatch.setattr(
cassette_transport, "publish_encrypted_kind_30078", fake_publish
)
return seen
class TestPublishOpsToAtm:
@pytest.mark.asyncio
async def test_publishes_v2_ops_to_the_config_d_tag(self, captured):
ops = [
op(id="a", op_type="refill", bills=100),
op(id="b", op_type="empty", position=3),
]
await cassette_transport.publish_ops_to_atm(machine(), ops, "op1")
# Same d-tag as the counts wire it replaces: the machine subscribes by
# this tag, and the document is addressable, so v2 replaces v1 in place.
assert captured["d_tag"] == f"bitspire-cassettes:{ATM_HEX}"
assert captured["recipient_pubkey_hex"] == ATM_HEX
assert captured["operator_user_id"] == "op1"
payload = captured["payload"]
assert payload["schema_version"] == 2
assert [o["id"] for o in payload["ops"]] == ["a", "b"]
assert payload["ops"][0]["bills"] == 100
assert "positions" not in payload
@pytest.mark.asyncio
async def test_preserves_window_order(self, captured):
"""Order is meaning: a recount then a refill is not the same as the
reverse, so the publisher must not re-sort what crud handed it."""
ops = [
op(id="first", op_type="recount", count=10),
op(id="second", op_type="refill", bills=5),
]
await cassette_transport.publish_ops_to_atm(machine(), ops, "op1")
assert [o["id"] for o in captured["payload"]["ops"]] == ["first", "second"]
@pytest.mark.asyncio
async def test_publishes_an_empty_window_rather_than_skipping(self, captured):
"""A machine with no operator history still gets a well-formed v2
document, so it can tell 'no operations' from 'operator still on v1'."""
await cassette_transport.publish_ops_to_atm(machine(), [], "op1")
assert captured["payload"] == {"schema_version": 2, "ops": []}
@pytest.mark.asyncio
async def test_accepts_an_npub_and_publishes_hex(self, captured):
"""Operators enter either form in the UI; the d-tag is always hex, or
the machine's subscription filter silently never matches."""
import bech32
data = bech32.convertbits(bytes.fromhex(ATM_HEX), 8, 5)
npub = bech32.bech32_encode("npub", data)
m = machine().copy(update={"machine_npub": npub})
await cassette_transport.publish_ops_to_atm(m, [], "op1")
assert captured["d_tag"] == f"bitspire-cassettes:{ATM_HEX}"
# =============================================================================
# _record_op_acknowledgements — the only ack this transport can carry
# =============================================================================
def state_payload(**kw) -> PublishCassettesPayload:
base = {"positions": {"1": {"denomination": 50, "count": 24}}}
return PublishCassettesPayload(**{**base, **kw})
class TestRecordOpAcknowledgements:
@pytest.mark.asyncio
async def test_marks_the_reported_ids(self):
calls = []
async def mark(machine_id, op_ids):
calls.append((machine_id, op_ids))
return len(op_ids)
await tasks._record_op_acknowledgements(
"m1", state_payload(applied_ops=["a", "b"]), mark
)
assert calls == [("m1", ["a", "b"])]
@pytest.mark.asyncio
async def test_does_nothing_when_the_machine_reports_none(self):
"""An older machine sends no applied_ops at all, and a new one with
nothing applied sends an empty list. Neither should write."""
calls = []
async def mark(machine_id, op_ids):
calls.append((machine_id, op_ids))
return 0
await tasks._record_op_acknowledgements("m1", state_payload(), mark)
await tasks._record_op_acknowledgements(
"m1", state_payload(applied_ops=[]), mark
)
assert calls == []
@pytest.mark.asyncio
async def test_acks_even_when_the_counts_were_not_newer(self):
"""The machine echoes its applied ids on every publish, including
heartbeats that carry nothing new about the counts. One of those can
still be the first event to tell us an operation landed, so the ack
must not depend on the state having advanced."""
seen = []
async def mark(machine_id, op_ids):
seen.extend(op_ids)
return len(op_ids)
# Same positions as already on file — a pure heartbeat.
await tasks._record_op_acknowledgements(
"m1", state_payload(applied_ops=["late-ack"]), mark
)
assert seen == ["late-ack"]
# =============================================================================
# _record_counts_uncertainty — the machine saying "don't trust these counts"
# =============================================================================
class TestRecordCountsUncertainty:
@pytest.mark.asyncio
async def test_stores_the_reported_moment_as_utc(self):
calls = []
async def setter(machine_id, since):
calls.append((machine_id, since))
await tasks._record_counts_uncertainty(
"m1", state_payload(counts_uncertain_since=1790110546), setter
)
assert len(calls) == 1
machine_id, since = calls[0]
assert machine_id == "m1"
assert since is not None
assert since.tzinfo is not None
assert int(since.timestamp()) == 1790110546
@pytest.mark.asyncio
async def test_clears_the_marker_when_the_machine_is_confident_again(self):
"""A banner that never goes away is a banner nobody reads. The
machine dropping the field is how the operator learns the recount
took, so None must be written through rather than skipped."""
calls = []
async def setter(machine_id, since):
calls.append((machine_id, since))
await tasks._record_counts_uncertainty("m1", state_payload(), setter)
assert calls == [("m1", None)]

View file

@ -0,0 +1,542 @@
"""
Tests for dispense-outcome capture (bitspire ADR-005 §1-§2, #122).
Covers the pure pieces and the handler's transitions with the crud layer
monkeypatched (no DB), in the project's established style: asyncio.run inside
the test body, SimpleNamespace for request/payment shapes.
- models: DispenseReportIn validation + derived numbers; resume_cash_out as a
machine-wide op (position 0, no position on the wire); the three new
worklist buckets default empty.
- handler: confirmed → pending + distribution spawned; nothing out →
cash_owed; some out → partial_pending; already-captured settlements are
recorded but not moved; a byte-identical resend is acked without a new
row; an orphan report (payment not landed) is stored unlinked; a
remediation report moves the owed settlement to pending; unpaired sender
and bad bodies are refused.
- gate: _handle_payment inserts cash_out as awaiting_dispense and does NOT
spawn distribution; cash_in is unchanged; an early report is adopted.
- consumer: the state document's cash_out_held_* mirrors onto the machine,
including clearing it.
"""
import asyncio
from datetime import datetime, timezone
from types import SimpleNamespace
from typing import Any
import pytest
from pydantic import ValidationError
from .. import crud as crud_mod
from .. import dispense_transport, tasks
from ..dispense_transport import (
_outcome_status,
handle_report_dispense,
)
from ..models import (
CASSETTE_OP_TYPES,
CassetteOp,
CreateCassetteOpData,
CreateDcaSettlementData,
DcaSettlement,
DispenseReportIn,
Machine,
PublishCassettesPayload,
StuckSettlementsResponse,
)
_NOW = datetime(2026, 10, 9, 7, 2, 33)
_ATM_HEX = "df2003343784b69cb813b2a4fd231f83ae81133279251c735414f9909baa7ac6"
_TXID = "tx_mv0madw6_wdhtea1v"
_HASH = "6f216df32c36" + "0" * 52
def _machine(**over) -> Machine:
base: dict[str, Any] = {
"id": "m1",
"operator_user_id": "op1",
"machine_npub": _ATM_HEX,
"wallet_id": "w1",
"name": "sintra",
"location": None,
"fiat_code": "EUR",
"is_active": True,
"created_at": _NOW,
"updated_at": _NOW,
}
base.update(over)
return Machine(**base)
def _settlement(status="awaiting_dispense", **over) -> DcaSettlement:
base: dict[str, Any] = {
"id": "s1",
"machine_id": "m1",
"payment_hash": _HASH,
"bitspire_event_id": None,
"bitspire_txid": _TXID,
"wire_sats": 54440,
"fiat_amount": 40.0,
"fiat_code": "EUR",
"exchange_rate": 1361.0,
"principal_sats": 54440,
"fee_sats": 0,
"platform_fee_sats": 0,
"operator_fee_sats": 0,
"tx_type": "cash_out",
"bills_json": None,
"cassettes_json": None,
"status": status,
"error_message": None,
"processed_at": None,
"created_at": _NOW,
}
base.update(over)
return DcaSettlement(**base)
def _report(**over) -> dict:
"""The wire body for sintra's 2026-10-09 jam, as a dict."""
body: dict[str, Any] = {
"txid": _TXID,
"payment_hash": _HASH,
"tx_type": "cash_out",
"dispense_confirmed": False,
"error": "Note stopped at the cassette exit",
"error_code": "F56DispenseError",
"raw_code": "78 42",
"error_class": "terminal",
"fiat_cents": 4000,
"currency": "EUR",
"bills": [{"denomination": 20, "requested": 2, "dispensed": 0, "rejected": 0}],
"cassettes": [
{
"position": 1,
"denomination": 50,
"provisioned": 0,
"dispensed": 0,
"rejected": 0,
},
{
"position": 2,
"denomination": 20,
"provisioned": 2,
"dispensed": 0,
"rejected": 0,
},
],
"counts_uncertain": True,
"at": 1791529353,
}
body.update(over)
return body
def _confirmed() -> dict:
return _report(
dispense_confirmed=True,
error=None,
error_code=None,
raw_code=None,
error_class=None,
bills=[{"denomination": 20, "requested": 2, "dispensed": 2, "rejected": 0}],
counts_uncertain=False,
)
def _partial() -> dict:
return _report(
bills=[{"denomination": 20, "requested": 2, "dispensed": 1, "rejected": 1}],
counts_uncertain=False,
)
# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------
class TestDispenseReportIn:
def test_derived_numbers(self):
r = DispenseReportIn(**_partial())
assert r.dispensed_fiat_cents == 2000
assert r.total_dispensed_notes == 1
assert DispenseReportIn(**_confirmed()).dispensed_fiat_cents == 4000
assert DispenseReportIn(**_report()).total_dispensed_notes == 0
def test_outcome_routing(self):
assert _outcome_status(DispenseReportIn(**_confirmed())) == "pending"
assert _outcome_status(DispenseReportIn(**_partial())) == "partial_pending"
assert _outcome_status(DispenseReportIn(**_report())) == "cash_owed"
def test_rejects_cash_in_and_unknown_class(self):
with pytest.raises(ValidationError):
DispenseReportIn(**_report(tx_type="cash_in"))
with pytest.raises(ValidationError):
DispenseReportIn(**_report(error_class="weird"))
with pytest.raises(ValidationError):
DispenseReportIn(**_report(txid=" "))
with pytest.raises(ValidationError):
DispenseReportIn(**_report(fiat_cents=-1))
class TestResumeCashOutOp:
def test_is_a_known_type_and_machine_wide(self):
assert "resume_cash_out" in CASSETTE_OP_TYPES
op = CassetteOp(
id="r1",
machine_id="m1",
position=0,
op_type="resume_cash_out",
created_at=_NOW,
)
wire = op.to_wire_dict()
assert wire == {
"id": "r1",
"at": int(_NOW.timestamp()),
"type": "resume_cash_out",
}
assert "position" not in wire
def test_position_must_be_zero_for_resume_and_positive_otherwise(self):
with pytest.raises(ValidationError):
CassetteOp(
id="r1",
machine_id="m1",
position=2,
op_type="resume_cash_out",
created_at=_NOW,
)
with pytest.raises(ValidationError):
CreateCassetteOpData(position=1, op_type="resume_cash_out")
with pytest.raises(ValidationError):
CreateCassetteOpData(position=0, op_type="refill", bills=5)
CreateCassetteOpData(position=0, op_type="resume_cash_out") # ok
def test_resume_carries_no_bay_fields(self):
with pytest.raises(ValidationError):
CreateCassetteOpData(position=0, op_type="resume_cash_out", count=3)
class TestWorklistModel:
def test_new_buckets_default_empty(self):
r = StuckSettlementsResponse(
threshold_minutes=30,
rejected=[],
errored=[],
stuck_pending=[],
stuck_processing=[],
)
assert (
r.cash_owed == []
and r.partial_pending == []
and r.dispense_unreported == []
)
# ---------------------------------------------------------------------------
# Handler
# ---------------------------------------------------------------------------
class _Wired:
"""Monkeypatched crud layer for handle_report_dispense."""
def __init__(self, monkeypatch, *, machine, settlement, existing_report=None):
self.inserted = []
self.applied = []
self.spawned = []
self.uncertain = []
self.statuses = []
self.settlement = settlement
async def get_machine(_hex):
return machine
async def get_report(_mid, _txid, _at):
return existing_report
async def get_settlement(_mid, txid):
if self.settlement is not None and self.settlement.bitspire_txid == txid:
return self.settlement
return None
async def insert(mid, sid, report):
self.inserted.append((mid, sid, report))
return SimpleNamespace(id="rep1", settlement_id=sid, txid=report.txid)
async def apply(sid, report, new_status, reported_at):
self.applied.append((sid, new_status, reported_at))
return (
self.settlement.copy(update={"status": new_status})
if self.settlement
else None
)
async def set_uncertain(mid, since):
self.uncertain.append((mid, since))
async def mark_status(sid, status, _msg):
self.statuses.append((sid, status))
return None
monkeypatch.setattr(
dispense_transport, "get_machine_by_atm_pubkey_hex", get_machine
)
monkeypatch.setattr(dispense_transport, "get_dispense_report", get_report)
monkeypatch.setattr(
dispense_transport, "get_settlement_by_txid", get_settlement
)
monkeypatch.setattr(dispense_transport, "insert_dispense_report", insert)
monkeypatch.setattr(dispense_transport, "apply_dispense_outcome", apply)
monkeypatch.setattr(
dispense_transport, "set_machine_counts_uncertain", set_uncertain
)
monkeypatch.setattr(
dispense_transport, "_spawn_distribution", self.spawned.append
)
monkeypatch.setattr(crud_mod, "mark_settlement_status", mark_status)
def _req(body, sender=_ATM_HEX):
return SimpleNamespace(body=body, sender_pubkey=sender, event_id="ev1")
class TestHandleReportDispense:
def test_confirmed_captures_and_distributes(self, monkeypatch):
w = _Wired(monkeypatch, machine=_machine(), settlement=_settlement())
out = asyncio.run(handle_report_dispense(None, _req(_confirmed())))
assert out["received"] is True
assert out["settlement_status"] == "pending"
assert w.applied == [("s1", "pending", datetime.fromtimestamp(1791529353))]
assert w.spawned == ["s1"]
assert w.inserted[0][1] == "s1" # linked to the settlement
assert w.uncertain == []
def test_nothing_out_is_cash_owed_and_nothing_moves(self, monkeypatch):
w = _Wired(monkeypatch, machine=_machine(), settlement=_settlement())
out = asyncio.run(handle_report_dispense(None, _req(_report())))
assert out["settlement_status"] == "cash_owed"
assert w.applied[0][1] == "cash_owed"
assert w.spawned == []
# counts_uncertain on the report mirrors onto the machine immediately
assert len(w.uncertain) == 1 and w.uncertain[0][0] == "m1"
def test_some_out_is_partial_pending_held_whole(self, monkeypatch):
w = _Wired(monkeypatch, machine=_machine(), settlement=_settlement())
out = asyncio.run(handle_report_dispense(None, _req(_partial())))
assert out["settlement_status"] == "partial_pending"
assert w.spawned == [] # ADR-005 Decision 1: one distribution, when final
def test_already_captured_settlement_is_recorded_not_moved(self, monkeypatch):
w = _Wired(
monkeypatch, machine=_machine(), settlement=_settlement(status="processed")
)
out = asyncio.run(handle_report_dispense(None, _req(_report())))
assert out["settlement_status"] == "processed"
assert w.applied == [] and w.spawned == []
assert len(w.inserted) == 1 # the row still lands — it is information
def test_identical_resend_is_acked_without_a_new_row(self, monkeypatch):
existing = SimpleNamespace(id="rep0", settlement_id="s1", txid=_TXID)
w = _Wired(
monkeypatch,
machine=_machine(),
settlement=_settlement(status="cash_owed"),
existing_report=existing,
)
out = asyncio.run(handle_report_dispense(None, _req(_report())))
assert out["received"] is True and out.get("duplicate") is True
assert out["settlement_status"] == "cash_owed"
assert w.inserted == [] and w.applied == []
def test_report_before_payment_is_stored_unlinked(self, monkeypatch):
w = _Wired(monkeypatch, machine=_machine(), settlement=None)
out = asyncio.run(handle_report_dispense(None, _req(_confirmed())))
assert out["settlement_status"] is None
assert w.inserted[0][1] is None
assert w.spawned == []
def test_remediation_moves_the_owed_settlement_to_pending(self, monkeypatch):
owed = _settlement(status="cash_owed")
w = _Wired(monkeypatch, machine=_machine(), settlement=owed)
body = _confirmed()
body.update(txid="manual-1", remediates_txid=_TXID, at=1791530000)
out = asyncio.run(handle_report_dispense(None, _req(body)))
assert out["settlement_status"] == "pending"
assert w.statuses == [("s1", "pending")]
assert w.spawned == ["s1"]
assert w.applied == [] # the ORIGINAL report's columns stay on the settlement
def test_remediation_that_did_not_confirm_leaves_it_owed(self, monkeypatch):
w = _Wired(
monkeypatch, machine=_machine(), settlement=_settlement(status="cash_owed")
)
body = _report()
body.update(txid="manual-2", remediates_txid=_TXID, at=1791530001)
out = asyncio.run(handle_report_dispense(None, _req(body)))
assert out["settlement_status"] == "cash_owed"
assert w.statuses == [] and w.spawned == []
def test_unpaired_sender_and_bad_body_are_refused(self, monkeypatch):
_Wired(monkeypatch, machine=None, settlement=None)
with pytest.raises(ValueError, match="not a paired machine"):
asyncio.run(handle_report_dispense(None, _req(_report())))
_Wired(monkeypatch, machine=_machine(), settlement=_settlement())
with pytest.raises(ValueError, match="invalid report_dispense body"):
asyncio.run(handle_report_dispense(None, _req({"txid": _TXID})))
with pytest.raises(ValueError, match="sender_pubkey"):
asyncio.run(handle_report_dispense(None, _req(_report(), sender="")))
# ---------------------------------------------------------------------------
# Gate in _handle_payment
# ---------------------------------------------------------------------------
def _payment(is_in=True):
return SimpleNamespace(
success=True,
wallet_id="w1",
extra={
"source": "bitspire",
"type": "cash_out" if is_in else "cash_in",
"txid": _TXID,
},
is_in=is_in,
sat=54440 if is_in else -54440,
payment_hash=_HASH,
)
def _data(tx_type) -> CreateDcaSettlementData:
return CreateDcaSettlementData(
machine_id="m1",
payment_hash=_HASH,
bitspire_txid=_TXID,
wire_sats=54440,
fiat_amount=40.0,
fiat_code="EUR",
exchange_rate=1361.0,
principal_sats=54440,
fee_sats=0,
platform_fee_sats=0,
operator_fee_sats=0,
tx_type=tx_type,
)
class _GateWired:
def __init__(self, monkeypatch, *, tx_type, early_report=None):
self.created = []
self.spawned = []
self.adopted = []
async def get_machine(_wid):
return _machine()
def attribution(_machine, _extra):
return None
async def get_super():
return SimpleNamespace(id="default")
def parse(**_kw):
return _data(tx_type)
async def create(data, initial_status, error_message=None):
self.created.append((data.tx_type, initial_status))
return _settlement(status=initial_status, tx_type=data.tx_type)
async def process(sid):
self.spawned.append(sid)
async def early(_mid, _txid):
return early_report
async def adopt(settlement, machine, row):
self.adopted.append((settlement.id, row.id))
return "pending"
monkeypatch.setattr(tasks, "get_active_machine_by_wallet_id", get_machine)
monkeypatch.setattr(tasks, "assert_nostr_attribution", attribution)
monkeypatch.setattr(tasks, "get_super_config", get_super)
monkeypatch.setattr(tasks, "parse_settlement", parse)
monkeypatch.setattr(tasks, "create_settlement_idempotent", create)
monkeypatch.setattr(tasks, "process_settlement", process)
monkeypatch.setattr(crud_mod, "get_latest_unlinked_dispense_report", early)
monkeypatch.setattr(dispense_transport, "adopt_unlinked_report", adopt)
async def _drain():
# let any create_task'd distribution run
await asyncio.sleep(0)
class TestPaymentGate:
def test_cash_out_lands_awaiting_dispense_and_does_not_distribute(
self, monkeypatch
):
w = _GateWired(monkeypatch, tx_type="cash_out")
async def run():
await tasks._handle_payment(_payment(is_in=True))
await _drain()
asyncio.run(run())
assert w.created == [("cash_out", "awaiting_dispense")]
assert w.spawned == []
assert w.adopted == []
def test_cash_in_is_unchanged(self, monkeypatch):
w = _GateWired(monkeypatch, tx_type="cash_in")
async def run():
await tasks._handle_payment(_payment(is_in=False))
await _drain()
asyncio.run(run())
assert w.created == [("cash_in", "pending")]
assert w.spawned == ["s1"]
def test_early_report_is_adopted_when_the_payment_lands(self, monkeypatch):
early = SimpleNamespace(id="rep-early", txid=_TXID)
w = _GateWired(monkeypatch, tx_type="cash_out", early_report=early)
async def run():
await tasks._handle_payment(_payment(is_in=True))
await _drain()
asyncio.run(run())
assert w.adopted == [("s1", "rep-early")]
assert w.spawned == [] # adoption decides; the fake adopt did not spawn
# ---------------------------------------------------------------------------
# Consumer mirror
# ---------------------------------------------------------------------------
class TestCashOutHoldMirror:
def test_sets_and_clears_from_the_state_document(self):
calls = []
async def setter(mid, since, reason, code):
calls.append((mid, since, reason, code))
held = PublishCassettesPayload(
positions={"2": {"denomination": 20, "count": 54}},
cash_out_held_since=1791529353,
cash_out_held_reason="Note stopped at the cassette exit",
cash_out_held_code="78 42",
)
clear = PublishCassettesPayload(
positions={"2": {"denomination": 20, "count": 54}}
)
asyncio.run(tasks._record_cash_out_hold("m1", held, setter))
asyncio.run(tasks._record_cash_out_hold("m1", clear, setter))
assert calls[0][0] == "m1"
assert calls[0][1] == datetime.fromtimestamp(1791529353, tz=timezone.utc)
assert calls[0][2:] == ("Note stopped at the cassette exit", "78 42")
assert calls[1] == ("m1", None, None, None)

424
tests/test_slice2.py Normal file
View file

@ -0,0 +1,424 @@
"""
ADR-005 slice 2: operator alerts, off-machine settlement, the
settle_transaction op, alerts_pubkey.
- NIP-17 builder: a gift wrap (kind 1059) signed by a throwaway key, p-tagged
to the recipient, backdated; its content opens with the recipient's key
to a seal (kind 13) signed by the sender, which opens to a kind-14 rumor
carrying the text. Built with a fake LocalSigner-style signer so no bunker
is needed.
- notify_cash_outcome is best-effort: publishes once, marks notified, never
raises, and does not re-alert a settlement already notified.
- apply_report_to_settlement alerts on cash_owed / partial_pending and not on
a confirmed dispense.
- settle_cash_owed_settlement: note appended, status → pending, distribution
run; refuses other statuses.
- models: settle_transaction op (machine-wide, carries txid + note, wire
shape), alerts_pubkey validator (hex / npub / clear).
"""
import asyncio
import json
from datetime import datetime
from types import SimpleNamespace
import coincurve
import pytest
from lnbits.utils.nostr import verify_event
from pydantic import ValidationError
from .. import dispense_transport, distribution, notify
from ..models import (
CassetteOp,
CreateCassetteOpData,
DcaSettlement,
DispenseReportIn,
Machine,
UpdateSuperConfigData,
)
from ..nip44 import decrypt_from, encrypt_for
_NOW = datetime(2026, 10, 9, 7, 2, 33)
_OP_SEC = "00" * 31 + "01"
_ALERT_SEC = "00" * 31 + "03"
def _pub(sec_hex: str) -> str:
return (
coincurve.PrivateKey(bytes.fromhex(sec_hex)).public_key.format(True)[1:].hex()
)
_OP_PUB = _pub(_OP_SEC)
_ALERT_PUB = _pub(_ALERT_SEC)
def _machine() -> Machine:
return Machine(
id="m1",
operator_user_id="op1",
machine_npub="df20" * 16,
wallet_id="w1",
name="sintra",
location=None,
fiat_code="EUR",
is_active=True,
created_at=_NOW,
updated_at=_NOW,
)
def _settlement(status="cash_owed", notified=None) -> DcaSettlement:
return DcaSettlement(
id="s1",
machine_id="m1",
payment_hash="ab" * 32,
bitspire_event_id=None,
bitspire_txid="tx_mv0madw6_wdhtea1v",
wire_sats=54440,
fiat_amount=40.0,
fiat_code="EUR",
exchange_rate=1361.0,
principal_sats=54440,
fee_sats=0,
platform_fee_sats=0,
operator_fee_sats=0,
tx_type="cash_out",
bills_json=None,
cassettes_json=None,
status=status,
error_message=None,
processed_at=None,
created_at=_NOW,
operator_notified_at=notified,
)
def _report(**over) -> DispenseReportIn:
body: dict = {
"txid": "tx_mv0madw6_wdhtea1v",
"payment_hash": "ab" * 32,
"dispense_confirmed": False,
"error": "Note stopped at the cassette exit",
"error_code": "F56DispenseError",
"raw_code": "78 42",
"error_class": "terminal",
"fiat_cents": 4000,
"currency": "EUR",
"bills": [{"denomination": 20, "requested": 2, "dispensed": 0, "rejected": 0}],
"at": 1791529353,
}
body.update(over)
return DispenseReportIn(**body)
# ---------------------------------------------------------------------------
# A signer that behaves like a LocalSigner holding _OP_SEC, without lnbits' DB
# ---------------------------------------------------------------------------
class _FakeSigner:
def can_sign(self):
return True
async def nip44_encrypt(self, plaintext, peer):
return encrypt_for(plaintext, _OP_SEC, peer)
async def sign_event(self, event):
from lnbits.utils.nostr import sign_event
return sign_event(event, _OP_PUB, coincurve.PrivateKey(bytes.fromhex(_OP_SEC)))
def _wire_signer(monkeypatch):
account = SimpleNamespace(pubkey=_OP_PUB, signer_type="LocalSigner", prvkey=_OP_SEC)
async def resolve(_uid):
return account, _FakeSigner()
async def sign_as_operator(_uid, event):
import time
event["created_at"] = int(time.time())
return await _FakeSigner().sign_event(event)
monkeypatch.setattr(notify, "resolve_operator_signer", resolve)
monkeypatch.setattr(notify, "sign_as_operator", sign_as_operator)
return account
# ---------------------------------------------------------------------------
# NIP-17 builder
# ---------------------------------------------------------------------------
class TestGiftWrap:
def test_wrap_seal_rumor_round_trip_to_the_recipient(self, monkeypatch):
_wire_signer(monkeypatch)
wrap = asyncio.run(
notify.build_gift_wrapped_dm("op1", _ALERT_PUB, "hello operator")
)
assert wrap["kind"] == 1059
assert wrap["tags"] == [["p", _ALERT_PUB]]
assert wrap["pubkey"] != _OP_PUB # throwaway key, not the sender
assert verify_event(wrap)
import time
assert wrap["created_at"] <= int(time.time())
assert wrap["created_at"] >= int(time.time()) - 2 * 86400 - 5
seal = json.loads(decrypt_from(wrap["content"], _ALERT_SEC, wrap["pubkey"]))
assert seal["kind"] == 13 and seal["pubkey"] == _OP_PUB and seal["tags"] == []
assert verify_event(seal)
rumor = json.loads(decrypt_from(seal["content"], _ALERT_SEC, _OP_PUB))
assert rumor["kind"] == 14
assert rumor["pubkey"] == _OP_PUB
assert rumor["content"] == "hello operator"
assert rumor["tags"] == [["p", _ALERT_PUB]]
assert "sig" not in rumor and len(rumor["id"]) == 64
def test_note_to_self_when_recipient_is_the_sender(self, monkeypatch):
_wire_signer(monkeypatch)
wrap = asyncio.run(notify.build_gift_wrapped_dm("op1", _OP_PUB, "to me"))
seal = json.loads(decrypt_from(wrap["content"], _OP_SEC, wrap["pubkey"]))
rumor = json.loads(decrypt_from(seal["content"], _OP_SEC, _OP_PUB))
assert rumor["content"] == "to me"
class TestNotifyCashOutcome:
def _wire(self, monkeypatch, *, alerts_pubkey=None, publish_fails=False):
_wire_signer(monkeypatch)
published, marked = [], []
async def get_super():
return SimpleNamespace(alerts_pubkey=alerts_pubkey)
async def publish(ev):
if publish_fails:
from ..nostr_publish import RelayUnavailable
raise RelayUnavailable("no relay")
published.append(ev)
async def mark(sid):
marked.append(sid)
monkeypatch.setattr(notify, "get_super_config", get_super)
monkeypatch.setattr(notify, "publish_signed_event", publish)
monkeypatch.setattr(notify, "mark_settlement_notified", mark)
return published, marked
def test_alerts_once_to_the_operator_by_default(self, monkeypatch):
published, marked = self._wire(monkeypatch)
ok = asyncio.run(
notify.notify_cash_outcome(
_settlement(), _machine(), _report(), "cash_owed"
)
)
assert ok is True
assert marked == ["s1"]
assert published[0]["tags"] == [["p", _OP_PUB]]
seal = json.loads(
decrypt_from(published[0]["content"], _OP_SEC, published[0]["pubkey"])
)
rumor = json.loads(decrypt_from(seal["content"], _OP_SEC, _OP_PUB))
assert "CASH OWED" in rumor["content"]
assert "sintra" in rumor["content"]
assert "40.00 EUR" in rumor["content"]
assert "tx_mv0madw6_wdhtea1v" in rumor["content"]
assert "78 42" not in rumor["content"] # raw code stays in the dashboard
assert "out of service" in rumor["content"] # terminal → latch mentioned
def test_alerts_pubkey_override_and_partial_wording(self, monkeypatch):
published, _ = self._wire(monkeypatch, alerts_pubkey=_ALERT_PUB)
rep = _report(
bills=[{"denomination": 20, "requested": 2, "dispensed": 1, "rejected": 1}]
)
asyncio.run(
notify.notify_cash_outcome(
_settlement("partial_pending"), _machine(), rep, "partial_pending"
)
)
assert published[0]["tags"] == [["p", _ALERT_PUB]]
seal = json.loads(
decrypt_from(published[0]["content"], _ALERT_SEC, published[0]["pubkey"])
)
rumor = json.loads(decrypt_from(seal["content"], _ALERT_SEC, _OP_PUB))
assert (
"PARTIAL" in rumor["content"] and "received 20.00 EUR" in rumor["content"]
)
def test_does_not_re_alert_and_never_raises(self, monkeypatch):
published, marked = self._wire(monkeypatch)
ok = asyncio.run(
notify.notify_cash_outcome(
_settlement(notified=_NOW), _machine(), _report(), "cash_owed"
)
)
assert ok is False and published == [] and marked == []
published, marked = self._wire(monkeypatch, publish_fails=True)
ok = asyncio.run(
notify.notify_cash_outcome(
_settlement(), _machine(), _report(), "cash_owed"
)
)
assert ok is False and marked == []
class TestCaptureAlerts:
def _wire(self, monkeypatch, status_after):
alerts = []
async def apply(sid, report, new_status, reported_at):
return _settlement(new_status)
async def alert(settlement, machine, report, status):
alerts.append(status)
monkeypatch.setattr(dispense_transport, "apply_dispense_outcome", apply)
monkeypatch.setattr(dispense_transport, "_spawn_distribution", lambda sid: None)
monkeypatch.setattr(notify, "notify_cash_outcome", alert)
return alerts
def test_alerts_on_owed_and_partial_not_on_confirmed(self, monkeypatch):
alerts = self._wire(monkeypatch, "cash_owed")
asyncio.run(
dispense_transport.apply_report_to_settlement(
_settlement("awaiting_dispense"), _report(), _machine()
)
)
rep_partial = _report(
bills=[{"denomination": 20, "requested": 2, "dispensed": 1, "rejected": 1}]
)
asyncio.run(
dispense_transport.apply_report_to_settlement(
_settlement("awaiting_dispense"), rep_partial, _machine()
)
)
rep_ok = _report(
dispense_confirmed=True,
error=None,
error_code=None,
raw_code=None,
error_class=None,
bills=[{"denomination": 20, "requested": 2, "dispensed": 2, "rejected": 0}],
)
asyncio.run(
dispense_transport.apply_report_to_settlement(
_settlement("awaiting_dispense"), rep_ok, _machine()
)
)
assert alerts == ["cash_owed", "partial_pending"]
# ---------------------------------------------------------------------------
# Off-machine settlement
# ---------------------------------------------------------------------------
class TestSettleCashOwed:
def _wire(self, monkeypatch):
notes, statuses, processed = [], [], []
async def note(sid, text, author):
notes.append((sid, text, author))
return _settlement()
async def status(sid, st, msg):
statuses.append((sid, st))
return None
async def process(sid):
processed.append(sid)
async def get(sid):
return _settlement("processed")
import importlib
crud = importlib.import_module("..crud", __package__)
monkeypatch.setattr(crud, "append_settlement_note", note)
monkeypatch.setattr(distribution, "mark_settlement_status", status)
monkeypatch.setattr(distribution, "process_settlement", process)
monkeypatch.setattr(distribution, "get_settlement", get)
return notes, statuses, processed
def test_closes_at_full_amount(self, monkeypatch):
notes, statuses, processed = self._wire(monkeypatch)
after = asyncio.run(
distribution.settle_cash_owed_settlement(
_settlement(), "handed 40 EUR by hand", "op1"
)
)
assert after.status == "processed"
assert notes[0][1].startswith(
"Settled off-machine (full amount): handed 40 EUR"
)
assert statuses == [("s1", "pending")]
assert processed == ["s1"]
def test_refuses_other_statuses(self, monkeypatch):
self._wire(monkeypatch)
with pytest.raises(ValueError):
asyncio.run(
distribution.settle_cash_owed_settlement(
_settlement("processed"), "x", "op1"
)
)
# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------
class TestSettleOpAndAlertsPubkey:
def test_settle_transaction_is_machine_wide_and_carries_txid_note(self):
op = CassetteOp(
id="st1",
machine_id="m1",
position=0,
op_type="settle_transaction",
txid="tx_mv0madw6_wdhtea1v",
note="paid by hand",
created_at=_NOW,
)
assert op.to_wire_dict() == {
"id": "st1",
"at": int(_NOW.timestamp()),
"type": "settle_transaction",
"txid": "tx_mv0madw6_wdhtea1v",
"note": "paid by hand",
}
CreateCassetteOpData(
position=0, op_type="settle_transaction", txid="tx_1", note="n"
)
with pytest.raises(ValidationError):
CreateCassetteOpData(
position=0, op_type="settle_transaction"
) # txid required
with pytest.raises(ValidationError):
CreateCassetteOpData(position=2, op_type="settle_transaction", txid="tx_1")
with pytest.raises(ValidationError):
CreateCassetteOpData(position=1, op_type="refill", bills=3, txid="tx_1")
with pytest.raises(ValidationError):
CreateCassetteOpData(position=1, op_type="refill", bills=3, note="no")
def test_alerts_pubkey_accepts_hex_npub_and_clear(self):
assert (
UpdateSuperConfigData(alerts_pubkey=_ALERT_PUB.upper()).alerts_pubkey
== _ALERT_PUB
)
from lnbits.utils.nostr import hex_to_npub
assert (
UpdateSuperConfigData(alerts_pubkey=hex_to_npub(_ALERT_PUB)).alerts_pubkey
== _ALERT_PUB
)
assert UpdateSuperConfigData(alerts_pubkey="").alerts_pubkey == ""
assert UpdateSuperConfigData().alerts_pubkey is None
with pytest.raises(ValidationError):
UpdateSuperConfigData(alerts_pubkey="not-a-key")

View file

@ -19,6 +19,7 @@ from lnbits.core.services.nsec_bunker import (
)
from lnbits.decorators import check_super_user, check_user_exists
from lnbits.utils.nostr import normalize_public_key
from loguru import logger
from .calculations import MAX_FEE_FRACTION_PER_DIRECTION
from .cassette_transport import (
@ -26,20 +27,12 @@ from .cassette_transport import (
OperatorIdentityMissing,
RelayUnavailable,
SignerUnavailable,
publish_to_atm,
)
from .fee_transport import publish_fee_config
from .pairing import (
PairResult,
PairingError,
RevokeResult,
default_relay_endpoint,
pair_spire,
revoke_spire,
publish_ops_to_atm,
)
from .crud import (
append_settlement_note,
count_completed_legs_for_settlement,
create_cassette_op,
create_dca_client,
create_deposit,
create_machine,
@ -47,6 +40,7 @@ from .crud import (
delete_deposit,
delete_machine,
force_reset_stuck_settlement,
get_cassette_ops_window,
get_client_balance_summary,
get_commission_splits,
get_dca_client,
@ -66,12 +60,12 @@ from .crud import (
get_super_config,
list_all_active_machines,
list_cassette_configs_for_machine,
list_cassette_ops,
lp_is_onboarded,
replace_commission_splits,
reset_settlement_for_retry,
set_machine_pairing,
set_machine_unpaired,
update_cassette_config,
update_dca_client,
update_deposit,
update_deposit_status,
@ -80,14 +74,18 @@ from .crud import (
)
from .distribution import (
apply_partial_dispense_and_redistribute,
settle_cash_owed_settlement,
process_settlement,
settle_lp_balance,
)
from .fee_transport import publish_fee_config
from .models import (
AppendSettlementNoteData,
CassetteConfig,
CassetteOp,
ClientBalanceSummary,
CommissionSplit,
CreateCassetteOpData,
CreateDcaClientData,
CreateDepositData,
CreateMachineData,
@ -98,7 +96,7 @@ from .models import (
Machine,
PairMachineData,
PartialDispenseData,
PublishCassettesPayload,
SettleCashOwedData,
SetCommissionSplitsData,
SettleBalanceData,
StuckSettlementsResponse,
@ -108,7 +106,14 @@ from .models import (
UpdateDepositStatusData,
UpdateMachineData,
UpdateSuperConfigData,
UpsertCassetteConfigData,
)
from .pairing import (
PairingError,
PairResult,
RevokeResult,
default_relay_endpoint,
pair_spire,
revoke_spire,
)
spirekeeper_api_router = APIRouter()
@ -766,7 +771,13 @@ async def api_list_stuck_settlements(
) -> StuckSettlementsResponse:
"""Operator worklist of settlements that didn't process cleanly.
Returns four lists:
Returns seven lists. The first three (ADR-005 §6) mean a customer is owed
money and render first:
- cash_owed: the machine reported nothing dispensed; nothing moved
- partial_pending: some notes out, value short; held until resolved
- dispense_unreported: cash-out landed, machine never reported within
the threshold
Then:
- rejected: Nostr attribution cross-check failed — signer didn't
match the machine identity. Investigate; do not retry.
- errored: distribution ran and failed; retry endpoint handles these
@ -781,6 +792,9 @@ async def api_list_stuck_settlements(
buckets = await get_stuck_settlements_for_operator(user.id, threshold_minutes)
return StuckSettlementsResponse(
threshold_minutes=threshold_minutes,
cash_owed=buckets["cash_owed"],
partial_pending=buckets["partial_pending"],
dispense_unreported=buckets["dispense_unreported"],
rejected=buckets["rejected"],
errored=buckets["errored"],
stuck_pending=buckets["stuck_pending"],
@ -803,6 +817,67 @@ async def api_get_settlement(
return settlement
@spirekeeper_api_router.post(
"/api/v1/dca/settlements/{settlement_id}/settle-cash-owed",
response_model=DcaSettlement,
)
async def api_settle_cash_owed(
settlement_id: str,
data: SettleCashOwedData,
user: User = Depends(check_user_exists),
) -> DcaSettlement:
"""ADR-005 §6 — the operator paid the customer outside the machine.
For a settlement in cash_owed or partial_pending: appends the provenance
note, moves it to pending and distributes at the FULL amount (the customer
is whole, so the sale is the full sale), and publishes a `settle_transaction`
op so the machine flips its own `dispense_error` / `partial` row to
`remediated`. Both ledgers close on one act; nothing is dispensed.
Until now the only way to clear an owed-cash row was to dispense more cash
from the machine that had just jammed.
Errors: 404 not yours; 409 wrong status. The machine op is best-effort —
recorded, and delivered with the next publish if the relay is down.
"""
settlement = await get_settlement(settlement_id)
if settlement is None:
raise HTTPException(HTTPStatus.NOT_FOUND, "Settlement not found")
machine = await get_machine(settlement.machine_id)
if machine is None or machine.operator_user_id != user.id:
raise HTTPException(HTTPStatus.NOT_FOUND, "Settlement not found")
if settlement.status not in ("cash_owed", "partial_pending"):
raise HTTPException(
HTTPStatus.CONFLICT,
f"settlement is {settlement.status!r}; only cash_owed / partial_pending "
"can be settled off-machine",
)
updated = await settle_cash_owed_settlement(settlement, data.note, user.id)
if machine.machine_npub and settlement.bitspire_txid:
try:
await create_cassette_op(
machine.id,
CreateCassetteOpData(
position=0,
op_type="settle_transaction",
txid=settlement.bitspire_txid,
note=data.note,
),
created_by=user.id,
)
window = await get_cassette_ops_window(machine.id)
await publish_ops_to_atm(machine, window, user.id)
except (OperatorIdentityMissing, SignerUnavailable, RelayUnavailable) as exc:
logger.warning(
f"spirekeeper: settle_transaction op for {settlement.bitspire_txid} "
f"recorded but not published yet: {exc}"
)
except CassetteTransportError as exc:
logger.error(f"spirekeeper: settle_transaction publish failed: {exc}")
return updated
@spirekeeper_api_router.post(
"/api/v1/dca/settlements/{settlement_id}/partial-dispense",
response_model=DcaSettlement,
@ -1102,16 +1177,21 @@ async def api_update_super_config(
# =============================================================================
# Cassette configs (#29 v1.1) — per-machine ATM cassette inventory
# Cassettes — per-machine ATM inventory (bitspire ADR-004)
# =============================================================================
# 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
# GET /machines/{id}/cassettes — bays as the machine last reported them
# GET /machines/{id}/cassettes/ops — recent operations, with their acks
# POST /machines/{id}/cassettes/ops — record one operation and publish
#
# 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.
# The operator does not write counts. It records what it DID to a bay and the
# machine, which holds the notes, keeps the running total. The rows behind the
# first endpoint are the machine's report, not an operator draft.
#
# This replaced an edit-and-publish form over absolute counts. Both sides wrote
# the same value across a transport that never tells a writer it lost, so a
# form loaded before a dispense discarded that dispense when published — and
# nothing could detect it afterwards. Bay count stays hardware-determined:
# rows appear and disappear only as the machine reports them.
@spirekeeper_api_router.get(
@ -1129,105 +1209,150 @@ async def api_list_machine_cassettes(
return await list_cassette_configs_for_machine(machine_id)
@spirekeeper_api_router.post(
"/api/v1/dca/machines/{machine_id}/cassettes/publish",
response_model=list[CassetteConfig],
@spirekeeper_api_router.get(
"/api/v1/dca/machines/{machine_id}/cassettes/ops",
response_model=list[CassetteOp],
)
async def api_publish_machine_cassettes(
async def api_list_machine_cassette_ops(
machine_id: str,
payload: PublishCassettesPayload,
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>.
) -> list[CassetteOp]:
"""Recent cassette operations for a machine, newest first.
The `<m>` placeholder in the published d-tag is the ATM's hex pubkey
from machine.machine_npub (canonicalised via normalize_public_key),
NOT the internal dca_machines.id UUID — see #29 'machine_id semantics'
section and coord-log 2026-05-30T11:50Z load-bearing nudge.
Each carries acked_at: null until the machine has reported that id back in
its state document, which is how the dashboard distinguishes an operation
that has been delivered from one that has merely been sent.
"""
await _machine_owned_by(machine_id, user.id)
return await list_cassette_ops(machine_id)
Returns the fresh cassette_configs rows after the upserts so the UI
can refresh its table from one round-trip.
@spirekeeper_api_router.post(
"/api/v1/dca/machines/{machine_id}/cassettes/ops",
response_model=CassetteOp,
)
async def api_create_machine_cassette_op(
machine_id: str,
data: CreateCassetteOpData,
user: User = Depends(check_user_exists),
) -> CassetteOp:
"""Record one cassette operation and publish the machine's recent window.
This replaces publishing absolute counts. The operator now records what it
DID — a refill, an empty, a recount, a denomination change — and the
machine keeps the running total. Both sides used to write the same value
over a transport that never tells a writer it lost, so a dashboard form
loaded before a dispense silently discarded that dispense when published.
The op is recorded BEFORE the publish and is deliberately not rolled back
if the publish fails. It represents something that physically happened —
notes went into a bay — and that stays true whether or not a relay was
reachable. Because each publish carries a window of recent operations
rather than just the newest, an op that missed its own publish rides out
with the next one.
Errors:
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
not installed on this LNbits instance
500 — anything else from the publish path
400 — machine not paired, so there is no ATM identity to publish to
400 — position is not a bay this machine has reported
503 — signer offline, or relay/nostrclient unreachable. The operation is
still recorded and will be delivered with the next publish.
"""
machine = await _machine_owned_by(machine_id, user.id)
if not machine.machine_npub:
# Unpaired machine (machine_npub None — nullable since #29/m011) has no
# ATM identity to publish a cassette config to. Fail fast with a clean
# 400 instead of crashing publish_to_atm's normalize_public_key(None).
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"machine is not paired — pair it before publishing cassette config",
"machine is not paired — pair it before recording cassette operations",
)
existing = await list_cassette_configs_for_machine(machine_id)
existing_positions = {row.position for row in existing}
incoming_positions = set(payload.positions.keys())
if not existing:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
(
"No cassette_configs rows exist for this machine yet — "
"waiting for the ATM's bootstrap state event. Power on the "
"ATM and confirm it has reached the configured relay; "
"spirekeeper will auto-populate cassette_configs on "
"receipt."
"No cassette rows for this machine yet — waiting for its state "
"event. Power on the ATM and confirm it has reached the "
"configured relay; spirekeeper populates the bays on receipt."
),
)
if existing_positions != incoming_positions:
missing = existing_positions - incoming_positions
extra = incoming_positions - existing_positions
known_positions = {row.position for row in existing}
if data.position not in known_positions:
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."
f"position {data.position} is not a bay this machine has "
f"reported (has: {sorted(known_positions)}). Bay count is "
"hardware-determined; re-provision via atm-tui to change it."
),
)
# Apply each per-row edit so the operator-believed state on
# spirekeeper 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():
updated = await update_cassette_config(
machine_id,
pos,
UpsertCassetteConfigData(denomination=row.denomination, count=row.count),
updated_by=user.id,
)
if updated is None:
# Defensive — we just validated the row exists, but a
# concurrent delete could land between. Surface as 500.
raise HTTPException(
HTTPStatus.INTERNAL_SERVER_ERROR,
f"cassette row for position {pos} disappeared mid-publish",
)
op = await create_cassette_op(machine_id, data, created_by=user.id)
window = await get_cassette_ops_window(machine_id)
try:
await publish_to_atm(machine, payload, user.id)
await publish_ops_to_atm(machine, window, user.id)
except OperatorIdentityMissing as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
except SignerUnavailable as exc:
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, str(exc)) from exc
except RelayUnavailable as exc:
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, str(exc)) from exc
except (SignerUnavailable, RelayUnavailable) as exc:
raise HTTPException(
HTTPStatus.SERVICE_UNAVAILABLE,
f"{exc} — the operation was recorded and will be delivered with "
"the next publish",
) from exc
except CassetteTransportError as exc:
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc)) from exc
return await list_cassette_configs_for_machine(machine_id)
return op
@spirekeeper_api_router.post(
"/api/v1/dca/machines/{machine_id}/resume-cash-out",
response_model=CassetteOp,
)
async def api_resume_cash_out(
machine_id: str,
user: User = Depends(check_user_exists),
) -> CassetteOp:
"""Release a machine's cash-out hold (bitspire ADR-005 §5).
After a terminal dispenser fault the machine refuses cash-out until an
operator has been to it. A `recount` releases the hold as a side effect;
this is for the case where the jam was cleared without touching a bay
count. Recorded as a machine-wide op (position 0) and published on the
same operator channel as the cassette ops — the machine honours it only if
it is stamped after the hold began, so a re-delivered old resume cannot
clear a newer fault.
Errors mirror the cassette-op endpoint: 400 unpaired, 503 signer/relay
unavailable (the op is recorded and rides out with the next publish).
"""
machine = await _machine_owned_by(machine_id, user.id)
if not machine.machine_npub:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"machine is not paired — there is no ATM identity to publish to",
)
if machine.cash_out_held_since is None:
logger.info(
f"spirekeeper: resume_cash_out for machine {machine_id} with no hold "
"on file — publishing anyway (the machine is the authority)"
)
op = await create_cassette_op(
machine_id,
CreateCassetteOpData(position=0, op_type="resume_cash_out"),
created_by=user.id,
)
window = await get_cassette_ops_window(machine_id)
try:
await publish_ops_to_atm(machine, window, user.id)
except OperatorIdentityMissing as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
except (SignerUnavailable, RelayUnavailable) as exc:
raise HTTPException(
HTTPStatus.SERVICE_UNAVAILABLE,
f"{exc} — the resume was recorded and will be delivered with the "
"next publish",
) from exc
except CassetteTransportError as exc:
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc)) from exc
return op