Commit graph

317 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
v0.1.8
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
v0.1.7
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
v0.1.6
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
30870ebd16 Merge pull request 'fix(cassettes): order ATM state events, and reconcile the bay set' (#44) from fix/cassette-state-reconcile into main
Some checks failed
ci.yml / Merge pull request 'fix(cassettes): order ATM state events, and reconcile the bay set' (#44) from fix/cassette-state-reconcile into main (push) Failing after 0s
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
v0.1.5
Reviewed-on: #44
2026-09-22 20:30:44 +00:00
106da5b46b fix(cassettes): drop bays the machine no longer reports
Some checks failed
ci.yml / fix(cassettes): drop bays the machine no longer reports (pull_request) Failing after 0s
apply_reported_state upserted the positions in the payload and left every
other row untouched. When a machine's bay count shrank, the stale row
stayed: the dashboard showed a mix of old and new bays, and publish
validation then rejected every operator payload for a position-set
mismatch. The error text told the operator to fix it with atm-tui, which
could not propagate either, so the only way out was DELETE FROM by hand.

The payload is the machine's full bay set and the machine owns that layout,
so positions absent from it are now deleted. The delete runs before the
upserts: since this data layer commits per statement, a crash between the
two leaves rows missing rather than stale, and the next heartbeat
re-inserts them — the safe direction of the two.

Refs #43

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 22:23:40 +02:00
27449e1d11 fix(cassettes): order state events by created_at, not by one remembered id
The gate on the ATM-state consumer compared the incoming event id against
the id stored on a single arbitrary row (SELECT ... LIMIT 1, no ORDER BY).
That is a one-event memory, not a watermark: a re-delivered A, B, A applied
three times. Worse, created_at was parsed, written to state_at and then
never compared, so an event arriving late overwrote newer state — nothing
in the path ever looked at the clock.

Events are now applied only when strictly newer than the OLDEST state stamp
on file. Strict '>' subsumes replay dedup, since a replay carries the same
stamp. Oldest rather than newest is deliberate: every execute in this data
layer commits on its own, so a multi-row apply cannot be made atomic here,
and gating on the oldest means a crash mid-apply is re-applied on the next
event instead of being mistaken for a complete one. The ATM republishes on
a heartbeat, so it converges.

Stamps are compared as unix floats because SQLite returns integers,
Postgres returns timestamps and the incoming value is tz-aware; comparing
raw would either raise or quietly mislead. An unparseable incoming stamp
fails closed.

Also renames apply_bootstrap_state to apply_reported_state and corrects the
module comments. There has never been a once-per-machine guard, so calling
it a one-shot bootstrap consumer described something the code did not do.

Refs #43

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 22:23:19 +02:00
08eea1ba16 Merge pull request 'feat(transport): get_machine_config RPC — server-delivered machine config (#41)' (#42) from feat/get-machine-config-rpc into main
Some checks failed
ci.yml / Merge pull request 'feat(transport): get_machine_config RPC — server-delivered machine config (#41)' (#42) from feat/get-machine-config-rpc into main (push) Failing after 0s
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
v0.1.4
Reviewed-on: #42
2026-07-02 21:52:49 +00:00
1f5652b425 feat(transport): get_machine_config RPC — server-delivered machine config (#41)
Some checks failed
ci.yml / feat(transport): get_machine_config RPC — server-delivered machine config (#41) (pull_request) Failing after 0s
A paired ATM pulls its operator pubkey + fee config over the already-
authenticated kind-21000 transport, instead of the operator pubkey being
provisioned into the machine's .env and the fee config learned only from an
operator-signed kind-30078 broadcast. This lets a seed-only machine (blank
.env, scanned spire-seed) leave "awaiting configuration" with zero per-machine
provisioning — closing the gap surfaced by bitspire#70. Client half: bitspire#71.

The transport is already per-machine authenticated (the ATM signs with its
bunker-minted spire key == dca_machines.machine_npub), so the handler resolves
the exact machine → operator from the verified request.sender_pubkey — same
lookup as cashin_transport, no client-supplied trust — and returns only the
caller's own config. Returns {operator_pubkey, fee_config, fiat_code,
machine_npub, wallet_id, created_at}; fee_config is None until the operator has
a super-config. Reuses build_fee_payload + get_account; AUTH_ACCOUNT; registers
soft-failing (older lnbits) like the roster hook. kind-30078 push stays
dual-run for live mid-run updates.

6 tests; full suite 235 pass; black + ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:54:40 +02:00
eefadb6c20 Merge pull request 'fix(ui): make admin dialogs mobile-friendly + add fullscreen QR zoom' (#40) from fix/mobile-friendly-dialogs-and-QR-zoom into main
Some checks failed
ci.yml / Merge pull request 'fix(ui): make admin dialogs mobile-friendly + add fullscreen QR zoom' (#40) from fix/mobile-friendly-dialogs-and-QR-zoom into main (push) Failing after 0s
Reviewed-on: #40
2026-07-02 18:56:04 +00:00
282d8e75c5 fix(ui): make the zoom QR actually fill the screen
Some checks failed
ci.yml / fix(ui): make the zoom QR actually fill the screen (pull_request) Failing after 0s
lnbits-qrcode has no `options` prop — it renders width:100% of its
parent, capped by a `max-width` PROP that defaults to 450px. So the
previous `:options="{width: qrZoom.size}"` was dead: the QR only ever
grew to its container, and inside the zoom dialog's items-center flex
column the container collapsed to the QR's natural width, so "enlarge"
barely changed anything.

Wrap the zoom QR in an explicit full-width div (capped 96vw), feed the
real `max-width` prop, hide the component's own copy/download buttons
for a clean scan target, and size it to ~full shorter-viewport-edge.
Also swap the inline pair-dialog QR's dead `:options` for `max-width`.
2026-07-02 19:58:05 +02:00
015208500a fix(ui): make spirekeeper dialogs mobile-friendly + add fullscreen QR zoom
Some checks failed
ci.yml / fix(ui): make spirekeeper dialogs mobile-friendly + add fullscreen QR zoom (pull_request) Failing after 0s
The `min-width: 480px` on dialog cards overrode `max-width: 95vw` on
narrow viewports (CSS min-width wins on conflict), so add-machine / pair
/ etc. dialogs overflowed off-screen on phones. Switch every dialog card
from minWidth to width so maxWidth can shrink it to fit.

Also cap the tall add-machine form section at 65vh with a scroll
container so all fields stay reachable on short screens.

For pairing, the seed-URL QR is now tappable and has an "Enlarge for
scanning" button opening a fullscreen maximized QR sized to the shorter
viewport edge — maximises visibility to the spire's scan camera.
2026-07-02 19:45:11 +02:00
8b64e9c742 Merge pull request 'feat(pairing): slim the spire seed + carry lnbits_npub (bitspire-#70)' (#37) from bitspire-70-seed-lnbits-npub into main
Some checks failed
ci.yml / Merge pull request 'feat(pairing): slim the spire seed + carry lnbits_npub (bitspire-#70)' (#37) from bitspire-70-seed-lnbits-npub into main (push) Failing after 0s
Reviewed-on: #37
2026-07-02 17:45:01 +00:00
2b90590104 fix(pairing): retry all bunker admin RPCs past transient timeouts (#38)
Some checks failed
ci.yml / fix(pairing): retry all bunker admin RPCs past transient timeouts (#38) (pull_request) Failing after 0s
The get_key_tokens retry only covered an empty token list; a transient
NsecBunkerTimeoutError on any admin RPC still failed pairing with a 502 (seen
live on aio-demo: create_new_key and get_key_tokens both 15s-timed-out, then a
manual retry succeeded). Generalise to `_bunker_retry`, wrapping every admin
call in the pair_spire chain (create_new_key, ensure_policy, create_new_token,
get_key_tokens): a NsecBunkerTimeoutError (and, for get_key_tokens, an empty
list) is transient → retry with backoff; a NsecBunkerRpcError rejection or
misconfig is terminal → fail fast. create_new_key is replace-by-name and
ensure_policy reconciles idempotently, so retrying on timeout is safe.

Also: _validate_relay now rejects a host-less ws:// (mint was laxer than the
consumer's parseSpireSeed), and stale "nostrclient" comments in the pair UI +
a test are corrected to "transport relay".

229 tests pass (incl. timeout-retry + fail-fast-rejection coverage).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 15:30:55 +02:00
0bb9939822 fix(pairing): default relay to the transport's nostrrelay, not nostrclient proxy
Some checks failed
ci.yml / fix(pairing): default relay to the transport's nostrrelay, not nostrclient proxy (pull_request) Failing after 0s
The nostrclient endpoint is a subscription MULTIPLEXER, not a full relay: its
router forwards a client's EVENT upstream but never returns an OK ack (see
nostrclient/router.py). A transport client that awaits OK on publish therefore
times out ("publish timed out"), so kind-21000 RPCs never complete — verified
on the Sintra: connect succeeded but list_wallets hung, and switching to the
nostrrelay endpoint made the whole flow work (wallet, balance, availability).

default_relay_endpoint now derives from settings.nostr_transport_relays — the
relay the transport actually listens on: use it as-is when already
machine-reachable, or re-home its path on lnbits_baseurl when it's a co-located
loopback relay (the bundled nostrrelay). Validation/localhost-reject and the
pair-dialog pre-fill/hint carry over; wording updated to "transport relay".

227 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:43:54 +02:00
765b07737b fix(pairing): retry get_key_tokens past a transient empty result
Some checks failed
ci.yml / fix(pairing): retry get_key_tokens past a transient empty result (pull_request) Failing after 0s
pair_spire failed with "bunker returned no tokens after create_new_token" when
the nsecbunkerd was briefly slow — get_key_tokens listed nothing in the few ms
after create_new_token before the write landed. Observed live: a nip44_decrypt
timeout, then a 502 on pair, then the identical call 25s later succeeding.

Retry get_key_tokens up to 5x with a 0.4s backoff before giving up, so pairing
survives a sluggish bunker instead of flaking. Tests cover the transient-empty
recovery and the exhausted-attempts failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:29:08 +02:00
4e36b98534 feat(pairing-ui): pre-fill the pair dialog relay with the default endpoint
Some checks failed
ci.yml / feat(pairing-ui): pre-fill the pair dialog relay with the default endpoint (pull_request) Failing after 0s
The backend now defaults an omitted relay to the nostrclient proxy endpoint,
but the pair dialog still showed an empty, client-side-required field — so the
operator saw "no default". Wire it through:

- GET /api/v1/dca/default-relay returns default_relay_endpoint() (the derived
  ws(s)://<host>/nostrclient/api/v1/relay).
- openPairDialog pre-fills the relay textarea with it; the operator can override
  or clear it (blank → same server-side default). Drops the client-side
  "at least one relay is required" guard.
- Hint updated to explain the default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:16:46 +02:00
c3791ed6c8 feat(pairing): default seed relay to the nostrclient endpoint + validate relays
Some checks failed
ci.yml / feat(pairing): default seed relay to the nostrclient endpoint + validate relays (pull_request) Failing after 0s
Two robustness fixes for on-machine pairing (bitspire-#70), after a QR scan
silently corrupted a seed's relay (ws://→As://) and crash-looped a machine on
an unreachable relay:

- Default relays: when the operator omits `relays`, derive the seed's relay from
  THIS lnbits' own nostrclient proxy endpoint — `<ws(s)>://<host>/nostrclient/api/v1/relay`,
  built from `lnbits_baseurl` (default_relay_endpoint). Operators configure
  upstream relays once in the nostrclient extension (public_ws must be on) and
  every seed points at one stable, operator-independent URL. `relays` is now
  optional on PairMachineData + the /pair endpoint.
- Validate every relay (+ bunker_relay) is a `ws://`/`wss://` URL AND reject
  loopback hosts (localhost/127.0.0.1/::1/0.0.0.0) — a seed is redeemed by a
  REMOTE machine, so a localhost relay is exactly the unreachable case. Catches
  both the ws://→As:// corruption and the localhost /pair gotcha at mint time.

Consumer side (bitspire): parseSpireSeed rejects non-ws relays, and the wizard
gained a "test relay" reachability button before committing.

223 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:00:13 +02:00
9dc4d09973 feat(pairing): slim the spire seed + carry lnbits_npub (bitspire-#70)
Some checks failed
ci.yml / feat(pairing): slim the spire seed + carry lnbits_npub (bitspire-#70) (pull_request) Failing after 0s
Mint the new-shape seed the bitspire consumer now expects: the pubkey rides
once as spire_npub (consumer derives the hex + reconstructs bunker_url from
bunker_secret + bunker_relay|relays[0]), and lnbits_npub is embedded so a paired
machine reaches this lnbits' nostr-transport with nothing else provisioned.

- build_seed_url emits {spire_npub, lnbits_npub, bunker_secret, relays} and
  bunker_relay only when it differs from relays[0] (omitted in the common case).
  Drops spire_pubkey + the full bunker_url from the payload.
- pair_spire reads settings.nostr_transport_public_key, hex_to_npub's it, and
  raises PairingError when it's empty (transport not running → can't mint a
  self-sufficient seed). bunker_url is still returned in PairResult for operator
  display / audit; only the seed stops embedding it.

Consumer side: bitspire packages/nostr-client/src/seed.ts + the #70 machine
wiring. Kept as v: 1 (redefined in place; no shipped seed to preserve).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:16:10 +02:00
b93e3fb698 test(pair-endpoint): update stale pair_spire double + mock get_super_config
The pairing endpoint tests' fake_pair predated the bunker_relay parameter
(views_api passes bunker_relay=data.bunker_relay), so it raised TypeError; and
api_pair_machine later grew a get_super_config() read for the post-pair fee
publish that the doubles never mocked, hitting a DB with no spirekeeper.super_config
table. Both were pre-existing failures on main (masked one behind the other).

Add bunker_relay to the fake_pair signature and mock get_super_config → None
(the happy path doesn't exercise fee publishing). Suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:16:10 +02:00
a5ab02e4b6 Merge pull request 'fix(pairing): default bunker_relay to the spire's public event relay, not localhost' (#35) from fix/pair-bunker-relay-default into main
Some checks failed
ci.yml / Merge pull request 'fix(pairing): default bunker_relay to the spire's public event relay, not localhost' (#35) from fix/pair-bunker-relay-default into main (push) Failing after 0s
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
v0.1.3
Reviewed-on: #35
2026-06-22 15:21:12 +00:00
b55fc8bc1c fix(pairing): default bunker_relay to the spire's public event relay, not localhost
Some checks failed
ci.yml / fix(pairing): default bunker_relay to the spire's public event relay, not localhost (pull_request) Failing after 0s
The seed minted via the Pair UI baked an unreachable bunker relay into
bunker_url. The UI form has no bunker_relay field, so pair_spire fell back to
its default `settings.lnbits_nsec_bunker_url` — which on a deployed instance is
the INTERNAL relay lnbits uses to reach the co-located bunker (e.g.
ws://127.0.0.1:5000/nostrrelay/demo). The remote ATM can't reach localhost, so
connectNewSeed hangs -> BunkerTimeoutError "Signer Unreachable". (Flagged by
bitspire on the demo; the localhost-relay /pair gotcha the coord thread called out.)

Default bunker_relay to the spire's own public event relay (relays[0]) instead:
the bunker lives on the same operator nostrrelay the spire publishes its events
to, so that URL is machine-reachable. An explicit `bunker_relay` still overrides
for split-relay deploys. An empty override now falls back to the same default
rather than raising.

Regression test: with no (or empty) bunker_relay, bunker_url embeds relays[0]
and contains no 127.0.0.1.

NOTE: relays[0] is a pragmatic default; whether the seed should carry multiple
relays / be sourced from the operator's nostrclient relay is a follow-up.
2026-06-22 17:18:24 +02:00
d0d20b0f94 Merge pull request 'fix: guard every machine_npub deref against unpaired machines (500 + cassette-consumer crash)' (#33) from fix/unpaired-machine-npub-guards into main
Some checks failed
ci.yml / Merge pull request 'fix: guard every machine_npub deref against unpaired machines (500 + cassette-consumer crash)' (#33) from fix/unpaired-machine-npub-guards into main (push) Failing after 0s
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
v0.1.2
Reviewed-on: #33
2026-06-22 14:58:03 +00:00
8dad72a00d fix: complete the unpaired-machine sweep + regression test
Some checks failed
ci.yml / fix: complete the unpaired-machine sweep + regression test (pull_request) Failing after 0s
Full sweep of every machine_npub deref found one more reachable crash:
_record_rejected (tasks.py) logs machine_npub[:12], and the
assert_nostr_attribution guard now routes an unpaired machine there, so
None[:12] -> TypeError. Fall back to machine.id.

Every other deref is safe by the attribution-gate invariant: a settlement only
flows past assert_nostr_attribution (now rejecting unpaired) for a paired
machine, so the downstream distribution / parse-path / "landed" logs can't see
None; the collision-loop display already uses `(m.machine_npub or m.id)`.

Adds tests/test_unpaired_machine_guards.py: attribution rejects an unpaired
machine with the domain SettlementAttributionError (not AttributeError), and
build_state_d_tags skips it. New tests + every guard-affected suite pass.

(Two pre-existing test_pair_endpoint failures — #29 drift: fake_pair lacks
bunker_relay, and the test DB lacks super_config — are out of scope; filed
separately.)
2026-06-22 16:55:33 +02:00
d52a3bfafe fix: guard every machine_npub deref against unpaired machines (None)
Some checks failed
ci.yml / fix: guard every machine_npub deref against unpaired machines (None) (pull_request) Failing after 0s
machine_npub became nullable in #29/m011 (register-unpaired flow), but
several consumers still assumed it's non-None and crashed
`normalize_public_key(None)` with `AttributeError: 'NoneType' object has no
attribute 'startswith'`. On the demo (which had an unpaired machine) this
broke the platform-fee update (500) and spammed the cassette consumer with
errors every 2s. The #29 create/pair paths were guarded; these were missed:

- views_api `api_update_super_config`: the "republish fee to every active
  machine" loop → skip unpaired (they get their config at pairing).
- cassette_transport `build_state_d_tags_for_machines`: skip unpaired (no
  state-beacon d-tag yet) — the cassette-consumer loop crash.
- crud `get_machine_by_atm_pubkey_hex`: its `except (ValueError,
  AssertionError)` didn't catch the AttributeError; skip unpaired before
  normalize — the cassette event-handler crash.
- bitspire `assert_nostr_attribution`: reject (SettlementAttributionError) an
  unpaired machine instead of crashing the payment listener.
- views_api cassettes/publish endpoint: 400 (not paired) instead of crashing
  publish_to_atm.

Verified on the dev stack: with an unpaired active machine present, the
cassette consumer registers (skipping it) and runs clean — no AttributeError.
2026-06-22 16:45:29 +02:00
622c1be5d3 Merge pull request 'feat(cash-in): secure create_withdraw nostr-transport RPC (#31)' (#32) from feat/secure-cashin-rpc into main
Some checks failed
ci.yml / Merge pull request 'feat(cash-in): secure `create_withdraw` nostr-transport RPC (#31)' (#32) from feat/secure-cashin-rpc into main (push) Failing after 0s
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
v0.1.1
Reviewed-on: #32
2026-06-22 13:54:43 +00:00
f67cb49bc3 fix(cash-in): return bech32 LNURL, not the raw URL
Some checks failed
ci.yml / fix(cash-in): return bech32 LNURL, not the raw URL (pull_request) Failing after 0s
`Lnurl.__str__` is the underlying URL, so `str(lnurl)` returned
`http://<baseurl>/withdraw/...` instead of the bech32 `LNURL1…` — wallets
need the encoded LNURL-withdraw (lud01). Use `str(lnurl.bech32)` and add
`lnurl_url` (the raw URL) alongside, mirroring withdraw's _populate_lnurl
field convention. (Note: the encoded URL still derives from LNBITS_BASEURL —
that must be an externally reachable https URL for a real wallet to claim.)
2026-06-22 15:32:44 +02:00
9abf695fd5 feat(cash-in): super_config.max_cash_in_sats per-tx cap + UI (#31)
Some checks failed
ci.yml / feat(cash-in): super_config.max_cash_in_sats per-tx cap + UI (#31) (pull_request) Failing after 0s
Wires the server-side per-transaction cash-in ceiling the `create_withdraw`
handler already enforces (it read the value defensively via getattr; this
makes it a first-class config field).

- migrations.py m012: ADD COLUMN super_config.max_cash_in_sats INTEGER (NULL
  = no cap).
- models.py: SuperConfig.max_cash_in_sats + UpdateSuperConfigData field with a
  >= 0 validator.
- super-fee dialog: a "Max cash-in per transaction (sats)" input; blank sends
  null (the PUT skips null, preserving the current value — set 0 to reject
  every cash-in). crud `update_super_config` and the PUT endpoint flow the
  field through automatically (dynamic dict update; check_super_user gated).

Why a sats cap and not the bunker ACL: the ACL / usage caps (#28) gate call
*rate*, not *sats*, and `principal_sats` is necessarily ATM-attested — so a
single in-rate call could request an arbitrarily large payout. This bounds a
compromised/buggy machine to one capped transaction.

Verified on the dev stack: m012 runs, the model round-trips the column
(GET returns the set value), and a negative value is rejected.
2026-06-22 12:51:59 +02:00
607b71e796 feat(cash-in): secure create_withdraw nostr-transport RPC (#31)
Some checks failed
ci.yml / feat(cash-in): secure `create_withdraw` nostr-transport RPC (#31) (pull_request) Failing after 0s
Adds a server-side cash-in RPC so the ATM no longer supplies the withdraw
amount, fee, or attribution. The ATM sends a bunker-signed kind-21000
`create_withdraw` with just the gross `principal_sats` (the hardware-
attested fiat value); the handler derives everything else SERVER-SIDE:

- attribution = the VERIFIED transport `sender_pubkey` (never read from the
  body), matched to an active machine on the authenticated wallet;
- fee = round(principal × super_cash_in) + round(principal × operator_cash_in),
  per-leg rounding so it matches parse_settlement exactly (fee_mismatch=0);
- net = principal − fee → the withdraw amount the customer receives;
- stamps `extra={source:bitspire, type:cash_in, principal_sats, fee_sats,
  nostr_sender_pubkey:<verified>, nostr_event_id}` onto the link.

The customer claims the NET link; the payout carries the stamped extra
(aiolabs/withdraw#3) and `_handle_payment` records the cash_in settlement
(spirekeeper#30) with cryptographic attribution — closing the vector where
`lnurlw_create_link` let the ATM set amount/fee/attribution freely.

Registered via `register_rpc("create_withdraw", …, AUTH_WALLET)` (extensions
register RPCs directly — withdraw already does). Soft-fails on lnbits without
`register_rpc`. Per-tx cap reads `super_config.max_cash_in_sats` defensively
(getattr) — the config field/UI is a fast-follow.

Wire schema pinned in #31. Depends on #30 (consumer-side settlement fix).
2026-06-22 12:21:23 +02:00
56ac4a69e9 Merge pull request 'fix(settlements): process cash-in (outbound) payments, not just cash-out' (#30) from fix/cash-in-settlement into main
Some checks failed
ci.yml / Merge pull request 'fix(settlements): process cash-in (outbound) payments, not just cash-out' (#30) from fix/cash-in-settlement into main (push) Failing after 0s
Reviewed-on: #30
2026-06-22 10:19:44 +00:00
7b55dc152b fix(settlements): process cash-in (outbound) payments, not just cash-out
Some checks failed
ci.yml / fix(settlements): process cash-in (outbound) payments, not just cash-out (pull_request) Failing after 0s
The `_handle_payment` cash-in branch existed but had never been exercised
end-to-end — bitSpire cash-in payouts only started reaching it once the
withdraw extension learned to stamp `source=bitspire` on an LNURL-withdraw
payout (aiolabs/withdraw#3). With that wired up, the first real cash-in
exposed two bugs:

1. `payment.sat` is signed by protocol direction — negative for an
   outbound (cash-in) payout. It was passed straight to `parse_settlement`
   as `wire_sats`, which enforces `wire_sats >= 0`, so every cash-in was
   rejected ("wire_sats must be >= 0, got -75795"). A settlement's
   `wire_sats` is a magnitude (direction lives in `tx_type`); pass
   `abs(payment.sat)`. Same in `_record_rejected`.

2. `_record_rejected` hard-coded `tx_type="cash_out"`, so a rejected
   cash-in showed the wrong direction in the operator dashboard. The
   parsed tx_type isn't available on the rejection path, but the
   authenticated protocol direction is — derive it: outbound → cash_in,
   inbound → cash_out.

Verified on the dev stack: a stamped cash-in now lands a `cash_in`
settlement (net 75795, principal 82386, fee 6591), pays the super its 3%
(2472 sats), and correctly skips the DCA leg (principal stays in the
operator's wallet as liquidity from the cash-in customer).
2026-06-21 17:27:58 +02:00