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.
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.
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.
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.
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.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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.)
nsecbunkerd#27 (deployed 2026-06-19) reverses the #24 finding: the
sign-time ACL now evaluates token lifecycle live on every request
(checkIfPubkeyAllowed step 4 joins through a liveWhere filter;
applyToken stopped photocopying grants into SigningConditions). So:
- duration_hours / token expiresAt now bounds an ESTABLISHED binding —
an expired token stops signing post-bind, not just at connect. The
prior docstring (connect-window-only, pointing at the now-closed
nsecbunkerd#24) is corrected.
- Token-revoke is no longer a post-redeem no-op (closes the #22
mechanism bunker-side). revoke_spire keeps using revoke_key_user
because that's the subject-level ban cutting the whole binding, not
just one token's grant — rationale updated, behavior unchanged.
Doc/comment only; 20 pairing tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
bitspire#52 consumer review (2026-06-18) enumerated the kinds the spire
signs as its OWN identity and found NIP-42 relay AUTH (kind 22242) missing
from SPIRE_POLICY_RULES — a silent bunker reject the moment a relay
challenges with AUTH. It must be bunker-signed (AUTH proves control of
spire_pubkey, which only the bunker holds; can't use the local client_nsec).
Adds 22242. Records the confirmed set in the policy comment: live = 21000 +
30078 + 22242; CLINK 21001-21003 dormant but kept; nip04 unused (v1 path is
dead code). New test locks the required-kinds contract so 22242 can't
silently regress.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Builds on the seed-URL pairing in #21 (stacked).
(b) TTL — PairMachineData.duration_hours (validated > 0) threads through
pair_spire -> create_new_token (lnbits#55). None = non-expiring.
(c) Revoke — POST /machines/{id}/revoke -> revoke_spire ->
admin_client.revoke_key_user(spire-<id>). Per spirekeeper#22, revoke
MUST go through KeyUser.revokedAt (revoke_key_user), NOT token revoke:
lnbits eager-binds (redeems) the connect token at provision, so
nsecbunkerd has materialised the policy into per-KeyUser grants its
ACL checks BEFORE the Token.revokedAt filter -> token revoke is a
silent no-op. Returns RevokeResult{revoked_count}: >=1 = cut, 0 =
never bound. set_machine_unpaired clears paired_at (keeps npub +
bunker_spire_key_name for audit / re-pair).
7 new tests (duration threading + default-None; revoke routes to
revoke_key_user and never token-revoke + error mapping; endpoint wiring
revoke happy/zero/502). 210 green; new code black/ruff-clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adopts aiolabs/lnbits#55 (merged b5fba561): pair_spire now calls the
public ensure_policy(client, name='spirekeeper-spire', rules=...,
methods_no_kind=...) instead of spirekeeper's cache-free
_ensure_spire_policy copy. #55 re-keyed _POLICY_ID_CACHE on
(admin_pubkey, policy_name), so the shared helper no longer returns the
wrong (lnbits-default) id for a non-default policy name — the exact
reason the duplicate existed. Net -45 LOC, one less fork-divergent
reimplementation to keep in sync.
Requires lnbits >= the #55 merge (ensure_policy importable) — already
true on dev/demo.
Tests: FakeBunker gains admin_pubkey; an autouse fixture clears lnbits'
_POLICY_ID_CACHE between tests (the shared helper caches, unlike the old
local one). 203 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator-side producer for seed-URL pairing (S0/#9, model A1). pair_spire()
orchestrates the nsecbunkerd admin chain via lnbits' NsecBunkerAdminClient:
create_new_key(spire-<id>) -> _ensure_spire_policy -> create_new_token ->
get_key_tokens -> package the <npub>#secret token into a bunker:// URL +
base64url seed URL {spire_npub, spire_pubkey, bunker_url, relays}.
The spire later self-signs all its events as that bunker-held key; lnbits'
path-B roster maps the npub to the operator wallet — no nsec on the spire.
spirekeeper does steps 1-4 only; the NIP-46 connect/bind happens spire-side
(bitspire#52) with the spire's own client keypair.
Scoped policy 'spirekeeper-spire': sign_event 21000/21001-3/30078 + nip44
(kind-less via add_policy_rule). Local _ensure_spire_policy (no cache)
avoids lnbits' admin-pubkey-keyed _ensure_policy cache (policy-name-blind).
9 unit tests with a fake bunker (orchestration, policy reconcile, seed/
bunker:// wire shape, error paths); npub<->hex via lnbits' real helpers.
200 tests green.
Known gaps (lnbits NsecBunkerAdminClient): no token-expiry param, no revoke
RPC — re-pair works; 'revoke spire access' deferred to a bunker follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fork of satmachineadmin's v2-bitspire line into its own repo. Renames
both identifiers so this extension is fully independent of the original
satmachineadmin install (which remains in service):
- extension id satmachineadmin -> spirekeeper
(router prefix, static path/static_url_for, module symbols, task
names, templates dir, config/manifest paths)
- database name satoshimachine -> spirekeeper
(Database(ext_spirekeeper), all schema-qualified table refs)
Also resets versioning to 0.1.0, sets the display name + manifest to
spirekeeper/aiolabs, and fixes the placeholder pyproject description.
Historical aiolabs/satmachineadmin#N issue references in comments are
left pointing at the original repo where those issues live.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three trigger points wire fee_transport.publish_fee_config into the
satmachineadmin API endpoints per the #39 spec. All three soft-fail on
transport errors — the underlying CRUD operation (machine create /
update / super-config save) succeeds even when the publish couldn't
reach the relay or the signer, and the operator can re-trigger by
editing again.
views_api.py:
- api_create_machine — publishes always after create, even when
operator fees default to 0/0 (the resulting super-only payload is
what unblocks the ATM past its `awaiting-fees` maintenance gate).
Reads super_config singleton; if absent (m001 should have inserted
it, so this is an impossible state), skips the publish to avoid
crashing create.
- api_update_machine — publishes only when either
operator_cash_*_fee_fraction is in the patch payload. Skip on
name/location/wallet_id/is_active/fiat_code edits since those don't
affect the fee model the ATM enforces (avoids unnecessary relay
churn).
- api_update_super_config — publishes to every active machine when
either super fraction changes. Per-machine: that machine's
operator_user_id is the signer (machines owned by different
operators sign with different keys); each soft-fail is independent.
Skip if only super_fee_wallet_id changed (no fee-model impact).
Tests (9 cases, all green):
- 3 create-machine triggers: default 0/0 operator fees still publishes
super-only payload, nonzero operator fees publish full payload,
None super_config short-circuits without crashing
- 4 update-machine triggers: publishes on cash_in change, publishes on
cash_out change, skips on name-only, skips on is_active-only
- 2 super-config triggers: publishes per-active-machine signed by
each machine's operator on fraction change, skips entirely on
wallet-id-only change (with an assertion that list_all_active_machines
is never called, proving the short-circuit path)
191/191 tests green. Layer 2 (#39) complete; ready for joint smoke
once bitspire fixes the three deploy gaps from coord-log §2026-06-01T18:30Z
(`relay.aiolabs.dev` default, `VITE_LNBITS_HTTP_URL` dead echo,
operator-fees subscriber not running in maintenance state).
Refs: aiolabs/satmachineadmin#37 (parent), #39 (closes Layer 2),
aiolabs/lamassu-next#57 (Layer 3 consumer — blocked on bitspire-side
gaps).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the second operator-pushed kind-30078 document type alongside
cassette config (#29). Wire format locked at coord-log §2026-06-01T14:25Z.
models.py:
- FeePayloadComponents — producer-mandatory `components` sub-object
with super + operator splits per direction. Consumer-optional in v1
but ships on every payload from this producer for audit + future-
promo extensibility.
- FeeConfigPayload — the wire-format envelope. Pydantic validators
enforce: cash_*_fee_fraction in [0, 0.15] (cap per direction);
|total - (super + operator)| < 1e-6 (consistency assert per the
§07:33Z lnbits advisory, mirrored on bitspire's #57 consumer side);
schema_version integer ≥ 1.
fee_transport.py:
- build_fee_payload(super_config, machine) — compose + validate in
one call; returned payload is wire-shippable. Raises ValueError
(via Pydantic) if the constructed totals violate the cap. That
shouldn't happen in practice because the API guards in
views_api._assert_machine_fee_cap_safe + _assert_super_config_cap_safe
refuse cap-violating writes; if it does, refuse-to-publish rather
than ship a malformed event.
- publish_fee_config(machine, super_config, operator_user_id) —
builds, encrypts, signs, publishes via the shared
publish_encrypted_kind_30078 helper from nostr_publish. d-tag is
`bitspire-fees:<atm_pubkey_hex>` per spec; recipient is the ATM
npub canonicalised to hex; signer is the operator.
- Soft-fail discipline matches cassette_transport.publish_to_atm —
transport-layer errors (RelayUnavailable / SignerUnavailable /
OperatorIdentityMissing) log WARN + return None so trigger callers
(api_create_machine etc.) don't break on transient transport hiccups.
Cap violations are NOT soft-fail since they indicate an API-guard
bypass and need operator attention.
Tests (18 cases, all green):
- 9 FeeConfigPayload validator cases (well-formed accept, wire round-
trip, cap violations per direction, exact-cap acceptance, sum/
components mismatch per direction, schema_version ≥ 1, zero-zero
free-charge ATM)
- 4 build_fee_payload composition cases (basic, asymmetric directions,
super-only-no-operator default, cap violation at build time)
- 5 publish_fee_config soft-fail discipline cases (relay unavailable,
signer unavailable, operator identity missing, publish success with
d-tag + recipient + payload-shape assertions, cap violation raises
before reaching publish)
182/182 tests green.
Refs: aiolabs/satmachineadmin#37 (parent), #39 (Layer 2), coord-log
§2026-06-01T14:25Z (locked wire format), §2026-06-01T07:33Z (lnbits
consistency-assert advisory).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase-1 observability per coord-log §2026-06-01T07:00Z (option A
locked: always record, no enforce_fee_match gate):
fee_mismatch_sats = bitspire_fee_sats - (platform_fee_sats + operator_fee_sats)
Positive = bitspire over-reported; negative = under-reported; zero =
exact match. Recorded unconditionally on every settlement; WARN-
logged via loguru only when |delta| > tolerance, where
tolerance = max(1, int(principal_sats * 0.001)) — 1-sat floor with
0.1% relative ceiling.
bitspire.py:parse_settlement:
- Computes the delta after split_principal_based returns.
- WARN log line carries bitspire_fee_sats / expected / delta /
tolerance / principal / both fractions / tx_type / machine-npub
prefix for triage queries.
- Always stamps fee_mismatch_sats onto CreateDcaSettlementData.
- Comment explains the pre-Layer-3 expectation: large deltas are
expected while the ATM hardcodes 7.77% cash-out (aiolabs/lamassu-
next#57); the data here will quiet once Layer 3 ships.
crud.py:create_settlement_idempotent: extends the INSERT to persist
the new column.
Tests:
- tests/conftest.py: `loguru_capture` fixture — loguru routes to a
pre-bound stderr sink that pytest's caplog (stdlib only) misses and
capsys can't see; the fixture adds a list-sink for the test's
duration. Reusable for future log-behavior tests.
- tests/test_fee_mismatch_recording.py: 8 cases covering exact-match
zero delta, bitspire over- and under-reporting, the pre-Layer-3
large-delta scenario, within-tolerance silence, over-tolerance
warning, diagnostic-fields presence in the WARN line, and the
1-sat floor on tiny-principal settlements.
164/164 tests green. Phase 2 (reject on out-of-tolerance) lands as
a follow-up once observability data justifies the tighter posture.
Refs: aiolabs/satmachineadmin#38 (Layer 1), coord-log §2026-06-01T07:00Z
(lnbits advisory + option A lock).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the broken fraction-of-fee math with fraction-of-principal,
direction-aware. Pre-#38: super_fee_fraction was interpreted as
`round(fee_sats * super_fraction)`, paying super ~13× below intent on
every cashout since the bitspire wire-shape landed. Post-#38: super
and operator shares are computed independently against principal
using the per-direction fractions from SuperConfig + Machine.
Per workspace CLAUDE.md "Backwards-compatibility on pre-public-launch
code" (v2-bitspire hasn't shipped to users), no compat shims:
- calculations.py: delete `split_two_stage_commission` (legacy
fraction-of-fee). Keep `split_principal_based` as the sole split fn.
- migrations.py m009: extend to also DROP the deprecated
`super_fee_fraction` column after backfilling its value into the
new directional fields.
- models.py: drop `super_fee_fraction` from SuperConfig +
UpdateSuperConfigData entirely.
- bitspire.py parse_settlement: new signature takes `super_config:
SuperConfig` instead of `super_fee_fraction: float`. Resolves
directional fractions from super_config + machine by tx_type, then
computes via split_principal_based. Raises SettlementInvariantError
on unknown tx_type.
- tasks.py: pass `super_config` through to parse_settlement; assert
non-None (m001 inserts the singleton at install time — None is an
impossible state).
- partial-dispense ratio path in distribution.py is unchanged — still
uses `settlement.platform_fee_sats / settlement.fee_sats` from the
landed row, which is the right invariant (lock at landing) and
independent of the per-direction config.
Tests:
- Rename `test_two_stage_split.py` → `test_operator_split_legs.py`.
Drop the legacy-function test classes. Keep TestAllocateOperatorSplitLegs
(still-production fn) and TestPartialDispenseSplitRatio (inline ratio
math in distribution.py).
- New `test_principal_based_fees.py`: pure-math tests for
`split_principal_based` (six cases including a direct regression
test pinning the pre-#38 bug at 240→3000 sats per 100k principal at
3% super), plus parse_settlement directional dispatch tests
(cash-in routes through cash-in fractions; cash-out through
cash-out; unknown tx_type raises; zero-zero free-charge ATM; cross-
direction guard).
Migration verified end-to-end via container restart: super_config
columns post-m009 = id/super_fee_wallet_id/updated_at/
super_cash_in_fee_fraction/super_cash_out_fee_fraction (no
super_fee_fraction). dca_machines + dca_settlements gained the
expected new columns. 156/156 tests green.
Refs: aiolabs/satmachineadmin#37 (parent), #38 (this layer). Closes
the load-bearing super under-payment bug standalone.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires the new directional fee fields through the write path and adds
the 15%-per-direction cap guard at the API boundary.
CRUD:
- create_machine INSERT includes operator_cash_in_fee_fraction +
operator_cash_out_fee_fraction (Pydantic default 0 covers existing
callers).
- update_machine + update_super_config already use generic update_data
dict, so the new fields flow through without per-call changes.
API boundary (views_api.py):
- _assert_machine_fee_cap_safe(operator_in, operator_out) — pairs
candidates against current super-config, rejects if (super_X +
operator_X) > 0.15 for either direction. Called from api_create_machine
+ api_update_machine (with partial-PATCH semantics: unset fields keep
the machine's current value).
- _assert_super_config_cap_safe(new_super_in, new_super_out) — fetches
every active machine; rejects with offending-machine name in the 400
detail if any (effective_super + operator) > cap. Called from
api_update_super_config.
Cap rounding: float arithmetic rounds (super + operator) to 4 decimals
(DECIMAL(10,4) precision) before comparing, so the IEEE 754 surprise
0.10 + 0.05 = 0.15000000000000002 doesn't trip the cap.
Tests (13 cases, all green): both directions hit the cap, exact-cap
acceptance, no-super-config degenerate path, partial PATCH on
super-config, offending-machine name in error detail, empty-fleet
vacuous safety.
Refs: aiolabs/satmachineadmin#38 (Layer 1), coord-log §2026-06-01T07:22Z
(cap lock at 15% per direction, defense in depth).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Exposes `resolve(sender_pubkey_hex) -> RouteHit | None` and a
`register_with_lnbits()` helper that lazily-imports + soft-fails on
lnbits versions without `register_roster_resolver`. Wired into
`satmachineadmin_start()`.
The hook delivers the path-B outcome ("cash-out sats go to the
operator's wallet, not an auto-created machine wallet") once the
lnbits side ships its half. Shape contract `(operator_user_id,
wallet_id, source_extension)` frozen per coord-log 2026-05-31T15:25Z.
Branch held local until lnbits lands the registry — no behaviour
change on the current lnbits version, just the future-ready handoff
+ a benign INFO log on boot.
Boot-smoked in dev container: extension loads, registration logs the
documented soft-fail message, invoice listener + cassette consumer
unchanged. 6 new unit tests cover happy path, miss, bech32 +
uppercase canonicalisation, fail-closed on malformed input, and the
soft-fail register branch.
The lone test (`test_router`) has been failing for the lifetime of
the repo. It's `@pytest.mark.asyncio`-decorated but the project
deliberately doesn't ship `pytest-asyncio` (newer tests use
`asyncio.run` inside the body — see `test_cassette_state_consumer.py`
header), so the test is uncollectable in our runner. The body asserts
nothing the extension-load boot path doesn't already cover.
After this: full suite is 155/155 green. Closes#34.
Adds `_assert_no_pubkey_collision` to `views_api`, wired into
`api_create_machine` between the wallet-ownership guard and the
`create_machine` CRUD call. Refuses with HTTP 400 + operator-actionable
error message if the supplied `machine_npub` matches any existing
LNbits operator account's `accounts.pubkey`.
## Why this matters
Reproducer 2026-05-30T21:33Z (coord-log archive `2026-05-31-pre-rotation.md`):
Greg's operator account `accounts.pubkey` had been seeded as the same
value as Sintra's `dca_machines.machine_npub` (`522a4538…`) during
manual setup. The collision masked the routing bug for days — lnbits'
nostr-transport `auth.py:resolve_nostr_auth` was routing inbound
kind-21000 RPCs from the ATM directly to Greg's wallet *by coincidence*
of the matching pubkey. When Greg's account migrated to
`RemoteBunkerSigner` and got a fresh pubkey, the coincidence broke +
`auto-account-from-npub` fired for the orphaned ATM npub. A real $20
test cash-out silently landed on a fresh auto-account wallet
(`a94b564f…`); satmachineadmin lost the settlement entirely — no
`dca_settlements` row, no DCA distribution, no commission split.
The proper architectural fix is path B / `aiolabs/satmachineadmin#20`
(S6, in-progress with lnbits — coord-log `2026-05-31T15:25Z`). This
guard is the complementary preventive layer: stops a future operator
from re-entering the broken state by registering a machine whose npub
collides with an existing account.
## What's in this commit
- **`views_api._assert_no_pubkey_collision`** — canonicalises the input
npub (accepts hex or `npub1…` bech32) via `normalize_public_key`,
queries `lnbits.core.crud.users.get_account_by_pubkey` (which itself
lowercases internally), raises HTTPException(400) on hit. Error
message names the canonical pubkey prefix, explains the
pubkey-collision dependency that breaks on operator pubkey rotation,
+ points to the `lamassu-next provision-atm` remediation path +
this issue for context.
- **Wired into `api_create_machine`** after `_assert_wallet_owned_by`
+ before `create_machine`. `api_update_machine` is unaffected
because `UpdateMachineData` doesn't allow npub changes on existing
rows.
- **`tests/test_collision_guard.py`** — 7 unit tests covering hex /
bech32 / uppercase-hex inputs all canonicalise to the same lookup,
the no-collision case returns silently, error message asserts
(truncated pubkey + remediation hint). Uses pytest monkeypatch to
isolate the assertion logic from a live `get_account_by_pubkey` DB
call — matches the assertion-style pattern of
`tests/test_nostr_attribution.py`.
- **`CLAUDE.md`** — new "No-collision invariant" subsection under
Security Considerations: documents the rule + the SQL check
operators can run on existing installs + the
`ATM_PRIVATE_KEY`-unset remediation + cross-refs to `#20` and `#32`.
## Regtest SQL check result
Ran the diagnostic SQL against the regtest LNbits + satmachineadmin DBs:
- 1 active `dca_machines.machine_npub`: `522a4538…` (Greg's Sintra)
- 1 collision found: the auto-account orphan `a94b564f…` (username =
None — auto-account signature) created during yesterday's silent-drop
failure mode. NOT a legitimate operator account. Greg's actual
operator account `ac35c9fc…` carries pubkey `197a4cf4…` post-bunker
migration, no collision there.
The orphan is operational cleanup (sweep + delete), separate from this
code fix. No real-operator collisions remain on the regtest instance.
## Test status
162 passed, 1 pre-existing async-plugin failure unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-merge lint hygiene on the PR #30 touched files:
- `black` reformatted 9 files (cassette_transport, crud, models, tasks,
views_api, nip44, all 3 cassette test files, migrations). Cosmetic:
line lengths, trailing commas, multi-line argument layout.
- `ruff check --fix` cleared 176 of 202 errors auto-fixed. Mostly
`UP006` `typing.Optional` → `| None` modernization, `I001` import
sort order, `UP035` typing-extensions cleanup.
- Two new mypy regressions introduced by the migration commit dcb7de0
fixed:
- `crud.py:apply_bootstrap_state` — annotated `existing_first: dict
| None` on the dedup fetch.
- `tasks.py:_cassette_consumer_tick` — `# type: ignore[arg-type]` on
the `nostr_client.relay_manager.add_subscription` call; nostrclient's
upstream typing declares `list[str]` for filters but the actual
Nostr protocol takes `list[<filter-dict>]`. The runtime accepts it
(live smoke at 13:43Z dispatched `nip44_decrypt` cleanly through
this subscription); the typing mismatch is upstream's.
Remaining lint state, intentionally not addressed in this commit
(all pre-existing baseline, not regressions):
- 8 mypy errors in `calculations.py` + the unchanged-by-this-PR parts
of `crud.py` — pre-existing on v2-bitspire.
- 26 ruff style warnings: 14 are N805 false-positives on Pydantic
validators (`cls` first-arg is correct for `@validator`-decorated
methods); 4 are N818 exception-name-suffix preferences on my new
exception classes (renaming would touch many call sites; keep
`OperatorIdentityMissing` / `SignerUnavailable` / `RelayUnavailable`
/ `_NostrclientUnavailable` as-is for clarity); 5 are E501 line-too-
long on docstrings (the long lines are formatted for clarity);
1 RUF002 unicode-minus in a docstring.
Tests: 155 passed, 1 pre-existing async-plugin failure unchanged.
Live smoke (both publish + consume directions through the bunker)
unaffected — this is purely a code-style pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migrates the cassette transport's encrypt/decrypt paths off direct
`account.prvkey` reads to `signer.nip44_encrypt` / `signer.nip44_decrypt`
on the NostrSigner ABC landed by aiolabs/lnbits PR #38 (phase 2.4). Closes
the operator-side regression flagged at coord-log 2026-05-31T06:50Z:
Greg's RemoteBunkerSigner-migrated account had `accounts.prvkey IS NULL`
post-bunker, which the old code couldn't handle — consumer was logging
WARN every poll cycle and skipping every inbound state event.
## What changed
### cassette_transport.py
- New imports: `resolve_signer`, `SignerError`, `SignerUnavailableError`,
`NsecBunkerTimeoutError`, `NsecBunkerRpcError` from the post-#38 lnbits
surface. (The `try: from lnbits.core.signers import SignerError` block
in the old code was permanently failing because `SignerError` actually
lives in `lnbits.core.signers.base`, not the package root — fixed.)
- New `_resolve_operator_signer(operator_user_id)`: single source of
truth for "give me the operator's account + NostrSigner, or raise an
operator-facing error." Used by both the publish path and the consumer
task.
- New `_nip44_encrypt_via_signer(account, signer, plaintext, peer)`
and `_nip44_decrypt_via_signer(...)`: route through `signer.nip44_*`
first; on `SignerUnavailableError` from a LocalSigner stub (the
post-#38 ABC has LocalSigner raise on nip44_* explicitly — bunker
migration required for NIP-44 v2), fall back to the hand-rolled impl
against `account.prvkey`. Transitional until every operator on the
instance is bunker-backed (S7).
- `_sign_as_operator` simplified: now `await signer.sign_event(event)`
(the ABC is async; the old code passed `signer.sign_event` to the
caller without await, returning a coroutine — also broken but never
hit because the ImportError fallback fired first).
- `publish_to_atm` flow: `_resolve_operator_signer` → `_nip44_encrypt_
via_signer` → `_sign_as_operator` → publish. Each step maps bunker /
signer errors to `OperatorIdentityMissing` (400) / `SignerUnavailable`
(503) / `CassetteTransportError` (500) for the API handler.
- `decrypt_and_parse_state_event` now `async` and takes `(event, account,
signer)` instead of `(event, operator_privkey_hex)`. Maps
`NsecBunkerTimeoutError` → `CassetteEventTransientError` (caller
should retry on next poll, NOT advance `state_event_id`).
`NsecBunkerRpcError` / `SignerUnavailableError` / `Nip44Error` / etc.
→ `CassetteEventDecodeError` (terminal — caller logs + skips).
- New `CassetteEventTransientError` class for the bunker-timeout case.
Distinct from `CassetteEventDecodeError` so the consumer can log at
INFO + retry vs WARNING + advance.
- Deleted `_get_operator_privkey_hex` (no longer needed).
### tasks.py — _handle_cassette_state_event
- Resolves the signer via `_resolve_operator_signer(machine.operator_
user_id)`. On `CassetteTransportError` (OperatorIdentityMissing /
SignerUnavailable), logs + skips.
- Awaits `decrypt_and_parse_state_event(event_obj, account, signer)`.
On `CassetteEventTransientError`, logs at INFO + returns (state_event_
id NOT advanced → consumer retries on next poll cycle).
On `CassetteEventDecodeError`, logs at WARNING + returns (still
state_event_id NOT advanced for v1; the WARN log surfaces the
underlying issue for operator triage).
### tests/test_cassette_state_consumer.py — rewritten
- Three test doubles: `_FakeBunkerSigner` (working nip44_decrypt via
hand-rolled impl), `_FakeLocalSignerStub` (raises like the post-#38
LocalSigner stub), `_FakeRaisingSigner` (configurable exception).
- `_fake_account` helper using SimpleNamespace — the code under test
only reads `.signer_type` + `.prvkey`.
- Five test classes covering: bunker-signer happy path (incl. multi-
same-denom round-trip), LocalSigner transitional fallback,
bunker-error mapping (timeout → transient, rpc reject → decode),
payload validation (tamper / wrong-key / missing-fields / garbage
JSON / wrong shape), d-tag construction (unchanged, kept as
regression guard).
- Async coroutines driven via `asyncio.run` — matches the existing
project pattern (no pytest-asyncio plugin in CI; see test_init.py
failure mode).
### nip44.py — docstring update
Added a "Runtime status (post lnbits PR #38, 2026-05-31)" section
documenting that runtime usage moved to `signer.nip44_*` and this
module's role narrowed to (a) the LocalSigner transitional fallback
called from `cassette_transport`, and (b) test-only fixtures in
test_nip44_v2.py for spec-vector + bitspire cross-test validation.
"Don't add new runtime call sites here. The signer abstraction is
the path."
## Verification
- 155 passed, 1 pre-existing async-plugin failure unchanged. The 19
consumer tests cover bunker happy path + LocalSigner fallback +
bunker error mapping + payload validation + d-tag construction.
- Live smoke against Greg's RemoteBunkerSigner-migrated account
on the regtest container: consumer correctly resolves the bunker
signer, fires `NIP-46 rpc -> method=nip44_decrypt`, catches the
resulting `NsecBunkerTimeoutError` (the local nsecbunkerd is not
responding within 15s — separate operational concern), maps to
`CassetteEventTransientError`, logs at INFO with "will retry next
poll", and crucially does NOT advance `state_event_id` on the
cassette_configs rows. Retry semantics preserved.
## Outstanding
- The bunker timeout itself is an operational issue (nsecbunkerd
config / policy / process state for kind-less nip44_decrypt RPC) —
not a satmachineadmin code concern; surface to the nsecbunkerd /
lnbits sessions if it persists.
- Once every operator on the instance is on RemoteBunkerSigner (S7
fully landed), the `_nip44_*_via_signer` helpers collapse to a
direct `await signer.nip44_*` call, the LocalSigner fallback can
be deleted, and `nip44.py`'s runtime exports retire (test-only).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the v1 fixture (denominations-keyed, 2026-05-30T13:15Z) with
bitspire's v1.1 fixture (positions-keyed, 2026-05-30T19:00Z log entry).
Drops the class-level @pytest.mark.skip from commit 1cebefc.
The v1.1 fixture intentionally includes two positions (1 + 2) both
holding denomination=20 — exercises the multi-same-denomination round-
trip end-to-end through encrypt → wire → decrypt → payload-validate.
Pinned explicitly in test_decrypts_bitspire_sample_event:
assert payload["positions"]["1"]["denomination"] == 20
assert payload["positions"]["2"]["denomination"] == 20
assert payload["positions"]["1"]["count"] != payload["positions"]["2"]["count"]
So a future "fix" that re-introduces denom-uniqueness validation
surfaces at this test instead of as a runtime rejection on real
machines (the v1.1 operational case from coord-log 18:45Z).
Other two cross-tests (test_signature_verifies_via_lnbits_helper,
test_encrypt_round_trip_via_our_impl_decrypts_with_their_keys) carry
over from v1 unchanged — same shape, just the new fixture's keys +
event flow through them.
Total: 151 passed, 0 skipped, 1 pre-existing async-plugin failure
unchanged. PR #30 is now byte-compat-verified end-to-end with the
v1.1 wire shape; bitspire side is typecheck-green + about to cache-
push (per coord-log 20:55Z). Joint re-smoke on Sintra is the
remaining v1.1 step.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The wire-shape pivot (m007 denomination-keyed → m008 position-keyed)
needs the unit test surface re-written to match:
test_cassette_configs.py
- PublishCassettesPayload tests pivot to positions-keyed input.
Validators reject non-int / non-positive position keys, negative
denom, negative count. Zero count allowed (empty cassette).
- NEW: test_accepts_multiple_same_denomination_cassettes — pins the
v1.1 operational requirement (real machines load 4×$20 for cash-out
throughput) per coord-log 18:45Z. No denom-unique validator.
- CassettePayloadRow tests pivot to the new field shape
(denomination + count, no position).
- UpsertCassetteConfigData tests cover edit-denomination (the v1.1
"operator swaps a cartridge during refill" scenario) and edit-count.
Position no longer in the model.
test_cassette_state_consumer.py
- _make_state_event helper builds {"positions": {...}} ciphertext.
- Happy-path assertion checks p.positions keys + denomination/count
per row.
- NEW: test_round_trips_multiple_same_denomination — covers the v1.1
four-of-the-same case through encrypt → decrypt → parse.
- All negative paths (tamper, wrong privkey, malformed pubkey,
missing fields, garbage JSON, wrong shape) carry over with the new
payload shape.
- d-tag tests unchanged (position vs denomination isn't on the d-tag).
test_nip44_v2.py
- TestBitspireCrossTest temporarily re-skipped at the class level: the
13:15Z fixture is encoded with the v1 denomination-keyed shape;
bitspire's posting a v1.1 fixture and commit g will swap +
unskip.
Total: 148 passed, 3 skipped (bitspire cross-test pending the v1.1
fixture from bitspire), 1 pre-existing async-plugin failure unchanged.
Branch tip is now functionally green (the pre-existing async failure
predates this PR + can't be addressed without a pytest plugin install).
Pending commit g for the cross-test fixture re-wire when bitspire posts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bitspire posted the sample event at ~/dev/coordination/log.md
2026-05-30T13:15Z — encrypted via @bitSpire/nostr-client's
encryptContentV2 + createSignedEvent (the same production code path
the ATM bootstrap publish uses), round-tripped on bitspire side
before posting.
Replaces the @pytest.mark.skip stub from commit da07bae with three
real cross-impl byte-compat assertions in TestBitspireCrossTest:
1. test_decrypts_bitspire_sample_event — the load-bearing one. Our
nip44.decrypt_from recovers the expected
{"denominations": {"20": ..., "50": ...}} plaintext from the
fixture's ciphertext. Confirms our hand-rolled NIP-44 v2 produces
wire output that nostr-tools' impl reads, and vice versa.
2. test_signature_verifies_via_lnbits_helper — lnbits.utils.nostr.
verify_event returns True for the fixture's (id, pubkey, sig).
Confirms both sides hash the event id the same way + Schnorr-
verify under the same x-only public-key convention. The consumer
path runs verify_event before NIP-44 decrypt, so this is the
other half of the sig-algorithm agreement check.
3. test_encrypt_round_trip_via_our_impl_decrypts_with_their_keys —
encrypts the expected plaintext using OUR encrypt_for with the
fixture's ATM keypair as sender + operator pubkey as recipient;
decrypts back with OUR decrypt_from; asserts the recovered
plaintext matches. Locks the encrypt direction too. Asserts the
re-encrypted ciphertext differs from the fixture's (NIP-44 v2
nonces are random — byte-equality would be a CSPRNG regression).
If any of these ever fail, the spec ambiguity surfaces before either
side ships — exactly what the cross-test is for.
Same trap I made writing 16:35Z (didn't re-tail before writing, missed
bitspire's 13:15Z fixture post between my 15:55Z ask and the 16:35Z
ack) that bitspire owned at 07:55Z and I'd written into my session
memory as a rule. Symmetric lesson — the trap fires for any session
that goes head-down on implementation work.
Total: 149 passed (146 + 3 new), 0 skipped (cross-test no longer
skipped), 1 pre-existing async-plugin failure unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Long-running task wired into satmachineadmin_start that subscribes to
kind-30078 bitspire-cassettes-state:<atm_pubkey_hex> events from every
active machine's ATM and upserts cassette_configs via apply_bootstrap_state
on receipt. Pairs with bitspire's one-shot bootstrap publish in
aiolabs/lamassu-next#56 — operator's first config publish then validates
against a non-empty denomination set.
Pattern mirrors wait_for_paid_invoices (try/except per event, never lets
the loop die). Uses the same nostr_client.relay_manager singleton that
cassette_transport.publish_to_atm uses, just on the subscribe side.
Implementation: poll the singleton NostrRouter.received_subscription_events
dict keyed by our subscription_id (satmachineadmin-cassette-bootstrap).
This is the same drain pattern nostrclient's per-WebSocket NostrRouter
uses; since we use a distinct sub_id, no cross-contamination with
WebSocket-connected clients of nostrclient.
Filter is re-derived from active machines each tick — newly-added
machines start receiving bootstrap events without an LNbits restart.
Soft-fail surfaces (none crash the listener):
- nostrclient extension not installed → log + 30s backoff
- inbound event sig-verify fails → log + skip
- sender pubkey not in dca_machines → log + skip (relay noise)
- operator privkey not on file → log + skip
- NIP-44 v2 decrypt / payload validation fails → log + skip
- apply_bootstrap_state error → log + skip
Per-event handler routes to the right operator's privkey by looking up
the machine via get_machine_by_atm_pubkey_hex (O(N) over active
machines — fine for small fleets; if fleets grow, normalize machine_npub
at write + add an index).
CRUD additions:
- list_all_active_machines: cross-operator query for the subscription
filter
- get_machine_by_atm_pubkey_hex: route inbound events to the right
machine row + operator account; accepts hex or bech32 storage
14 tests in test_cassette_state_consumer.py covering:
- decrypt_and_parse_state_event happy path + 6 negative paths (tamper,
wrong privkey, malformed pubkey, missing fields, garbage JSON,
wrong-shape payload)
- d-tag construction regression guard (REGRESSION GUARD: d-tag uses
ATM hex pubkey not internal UUID — pins the load-bearing detail
from coord-log 11:50Z)
- build_state_d_tags_for_machines + bech32 → hex canonicalisation
Full handler dispatch (verify_event → get_machine_by_atm_pubkey_hex →
apply_bootstrap_state) needs a live LNbits DB; smoke-tested manually
per the existing project convention.
Total: 146 passed, 1 skipped (cross-test fixture pending), 1 pre-existing
async-plugin failure unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LNbits ships only NIP-04 (AES-CBC) in lnbits.utils.nostr.encrypt_content,
but the locked design at #29 (paired with lamassu-next#56) wires kind-30078
cassette config with NIP-44 v2 content per the privacy-by-default
architecture (dcd0874). Hand-rolling rather than adding a Python lib dep
per the plan-approval (option A) — keeps the impl auditable inline and
avoids pulling in a non-trivial dep tree.
nip44.py covers the full envelope:
- get_conversation_key — ECDH x-coord + HKDF-extract with salt b"nip44-v2"
- encrypt_with_conversation_key / decrypt_with_conversation_key — low-level,
nonce-controllable for testing pinned vectors
- encrypt_for / decrypt_from — high-level pair-keyed API (the shape app
code reaches for)
- _pad / _unpad — NIP-44 v2 length-prefixed padding scheme
- HMAC-SHA256 verification on nonce || ciphertext, constant-time compare
via hmac.compare_digest
- Typed errors (Nip44VersionError / Nip44MacError / Nip44LengthError)
so callers can distinguish tamper from corruption from spec mismatch
Stack: coincurve for ECDH (already a transitive lnbits dep), cryptography
for ChaCha20 + HKDF-expand (also already there). No new pyproject deps.
34 tests in tests/test_nip44_v2.py, three layers:
1. Pinned reference vector — conversation_key for (sec=1, sec=2) matches
the canonical paulmillr/nip44 published value
(c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d).
Regression-fails loudly if key derivation drifts.
2. Round-trip + tamper detection — encrypt/decrypt across plaintext
lengths (1, 32, 33, 1000, 5000, 65535 bytes); flipped MAC byte;
flipped ciphertext byte; flipped nonce byte; wrong recipient privkey;
version-byte rejection; padding-formula spot checks.
3. Cross-impl byte-compat — placeholder test_decrypts_bitspire_sample
marked @pytest.mark.skip, pending bitspire posting a sample event
encrypted on their nostr-tools side to the coord log (per the
2026-05-30T15:55Z entry). Wire that fixture and unskip when posted.
Total: 132 passed, 1 skipped (cross-test fixture pending), 1 pre-existing
async-plugin failure unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire up the cassette_configs storage layer:
- get_cassette_config / list_cassette_configs_for_machine — reads
- update_cassette_config — operator UI per-row edit (count + position).
Refuses to create new rows; the denomination set is hardware-determined
per #29 row lifecycle.
- apply_bootstrap_state — consumer-side upsert from an ATM-published
kind-30078 bitspire-cassettes-state event. Populates both the
operator-believed columns and the v2 reverse-channel columns
(state_count, state_at, state_event_id) in one transaction. Returns
False on relay re-delivery (any existing row's state_event_id matches
the incoming event_id).
- _should_apply_bootstrap_state — pure-function dedup gate extracted
from apply_bootstrap_state so the relay-re-delivery decision is
unit-testable without a database round-trip.
23 new pure-function/model tests in tests/test_cassette_configs.py
covering the wire-shape validators (denomination key coercion, no-duplicate-
positions, int ranges, wire-dict round-trip) and the dedup-helper logic.
DB-touching CRUD follows the existing project convention (see
test_deposit_currency.py rationale): smoke-tested manually via the dev
container, integration tests deferred.
Total: 98 passed, 1 pre-existing async-plugin failure unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each machine handles exactly one currency today (operator-set on
`dca_machines.fiat_code`). The deposit's currency is fully determined
by the machine it's recorded against, so it shouldn't be operator-
chooseable in the first place.
Surfaced during 2026-05-16 E2E testing: Jordan had a "15 USD" deposit
recorded against an EUR Sintra (operator typo in the freeform currency
input). The balance summary is currency-blind (`SUM(amount)` over
mixed currencies), so on the next cash-out the system distributed
15 EUR worth of sats on the strength of that 15 USD row. Worked out
by chance; could have over-paid by ~10% if the actual EUR/USD rate
had been further off.
Fix:
- `CreateDepositData` / `UpdateDepositData` no longer carry a
`currency` field. Any client-submitted value is silently dropped
at Pydantic validation, before reaching the handler.
- `api_create_deposit` resolves the machine's `fiat_code` and
passes it to `create_deposit(..., currency=...)` as a required
keyword arg. The deposit row's `currency` column always matches
the machine going forward.
- UI: the freeform `<q-input label="Currency">` becomes a read-only
`<q-chip>` slot on the amount field, sourced from the new
`depositMachineFiatCode` computed (resolves via the selected
client's machine).
- `m005_lock_deposit_currency_to_machine_fiat_code` migration
backfills existing rows: every `dca_deposits.currency` gets
rewritten to match its joined `dca_machines.fiat_code`. Greg's
stray `15 USD` row becomes `15 EUR` (the right answer at today's
invariant).
Multi-currency-per-machine support is explicitly out of scope here;
when hardware ships that reads multiple denominations across
currencies, the relevant changes are documented in issue #26's
"Future" section (dca_machines.fiat_codes set, currency-aware
balance summary, etc.). The current fix is "lock the input side";
that future work is "unlock it but constrained to the machine's
declared set".
3 new unit tests (`tests/test_deposit_currency.py`) lock in the
model-contract guarantees. Total suite 89 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When LNbits' nostr-transport stamps `nostr_sender_pubkey` and
`nostr_event_id` onto Payment.extra (post aiolabs/lnbits PR #4), the
listener now cross-checks the signer against the resolved machine's
`machine_npub` before any distribution. Mismatch / absence / unparseable
pubkey → settlement is recorded with `status='rejected'` and the
reason in `error_message`, distribution is skipped.
Wire shape:
bitspire.SettlementAttributionError + assert_nostr_attribution()
Raises on absence, mismatch, or unparseable pubkey on either side.
Normalises both `machine.machine_npub` (operator UI accepts hex
or `npub1...`) and the stamped sender through
`lnbits.utils.nostr.normalize_public_key` so the comparison is
canonical-hex on both sides.
tasks._handle_payment
parse_settlement -> stamp nostr_event_id onto bitspire_event_id ->
try assert_nostr_attribution: on failure, insert row with
initial_status='rejected' + error_message, return without
spawning process_settlement.
crud.create_settlement_idempotent
Now takes `initial_status` (required) and `error_message`.
Normal path passes 'pending'; rejected path passes 'rejected'
with the reason. Single-statement insert — no two-step pending->
errored dance.
crud.get_stuck_settlements_for_operator
New `rejected` bucket alongside `errored` / `stuck_pending` /
`stuck_processing`. Distinct because retry is wrong for these:
the row was misrouted, not operationally failed.
models.DcaSettlement.status enum extended with 'rejected'.
Worklist response model carries the new bucket; API + UI plumbed
end-to-end.
static/js/index.js + templates/satmachineadmin/index.html
New 'rejected' worklist bucket (deep-orange, gpp_bad icon).
Force-reset button now scoped to stuck_pending / stuck_processing
only — was 'not errored' which would have shown on rejected too.
10 unit tests in tests/test_nostr_attribution.py cover hex<->hex,
hex<->bech32, case-insensitivity, every absent variant, mismatch,
and unparseable on either side. All pass.
Closes the consumer-side of aiolabs/satmachineadmin#19 (G5).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes H6 from #11. The partial-dispense recompute path was reading the
CURRENT super_fee_pct via get_super_config() to re-derive the platform/
operator split. That breaks the "absolute fields are the source of
truth" invariant the v2 schema was built around: if super raises (or
lowers) the global rate between landing and partial-dispense, the
operator's share would retroactively shift — without any notice to the
operator and contrary to the original transaction's contract.
Fix: re-derive new_platform from the *original* platform_fee_sats /
commission_sats ratio stored on the settlement row, not from the
current super_config. The contract was locked at landing; rate changes
after the fact must not retroactively touch this transaction.
Before:
super_config = await get_super_config()
super_fee_pct = float(super_config.super_fee_pct) if super_config else 0.0
new_platform, new_operator = split_two_stage_commission(
new_commission, super_fee_pct
)
After:
ratio = (settlement.platform_fee_sats / settlement.commission_sats
if settlement.commission_sats > 0 else 0.0)
new_platform = round(new_commission * ratio)
new_platform = max(0, min(new_platform, new_commission))
new_operator = new_commission - new_platform
Note: split_two_stage_commission at LANDING time (in bitspire.py) still
uses the current super_fee_pct — that's correct, the rate at landing is
the locked rate. Only the *recompute* path was wrong.
Tests:
TestPartialDispenseSplitRatio.test_plan_scenario_30pct_lands_then_partial:
100-sat commission @ 30% → partial to 50% → 15/35 (preserves ratio).
test_super_changed_rate_doesnt_affect_existing_settlement:
Super raises rate to 50% after a 30% landing; partial-dispense to
50% must keep the ORIGINAL ~30% platform share, not the new 50%.
test_zero_original_commission_yields_zero_platform: edge case.
test_invariant_sum_equals_new_commission: parametrised sum invariant.
Also dropped the now-unused split_two_stage_commission import from
distribution.py (still used in bitspire.py at landing time and by the
test suite, just not in this file anymore).
54 / 54 tests pass.
Refs: aiolabs/satmachineadmin#11 — H6 ✅
Remaining in #11: fix bundle 3 (dead-code purge), M and N items.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After a settlement lands (P1a), this commit pays out the three leg
groups via LNbits internal transfers (create_invoice + pay_invoice with
internal=True). Wired synchronously from the invoice listener — latency
is one bitSpire-tx wide. process_settlement is idempotent (status guard)
so retries are safe.
distribution.py — three leg groups, in order:
1. super_fee leg:
platform_fee_sats → super_fee_wallet_id (if set)
skip + warn if super fee % > 0 but wallet not configured
2. operator_split legs:
operator_fee_sats sliced per the operator's commission_splits
ruleset (per-machine override or operator default)
skip + warn if operator has no ruleset configured
3. dca legs:
net_sats distributed proportionally to active flow-mode LPs at
this machine, each capped at the LP's remaining-fiat-balance-
in-sats (preserves the v1 sync-mismatch fix from PR #2)
skip if exchange_rate=0 (fallback path with missing rate)
Every leg lands a dca_payments row with the leg_type discriminator and
inherits Payment.tag "satmachine:{machine_npub}" so LNbits payment-
history filters work natively across machines + operators.
Atomicity model: LN payments cannot be rolled back. Each leg is
attempted independently; success/fail recorded on the dca_payments row.
The settlement is marked 'processed' only when every leg completed; any
failure marks 'errored' with a concatenated message but leaves successful
legs in place. Sats that don't pay out (failed legs, missing super
wallet, no commission ruleset, no LP coverage) remain in the machine's
wallet — visible to the operator on the dashboard.
calculations.py — extracted two pure helpers:
split_two_stage_commission(commission_sats, super_fee_pct)
Stage-1: super takes super_fee_pct (rounded); operator absorbs the
rounding remainder so platform + operator == commission_sats exactly.
allocate_operator_split_legs(operator_fee_sats, leg_pcts)
Stage-2: distributes the remainder across N legs per pct rules. Last
leg absorbs the rounding remainder so sum(legs) == operator_fee_sats.
50 new tests cover the plan's verification scenario:
100 sats commission, super=30%, operator splits 50/30/20
→ super 30, operator 35/21/14. Sum 100 ✓
plus all the edge cases the plan called out (super=0, super=100,
single-leg, zero-fee, parametrised invariant on sums).
views_api.py adds the super-only platform-fee write endpoint:
PUT /api/v1/dca/super-config (check_super_user)
This is the only super-only endpoint in v2 — sets super_fee_pct and the
destination wallet for collecting the fee.
72/72 tests pass (22 calculation + 50 two-stage-split). 13 routes
registered against LNbits 1.4 (nostr-transport).
Refs: aiolabs/satmachineadmin#9
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>