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:
parent
ec14bb16c6
commit
b7cfb5d09b
4 changed files with 77 additions and 12 deletions
|
|
@ -40,7 +40,7 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
|
||||||
* - VITE_RELAY_URL: Nostr relay WebSocket URL
|
* - VITE_RELAY_URL: Nostr relay WebSocket URL
|
||||||
* - VITE_LIGHTNING_PUB_PUBKEY: Lightning.Pub's Nostr pubkey (hex or npub)
|
* - VITE_LIGHTNING_PUB_PUBKEY: Lightning.Pub's Nostr pubkey (hex or npub)
|
||||||
* - VITE_LIGHTNING_PUB_API_URL: Lightning.Pub HTTP API URL
|
* - 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)
|
* - VITE_ADMIN_TOKEN: Lightning.Pub admin token (dev only)
|
||||||
*/
|
*/
|
||||||
interface LightningConfig {
|
interface LightningConfig {
|
||||||
|
|
@ -63,7 +63,7 @@ interface LightningConfig {
|
||||||
* Load configuration - async to support Electron IPC
|
* Load configuration - async to support Electron IPC
|
||||||
*
|
*
|
||||||
* SECURITY: Public config comes from get-config 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.
|
* which returns secrets only once per app lifecycle.
|
||||||
*/
|
*/
|
||||||
async function loadLightningConfig(): Promise<LightningConfig> {
|
async function loadLightningConfig(): Promise<LightningConfig> {
|
||||||
|
|
@ -749,14 +749,61 @@ function createATMServices(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a BOLT11 invoice for cash-out (customer pays the ATM).
|
* 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> => {
|
generateInvoice: async (context: ATMContext): Promise<string> => {
|
||||||
const amountSats = Math.floor(amountMsat / 1000)
|
const amountSats = context.satsAmount
|
||||||
console.log('[ATM Service] Generating invoice for', amountSats, 'sats')
|
// 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, {
|
const payment = await lnbits.createInvoice(lnbitsWalletId, {
|
||||||
amount: amountSats,
|
amount: amountSats,
|
||||||
memo: 'bitSpire - Cash Out',
|
memo: 'bitSpire - Cash Out',
|
||||||
unit: 'sat',
|
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) {
|
if (!payment.payment_request) {
|
||||||
throw new Error('LNbits createInvoice returned empty payment_request')
|
throw new Error('LNbits createInvoice returned empty payment_request')
|
||||||
|
|
|
||||||
|
|
@ -187,9 +187,13 @@ const mockServices: ATMServices = {
|
||||||
return `LNURL1MOCK${Date.now().toString(36).toUpperCase()}`
|
return `LNURL1MOCK${Date.now().toString(36).toUpperCase()}`
|
||||||
},
|
},
|
||||||
|
|
||||||
generateInvoice: async (amountMsat) => {
|
generateInvoice: async (context) => {
|
||||||
console.log('[Mock] Generating invoice for', amountMsat, 'msats')
|
console.log(
|
||||||
return `lnbc${amountMsat}n1mock${Date.now().toString(36)}`
|
'[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) => {
|
getExchangeRate: async (currency) => {
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ export function createATMMachine(
|
||||||
}
|
}
|
||||||
return services.generateLnurlWithdraw(input)
|
return services.generateLnurlWithdraw(input)
|
||||||
}),
|
}),
|
||||||
generateInvoice: fromPromise(async ({ input }: { input: number }) => {
|
generateInvoice: fromPromise(async ({ input }: { input: ATMContext }) => {
|
||||||
if (!services.generateInvoice) {
|
if (!services.generateInvoice) {
|
||||||
throw new Error('generateInvoice service not provided')
|
throw new Error('generateInvoice service not provided')
|
||||||
}
|
}
|
||||||
|
|
@ -662,7 +662,12 @@ export function createATMMachine(
|
||||||
generatingInvoice: {
|
generatingInvoice: {
|
||||||
invoke: {
|
invoke: {
|
||||||
src: 'generateInvoice',
|
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: {
|
onDone: {
|
||||||
target: 'displayingInvoice',
|
target: 'displayingInvoice',
|
||||||
actions: assign({
|
actions: assign({
|
||||||
|
|
|
||||||
|
|
@ -199,8 +199,17 @@ export interface ATMServices {
|
||||||
generateNdebit: (context: ATMContext) => Promise<string>
|
generateNdebit: (context: ATMContext) => Promise<string>
|
||||||
/** Generate an LNURL-withdraw link (for cash-in - customer receives sats) - legacy */
|
/** Generate an LNURL-withdraw link (for cash-in - customer receives sats) - legacy */
|
||||||
generateLnurlWithdraw: (context: ATMContext) => Promise<string>
|
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 */
|
/** Send receipt via Nostr */
|
||||||
sendNostrReceipt: (context: ATMContext) => Promise<void>
|
sendNostrReceipt: (context: ATMContext) => Promise<void>
|
||||||
/** Dispense cash (always resolves with result, never throws) */
|
/** Dispense cash (always resolves with result, never throws) */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue