Carry principal/fee split end-to-end via Payment.extra on cash-out invoices (and LNURL-withdraw equivalent) #44

Open
opened 2026-06-13 22:02:59 +00:00 by padreug · 7 comments
Owner

Migrated from aiolabs/lamassu-next#44 — opened by @padreug on 2026-05-14.\n\n## Context

The new satmachineadmin v2 (LNbits extension on the nostr-transport branch — see aiolabs/satmachineadmin v2 epic) subscribes to kind-21000 settlement pushes for an operator's ATM wallets and runs the principal-to-LP distribution and the two-stage commission split (super first, then operator-defined remainder). For this to be precise, it needs to know the principal (principal_sats) and the fee (fee_sats) separately for each transaction — and without back-deriving them from a contractual commission rate (the way the old Lamassu integration did via base = total / (1 + commission)).

Canonical vocabulary (locked 2026-05-26)

Cross-codebase decision across satmachineadmin + lamassu-next + atm-tui — see memory file reference_sat_amount_vocabulary.md in the satmachineadmin session. Use these names verbatim on Payment.extra and on state.db:

Field Unit Meaning
wire_sats int Actual Lightning payment amount. Direction-agnostic. (= payment.sat)
principal_sats int Market-rate sats for the customer's fiat, BEFORE commission. floor(fiat × exchange_rate).
fee_sats int Commission. Operator's cut.
fee_fraction float in [0, 1] Commission rate as a unit fraction. Never percentage. 0.05 = 5%.

Invariants (assertion-worthy at every layer):

  • cash-out: wire_sats == principal_sats + fee_sats
  • cash-in: wire_sats == principal_sats - fee_sats AND fee_sats <= principal_sats
  • always: 0 <= fee_fraction <= 1

The gap

Internally, bitSpire already keeps the split separate. The state-machine context (packages/state-machine/src/types.ts + apps/machine/src/stores/atm.ts:computeFeeSats) computes feeSats and feeFraction (per canonical; today still feePercent pending rename), and the transaction record (apps/machine/src/types/state.ts) carries sats / feeSats / feePercent / exchangeRate / currency / bills[] / cassettes[] / txid as distinct fields.

But the invoice creation initially dropped all of this. Pre-138cd1a generateInvoice in apps/machine/src/services/lightning.ts passed only {amount, memo, unit} to lnbits.createInvoice(). So when LNbits later pushed the settlement (kind-21000), the receiving extension saw only "wallet got X sats with payment_hash Y" and had to either back-derive the split (Lamassu-style — error-prone for tiered/dynamic commissions per #6) or fall back to a separate per-machine config the operator must keep in sync.

The ask

Populate Payment.extra with the canonical split fields on every cash-out invoice bitSpire creates, and the analogous fields on every cash-in LNURL-withdraw link. LNbits already persists Payment.extra (see lnbits/core/models/payments.py:79 on the nostr-transport branch) and includes it in the kind-21000 settlement push.

Proposed payload (cash-out, canonical names):

const payment = await lnbits.createInvoice(lnbitsWalletId, {
  amount: amountSats,
  memo: 'bitSpire - Cash Out',
  unit: 'sat',
  extra: {
    source: 'bitspire',
    type: 'cash_out',
    txid,                         // bitSpire's internal tx id
    machine_npub: identity.npub,  // for cross-verification on receiver
    principal_sats,               // what the LP-distribution algorithm should see
    fee_sats,                     // commission — what feeds the split engine
    fee_fraction,                 // commission rate as unit fraction in [0, 1]
                                  // (e.g. 0.05 — NEVER 5.0)
    exchange_rate,                // sats per 1 fiat unit
    currency,                     // matches the machine's fiat code
    fiat_amount,                  // operator-facing display + audit
    bills: ctx.bills,             // for partial-dispense reconciliation later
    cassettes: ctx.cassettes,
  },
})

For cash-in (generateLnurlWithdraw in the same file): equivalent fields on the LNURL-withdraw link metadata so the LNbits subscribe_payments({tag:"withdraw", link_id}) push carries the same shape. Cash-in invariant means wire_sats < principal_sats (commission deducted from the principal); use tx_type: 'cash_in'.

Why this matters

  • Eliminates back-derivation. Lamassu forced satmachineadmin to compute base = total / (1 + commission) from a stored commission rate. With dynamic/tiered commissions (#6 — on-chain-fee-adjusted, amount-tiered, promo-coded), back-derivation is unreliable. bitSpire is the source of truth on what the split actually was for this specific transaction; pass it through.
  • Enables the v2 customer-discount engine (planned post-v1 in satmachineadmin) without a schema migration: the audit trail of who-forgave-what comes for free from Payment.extra.
  • Helps partial-dispense recovery (satmachineadmin#3): if the ATM only dispensed 6 of 10 bills, satmachineadmin needs bills[] and cassettes[] to recompute the correct partial settlement.

Acceptance

  • generateInvoice populates extra with the canonical fields (shipped at 138cd1a 2026-05-16). Note: field currently stamped as fee_percent with feePercent * 100 — pending rename to fee_fraction (drop the * 100) per the canonical decision.
  • generateLnurlWithdraw populates equivalent metadata on the withdraw link (cash-in flow, awaiting satmachineadmin S8 / #22 + #25).
  • Verified end-to-end: a cash-out tx from a Sintra dev unit → LNbits dev instance → Payment.extra round-trips intact in the kind-21000 settlement push received by an extension subscriber.
  • No regressions on the customer wallet side (the BOLT11 is unchanged; this is metadata only).
  • Rename fee_percent → fee_fraction on Payment.extra + drop the * 100 conversion at lightning.ts:~780 so the wire field is a unit fraction in [0, 1] consistent with state.db storage and the satmachineadmin canonical (coordinated via ~/dev/coordination/log.md 2026-05-26T17:10Z entry).
  • aiolabs/satmachineadmin — v2 epic (depends on this).
  • aiolabs/lamassu-next#6 — Dynamic Commission System (this is the upstream rationale for not back-deriving).
  • aiolabs/lamassu-next#22 — LNBits as Nostr-native UI layer (the architectural direction).
  • Canonical vocabulary memory: ~/.claude/projects/-home-padreug-dev-shared-extensions-satmachineadmin/memory/reference_sat_amount_vocabulary.md — read before touching field names in any of the three repos.
> _Migrated from [aiolabs/lamassu-next#44](https://git.atitlan.io/aiolabs/lamassu-next/issues/44) — opened by @padreug on 2026-05-14._\n\n## Context The new `satmachineadmin` v2 (LNbits extension on the `nostr-transport` branch — see `aiolabs/satmachineadmin` v2 epic) subscribes to kind-21000 settlement pushes for an operator's ATM wallets and runs the principal-to-LP distribution and the two-stage commission split (super first, then operator-defined remainder). For this to be precise, it needs to know the principal (`principal_sats`) and the fee (`fee_sats`) separately for each transaction — and **without back-deriving** them from a contractual commission rate (the way the old Lamassu integration did via `base = total / (1 + commission)`). ## Canonical vocabulary (locked 2026-05-26) Cross-codebase decision across satmachineadmin + lamassu-next + atm-tui — see memory file `reference_sat_amount_vocabulary.md` in the satmachineadmin session. Use these names verbatim on Payment.extra and on `state.db`: | Field | Unit | Meaning | |---|---|---| | `wire_sats` | int | Actual Lightning payment amount. Direction-agnostic. (= `payment.sat`) | | `principal_sats` | int | Market-rate sats for the customer's fiat, BEFORE commission. `floor(fiat × exchange_rate)`. | | `fee_sats` | int | Commission. Operator's cut. | | `fee_fraction` | float in [0, 1] | Commission rate as a unit fraction. **Never percentage**. `0.05` = 5%. | Invariants (assertion-worthy at every layer): - cash-out: `wire_sats == principal_sats + fee_sats` - cash-in: `wire_sats == principal_sats - fee_sats` AND `fee_sats <= principal_sats` - always: `0 <= fee_fraction <= 1` ## The gap **Internally, bitSpire already keeps the split separate.** The state-machine context (`packages/state-machine/src/types.ts` + `apps/machine/src/stores/atm.ts:computeFeeSats`) computes `feeSats` and `feeFraction` (per canonical; today still `feePercent` pending rename), and the transaction record (`apps/machine/src/types/state.ts`) carries `sats` / `feeSats` / `feePercent` / `exchangeRate` / `currency` / `bills[]` / `cassettes[]` / `txid` as distinct fields. **But the invoice creation initially dropped all of this.** Pre-`138cd1a` `generateInvoice` in `apps/machine/src/services/lightning.ts` passed only `{amount, memo, unit}` to `lnbits.createInvoice()`. So when LNbits later pushed the settlement (kind-21000), the receiving extension saw only "wallet got X sats with payment_hash Y" and had to either back-derive the split (Lamassu-style — error-prone for tiered/dynamic commissions per #6) or fall back to a separate per-machine config the operator must keep in sync. ## The ask Populate `Payment.extra` with the canonical split fields on **every cash-out invoice** bitSpire creates, and the analogous fields on **every cash-in LNURL-withdraw link**. LNbits already persists `Payment.extra` (see `lnbits/core/models/payments.py:79` on the `nostr-transport` branch) and includes it in the kind-21000 settlement push. Proposed payload (cash-out, **canonical names**): ```ts const payment = await lnbits.createInvoice(lnbitsWalletId, { amount: amountSats, memo: 'bitSpire - Cash Out', unit: 'sat', extra: { source: 'bitspire', type: 'cash_out', txid, // bitSpire's internal tx id machine_npub: identity.npub, // for cross-verification on receiver principal_sats, // what the LP-distribution algorithm should see fee_sats, // commission — what feeds the split engine fee_fraction, // commission rate as unit fraction in [0, 1] // (e.g. 0.05 — NEVER 5.0) exchange_rate, // sats per 1 fiat unit currency, // matches the machine's fiat code fiat_amount, // operator-facing display + audit bills: ctx.bills, // for partial-dispense reconciliation later cassettes: ctx.cassettes, }, }) ``` For cash-in (`generateLnurlWithdraw` in the same file): equivalent fields on the LNURL-withdraw link metadata so the LNbits `subscribe_payments({tag:"withdraw", link_id})` push carries the same shape. Cash-in invariant means `wire_sats < principal_sats` (commission deducted from the principal); use `tx_type: 'cash_in'`. ## Why this matters - **Eliminates back-derivation.** Lamassu forced satmachineadmin to compute `base = total / (1 + commission)` from a stored commission rate. With dynamic/tiered commissions (#6 — on-chain-fee-adjusted, amount-tiered, promo-coded), back-derivation is unreliable. bitSpire is the source of truth on what the split *actually was* for this specific transaction; pass it through. - **Enables the v2 customer-discount engine** (planned post-v1 in satmachineadmin) without a schema migration: the audit trail of who-forgave-what comes for free from `Payment.extra`. - **Helps partial-dispense recovery** (satmachineadmin#3): if the ATM only dispensed 6 of 10 bills, satmachineadmin needs `bills[]` and `cassettes[]` to recompute the correct partial settlement. ## Acceptance - [x] `generateInvoice` populates `extra` with the canonical fields (shipped at `138cd1a` 2026-05-16). Note: field currently stamped as `fee_percent` with `feePercent * 100` — pending rename to `fee_fraction` (drop the `* 100`) per the canonical decision. - [ ] `generateLnurlWithdraw` populates equivalent metadata on the withdraw link (cash-in flow, awaiting satmachineadmin S8 / #22 + #25). - [x] Verified end-to-end: a cash-out tx from a Sintra dev unit → LNbits dev instance → `Payment.extra` round-trips intact in the kind-21000 settlement push received by an extension subscriber. - [x] No regressions on the customer wallet side (the BOLT11 is unchanged; this is metadata only). - [ ] **Rename `fee_percent` → `fee_fraction` on Payment.extra** + drop the `* 100` conversion at `lightning.ts:~780` so the wire field is a unit fraction in [0, 1] consistent with `state.db` storage and the satmachineadmin canonical (coordinated via `~/dev/coordination/log.md` 2026-05-26T17:10Z entry). ## Related - `aiolabs/satmachineadmin` — v2 epic (depends on this). - `aiolabs/lamassu-next#6` — Dynamic Commission System (this is the upstream rationale for not back-deriving). - `aiolabs/lamassu-next#22` — LNBits as Nostr-native UI layer (the architectural direction). - **Canonical vocabulary memory**: `~/.claude/projects/-home-padreug-dev-shared-extensions-satmachineadmin/memory/reference_sat_amount_vocabulary.md` — read before touching field names in any of the three repos.
Author
Owner

@padreug commented on 2026-05-14 (lamassu-next#44):

Proposing a rename in the extra shape: net_sats → principal_sats.

"Net" is doing too much work in this codebase already:

  • The issue body itself defines net_sats as "principal — what the LP-distribution algorithm should see" — so we may as well call the field what we mean.
  • apps/machine/src/stores/atm.ts:31,36 already uses "net" with the opposite meaning: "Cash-in: customer gets net (gross - fee)". There, "net" is the customer's take-home (after commission is withheld), not the principal. Reusing the same word for "principal on cash-out" in Payment.extra would be a foot-gun for anyone reading both sides.
  • bitspire/atm-tui already settled on "principal" for this concept: column header "Principal" in src/main.zig:98,716, and src/db.zig:166-171 derives it directly:
    // Principal = gross sats (exchange value at market rate)
    // cash_in:  sats = gross - fee, so gross = sats + fee_sats
    // cash_out: sats = gross + fee, so gross = sats - fee_sats
    const principal = if (is_cash_in) sats + fee_sats else sats - fee_sats;
    
    Matching that vocabulary downstream keeps the operator-facing TUI, the machine DB schema, and the kind-21000 push aligned on one word.

So the proposed shape becomes:

extra: {
  source: 'bitspire',
  type: 'cash_out',
  txid,
  machine_npub,
  principal_sats,   // ← was net_sats
  fee_sats,
  fee_pct,
  exchange_rate,
  currency,
  bills,
  cassettes,
}

Same idea for generateLnurlWithdraw metadata. No semantic change vs. the original proposal — just removing the operator-vs-customer ambiguity around "net" and aligning with the existing principal term in atm-tui and the sats/fee_sats DB columns in state-store.ts.

(Tiny side note unrelated to the rename: the issue body points at packages/state-machine/src/types.ts:33-39 for computeFeeSats, but it actually lives at apps/machine/src/stores/atm.ts:33-39 on dev. Worth fixing the link when you're back in the issue.)

> _@padreug commented on 2026-05-14 ([lamassu-next#44](https://git.atitlan.io/aiolabs/lamassu-next/issues/44#issuecomment-598)):_ Proposing a rename in the `extra` shape: **`net_sats` → `principal_sats`**. "Net" is doing too much work in this codebase already: - The issue body itself defines `net_sats` as *"principal — what the LP-distribution algorithm should see"* — so we may as well call the field what we mean. - `apps/machine/src/stores/atm.ts:31,36` already uses "net" with the **opposite** meaning: `"Cash-in: customer gets net (gross - fee)"`. There, "net" is the customer's take-home (after commission is withheld), not the principal. Reusing the same word for "principal on cash-out" in `Payment.extra` would be a foot-gun for anyone reading both sides. - `bitspire/atm-tui` already settled on **"principal"** for this concept: column header `"Principal"` in `src/main.zig:98,716`, and `src/db.zig:166-171` derives it directly: ```zig // Principal = gross sats (exchange value at market rate) // cash_in: sats = gross - fee, so gross = sats + fee_sats // cash_out: sats = gross + fee, so gross = sats - fee_sats const principal = if (is_cash_in) sats + fee_sats else sats - fee_sats; ``` Matching that vocabulary downstream keeps the operator-facing TUI, the machine DB schema, and the kind-21000 push aligned on one word. So the proposed shape becomes: ```ts extra: { source: 'bitspire', type: 'cash_out', txid, machine_npub, principal_sats, // ← was net_sats fee_sats, fee_pct, exchange_rate, currency, bills, cassettes, } ``` Same idea for `generateLnurlWithdraw` metadata. No semantic change vs. the original proposal — just removing the operator-vs-customer ambiguity around "net" and aligning with the existing `principal` term in atm-tui and the `sats`/`fee_sats` DB columns in `state-store.ts`. (Tiny side note unrelated to the rename: the issue body points at `packages/state-machine/src/types.ts:33-39` for `computeFeeSats`, but it actually lives at `apps/machine/src/stores/atm.ts:33-39` on `dev`. Worth fixing the link when you're back in the issue.)
Author
Owner

@padreug commented on 2026-05-14 (lamassu-next#44):

One more small alignment while we're tidying field names: fee_pct → fee_percent in the extra shape.

The canonical bitSpire state DB (/var/lib/bitspire/state.db, schema at apps/machine/electron/state-store.ts:67-80) stores the column as fee_percent, and atm-tui reads it under the same name (src/db.zig:143,219). Using fee_pct only in Payment.extra would be the one outlier across DB ↔ TUI ↔ kind-21000 push. Cheap to keep the full word everywhere.

Final shape:

extra: {
  source: 'bitspire',
  type: 'cash_out',
  txid,
  machine_npub,
  principal_sats,   // ← was net_sats
  fee_sats,
  fee_percent,      // ← was fee_pct
  exchange_rate,
  currency,
  bills,
  cassettes,
}

(Note for implementers: principal_sats is derived at invoice-creation time — it's not a stored column. principal_sats = sats - fee_sats on cash-out, sats + fee_sats on cash-in, matching the atm-tui read-path derivation in src/db.zig:166-171.)

> _@padreug commented on 2026-05-14 ([lamassu-next#44](https://git.atitlan.io/aiolabs/lamassu-next/issues/44#issuecomment-599)):_ One more small alignment while we're tidying field names: **`fee_pct` → `fee_percent`** in the `extra` shape. The canonical bitSpire state DB (`/var/lib/bitspire/state.db`, schema at `apps/machine/electron/state-store.ts:67-80`) stores the column as `fee_percent`, and atm-tui reads it under the same name (`src/db.zig:143,219`). Using `fee_pct` only in `Payment.extra` would be the one outlier across DB ↔ TUI ↔ kind-21000 push. Cheap to keep the full word everywhere. Final shape: ```ts extra: { source: 'bitspire', type: 'cash_out', txid, machine_npub, principal_sats, // ← was net_sats fee_sats, fee_percent, // ← was fee_pct exchange_rate, currency, bills, cassettes, } ``` (Note for implementers: `principal_sats` is derived at invoice-creation time — it's not a stored column. `principal_sats = sats - fee_sats` on cash-out, `sats + fee_sats` on cash-in, matching the atm-tui read-path derivation in `src/db.zig:166-171`.)
Author
Owner

@padreug commented on 2026-05-14 (lamassu-next#44):

Confirming the semantics of exchange_rate for any implementer reading this thread:

exchange_rate is the unadjusted market rate, in sats per 1 fiat unit. Commission is NOT baked in. The commission lives entirely in fee_sats / fee_percent.

Two sources prove this:

  1. Sourcing — fetchExchangeRate() (called from apps/machine/src/services/lightning.ts:777-781) pulls from LNbits / rates.shockwallet.app / CoinGecko in priority order. No markup layer between the rate provider and ctx.exchangeRate.

  2. Math invariant — computeFeeSats() (apps/machine/src/stores/atm.ts:33-39) is built on:

    const grossSats = Math.floor((ctx.fiatCents / 100) * ctx.exchangeRate)
    const feeSats = isCashIn
      ? grossSats - ctx.satsAmount     // cash-in
      : ctx.satsAmount - grossSats     // cash-out
    

    grossSats here is principal_sats. If exchangeRate already had commission baked in, feeSats would always come out to ~0 and the entire split would collapse. So by construction, exchangeRate must be the raw market rate.

Practical consequence: principal_sats is fully reconstructable downstream as floor((fiat_cents / 100) * exchange_rate). We're shipping it explicitly in extra anyway to avoid floor-rounding drift and to keep consumers from needing the formula, but it's a derived value, not new state.

Worked example for the proposed extra shape, using "USD at 100k USD/BTC, 5% commission, customer cashing out 100 USD":

extra: {
  source: 'bitspire',
  type: 'cash_out',
  txid: '...',
  machine_npub: '...',
  principal_sats: 100_000,     // = floor((10_000 / 100) * 1000)
  fee_sats: 5_000,             // 5% commission
  fee_percent: 5.0,            // matches DB column name
  exchange_rate: 1000,         // sats/USD — i.e., 100k USD/BTC market rate
  currency: 'USD',
  bills: [...],
  cassettes: [...],
}
// Customer's BOLT11 invoice amount: 105_000 sats (= principal_sats + fee_sats)
// Effective customer-facing rate (derived, never stored): ~105k USD/BTC

Side note on internal naming (optional follow-up, not part of this issue's scope): the variable grossSats in apps/machine/src/stores/atm.ts:34,853,1109 and apps/machine/src/views/CashInView.vue:123 is computing exactly the same quantity we're now calling principal_sats in the wire format. "Gross" has the same operator-vs-customer-perspective ambiguity that pushed us off "net": from the customer's POV on cash-out, the gross payment is satsAmount (principal + commission), not grossSats (principal alone). atm-tui already uses principal for this concept (src/db.zig:166-171, src/main.zig:98,716).

Worth a small follow-up PR to rename grossSats → principalSats in the bitspire machine code so the term is uniform across DB-derived TUI, internal state-machine math, and the new Payment.extra wire payload. Out of scope for this issue but flagging for tracking.

> _@padreug commented on 2026-05-14 ([lamassu-next#44](https://git.atitlan.io/aiolabs/lamassu-next/issues/44#issuecomment-600)):_ Confirming the **semantics of `exchange_rate`** for any implementer reading this thread: > **`exchange_rate` is the unadjusted market rate, in sats per 1 fiat unit. Commission is NOT baked in. The commission lives entirely in `fee_sats` / `fee_percent`.** Two sources prove this: 1. **Sourcing** — `fetchExchangeRate()` (called from `apps/machine/src/services/lightning.ts:777-781`) pulls from LNbits / `rates.shockwallet.app` / CoinGecko in priority order. No markup layer between the rate provider and `ctx.exchangeRate`. 2. **Math invariant** — `computeFeeSats()` (`apps/machine/src/stores/atm.ts:33-39`) is built on: ```ts const grossSats = Math.floor((ctx.fiatCents / 100) * ctx.exchangeRate) const feeSats = isCashIn ? grossSats - ctx.satsAmount // cash-in : ctx.satsAmount - grossSats // cash-out ``` `grossSats` here **is** `principal_sats`. If `exchangeRate` already had commission baked in, `feeSats` would always come out to ~0 and the entire split would collapse. So by construction, `exchangeRate` must be the raw market rate. Practical consequence: `principal_sats` is fully reconstructable downstream as `floor((fiat_cents / 100) * exchange_rate)`. We're shipping it explicitly in `extra` anyway to avoid floor-rounding drift and to keep consumers from needing the formula, but it's a derived value, not new state. Worked example for the proposed `extra` shape, using "USD at 100k USD/BTC, 5% commission, customer cashing out 100 USD": ```ts extra: { source: 'bitspire', type: 'cash_out', txid: '...', machine_npub: '...', principal_sats: 100_000, // = floor((10_000 / 100) * 1000) fee_sats: 5_000, // 5% commission fee_percent: 5.0, // matches DB column name exchange_rate: 1000, // sats/USD — i.e., 100k USD/BTC market rate currency: 'USD', bills: [...], cassettes: [...], } // Customer's BOLT11 invoice amount: 105_000 sats (= principal_sats + fee_sats) // Effective customer-facing rate (derived, never stored): ~105k USD/BTC ``` --- **Side note on internal naming (optional follow-up, not part of this issue's scope):** the variable `grossSats` in `apps/machine/src/stores/atm.ts:34,853,1109` and `apps/machine/src/views/CashInView.vue:123` is computing exactly the same quantity we're now calling `principal_sats` in the wire format. "Gross" has the same operator-vs-customer-perspective ambiguity that pushed us off "net": from the customer's POV on cash-out, the *gross* payment is `satsAmount` (principal + commission), not `grossSats` (principal alone). atm-tui already uses **`principal`** for this concept (`src/db.zig:166-171`, `src/main.zig:98,716`). Worth a small follow-up PR to rename `grossSats` → `principalSats` in the bitspire machine code so the term is uniform across DB-derived TUI, internal state-machine math, and the new `Payment.extra` wire payload. Out of scope for this issue but flagging for tracking.
Author
Owner

@padreug commented on 2026-05-14 (lamassu-next#44):

The side-note follow-up is landed on dev: a356c04 — grossSats → principalSats across apps/machine/src/stores/atm.ts, apps/machine/src/views/CashInView.vue, and packages/state-machine/src/machine.ts. 3 files, 25/22 lines, pure mechanical rename + JSDoc rewrite on computeFeeSats. Typecheck clean, state-machine tests all 18 pass.

So now the internal variable name matches what we'll be putting on the wire when this issue is implemented — one fewer translation step for whoever picks up the Payment.extra work.

> _@padreug commented on 2026-05-14 ([lamassu-next#44](https://git.atitlan.io/aiolabs/lamassu-next/issues/44#issuecomment-602)):_ The side-note follow-up is landed on `dev`: [`a356c04`](https://git.atitlan.io/aiolabs/lamassu-next/commit/a356c04) — `grossSats` → `principalSats` across `apps/machine/src/stores/atm.ts`, `apps/machine/src/views/CashInView.vue`, and `packages/state-machine/src/machine.ts`. 3 files, 25/22 lines, pure mechanical rename + JSDoc rewrite on `computeFeeSats`. Typecheck clean, state-machine tests all 18 pass. So now the internal variable name matches what we'll be putting on the wire when this issue is implemented — one fewer translation step for whoever picks up the `Payment.extra` work.
Author
Owner

@padreug commented on 2026-05-15 (lamassu-next#44):

End-to-end evidence from 2026-05-15 Sintra cash-out test

Real cash-out on the Sintra at 192.168.0.252 against the local LNbits stack (post-S5 nostr-transport branch). Customer paid 20 EUR for ~31,684 sats. The settlement landed on the operator's wallet — Lightning side worked, S5 attribution stamps round-tripped — but Payment.extra is still on the pre-#44 shape, so satmachineadmin's parse_settlement fell into _parse_fallback and recorded 0.00 EUR in the fiat column.

What bitSpire actually sent in Payment.extra

{
  "nostr_sender_pubkey": "522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891",
  "nostr_event_id":      "6fb33a8c58f1ed90e01e737a3b7e7e5ed93b7987f29d47e17f5a03d7c01ba95c",
  "wallet_fiat_currency": "USD",
  "wallet_fiat_amount":   25.056,
  "wallet_fiat_rate":     1264.5331210940942,
  "wallet_btc_rate":      79080.57
}

(The two nostr_* fields are added by LNbits' nostr-transport dispatcher, not by bitSpire — those are the S5 attribution stamps.)

What's missing per this issue's spec (post-rename names per comments #598/#599)

Every field satmachineadmin._parse_extra looks for:

Required field Sent?
source: "bitspire" ❌ no source marker → is_bitspire_payment(extra) returns False
type: "cash_out" | "cash_in" ❌
txid ❌
principal_sats ❌
fee_sats ❌
fee_percent ❌
exchange_rate ❌ (only wallet_btc_rate/wallet_fiat_rate — operator-reporting, not customer-paid)
currency ❌ (only wallet_fiat_currency = USD; customer paid EUR)
machine_npub ❌ (we get it via NIP-01 sig + nostr_sender_pubkey stamp now, so optional)
bills[], cassettes[] ❌

Cross-currency note

Worth flagging beyond what this issue already specifies: bitSpire is currently sending operator-side wallet-reporting fields (wallet_fiat_* in USD), which is not the same as customer-paid fiat. The Sintra is configured fiat_code=EUR, customer paid 20 EUR, but wallet_fiat_currency=USD with wallet_fiat_amount=25.056. Even if satmachineadmin read these keys verbatim it would display 25.06 USD against an EUR-configured machine — confusing.

The spec in this issue is right to call for currency = the customer-paid currency (i.e., the machine's fiat_code) and exchange_rate derived against that, not the operator's reporting currency. Just calling it out so the implementation doesn't accidentally re-use the existing wallet_fiat_* plumbing.

Symptoms on the satmachineadmin side

  • Settlements UI shows 0.00 EUR in the Fiat column (the _parse_fallback default).
  • The ⚠ icon next to processed is the used_fallback_split=true indicator — already firing.
  • Distribution legs both skipped with messages that point at this exact gap:
    • dca leg → "no exchange_rate on settlement (bitSpire fallback path; see aiolabs/lamassu-next#44)"
    • operator_split leg → orthogonal: operator hasn't configured a commission_splits ruleset.
  • Filed aiolabs/satmachineadmin#25 for the UX side (make the fallback-row warning more prominent so operators don't think it's a satmachineadmin bug while this issue is still open).
  • Landed aiolabs/satmachineadmin@1feaba8 renaming the consumer-side column + Pydantic field from net_sats → principal_sats and column SQL alias accordingly, so the LNbits-side now matches what this issue's #598 comment proposed for the wire payload.

S5 attribution worked end-to-end

Worth noting for context: the nostr_sender_pubkey stamp matches the Sintra's machine_npub, assert_nostr_attribution returned silently, the row landed status='processed'. The settlement plumbing is healthy — only the Payment.extra content on the bitSpire side is short.

Once this issue lands and a Sintra firmware update ships, satmachineadmin should record customer-paid EUR amounts with no further code changes on its side.


[edit: replaced net_sats/fee_pct in the required-fields table with the renamed principal_sats/fee_percent per #598/#599, and added the cross-link to the satmachineadmin-side rename commit so this issue's thread is consistent with what the consumer now expects on the wire.]

> _@padreug commented on 2026-05-15 ([lamassu-next#44](https://git.atitlan.io/aiolabs/lamassu-next/issues/44#issuecomment-697)):_ ## End-to-end evidence from 2026-05-15 Sintra cash-out test Real cash-out on the Sintra at 192.168.0.252 against the local LNbits stack (post-S5 nostr-transport branch). Customer paid **20 EUR** for ~31,684 sats. The settlement landed on the operator's wallet — Lightning side worked, S5 attribution stamps round-tripped — **but `Payment.extra` is still on the pre-#44 shape**, so satmachineadmin's `parse_settlement` fell into `_parse_fallback` and recorded **0.00 EUR** in the fiat column. ### What bitSpire actually sent in `Payment.extra` ```json { "nostr_sender_pubkey": "522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891", "nostr_event_id": "6fb33a8c58f1ed90e01e737a3b7e7e5ed93b7987f29d47e17f5a03d7c01ba95c", "wallet_fiat_currency": "USD", "wallet_fiat_amount": 25.056, "wallet_fiat_rate": 1264.5331210940942, "wallet_btc_rate": 79080.57 } ``` (The two `nostr_*` fields are added by LNbits' nostr-transport dispatcher, not by bitSpire — those are the S5 attribution stamps.) ### What's missing per this issue's spec (post-rename names per comments [#598](https://git.atitlan.io/aiolabs/lamassu-next/issues/44#issuecomment-598)/[#599](https://git.atitlan.io/aiolabs/lamassu-next/issues/44#issuecomment-599)) Every field `satmachineadmin._parse_extra` looks for: | Required field | Sent? | |---|---| | `source: "bitspire"` | ❌ no source marker → `is_bitspire_payment(extra)` returns False | | `type: "cash_out"` \| `"cash_in"` | ❌ | | `txid` | ❌ | | `principal_sats` | ❌ | | `fee_sats` | ❌ | | `fee_percent` | ❌ | | `exchange_rate` | ❌ (only `wallet_btc_rate`/`wallet_fiat_rate` — operator-reporting, not customer-paid) | | `currency` | ❌ (only `wallet_fiat_currency` = USD; customer paid EUR) | | `machine_npub` | ❌ (we get it via NIP-01 sig + `nostr_sender_pubkey` stamp now, so optional) | | `bills[]`, `cassettes[]` | ❌ | ### Cross-currency note Worth flagging beyond what this issue already specifies: bitSpire is currently sending operator-side wallet-reporting fields (`wallet_fiat_*` in USD), which is **not the same** as customer-paid fiat. The Sintra is configured `fiat_code=EUR`, customer paid 20 EUR, but `wallet_fiat_currency=USD` with `wallet_fiat_amount=25.056`. Even if satmachineadmin read these keys verbatim it would display `25.06 USD` against an EUR-configured machine — confusing. The spec in this issue is right to call for `currency` = the customer-paid currency (i.e., the machine's `fiat_code`) and `exchange_rate` derived against that, not the operator's reporting currency. Just calling it out so the implementation doesn't accidentally re-use the existing `wallet_fiat_*` plumbing. ### Symptoms on the satmachineadmin side - Settlements UI shows `0.00 EUR` in the Fiat column (the `_parse_fallback` default). - The `⚠` icon next to `processed` is the `used_fallback_split=true` indicator — already firing. - Distribution legs both skipped with messages that point at this exact gap: - `dca` leg → `"no exchange_rate on settlement (bitSpire fallback path; see aiolabs/lamassu-next#44)"` - `operator_split` leg → orthogonal: operator hasn't configured a `commission_splits` ruleset. - Filed `aiolabs/satmachineadmin#25` for the UX side (make the fallback-row warning more prominent so operators don't think it's a satmachineadmin bug while this issue is still open). - Landed `aiolabs/satmachineadmin@1feaba8` renaming the consumer-side column + Pydantic field from `net_sats` → `principal_sats` and column SQL alias accordingly, so the LNbits-side now matches what this issue's [#598](https://git.atitlan.io/aiolabs/lamassu-next/issues/44#issuecomment-598) comment proposed for the wire payload. ### S5 attribution worked end-to-end Worth noting for context: the `nostr_sender_pubkey` stamp matches the Sintra's `machine_npub`, `assert_nostr_attribution` returned silently, the row landed `status='processed'`. The settlement plumbing is healthy — only the `Payment.extra` *content* on the bitSpire side is short. Once this issue lands and a Sintra firmware update ships, satmachineadmin should record customer-paid EUR amounts with no further code changes on its side. --- *[edit: replaced `net_sats`/`fee_pct` in the required-fields table with the renamed `principal_sats`/`fee_percent` per [#598](https://git.atitlan.io/aiolabs/lamassu-next/issues/44#issuecomment-598)/[#599](https://git.atitlan.io/aiolabs/lamassu-next/issues/44#issuecomment-599), and added the cross-link to the satmachineadmin-side rename commit so this issue's thread is consistent with what the consumer now expects on the wire.]*
Author
Owner

@padreug commented on 2026-05-16 (lamassu-next#44):

Cash-out side implemented — 138cd1a on dev

generateInvoice now stamps Payment.extra with the agreed shape:

extra: {
  source:         'bitspire',
  type:           'cash_out',
  txid:           context.txid,
  principal_sats: floor((fiatCents / 100) * exchangeRate),
  fee_sats:       max(0, satsAmount - principal_sats),
  fee_percent:    feePercent * 100,
  exchange_rate:  context.exchangeRate,
  currency:       context.currency,
}

Field names follow the #598/#599/#600 consensus (principal_sats, fee_percent). satmachineadmin already reads these names; the consumer side has been aligned since 1feaba8 on aiolabs/satmachineadmin v2-bitspire.

Plumbing: ATMServices.generateInvoice signature changed from (amountMsat: number) to (context: ATMContext) so the implementation has access to the full split rather than just the BOLT11 amount. State-machine actor + dev mock updated to match. All 18 state-machine tests still pass.

What's covered

  • generateInvoice populates extra with source/type/txid/principal_sats/fee_sats/fee_percent/exchange_rate/currency
  • generateLnurlWithdraw populates equivalent metadata on the withdraw link
  • Verified end-to-end: cash-out tx from a Sintra dev unit → LNbits dev instance → Payment.extra round-trips intact in the kind-21000 settlement push received by satmachineadmin

What's not covered

  • bills / cassettes — not stamped yet. Meaningful for partial-dispense reconciliation (aiolabs/satmachineadmin#3) and cash-in flows but the consumer side doesn't read them yet.
  • generateLnurlWithdraw — cash-in metadata intentionally left alone. satmachineadmin's listener doesn't process the outbound LNURL-withdraw path yet (aiolabs/satmachineadmin#22). Stamping metadata that won't be read would be premature; will land alongside that issue.

Next step

End-to-end verification: rebuild + redeploy the Sintra and run a real cash-out against the local LNbits stack. The expected outcome on the satmachineadmin side:

  • parse_settlement takes the _parse_extra happy path (not _parse_fallback) → used_fallback_split=false, ⚠ icon gone.
  • fiat_amount populated correctly in the customer-paid currency.
  • DCA leg actually distributes (no more "no exchange_rate" skip) — flow-mode LPs get their proportional share.
> _@padreug commented on 2026-05-16 ([lamassu-next#44](https://git.atitlan.io/aiolabs/lamassu-next/issues/44#issuecomment-701)):_ ## Cash-out side implemented — `138cd1a` on `dev` `generateInvoice` now stamps `Payment.extra` with the agreed shape: ```ts extra: { source: 'bitspire', type: 'cash_out', txid: context.txid, principal_sats: floor((fiatCents / 100) * exchangeRate), fee_sats: max(0, satsAmount - principal_sats), fee_percent: feePercent * 100, exchange_rate: context.exchangeRate, currency: context.currency, } ``` Field names follow the [#598](#issuecomment-598)/[#599](#issuecomment-599)/[#600](#issuecomment-600) consensus (`principal_sats`, `fee_percent`). `satmachineadmin` already reads these names; the consumer side has been aligned since `1feaba8` on `aiolabs/satmachineadmin v2-bitspire`. Plumbing: `ATMServices.generateInvoice` signature changed from `(amountMsat: number)` to `(context: ATMContext)` so the implementation has access to the full split rather than just the BOLT11 amount. State-machine actor + dev mock updated to match. All 18 state-machine tests still pass. ### What's covered - [x] `generateInvoice` populates `extra` with `source`/`type`/`txid`/`principal_sats`/`fee_sats`/`fee_percent`/`exchange_rate`/`currency` - [ ] `generateLnurlWithdraw` populates equivalent metadata on the withdraw link - [ ] Verified end-to-end: cash-out tx from a Sintra dev unit → LNbits dev instance → `Payment.extra` round-trips intact in the kind-21000 settlement push received by satmachineadmin ### What's not covered - **`bills` / `cassettes`** — not stamped yet. Meaningful for partial-dispense reconciliation (`aiolabs/satmachineadmin#3`) and cash-in flows but the consumer side doesn't read them yet. - **`generateLnurlWithdraw`** — cash-in metadata intentionally left alone. `satmachineadmin`'s listener doesn't process the outbound LNURL-withdraw path yet (`aiolabs/satmachineadmin#22`). Stamping metadata that won't be read would be premature; will land alongside that issue. ### Next step End-to-end verification: rebuild + redeploy the Sintra and run a real cash-out against the local LNbits stack. The expected outcome on the satmachineadmin side: - `parse_settlement` takes the `_parse_extra` happy path (not `_parse_fallback`) → `used_fallback_split=false`, `⚠` icon gone. - `fiat_amount` populated correctly in the customer-paid currency. - DCA leg actually **distributes** (no more `"no exchange_rate"` skip) — flow-mode LPs get their proportional share.
Author
Owner

@padreug commented on 2026-05-26 (lamassu-next#44):

2026-05-26 — body updated for canonical vocabulary

The original body used net_sats and fee_pct — pre-canonical naming that's since been overhauled. Body now uses the canonical principal_sats / fee_sats / fee_fraction per the cross-codebase decision logged at memory reference_sat_amount_vocabulary.md (also captured in the coordination log ~/dev/coordination/log.md 2026-05-26T17:10Z entry).

What's changed in the codebase since this issue was filed:

  • ✅ The principal/fee split stamping itself shipped at commit 138cd1a (2026-05-16). apps/machine/src/services/lightning.ts:generateInvoice populates Payment.extra with source, type, txid, fiat_amount, currency, principal_sats, fee_sats, fee_percent, exchange_rate. Cash-out path is end-to-end verified against the satmachineadmin extension.
  • ⏸️ Two follow-ups remain (see acceptance checklist):
    1. fee_percent → fee_fraction rename + drop the * 100 at lightning.ts:~780. This is the wire-side half of the canonical pass; satmachineadmin side already reads fee_fraction and ignores the old fee_percent field (specifically to dodge the 100× misinterpretation risk during the rename window). State.db column rename (state-store.ts) needs to land in the same change, plus the atm-tui read.
    2. generateLnurlWithdraw cash-in equivalent — blocked on satmachineadmin S8 work (#22), which now lives on the consumer side. lamassu-next is ready to add the stamp once the cash-in flow is wired downstream.

For the canonical update specifically:

  • Decision lives in memory at ~/.claude/projects/-home-padreug-dev-shared-extensions-satmachineadmin/memory/reference_sat_amount_vocabulary.md. Worth reading before any work that touches field names.
  • Canonical names: wire_sats, principal_sats, fee_sats, fee_fraction. NEVER gross_sats (direction-misleading), commission_sats (gratuitous rename), _pct / _percent (unit-ambiguous), or net_sats (also direction-misleading).
  • Sum invariants: cash-out wire = principal + fee; cash-in wire = principal - fee. Assertion-worthy at every layer that handles the values.

This issue stays open until both remaining checklist items land.

> _@padreug commented on 2026-05-26 ([lamassu-next#44](https://git.atitlan.io/aiolabs/lamassu-next/issues/44#issuecomment-1181)):_ ## 2026-05-26 — body updated for canonical vocabulary The original body used `net_sats` and `fee_pct` — pre-canonical naming that's since been overhauled. Body now uses the canonical `principal_sats` / `fee_sats` / `fee_fraction` per the cross-codebase decision logged at memory `reference_sat_amount_vocabulary.md` (also captured in the coordination log `~/dev/coordination/log.md` 2026-05-26T17:10Z entry). **What's changed in the codebase since this issue was filed:** - ✅ The principal/fee split stamping itself **shipped at commit `138cd1a`** (2026-05-16). `apps/machine/src/services/lightning.ts:generateInvoice` populates `Payment.extra` with `source`, `type`, `txid`, `fiat_amount`, `currency`, `principal_sats`, `fee_sats`, `fee_percent`, `exchange_rate`. Cash-out path is end-to-end verified against the satmachineadmin extension. - ⏸️ Two follow-ups remain (see acceptance checklist): 1. **`fee_percent` → `fee_fraction` rename + drop the `* 100`** at `lightning.ts:~780`. This is the wire-side half of the canonical pass; satmachineadmin side already reads `fee_fraction` and ignores the old `fee_percent` field (specifically to dodge the 100× misinterpretation risk during the rename window). State.db column rename (`state-store.ts`) needs to land in the same change, plus the atm-tui read. 2. **`generateLnurlWithdraw` cash-in equivalent** — blocked on satmachineadmin S8 work (#22), which now lives on the consumer side. lamassu-next is ready to add the stamp once the cash-in flow is wired downstream. **For the canonical update specifically:** - Decision lives in memory at `~/.claude/projects/-home-padreug-dev-shared-extensions-satmachineadmin/memory/reference_sat_amount_vocabulary.md`. Worth reading before any work that touches field names. - Canonical names: `wire_sats`, `principal_sats`, `fee_sats`, `fee_fraction`. NEVER `gross_sats` (direction-misleading), `commission_sats` (gratuitous rename), `_pct` / `_percent` (unit-ambiguous), or `net_sats` (also direction-misleading). - Sum invariants: cash-out `wire = principal + fee`; cash-in `wire = principal - fee`. Assertion-worthy at every layer that handles the values. This issue stays open until both remaining checklist items land.
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#44
No description provided.