fix(receive): handle additional receive liquidity edge cases - #1222
Conversation
53e0156 to
314445d
Compare
Greptile SummaryThis PR centralizes receive-liquidity decisions and updates invoice creation, CJIT limits, receive navigation, session reset behavior, and receive-detail presentation.
Confidence Score: 4/5The PR should not merge until the CJIT maximum is derived from current Blocktank options rather than potentially stale cached limits. A previously populated Blocktank info object bypasses refresh during maximum calculation, so the receive flow can enforce an outdated channel-size limit and repeat it after a server rejection. Files Needing Attention: app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt
|
| Filename | Overview |
|---|---|
| app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt | Adds a pure, tested decision model for ordinary Lightning invoices and source-aware additional-liquidity routing. |
| app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt | Adds channel-size validation and maximum-CJIT calculation, but derives the maximum from potentially stale cached Blocktank options. |
| app/src/main/java/to/bitkit/repositories/WalletRepo.kt | Gates invoice generation and channel-event refreshes on ready-channel inbound capacity. |
| app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt | Coordinates source-specific liquidity decisions and Blocktank limit lookup for edited receive amounts. |
| app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt | Adds maximum CJIT validation, input clamping, and dedicated maximum-exceeded feedback. |
| app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt | Updates receive-tab availability, fallback selection, and details layout according to Lightning receive capability. |
| app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt | Tracks the edit source and routes initial versus additional CJIT flows to their corresponding confirmation screens. |
| app/src/main/java/to/bitkit/ui/ContentView.kt | Keys each receive-sheet instance so remembered navigation and CJIT state reset on close and reopen. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Edit receive amount] --> B{Selected source}
B -->|Savings or Auto| C[Update shared receive invoice]
B -->|Spending| D{Amount exceeds inbound liquidity?}
D -->|No| C
D -->|Yes| E{Geo-blocked?}
E -->|Yes| F[Geo-block screen]
E -->|No| G{Within current CJIT limits?}
G -->|No| H[CJIT amount entry]
G -->|Yes| I[Create additional CJIT]
I --> J[Additional-liquidity confirmation]
H --> J
Reviews (1): Last reviewed commit: "fix(receive): handle additional receive ..." | Re-trigger Greptile
ovitrif
left a comment
There was a problem hiding this comment.
QA Notes
Pixel_8_Pro emulator (sdk_gphone16k_arm64), regtest via network Electrum/Blocktank.
- Savings edit above inbound stayed on the on-chain QR and did not route to CJIT.
- Auto edit above inbound fell back to Savings/on-chain QR.
- Spending edit below the CJIT minimum routed to CJIT amount entry.
- Spending edit with a valid additional-CJIT amount opened confirmation.
- Spending edit above the real CJIT maximum stayed on amount entry and clamped to max.
- Receive sheet close/reopen started fresh on Auto.
- Auto details showed both on-chain and Lightning copy cards.
Approve.
|
There are conflicts. |
ovitrif
left a comment
There was a problem hiding this comment.
Added some nits after manual review for covering potential edge cases and defaulting to existing patterns throughout the code.
Been db checking them with AI on the side.
539d014 to
d9c603a
Compare
|
All review comments addressed |
|
QA Android, regtest. Existing channel: inbound 766,164, spending 3,000. Invoice at 766,164 is fine. Savings edit above inbound stayed on-chain. Auto edit above inbound fell back to Savings QR. Neither entered extra CJIT. Spending 766,165 opens extra-CJIT amount (MINIMUM 3,000). Continue at 3,000 fails. UI toast is the raw exception, not the new max copy:
Same Blocktank node-cap rejection as synonymdev/bitkit-ios#711. Client max handling only covers per-channel Please don’t offer extra CJIT (or map this error) when the node is already at Blocktank’s capacity limit. Don’t toast Recording: Screen.Recording.2026-09-04.at.15.37.47.movLogs: |
jvsena42
left a comment
There was a problem hiding this comment.
Diffed against the iOS port (#711). ReceiveLiquidityDecision and its 12 tests are a 1:1 match, and the routing, CJIT max search and session reset are equivalent. Android is the better of the two in three places (refreshes Blocktank info before the max check and tests it, hides Auto while a CJIT invoice is shown, routes edit-flow failures to the amount screen).
The two items worth attention are the tab effects, where this PR changed behaviour that the iOS side did not. Three of these are replies on existing threads.
Separately, and not filed as a finding since it is a bitkit-core question rather than a defect in this PR: get_default_lsp_balance returns max_channel_size_sat above threshold 2 (~EUR 495), so client + lsp > max for every amount above that and the "real max" this PR surfaces is effectively capped there regardless of Blocktank's maxChannelSizeSat. calc_default_lsp_balance uses max - client in the same band. Worth confirming that cap is intended.
ovitrif
left a comment
There was a problem hiding this comment.
Two things regress behaviour that master had, both in code the rebase brought in, so I'd like them fixed before this goes in.
The bigger one is BlocktankRepo.freshMaxChannelSizeSat(). It calls refreshInfo().getOrThrow(), and refreshInfo() wraps the whole cache-then-server sequence in a single runCatching, so a failing info(refresh = true) request returns Result.failure even after the cached value was already written into _blocktankState. Since createCjit() now calls it before the LSP request, one flaky Blocktank /info call aborts CJIT creation entirely. On master, createCjit had no dependency on /info at all. The earlier thread on this was marked fixed, but getOrThrow() is still there on this head.
The second is the AUTO auto-switch effect in ReceiveQrScreen. It lost the initialTab == null guard that master had. A LaunchedEffect body runs on first composition, so it now scrolls to ReceiveTab.AUTO and sets selectedTab = ReceiveTab.AUTO whenever Auto is visible, undoing the effect above it that applied the requested tab. ReceiveSheet passes initialTab = ReceiveTab.TREZOR whenever a hardware wallet is attached or the user is coming back from a hardware invoice edit, so hardware receive lands on Auto instead of Trezor.
The rest are non-blocking. ./gradlew detekt reports two violations on BlocktankRepo.kt on this head (ComplexCondition at line 478 and ImportOrdering), which is why the code-scanning alert is still open; the error-classification substring is broader than intended; and there are a few small rule and cleanup items noted inline.
|
HW receive broke after rebasing onto master (Trezor tab). e2e Home Receive, paired Trezor, Spending 0 / no LN:
New Lightning-unavailable fallback does E2E helper reads the default-tab QR, taps Trezor, and waits for a different address. This branch already opened on Trezor, so the addresses match and it times out. Home Receive should stay Savings (Auto if LN works). Trezor only when opening from the HW wallet screen. Recording (master): Screen.Recording.2026-09-07.at.12.41.16-master.movRecording (this branch): Screen.Recording.2026-09-07.at.12.44.47-1222.mov |
Addressed. Home Receive no longer defaults to Trezor just because a single hardware wallet is paired; it defaults to Auto when Lightning is available, otherwise Savings. Trezor is now only used as the initial tab when opening from the hardware wallet receive flow, or when returning from an edit that actually originated on the Trezor tab. I also fixed the Savings fallback to scroll to the actual Savings index instead of hardcoding |
jvsena42
left a comment
There was a problem hiding this comment.
Traced the funds-critical parts and they hold up: createCjit still sends invoiceSat = amountSats with channelSizeSat = amountSats + lspBalance, the Confirm screen's receiveAmountSats is the same action.amountSats used for the invoice, the binary search in maxCjitAmountSats() is over a monotone predicate and is overflow-safe, getInvoiceForTab is called with the per-item tab, and the hardware address is only produced under ReceiveTab.TREZOR. I found no way to show a destination belonging to the wrong tab and no sat/msat or off-by-one error in the limits.
One regression and one nit inline.
Regression test — stale channel cache in
|
jvsena42
left a comment
There was a problem hiding this comment.
Re-reviewed at 3c21400. No HIGH/MEDIUM — nothing blocking from me. One LOW nit inline, pre-existing, take it or leave it.
Amount and fee honesty — clean. createCjit sends invoiceSat = amountSats and channelSizeSat = amountSats.safe() + lspBalance.safe(); both confirm routes read feeSat/invoice off the same IcJitEntry, and ReceiveConfirmScreen displays receiveAmountSats - feeSat. CJIT entries are immutable and unpaid ones cost nothing, so a retry can't double-charge. Every CJIT still passes through ReceiveConfirmScreen (initial or isAdditional) before cjitInvoice is set — no path shows the LSP invoice without the fee screen first.
Max-amount search — clean. maxCjitAmountSats()'s binary search and canFitChannelSize are USat-guarded and monotone, and freshMaxChannelSizeSat() is now best-effort with a cached fallback, so a /info blip degrades instead of hard-failing.
Stale-inbound — clean, and this was the one I most wanted to settle. currentChannels() is live-first (lightningRepo.getChannels() → node.listChannels(), guarded by isRunning), syncState() runs before the Channel* decisions, and calculateRemoteBalance() filters filterOpen and then isUsable — so pending channels can't inflate displayed inbound and steer someone away from a CJIT they actually need.
Also traced clean: updateBip21Invoice clears bolt11 before updateBip21Url, so no stale lightning= param can survive; getInvoiceForTab(AUTO) strips lightning on fallback and invoicesByTab keys cover every input that can change it. presentationId only participates in key() — no production code compares Sheet.Receive by equality, so the session reset is sound. createCjit/maxCjitAmountSats use runSuspendCatching, and the effect SharedFlow drops with no subscriber, so a back-press mid-decision just discards the effect rather than stranding state. toCjitError only substring-matches into a typed error — no Blocktank string is rendered beyond the pre-existing app.toast(e). Nothing seed-derived is touched, and nothing here is persisted, so there's no migration path from the shipped build to worry about.
iOS #711 parity: every finding from that side is either already handled here or structurally absent — pending-channel inbound (filtered by isChannelReady && isUsable), Auto hidden while CJIT is shown (visibleTabs), edit-flow failure routing (navigateCjitAmount), refresh-before-max (freshMaxChannelSizeSat), and canCreateCjit/max coverage (BlocktankRepoTest). The only iOS point with an Android analog not already on record is the in-flight guard, inline below — and it lands on the edit path here rather than the amount screen.
jvsena42
left a comment
There was a problem hiding this comment.
Re-reviewed at 10355fa. The only change since 3c21400 is the isCreatingCjit guard in EditInvoiceScreen, and it's correct: the flag is set before the createCjit call, cleared after the whole runSuspendCatching block (so both the success and failure branches are covered), and OR'd into isLoading on the way to EditInvoiceContent — the Continue button now stays disabled and spinning for the duration of the LSP round trip. Cancellation is fine too: runSuspendCatching rethrows, but the composable is leaving at that point and the remember state goes with it.
No new findings. Still no HIGH/MEDIUM from me on this PR.
There was a problem hiding this comment.
QA Notes
Tested on a Pixel_9a emulator (API 37, regtest via network Electrum), built and installed from this head after uninstalling the previous app.
Passed:
- Savings edit above inbound returns to the Savings on-chain QR and does not route to CJIT. With a fresh wallet at zero inbound, editing to
2 000 000sats came back to Savings and Show Details listed only the on-chain address, with no Auto tab offered. - Spending shows CJIT onboarding when Lightning cannot receive, and editing from Spending routes to CJIT amount entry with the
4 000sat minimum shown. - Spending edit above the real CJIT maximum clamps to the maximum instead of letting the amount grow, and the entry stays on the amount screen.
- Spending edit with a valid CJIT amount creates the additional CJIT and opens confirmation with the fee breakdown, and the entry is created successfully.
- The resulting CJIT invoice shows on Spending only, as a Lightning invoice with no on-chain address and no Auto tab.
- Receive sheet close and reopen starts a fresh session on Savings, since Auto is unavailable at zero usable inbound.
Not exercised: the Auto-tab fallback and the Auto details spacing check, because reaching a usable channel needs a funded counterparty this emulator session did not have.
Approve.
Left a few inline nits.
—
Reviewed by Claude Code claude-opus-5 (xhigh) via gh-pr-review-loop
jvsena42
left a comment
There was a problem hiding this comment.
Re-reviewed the delta at 07ec44104. No HIGH/MEDIUM.
The off-main move is right and it fixed a second thing at the same time. inboundLiquiditySats() is now suspend on bgDispatcher, so currentUsableChannels().calculateRemoteBalance() no longer runs on the caller's thread — and hoisting it to a single inboundCapacitySats local in onClickContinue removes the duplicate call that needsCjitLimitsForAdditionalLiquidity and additionalLiquidityAction were each making. Both now read the same snapshot, which also closes a small window where the two decisions could have been made against different channel state mid-evaluation.
verify(walletRepo, times(1)).inboundLiquiditySats() pins the dedup rather than just the happy path, so a future refactor that re-introduces the second read fails the test. The syncState() assertions added across the WalletRepoTest bip21 cases are the right shape too — they pin that the refresh path syncs before deciding, including on the negative branches.
Nothing else in the delta.
There was a problem hiding this comment.
QA Notes
Tested on a Pixel_9a emulator (API 37, regtest), built and installed from this head after uninstalling the previous app. A real usable channel was opened through the repo's own lsp skill (channel order, regtest deposit, mine, open, confirm), so the Auto paths this PR touches could be exercised for real rather than skipped.
Passed:
- Auto becomes available and is auto-selected once a usable channel exists, showing the unified QR.
- Auto details show both the on-chain and Lightning copy cards.
- Auto edit above inbound falls back to the Savings on-chain QR and does not route to CJIT. Editing to
5 000 000sats against roughly200 000sats of inbound dropped the Auto tab and returned to Savings with only the on-chain address. - Savings edit above inbound stays on the Savings on-chain QR.
- Spending shows CJIT onboarding when Lightning cannot receive, and a Spending edit routes to CJIT amount entry at the minimum.
- Receive sheet close and reopen starts a fresh session on Auto when it is available.
No dropped frames, ANRs, or crashes were observed while driving the edit flow, which is the path this change moves off the main thread.
Approve.
—
Reviewed by Claude Code (claude-opus-5 high) via gh-pr-review-loop skill
ovitrif
left a comment
There was a problem hiding this comment.
LGTM, review bot did the rest to confirm this is ready to merge from my side.
Fixes #1226
Description
Ports the receive liquidity/CJIT behavior from bitkit-ios synonymdev/bitkit-ios#711.
ReceiveLiquidityDecisionhelper for Lightning invoice vs additional CJIT routing.Preview
Screen.Recording.2026-09-03.at.12.35.19.mov
QA Notes
Manual checks:
Automated checks:
ReceiveLiquidityDecisionTest.ktReceiveInvoiceUtilsTest.ktEditInvoiceVMTest.kt