Skip to content

Payjoin Fixes (Requires review) - #2499

Merged
i5hi merged 50 commits into
developfrom
payjoin-upgrade
Jul 24, 2026
Merged

Payjoin Fixes (Requires review)#2499
i5hi merged 50 commits into
developfrom
payjoin-upgrade

Conversation

@i5hi

@i5hi i5hi commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Some work from payjoin-upgrade was not included in develop. Starting this PR to review it and merge in useful work.

ethicnology and others added 30 commits July 20, 2026 10:11
The transaction reference validation checked the whole 'txid:vout'
string against the 64-hex-char txid rule, so it rejected every valid
input/output/publicKey label reference. Validate the txid slice only.
Validate a label before inserting it, and skip (rather than throw on) a
corrupt persisted row when reading back, so one bad row can't take down
the whole label fetch.
Building a plain (local) DateTime from a UTC end date's wall-clock
fields shifted the inclusive-day boundary by the device's UTC offset, so
the same export included or excluded edge transactions depending on the
machine's timezone. Round up in UTC when the input is UTC.
Adds is_aborted to payjoin_receivers/payjoin_senders and
is_payjoin_enabled / payjoin_min_amount_sat / payjoin_expire_after_sec
to settings, migrated in schema_13_to_14 with backfilled defaults
(disabled, 10000 sat, 24h). PayjoinConstants gains the bounds and the
documented 24h-expiry rationale.

The bulk of the diff is the generated v14 schema snapshot; review the
migration step, the table definitions, the seeds and the constants.
Threads the three new payjoin settings from the settings entity through
the repository and datasource, fed by the DB columns added in the v14
migration.
Adds PayjoinStatus.aborted (a fallback broadcast: the payment landed as
a plain transaction) and the isAborted model field, mapped to/from the
new DB column and round-tripped in fromReceiverTable/fromSenderTable
(fixing a pre-existing bug where isExpired/isCompleted were never mapped
back, silently resetting a re-fetched session's status). The CSV export
gains the matching aborted case so its status switch stays exhaustive.

Entity getters logRef and canManuallyBroadcastOriginal are introduced
here as the shared vocabulary the core engine (next) builds on.
Adds the payjoin settings screen (auto-saving toggle, min-amount and
expiry with bounds-validating use-cases) reachable from bitcoin
settings, wired through the settings cubit and locator. Includes all new
payjoin l10n keys (settings + the send/receive/transactions keys the
later feature PRs consume) so every stacked branch compiles standalone.
Adds a payjoinEnabledChangeStream to SettingsRepository (mirrors the
existing currencyChangeStream pattern), emitted from setPayjoinEnabled,
plus a WatchPayjoinEnabledChangesUsecase. Lets a live listener (the
receive flow) react to the global setting changing without needing to
leave and re-enter its screen.
Adds ServiceStatus.disabled (intentionally turned off, distinct from
unknown/not-checked) and returns it for the payjoin service the moment
the setting is off, instead of probing the OHTTP relay and painting the
whole status page red for a feature the user isn't relying on. The
status page shows it muted-grey with a "Disabled" label, and it no
longer counts against allServicesOnline.
WatchPayjoinUsecase now emits both PayjoinReceiver and PayjoinSender
(scoped by ids); the send flow needs sender completion events a
receiver-only filter would swallow. ReceiveBloc keeps its receiver-only
behaviour by filtering the concrete type at its own call site.
Adds a sealed WalletFailure family and a single-transaction lookup
use-case returning Result<WalletTransaction?, WalletTransactionLookupFailure>
(Ok(null) = synced-and-absent, Err = infra failure), per the #1895
Result migration. Consumed by the payjoin engine and transaction
details.
PdkPayjoinDatasource gains stopPolling(id) and an idempotent dispose()
so a resolved session's directory poll can be cut (no more stale expiry
events) and tests get a clean teardown. The local datasource filters
include isAborted and fetchByTxId matches originalTxId too, and sender
poll logs/exceptions use a hashed logRef instead of the raw BIP21 URI.
The core of the fix. A unified backoff-poll engine arms two per-session
watchers — one for the real payjoin tx, one for the original (fallback)
tx — so a session always reaches a terminal state (completed or
aborted) instead of hanging. tryBroadcastOriginalTransaction is split
into a guarded public entry (refuses via canManuallyBroadcastOriginal,
emits on the stream) and the internal mechanism; expiry/proposal
processing re-fetch the fresh row and bail on terminal; resume moves out
of the constructor to resumePayjoinsOnStartup, called from AppLocator
once wallet/labels deps are registered. Wallet repos are injected as
lazy closures; the directory receiveTimeout is raised to 35s (above the
30s long-poll hold). Adds the PAYJOIN --> LABELS edge to FEATURES.md.
…2246)

