feat: Layer 2 — publish operator fee config to bitspire via Nostr (kind-30078) #39

Closed
opened 2026-05-31 20:23:10 +00:00 by padreug · 0 comments
Owner

Layer 2 of the operator-configurable fee architecture (parent: #37).

Wire side: satmachineadmin publishes; bitspire consumes (aiolabs/lamassu-next#57 is the consumer half — needs coordinated wire-format agreement before either side ships).

Locked design decisions (per #37)

  • Separate cash-in and cash-out fees carried as independent fields in the wire payload.
  • schema_version in payload — version-gated upgrade path preserved for future fields (notably future promo additions).

What ships (publisher side)

Mirrors the cassette-config publisher pattern from #29 (PR #30) — same kind, same encryption envelope, same trigger points. Operator-config-over-Nostr is now a multi-document pattern; this issue adds the second document type.

Wire format (draft — needs lamassu-next ack on #57)

  • Kind: 30078 (NIP-78 addressable app data)
  • d-tag: bitspire-fees:<atm_pubkey_hex> (sibling to bitspire-cassettes:<atm_pubkey_hex>)
  • #p tag: [atm_pubkey_hex] (recipient — the ATM the fees apply to)
  • Encryption: NIP-44 v2, recipient = ATM pubkey, sender = operator account (via lnbits.core.signers.resolve_signer per PR #30's signer-abstraction pattern)
  • Plaintext content (JSON):
    {
      "cash_in_fee_fraction": 0.0333,
      "cash_out_fee_fraction": 0.0777,
      "schema_version": 1,
      "published_at": 1780256000
    }
    

cash_in_fee_fraction and cash_out_fee_fraction are independent fields carrying super_cash_*_fee_fraction + operator_cash_*_fee_fraction per direction. Both mandatory. schema_version lets future evolutions (promo discounts, per-currency rates, time-of-day variations) carry version-gated payload shapes without breaking v1 consumers. published_at is a UTC Unix timestamp; ATM accepts the most-recent-by-created_at event regardless of order and rejects events older than the last-applied published_at (watermark dedup, same pattern as cassette state).

Trigger points (publisher)

  • On machine createapi_create_machine publishes initial fee config immediately after _assert_no_pubkey_collision + create_machine. If both operator_cash_*_fee_fraction=0 (default), publishes anyway so ATM has authoritative cash_*_fee_fraction = super_cash_* only.
  • On machine updateapi_update_machine republishes if either operator_cash_*_fee_fraction changes.
  • On super fee changeupdate_super_config republishes to every active machine (one event per machine; per-machine payload combines that machine's operator fractions with the new super fractions).

Computed fields

  • cash_in_fee_fraction = super.super_cash_in_fee_fraction + machine.operator_cash_in_fee_fraction
  • cash_out_fee_fraction = super.super_cash_out_fee_fraction + machine.operator_cash_out_fee_fraction

Both capped at 1.0 (reject above with 400 on the satmachineadmin-side update path).

Soft-fail behavior

If publish fails (relay unreachable, signer error, nostrclient extension absent), log + skip — same soft-fail discipline as cassette_transport.publish_to_atm in PR #30. Machine creation/update succeeds locally; the operator gets a non-fatal warning in the UI ("fee config not yet pushed to ATM; will retry on next machine edit or super-config save"). Background catch-up republish task is out-of-scope for v1.

Still-open design questions (need lnbits / bitspire input)

  1. Total-fee cap policy — what's the maximum allowed sum super + operator per direction? Hardcoded ceiling (my straw is 25% per direction), super-admin-settable, or unrestricted? Today's bitspire defaults of 3.33% / 7.77% suggest 25% would be a reasonable per-direction ceiling that doesn't constrain real-world operators but flags obvious typos.
  2. enforce_fee_match default — once Layers 2+3 ship, should satmachineadmin reject settlements whose fee_sats doesn't match principal × (super + operator) ± tolerance? Recommend "yes, default-on" once the wire is reliable.
  3. lnbits multi-document pattern review — kind-30078 now carries two distinct operator-pushed documents (bitspire-cassettes: from #29, bitspire-fees: here). Is "one d-tag per document type" the right scaling shape, or should we converge on one envelope with multi-key payload? Flagging for lnbits-session review.

Future-proofing for promos

schema_version is the migration vehicle — a future v2 payload can add discounts: [{npub, applies_to: "super"|"operator", fraction, expires_at}, ...] or similar. v1 consumers ignore unknown fields gracefully. No code added in this issue attempts to implement promos.

Tests

  • test_publish_fee_config_on_machine_create — new machine triggers a kind-30078 event with correct d-tag and content (both directions populated)
  • test_publish_fee_config_on_machine_update_fee_change — updating either operator fee fraction triggers republish; updating other fields (name, location) does NOT
  • test_publish_fee_config_on_super_config_update — changing either super_cash_*_fee_fraction triggers republish to every active machine
  • test_publish_payload_shape_includes_schema_version — JSON content matches the agreed wire format with schema_version: 1
  • test_publish_soft_fails_when_nostrclient_missing — relay/signer absence logs warning, doesn't break the API call
  • test_total_fee_cap_validation_per_direction — super + operator > cap raises 400 in the affected direction (TBD once design question 1 answered)

Parent: #37
Partner issue: aiolabs/lamassu-next#57 (consumer side).

Layer 2 of the operator-configurable fee architecture (parent: #37). Wire side: **satmachineadmin publishes; bitspire consumes** (`aiolabs/lamassu-next#57` is the consumer half — needs coordinated wire-format agreement before either side ships). ## Locked design decisions (per #37) - ✅ **Separate cash-in and cash-out fees** carried as independent fields in the wire payload. - ✅ **`schema_version` in payload** — version-gated upgrade path preserved for future fields (notably future promo additions). ## What ships (publisher side) Mirrors the cassette-config publisher pattern from #29 (PR #30) — same kind, same encryption envelope, same trigger points. Operator-config-over-Nostr is now a multi-document pattern; this issue adds the second document type. ### Wire format (draft — needs lamassu-next ack on #57) - **Kind**: `30078` (NIP-78 addressable app data) - **d-tag**: `bitspire-fees:<atm_pubkey_hex>` (sibling to `bitspire-cassettes:<atm_pubkey_hex>`) - **`#p` tag**: `[atm_pubkey_hex]` (recipient — the ATM the fees apply to) - **Encryption**: NIP-44 v2, recipient = ATM pubkey, sender = operator account (via `lnbits.core.signers.resolve_signer` per PR #30's signer-abstraction pattern) - **Plaintext content** (JSON): ```json { "cash_in_fee_fraction": 0.0333, "cash_out_fee_fraction": 0.0777, "schema_version": 1, "published_at": 1780256000 } ``` `cash_in_fee_fraction` and `cash_out_fee_fraction` are **independent fields** carrying `super_cash_*_fee_fraction + operator_cash_*_fee_fraction` per direction. Both mandatory. `schema_version` lets future evolutions (promo discounts, per-currency rates, time-of-day variations) carry version-gated payload shapes without breaking v1 consumers. `published_at` is a UTC Unix timestamp; ATM accepts the most-recent-by-`created_at` event regardless of order and rejects events older than the last-applied `published_at` (watermark dedup, same pattern as cassette state). ### Trigger points (publisher) - **On machine create** — `api_create_machine` publishes initial fee config immediately after `_assert_no_pubkey_collision` + `create_machine`. If both `operator_cash_*_fee_fraction=0` (default), publishes anyway so ATM has authoritative `cash_*_fee_fraction = super_cash_*` only. - **On machine update** — `api_update_machine` republishes if either `operator_cash_*_fee_fraction` changes. - **On super fee change** — `update_super_config` republishes to every active machine (one event per machine; per-machine payload combines that machine's operator fractions with the new super fractions). ### Computed fields - `cash_in_fee_fraction` = `super.super_cash_in_fee_fraction + machine.operator_cash_in_fee_fraction` - `cash_out_fee_fraction` = `super.super_cash_out_fee_fraction + machine.operator_cash_out_fee_fraction` Both capped at 1.0 (reject above with 400 on the satmachineadmin-side update path). ### Soft-fail behavior If publish fails (relay unreachable, signer error, nostrclient extension absent), log + skip — same soft-fail discipline as `cassette_transport.publish_to_atm` in PR #30. Machine creation/update succeeds locally; the operator gets a non-fatal warning in the UI ("fee config not yet pushed to ATM; will retry on next machine edit or super-config save"). Background catch-up republish task is out-of-scope for v1. ## Still-open design questions (need lnbits / bitspire input) 1. **Total-fee cap policy** — what's the maximum allowed sum `super + operator` per direction? Hardcoded ceiling (my straw is 25% per direction), super-admin-settable, or unrestricted? Today's bitspire defaults of 3.33% / 7.77% suggest 25% would be a reasonable per-direction ceiling that doesn't constrain real-world operators but flags obvious typos. 2. **`enforce_fee_match` default** — once Layers 2+3 ship, should satmachineadmin reject settlements whose `fee_sats` doesn't match `principal × (super + operator)` ± tolerance? Recommend "yes, default-on" once the wire is reliable. 3. **lnbits multi-document pattern review** — kind-30078 now carries two distinct operator-pushed documents (`bitspire-cassettes:` from #29, `bitspire-fees:` here). Is "one d-tag per document type" the right scaling shape, or should we converge on one envelope with multi-key payload? Flagging for lnbits-session review. ## Future-proofing for promos `schema_version` is the migration vehicle — a future v2 payload can add `discounts: [{npub, applies_to: "super"|"operator", fraction, expires_at}, ...]` or similar. v1 consumers ignore unknown fields gracefully. No code added in this issue attempts to implement promos. ## Tests - `test_publish_fee_config_on_machine_create` — new machine triggers a kind-30078 event with correct d-tag and content (both directions populated) - `test_publish_fee_config_on_machine_update_fee_change` — updating either operator fee fraction triggers republish; updating other fields (name, location) does NOT - `test_publish_fee_config_on_super_config_update` — changing either `super_cash_*_fee_fraction` triggers republish to every active machine - `test_publish_payload_shape_includes_schema_version` — JSON content matches the agreed wire format with `schema_version: 1` - `test_publish_soft_fails_when_nostrclient_missing` — relay/signer absence logs warning, doesn't break the API call - `test_total_fee_cap_validation_per_direction` — super + operator > cap raises 400 in the affected direction (TBD once design question 1 answered) Parent: #37 Partner issue: `aiolabs/lamassu-next#57` (consumer side).
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/satmachineadmin#39
No description provided.