fix(lightning): cash-in commission charged twice (send gross principal, not net) #81

Merged
padreug merged 1 commit from fix/cashin-double-fee into dev 2026-07-30 01:04:20 +00:00
Owner

Summary

Buy Bitcoin short-changes the customer: on the batm3, a $5 buy at 1564 sats/USD with a 12% commission paid out 6,056 sats instead of 6,882 — an effective ~22.6%. The commission is applied twice.

Root cause

state-machine calculateSats already subtracts the fee: 7820 gross → 938 fee → 6882 net, stored in context.satsAmount. But services/lightning.ts generateLnurlWithdraw then passed that already-net value as principal_sats to the server's create_withdraw, which itself derives fee + net from the principal — so it subtracted 12% a second time. Straight from the machine's log:

[ATM Service] create_withdraw: principal=6882 fee=826 net=6056

This directly contradicts the function's own documented contract:

"the ATM sends only the hardware-attested gross principal; the operator side … derives fee + NET"

The quote, the recorded transaction (sats=6882, fee_sats=938, fee_fraction=0.12), and the on-screen commission (12%) all read a single fee — only the delivered LNURL-withdraw amount was double-charged, so it's invisible on the receipt.

The exchange rate was never wrong (1564 sats/USD ≈ $63,934/BTC, matches market).

Fix

Send the gross principal (fiat × rate, before commission) to create_withdraw, so the server applies the fee exactly once:

7820 (gross) → server 12% → 6882 net   ✓  (matches quote/receipt)

One-line change in generateLnurlWithdraw (context.satsAmount → gross principal), plus a clarifying log.

Verification

  • vue-tsc --noEmit clean.
  • Arithmetic: floor(5×1564)=7820, floor(7820×0.12)=938, net 6882.
  • Recommended before merge: one live $5 buy on the batm3 → confirm create_withdraw: principal=7820 … net=6882 and the wallet receives 6882.

Follow-up (not this PR)

Consider making the server's net_sats the single source of truth for the displayed/recorded amount, so a divergence between the ATM's fee config and the server's can't silently mis-quote (today they agree at 12%, so quote == delivered after this fix).

🤖 Generated with Claude Code

## Summary Buy Bitcoin short-changes the customer: on the batm3, a **$5 buy** at 1564 sats/USD with a **12% commission** paid out **6,056 sats** instead of **6,882** — an effective **~22.6%**. The commission is applied **twice**. ## Root cause `state-machine` `calculateSats` already subtracts the fee: `7820 gross → 938 fee → 6882 net`, stored in `context.satsAmount`. But `services/lightning.ts` `generateLnurlWithdraw` then passed that **already-net** value as `principal_sats` to the server's `create_withdraw`, which itself derives fee + net from the principal — so it subtracted 12% a **second time**. Straight from the machine's log: ``` [ATM Service] create_withdraw: principal=6882 fee=826 net=6056 ``` This directly contradicts the function's own documented contract: > *"the ATM sends only the hardware-attested gross principal; the operator side … derives fee + NET"* The quote, the recorded transaction (`sats=6882, fee_sats=938, fee_fraction=0.12`), and the on-screen commission (12%) **all read a single fee** — only the delivered LNURL-withdraw amount was double-charged, so it's invisible on the receipt. **The exchange rate was never wrong** (1564 sats/USD ≈ $63,934/BTC, matches market). ## Fix Send the **gross** principal (`fiat × rate`, before commission) to `create_withdraw`, so the server applies the fee exactly once: ``` 7820 (gross) → server 12% → 6882 net ✓ (matches quote/receipt) ``` One-line change in `generateLnurlWithdraw` (`context.satsAmount` → gross principal), plus a clarifying log. ## Verification - `vue-tsc --noEmit` clean. - Arithmetic: `floor(5×1564)=7820`, `floor(7820×0.12)=938`, net `6882`. - **Recommended before merge:** one live $5 buy on the batm3 → confirm `create_withdraw: principal=7820 … net=6882` and the wallet receives 6882. ## Follow-up (not this PR) Consider making the **server's `net_sats` the single source of truth** for the displayed/recorded amount, so a divergence between the ATM's fee config and the server's can't silently mis-quote (today they agree at 12%, so quote == delivered after this fix). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
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 <noreply@anthropic.com>
padreug deleted branch fix/cashin-double-fee 2026-07-30 01:04:20 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/bitspire!81
No description provided.