SendCubit._watchPayjoin now owns resolving the flow: a completed/aborted
sender session moves to success with the on-chain txid, syncs the wallet
and stores the user label on the final txid; an expired session returns
to confirm with a broadcast-failure error and clears the provisional
txId/payjoinSender so a retry starts clean. Payjoin is attempted only
when willAttemptPayjoin holds (global setting on, locally-signing
wallet, non-self BIP21 with a pj= param), fail-closed by default.
The bitcoin confirm review shows a 'payjoin will be attempted' card; the
success screen says when a send fell back to a plain transaction and
navigates to the broadcast tx by txid (never the sender BIP21 URI); the
coordinating screen shows a fallback countdown, gated on
canManuallyBroadcastOriginal and only when the fallback is imminent
(<=1h), and localizes its title. Adds the SEND --> TX_HISTORY edge.
Gates payjoin session creation on the global setting and carries
payjoinMinAmountSat in state (with isPayjoinBelowMinimum); drops the
per-address payjoin toggle in favour of that setting. Adds
isPayjoinFlowOwningNavigation so the shell's generic tx-received
listener defers to the payjoin screen, and lets the in-progress screen
be entered on any post-started status. Guards the manual original-tx
broadcast behind canManuallyBroadcastOriginal, adds isClosed guards to
the watchers, splits the new-address error like the start path, and
fixes the lightning in-progress navigation to use the route name.
Adds the RECEIVE --> TX_HISTORY edge.
Rewrites the payjoin-in-progress screen to distinguish below-minimum,
generic fallback, real payjoin and expired outcomes with their own copy,
auto-navigates to transaction details on a real completion, and shows a
fallback countdown (imminence-gated) plus a View Details / receive
normally action driven by canManuallyBroadcastOriginal.
White in both light and dark mode, mirroring onError next to error. Lets
a green success surface use theme-correct white text instead of reaching
for a mismatched onSecondary/onError token.
Adds an optional action widget slot alongside the existing
onAction/actionIcon plain-icon-button trailing, for a screen that needs
something richer than a single icon (e.g. the receive screen's payjoin
toggle chip). Positioned like the plain icon button — no extra offset
stacked on top of its own padding — so it stays aligned with the title.
Adds a green/red toggle chip (ReceivePayjoinToggleButton) to the
receive TopBar, wired to the global setting — only for a Bitcoin
receive with a payjoin-capable wallet (funded, locally-signing), so it
never shows on Liquid/Lightning or non-eligible wallets. Tapping flips
the setting; long-pressing opens the payjoin settings screen. The
BlocProvider now wraps the whole ReceiveScaffold (not just its body) so
the TopBar can host it, gated behind a narrow selector so switching
receive networks still does not rebuild the scaffold.

ReceiveBloc now watches payjoinEnabledChangeStream live, creating or
clearing the session the moment the setting changes anywhere in the
app — no need to leave and re-enter the receive screen. Session
creation (and the toggle itself) is also gated on the wallet having a
confirmed balance (isPayjoinToggleable/_isPayjoinEligible): a payjoin
proposal needs at least one UTXO, so there is nothing to toggle for an
empty wallet, and isPayjoinLoading resolves immediately instead of
waiting forever for a session that will never be created.

