Skip to content

fix(receive): handle additional receive liquidity edge cases - #711

Open
pwltr wants to merge 1 commit into
masterfrom
fix/additional-cjit
Open

fix(receive): handle additional receive liquidity edge cases#711
pwltr wants to merge 1 commit into
masterfrom
fix/additional-cjit

Conversation

@pwltr

@pwltr pwltr commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Gate additional CJIT routing to receive edits that originate from the Spending tab.
  • Keep Savings and Auto edits on the normal receive QR flow, falling back to on-chain when Lightning cannot receive the entered amount.
  • Prevent normal Lightning invoices when there is no ready channel, zero inbound liquidity, or insufficient inbound liquidity for a fixed amount.
  • Extract receive liquidity decisions into a focused, unit-tested helper.
  • Enforce the real CJIT maximum using invoice amount plus default LSP balance against Blocktank max channel size.
  • Improve the CJIT max amount error toast instead of surfacing the generic App Error.
  • Use additional-liquidity copy when the wallet already has channels.
  • Document the receive liquidity behavior in 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

  • Ran swiftformat on touched Swift files.
  • Ran focused unit tests: ReceiveLiquidityDecisionTests passed, 12/12.
  • Manually validated:
    • Savings/Auto edit over inbound falls back to on-chain without CJIT.
    • Spending edit over inbound but below CJIT minimum routes to CJIT amount entry.
    • Spending edit with valid CJIT amount shows confirmation.
    • CJIT amount over max shows the improved max amount toast.

@pwltr
pwltr force-pushed the fix/additional-cjit branch from 5aa9515 to 54e6fdf Compare September 1, 2026 17:00
@pwltr
pwltr marked this pull request as ready for review September 1, 2026 17:12
@greptile-apps

greptile-apps Bot commented Sep 1, 2026

Copy link
Copy Markdown

Greptile Summary

The PR centralizes receive-liquidity decisions and updates the receive flow to distinguish normal Lightning invoices, on-chain fallback, and additional CJIT capacity.

  • Gates fixed and variable Lightning invoices using channel readiness and inbound capacity.
  • Restricts additional CJIT routing to edits originating from the Spending tab.
  • Calculates the CJIT amount ceiling from Blocktank’s maximum channel size and default LSP balance.
  • Adds focused decision tests, improved capacity messaging, source-tab-aware navigation, and receive-liquidity documentation.

Confidence Score: 5/5

The 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.

Important Files Changed

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]
Loading

Reviews (1): Last reviewed commit: "fix(receive): handle additional receive ..." | Re-trigger Greptile

@pwltr
pwltr force-pushed the fix/additional-cjit branch from 54e6fdf to 282da3f Compare September 3, 2026 10:41
@ovitrif

ovitrif commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

pls sync with master and don't forget to request review(ers) when ready

@piotr-iohk

piotr-iohk commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

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:

Creating CJIT invoice with channel size: 777600 and invoice amount: 2000
Failed to create CJIT entry: … Node capacity is above our capacity limit

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 maxChannelSizeSat, so the user still gets App Error.

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

Logs:
bitkit_logs_2026-09-04_13-16-32.zip

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Receive liquidity flow edge cases

4 participants