diff --git a/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts index 499e0ce..a2e237f 100644 --- a/apps/machine/src/services/lightning.ts +++ b/apps/machine/src/services/lightning.ts @@ -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 { @@ -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 => { - const amountSats = Math.floor(amountMsat / 1000) - console.log('[ATM Service] Generating invoice for', amountSats, 'sats') + generateInvoice: async (context: ATMContext): Promise => { + 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') diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index f6c9380..f3b71b8 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -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) => { diff --git a/packages/state-machine/src/machine.ts b/packages/state-machine/src/machine.ts index 828eb39..848ac4d 100644 --- a/packages/state-machine/src/machine.ts +++ b/packages/state-machine/src/machine.ts @@ -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({ diff --git a/packages/state-machine/src/types.ts b/packages/state-machine/src/types.ts index aa67a9c..f24d0e8 100644 --- a/packages/state-machine/src/types.ts +++ b/packages/state-machine/src/types.ts @@ -199,8 +199,17 @@ export interface ATMServices { generateNdebit: (context: ATMContext) => Promise /** Generate an LNURL-withdraw link (for cash-in - customer receives sats) - legacy */ generateLnurlWithdraw: (context: ATMContext) => Promise - /** Generate a Lightning invoice */ - generateInvoice: (amountMsat: number) => Promise + /** + * 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 /** Send receipt via Nostr */ sendNostrReceipt: (context: ATMContext) => Promise /** Dispense cash (always resolves with result, never throws) */