Skip to content

feat(payjoin): upgrade, settings, anti-probing hardening, and status/reliability fixes - #2461

Closed
ethicnology wants to merge 86 commits into
developfrom
payjoin-hardening
Closed

feat(payjoin): upgrade, settings, anti-probing hardening, and status/reliability fixes#2461
ethicnology wants to merge 86 commits into
developfrom
payjoin-hardening

Conversation

@ethicnology

Copy link
Copy Markdown
Member

Closes:

Non-exhaustive summary:

  • Upgrade payjoin #2041 thanks to @spacebear21 @DanGould and @benalleng
  • Migrated off the unmaintained payjoin_flutter fork onto the official payjoin package (rust-payjoin via uniffi), currently pinned to 1.0.0-rc.4 bindings.
  • Configurable minimum receive amount (default 10,000 sat) below which a payjoin is declined and the payment broadcasts normally — raises the cost of BIP78 UTXO probing.
  • Configurable session expiry defaulted to 1 minute.
  • Enable/disable payjoin globally.
  • Show to the user url of ohttp relays and payjoin directory (still in payjoin settings page).
  • PayjoinStatus.aborted names the outcome, not the mechanism: the payjoin was aborted and the payment landed via a plain broadcast of the original transaction instead.
  • Fixed the root cause of several stale-status symptoms: the local datasource never mapped isCompleted/isExpired back from the database row, so every fetch silently returned a "never completed" session no matter what had actually been persisted — surfacing as stuck "requested"/"proposed" statuses long after a payjoin had actually completed or aborted.
  • Transaction details now derives the displayed payjoin status from the broadcast transaction itself (not the possibly-lagging session row), and resolves straight to the transaction view instead of a payjoin-session placeholder that swaps out moments later.
  • Session watchers now settle once a session resolves instead of continuing to poll: no more redundant re-broadcasting of the original transaction on a stale expiry, and our own broadcasts stay watched (via direct, sync-coordinator-bypassing lookups) until visible in the local wallet, instead of depending on one best-effort sync that can be throttled or lost to a race — no more manual resyncs needed to see a just-completed payjoin.
  • Explains the BIP78 fee contribution on the receive side of a completed payjoin (the mining fee for the receiver's contributed input, deducted from the amount received) so the net amount doesn't look like a bug.
  • System label ("Payjoin") for transactions that actually completed via a real payjoin — never applied to a plain-broadcast fallback, which isn't one.
  • Actually enforces "prefer re-contributing an already-exposed UTXO over a fresh one": the coin-selection call now tries the already-exposed candidates first, and only falls back to the full candidate set if none of them work for this specific proposal — the underlying privacy heuristic (UIH2 avoidance) always keeps the final say, we only control which set it gets to choose from. A separate, temporary "Payjoin exposed" system label tracks UTXOs currently committed to an in-flight or failed proposal, distinct from the "Payjoin" label above; it's removed once the transaction actually completes, since the coin is spent and the transaction itself now carries the definitive tag.
  • Preserves the user's own typed label on a completed payjoin send.

spacebear21 and others added 30 commits May 11, 2026 15:10
Switch from payjoin_flutter to dart payjoin bindings, which are actively
maintained and support the latest rust-payjoin versions.
These pre-load the wallet and return a synchronous callback compatible
with the synchronous payjoin interface, for isMine and signPsbtSync.
These session persisters hold payjoin events in memory as a transitive
step, so that DB migrations and complete event persistence may be
implemented in a follow-up step.
Implements a chaining pattern with processReceiveSession to process and
advance a session from any state to its terminal state.

BBM needs the proposal PSBT to save to its model, so it needs to be
extracted before transitioning to the Monitor typestate to be returned
alongside the session.
This should be droppable once isolates architecture is replaced
Move receiver/sender polling onto the main isolate, keyed by session
idin two Timer.periodic maps.

The old isolate indirection existed because frb async FFI could block
the UI isolate. With sync uniffi, `Timer.periodic` on the main isolate
works and removes ~150 lines of accidental complexity.

Co-Authored-By: Dan Gould <d@ngould.dev>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolves conflicts against the current payjoin-upgrade (= develop) base:
- pubspec.yaml/.lock: drop payjoin_flutter (SatoshiPortal fork), adopt
  the published payjoin 0.1.1 package, the whole point of this upgrade.
- lib/main.dart: drop PConfig.initializeApp() (payjoin_flutter's FRB
  init, no longer needed); keep BullSdk.init()/BitBoxApi.initialize()
  from develop's bull_sdk consolidation, dropping the stale
  LibLwk/BoltzCore/LibBbqr/LibArk/BitBoxFlutterApi calls the PR branch
  still had from before that consolidation.
- lib/core/wallet/data/datasources/bdk_wallet_datasource.dart: combine
  both independent fixes — the PR's rustls CryptoProvider install-race
  retry when building the ElectrumClient, and develop's try/catch
  logging around fullScan.
- ios/Podfile.lock, linux/flutter/generated_plugins.cmake: drop the
  payjoin_flutter plugin entries; the new payjoin package needs none
  (native code via Rust native assets, not a CocoaPods/plugin
  registration).
- integration_test/payjoin_test.dart: take the PR's active test body
  (develop's was fully commented out in d721ce9) and update it to
  the current API surface: PrepareBitcoinSendUsecase moved to
  core/wallet/domain/usecases and lost ignoreUnspendableInputs
  (unspendable filtering is now automatic); NetworkFee.relative no
  longer exists, use NetworkFee.relativeFromSatPerVbyte; mnemonics
  are read from Platform.environment at runtime (matching the CI
  step), not String.fromEnvironment/--dart-define.

fvm flutter analyze, dart fix --dry-run, dart format --set-exit-if-changed
and fvm flutter test test/ (523 tests) are all green after this merge.
The 0.1.2 bindings use the latest uniffi-dart (uniffi 0.31.2), which
restores reproducible native builds.
- _buildInputPair: throw on a missing input value instead of silently
  defaulting to zero, which would sign over the wrong segwit-committed
  amount and produce an invalid signature surfacing far away.
- _decodeEvents: eagerly build the event list inside the try/catch so a
  persisted list with non-string entries is caught as a corrupt log
  instead of slipping through .cast()'s lazy view and throwing later on
  every poll tick.
- _resumePayjoins: emit the updated (expired) model on the stream, not
  the stale one, so listeners see the expired status.
- createPsbtSigner: drop the 'not finalized' log — the receiver only
  signs its own contribution to a multi-party proposal, so a
  non-finalized PSBT is expected here, not an error.
- Surface terminal all-relays-failed errors via logger.log.warning so
  they reach production logs, not just dart:developer.
- Remove the unused OhttpRelaysUnavailableException and drop the stray
  'required' field from PayjoinInputPairModel's freezed factory.
- Stop logging the full proposal PSBT at info level.
Adds a list-of-non-strings case to the session persister decode tests,
and covers the postBytes relay choke point: a Dio receive-timeout
propagates unwrapped (so the relay loops can catch it and fall back to
the next relay) and a success returns the response bytes.
The multi-payjoin group's Timeout used Duration(minutes: ...) where the
interval math is expressed in seconds — 30 minutes instead of the
intended 30 seconds. Pre-existing typo, fixed while here.
_resumePayjoins handled a session that expired while the app was closed by
only updating the DB and emitting — it never broadcast the receiver's stored
original transaction, unlike the live-expiry path (_processExpiredPayjoin).
A receiver that had the sender's original tx but was killed before a proposal
completed would, on next launch, silently drop it: neither the payjoin nor
the fallback ever hit the chain, stranding the sender's payment.

Delegate to _processExpiredPayjoin so restart-time expiry runs the same
original-transaction fallback.
The Dio for OHTTP relay polling set connect and receive timeouts but left
the request-body upload phase unbounded. The per-session in-flight guard
turns any unbounded await into a permanent stall: if a relay stalls mid-send,
postBytes never completes, the poll's finally never runs, the session id is
never cleared from the in-flight set, and every later tick is skipped —
polling for that session silently stops until app restart.

Add sendTimeout (10s; OHTTP bodies are small) so all three phases are
bounded, restoring the locator's 'a slow relay can't hold a session in
flight' guarantee.
Tag a payjoin transaction with the payjoin system label once it completes,
so it is recognisable as a payjoin in the transaction list. Labelling is
wired at the two completion points in the repository:

- _broadcastPsbt (sender success): labels the finalized payjoin tx (txId).
- tryBroadcastOriginalTransaction (fallback): the negotiation didn't
  complete and the original tx landed on-chain, so it labels originalTxId
  (txId is the payjoin proposal tx and is typically null on this path).

Best-effort and idempotent: a labelling failure is logged and swallowed so
it never fails the already-broadcast payjoin, and the labels store dedupes
on (label, reference). The labels facade is injected as a lazy closure
because the payjoin repository is an eager singleton built before the facade
is registered.
…button

Matches the enable/disable toggle's immediate-persistence pattern: each
field now saves on blur or keyboard submit, validated and persisted
independently (they are two unrelated settings values, so an invalid
edit in one must never block the other). The Save button and the Form
wrapper it needed are gone.
Superseded by the global payjoin setting, which already hides this
switch entirely when disabled — the per-address opt-out added no value
on top of it. The UTXO-reveal disclosure it showed conditionally is
already covered unconditionally by the settings screen's explanation.

Removes the now-unreachable plumbing this left behind:
ReceiveEvent.receiveAddressOnlyToggled, its handler, and
ReceiveState.isAddressOnly, folding canPayjoin/isPayjoinLoading down to
drop the dead condition.
…made it, and distinguish it from a real payjoin in status

Both sides hold their own copy of the original transaction and can each
independently decide to broadcast it (a receiver declining below the
anti-probing minimum, either session's own expiry with no proposal
exchanged, or a sender's negotiation failing). Whichever side actually
broadcasts it persists completion itself, but the OTHER side previously
had no way to find out — it just kept waiting on its own session with
no signal that the payment had already landed via the counterparty's
fallback. Observed live: a receiver declining below-minimum broadcasts
immediately, while the sender's session sat on its prior status for up
to a full expiry window with nothing to show for it — and if that
second, now-redundant broadcast attempt then errored (already known to
the network), the session never completed at all, since the failure was
silently swallowed with nothing left watching for the transaction to
land through any other path.

Adds _watchForFallback, a passive+active watch for the ORIGINAL
transaction (mirroring the existing _watchForBroadcast for the real
payjoin one), armed as soon as originalTxId is known — session creation
for a sender, request-received for a receiver — and resumed across app
restarts. Whichever of the two watches fires first resolves the
session. Also stops calling _stopWatching before attempting a fallback
broadcast in _processExpiredPayjoin: it must survive a failed attempt
so the watch stays alive to catch the transaction landing through any
other path.

Introduces PayjoinStatus.fallback as its own status, distinct from
completed: both mean the session is done (isCompleted covers both), but
only completed means a real payjoin happened. Threads through every
consumer that read the old completed+no-txid combination or the
isRealPayjoinCompletion heuristic: transaction details (status label and
table row), the CSV export, and the receive in-progress screen.
The status names the outcome, not the mechanism: the payjoin was aborted and the payment fell back to a plain broadcast of the original transaction. User-facing wording follows (Payjoin aborted / Aborted, fr: Payjoin abandonné / Abandonné), the l10n keys are renamed to match, and the CSV export status value becomes 'aborted'.
The session row's persisted status can lag reality: completion detection runs on background polls, so right after a payment lands the row may still say requested/proposed while the broadcast transaction is already visible in the wallet. Transaction.displayPayjoinStatus makes the on-chain txid authoritative — the payjoin txid means completed, the original txid means aborted — and both the details heading and the payjoin status row now render it, localized for every case instead of leaking the raw enum name.
fromReceiverTable/fromSenderTable never mapped isCompleted/isExpired back from the row, so every by-id fetch silently fell back to their @default(false) and returned a never-completed session no matter what was persisted. Every terminal status derives from these flags, so this single gap surfaced everywhere: stale requested/proposed statuses on transaction details after a completed or aborted payjoin, expiry handlers re-broadcasting the original for already-resolved sessions, and completion handlers re-resolving sessions that had already resolved (all observed live).
A session resolving through the fallback left its directory poll running until expiry, which then re-broadcast the original transaction off a stale in-memory copy (observed live: a redundant second broadcast a minute after resolution). _stopWatching now also cancels the PDK poll, and _processExpiredPayjoin re-fetches the persisted row and bails when it already resolved, deciding and persisting on the fresh row instead of the event's copy.

In the other direction, our own broadcasts now stay watched until visible: the single post-broadcast sync can be throttled by the sync coordinator or race the broadcast (observed live: a receiver's below-minimum fallback stayed invisible until manual resyncs), so tryBroadcastOriginalTransaction re-arms the original-tx watch and _broadcastPsbt watches the payjoin txid, both polls forcing direct electrum-backed lookups and tearing themselves down once the transaction lands. Arming the broadcast watch is now best-effort like its fallback sibling, so a failure to arm can never misreport an already-successful broadcast.
Opening details by payjoin id showed payjoin-session-only data until the next organic sync armed watchers fired — a stale placeholder even though the payment was already on-chain. The cubit now resolves the broadcast transaction immediately (payjoin txid, else the original on a fallback), and when a resolved session's broadcast isn't visible locally yet it awaits one forced lookup through the new GetWalletTransactionUsecase — the repository's per-transaction sync path bypasses the sync coordinator, so it can't be throttled away — landing the user straight on the transaction view. A still-invisible transaction falls back to the session view plus a targeted sync, and the armed watchers upgrade the screen when it arrives.
The receive side of a real payjoin gets less than the negotiated payment: per BIP78 the mining fee for the input the receiver contributed is deducted from its output (observed live: 1002 sats sent, 948 displayed), which looked like a wrong amount. Transaction.payjoinFeeContributionSat derives that gap and the details table shows it with an expandable explanation, only when it applies — completed real payjoin, receive side, positive gap.
… inputs

Offering the full candidate list to tryPreservingPrivacy in one shot let its UIH2-avoidance heuristic pick a fresh coin over one already exposed by a prior proposal, so the reuse preference only acted through its narrow sort fallback. Try the exposed subset first and fall back to the full list, under a dedicated payjoin_exposed system label (distinct from the completed-payjoin tag) that is applied on proposal send, kept on failed negotiations, and removed once a real payjoin completes.
@ethicnology ethicnology self-assigned this Jul 16, 2026
@claude

claude Bot commented Jul 16, 2026

Copy link
Copy Markdown

Claude finished @ethicnology's task in 11m 5s —— View job


Reviewing this PR for correctness bugs, security issues, and regressions…

Todo

  • Gather context (PR branch checkout; base diff unavailable — shallow clone, reviewing HEAD state)
  • Deep review of payjoin core (datasource, repository, entity, models, local datasource)
  • Review transaction details / status derivation
  • Review settings, storage migration, and settings UI
  • Review receive/send blocs, labels, wallet datasource (in progress)
  • Verify remaining findings and post full review

Confirmed findings so far include a significant one: PayjoinModel.fromReceiverTable/fromSenderTable never map isCompleted/isExpired back from the database row, so every DB read returns a "never completed, never expired" session — the exact stale-status root cause the PR description says was fixed. Full details in the final review.

@ethicnology

Copy link
Copy Markdown
Member Author

Hey @claude I expect your review/audit for this huge PR

@claude

claude Bot commented Jul 16, 2026

Copy link
Copy Markdown

Claude finished @ethicnology's task in 3m 41s —— View job


Review: payjoin upgrade, settings, anti-probing hardening & reliability fixes

Full audit done — 6 parallel deep-review passes over the diff vs origin/develop (payjoin datasource, repository, wallet/coin-selection/labels, send/receive/transactions presentation, settings/migration/DI, tests), with every reported finding re-verified against HEAD before inclusion.

Overall: this is a genuinely strong PR. The stale-status root cause (isCompleted/isExpired never mapped back from DB rows) is truly fixed and pinned by real-DB tests; the migration v13→v14 is correct and tested; the double-broadcast guards, expiry-vs-proposal races, and watcher teardown in the live (in-run) paths are carefully done and the in-code reasoning is excellent. The issues below are concentrated in three areas: the anti-probing premise, txid derivation, and resume-after-restart.


Major — security / protocol

1. The min-amount anti-probing gate is bypassable at zero cost. The receiver skips BIP78's can-broadcast check (assumeInteractiveReceiver, pdk_payjoin_datasource.dart:450, :795) and isBelowPayjoinMinimum (payjoin_repository_impl.dart:499-516) is evaluated against the amount in that unverified original tx. An attacker holding a live BIP21 URI can post an "original" PSBT paying an above-minimum amount from fabricated/already-spent inputs: it passes the gate, the receiver contributes and exposes a real UTXO + ownership proof, and the "declining costs the sender mining fees" deterrent never applies because the fallback broadcast of an unspendable original simply fails. The PR's headline hardening lever doesn't bind for a deliberate prober — worth at least validating sender inputs exist/are unspent (or documenting the residual risk).

2. Receiver's max fee rate is hardcoded to 10,000 sat/vb (receive_with_payjoin_usecase.dart:28, applied at pdk_payjoin_datasource.dart:146-148, :645-649). This is the max effective fee rate the receiver agrees to pay for its own contribution, deducted from the amount received. A malicious sender (possibly the miner) crafting an extreme-fee original can dock up to ~680,000 sats (68 vb × 10,000) from the receiver's payment, silently auto-accepted. The value predates this PR, but the whole fee-range plumbing was rewritten here without adding a sane cap — tie it to current network fee estimates or a small multiple thereof.

3. User-frozen coins can be contributed to a payjoin — and the new exposed-first preference can make them the first choice. Receiver input candidates exclude only payjoin-locked coins, never user-frozen ones (payjoin_repository_impl.dart:1208-1214 + filterAvailableUtxos:1285-1306; preferred pass at pdk_payjoin_datasource.dart:602-621). Scenario: a coin exposed by a failed proposal gets frozen by the user (e.g. identified as dust/taint); the next incoming payjoin contributes exactly that coin — violating the D7 "frozen coins must never be spendable" invariant documented in prepare_bitcoin_send_usecase.dart:37-42. Base omission is pre-existing; preferring the frozen coin is new here.

4. txId is derived from the pre-finalization proposal PSBT (pdk_payjoin_datasource.dart:331 receiver, :884 sender). For any non-native-segwit sender input (BIP44/BIP49 — and the sender is an uncontrolled external party on the receive side), finalization fills scriptSig and changes the txid. This PR newly binds terminal session status to that txid: the receiver's _watchForBroadcast polls a txid that will never appear, the session terminally lands "expired" despite the payjoin succeeding, the expiry fallback then broadcasts the conflicting original, and the "Payjoin" system label + user label attach to a nonexistent txid. Two independent review passes converged on this one. Consider extracting the txid only after finalization (sender side) and, on the receiver side, watching for any tx spending the contributed outpoint instead of a fixed txid.

Major — resume after restart (payjoin_repository_impl.dart)

5. An expired-while-closed receiver whose proposal was already sent is permanently stranded. _resumeOne (:1121) routes any past-expiry session to _processExpiredPayjoin before the branch (:1140-1160) that re-arms _watchForBroadcast/_watchForFallback; the terminal else-branch (:700-715) persists isExpired, which excludes it from all future onlyUnfinished resumes. Its own comment ("stopping the watcher would strand the session…") only holds within a single app run — after restart there is no watcher, and none is armed. Sender broadcasts the payjoin 2 min after the user killed the app → session forever "expired", tx never labelled, exposed-UTXO labels never cleaned.

6. An expired-while-closed sender with a persisted proposal never resolves, and the manual-retry escape hatch is a double-broadcast footgun. Same routing: the sender fallback branch is guarded on proposalPsbt == null, so a sender that crashed between proposal receipt and broadcast falls to the terminal branch — no payjoin broadcast, no original fallback; the payment silently doesn't happen until the user finds "Send without payjoin". Worse: if the crash happened after the payjoin broadcast succeeded but before isCompleted persisted (:1398-1413), canManuallyBroadcastOriginal (payjoin.dart:115) returns true for the expired sender, so the manual button broadcasts the original in RBF conflict with the live payjoin tx — the exact race the guard's comment (:325-331) exists to prevent.

7. Fallible awaits sit outside the try/catch in stream handlers. _processPayjoinRequest awaits update() (:472) and _settingsRepository.fetch() (:499) before the try at :525 (same shape in _processPayjoinProposal:558). A throw there is swallowed as an unhandled async error inside listen(): no fallback, no decline, no terminal emission — and the receiver's directory poll was already cancelled, so no expiry event will fire either. Session stranded in "requested" until restart. Hoisting the try to the top of the handler fixes it.

Major — presentation state machines

8. Receive: a plain payment landing after payjoin expiry leaves the user stuck on the QR screen with zero feedback. isPayjoinFlowOwningNavigation (receive_state.dart:264-267) treats expired (no request ever arrived) as "flow owns navigation", but the owning screen is only entered on requested (receive_router.dart:68-73), and the payment-received listener defers to it (:82-90). With the new 60s default expiry plus canPayjoin (:297) having no status check (QR keeps advertising the dead pj= endpoint), "sender pays plain after receiver expiry" is the common path, not an edge — and it now regresses the pre-PR navigate-to-details behavior. Exclude expired from the getter. Fix this →

9. Send: the payjoin-expired retry path leaves poisoned state that silently breaks the next send. The expired branch (send_cubit.dart:2248-2257) returns the user to confirm but never clears state.txId (still the dead attempt's originalTxId) or payjoinSender. If the user then edits to a plain address and confirms, broadcastTransaction early-returns at :1958 (state.txId != null) — nothing broadcasts, and the tx watcher at :2078 is skipped too — the UI hangs on "sending" forever. Clear txId/payjoinSender (and ideally unsignedPsbt) when handing back to confirm. Fix this →

10. The user's typed label on a payjoin send is silently lost if they leave the send flow before completion. It's stored only inside the _watchPayjoin listener (send_cubit.dart:2225-2233) behind an isClosed guard (:2193), and the subscription is cancelled in close(). Since the repository outlives the cubit and already knows the session, persisting the user label alongside the session (like the system label) would survive navigation.

Minor

  • Privacy/logging (the PR is otherwise exemplary here): raw $payjoinId — the full BIP21 URI for senders — in WARNING logs at payjoin_repository_impl.dart:789 and :936; a raw wallet outpoint in the StateError from _buildInputPair (pdk_payjoin_datasource.dart:707-711) reaching SEVERE logs/Sentry via :531-536. Both are exactly what log_redaction.dart exists to prevent, and every neighboring line uses logRef correctly.
  • createSender posts the signed original PSBT to the directory before anything is persisted (pdk_payjoin_datasource.dart:217 vs :221-241): a throw in between means the receiver holds a broadcastable original while the user is told the send failed (and inputs aren't frozen — a retry can double-pay if coin selection differs). Persist-then-post, or post last.
  • Proposal persisted only after POST (payjoin_repository_impl.dart:1229-1242): a crash in the window causes re-negotiation with a possibly different exposed UTXO, or a conflicting original broadcast on an expired resume.
  • Post-broadcast bookkeeping failure inside _broadcastPsbt (:1408-1413) is caught as a broadcast failure at :582-595 and triggers a conflicting original-tx broadcast.
  • tryBroadcastOriginalTransaction's guard (:340-354) is TOCTOU — it doesn't take _lock, so a manual tap can race an in-flight _proposePayjoin.
  • Below-minimum decline whose fallback broadcast fails emits nothing (:512-515) — the receive UI gets no terminal signal from this device within the run (self-heals via the sender's expiry or restart).
  • BIP77 error replies are never posted to the sender (pdk_payjoin_datasource.dart:427-428): a rejected sender long-polls the full session lifetime instead of learning immediately.
  • Forced watcher polls do full descriptor scans — up to ~24 fullScans per payjoin; combined with the pre-existing delete-on-any-load-error in bdk_facade.dart:69-82/166-179, the added concurrent DB access widens a window where a transient sqlite lock error wipes the wallet's BDK chain state. Worth an incremental sync and a narrower catch in the facade.
  • Settings screen: toggling payjoin off→on resets a custom min-amount to 10,000 while the text field still shows the old value (payjoin_settings_screen.dart:38-43, settings_cubit.dart:106-111) — the displayed threshold diverges from the enforced one; and backing out while a field is focused silently discards a valid edit (:47-54). The domain usecases also don't enforce the documented bounds (UI validators are the only clamp).
  • Scope creep: this PR deletes the Liquid risk disclosure link + DisclosureBottomSheet app-wide (no remaining surface). If that disclosure was a product/legal requirement, it shouldn't ride along in a payjoin PR.
  • PR description nit: pubspec.yaml ships payjoin: ^0.1.2 from pub.dev (sha256-locked at 0.1.2), not "pinned to 1.0.0-rc.4" — presumably 0.1.2 wraps the rc.4 Rust bindings, but the caret range means CI can silently float to 0.1.3.

Tests

  • The below-minimum decline test (payjoin_repository_impl_test.dart:2023-2072) cannot fail: settings.fetch() is called once on both the decline and the catch-and-fallback paths, so an inverted/removed threshold check produces identical observable outcomes. Add verifyNever(pdk.proposePayjoin) to pin the wiring.
  • New order-dependence in integration_test/payjoin_test.dart: the round-trip test now consolidates each wallet to one UTXO, then the later 'should have wallets with enough utxos' test asserts ≥2 — near-deterministically red after a green first test. Also three revived always-green empty test bodies (:312-316), and the multi-payjoin group timeout dropped to 30s while still doing a real testnet sync.
  • Coverage gaps on exactly the riskiest logic: the receiver expiry-fallback branch (payjoin_repository_impl.dart:638-676) is never driven (all expiry tests feed sender models); the exposed-UTXO label write/cleanup and getUtxosFrozenByOngoingPayjoins silently no-op in unit tests because BitcoinTx.fromPsbt is FFI-backed and throws on the fake fixtures — the guarantee commit b0fc6c6 claims lives exactly there, untested.
  • The sender expiry-fallback-failure test should also verifyNever(local.update) — the deliberate don't-persist-on-failed-fallback retry behavior isn't pinned.

What's solid (verified, no findings)

The isExpired/isCompleted round-trip fix (real-DB pinned); migration v13→v14 (correct backfill, idempotency guard, tested); defaults consistent across entity/table/seed/constants/migration; all localization keys present (EN+FR); DI registrations correct; live expiry-vs-proposal races and watcher teardown; the min-amount decline placement (receiver-side, before any contribution, sender still paid); displayPayjoinStatus derivation (fallbacks persist txId: null, so it can't mislabel); outpoint formats consistent end-to-end; exposed-first preference logic with UIH2 keeping the final say; the _validateTxid labels fix; log_redaction.dart's hash-token mechanism itself.

Verdict

The reliability rework is real and the live-path engineering is careful. Before merge I'd prioritize #4 (txid derivation — it silently breaks the new completion architecture for legacy-input senders), #5-7 (resume-after-restart, which undoes this PR's own stale-status fix for anyone who closes the app mid-session), and #8-9 (user-visible dead ends). #1-3 deserve issues of their own if not addressed here, since #1 undercuts the PR's stated anti-probing goal.

Review caveat: the runner's shallow clone had no merge base, so the diff was reconstructed as a two-dot diff against origin/develop restricted to the PR's files — insertion counts match the PR exactly, so coverage should be complete.


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.

payjoin improments Default payjoin settings Upgrade to Payjoin 1.0.0 Better Payjoin Status Allow user to view payjoin settings

2 participants