fix(receive): handle additional receive liquidity edge cases - #711
Conversation
5aa9515 to
54e6fdf
Compare
Greptile SummaryThe PR centralizes receive-liquidity decisions and updates the receive flow to distinguish normal Lightning invoices, on-chain fallback, and additional CJIT capacity.
Confidence Score: 5/5The PR appears safe to merge, with no concrete blocking or independently actionable non-blocking defects identified. The centralized receive-liquidity policy, navigation changes, invoice gating, and CJIT maximum enforcement are internally consistent with the documented behavior and focused tests.
|
| Filename | Overview |
|---|---|
| Bitkit/Models/ReceiveLiquidityDecision.swift | Introduces a focused, deterministic policy for Lightning invoice eligibility and additional-liquidity routing, with matching unit coverage. |
| Bitkit/ViewModels/BlocktankViewModel.swift | Enforces maximum CJIT channel size and derives the maximum invoice amount through the existing LSP-balance calculation. |
| Bitkit/ViewModels/WalletViewModel.swift | Routes receive invoice generation through the centralized readiness and inbound-capacity decision. |
| Bitkit/Views/Wallets/Receive/ReceiveEdit.swift | Preserves the originating receive tab and limits additional CJIT creation or routing to Spending edits. |
| Bitkit/Views/Wallets/Receive/ReceiveQr.swift | Updates receive-tab availability and onboarding presentation when a Lightning invoice cannot be created. |
| Bitkit/Views/Wallets/Receive/ReceiveCjitAmount.swift | Applies the calculated CJIT ceiling to amount entry and surfaces a dedicated maximum-capacity warning. |
| BitkitTests/ReceiveLiquidityDecisionTests.swift | Covers the principal source-tab, inbound-capacity, geographic, minimum, and maximum decision boundaries. |
| Docs/receive-liquidity.md | Documents the intended receive fallback and additional-liquidity behavior across supported edge cases. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Receive amount edited] --> B{Source tab}
B -->|Savings or Auto| C{Lightning capacity sufficient?}
C -->|Yes| D[Generate unified or Lightning-capable QR]
C -->|No| E[Show on-chain Savings QR]
B -->|Spending| F{Additional inbound liquidity needed?}
F -->|No| G[Generate normal Lightning invoice]
F -->|Yes| H{Geo-blocked?}
H -->|Yes| I[Show geo-block screen]
H -->|No| J{Amount within CJIT limits?}
J -->|Yes| K[Create additional CJIT]
J -->|No or limits unavailable| L[Open CJIT amount entry]
Reviews (1): Last reviewed commit: "fix(receive): handle additional receive ..." | Re-trigger Greptile
54e6fdf to
282da3f
Compare
|
pls sync with master and don't forget to request review(ers) when ready |
|
QA iOS sim, regtest. Existing CJIT channel: inbound 768,097, channel size 777,600, spending 1,067. Invoice at exactly 768,097 creates a normal Lightning invoice. Savings edit above inbound stayed on-chain. Auto edit above inbound fell back to Savings QR. Neither entered extra CJIT. Spending bump to 768,098 opens extra-CJIT amount entry (MINIMUM 2,000). Continue at 2,000 (the minimum), 20,000, and 200,009 all fail with generic App Error. Client max toast does not show — these amounts are under the per-channel max (~694k). Logs: Same error for invoice amounts 20000 and 200009. Extra CJIT is trying to open another 777,600 channel on a node that already has one. Blocktank’s node cap rejects it; the new 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. Recording: Screen.Recording.2026-09-04.at.15.15.24.mov |
jvsena42
left a comment
There was a problem hiding this comment.
Diffed against the Android port (#1222). The core is a faithful port — ReceiveLiquidityDecision and its 12 tests match 1:1, and the routing, CJIT max search and session reset are equivalent. Android ended up ahead in three places, which is where most of these comments come from.
On the funds-risk questions: the fee shown is the fee committed (both read from the IcJitEntry whose invoice is displayed), CJIT entries are immutable once created so the quote cannot drift from the payment, and zero-amount invoices with inbound > 0 behave identically on both platforms.
Also: AGENTS.md wants a changelog fragment for a user-facing fix:, and there is none here. Android #1222 ships changelog.d/next/1220.fixed.md.
| func canCreateReceiveLightningInvoice(amountSats: UInt64?) -> Bool { | ||
| ReceiveLiquidityDecision.canCreateLightningInvoice( | ||
| hasReadyChannels: hasReadyChannels, | ||
| inboundCapacitySats: totalInboundLightningSats, |
There was a problem hiding this comment.
🟡 Filter inbound capacity to ready channels before gating invoice creation
totalInboundLightningSats (line 1184, pre-existing) sums inboundCapacityMsat over every channel including pending (isChannelReady == false) ones. Android's equivalent filters: calculateRemoteBalance() -> filterOpen() -> filter { it.isChannelReady }. This PR makes that unfiltered sum newly load-bearing by routing it into canCreateReceiveLightningInvoice, which now gates whether a bolt11 is offered at all. A pending channel's capacity inflates the total, so the gate returns true for an amount the node cannot receive and a plain bolt11 is shown instead of routing to CJIT. Filter by isChannelReady in totalInboundLightningSats.
Regression test:
@testable import Bitkit
import LDKNode
import XCTest
@MainActor
final class ReceiveInboundLiquidityTests: XCTestCase {
func testPendingChannelInboundDoesNotEnableLightningInvoice() {
let wallet = WalletViewModel()
wallet.channels = [
.mock(isChannelReady: true, isUsable: true, inboundCapacityMsat: 0),
.mock(isChannelReady: false, isUsable: false, inboundCapacityMsat: 100_000_000),
]
XCTAssertFalse(wallet.canCreateReceiveLightningInvoice(amountSats: 50_000))
}
}| return description.contains("Channel size is too big") | ||
| || description.contains("channelSizeExceedsMaximum") | ||
| || description.contains("maxChannelSizeSat") | ||
| || description.contains("channelSizeSat") |
There was a problem hiding this comment.
🟡 Substring match on "channelSizeSat" misclassifies the LSP's too-small rejection as maximum exceeded
The last clause strictly subsumes the maxChannelSizeSat clause above it: contains("channelSizeSat") fires on any LSP error text that names that field, including a below-minimum rejection, which is then reported as "Receiving Capacity Maximum ... ₿ {max}" telling the user to enter less. Reachable: .task swallows a failed refreshMinCjitSats(), leaving minimumAmount == 0, so the Continue button's min guard is vacuous and any amount reaches createCjit. Match on the maximum wording only (e.g. "bigger than the maximum"), keep channelSizeExceedsMaximum for the local throw, and let everything else fall through to app.toast(error).
| .padding(.bottom, UIScreen.main.isSmall ? -16 : 0) | ||
|
|
||
| SegmentedControl(selectedTab: $selectedTab, tabItems: availableTabItems) | ||
| SegmentedControl(selectedTab: selectedTabBinding, tabItems: availableTabItems) |
There was a problem hiding this comment.
🟡 Hide the Auto tab while a CJIT invoice is displayed
availableTabItems (and the TabView below) show .unified whenever wallet.bolt11 is non-empty, regardless of cjitInvoice. Android's visibleTabs drops AUTO when cjitInvoice is set, and Docs/receive-liquidity.md (shipped in this PR) states the CJIT invoice "must be shown as Spending-only, not as Auto/unified receive". Because the .createCjit path deliberately skips refreshBip21, the Auto QR at this point carries the previous bolt11/amount. Gate .unified on cjitInvoice == nil as well.
| navigationPath.append(.cjitGeoBlocked) | ||
| } | ||
| } catch { | ||
| app.toast(error) |
There was a problem hiding this comment.
🔵 Edit-flow createCjit failure shows the generic error Android replaced
When .createCjit throws here the user gets app.toast(error) ("Channel size exceeds maximum allowed size" for the max case, no amount) and stays on Edit. Android's port routes to the CJIT amount screen and suppresses the generic toast for ChannelSizeExceedsMaximum, so the user lands where the max is enforced and shown. The PR body claims the improved max toast, but only ReceiveCjitAmount has it. Either route to .cjitAmount on failure like Android, or reuse the max toast here.
|
|
||
| let channelSizeSat = amountSats + lspBalance | ||
|
|
||
| if let maxChannelSizeSat = info?.options.maxChannelSizeSat, channelSizeSat > maxChannelSizeSat { |
There was a problem hiding this comment.
🔵 Max check uses cached info; Android refreshes before checking
createCjit, canCreateCjit and maxCjitAmountSats only call refreshInfo() when info == nil. Android's freshMaxChannelSizeSat() refreshes on every call and has a unit test (canCreateCjit refreshes max channel size before checking amount) pinning that. Refresh (best-effort, keep cached on failure) before reading maxChannelSizeSat so the two platforms enforce the same limit.
| if await wallet.waitForNodeToRun() { | ||
| // Only proceed if node is running | ||
| do { | ||
| let entry = try await blocktank.createCjit(amountSats: amountSats, description: "Bitkit") |
There was a problem hiding this comment.
🔵 No in-flight guard on Continue; double tap creates two CJIT entries
Pre-existing, but Android's ReceiveAmountScreen guards with isCreatingInvoice and this PR touches onContinue. Two taps before the first createCjit returns produce two LSP entries and two .cjitConfirm pushes. Add an isCreating state, disable the button and early-return in onContinue while set.
| ) | ||
| } | ||
|
|
||
| func canCreateCjit(amountSats: UInt64) async throws -> Bool { |
There was a problem hiding this comment.
⚪ canCreateCjit / maxCjitAmountSats have no unit coverage
No iOS coverage for canCreateCjit or the maxCjitAmountSats binary search; Android pins the equivalent in BlocktankRepoTest (canCreateCjit refreshes max channel size before checking amount). Worth adding, but note BlocktankViewModel.init fires refreshInfo() and startPolling(), so a test has to inject a stub CurrencyService and suppress the init-time refresh before assigning info.
Summary
Docs/receive-liquidity.md.Linked Issues/Tasks
Closes #671
Screenshot / Video
Simulator.Screen.Recording.-.iPhone.17.-.2026-09-01.at.19.11.14.mov
Testing
swiftformaton touched Swift files.ReceiveLiquidityDecisionTestspassed, 12/12.