Also suppresses the pj= endpoint from the QR (without tearing down the
session) when the requested amount is below the configured anti-probing
minimum, since the receiver would decline it anyway — with an
explanatory hint, and it reappears the instant the amount is raised or
cleared. Adds the RECEIVE --> SETTINGS edge (payjoin settings
long-press).
Adds Transaction.displayPayjoinStatus: when the broadcast transaction is
visible its txid is authoritative (== payjoin txid -> completed, ==
original txid -> aborted), else the session status. The status label and
details table consume it, and the table's status row becomes an
exhaustive switch (no more raw status.name leaking to the user).
TransactionDetailsCubit reloads on payjoin events (not just syncs),
resolves a resolved session's broadcast txid (local then a forced
Result-based lookup) and re-enters by that txid so the screen lands on
the real transaction instead of a session placeholder. The manual
original-tx broadcast and its button gate on canManuallyBroadcastOriginal,
with tightened subscription hygiene (cancel-before-replace, isClosed
guards).
Adds a paged carousel of dismissible announcements on the wallet home,
shown between the balance header and the wallet list. Ships two
compile-time announcements: a payjoin-privacy nudge (once the wallet has
transaction history and payjoin is off) and an autoswap-active notice.

Each card is tappable to its target (payjoin/autoswap settings) and
dismissible via an explicit x with a Read/Dismiss confirmation; the
Read option opens the same target as tapping. Dismissals persist in the
dismissed_announcements table with a permanent/snooze policy per
announcement. The carousel adapts its height to the text scale and
collapses to nothing when no announcement is visible.

Built on bull_ui (BullInfoCard + PageView) and wired via a thin cubit
that re-evaluates on payjoin-setting changes and wallet-sync completion.
feat(payjoin): settings, DB v14 migration, and live status
fix(send): resolve stuck sender flow on payjoin fallback (#2246)
i5hi and others added 17 commits July 21, 2026 17:54
feat(receive): payjoin toggle badge in TopBar with eligibility guard
feat(transactions): display payjoin status and land on broadcast tx
fix(labels): correct payjoin transaction labeling
feat(announcements): dismissible home announcement carousel
Recover stranded payjoin stack (pj/01-06)
@ethicnology

Copy link
Copy Markdown
Member

@claude can you review this whole PR ?

@claude

claude Bot commented Jul 24, 2026

Copy link
Copy Markdown

Claude finished @ethicnology's task in 2m 19s —— View job


Review of PR #2499 — Payjoin Fixes

I reviewed the full diff vs origin/develop (~22k additions, 153 files) area by area: payjoin core engine, receive/send flows, settings + v13→v14 migration, announcements, transactions/labels/wallet. Verdict up front: this is careful, well-tested work that is close to mergeable — the session-resolution engine, migration, and status derivation are unusually solid — but there are 3 verified issues I'd fix before merge and a handful of worthwhile follow-ups.

CI is green, the branch is up to date with develop, FEATURES.md was updated (two graph inaccuracies noted below), and app_en.arb carries all new keys.


🔴 Should fix before merge

1. Two payjoin stream handlers lack the unhandled-error hardening this PR added to the thirdlib/core/payjoin/data/repository/payjoin_repository_impl.dart:642-650 and :751-864
The constructor registers three bare .listen() callbacks (lines 119–121). _processPayjoinRequest was explicitly hardened (its comment at ~492 describes a DB throw escaping the listener and "stranding the session"), but its siblings weren't: in _processPayjoinProposal, the fetchSender (642) and update(payjoinModel) (650) awaits sit before the try at 657 (and the catch path's fetchSender/update at 724/735 are unprotected), while _processExpiredPayjoin has no try/catch at all (fetch at 751–753, update(expiredModel) at 864). A DB throw there becomes an unhandled zone error; since the sender poll cancels its timer before emitting, the proposal PSBT is never persisted and the session strands until app restart — exactly the failure mode the request-handler fix was written for. Fix this →

2. BIP-329 pubkey labels are validated as txid:vout and now hard-rejected before writelib/features/labels/domain/label_entity.dart:29-33
LabelType.publicKey shares the txid:vout split-validation with input. Per BIP-329 a pubkey record's ref is a public key (and bip329_codec.dart:178-183 maps it straight through), so a 66-char compressed key fails _validateTxid and a 64-char x-only key throws RangeError at parts[1]. This bug is pre-existing, but the PR's validate-before-write makes it a hard rejection, ImportLabelsUsecase aborts the whole import on the first pubkey record, _mapRowsTolerantly silently drops legacy pubkey rows on read — and the new test (label_entity_test.dart:47, "accepts a well-formed publicKey reference (txid:vout)") cements the wrong shape. Fix this →

