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

@ -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) */