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.
jvsena42
left a comment
There was a problem hiding this comment.
Re-reviewed at a2a1edf74. The two new commits are f1f088ad (the updateOnchainBip21Amount call I asked for on :303) and a2a1edf74 (the unchanged-edit guard). f1f088ad is correct. The guard in a2a1edf74 has a bug inside it — one MEDIUM below, plus a LOW on the same branch.
Neither finding reverses anything: keep-until-confirm (dff7f789b) and the :303 call stay exactly as they are. The MEDIUM is a defect in the new guard, not an argument against having one.
Checked and clean (exits walked at head, and what keeps each correct):
| Exit | Result |
|---|---|
| QR → Edit → system/gesture Back | onBack = popBackStack (:376); resetEditInvoiceAmount() writes only to the edit VM. CJIT and bip21 untouched |
| Edit → CreateCjit → Confirm → Back | onCjitCreated (:451) only sets entryDetails; navigateReceiveConfirm touches no wallet state. Displayed CJIT and Savings amount unchanged |
| Edit → CreateCjit failure / GeoBlocked | navigateCjitAmount / navigateGeoBlock; nothing cleared; both Backs are plain pops |
| Confirm → Continue (both routes) | fee, net and entry.invoice all read off the same let-bound entryDetails; popUpTo(QR) inclusive makes a double-tap inert |
| Liquidity / LiquidityAdditional → Back or Continue | both popBackStack; entryDetails retained |
| Sheet swipe-dismiss → reopen | fresh presentationId → key(...) recreates the sheet → fresh ReceiveCjitSessionState + LaunchedEffect(startRoute) { clear() } + clearBip21State. No CJIT state lives in WalletRepo/WalletViewModel |
| Backgrounding without death | a ChannelReady regenerates bolt11, but Spending still prefers cjitInvoice and AUTO stays hidden while one exists |
Also verified: CjitQuoteValidator runs twice (in createCjit and again in CjitEntryDetails.from) before any Long conversion, and net receive is guarded by feeSat < invoiceSat; ReceiveCjitErrorPresenter renders only string resources, no server text; refreshCjitEntries still has the correct withTimeout + explicit CancellationException/TimeoutCancellationException guard while createCjit uses runSuspendCatching. BlocktankRepo, WalletRepo, the validator and the decision table are unchanged since dff7f789b and still look right.
Process death mid-session is still out of scope — you deferred it earlier and I'm not reopening it.
Non-blocking, separate from the findings: ### Design says N/A — no design available., but all four screens this PR touches are mapped to real frames in docs/screens-map.md — EditInvoiceScreen.kt, ReceiveAmountScreen.kt, ReceiveConfirmScreen.kt, ReceiveQrScreen.kt (lines 192-197), none of them todo or n/a. Link the relevant frames and I'll diff the new error states against them next pass. Doesn't block anything.
jvsena42
left a comment
There was a problem hiding this comment.
69a076b47 fixes the MEDIUM and the LOW. Clearing my REQUEST_CHANGES — details on the two threads.
The repro is closed at the guard itself: confirmedAmountSats is snapshotted in onCjitConfirmed from the same entryDetails both Confirm routes write their bip21 amount from, and clear() nulls it alongside cjitInvoice. A declined replacement quote now leaves entryDetails desynced harmlessly, because the guard no longer reads it.
I spent most of this pass on the mirror bug rather than the one I reported, since a guard fixed in response to review is exactly where the next defect lands — that is how the original got in. hasConfirmedInvoiceForAmount returning false for the genuinely confirmed amount would destroy a CJIT invoice the user has already shown a payer, so I traced every writer of bip21AmountSats that could make the Edit prefill diverge from the snapshot: setSats stores sats verbatim and switchUnit only rewrites display text (no fiat rounding drift on a prefilled untouched amount), and the only in-sheet trigger of refreshBip21/clearBip21State is the notification switch on Confirm/Liquidity, which fires before Confirm's own amount write. Inbound liquidity growing mid-session is no longer relevant either, now that the guard is pure amount equality. Nothing diverges.
Exits re-walked at the new head, since the control flow changed shape: Edit toolbar/system/gesture Back; Confirm → Back after a declined replacement quote, and the mirror where the confirmed amount is re-applied afterwards; an Amount-created quote declined twice back to QR; Liquidity and LiquidityAdditional Back and Continue; CreateCjit failure and GeoBlocked; system Back on the QR root; swipe and scrim dismiss then reopen (fresh presentationId → fresh session state → refreshBip21 reset); config change (all three fields are remembered so they die together — no partial state, and the CJIT loss there is the limitation you explicitly deferred, not reopening it); process death; backgrounding without death.
The test is load-bearing: reverting ReceiveSheet.kt to a2a1edf74 while keeping the new test fails receive CJIT session matches confirmed amount when pending quote differs; at head 10/10 pass. Throwaway worktree outside the repo, nothing committed.
Docs line matches the code. One note for whoever merges: #1231 renames ReceiveTab.TREZOR on lines immediately adjacent to the ones this PR rewrites in ReceiveQrScreen.kt and ReceiveSheet.kt, so expect a textual conflict — no semantic conflict, and ReceiveInvoiceEditStateTest.kt hunks are disjoint.
Description
Follow-up from synonymdev/bitkit-ios#711 (comment)
This PR:
Design
Mapped frames in
docs/screens-map.md:EditInvoiceScreen.kt: Send (Contact) (Lightning) › Edit invoice populatedReceiveAmountScreen.kt: Receive CJIT Onboarding › CJIT Enter amountReceiveConfirmScreen.kt: Send (Enter Manually) › CJIT Fee to connectReceiveQrScreen.kt: Receive Lightning Active › Receive Auto (Unified QR)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