3. Toggle-off vs in-flight session creation race in the receive bloclib/features/receive/presentation/bloc/receive_bloc.dart:273-300, 795-827
_onPayjoinSettingChanged got a post-await re-check (if (state.payjoinGloballyEnabled != event.enabled) return; at ~919, verified) plus restartable(), but the two other session-creation sites didn't. In _onBitcoinStarted, after payjoinEnabled is read, _receiveWithPayjoinUsecase.execute(...) does a directory round trip; if the user toggles payjoin off during that window, _onPayjoinSettingChanged(false) sees state.payjoin == null and has nothing to clear, then _onBitcoinStarted resumes and unconditionally arms _watchPayjoin and emits — so the QR advertises a pj= endpoint with a live watcher while the chip shows disabled. Same window in _onNewAddressGenerated. Apply the same re-check before _watchPayjoin/emit. Fix this →


🟡 Worth addressing (minor)

Payjoin core (payjoin_repository_impl.dart)

  • Fallback watcher keyed by BIP21 URI can cross-contaminate a retried send (:306-316, :1278): a sender's payjoin id is the URI and _watchForTransaction early-returns on containsKey, so re-scanning the same QR after a failed attempt leaves the old originalTxId armed; if that old original lands, _onOriginalTransactionSeen re-fetches by id and marks the new session aborted with the wrong original. Funds-bounded (both pay the same recipient) but contaminates the state machine.
  • Post-broadcast throw triggers a knowingly-conflicting original broadcast (:686-701, :1128-1148): in _broadcastPsbt, the fetchSender null-throw and update(completedModel) come after the successful network broadcast; a throw there lands in the proposal handler's catch, which broadcasts the original against the payjoin tx already relayed. The isCompleted-wins derivation only covers the persisted case.
  • Resume sweep retries a permanently-unbroadcastable original forever (:901-948): no attempt bound or age cutoff; logs SEVERE on every app start.
  • Receiver poll loses in-memory PDK progress on a late throw (pdk_payjoin_datasource.dart:719, 742-767): the retry assumes the directory re-serves an already-delivered payload; if not, the session strands until expiry.

Receive/send

  • Disabling payjoin only cancels the bloc's subscription and nulls state (receive_bloc.dart:930-932) — the repository exposes no session-cancel API, so the persisted receiver keeps polling until expiry and a sender who scanned the earlier QR can still complete a payjoin after the user turned it off. Worth a follow-up issue if not this PR.
  • A session expiring with no sender engagement (originalTxBytes == null) still counts as isPayjoinFlowOwningNavigation (receive_state.dart:254-257), so a user quietly displaying a QR gets yanked to a "payjoin expired" screen. With user-configurable payjoinExpireAfterSec this is realistically reachable.
  • _onPayjoinToggled failure is log-only (receive_bloc.dart:874-887) — a failed settings write means the chip tap does nothing visible.
  • Latent Countdown dispose crash newly reachable: late Timer? timer is never assigned when until is already past (lib/core/widgets/timers/countdown.dart:24-36), so dispose() throws LateInitializationError; both new call sites (send_screen.dart:1753, payjoin-in-progress screen) can plausibly mount with a past deadline.

Transactions

  • initByPayjoinId/initByWalletTxId stream listeners lack the isClosed guard the other three new listeners got (transaction_details_cubit.dart:90, 325) — emit-after-close becomes an unhandled async error.
  • Concurrent _loadDetailsByPayjoinId runs aren't serialized — an older stalled run can clobber a newer full-tx view (self-healing on next sync); and a resolved payjoin whose tx isn't local can trigger up to three full wallet syncs per event.
  • _broadcastTxIdForPayjoin swallows all exceptions unlogged (:450-452).

Settings / migration

  • The debounced saves on the payjoin settings screen are cancelled, not flushed, on dispose (payjoin_settings_screen.dart:63-70) — a value typed <500 ms before leaving the screen is shown but never persisted.
  • The five addColumn duplicate-column guards in schema_13_to_14.dart:36-72 swallow silently, while the createTable guard logs — inconsistent observability of the same event class.

