Millisatoshi audit: 1000x display error in market checkout, plus two conversion/unit defects #172

Open
opened 2026-09-23 07:57:43 +00:00 by padreug · 0 comments
Owner

Findings from a satoshi/millisatoshi audit across the tree, checked against the LNbits source and verified against a live instance. Each item below was reproduced, not inferred.

1. Market checkout shows a 1000x inflated amount

src/modules/market/components/PaymentDisplay.vue:17 renders:

{{ invoice.amount }} {{ currency }}

invoice is order.lightningInvoice, stored raw from the LNbits create-invoice response at src/modules/market/stores/market.ts:520, and that amount is millisats. currency defaults to 'sat' at line 206.

Reproduced live:

sent      amount: 1500 sat
response  amount: 1500000 (msat)
displayed: "1500000 sat"      <- 1000x the real order

The wallet module divides the same field by 1000 (WalletService.ts:230). This one does not.

The bolt11 and QR are correct, so the customer still pays the right amount. The number next to them is simply wrong by 1000x, which on a payment screen is alarming. Fix is Math.floor(invoice.amount / 1000), or better, reuse one of the existing msat formatters.

2. useExpenseDrafts never gets a BTC price

src/accounting-app/composables/useExpenseDrafts.ts:137 reads:

const btcPriceInFiat = data.amount ?? data.result

from POST /api/v1/conversion. That endpoint keys its response by currency code and never returns amount or result, so the value is always undefined:

request:      {from_: "sat", amount: 100000000, to: "EUR"}
response keys: ['BTC', 'EUR', 'sats']
data.amount ?? data.result -> undefined
actual value under EUR     -> 75453.65

Same root cause as #165, which fixed this for usePriceConversion. This site was missed because it calls the API directly instead of going through that composable. Best fix is to route it through usePriceConversion so there is one extraction strategy.

3. Events payment provider silently drops the currency

src/modules/events/services/LnbitsPaymentProvider.ts:28-33 sends amount with no unit field, while PaymentProviderInterface.ts:9-12 documents the parameter as "Amount in the specified currency" alongside a currency field that is never forwarded. LNbits defaults to sats, so a fiat-denominated amount would be created as that many satoshis.

