Conversation
884ef2e to
f604acd
Compare
|
jvsena42
left a comment
There was a problem hiding this comment.
Funds-focused review. No sats are at risk anywhere in this PR, and three of the four iOS-parity gaps I'd recorded for Android are closed:
validateCjitEntrytwin — closed.CjitQuoteValidator(models/CjitQuoteValidator.kt:17-24) rejectsfeeSat >= invoiceSatbefore subtracting, thenchannelSizeSat < invoiceSat - feeSat, all inULong— the guard makes the subtraction underflow-proof. Placement is what matters and it's right: it runs insideBlocktankRepo.createCjit(:283-287) right after the onlycoreService.blocktank.createCjitcall in the app, and both UI callers go throughBlocktankViewModel.createCjit→ repo.CjitEntryDetails.from(ReceiveConfirmScreen.kt:208-223) is the only production constructor of the confirm model, so the old signed-Longpath at:83can no longer render a negative "you will receive".- Stale CJIT after editing, ready-channel half — closed, by a different mechanism than iOS: clearing eagerly rather than replacing the route on completion. That eagerness is the problem inline.
- Stale channel cache in
canCreateLightningInvoice— closed, not reintroduced.currentChannels()(WalletRepo.kt:771-773) reads livegetChannels()when the node is running; this PR only swapsisUsable→isChannelReady(:776), andcalculateRemoteBalance()already filtered onisChannelReady, so the liquidity number is consistent.
Also checked clean: the hardware address is only emitted under ReceiveTab.TREZOR and CJIT only under SPENDING (ReceiveInvoiceUtils.kt:40-52), with AUTO hidden while a CJIT invoice exists — no wrong-destination display. All new arithmetic is ULong behind the underflow guard. Session reset per presentation is guaranteed by key(receiveSheetPresentationKey(sheet)) plus LaunchedEffect(startRoute) { clear() }.
Still open at LOW — zero-inbound first-channel half. With no channels, Edit on the initial CJIT QR → Continue returns None via (inboundCapacitySats ?: 0uL) == 0uL (ReceiveLiquidityDecision.kt:45), so the CJIT is cleared, the typed amount only reaches the bip21, and ReceiveQrScreen.kt:211-217 flips to Savings. "Receive on Spending" then opens ReceiveAmountScreen empty — it uses a fresh hiltViewModel() AmountInputViewModel whose only setSats calls are the max clamp and the min button. Same ~3-line fix as I suggested on iOS: prefill from walletState.bip21AmountSats in a LaunchedEffect, since both the None and ChooseAmount branches already persist the typed amount there. (Not in the diff, so noting it here.)
Requesting changes for one regression — inline. It's in core receive rather than Paykit, so it isn't gated.
jvsena42
left a comment
There was a problem hiding this comment.
Finding A is fixed — 79dd4c3bf drops the start-of-edit clear (beginReceiveEdit() is gone from :155-164 and deleted) and clears on completion in updateInvoice (:365-368). Lifting my REQUEST_CHANGES.
I walked every path in EditInvoiceScreen.kt:116-154 to check the stale-invoice bug this PR originally fixed stays closed:
| Path | Edit applied? | CJIT |
|---|---|---|
None |
yes | cleared via updateInvoice → the "edit down with a ready channel" bug stays closed |
ChooseAmount |
yes | cleared, then Amount |
CreateCjit success |
on Confirm | onCjitConfirmed sets the new invoice |
CreateCjit failure / GeoBlocked |
no | old, still-valid CJIT kept — correct |
| Trezor on-chain edit | on-chain only | kept — correct |
| Back from Edit without applying | no | kept — the bug is fixed |
Per-presentation scoping still holds: cjitSessionState is still remembered inside ReceiveSheet (:81) under key(receiveSheetPresentationKey(sheet)), with a fresh UUID per Sheet.Receive, and no CJIT state moved into WalletRepo/WalletViewModel. The only new repo writes are bip21AmountSats/bip21, which every sheet open resets via refreshReceiveState(), so nothing leaks into the next presentation.
B is addressed — updateOnchainBip21Amount keeps the Savings QR on the approved amount. C is addressed — getOrThrow() now sits inside runSuspendCatching (EditInvoiceScreen.kt:133-135). The zero-inbound prefill is still open, but I'd marked it optional.
Three LOW notes inline, none blocking.
Finding A is fixed in 79dd4c3 and nothing blocking remains. Remaining notes are non-blocking — see the latest review.
Regtest APKDownload bitkit-dev-debug universal APK (expires in 30 days). |
jvsena42
left a comment
There was a problem hiding this comment.
Re-reviewed at dff7f789b, and I re-derived the whole path table from the code rather than leaning on my previous sign-off — the commit changes exactly the mechanism I signed off on last time, and the skill I work from is explicit that a safety net a prior review praised is an unverified claim until stressed again.
The regression I flagged is fixed. Confirm -> Back now leaves everything alone: onCjitCreated (:447-449) no longer nulls cjitInvoice, navigateReceiveConfirm (:396-397) is synchronous again and no longer touches bip21, and the bip21 write moved into ConfirmIncreaseInbound.onContinue (:317). So declining a replacement quote leaves the old CJIT intact and the QR recomposes onto Spending showing it.
Re-derived table at head:
| Path | Edit applied | Existing CJIT | Payer-visible QR stale? |
|---|---|---|---|
None |
yes | cleared | no — re-derived from bip21 at the new amount |
ChooseAmount |
yes | cleared, then Amount | no |
CreateCjit -> Confirm |
at Confirm only | kept until :318, then replaced |
no — fee, "you will receive" and entry.invoice all come from the same entryDetails object |
CreateCjit -> Confirm -> Back |
no | kept | no — the fix |
CreateCjit failure |
no | kept | no |
GeoBlocked |
no | kept | no |
| Trezor on-chain edit | on-chain only | kept | no |
| Back from Edit unapplied | no | kept | no — resetEditInvoiceAmount() only writes to the edit view model, never to wallet state |
| Sheet dismissed at any point | no | lost with the presentation | by design; and dismissing at Confirm now leaves bip21AmountSats/bolt11 untouched |
| System Back at Confirm | no | kept | no |
| Config change mid-flow | n/a | lost | see the second inline note |
No completing or cancelling path replaces or destroys a displayed CJIT before the user confirms the replacement. Nothing moved CJIT state into WalletRepo/WalletViewModel — remember at :80 under key(receiveSheetPresentationKey(sheet)) still scopes it per presentation, and the one new repo write (setBolt11("")) is regenerated by refreshBip21() on every sheet open.
Two findings inline, both reproduced on a Pixel 9 against the PR-head CI build rather than argued from code. One is MEDIUM and is why I am requesting changes; the other is LOW and optional. Both are pre-existing symptoms rather than regressions — I have said so explicitly on each, and on the MEDIUM I have offered to drop the block if you would rather take it separately.
Also checked and clean:
- Approve-time display vs encoded invoice.
ReceiveConfirmScreen.kt:82-93computes fees and "you will receive" fromentry, and:110passesentry.invoice;:316-322readsreceiveAmountSatsoff the samelet-bound object, so the screen and the lambda recompose together and cannot disagree. - Savings vs Spending after an additional CJIT.
:317sets Savings to the exact amount the quote was priced for andWalletRepo.kt:636clears the stale-amountbolt11;getInvoiceForTabpreferscjitInvoiceand AUTO is hidden while one exists, so no tab can show the old-amount bolt11. - Arithmetic. No new signed-
Longsubtraction.CjitEntryDetails.fromis still the only production constructor and still runsCjitQuoteValidator.validatebefore theLongfields exist. - Partial failure.
CreateCjitfailure touches neithercjitInvoicenorentryDetails. - Double-tap. Navigation is synchronous again, which closes the window I flagged last round.
_isLoadingis set onMain.immediatebefore the first suspension,PrimaryButtonisenabled && !isLoadingwith a 500 ms debounce. The residual — a tap during the 300 ms exit transition starting a secondcreateCjit— has itsLaunchedEffectcancelled when Edit leaves composition, so nothing reachesonCjitCreated; worst case is an orphan quote at the LSP. - Fire-and-forget
updateOnchainBip21Amount.WalletViewModel.kt:522is nowviewModelScope.launch. The only concurrent writer isrefreshBip21()on a new presentation, which needs dismiss-and-reopen inside the DataStore write window;SCREEN_TRANSITION_DELAYinshowSheetwidens that further. Not reachable by a human. setBolt11("")vs sheet auto-close.observeReceiveSheetInvoice(AppViewModel.kt:550) returns early on an empty bolt11, soreceiveSheetContextkeeps the previous value and auto-close behaviour is unchanged.- Conventions.
runSuspendCatchingkept atWalletRepo.kt:634;Logger.error(..., context = ...); norunBlocking, no!!, noSystem.currentTimeMillis(). ReceiveInvoiceEditStateTest.kt:70-86now pins keep-until-confirm at the state level.
Gating: core receive, no feature flag, so severity is assigned without any gating discount.
| onLearnMore = { navController.navigateTo(ReceiveRoute.Liquidity) }, | ||
| onContinue = { invoice -> | ||
| cjitInvoice.value = invoice | ||
| cjitSessionState.onCjitConfirmed(invoice) |
There was a problem hiding this comment.
Medium: this route is missing the updateOnchainBip21Amount call its sibling has two blocks below, and the new resetEditInvoiceAmount() helper turns that gap into a destroyed CJIT.
Compare ConfirmIncreaseInbound.onContinue at :317:
wallet.updateOnchainBip21Amount(entryDetails.receiveAmountSats.toULong())
cjitSessionState.onCjitConfirmed(invoice)against this one, which goes straight to onCjitConfirmed. So after the first CJIT, bip21AmountSats is still null.
Reproduced on device (Pixel 9, PR-head CI build dff7f789b), regtest wallet with no channels:
- Receive -> Spending -> Receive Lightning funds -> Min (₿2,000) -> Continue -> Confirm ("YOU WILL RECEIVE ₿593") -> Continue. CJIT QR shown on the Spending tab.
- Tap Edit — the amount field reads ₿0 / $0.00, not 2,000.
resetEditInvoiceAmount()(:121-128) readswalletState.bip21AmountSats, which this route never set, so it takes theclearInput()branch. - Tap Show QR without touching the amount — the intent being to add a note or a tag.
onClickContinue(SPENDING, 0, …)->needsInboundLiquidity(0, 0)is true (ReceiveLiquidityDecision.kt:71-73) ->:45returnsNone->updateInvoice(0)->clear()(:377). The CJIT is gone and the sheet silently switches to the Savings tab showing an on-chain address.
No sats are at risk — the LSP invoice stays payable for anyone who already scanned it, and an on-chain payment still credits the user. What makes this MEDIUM rather than LOW is that the trigger is completely benign (open Edit to add a note), and the outcome silently changes the receive destination the user explicitly chose, with no indication. A user who shows the new QR believing they are still receiving on Spending gets an on-chain payment and no channel.
On scope, because this matters for whether it is fair to raise here: the symptom predates this PR — the edit view model was 0 on this route before dff7f789b too, since it is cleared once per sheet at :79 and the Amount screen uses its own hiltViewModel() instance. But this commit added resetEditInvoiceAmount() specifically to prefill the edit amount, and wired it to both Edit entry points; this is the one route that does not feed it. The fix is one line, in code this commit already edits:
onContinue = { invoice ->
wallet.updateOnchainBip21Amount(entryDetails.receiveAmountSats.toULong())
cjitSessionState.onCjitConfirmed(invoice)That makes the prefill show 2,000, makes the Savings QR encode the same amount the quote was priced for, and stops step 4 from collapsing to None.
If you would rather take this as a separate PR since the symptom is pre-existing, say so and I will drop the block — that is a reasonable call and I would not argue it. iOS has the same gap (ReceiveCjitConfirmation.swift never sets invoiceAmountSats, while ReceiveEdit.swift:160-161 prefills from it), so it is worth fixing on both sides either way.
|
|
||
| val cjitInvoice = remember { mutableStateOf<String?>(null) } | ||
| val cjitEntryDetails = remember { mutableStateOf<CjitEntryDetails?>(null) } | ||
| val cjitSessionState = remember { ReceiveCjitSessionState() } |
There was a problem hiding this comment.
Low, pre-existing: cjitSessionState is remember rather than rememberSaveable, so an activity recreation drops a displayed CJIT while the sheet itself survives.
Reproduced on device (same build): with a confirmed CJIT QR showing on the Spending tab, toggling system dark mode (adb shell cmd uimode night yes) recreates MainActivity. AppViewModel._currentSheet is view-model-scoped so the Receive sheet reappears — but cjitSessionState is fresh and LaunchedEffect(startRoute) at :84 clears it. The sheet came back on Savings with an on-chain QR; the CJIT was gone.
Not introduced here — master already held cjitInvoice in a plain remember { mutableStateOf<String?>(null) }. And it is narrower than it sounds: MainActivity is locked to portrait, so rotation cannot trigger it; it takes a uiMode, locale, or font-scale change, or a low-memory kill. The LSP invoice also stays payable.
Raising it because it is the same failure the rest of this PR is about — a CJIT the user is actively showing to a payer disappearing underneath them — and because both fields are strings, so rememberSaveable with a small Saver would close it. Entirely optional, and reasonable to defer.
Description
Follow-up from synonymdev/bitkit-ios#711 (comment)
This PR:
Design
N/A — no design available.
Preview
Screen.Recording.2026-09-10.at.19.48.49.mov
QA Notes
Manual Tests
regression:ready channel with inbound > 0 but peer not yet usable → Receive: normal LN invoice/Auto availability follows ready inbound capacity.Automated Checks
CjitQuoteValidatorTest.ktcovers invalid fee/net quote cases and a valid quote.CjitEntryDetailsTest.ktcovers confirmation-entry mapping and quote rejection before confirmation UI.ReceiveInvoiceEditStateTest.ktcovers clearing stale CJIT invoice state on edit and fresh CJIT creation.ReceiveLiquidityDecisionTest.ktandWalletRepoTest.ktcover ready-channel receive eligibility and ready inbound liquidity.BlocktankRepoTest.ktcovers node capacity error classification separately from per-channel max-size errors../gradlew testDevDebugUnitTest --tests to.bitkit.models.ReceiveLiquidityDecisionTest --tests to.bitkit.models.CjitQuoteValidatorTest --tests to.bitkit.ui.screens.wallets.receive.ReceiveInvoiceEditStateTest --tests to.bitkit.ui.screens.wallets.receive.CjitEntryDetailsTest --tests to.bitkit.repositories.WalletRepoTest --tests to.bitkit.repositories.BlocktankRepoTest./gradlew compileDevDebugKotlin