fix(core): read the routing fee from fee, not the absent fee_msat #171

Merged
padreug merged 2 commits from fix/fee-field-name into dev 2026-09-26 12:37:30 +00:00
Owner

Closes #170.

LNbits payment payloads carry fee, signed millisats like amount. There is no fee_msat field, so three modules were reading undefined.

site effect
PaymentService.PaymentResult declared fee_msat: number, a field the API never sends, so the compiler vouched for a number that was always undefined
market/useLightningPayment persisted feeMsat: undefined onto paid orders, on a property Order did not declare
events/LnbitsPaymentProvider data.fee_msat ?? 0 — the fallback masked the miss, so every payment recorded a zero fee

Approach

payInvoice now normalizes at the boundary instead of returning the raw payload, mapping fee to a positive feeMsat. Doing it once there means no call site can pick the wrong field name or forget that outgoing amounts are signed, and PaymentResult finally describes what callers actually receive.

Order now declares feeMsat?: number. updateOrder takes Partial<Order> and the write goes through an inferred variable, so the excess-property check never fired — the fee market persists was landing in the store invisible to the type system, and stayed that way even once the value was correct.

Verification

Against a live pay-invoice response, plus a synthetic payment carrying a real routing fee. Internal LNbits transfers are fee-free, so the live case cannot exercise a non-zero fee:

live response          fee present: True    fee_msat present: False
synthetic -1234 msat
  old (events)  -> 0           fee silently lost
  old (market)  -> undefined
  new (both)    -> 1234        positive magnitude

Builds pass for the hub and for the events, market and wallet standalones; vue-tsc -b is clean across the project.

Nothing read these values downstream, so no totals were ever miscomputed. The fee was simply recorded as missing or zero.

Context

This came out of a wider satoshi/millisatoshi audit against the LNbits source, which I'll summarise separately. The headline from that: GET /api/v1/wallet returns balance in millisats while the WebSocket's wallet_balance is in sats — same name, 1000x apart. Both of our code paths happen to handle their own correctly.

Closes #170. LNbits payment payloads carry `fee`, signed millisats like `amount`. There is no `fee_msat` field, so three modules were reading `undefined`. | site | effect | |---|---| | `PaymentService.PaymentResult` | declared `fee_msat: number`, a field the API never sends, so the compiler vouched for a number that was always undefined | | `market/useLightningPayment` | persisted `feeMsat: undefined` onto paid orders, on a property `Order` did not declare | | `events/LnbitsPaymentProvider` | `data.fee_msat ?? 0` — the fallback masked the miss, so every payment recorded a zero fee | ## Approach `payInvoice` now normalizes at the boundary instead of returning the raw payload, mapping `fee` to a positive `feeMsat`. Doing it once there means no call site can pick the wrong field name or forget that outgoing amounts are signed, and `PaymentResult` finally describes what callers actually receive. `Order` now declares `feeMsat?: number`. `updateOrder` takes `Partial<Order>` and the write goes through an inferred variable, so the excess-property check never fired — the fee market persists was landing in the store invisible to the type system, and stayed that way even once the value was correct. ## Verification Against a live pay-invoice response, plus a synthetic payment carrying a real routing fee. Internal LNbits transfers are fee-free, so the live case cannot exercise a non-zero fee: ``` live response fee present: True fee_msat present: False synthetic -1234 msat old (events) -> 0 fee silently lost old (market) -> undefined new (both) -> 1234 positive magnitude ``` Builds pass for the hub and for the events, market and wallet standalones; `vue-tsc -b` is clean across the project. Nothing read these values downstream, so no totals were ever miscomputed. The fee was simply recorded as missing or zero. ## Context This came out of a wider satoshi/millisatoshi audit against the LNbits source, which I'll summarise separately. The headline from that: `GET /api/v1/wallet` returns `balance` in **millisats** while the WebSocket's `wallet_balance` is in **sats** — same name, 1000x apart. Both of our code paths happen to handle their own correctly.
Closes #170.

LNbits payment payloads carry `fee`, signed millisats like `amount`.
There is no `fee_msat` field, so three modules read `undefined`:

  PaymentService.PaymentResult declared `fee_msat: number` — a field the
  API never sends — so the compiler vouched for a number that was always
  undefined, and the lie propagated to every consumer.

  market/useLightningPayment persisted `feeMsat: undefined` onto paid
  orders (on a property the Order type does not even declare).

  events/LnbitsPaymentProvider had `data.fee_msat ?? 0`, whose fallback
  masked the miss so every payment recorded a zero fee.

`payInvoice` now normalizes at the boundary instead of returning the raw
payload: it maps `fee` to a positive `feeMsat`. Doing it once there means
no call site can pick the wrong field name or forget that outgoing
amounts are signed. `PaymentResult.feeMsat` replaces the phantom field,
so the type finally matches what callers receive.

Verified against a live pay-invoice response, plus a synthetic payment
carrying a real routing fee (internal LNbits transfers are fee-free, so
the live case cannot exercise a non-zero fee):

  live response       fee present: True   fee_msat present: False
  synthetic -1234 msat
    old (events)  -> 0      fee silently lost
    old (market)  -> undefined
    new (both)    -> 1234   positive magnitude

Nothing read these values downstream, so no totals were miscomputed; the
fee was simply recorded as missing or zero.
`useLightningPayment` writes feeMsat onto the paid order, but `Order`
never declared it. `updateOrder` takes `Partial<Order>`, and the write
goes through an inferred variable, so the excess-property check never
fired — the value landed in the store invisible to the type system and
unreadable by any consumer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
padreug deleted branch fix/fee-field-name 2026-09-26 12:37:31 +00:00
Sign in to join this conversation.
No description provided.