Localization / FEATURES.md

  • All new payjoin, announcement, and status strings exist only in en (and partially fr/de); the other ~24 locales fall back to English. Fine if translations arrive via a later pipeline — flagging in case they don't.
  • FEATURES.md graph: ANNOUNCEMENTS --> TX_HISTORY doesn't match the code (the signal comes from core/wallet, not features/transactions), and the new WALLETS --> ANNOUNCEMENTS edge is missing (wallet_home_screen.dart:3 imports the carousel).

🟢 Verified solid (checked hard, no issues)

  • Funds safety: every double-broadcast window I could construct resolves to conflicting txs that each pay the intended recipient; the completed-wins status derivation covers the persisted race; the manual-broadcast double-spend guard is genuinely closed and tested.
  • Resume sweep vs live watchers: no double-processing — disjoint predicates, containsKey-idempotent watchers, per-session in-flight sets, terminal-state re-checks under the Lock.
  • v13→v14 migration: column defaults, backfill, fresh-install seed path, and table/steps/schema-json parity all line up, and schema_v13_to_v14_test.dart inserts real v13 rows and asserts backfilled values — unusually well done.
  • Stream hygiene: all new subscriptions cancelled in close()/dispose(), isClosed guards on bloc/cubit callbacks (except the two noted), broadcast controllers closed.
  • Privacy: sender payjoin ids (full BIP21 URIs) hashed for logs; success navigation switched to txid so the URI stays out of router locations.
  • Settings bounds enforced in domain usecases, not just UI; no raw colors (new onSuccess token in both palettes); no hardcoded user-facing strings.
  • Tests are substantive, not decorative: 2k-line repository test covering resume sweeps, watcher families, expiry semantics; send/receive bloc tests driving real terminal-state flows; adapter tests on an in-memory drift DB. Gaps: nothing exercises a stream-handler throw (issue 1), the toggle race (issue 3), or same-URI resend reuse.

Summary

The payjoin session-resolution engine is the strongest part of this PR — nearly every race has an explicit re-fetch guard, a documented rationale citing an observed incident, and a test. The three fix-before-merge items are: carry the stream-handler hardening to the two sibling handlers, correct the BIP-329 pubkey validation (it now hard-rejects valid imports), and extend the toggle-race guard to the other two session-creation sites. Everything else is polish-level or a reasonable follow-up issue.

Review method: full diff read across five areas by parallel review agents, with the top findings re-verified directly against the code before posting.

@i5hi

i5hi commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

@claude please add a follow up review

@claude

claude Bot commented Jul 24, 2026

Copy link
Copy Markdown

Claude finished @i5hi's task in 4m 45s —— View job


Follow-up review — verifying the fixes since the last review

Verdict: all three fix-before-merge findings from the previous review (above in this thread) are properly fixed, each with a regression test. I re-read every fix commit in full context against the live code, checked for regressions and new edge cases the fixes might introduce, and found nothing blocking. CI for the fix commits was still running at review time (run 30092274219) — worth a glance before merge, but the changes are small and mechanical.

Todo

  • Identify commits pushed since the previous review
  • Verify fix for issue 1 (stream-handler hardening in payjoin_repository_impl.dart)
  • Verify fix for issue 2 (BIP-329 publicKey label validation)
  • Verify fix for issue 3 (toggle-off race in receive_bloc.dart)
  • Review the announcement carousel dots-strip fix (d44aa1b42)
  • Re-check outstanding minor items from the previous review
  • Post follow-up review

✅ Issue 1 — stream-handler hardening (0b7ee5b0f) — fixed correctly

_processPayjoinProposal and _processExpiredPayjoin are now thin try/catch wrappers delegating to ..Inner methods (payjoin_repository_impl.dart:634-651, :760-774), matching the pattern already used in _processPayjoinRequest. This covers everything the previous review flagged: the pre-try fetchSender/update awaits, the proposal handler's own catch path (whose original-broadcast fallback can itself throw — that now lands in the outer catch instead of the zone), and the entirely-unprotected expiry handler. The new tests throw from fetchSender inside each handler and additionally verify the repository still processes a subsequent event after the proposal-handler throw. Wrapping at the outermost level rather than patching individual awaits is the right call — no future edit inside the handler can reopen the hole.

✅ Issue 2 — BIP-329 publicKey validation (fa24ffb9e) — fixed correctly

