Skip to content

fix: settle spending transfer maximums - #686

Draft
jvsena42 wants to merge 6 commits into
masterfrom
fix/spending-transfer-affordability
Draft

fix: settle spending transfer maximums#686
jvsena42 wants to merge 6 commits into
masterfrom
fix/spending-transfer-affordability

Conversation

@jvsena42

@jvsena42 jvsena42 commented Aug 26, 2026

Copy link
Copy Markdown
Member

This PR:

  1. Settles the maximum Savings → Spending transfer on an amount the wallet can actually pay the order fee for.
  2. Settles the maximum receiving capacity on the advanced screen the same way.
  3. Re-checks both against a live funding budget before an order is created.

Ports synonymdev/bitkit-android#1179 and synonymdev/bitkit-android#1180.

Description

Sizing a max transfer takes two LSP fee quotes: the first prices the whole available balance, the second re-prices at the balance left after that fee. The maximum was then set to the available balance minus the second quote — a balance that quote never priced. The resulting order can cost more than the user has. On Android it was one satoshi over, and the transfer failed with an insufficient funds error. The same derivation is here, and it also backs the hardware wallet limits.

Capping the maximum at the quoted balance is not enough, because the service fee moves with the client/LSP split and the direction differs by deployment: in production it rises with the client balance, on staging and regtest it falls. That belongs to the LSP rather than the app, so the maximum is now verified instead of assumed — each round re-quotes its own candidate, and only an amount whose own quote fits the budget is offered. Where the fee rises the first check passes immediately, so that path keeps its current latency.

The advanced screen has the same gap: Min, Default and Max come straight from the LSP's liquidity options, which know nothing about the client balance already committed. Since the LSP prices both sides, raising the receiving capacity raises the order fee, so Max on a transfer sized near the balance produced an order that only failed later on the confirm screen. The offered maximum is now settled before it is shown, and settling it below what is already entered brings the entered amount down with it.

Both maximums are backstopped by a live re-check before the order is created, since a balance can drain after the limits were sized. A hardware transfer reads the device account, never on-chain savings, which would reject every hardware transfer. An unreadable balance or a missing quote deliberately does not block the user — the confirm step stays the authority — and both cases are logged.

Two differences from Android, for anyone comparing: the fee is read as network plus service fee rather than the estimate's total, since the bindings document neither convention and the split is what the existing calculation already uses; and Android's fee-quote race fix is not ported, because the advanced screen here already cancels its in-flight quote correctly.

Linked Issues/Tasks

Screenshot / Video

QA Notes

Needs the whole on-chain balance transferred at once — the gap only appears at exactly Max. Regtest is regression coverage only: its vulnerable window is roughly 2 satoshis wide versus about 37 in production, and its channel limits drift on their own. The unit tests are the gate for the fix itself.

Manual Tests

  • 1. Spending Amount → Max → Continue → Spending Confirm → swipe: the transfer funds, and the logged order fee is at or below the available amount.
  • 2. Spending Confirm → Advanced: the number pad is disabled while the maximum settles, then Max → Continue returns to Spending Confirm with the order created.
  • 3. Spending Advanced → enter a capacity above the settled maximum: the toast shows the settled value and the amount snaps to it.
  • 4. Spending Advanced → tap Max before the maximum settles: the entered amount comes down to the settled maximum.
  • 5a. regression: Spending Advanced → Default → Continue: the order is created normally.
    • 5b. regression: Spending Advanced → Min → Continue: the order is created normally.
  • 6. regression: Spending Amount → ¼ → Continue → swipe: funds normally.
  • 7a. Spending Amount Hw → Max → Continue: reaches Spending Hw Sign on an empty on-chain savings balance.
    • 7b. Spending Hw Sign → Advanced → Max → Continue: the capacity is priced against the device account.

Automated Checks

  • Unit tests added in BitkitTests/TransferViewModelTests.swift (4 → 24): both fee directions, each re-quote pricing the split the order will use, a failed re-quote, exhausted rounds, six advanced-capacity cases, and the funding guards.
  • Unit tests added in BitkitTests/TransferViewModelHwTests.swift (32 → 35): the budget reads the device account, and is nil when the device is unreachable so the guard stays non-blocking.
  • Each new rejection case was confirmed to fail with its fix removed, so none are vacuous.
  • Local xcodebuild test passes (975 tests; only the regtest-dependent UtxoSelectionTests.testUtxoSelection fails, on an unreachable Blocktank endpoint), plus swiftformat --lint and node scripts/validate-translations.js.

@jvsena42 jvsena42 self-assigned this Aug 26, 2026
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