From 8fbe6df5c338017ebf938e89d61371a548f5b20d Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Thu, 30 Jul 2026 02:36:46 +0200 Subject: [PATCH] fix(lightning): cash-in double-charged commission (send gross, not net) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buy Bitcoin short-changed the customer: a $5 buy at 1564 sats/USD with a 12% commission paid out 6056 sats instead of 6882 — an effective ~22.6%. The commission was applied twice. `calculateSats` already subtracts the fee (7820 gross → 6882 net) into `context.satsAmount`. But `generateLnurlWithdraw` then passed that already-net value as `principal_sats` to the server's create_withdraw, which derives fee + net from the principal and subtracted 12% AGAIN: [ATM Service] create_withdraw: principal=6882 fee=826 net=6056 This contradicted the function's own contract ("the ATM sends only the hardware-attested gross principal; the operator side derives fee + NET"). The quote, the recorded transaction (sats=6882, fee_fraction=0.12), and the on-screen commission (12%) all read a single fee — only the delivered LNURL-withdraw amount was double-charged. Fix: send the GROSS principal (fiat × rate, before commission), so the server applies the fee exactly once. Now 7820 → server 12% → net 6882, matching the quote/receipt. Exchange rate itself was always correct. Verified: vue-tsc typechecks; math checks (gross=7820 fee=938 net=6882). Hardware retest (one $5 buy → 6882) recommended before relying in prod. Co-Authored-By: Claude Opus 4.8 --- apps/machine/src/services/lightning.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts index 4eca3e2..eb2e1c7 100644 --- a/apps/machine/src/services/lightning.ts +++ b/apps/machine/src/services/lightning.ts @@ -725,7 +725,17 @@ function createATMServices( * over nostr, we trigger dispense. */ generateLnurlWithdraw: async (context: ATMContext): Promise => { - console.log('[ATM Service] Generating LNURL-withdraw for', context.satsAmount, 'sats') + // GROSS principal (fiat × rate, BEFORE commission). The server derives + // fee + NET from this, so we must NOT send the already-fee'd + // context.satsAmount — doing so double-applies the commission (client + // subtracts it in calculateSats, then the server subtracts it again, + // e.g. 12% → 22.6% effective; the customer is short-changed while the + // quote/receipt still read 12%). Mirror calculateSats's principal. + const grossPrincipalSats = Math.floor((context.fiatCents / 100) * context.exchangeRate) + console.log( + `[ATM Service] Generating LNURL-withdraw: gross principal=${grossPrincipalSats} sats ` + + `(net after ${(context.feeFraction * 100).toFixed(2)}% ≈ ${context.satsAmount})` + ) try { if (context.cashInSessionId) { @@ -738,7 +748,7 @@ function createATMServices( // the amount or extra. We display the returned LNURL (for NET) and // watch link_id for settlement. const link = await lnbits.createWithdraw(lnbitsWalletId, { - principal_sats: context.satsAmount, + principal_sats: grossPrincipalSats, fiat_amount: context.fiatCents / 100, fiat_code: context.currency, title: `bitSpire Cash-In ${context.cashInSessionId?.slice(0, 8) || 'session'}`,