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_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')
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue