Carry principal/fee split end-to-end via Payment.extra on cash-out invoices (and LNURL-withdraw equivalent) #44
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The new
satmachineadminv2 (LNbits extension on thenostr-transportbranch — seeaiolabs/satmachineadminv2 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 viabase = 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.mdin the satmachineadmin session. Use these names verbatim on Payment.extra and onstate.db:wire_satspayment.sat)principal_satsfloor(fiat × exchange_rate).fee_satsfee_fraction0.05= 5%.Invariants (assertion-worthy at every layer):
wire_sats == principal_sats + fee_satswire_sats == principal_sats - fee_satsANDfee_sats <= principal_sats0 <= fee_fraction <= 1The 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) computesfeeSatsandfeeFraction(per canonical; today stillfeePercentpending rename), and the transaction record (apps/machine/src/types/state.ts) carriessats/feeSats/feePercent/exchangeRate/currency/bills[]/cassettes[]/txidas distinct fields.But the invoice creation initially dropped all of this. Pre-
138cd1agenerateInvoiceinapps/machine/src/services/lightning.tspassed only{amount, memo, unit}tolnbits.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.extrawith the canonical split fields on every cash-out invoice bitSpire creates, and the analogous fields on every cash-in LNURL-withdraw link. LNbits already persistsPayment.extra(seelnbits/core/models/payments.py:79on thenostr-transportbranch) and includes it in the kind-21000 settlement push.Proposed payload (cash-out, canonical names):
For cash-in (
generateLnurlWithdrawin the same file): equivalent fields on the LNURL-withdraw link metadata so the LNbitssubscribe_payments({tag:"withdraw", link_id})push carries the same shape. Cash-in invariant meanswire_sats < principal_sats(commission deducted from the principal); usetx_type: 'cash_in'.Why this matters
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.Payment.extra.bills[]andcassettes[]to recompute the correct partial settlement.Acceptance
generateInvoicepopulatesextrawith the canonical fields (shipped at138cd1a2026-05-16). Note: field currently stamped asfee_percentwithfeePercent * 100— pending rename tofee_fraction(drop the* 100) per the canonical decision.generateLnurlWithdrawpopulates equivalent metadata on the withdraw link (cash-in flow, awaiting satmachineadmin S8 / #22 + #25).Payment.extraround-trips intact in the kind-21000 settlement push received by an extension subscriber.fee_percent→fee_fractionon Payment.extra + drop the* 100conversion atlightning.ts:~780so the wire field is a unit fraction in [0, 1] consistent withstate.dbstorage and the satmachineadmin canonical (coordinated via~/dev/coordination/log.md2026-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).~/.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.Proposing a rename in the
extrashape:net_sats→principal_sats."Net" is doing too much work in this codebase already:
net_satsas "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,36already 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" inPayment.extrawould be a foot-gun for anyone reading both sides.bitspire/atm-tuialready settled on "principal" for this concept: column header"Principal"insrc/main.zig:98,716, andsrc/db.zig:166-171derives it directly: 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:
Same idea for
generateLnurlWithdrawmetadata. No semantic change vs. the original proposal — just removing the operator-vs-customer ambiguity around "net" and aligning with the existingprincipalterm in atm-tui and thesats/fee_satsDB columns instate-store.ts.(Tiny side note unrelated to the rename: the issue body points at
packages/state-machine/src/types.ts:33-39forcomputeFeeSats, but it actually lives atapps/machine/src/stores/atm.ts:33-39ondev. Worth fixing the link when you're back in the issue.)One more small alignment while we're tidying field names:
fee_pct→fee_percentin theextrashape.The canonical bitSpire state DB (
/var/lib/bitspire/state.db, schema atapps/machine/electron/state-store.ts:67-80) stores the column asfee_percent, and atm-tui reads it under the same name (src/db.zig:143,219). Usingfee_pctonly inPayment.extrawould be the one outlier across DB ↔ TUI ↔ kind-21000 push. Cheap to keep the full word everywhere.Final shape:
(Note for implementers:
principal_satsis derived at invoice-creation time — it's not a stored column.principal_sats = sats - fee_satson cash-out,sats + fee_satson cash-in, matching the atm-tui read-path derivation insrc/db.zig:166-171.)Confirming the semantics of
exchange_ratefor any implementer reading this thread:Two sources prove this:
Sourcing —
fetchExchangeRate()(called fromapps/machine/src/services/lightning.ts:777-781) pulls from LNbits /rates.shockwallet.app/ CoinGecko in priority order. No markup layer between the rate provider andctx.exchangeRate.Math invariant —
computeFeeSats()(apps/machine/src/stores/atm.ts:33-39) is built on:grossSatshere isprincipal_sats. IfexchangeRatealready had commission baked in,feeSatswould always come out to ~0 and the entire split would collapse. So by construction,exchangeRatemust be the raw market rate.Practical consequence:
principal_satsis fully reconstructable downstream asfloor((fiat_cents / 100) * exchange_rate). We're shipping it explicitly inextraanyway 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
extrashape, using "USD at 100k USD/BTC, 5% commission, customer cashing out 100 USD":Side note on internal naming (optional follow-up, not part of this issue's scope): the variable
grossSatsinapps/machine/src/stores/atm.ts:34,853,1109andapps/machine/src/views/CashInView.vue:123is computing exactly the same quantity we're now callingprincipal_satsin 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 issatsAmount(principal + commission), notgrossSats(principal alone). atm-tui already usesprincipalfor this concept (src/db.zig:166-171,src/main.zig:98,716).Worth a small follow-up PR to rename
grossSats→principalSatsin the bitspire machine code so the term is uniform across DB-derived TUI, internal state-machine math, and the newPayment.extrawire payload. Out of scope for this issue but flagging for tracking.The side-note follow-up is landed on
dev:a356c04—grossSats→principalSatsacrossapps/machine/src/stores/atm.ts,apps/machine/src/views/CashInView.vue, andpackages/state-machine/src/machine.ts. 3 files, 25/22 lines, pure mechanical rename + JSDoc rewrite oncomputeFeeSats. 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.extrawork.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.extrais still on the pre-#44 shape, so satmachineadmin'sparse_settlementfell into_parse_fallbackand recorded 0.00 EUR in the fiat column.What bitSpire actually sent in
Payment.extra(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_extralooks for:source: "bitspire"is_bitspire_payment(extra)returns Falsetype: "cash_out"|"cash_in"txidprincipal_satsfee_satsfee_percentexchange_ratewallet_btc_rate/wallet_fiat_rate— operator-reporting, not customer-paid)currencywallet_fiat_currency= USD; customer paid EUR)machine_npubnostr_sender_pubkeystamp 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 configuredfiat_code=EUR, customer paid 20 EUR, butwallet_fiat_currency=USDwithwallet_fiat_amount=25.056. Even if satmachineadmin read these keys verbatim it would display25.06 USDagainst an EUR-configured machine — confusing.The spec in this issue is right to call for
currency= the customer-paid currency (i.e., the machine'sfiat_code) andexchange_ratederived against that, not the operator's reporting currency. Just calling it out so the implementation doesn't accidentally re-use the existingwallet_fiat_*plumbing.Symptoms on the satmachineadmin side
0.00 EURin the Fiat column (the_parse_fallbackdefault).⚠icon next toprocessedis theused_fallback_split=trueindicator — already firing.dcaleg →"no exchange_rate on settlement (bitSpire fallback path; see aiolabs/lamassu-next#44)"operator_splitleg → orthogonal: operator hasn't configured acommission_splitsruleset.aiolabs/satmachineadmin#25for 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).aiolabs/satmachineadmin@1feaba8renaming the consumer-side column + Pydantic field fromnet_sats→principal_satsand 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_pubkeystamp matches the Sintra'smachine_npub,assert_nostr_attributionreturned silently, the row landedstatus='processed'. The settlement plumbing is healthy — only thePayment.extracontent 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_pctin the required-fields table with the renamedprincipal_sats/fee_percentper #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.]Cash-out side implemented —
138cd1aondevgenerateInvoicenow stampsPayment.extrawith the agreed shape:Field names follow the #598/#599/#600 consensus (
principal_sats,fee_percent).satmachineadminalready reads these names; the consumer side has been aligned since1feaba8onaiolabs/satmachineadmin v2-bitspire.Plumbing:
ATMServices.generateInvoicesignature 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
generateInvoicepopulatesextrawithsource/type/txid/principal_sats/fee_sats/fee_percent/exchange_rate/currencygenerateLnurlWithdrawpopulates equivalent metadata on the withdraw linkPayment.extraround-trips intact in the kind-21000 settlement push received by satmachineadminWhat'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_settlementtakes the_parse_extrahappy path (not_parse_fallback) →used_fallback_split=false,⚠icon gone.fiat_amountpopulated correctly in the customer-paid currency."no exchange_rate"skip) — flow-mode LPs get their proportional share.2026-05-26 — body updated for canonical vocabulary
The original body used
net_satsandfee_pct— pre-canonical naming that's since been overhauled. Body now uses the canonicalprincipal_sats/fee_sats/fee_fractionper the cross-codebase decision logged at memoryreference_sat_amount_vocabulary.md(also captured in the coordination log~/dev/coordination/log.md2026-05-26T17:10Z entry).What's changed in the codebase since this issue was filed:
138cd1a(2026-05-16).apps/machine/src/services/lightning.ts:generateInvoicepopulatesPayment.extrawithsource,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.fee_percent→fee_fractionrename + drop the* 100atlightning.ts:~780. This is the wire-side half of the canonical pass; satmachineadmin side already readsfee_fractionand ignores the oldfee_percentfield (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.generateLnurlWithdrawcash-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:
~/.claude/projects/-home-padreug-dev-shared-extensions-satmachineadmin/memory/reference_sat_amount_vocabulary.md. Worth reading before any work that touches field names.wire_sats,principal_sats,fee_sats,fee_fraction. NEVERgross_sats(direction-misleading),commission_sats(gratuitous rename),_pct/_percent(unit-ambiguous), ornet_sats(also direction-misleading).wire = principal + fee; cash-inwire = principal - fee. Assertion-worthy at every layer that handles the values.This issue stays open until both remaining checklist items land.