Latent rather than live: current callers pass sats. Worth either forwarding currency as unit (the mechanism #166 uses in the wallet) or narrowing the interface docs to say sats only.

4. Lower priority consistency items

  • src/modules/market/services/paymentMonitor.ts:77,221 propagate the msat invoice.amount into PaymentUpdate.amount, landing at market.ts:571 in a store where every other amount is stall-currency sats.
  • Four independent msat-to-sat display divisors exist (lib/utils/formatting.ts:32, lib/utils/currency.ts:67, CurrencyDisplay.vue:40, WalletPage.vue:196), plus two different exported functions both named formatWalletBalance with identical signatures. All agree on the unit today; it is an import-site hazard rather than a bug.
  • src/modules/wallet/components/SendDialog.vue:35-36 declares minSendable/maxSendable in msats but never compares them to the sat amount. Dead today; would be a 1000x bug the moment LNURL min/max enforcement is added.
  • restaurant/views/SettingsPage.vue:99 stores a currencyDisplay: 'msat' preference that no formatter reads.

Checked and correct

Recording these so nobody re-investigates:

  • The wallet's two balance paths are both right. GET /api/v1/wallet returns balance in millisats, while the WebSocket's wallet_balance is in satoshis. The LNbits REST handler reads the balance_msat field and the WebSocket handler reads the balance property, which floor-divides by 1000. Our WebSocket path multiplies by 1000 and our polling path does not, which is correct for each. Verified live at a ratio of exactly 1000.
  • amount and fee on payments are signed millisats everywhere, and all three of our fee reads apply Math.abs. This matters because LNbits signs fee negative for external sends but positive for internal payments.
  • Nothing sums amount + fee, which would be wrong on internal payments.
  • We call none of the LNbits endpoints whose units disagree with their siblings (/payments/stats/daily is sats while /payments/stats/wallets is millisats; /payments/history mixes signed and unsigned in one object).
  • The aio extensions avoid the problem entirely by naming fields with explicit units (amount_sat, price_fiat, quote_sat) and never using millisats.
Findings from a satoshi/millisatoshi audit across the tree, checked against the LNbits source and verified against a live instance. Each item below was reproduced, not inferred. ## 1. Market checkout shows a 1000x inflated amount `src/modules/market/components/PaymentDisplay.vue:17` renders: ``` {{ invoice.amount }} {{ currency }} ``` `invoice` is `order.lightningInvoice`, stored raw from the LNbits create-invoice response at `src/modules/market/stores/market.ts:520`, and that `amount` is **millisats**. `currency` defaults to `'sat'` at line 206. Reproduced live: ``` sent amount: 1500 sat response amount: 1500000 (msat) displayed: "1500000 sat" <- 1000x the real order ``` The wallet module divides the same field by 1000 (`WalletService.ts:230`). This one does not. The bolt11 and QR are correct, so the customer still pays the right amount. The number next to them is simply wrong by 1000x, which on a payment screen is alarming. Fix is `Math.floor(invoice.amount / 1000)`, or better, reuse one of the existing msat formatters. ## 2. `useExpenseDrafts` never gets a BTC price `src/accounting-app/composables/useExpenseDrafts.ts:137` reads: ```js const btcPriceInFiat = data.amount ?? data.result ``` from `POST /api/v1/conversion`. That endpoint keys its response by currency code and never returns `amount` or `result`, so the value is always undefined: ``` request: {from_: "sat", amount: 100000000, to: "EUR"} response keys: ['BTC', 'EUR', 'sats'] data.amount ?? data.result -> undefined actual value under EUR -> 75453.65 ``` Same root cause as #165, which fixed this for `usePriceConversion`. This site was missed because it calls the API directly instead of going through that composable. Best fix is to route it through `usePriceConversion` so there is one extraction strategy. ## 3. Events payment provider silently drops the currency `src/modules/events/services/LnbitsPaymentProvider.ts:28-33` sends `amount` with **no `unit` field**, while `PaymentProviderInterface.ts:9-12` documents the parameter as "Amount in the specified currency" alongside a `currency` field that is never forwarded. LNbits defaults to sats, so a fiat-denominated amount would be created as that many satoshis. Latent rather than live: current callers pass sats. Worth either forwarding `currency` as `unit` (the mechanism #166 uses in the wallet) or narrowing the interface docs to say sats only. ## 4. Lower priority consistency items - `src/modules/market/services/paymentMonitor.ts:77,221` propagate the msat `invoice.amount` into `PaymentUpdate.amount`, landing at `market.ts:571` in a store where every other amount is stall-currency sats. - Four independent msat-to-sat display divisors exist (`lib/utils/formatting.ts:32`, `lib/utils/currency.ts:67`, `CurrencyDisplay.vue:40`, `WalletPage.vue:196`), plus two different exported functions both named `formatWalletBalance` with identical signatures. All agree on the unit today; it is an import-site hazard rather than a bug. - `src/modules/wallet/components/SendDialog.vue:35-36` declares `minSendable`/`maxSendable` in msats but never compares them to the sat `amount`. Dead today; would be a 1000x bug the moment LNURL min/max enforcement is added. - `restaurant/views/SettingsPage.vue:99` stores a `currencyDisplay: 'msat'` preference that no formatter reads. ## Checked and correct Recording these so nobody re-investigates: - **The wallet's two balance paths are both right.** `GET /api/v1/wallet` returns `balance` in millisats, while the WebSocket's `wallet_balance` is in satoshis. The LNbits REST handler reads the `balance_msat` field and the WebSocket handler reads the `balance` property, which floor-divides by 1000. Our WebSocket path multiplies by 1000 and our polling path does not, which is correct for each. Verified live at a ratio of exactly 1000. - `amount` and `fee` on payments are signed millisats everywhere, and all three of our fee reads apply `Math.abs`. This matters because LNbits signs `fee` negative for external sends but positive for internal payments. - Nothing sums `amount + fee`, which would be wrong on internal payments. - We call none of the LNbits endpoints whose units disagree with their siblings (`/payments/stats/daily` is sats while `/payments/stats/wallets` is millisats; `/payments/history` mixes signed and unsigned in one object). - The aio extensions avoid the problem entirely by naming fields with explicit units (`amount_sat`, `price_fiat`, `quote_sat`) and never using millisats.
Sign in to join this conversation.
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/webapp#172
No description provided.