LabelType.publicKey now gets _validatePublicKey (64/66/130 hex chars, hex-decodability via the already-imported convert package) instead of the txid:vout split (label_entity.dart:29-34, :87-104). The bonus fix landed too: _validateOutPoint throws LabelValidationException on a missing : separator instead of the RangeError from indexing split() output. The wrong-shape test was replaced with four correct ones (compressed key accepted, x-only accepted, txid:vout rejected for publicKey, separator-less input rejected with the typed exception). Downstream this un-breaks ImportLabelsUsecase for files with pubkey records and stops _mapRowsTolerantly from dropping valid pubkey rows.

One optional tightening: the validator accepts any hex of a plausible key length — it doesn't check the prefix byte (02/03 for 66-char compressed, 04 for 130-char uncompressed). For a label reference this leniency is defensible (BIP-329 tolerance over strictness), so take it or leave it.

✅ Issue 3 — toggle-off vs in-flight session creation (7c1d1f729) — fixed correctly

Both remaining session-creation sites now re-check the live state after the directory round trip: receive_bloc.dart:286 (_onBitcoinStarted) and :813 (_onNewAddressGenerated) compare state.payjoinGloballyEnabled (which _onPayjoinSettingChanged updates synchronously at :907 before any await) against the value captured pre-await, and skip both _watchPayjoin and surfacing the session when they disagree. I verified the interleaving actually works in bloc semantics — the two handlers are registered on different event types, so the setting-changed handler genuinely runs during _onBitcoinStarted's await — and the new test drives exactly that schedule with a Completer, asserting no payjoin surfaces and verifyNever on the watcher.

Two small residuals, neither blocking:

  • Toggle off→on during the flight creates a session leak, not a state bug: the re-check passes (true == true), _onPayjoinSettingChanged(true) also creates a session, and _watchPayjoin cancels the prior subscription (receive_bloc.dart:1049) so the bloc tracks exactly one — but the loser session stays persisted and polls until expiry. This is the same class as the already-flagged "disable doesn't cancel the persisted session" follow-up; a repository-level session-cancel API would resolve both.
  • The regression test covers _onBitcoinStarted only; _onNewAddressGenerated got the identical guard but no test. Fine given the code is a mirror image.

✅ Carousel dots-strip fix (d44aa1b42) — good

Reserving the 22px dots strip only when showDots is true (and folding it into the scaled height budget instead of a magic 112) is a clean improvement — the single-announcement card no longer carries dead space. Nit only: the height budget scales the strip by textScale while the actual bottom padding stays a fixed 22, so at text scales below 1.0 the card content loses ~2px vs. its scaled base height. Almost certainly absorbed by layout slack; not worth a change unless an overflow shows up in testing.

🟡 Previously-flagged minor items — status unchanged

The four commits only address the three blockers plus the carousel; the minor list from the previous review remains open. None block merge; the ones I'd prioritize as follow-up issues:

  1. Persisted receiver session outlives a payjoin disable (no session-cancel API) — now also the residual of the toggle race above, so it covers two findings at once. Fix this →
  2. Fallback watcher keyed by BIP21 URI can cross-contaminate a retried send (payjoin_repository_impl.dart _watchForTransaction early-return on containsKey).
  3. Session expiring with no sender engagement still yanks navigation to the expired screen (receive_state.dart isPayjoinFlowOwningNavigation).
  4. Countdown's late Timer? dispose crash when mounted with a past deadline; the isClosed guards missing from initByPayjoinId/initByWalletTxId listeners; the debounced payjoin-settings saves cancelled (not flushed) on dispose; translations beyond en/fr/de; the two FEATURES.md graph edges.

Summary

The fixes are exactly what the previous review asked for — no shortcuts, correct placement (outermost wrapping for the handlers, live-state re-check for the race), and each with a test that drives the actual failure schedule rather than a proxy. From my side this PR is mergeable once CI on the fix commits completes; the remaining minors are follow-up-issue material.

Note: I could not run the Flutter test suite locally in this environment (SDK checks require additional tool permissions); verification was by code reading, and CI run 30092274219 will confirm the suite.


@i5hi
i5hi merged commit 6caa9f1 into develop Jul 24, 2026
2 checks passed
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.

3 participants