feat(machine,state-machine): stamp Payment.extra per lamassu-next#44

Cash-out invoices created via `lnbits.createInvoice()` now carry the
principal / commission / exchange-rate metadata satmachineadmin needs
to drive DCA distribution without back-deriving from a stored rate.
Closes the wire-format side of `aiolabs/lamassu-next#44`.

Wire payload (matches the canonical names agreed in #44 comments
#598/#599/#600 — `principal_sats` not `net_sats`, `fee_percent` not
`fee_pct`):

  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,  // raw market rate, sats/fiat
    currency:       context.currency,      // customer-paid currency
  }

`bills` / `cassettes` deferred — they're meaningful for cash-in and
partial-dispense reconciliation, neither of which is wired on the
satmachineadmin side yet (#22, #3).

Plumbing:
  - `ATMServices.generateInvoice` signature changes from
    `(amountMsat: number) => Promise<string>` to
    `(context: ATMContext) => Promise<string>`. The on-wire BOLT11
    amount is derived inside the service as `satsAmount * 1000` msats;
    the rest of the context drives the extra payload.
  - State-machine `generatingInvoice` actor passes the full context
    instead of just msats.
  - Dev mock in `apps/machine/src/stores/atm.ts` updated to match.

All 18 state-machine tests pass. Typecheck clean across the app.

Two `// pragma: allowlist secret` markers added to lightning.ts on
existing doc-comment lines that mention "private key" — the dev-env
pre-commit secret scanner flagged them as false positives (every
prior commit touching this file had bypassed via --no-verify).
Cash-in (`generateLnurlWithdraw`) intentionally left alone for now —
satmachineadmin's listener doesn't handle the outbound LNURL-withdraw
flow yet (`aiolabs/satmachineadmin#22`), so stamping metadata it
won't read would be premature. Will land alongside that issue.
This commit is contained in:
Padreug 2026-05-16 16:12:34 +02:00
commit b7cfb5d09b
4 changed files with 77 additions and 12 deletions

View file

@ -40,7 +40,7 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
* - VITE_RELAY_URL: Nostr relay WebSocket URL
* - VITE_LIGHTNING_PUB_PUBKEY: Lightning.Pub's Nostr pubkey (hex or npub)
* - VITE_LIGHTNING_PUB_API_URL: Lightning.Pub HTTP API URL
* - VITE_ATM_PRIVATE_KEY: ATM's Nostr private key (hex or nsec)
* - VITE_ATM_PRIVATE_KEY: ATM's Nostr private key (hex or nsec) // pragma: allowlist secret
* - VITE_ADMIN_TOKEN: Lightning.Pub admin token (dev only)
*/
interface LightningConfig {
@ -63,7 +63,7 @@ interface LightningConfig {
* Load configuration - async to support Electron IPC
*
* SECURITY: Public config comes from get-config IPC.
* Secrets (private key, admin token) come from the one-shot get-atm-secrets IPC,
* Secrets (private key, admin token) come from the one-shot get-atm-secrets IPC, // pragma: allowlist secret
* which returns secrets only once per app lifecycle.
*/
async function loadLightningConfig(): Promise<LightningConfig> {
@ -749,14 +749,61 @@ function createATMServices(
/**
* Generate a BOLT11 invoice for cash-out (customer pays the ATM).
*
* Stamps the per-transaction split on `Payment.extra` per
* `aiolabs/lamassu-next#44` so the receiving extension
* (`satmachineadmin`) doesn't have to back-derive principal /
* commission from a stored rate. The on-wire amount remains
* `context.satsAmount` sats (= principal + commission for cash-out).
*
* Field-name vocabulary follows the comment thread on #44:
* - `principal_sats` (was `net_sats`) — the LP/DCA share
* - `fee_sats` — the commission
* - `fee_percent` (was `fee_pct`) — for display + auditing
* - `exchange_rate` — sats per 1 fiat unit
* (RAW market rate, no
* commission baked in)
* - `currency` — customer-paid currency,
* matches machine fiat_code
* - `type: "cash_out"` / `source: "bitspire"` — discriminators
*/
generateInvoice: async (amountMsat: number): Promise<string> => {
const amountSats = Math.floor(amountMsat / 1000)
console.log('[ATM Service] Generating invoice for', amountSats, 'sats')
generateInvoice: async (context: ATMContext): Promise<string> => {
const amountSats = context.satsAmount
// Cash-out: satsAmount = principal + commission. principal is
// derived from the raw market rate (no commission baked in) so a
// consumer can independently audit the split.
const principalSats =
context.exchangeRate > 0
? Math.floor((context.fiatCents / 100) * context.exchangeRate)
: 0
const feeSats = Math.max(0, amountSats - principalSats)
const feePercent = +(context.feePercent * 100).toFixed(4) // 0.05 -> 5.0
console.log(
'[ATM Service] Generating invoice — gross',
amountSats,
'sats (principal',
principalSats,
'+ fee',
feeSats,
`≈ ${feePercent}% @ ${context.exchangeRate} sats/${context.currency})`
)
const payment = await lnbits.createInvoice(lnbitsWalletId, {
amount: amountSats,
memo: 'bitSpire - Cash Out',
unit: 'sat',
extra: {
source: 'bitspire',
type: 'cash_out',
txid: context.txid,
principal_sats: principalSats,
fee_sats: feeSats,
fee_percent: feePercent,
exchange_rate: context.exchangeRate,
currency: context.currency,
// bills/cassettes deferred — they're meaningful for cash-in
// and for partial-dispense reconciliation, neither of which
// is wired on the satmachineadmin side yet (#22, #3).
},
})
if (!payment.payment_request) {
throw new Error('LNbits createInvoice returned empty payment_request')

View file

@ -187,9 +187,13 @@ const mockServices: ATMServices = {
return `LNURL1MOCK${Date.now().toString(36).toUpperCase()}`
},
generateInvoice: async (amountMsat) => {
console.log('[Mock] Generating invoice for', amountMsat, 'msats')
return `lnbc${amountMsat}n1mock${Date.now().toString(36)}`
generateInvoice: async (context) => {
console.log(
'[Mock] Generating invoice for',
context.satsAmount,
`sats (gross — principal+fee on cash-out, ${context.currency})`
)
return `lnbc${context.satsAmount * 1000}n1mock${Date.now().toString(36)}`
},
getExchangeRate: async (currency) => {

View file

@ -46,7 +46,7 @@ export function createATMMachine(
}
return services.generateLnurlWithdraw(input)
}),
generateInvoice: fromPromise(async ({ input }: { input: number }) => {
generateInvoice: fromPromise(async ({ input }: { input: ATMContext }) => {
if (!services.generateInvoice) {
throw new Error('generateInvoice service not provided')
}
@ -662,7 +662,12 @@ export function createATMMachine(
generatingInvoice: {
invoke: {
src: 'generateInvoice',
input: ({ context }) => context.satsAmount * 1000, // sats to msats
// Pass the full context so the implementation can stamp
// principal/fee/exchange-rate/etc. onto Payment.extra
// (lamassu-next#44 / satmachineadmin#19). The on-wire
// BOLT11 amount is derived inside the service as
// `context.satsAmount * 1000` msats.
input: ({ context }) => context,
onDone: {
target: 'displayingInvoice',
actions: assign({

View file

@ -199,8 +199,17 @@ export interface ATMServices {
generateNdebit: (context: ATMContext) => Promise<string>
/** Generate an LNURL-withdraw link (for cash-in - customer receives sats) - legacy */
generateLnurlWithdraw: (context: ATMContext) => Promise<string>
/** Generate a Lightning invoice */
generateInvoice: (amountMsat: number) => Promise<string>
/**
* Generate a Lightning invoice (cash-out flow). Takes the full
* `ATMContext` rather than a bare amount so the implementation can
* populate `Payment.extra` with the principal/commission split,
* exchange rate, currency, etc. — consumed by `satmachineadmin`
* downstream (`aiolabs/lamassu-next#44`,
* `aiolabs/satmachineadmin#19`). The on-wire BOLT11 amount is still
* `context.satsAmount * 1000` msats (principal + commission for
* cash-out); the extras are metadata only.
*/
generateInvoice: (context: ATMContext) => Promise<string>
/** Send receipt via Nostr */
sendNostrReceipt: (context: ATMContext) => Promise<void>
/** Dispense cash (always resolves with result, never throws) */