fix: match cjit entry to channel by funding tx#1017
Conversation
Greptile SummaryFixes a false-positive CJIT match that suppressed the "Spending Balance Ready" toast and generated a spurious received-payment entry whenever an unpaid instant-payment invoice was still pending during a Transfer to Spending. The matching logic in
Confidence Score: 5/5Safe to merge — the fix is well-scoped, mirrors the iOS behaviour, and is guarded by comprehensive unit tests covering the key edge cases. The changed paths are all covered by new unit tests; the matching logic itself is simple and provably correct (exact outpoint comparison); the app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt — specifically the retry loop in
|
| Filename | Overview |
|---|---|
| app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt | Core fix: getCjitEntry now matches by funding outpoint (txid+vout) instead of channel size/LSP pubkey. Adds a refreshCjitEntries helper with retry logic and the documented runCatching+explicit-rethrow pattern for timeout-as-retriable-failure; minor wasted delay after the last retry attempt. |
| app/src/main/java/to/bitkit/domain/commands/NotifyChannelReadyHandler.kt | Migrated from runCatching to runSuspendCatching so coroutine cancellation is no longer swallowed; all return@ labels updated accordingly. |
| app/src/main/java/to/bitkit/ext/Coroutines.kt | Adds runSuspendCatching, an inline wrapper that re-throws CancellationException before wrapping any other Throwable in Result.failure. |
| app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt | Comprehensive new test coverage: null funding txo, stale unpaid entry, txid+vout match/no-match, vout-only mismatch, no-pending-CJIT skip, expired entry skip, cached hit without refresh, total refresh failure, and retry-on-transient-error. |
| app/src/test/java/to/bitkit/ext/CoroutinesTest.kt | Unit tests for runSuspendCatching: success wrapping, non-cancellation failure wrapping, and CancellationException re-throw — all three contract points covered. |
| AGENTS.md | Documents the new runSuspendCatching rule and the withTimeout exception pattern; canonicalises BlocktankRepo.refreshCjitEntries as the documented exception site. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["getCjitEntry(channel)"] --> B{"channel.fundingTxo == null?"}
B -- Yes --> C["return null"]
B -- No --> D["check cached cjitEntries for txid+vout match"]
D -- Match found --> E["return cached entry"]
D -- No match --> F{"any cached entry has channel==null AND state != EXPIRED/FAILED?"}
F -- No --> G["return null (skip server round-trip)"]
F -- Yes --> H["refreshCjitEntries()"]
H --> I["withTimeout(5s): cjitEntries(refresh=true)"]
I -- Success --> J["update _blocktankState, return fresh list"]
I -- Timeout or network error --> K{"attempt < 3?"}
K -- Yes --> L["delay(1s) → retry"]
L --> I
K -- No --> M["Logger.warn, return cached list"]
J --> N{"fresh list contains txid+vout match?"}
M --> N
N -- Yes --> O["return matching entry"]
N -- No --> P["return null"]
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A["getCjitEntry(channel)"] --> B{"channel.fundingTxo == null?"}
B -- Yes --> C["return null"]
B -- No --> D["check cached cjitEntries for txid+vout match"]
D -- Match found --> E["return cached entry"]
D -- No match --> F{"any cached entry has channel==null AND state != EXPIRED/FAILED?"}
F -- No --> G["return null (skip server round-trip)"]
F -- Yes --> H["refreshCjitEntries()"]
H --> I["withTimeout(5s): cjitEntries(refresh=true)"]
I -- Success --> J["update _blocktankState, return fresh list"]
I -- Timeout or network error --> K{"attempt < 3?"}
K -- Yes --> L["delay(1s) → retry"]
L --> I
K -- No --> M["Logger.warn, return cached list"]
J --> N{"fresh list contains txid+vout match?"}
M --> N
N -- Yes --> O["return matching entry"]
N -- No --> P["return null"]
Reviews (4): Last reviewed commit: "Merge branch 'master' into fix/cjit-chan..." | Re-trigger Greptile
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6b143259f3
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Pull request was converted to draft
This comment was marked as resolved.
This comment was marked as resolved.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ae7baa7b7d
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a884da4459
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 85d27e6d75
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
… to the same wallet into one funding transaction while two CJIT invoices are pending, so their entries share a txid and differ only by vout
|
on draft to apply suggestion |
…throw inside a side function
…ationException gracefully
|
Videos updated |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6fceabe494
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
|
||
| fun List<IcJitEntry>.matching(): IcJitEntry? = firstOrNull { entry -> | ||
| val fundingTx = entry.channel?.fundingTx ?: return@firstOrNull false | ||
| fundingTx.id == fundingTxo.txid && fundingTx.vout == fundingTxo.vout.toULong() |
There was a problem hiding this comment.
Use vout in CJIT activity dedupe too
When Blocktank batches two CJIT channels in one funding transaction, this matcher now correctly distinguishes them by vout, but the matched entry is immediately recorded through ActivityRepo.insertActivityFromCjit, which still uses only channel.fundingTxo?.txid as the activity/dedup id (ActivityRepo.kt:627). Both outputs therefore share the same activity id, so after the first channel-ready event inserts an activity, the second is treated as Duplicate and its receive sheet/notification is suppressed; please carry the output index into the downstream activity id/dedup key as well.
Useful? React with 👍 / 👎.
| val hasPendingCjit = cached.any { | ||
| it.channel == null && it.state != CJitStateEnum.EXPIRED && it.state != CJitStateEnum.FAILED | ||
| } | ||
| if (!hasPendingCjit) return@withContext null |
There was a problem hiding this comment.
Refresh when the CJIT cache is empty
When a paid CJIT is handled before _blocktankState.cjitEntries has been populated (for example immediately after createCjit(), which only starts refreshOrders() asynchronously, or after a cold start/wake), cached is empty so hasPendingCjit is false and this returns without the server refresh that would contain the matching funding tx. That makes the channel-ready path treat a real CJIT as a normal channel open and skip the received-payment activity/notification; avoid making an uninitialized/empty cache a terminal no-match.
Useful? React with 👍 / 👎.
This PR fixes the "Spending Balance Ready" confirmation not appearing after a successful Transfer to Spending when an unused instant-payment (CJIT) invoice is still pending.
Description
When a spending channel opens, the app decides whether the new channel came from a CJIT (instant-payment) invoice or from a paid channel order. Previously this match was done by comparing only the channel size and the LSP node pubkey. Because both flows use the same LSP and the same default channel sizing, a leftover unpaid CJIT entry of the same size would be mistaken for the freshly opened channel-order channel.
The side effects of that false match were:
The CJIT entry is now matched to a channel by its funding transaction id, which is the stable identifier that actually ties a CJIT entry to the channel it opened (mirroring the iOS matching). Entries are refreshed from the server first so a genuinely opened CJIT channel association is current before matching, with a fallback to cached state. A stale, unpaid CJIT entry has no opened channel and can no longer be mistaken for a channel-order channel.
Preview
without-cjit.webm
bug.webm
fix.webm
cjit.webm
QA Notes
Manual Tests
regression:No leftover CJIT invoice → Transfer to Spending → Confirm → swipe: "Spending Balance Ready" confirmation still appears.regression:Pay a real instant-payment (CJIT) invoice → channel opens: received-payment sheet and received Lightning activity still show as before.Automated Checks
app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt— stale unpaid entry is not matched, funding-tx match/no-match, and null funding txo.