feat: Layer 3 — consume operator fee config from Nostr; drop hardcoded cashInFeeFraction / cashOutFeeFraction #57

Closed
opened 2026-06-13 22:03:05 +00:00 by padreug · 0 comments
Owner

Migrated from aiolabs/lamassu-next#57 — opened by @padreug on 2026-05-31.\n\nLayer 3 of the cross-repo operator-configurable fee architecture.

  • Parent tracking issue: aiolabs/satmachineadmin#37 (architectural intent + bug surfaced 2026-05-31)
  • Partner publisher issue: aiolabs/satmachineadmin#39 (sat-side: publishes the kind-30078 events this issue consumes)
  • Wire-format agreement required before either side ships. See still-open design questions below.

Locked design decisions (per aiolabs/satmachineadmin#37)

  • ✅ Separate cash-in and cash-out fees carried as independent fields in the wire payload.
  • ✅ schema_version in payload — version-gated upgrade path for future fields.

Why this exists

apps/machine/src/stores/atm.ts:290-291 currently hardcodes the fees:

const cashInFeeFraction = ref(0.0333)   // 3.33% cash-in
const cashOutFeeFraction = ref(0.0777)  // 7.77% cash-out

Every Sintra that runs the current bitspire build charges those rates regardless of who owns it. The operator can't configure their own fee without rebuilding the firmware. Per the architectural intent in aiolabs/satmachineadmin#37, the fee should be the sum of:

  • The lnbits super-admin's super_cash_*_fee_fraction (X%, instance-wide, per-direction)
  • The per-machine operator_cash_*_fee_fraction (Y%, set by the ATM operator, per-direction)

Both calculated against the principal amount. Total fee per direction = (X+Y)%. The split (super getting X%, operator getting Y%) happens on the satmachineadmin side at settlement time (Layer 1 / aiolabs/satmachineadmin#38); bitspire's job here is to apply the total (X+Y)% to the principal when generating the invoice for the corresponding flow direction.

What ships (consumer side)

Subscribe to the kind-30078 fee-config envelope

Mirrors the cassette-config consumer pattern from aiolabs/lamassu-next#56 — same kind, same encryption envelope. Operator-config-over-Nostr is now a multi-document pattern; this issue adds the second document type on the bitspire side.

  • Subscription filter (drafted; needs sat-side ack — see aiolabs/satmachineadmin#39):
    • kind: 30078
    • #p: [atm_pubkey_hex]
    • #d: ["bitspire-fees:" + atm_pubkey_hex]
  • Decryption: NIP-44 v2, recipient privkey = ATM nsec
  • Plaintext content (draft):
    {
      "cash_in_fee_fraction": 0.0333,
      "cash_out_fee_fraction": 0.0777,
      "schema_version": 1,
      "published_at": 1780256000
    }
    
  • Watermark dedup: track lastAppliedPublishedAt (similar to meta.lastKnownConfigCreatedAt for operator config in #56); reject events older than the last applied.
  • schema_version handling: v1 consumer parses cash_in_fee_fraction + cash_out_fee_fraction + published_at; ignores any other top-level keys (future-proofing — sat-side may publish v2 events with promo fields once that work scopes; v1 bitspire should keep working against v2 events with the fee fields it knows).

Apply received config

  • Update the cashInFeeFraction / cashOutFeeFraction refs to the values from the most recently received event.
  • Persist the last-applied config to a local store (same shape as cassette config persistence per #56) so a restart restores it without waiting for a fresh publish.
  • Emit a log line on every apply: [Fees] applied cash_in=X cash_out=Y schema=1 published_at=N.

Fail-closed on no-config-received

  • On boot, if no fee config has ever been received AND no persisted config exists: refuse to enter the normal operating state. Display a maintenance screen: "Awaiting fee configuration from operator. Contact operator to publish initial fee config." Same posture as path-B's roster_required=true — the safety net is fail-closed when the prerequisite isn't met.
  • If a persisted config exists but the operator's publish hasn't been received yet this session, proceed with the persisted values (don't block on relay availability; the persisted values are authoritative until invalidated).

Drop the hardcoded constants

atm.ts:290-291 becomes:

// Defaults are 0 — the operator's fee config (received via Nostr kind-30078
// `bitspire-fees:<atm_pubkey>` envelope from satmachineadmin) is the source
// of truth. If no config is received and none persisted, the ATM refuses to
// operate (maintenance screen). See aiolabs/lamassu-next#57 (this issue)
// + aiolabs/satmachineadmin#37 (architectural intent).
const cashInFeeFraction = ref(0)
const cashOutFeeFraction = ref(0)

Both refs start at 0; reactivity propagates the fee changes through the existing CashInView / CashOutView UI without changes there.

Still-open design questions (need joint resolution — mirrors aiolabs/satmachineadmin#39)

  1. Total-fee cap policy — bitspire should also enforce client-side (reject a config event whose either-direction sum > cap as malformed, log + keep prior config). What's the cap? Per-direction or shared? My straw is 25% per direction.
  2. Apply-mid-transaction policy — if a config event arrives between QR-scan and pay, apply immediately to the next transaction, or hold until current transaction completes? Recommend hold-until-completion to avoid the user seeing a fee change between scan and pay.

Future-proofing for promos

Treat any unknown top-level keys in the wire payload (including a future discounts array) as ignorable in this v1 consumer. When a future bitspire ships v2 promo awareness, the schema_version bump tells it to parse the new fields. Current implementation must not crash on unknown fields. See parent aiolabs/satmachineadmin#37 for the broader future-proofing rationale.

Tests

  • test_subscribe_filter_shape — filter exactly matches the wire-format agreement
  • test_apply_received_fee_config_both_directions — kind-30078 event with both fields populated updates both refs + persists
  • test_watermark_rejects_older_event — event with published_at < lastAppliedPublishedAt is ignored
  • test_decryption_failure_logs_and_skips — corrupt ciphertext doesn't crash the consumer
  • test_unknown_top_level_fields_ignored — payload with extra keys (simulated v2 forward-compat) parses successfully, applies the known fields
  • test_boot_no_config_no_persistence_shows_maintenance — first boot with empty store + no inbound events → maintenance screen
  • test_boot_persisted_config_proceeds_offline — persisted config but relay unreachable → ATM operates with persisted values
  • test_total_fee_cap_rejection_per_direction — config event with either direction sum > cap is dropped (TBD once cap is decided)

Sequencing

  • Wire-format agreement first — resolve open design questions in aiolabs/satmachineadmin#39 / this issue jointly before either side ships
  • aiolabs/satmachineadmin#38 (Layer 1) ships independently — fixes the super under-payment immediately
  • aiolabs/satmachineadmin#39 + this issue ship together — must be agreed on the wire shape, then can ship roughly in parallel
  • Smoke test once both sides ship: bitspire restart on Sintra picks up persisted-empty + waits; satmachineadmin operator edits the fee in the UI → kind-30078 publishes → bitspire receives + applies + UI shows the new fee % when next user starts a transaction (separately per direction)

Cross-refs

  • Parent tracking issue (with architectural intent + the under-payment bug demo): aiolabs/satmachineadmin#37
  • Sat-side publisher: aiolabs/satmachineadmin#39
  • Sat-side math + DB: aiolabs/satmachineadmin#38
  • Existing operator-config Nostr pattern to mirror: aiolabs/lamassu-next#56 (cassette config consumer)
  • Today's path-B work that surfaced this: cross-session log archived at ~/dev/coordination/archive/2026-05-31-path-b-shipped.md
> _Migrated from [aiolabs/lamassu-next#57](https://git.atitlan.io/aiolabs/lamassu-next/issues/57) — opened by @padreug on 2026-05-31._\n\nLayer 3 of the cross-repo operator-configurable fee architecture. - **Parent tracking issue**: `aiolabs/satmachineadmin#37` (architectural intent + bug surfaced 2026-05-31) - **Partner publisher issue**: `aiolabs/satmachineadmin#39` (sat-side: publishes the kind-30078 events this issue consumes) - **Wire-format agreement required before either side ships.** See still-open design questions below. ## Locked design decisions (per `aiolabs/satmachineadmin#37`) - ✅ **Separate cash-in and cash-out fees** carried as independent fields in the wire payload. - ✅ **`schema_version` in payload** — version-gated upgrade path for future fields. ## Why this exists `apps/machine/src/stores/atm.ts:290-291` currently hardcodes the fees: ```ts const cashInFeeFraction = ref(0.0333) // 3.33% cash-in const cashOutFeeFraction = ref(0.0777) // 7.77% cash-out ``` Every Sintra that runs the current bitspire build charges those rates regardless of who owns it. The operator can't configure their own fee without rebuilding the firmware. Per the architectural intent in `aiolabs/satmachineadmin#37`, the fee should be the sum of: - The lnbits **super-admin's `super_cash_*_fee_fraction`** (X%, instance-wide, per-direction) - The **per-machine `operator_cash_*_fee_fraction`** (Y%, set by the ATM operator, per-direction) Both calculated against the **principal** amount. Total fee per direction = (X+Y)%. The split (super getting X%, operator getting Y%) happens on the satmachineadmin side at settlement time (Layer 1 / `aiolabs/satmachineadmin#38`); bitspire's job here is to apply the total (X+Y)% to the principal when generating the invoice for the corresponding flow direction. ## What ships (consumer side) ### Subscribe to the kind-30078 fee-config envelope Mirrors the cassette-config consumer pattern from `aiolabs/lamassu-next#56` — same kind, same encryption envelope. Operator-config-over-Nostr is now a multi-document pattern; this issue adds the second document type on the bitspire side. - **Subscription filter** (drafted; needs sat-side ack — see `aiolabs/satmachineadmin#39`): - kind: `30078` - `#p`: `[atm_pubkey_hex]` - `#d`: `["bitspire-fees:" + atm_pubkey_hex]` - **Decryption**: NIP-44 v2, recipient privkey = ATM nsec - **Plaintext content** (draft): ```json { "cash_in_fee_fraction": 0.0333, "cash_out_fee_fraction": 0.0777, "schema_version": 1, "published_at": 1780256000 } ``` - **Watermark dedup**: track `lastAppliedPublishedAt` (similar to `meta.lastKnownConfigCreatedAt` for operator config in `#56`); reject events older than the last applied. - **`schema_version` handling**: v1 consumer parses `cash_in_fee_fraction` + `cash_out_fee_fraction` + `published_at`; **ignores** any other top-level keys (future-proofing — sat-side may publish v2 events with promo fields once that work scopes; v1 bitspire should keep working against v2 events with the fee fields it knows). ### Apply received config - Update the `cashInFeeFraction` / `cashOutFeeFraction` refs to the values from the most recently received event. - Persist the last-applied config to a local store (same shape as cassette config persistence per `#56`) so a restart restores it without waiting for a fresh publish. - Emit a log line on every apply: `[Fees] applied cash_in=X cash_out=Y schema=1 published_at=N`. ### Fail-closed on no-config-received - **On boot, if no fee config has ever been received AND no persisted config exists**: refuse to enter the normal operating state. Display a maintenance screen: *"Awaiting fee configuration from operator. Contact operator to publish initial fee config."* Same posture as path-B's `roster_required=true` — the safety net is fail-closed when the prerequisite isn't met. - If a persisted config exists but the operator's publish hasn't been received yet this session, proceed with the persisted values (don't block on relay availability; the persisted values are authoritative until invalidated). ### Drop the hardcoded constants `atm.ts:290-291` becomes: ```ts // Defaults are 0 — the operator's fee config (received via Nostr kind-30078 // `bitspire-fees:<atm_pubkey>` envelope from satmachineadmin) is the source // of truth. If no config is received and none persisted, the ATM refuses to // operate (maintenance screen). See aiolabs/lamassu-next#57 (this issue) // + aiolabs/satmachineadmin#37 (architectural intent). const cashInFeeFraction = ref(0) const cashOutFeeFraction = ref(0) ``` Both refs start at 0; reactivity propagates the fee changes through the existing CashInView / CashOutView UI without changes there. ## Still-open design questions (need joint resolution — mirrors `aiolabs/satmachineadmin#39`) 1. **Total-fee cap policy** — bitspire should also enforce client-side (reject a config event whose either-direction sum > cap as malformed, log + keep prior config). What's the cap? Per-direction or shared? My straw is 25% per direction. 2. **Apply-mid-transaction policy** — if a config event arrives between QR-scan and pay, apply immediately to the next transaction, or hold until current transaction completes? Recommend hold-until-completion to avoid the user seeing a fee change between scan and pay. ## Future-proofing for promos Treat any unknown top-level keys in the wire payload (including a future `discounts` array) as ignorable in this v1 consumer. When a future bitspire ships v2 promo awareness, the `schema_version` bump tells it to parse the new fields. Current implementation must not crash on unknown fields. See parent `aiolabs/satmachineadmin#37` for the broader future-proofing rationale. ## Tests - `test_subscribe_filter_shape` — filter exactly matches the wire-format agreement - `test_apply_received_fee_config_both_directions` — kind-30078 event with both fields populated updates both refs + persists - `test_watermark_rejects_older_event` — event with `published_at < lastAppliedPublishedAt` is ignored - `test_decryption_failure_logs_and_skips` — corrupt ciphertext doesn't crash the consumer - `test_unknown_top_level_fields_ignored` — payload with extra keys (simulated v2 forward-compat) parses successfully, applies the known fields - `test_boot_no_config_no_persistence_shows_maintenance` — first boot with empty store + no inbound events → maintenance screen - `test_boot_persisted_config_proceeds_offline` — persisted config but relay unreachable → ATM operates with persisted values - `test_total_fee_cap_rejection_per_direction` — config event with either direction sum > cap is dropped (TBD once cap is decided) ## Sequencing - **Wire-format agreement first** — resolve open design questions in `aiolabs/satmachineadmin#39` / this issue jointly before either side ships - **`aiolabs/satmachineadmin#38` (Layer 1)** ships independently — fixes the super under-payment immediately - **`aiolabs/satmachineadmin#39` + this issue** ship together — must be agreed on the wire shape, then can ship roughly in parallel - **Smoke test** once both sides ship: bitspire restart on Sintra picks up persisted-empty + waits; satmachineadmin operator edits the fee in the UI → kind-30078 publishes → bitspire receives + applies + UI shows the new fee % when next user starts a transaction (separately per direction) ## Cross-refs - Parent tracking issue (with architectural intent + the under-payment bug demo): `aiolabs/satmachineadmin#37` - Sat-side publisher: `aiolabs/satmachineadmin#39` - Sat-side math + DB: `aiolabs/satmachineadmin#38` - Existing operator-config Nostr pattern to mirror: `aiolabs/lamassu-next#56` (cassette config consumer) - Today's path-B work that surfaced this: cross-session log archived at `~/dev/coordination/archive/2026-05-31-path-b-shipped.md`
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/bitspire#57
No description provided.