feat(payjoin): upgrade to official payjoin pub.dev bindings, drop isolate architecture - #2443
Conversation
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.
This comment was marked as resolved.
This comment was marked as resolved.
|
Following the review feedback, this branch now includes a hardening pass on top of the original upgrade. All fixes were verified end-to-end with real payjoin roundtrips on testnet Review fixes
Tests
|
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as resolved.
This comment was marked as resolved.
|
https://pub.dev/packages/payjoin/versions/0.1.2 is now published! Importantly, it uses the latest version of uniffi-dart, which uses uniffi 0.31.2 ensuring reproducible builds. |
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.
|
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as resolved.
This comment was marked as resolved.
_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.
ethicnology
left a comment
There was a problem hiding this comment.
@i5hi payjoin-upgrade is good to merge. Several non-blocking hardening items can be addressed in a follow-up branch, kept out of here to keep the scope tight:
- Shorter session lifetime (anti-probing). Expiry drops 24h → 5min: a payjoin is a synchronous exchange, so a long window only lengthens the period a probing sender can hold an exposed UTXO and cheaply replace the original tx (BIP78).
- Exposed-UTXO reuse (anti-probing). Once a proposal reveals a receiver UTXO, a later attempt should re-contribute that same already-burned coin rather than expose a fresh one. Follow-up labels contributed UTXOs (
_labelExposedUtxos) and prefers them in selection (filterAvailableUtxos), closing a probing vector. - Don't race our own in-flight payjoin on expiry.
_processExpiredPayjoinbroadcasts the original only whenproposalPsbt == null; once a proposal has gone out the sender owns broadcasting the payjoin (same inputs), so broadcasting the original would just race it. - Corrupt persisted log → replay loop.
_decodeEventsresets silently to[], resurrecting the session empty and re-polling until expiry. CorruptPayjoinSessionExceptionthat retires the session.- Datasource lifecycle leak. No
dispose(), so a never-resolved session can leaveTimer.periodic+StreamControllers alive. Follow-up adds an idempotentdispose(), delegated from the repository. // Todo: listen for the broadcast of the transaction(_resumePayjoins). Follow-up implements watch-for-broadcast so the receiver completes/labels the session once the tx lands on-chain.- Transient relay blip. A momentary all-relays failure can irrevocably cancel a payjoin; follow-up wraps proposal posting in a bounded retry.
- Signer robustness.
createPsbtSignerthrowsFailedToSignPsbtExceptionwhen the wallet matches no proposal input (fails at the boundary, not downstream); CryptoProvider install retry is bounded with backoff. - User disclosure. Receive screen now warns that payjoin reveals one of your UTXOs to the sender.
|
heads up: one database breaking change in payjoin/rust-payjoin#1747 coming before the change. This freezes the persisted data so it shouldn't be a problem when we introduce planned updates. This PR doesn't need to change with code, just gotta update the dep. Your "Follow-up implements watch-for-broadcast so the receiver completes/labels the session once the tx lands on-chain." suggestion is something we're actively trying to accomodate in our own library with 1.1 here. I don't recommend building it on the current
|
Migrates the payjoin integration from our custom
payjoin_flutterfork (SatoshiPortal/payjoin-dart, git dependency) to the officialpayjoinpackage on pub.dev (payjoin.org, uniffi-dart bindings,^0.1.1), and rewritesPdkPayjoinDatasourcearound its new, synchronous typestate API.This upgrade was worked on over several months (PR #2041, opened 2026-04-22). Thanks to everyone who helped get it across the line:
What changed
payjoin_flutter(git ref) →payjoin: ^0.1.1(hosted, verified publisher). Removes the platform plugin wiring (ios/Podfile.lock,linux/flutter/generated_plugins.cmake) that came with the old Flutter plugin fork, and the now-unneededPConfig.initializeApp()call inmain.dart.Isolates spawned lazily and communicating overSendPort/ReceivePort. The new PDK exposes a synchronous typestate machine with an in-memory JSON session persister, so polling now runs as plainTimer.periodicin the main isolate (PdkPayjoinDatasource). Net effect: much less code, no isolate bring-up latency, no serialization boundary between isolate and main.BdkWalletDatasource: addedcreateIsMineChecker/createPsbtSigner, which preload a bdk wallet once and return plain synchronous closures, matching the new PDK callback signatures (bool Function(Uint8List),String Function(String)instead ofFutureOr<...> Function). Also hardens Electrum client creation against arustlsCryptoProviderinstall race that could otherwise fail one of two concurrently-syncing wallets.integration_test/payjoin_test.dart, which had been fully commented out ondevelop. Rewrote it against the new API and added draining of leftover in-flight payjoin state insetUpAllto stop previous (possibly crashed) runs from starving the test wallets.Known follow-ups (not addressed in this PR)
processReceiveSessionand its_check*/_commit*/_finalize*chain) has no unit test coverage — only the integration test, which isn't run in CI and needs funded testnet wallets. Worth a follow-up with fakes around the repository/datasource seam.integration_test/payjoin_test.dartstill has three empty stub tests (resume-after-restart, insufficient-funds paths) and a commented-out multi-payjoin test (TODO: Fix this test) — pre-existing gaps, now visible again now that the file is no longer fully disabled.