fix: avoid msat truncation when paying invoices with built-in amounts#879
fix: avoid msat truncation when paying invoices with built-in amounts#879ben-kaufman wants to merge 14 commits intomasterfrom
Conversation
Bump bitkit-core to v0.1.56 which rounds up sub-satoshi invoice amounts. Additionally, stop overriding the amount for invoices that already have one. Pass null so LDK uses the invoice's native msat precision instead of our truncated sats value converted back to msat. Only pass the amount for zero-amount invoices where the user specifies it. Closes #877
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
|
Observations from testing: 1) The fix rounds up to full sats precision for the payment and display amount, so ln invoice with from synonymdev/bitkit-e2e-tests#141. Screen.Recording.2026-04-02.at.12.58.54.mov2) There is an issue with lnurl-pay and and lnurl-withdrawal. For testing on local regtest, more utility commands for
Screen.Recording.2026-04-02.at.13.53.59.mov
Screen.Recording.2026-04-02.at.14.26.12.mov |
This comment has been minimized.
This comment has been minimized.
LNURL protocol uses millisatoshis, but the app was converting to sats and back for callbacks, losing the fractional part. For fixed-amount LNURL-pay (e.g. 500500 msat), ceil(min)=501 > floor(max)=500 caused the UI to show 0/invalid amount. - Add isFixedAmount() helpers that detect sub-sat fixed amounts - Add callbackAmountMsats() to return original msat for fixed amounts - Change fetchLnurlInvoice to accept amountMsats directly - For fixed-amount LNURL-withdraw, use floor division for invoice amount - Fix validateAmount for withdraw: use <= instead of < for max bound - Add unit tests for all new helper functions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| return@launch | ||
| } | ||
| invoice.bolt11 to data.sats | ||
| Triple(invoice.bolt11, data.sats, data.sats) |
There was a problem hiding this comment.
Bug: LnurlPay quick-pay still overrides invoice amount with truncated sats
The LnurlPay branch passes data.sats (a truncated sat value) as the payment amount, so sendLightning calls sendUsingAmount(bolt11, data.sats * 1000u) — which is exactly the msat truncation this PR is fixing. The Bolt11 branch correctly passes null to let LDK use the invoice's native msat precision. The LNURL-fetched invoice already has the correct msat amount embedded, so amount here should also be null.
Compare with AppViewModel.kt which correctly does:
val paymentAmount = if (decodedInvoice.amountSatoshis > 0uL) null else amount| Triple(invoice.bolt11, data.sats, data.sats) | |
| Triple(invoice.bolt11, null, data.sats) |
CHANGELOG.md
Outdated
| - Preserve msat precision for LNURL pay and withdraw callbacks #879 | ||
| - Avoid msat truncation when paying invoices with built-in amounts #879 |
There was a problem hiding this comment.
CLAUDE.md violation: multiple changelog entries for the same PR
Per CLAUDE.md:
NEVER add multiple changelog lines for the same PR — summarize all changes in a single concise entry
Both lines reference #879. Please consolidate into one entry, e.g.:
| - Preserve msat precision for LNURL pay and withdraw callbacks #879 | |
| - Avoid msat truncation when paying invoices with built-in amounts #879 | |
| - Avoid msat truncation when paying invoices and LNURL callbacks #879 |
| }.onFailure { | ||
| Logger.error( | ||
| "fetchLnurlInvoice error, url: $callbackUrl, amount: $amountSats, comment: $comment", | ||
| "fetchLnurlInvoice error, url: $callbackUrl, amountMsats: $amountMsats, comment: $comment", |
There was a problem hiding this comment.
CLAUDE.md violation: log parameter values must be wrapped in single quotes
Per CLAUDE.md:
ALWAYS wrap parameter values in log messages with single quotes, e.g.
Logger.info("Received event '$eventName'", context = TAG)
| "fetchLnurlInvoice error, url: $callbackUrl, amountMsats: $amountMsats, comment: $comment", | |
| "fetchLnurlInvoice error, url: '$callbackUrl', amountMsats: '$amountMsats', comment: '$comment'", |
| if (hasAmount) { | ||
| Logger.info("Found amount $$minSendable in lnurlPay, proceeding with payment", context = TAG) | ||
| if (isFixed) { | ||
| Logger.info("Found fixed amount $displaySats sats in lnurlPay, proceeding with payment", context = TAG) |
There was a problem hiding this comment.
CLAUDE.md violation: log parameter values must be wrapped in single quotes
Per CLAUDE.md:
ALWAYS wrap parameter values in log messages with single quotes, e.g.
Logger.info("Received event '$eventName'", context = TAG)
| Logger.info("Found fixed amount $displaySats sats in lnurlPay, proceeding with payment", context = TAG) | |
| Logger.info("Found fixed amount '$displaySats' sats in lnurlPay, proceeding with payment", context = TAG) |
This comment has been minimized.
This comment has been minimized.
- Pass null instead of data.sats for LNURL quick-pay so LDK uses the invoice's native msat precision (same fix as Bolt11 path) - Wrap log parameter values in single quotes per CLAUDE.md - Consolidate duplicate changelog entries into one Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Thanks for the thorough testing @piotr-iohk! Issue 1 (display 501 vs 500): This is because Issue 2 (LNURL-pay and LNURL-withdraw): Both fixed in the latest commits. Root cause was msat→sats→msat round-trip losing precision for LNURL callbacks. The fix:
Ready for re-testing. |
| }.onFailure { | ||
| Logger.error( | ||
| "fetchLnurlInvoice error, url: $callbackUrl, amount: $amountSats, comment: $comment", | ||
| "fetchLnurlInvoice error, url: '$callbackUrl', amountMsats: '$amountMsats', comment: '$comment'", |
There was a problem hiding this comment.
CLAUDE.md violation: Log messages must always start with a verb.
"fetchLnurlInvoice error" starts with a method name, not a verb. This line was modified by this PR (parameter rename + quote-wrapping), which was an opportunity to fix the prefix.
bitkit-android/app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Lines 912 to 916 in 7bfb20b
| "fetchLnurlInvoice error, url: '$callbackUrl', amountMsats: '$amountMsats', comment: '$comment'", | |
| "Failed to fetch LNURL invoice, url: '$callbackUrl', amountMsats: '$amountMsats', comment: '$comment'", |
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
For LNURL-withdraw with sub-sat precision (e.g. 222538 msat), neither floor (222 sats) nor ceiling (223 sats) matches the server's exact amount range. Add receiveMsats/createInvoiceMsats to create invoices with native msat precision, used for fixed-amount LNURL withdrawals. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
Ok, clear, thanks for explanation. I think that it is fine as it is then. |
|
I found remaining amount-display inconsistencies vs regular BOLT11 when using an msat-precision LNURL server.
Notes
Expected
|
Previously LNURL-pay showed 222 sats on review but 223 after sending, and LNURL-withdraw showed 222 while BOLT11 showed 223 for the same 222222 msat amount. Use ceiling division consistently for display. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
@piotr-iohk Display inconsistency fixed — all LNURL display amounts now use ceiling division ( |
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| * | ||
| * Uses floor division so the invoice amount never exceeds `maxWithdrawable` in msats. | ||
| */ | ||
| fun LnurlWithdrawData.fixedWithdrawAmountSat(): ULong = maxWithdrawable / MSATS_PER_SAT |
There was a problem hiding this comment.
fixedWithdrawAmountSat() is added to production code but has no production call sites — it is only referenced from LnurlExtTest.kt. This violates CLAUDE.md's YAGNI rule:
ALWAYS apply the YAGNI (You Ain't Gonna Need It) principle for new code
Looking at AppViewModel.kt:1530, the isFixed branch in the displayAmount expression calls data.minWithdrawableSat() (ceiling division on minWithdrawable) when data.fixedWithdrawAmountSat() (floor division on maxWithdrawable) was likely the intended call — to ensure the displayed sat amount never exceeds maxWithdrawable for sub-sat fixed amounts. If that's the case, this function should be wired up there rather than left as dead production code.
| return | ||
| } | ||
|
|
||
| val displayAmount = if (isFixed) data.minWithdrawableSat() else minWithdrawable |
There was a problem hiding this comment.
Both branches of the displayAmount conditional always produce the same value as minWithdrawable:
val minWithdrawable = data.minWithdrawableSat() // line 1527 — ceiling division on minWithdrawable
// ...
val displayAmount = if (isFixed) data.minWithdrawableSat() else minWithdrawable
// ^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^
// same as minWithdrawable same as minWithdrawableThe isFixed branch was likely intended to call data.fixedWithdrawAmountSat() (floor division on maxWithdrawable), which was added in this PR precisely for this purpose. Using floor division in the fixed-amount branch ensures the displayed sat value never exceeds maxWithdrawable for sub-sat fixed withdrawals (e.g. 500 500 msat → displays 500 sat instead of 501 sat).
Suggested fix:
val displayAmount = if (isFixed) data.fixedWithdrawAmountSat() else minWithdrawable(See also the related dead-code note on fixedWithdrawAmountSat() in Lnurl.kt.)
fixedWithdrawAmountSat() had no production call sites after switching to createInvoiceMsats for fixed-amount withdrawals and satsCeil for display. Remove the function, its test, and simplify the redundant displayAmount conditional. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. |
Ok, that works consistent accross BOLT11, LNURL pay and withdraw now. 👍 Unfortunately, while hardening/adapting e2e tests to this behavior, I spotted inconsistency on what's reported on the screens vs. on what is reported on the activity list... activity details show 222. This is common for LNURL-pay, withdraw and BOLT11 for invoices with msats precision... Screen.Recording.2026-04-03.at.18.19.40.mov |
Summary
nullso LDK uses the invoice's native msat precision instead of our truncated sats value converted back to msatTest plan
lnd.addInvoice({ valueMsat })using amounts222538,222222,500500msatDepends on synonymdev/bitkit-core#85
Closes #877
🤖 Generated with Claude Code