Skip to content

fix: guard advanced transfer against unaffordable capacity - #1180

Draft
jvsena42 wants to merge 4 commits into
fix/899-max-transfer-to-spendingfrom
fix/advanced-transfer-affordability
Draft

fix: guard advanced transfer against unaffordable capacity#1180
jvsena42 wants to merge 4 commits into
fix/899-max-transfer-to-spendingfrom
fix/advanced-transfer-affordability

Conversation

@jvsena42

Copy link
Copy Markdown
Member

This PR stops the advanced transfer screen from creating a channel order the wallet cannot pay for.

Stacked on #1179 — review that one first; this branch targets it, not master.

Description

The advanced screen lets the receiving capacity be chosen independently of the client balance, and the Min, Default and Max buttons set it straight from the LSP's liquidity options. Those bounds describe the LSP's channel size limits and existing channels — nothing in them knows what the wallet can afford. Since the LSP prices both sides of the channel, raising the receiving capacity raises the order fee, so tapping Max on a transfer already sized near the balance produces an order that costs more than the user has.

Nothing checked for it. The order was created regardless, and the failure only surfaced on the confirm screen, which throws while preparing the funding plan, toasts, and leaves the swipe control stuck in its loading state. The user ends up at a dead end with a live order already placed against the LSP.

Continue is now gated on the selected capacity fitting what the on-chain balance can fund, using the fee quote the screen already fetches for its liquidity fee display, so no extra requests are made while picking an amount. The check is repeated in the view model before the order is created, so a stale or racing UI state cannot wave one through.

Clamping the Max button to an affordable ceiling was considered and rejected: finding that ceiling needs either a long binary search of fee quotes or an assumption about how the fee scales with the receiving side, and #1179 exists because that kind of assumption was already wrong once.

When the budget cannot be determined

The funding budget is resolved at the moment of the decision rather than read from cached state, so a value that never loaded, or went stale while the screen sat open, cannot approve an unaffordable order. Both reads are local to the node, so this stays cheap. The cached copy backing the button is refreshed on the same path and is only an optimisation.

If the node will not report a balance at all, the user is deliberately not blocked and the confirm step stays the authority — blocking there would lock people out of the flow whenever the node is briefly unready, which is worse than the failure it prevents. That case and an unavailable fee quote are logged separately so support logs show why a check was skipped.

Preview

QA Notes

Needs a transfer sized at or near Max — at smaller amounts there is enough headroom that raising the receiving capacity never crosses the balance, and the guard correctly never engages.

Verified on a regtest emulator with a 300,000 sat balance: at Max client amount (298,819) the advanced Max capacity of 1,072,921 quoted a 1,743 sat liquidity fee and Continue stayed inert, while Default at 478,781 proceeded to confirm and created the order.

Manual Tests

  • 1. Transfer → Spending → Max → Continue → Advanced → tap Max: Continue does not advance and no order is created.
  • 2. Same screen → tap Default: Continue advances to Spending Confirm and the order is created.
  • 3. regression: Same screen → tap Min: Continue advances normally.
  • 4. regression: Transfer a small amount (¼) → Advanced → tap Max: Continue advances, since the capacity is still affordable.
  • 5. regression: Spending Confirm → Advanced → Default button returns to the default order and confirm still swipes through.
  • 6. Airplane mode on the advanced screen → tap Continue: the user is not hard-blocked, and the log carries a skipped-check warning.

Automated Checks

  • Unit tests added in TransferViewModelTest.kt: five cases covering budget reservation, rejecting an unaffordable capacity, accepting an affordable one, rejecting without a cached budget, and proceeding when the balance cannot be read.
  • The rejection cases were each confirmed to fail with the guard removed, so they are not vacuous.
  • Local just compile, just test, and just lint all pass, with only pre-existing detekt findings in untouched files.

@jvsena42 jvsena42 self-assigned this Aug 25, 2026
@jvsena42
jvsena42 force-pushed the fix/advanced-transfer-affordability branch from 79d23d9 to 542700f Compare August 25, 2026 14:47
jvsena42 and others added 4 commits August 25, 2026 12:58
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jvsena42
jvsena42 force-pushed the fix/advanced-transfer-affordability branch from 542700f to a006370 Compare August 25, 2026 16:08
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.

1 participant