From a7a236a98f9ac6916291e2f38e713e41332d36c2 Mon Sep 17 00:00:00 2001 From: spacebear Date: Mon, 20 Apr 2026 16:53:36 -0400 Subject: [PATCH 01/28] Update payjoin dependency Switch from payjoin_flutter to dart payjoin bindings, which are actively maintained and support the latest rust-payjoin versions. --- ios/Podfile.lock | 62 +++++++++++++++++++++++++----------------------- pubspec.lock | 57 ++++++++++---------------------------------- pubspec.yaml | 5 +--- 3 files changed, 45 insertions(+), 79 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index d7d4d934a5..ac7e243cf0 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -99,7 +99,9 @@ PODS: - Flutter - package_info_plus (0.4.5): - Flutter - - payjoin_flutter (0.20.0) + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS - permission_handler_apple (9.3.0): - Flutter - PromisesObjC (2.4.0) @@ -175,7 +177,7 @@ DEPENDENCIES: - lwk (from `.symlinks/plugins/lwk/ios`) - no_screenshot (from `.symlinks/plugins/no_screenshot/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - - payjoin_flutter (from `.symlinks/plugins/payjoin_flutter/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) @@ -239,8 +241,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/no_screenshot/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" - payjoin_flutter: - :path: ".symlinks/plugins/payjoin_flutter/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" sentry_flutter: @@ -267,46 +269,46 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: AppAuth: d4f13a8fe0baf391b2108511793e4b479691fb73 AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f - ark_wallet: cb7a2af0c3a711d488709b7b91b6e639cfd441b1 + ark_wallet: f985745c06c7cf93f368c61218c51ac56b46c71e boltz: 6388ec2412f3753b63a9e65c97f87ea26f43bddc - camera_avfoundation: 5675ca25298b6f81fa0a325188e7df62cc217741 - dart_bbqr: bfd89cc8a74538d94ef6d87d11e4a2ad55578e7d + camera_avfoundation: 281867ff09f1da66f031a184ecfbc6f2e625c9f5 + dart_bbqr: 10143a83ef3919f9ac06974e3bbe23cac4abc39b DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 - file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be + file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf - flutter_nfc_kit: e1b71583eafd2c9650bc86844a7f2d185fb414f6 - flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 - flutter_secure_storage_legacy: 2b1517bd98433d760370884d3e2cc3ed8eeb2538 - flutter_zxing: e8bcc43bd3056c70c271b732ed94e7a16fd62f93 - google_sign_in_ios: b48bb9af78576358a168361173155596c845f0b9 + flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29 + flutter_nfc_kit: 3985c93f749b9cb4747479205c2f10bd2f877a11 + flutter_secure_storage_darwin: 557817588b80e60213cbecb573c45c76b788018d + flutter_secure_storage_legacy: a315f1c83d88e9490f44a269454281a2c9b2c160 + flutter_zxing: d527c3ff9c7f3606dd29a7ec8c4055f7daa088c5 + google_sign_in_ios: 7411fab6948df90490dc4620ecbcabdc3ca04017 GoogleSignIn: ce8c89bb9b37fb624b92e7514cc67335d1e277e4 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 - image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 - integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e + image_picker_ios: 4f2f91b01abdb52842a8e277617df877e40f905b + integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 lwk: 22e06bc5664247d6b2dac91cfe209b63b70dd580 - no_screenshot: 5e345998c43ffcad5d6834f249590483fcc037bd - package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 - payjoin_flutter: 6397d7b698cdad6453be4949ab6aca1863f6c5e5 - permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d + no_screenshot: e91f3e7a771bf761b087c575028bb1b24a7ca33d + package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 + path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba + permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf Sentry: 958d9619ceccf6abb8c4736003fa336dac1a80a7 - sentry_flutter: bdfd7a7b8931c4ecefa37fde9e44a15cd0af30b1 - share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a - shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + sentry_flutter: 9bde8d71f013f0e807c424017de923f0ee489e9a + share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f + shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 sqlite3: a51c07cf16e023d6c48abd5e5791a61a47354921 - sqlite3_flutter_libs: b3e120efe9a82017e5552a620f696589ed4f62ab + sqlite3_flutter_libs: f9114e4bbe1f2e03dd543373c53d23245982ca13 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 - tor: 767208930250ef7be241963b75568c55c0a81890 - universal_ble: 45519b2aeafe62761e2c6309f8927edb5288b914 - url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b - webview_cookie_manager: d63a76cabdf42a7ea3d92768ac67d4853a1367f8 - webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d - workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778 + tor: 662a9f5b980b5c86decb8ba611de9bcd4c8286eb + universal_ble: 65e1257dffc557cc7991a93d253beeddc7c1dc92 + url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa + webview_cookie_manager: eaf920722b493bd0f7611b5484771ca53fed03f7 + webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4 + workmanager_apple: 7bac258335c310689a641e2d66e88d4845d372e9 PODFILE CHECKSUM: b9aa080c2a42d4d61d216015adddd3850778d844 diff --git a/pubspec.lock b/pubspec.lock index 47ce6de08a..05da7a16bd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -398,14 +398,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - code_builder: - dependency: transitive - description: - name: code_builder - sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" - url: "https://pub.dev" - source: hosted - version: "4.11.1" collection: dependency: transitive description: @@ -932,10 +924,10 @@ packages: dependency: "direct main" description: name: go_router - sha256: "08b742eef4f71c9df5af543751cd0b7f1c679c4088488f4223ecaddc1a813b79" + sha256: "92d8cee7c57dff0a6c409c05597b460002434eccf7424a712283225b3962d03f" url: "https://pub.dev" source: hosted - version: "17.2.2" + version: "17.2.3" google_cloud: dependency: transitive description: @@ -1335,14 +1327,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" - mockito: - dependency: transitive - description: - name: mockito - sha256: eff30d002f0c8bf073b6f929df4483b543133fcafce056870163587b03f1d422 - url: "https://pub.dev" - source: hosted - version: "5.6.4" mocktail: dependency: "direct dev" description: @@ -1351,14 +1335,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" - native_toolchain_c: - dependency: transitive - description: - name: native_toolchain_c - sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" - url: "https://pub.dev" - source: hosted - version: "0.17.6" native_toolchain_rust: dependency: transitive description: @@ -1399,14 +1375,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" - objective_c: - dependency: transitive - description: - name: objective_c - sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" - url: "https://pub.dev" - source: hosted - version: "9.3.0" package_config: dependency: transitive description: @@ -1467,10 +1435,10 @@ packages: dependency: transitive description: name: path_provider_foundation - sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.5.1" path_provider_linux: dependency: transitive description: @@ -1495,15 +1463,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" - payjoin_flutter: + payjoin: dependency: "direct main" description: - path: "." - ref: e939cf5128b61d02aefca725f6de80cbb8818e09 - resolved-ref: e939cf5128b61d02aefca725f6de80cbb8818e09 - url: "https://github.com/SatoshiPortal/payjoin-dart" - source: git - version: "0.23.0" + name: payjoin + sha256: a2047b2f2ecb40543f1a8b0d5ea93cf377151ac72463df4096aa73a443cef42e + url: "https://pub.dev" + source: hosted + version: "0.1.1" permission_handler: dependency: "direct main" description: @@ -2347,5 +2314,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.3 <4.0.0" - flutter: ">=3.38.4" + dart: ">=3.10.0 <4.0.0" + flutter: ">=3.38.0" diff --git a/pubspec.yaml b/pubspec.yaml index 84b1afae2b..4aaf790110 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,10 +32,7 @@ dependencies: path: flutter_secure_storage path_provider: ^2.1.5 shared_preferences: ^2.3.0 - payjoin_flutter: - git: - url: https://github.com/SatoshiPortal/payjoin-dart - ref: e939cf5128b61d02aefca725f6de80cbb8818e09 + payjoin: ^0.1.1 qr_flutter: ^4.1.0 dio: ^5.9.0 timeago: ^3.7.1 From 8aa8c5207fca95d31afed2131a0ae3002a267547 Mon Sep 17 00:00:00 2001 From: spacebear Date: Mon, 20 Apr 2026 18:43:21 -0400 Subject: [PATCH 02/28] Add synchronous wallet workarounds These pre-load the wallet and return a synchronous callback compatible with the synchronous payjoin interface, for isMine and signPsbtSync. --- .../repository/payjoin_repository_impl.dart | 13 +++----- .../datasources/bdk_wallet_datasource.dart | 32 +++++++++++++++++++ 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/lib/core/payjoin/data/repository/payjoin_repository_impl.dart b/lib/core/payjoin/data/repository/payjoin_repository_impl.dart index 7d8c6ba8ca..ca4f240d79 100644 --- a/lib/core/payjoin/data/repository/payjoin_repository_impl.dart +++ b/lib/core/payjoin/data/repository/payjoin_repository_impl.dart @@ -18,7 +18,6 @@ import 'package:bb_mobile/core/settings/domain/settings_entity.dart'; import 'package:bb_mobile/core/utils/bitcoin_tx.dart'; import 'package:bb_mobile/core/utils/constants.dart' show PayjoinConstants; import 'package:bb_mobile/core/utils/logger.dart'; -import 'package:bb_mobile/core/wallet/data/datasources/bdk_facade.dart'; import 'package:bb_mobile/core/wallet/data/datasources/bdk_wallet_datasource.dart'; import 'package:bb_mobile/core/wallet/data/datasources/wallet_metadata_datasource.dart'; import 'package:bb_mobile/core/wallet/data/models/wallet_metadata_model.dart'; @@ -498,16 +497,14 @@ class PayjoinRepositoryImpl implements PayjoinRepository { ); if (freshModel == null) throw Exception('Payjoin receiver not found'); - final bdkWallet = await BdkFacade.createWallet(wallet); - + final isMineSync = await _bdkWallet.createIsMineChecker(wallet: wallet); + final signPsbtSync = await _bdkWallet.createPsbtSigner(wallet: wallet); final updatedModel = await _pdkPayjoinDatasource.proposePayjoin( receiverModel: freshModel, - hasOwnedInputs: (script) => - _bdkWallet.isMine(script, wallet: wallet, bdkWallet: bdkWallet), - hasReceiverOutput: (script) => - _bdkWallet.isMine(script, wallet: wallet, bdkWallet: bdkWallet), + hasOwnedInputs: isMineSync, + hasReceiverOutput: isMineSync, inputPairs: inputPairs, - processPsbt: (psbt) => _bdkWallet.signPsbt(psbt, wallet: wallet), + processPsbt: signPsbtSync, ); await _localPayjoinDatasource.update(updatedModel); diff --git a/lib/core/wallet/data/datasources/bdk_wallet_datasource.dart b/lib/core/wallet/data/datasources/bdk_wallet_datasource.dart index 2cbee9a13c..71e88347a1 100644 --- a/lib/core/wallet/data/datasources/bdk_wallet_datasource.dart +++ b/lib/core/wallet/data/datasources/bdk_wallet_datasource.dart @@ -141,6 +141,38 @@ class BdkWalletDatasource { return w.isMine(script: bdk.Script(rawOutputScript: scriptBytes)); } + /// Returns a synchronous `isMine` check bound to a pre-loaded bdk wallet. + Future createIsMineChecker({ + required WalletModel wallet, + }) async { + final bdkWallet = await BdkFacade.createWallet(wallet); + return (Uint8List scriptBytes) => + bdkWallet.isMine(script: bdk.Script(rawOutputScript: scriptBytes)); + } + + /// Returns a synchronous PSBT signer bound to a pre-loaded private bdk + /// wallet. + Future createPsbtSigner({ + required PrivateBdkWalletModel wallet, + }) async { + final bdkWallet = await BdkFacade.createPrivateWallet(wallet); + return (String psbtBase64) { + final psbt = bdk.Psbt(psbtBase64: psbtBase64); + bdkWallet.sign( + psbt: psbt, + signOptions: bdk.SignOptions( + trustWitnessUtxo: true, + assumeHeight: null, + allowAllSighashes: true, + tryFinalize: true, + signWithTapInternalKey: false, + allowGrinding: true, + ), + ); + return psbt.serialize(); + }; + } + Future isAddressMine( String address, { required WalletModel wallet, From ada5c16f75f54b213e3cdd5a76046751f3d0fc15 Mon Sep 17 00:00:00 2001 From: spacebear Date: Mon, 20 Apr 2026 19:00:13 -0400 Subject: [PATCH 03/28] Implement transitory in-memory session persisters 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. --- .../datasources/pdk_payjoin_datasource.dart | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart index bda194e43e..3391006738 100644 --- a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart +++ b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart @@ -817,3 +817,71 @@ class PayjoinExpiredException extends BullException { class OhttpRelaysUnavailableException extends BullException { OhttpRelaysUnavailableException(super.message); } + +class InMemoryJsonReceiverSessionPersister + implements JsonReceiverSessionPersister { + final List _events; + bool _closed; + + InMemoryJsonReceiverSessionPersister([List? initial]) + : _events = [...?initial], + _closed = false; + + factory InMemoryJsonReceiverSessionPersister.fromJson(String? raw) { + return InMemoryJsonReceiverSessionPersister(_decodeEvents(raw)); + } + + List get events => List.unmodifiable(_events); + + bool get isClosed => _closed; + + String toJson() => jsonEncode(_events); + + @override + void save(String event) => _events.add(event); + + @override + List load() => List.from(_events); + + @override + void close() => _closed = true; +} + +class InMemoryJsonSenderSessionPersister implements JsonSenderSessionPersister { + final List _events; + bool _closed; + + InMemoryJsonSenderSessionPersister([List? initial]) + : _events = [...?initial], + _closed = false; + + factory InMemoryJsonSenderSessionPersister.fromJson(String? raw) { + return InMemoryJsonSenderSessionPersister(_decodeEvents(raw)); + } + + List get events => List.unmodifiable(_events); + + bool get isClosed => _closed; + + String toJson() => jsonEncode(_events); + + @override + void save(String event) => _events.add(event); + + @override + List load() => List.from(_events); + + @override + void close() => _closed = true; +} + +List _decodeEvents(String? raw) { + if (raw == null || raw.isEmpty) return const []; + try { + final decoded = jsonDecode(raw); + if (decoded is List) { + return decoded.cast(); + } + } catch (_) {} + return const []; +} From 26432850cc39cff5281bb7975f7c0bd1ce86ef78 Mon Sep 17 00:00:00 2001 From: spacebear Date: Tue, 21 Apr 2026 20:26:55 -0400 Subject: [PATCH 04/28] fixup:delete inmemory persisters --- .../datasources/pdk_payjoin_datasource.dart | 47 ------------------- 1 file changed, 47 deletions(-) diff --git a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart index 3391006738..1d2f80e98f 100644 --- a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart +++ b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart @@ -751,53 +751,6 @@ class PdkPayjoinDatasource { } } -class InMemoryReceiverPersister { - final Map _store = {}; - - Future save({required Receiver receiver}) async { - final token = receiver.key(); - _store[token.toBytes().toString()] = receiver; - return token; - } - - Future load({required ReceiverToken token}) async { - logger.log.info('LOADING RECEIVER'); - final receiver = _store[token.toBytes().toString()]; - if (receiver == null) { - throw Exception('Receiver not found for the provided token.'); - } - return receiver; - } -} - -class InMemorySenderPersister implements DartSenderPersister { - final Map _store = {}; - - Future save({required Sender sender}) async { - final token = sender.key(); - logger.log.info('TOKEN SAVE}'); - _store[token.toBytes().toString()] = sender; - return token; - } - - Future load({required SenderToken token}) async { - logger.log.info('TOKEN LOAD}'); - final sender = _store[token.toBytes().toString()]; - if (sender == null) { - throw Exception('Sender not found for the provided token.'); - } - return sender; - } - - @override - void dispose() { - _store.clear(); - } - - @override - bool get isDisposed => _store.isEmpty; -} - class PayjoinNotFoundException extends BullException { PayjoinNotFoundException(super.message); } From 3acecf5b242674a1625e81f0569be872deaac51b Mon Sep 17 00:00:00 2001 From: spacebear Date: Tue, 21 Apr 2026 23:20:02 -0400 Subject: [PATCH 05/28] Add helpers --- .../datasources/pdk_payjoin_datasource.dart | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart index 1d2f80e98f..42a9df4b1e 100644 --- a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart +++ b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:developer'; import 'dart:isolate'; import 'dart:typed_data'; @@ -9,7 +10,9 @@ import 'package:bb_mobile/core/payjoin/data/models/payjoin_model.dart'; import 'package:bb_mobile/core/utils/bitcoin_tx.dart'; import 'package:bb_mobile/core/utils/constants.dart'; import 'package:bb_mobile/core/utils/logger.dart' as logger; +import 'package:crypto/crypto.dart'; import 'package:dio/dio.dart'; +import 'package:payjoin/payjoin.dart'; import 'package:payjoin_flutter/bitcoin_ffi.dart'; import 'package:payjoin_flutter/common.dart'; import 'package:payjoin_flutter/receive.dart'; @@ -749,6 +752,23 @@ class PdkPayjoinDatasource { return null; } } + + static Future _postBytes( + Dio dio, + String url, + Uint8List body, + String contentType, + ) async { + final response = await dio.post>( + url, + data: body, + options: Options( + headers: {'Content-Type': contentType}, + responseType: ResponseType.bytes, + ), + ); + return Uint8List.fromList(response.data ?? const []); + } } class PayjoinNotFoundException extends BullException { @@ -771,6 +791,32 @@ class OhttpRelaysUnavailableException extends BullException { OhttpRelaysUnavailableException(super.message); } +class SendCreationException extends BullException { + SendCreationException(super.message); +} + +class _IsScriptOwned implements IsScriptOwned { + final bool Function(Uint8List) _fn; + _IsScriptOwned(this._fn); + + @override + bool callback(Uint8List script) => _fn(script); +} + +/// Assume the wallet has not seen the inputs since it is an interactive wallet +class _AssumeUnseen implements IsOutputKnown { + @override + bool callback(OutPoint outpoint) => false; +} + +class _ProcessPsbt implements ProcessPsbt { + final String Function(String) _sign; + _ProcessPsbt(this._sign); + + @override + String callback(String psbt) => _sign(psbt); +} + class InMemoryJsonReceiverSessionPersister implements JsonReceiverSessionPersister { final List _events; From 3963035ee28332bc56a1ad5c8a12d7ab42c071d3 Mon Sep 17 00:00:00 2001 From: spacebear Date: Tue, 21 Apr 2026 16:58:49 -0400 Subject: [PATCH 06/28] Update fetchOhttpKeys --- .../datasources/pdk_payjoin_datasource.dart | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart index 42a9df4b1e..7efa170be5 100644 --- a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart +++ b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart @@ -13,7 +13,8 @@ import 'package:bb_mobile/core/utils/logger.dart' as logger; import 'package:crypto/crypto.dart'; import 'package:dio/dio.dart'; import 'package:payjoin/payjoin.dart'; -import 'package:payjoin_flutter/bitcoin_ffi.dart'; +import 'package:payjoin/http.dart' show fetchOhttpKeys; +import 'packapayjoin_flutter/bitcoin_ffi.dart'; import 'package:payjoin_flutter/common.dart'; import 'package:payjoin_flutter/receive.dart'; import 'package:payjoin_flutter/send.dart'; @@ -53,25 +54,21 @@ class PdkPayjoinDatasource { Stream get expiredPayjoins => _expiredController.stream; - Future<(OhttpKeys?, Url?)> fetchOhttpKeyAndRelay({ + Future<(OhttpKeys?, String?)> fetchOhttpKeyAndRelay({ required String payjoinDirectory, }) async { - Url? ohttpRelay; - OhttpKeys? ohttpKeys; for (final ohttpRelayUrl in PayjoinConstants.ohttpRelayUrls) { try { - final relay = await Url.fromStr(ohttpRelayUrl); - ohttpKeys = await fetchOhttpKeys( - ohttpRelay: ohttpRelayUrl, - payjoinDirectory: payjoinDirectory, + final ohttpKeys = await fetchOhttpKeys( + ohttpRelayUrl: ohttpRelayUrl, + directoryUrl: payjoinDirectory, ); - ohttpRelay = relay; - break; + return (ohttpKeys, ohttpRelayUrl); } catch (e) { continue; } } - return (ohttpKeys, ohttpRelay); + return (null, null); } Future createReceiver({ From 7e80a4854b46a4ad62312fa59eb81ce93112ae76 Mon Sep 17 00:00:00 2001 From: spacebear Date: Tue, 21 Apr 2026 17:05:24 -0400 Subject: [PATCH 07/28] Update createReceiver --- .../datasources/pdk_payjoin_datasource.dart | 46 ++++++++----------- 1 file changed, 18 insertions(+), 28 deletions(-) diff --git a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart index 7efa170be5..bd58e13085 100644 --- a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart +++ b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart @@ -87,42 +87,32 @@ class PdkPayjoinDatasource { throw Exception('All OHTTP relays failed'); } - final newReceiver = NewReceiver.create( - address: address, - network: isTestnet ? Network.testnet : Network.bitcoin, - directory: _payjoinDirectoryUrl, - ohttpKeys: ohttpKeys, - expireAfter: BigInt.from(expireAfterSec), - ); - - final imp = InMemoryReceiverPersister(); - final noOpPersister = DartReceiverPersister( - save: (receiver) async { - logger.log.info('SAVING RECEIVER'); - final token = await imp.save(receiver: receiver); - return token; - }, - load: (token) async { - final receiver = await imp.load(token: token); - return receiver; - }, - ); - final token = await newReceiver.persist(persister: noOpPersister); + var receiverBuilder = + ReceiverBuilder( + address: address, + directory: _payjoinDirectoryUrl, + ohttpKeys: ohttpKeys, + ) + .withExpiration(expirationSecs: expireAfterSec) + .withMaxFeeRate( + maxEffectiveFeeRateSatPerVb: maxFeeRateSatPerVb.toInt(), + ); - final receiver = await Receiver.load( - token: token, - persister: noOpPersister, - ); + final persister = InMemoryJsonReceiverSessionPersister(); + final initialized = receiverBuilder.build().save(persister: persister); + final pjUri = initialized.pjUri().asString(); + // Derive the receiver ID from pjUri + final id = sha256.convert(utf8.encode(pjUri)).toString().substring(0, 16); // Create and store the model to keep track of the payjoin session final model = PayjoinModel.receiver( - id: receiver.id(), + id: id, address: address, isTestnet: isTestnet, - receiver: receiver.toJson(), + receiver: persister.toJson(), walletId: walletId, - pjUri: (await receiver.pjUri()).asString(), + pjUri: pjUri, maxFeeRateSatPerVb: maxFeeRateSatPerVb, createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, expireAfterSec: expireAfterSec, From 62f9468774370a31be5519f6090bbc17ed192843 Mon Sep 17 00:00:00 2001 From: spacebear Date: Tue, 21 Apr 2026 17:08:40 -0400 Subject: [PATCH 08/28] Delete flutter stuff --- .../payjoin/data/datasources/pdk_payjoin_datasource.dart | 9 --------- lib/main.dart | 3 --- 2 files changed, 12 deletions(-) diff --git a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart index bd58e13085..61b2347785 100644 --- a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart +++ b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart @@ -14,11 +14,6 @@ import 'package:crypto/crypto.dart'; import 'package:dio/dio.dart'; import 'package:payjoin/payjoin.dart'; import 'package:payjoin/http.dart' show fetchOhttpKeys; -import 'packapayjoin_flutter/bitcoin_ffi.dart'; -import 'package:payjoin_flutter/common.dart'; -import 'package:payjoin_flutter/receive.dart'; -import 'package:payjoin_flutter/send.dart'; -import 'package:payjoin_flutter/uri.dart'; class PdkPayjoinDatasource { final String _payjoinDirectoryUrl; @@ -379,8 +374,6 @@ class PdkPayjoinDatasource { static Future _receiversIsolateEntryPoint(SendPort sendPort) async { log('[Receivers Isolate] Started _receiversIsolateEntryPoint'); - // Initialize core library in the isolate too for the native pdk library - await PConfig.initializeApp(); final receivePort = ReceivePort(); sendPort.send(receivePort.sendPort); @@ -470,8 +463,6 @@ class PdkPayjoinDatasource { static Future _sendersIsolateEntryPoint(SendPort sendPort) async { log('[Senders Isolate] Started _sendersIsolateEntryPoint'); - // Initialize core library in the isolate too for the native pdk library - await PConfig.initializeApp(); final receivePort = ReceivePort(); sendPort.send(receivePort.sendPort); diff --git a/lib/main.dart b/lib/main.dart index 4be88f50d3..1e541a0ba6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -36,8 +36,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lwk/lwk.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:payjoin_flutter/common.dart'; - import 'package:workmanager/workmanager.dart'; class Bull { @@ -70,7 +68,6 @@ class Bull { final initTasks = [ LibLwk.init(), BoltzCore.init(), - PConfig.initializeApp(), LibBbqr.init(), LibArk.init(), if (Platform.isAndroid) BitBoxFlutterApi.initialize(), From d840e1cd1af211ff36dd9741eeb4d869de61a701 Mon Sep 17 00:00:00 2001 From: spacebear Date: Tue, 21 Apr 2026 17:48:27 -0400 Subject: [PATCH 09/28] Update createSender --- .../datasources/pdk_payjoin_datasource.dart | 127 +++++++----------- 1 file changed, 52 insertions(+), 75 deletions(-) diff --git a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart index 61b2347785..2709afeadb 100644 --- a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart +++ b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart @@ -133,43 +133,37 @@ class PdkPayjoinDatasource { int? expireAfterSec, }) async { final expirySec = expireAfterSec ?? PayjoinConstants.defaultExpireAfterSec; - final uri = await Uri.fromStr(bip21); PjUri pjUri; + final Uri parsedUri; try { - pjUri = uri.checkPjSupported(); + parsedUri = Uri.parse(uri: bip21); + pjUri = parsedUri.checkPjSupported(); } catch (e) { throw NoValidPayjoinBip21Exception(e.toString()); } - final minFeeRateSatPerKwu = BigInt.from(networkFeesSatPerVb * 250); - final senderBuilder = await SenderBuilder.fromPsbtAndUri( - psbtBase64: originalPsbt, - pjUri: pjUri, - ); - final newSender = await senderBuilder.buildRecommended( - minFeeRate: minFeeRateSatPerKwu, - ); - final imp = InMemorySenderPersister(); - final persister = DartSenderPersister( - save: (sender) async { - return await imp.save(sender: sender); - }, - load: (token) async { - return await imp.load(token: token); - }, - ); - final token = await newSender.persist(persister: persister); - final sender = await Sender.load(token: token, persister: persister); - final senderJson = sender.toJson(); + var sendBuilder = SenderBuilder(psbt: originalPsbt, uri: pjUri); + final persister = InMemoryJsonSenderSessionPersister(); + final minFeeRateSatPerKwu = (networkFeesSatPerVb * 250).round(); + WithReplyKey? withReplyKey; + try { + withReplyKey = sendBuilder + .buildRecommended(minFeeRateSatPerKwu: minFeeRateSatPerKwu) + .save(persister: persister); + } catch (e) { + throw SendCreationException(e.toString()); + } + + await postOriginalProposal(withReplyKey, persister); // Create and store the model with the data needed to keep track of the - // payjoin session + // payjoin session final model = PayjoinModel.sender( - uri: uri.asString(), + uri: parsedUri.asString(), isTestnet: isTestnet, - sender: senderJson, + sender: persister.toJson(), walletId: walletId, originalPsbt: originalPsbt, originalTxId: (await BitcoinTx.fromPsbt(originalPsbt)).txid, @@ -185,6 +179,39 @@ class PdkPayjoinDatasource { return model; } + Future postOriginalProposal( + WithReplyKey withReplyKey, + InMemoryJsonSenderSessionPersister persister, + ) async { + Object? lastError; + var posted = false; + for (final relay in PayjoinConstants.ohttpRelayUrls) { + try { + final reqCtx = withReplyKey.createV2PostRequest(ohttpRelay: relay); + final body = await _postBytes( + _dio, + reqCtx.request.url, + reqCtx.request.body, + reqCtx.request.contentType, + ); + withReplyKey + .processResponse(response: body, postCtx: reqCtx.ohttpCtx) + .save(persister: persister); + posted = true; + break; + } catch (e) { + log('sender v2 post via $relay failed: $e'); + lastError = e; + continue; + } + } + if (!posted) { + throw SendCreationException( + 'Failed to post original PSBT to any OHTTP relay: $lastError', + ); + } + } + Future proposePayjoin({ required PayjoinReceiverModel receiverModel, required FutureOr Function(Uint8List) hasOwnedInputs, @@ -622,56 +649,6 @@ class PdkPayjoinDatasource { ); } - static Future request({ - required Sender sender, - required Dio dio, - }) async { - (Request, V2PostContext)? result; - - for (final ohttpProxyUrl in PayjoinConstants.ohttpRelayUrls) { - try { - log( - '[Senders Isolate] Extracting V2 request from sender with relay: $ohttpProxyUrl', - ); - result = await sender.extractV2( - ohttpProxyUrl: await Url.fromStr(ohttpProxyUrl), - ); - break; - } catch (e) { - final msg = (e as dynamic).msg ?? e.toString(); - log('[Senders Isolate] request error: $msg'); - log('[Senders Isolate] Continuing to next OHTTP relay'); - continue; - } - } - - if (result == null) { - log('[Senders Isolate] All OHTTP relays failed'); - throw Exception('All OHTTP relays failed'); - } - - final (req, context) = result; - - log('[Senders Isolate] Sending V2 request to ${req.url.asString()}'); - final res = await dio.post( - req.url.asString(), - data: req.body, - options: Options( - headers: {'Content-Type': req.contentType}, - responseType: ResponseType.bytes, - ), - ); - log('[Senders Isolate] Received response from ${req.url.asString()}'); - - final getCtx = await context.processResponse( - response: res.data as List, - ); - - log('[Senders Isolate] Processed response for V2 request: $getCtx'); - - return getCtx; - } - static Future getProposalPsbt({ required V2GetContext context, required Dio dio, From e8479819202089c026f05b6a86d00d8e6d54e8d1 Mon Sep 17 00:00:00 2001 From: spacebear Date: Tue, 21 Apr 2026 19:56:41 -0400 Subject: [PATCH 10/28] Update proposePayjoin 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. --- .../datasources/pdk_payjoin_datasource.dart | 421 +++++++++++++----- 1 file changed, 311 insertions(+), 110 deletions(-) diff --git a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart index 2709afeadb..b018ae717b 100644 --- a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart +++ b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart @@ -214,105 +214,336 @@ class PdkPayjoinDatasource { Future proposePayjoin({ required PayjoinReceiverModel receiverModel, - required FutureOr Function(Uint8List) hasOwnedInputs, - required FutureOr Function(Uint8List) hasReceiverOutput, + required bool Function(Uint8List) hasOwnedInputs, + required bool Function(Uint8List) hasReceiverOutput, required List inputPairs, - required FutureOr Function(String) processPsbt, + required String Function(String) processPsbt, }) async { - final receiver = Receiver.fromJson(json: receiverModel.receiver); - final request = await getRequest(receiver: receiver, dio: _dio); + final persister = InMemoryJsonReceiverSessionPersister.fromJson( + receiverModel.receiver, + ); + final state = replayReceiverEventLog(persister: persister).state(); + + final result = await processReceiveSession( + state: state, + persister: persister, + hasOwnedInputs: hasOwnedInputs, + hasReceiverOutput: hasReceiverOutput, + inputPairs: inputPairs, + receiverModel: receiverModel, + processPsbt: processPsbt, + ); - if (request == null) { - throw Exception('No request found'); + // Update the model with the proposal psbt so it can be known a proposal has + // been sent + final proposalPsbt = result.psbt; + + final updatedModel = receiverModel.copyWith( + receiver: persister.toJson(), + proposalPsbt: proposalPsbt, + txId: (await BitcoinTx.fromPsbt(proposalPsbt)).txid, + ); + + logger.log.info( + 'Payjoin request processed and proposal sent for ${receiverModel.id}: $proposalPsbt', + ); + + return updatedModel; + } + + Future<({Monitor monitor, String psbt})> processReceiveSession({ + required ReceiveSession state, + required InMemoryJsonReceiverSessionPersister persister, + required bool Function(Uint8List) hasOwnedInputs, + required bool Function(Uint8List) hasReceiverOutput, + required List inputPairs, + required PayjoinReceiverModel receiverModel, + required String Function(String) processPsbt, + }) async { + switch (state) { + case InitializedReceiveSession(): + throw StateError('Original PSBT is retrieved in the receiver isolate'); + case UncheckedOriginalPayloadReceiveSession(): + return _checkProposal( + state.inner, + persister, + hasOwnedInputs, + hasReceiverOutput, + inputPairs, + receiverModel, + processPsbt, + ); + case MaybeInputsOwnedReceiveSession(): + return _checkInputsNotOwned( + state.inner, + persister, + hasOwnedInputs, + hasReceiverOutput, + inputPairs, + receiverModel, + processPsbt, + ); + case MaybeInputsSeenReceiveSession(): + return _checkNoInputsSeenBefore( + state.inner, + persister, + hasReceiverOutput, + inputPairs, + receiverModel, + processPsbt, + ); + case OutputsUnknownReceiveSession(): + return _identifyReceiverOutputs( + state.inner, + persister, + hasReceiverOutput, + inputPairs, + receiverModel, + processPsbt, + ); + case WantsOutputsReceiveSession(): + return _commitOutputs( + state.inner, + persister, + inputPairs, + receiverModel, + processPsbt, + ); + case WantsInputsReceiveSession(): + return _contributeInputs( + state.inner, + persister, + inputPairs, + receiverModel, + processPsbt, + ); + case WantsFeeRangeReceiveSession(): + return _applyFeeRange( + state.inner, + persister, + receiverModel, + processPsbt, + ); + case ProvisionalProposalReceiveSession(): + return _finalizeProposal(state.inner, persister, processPsbt); + case PayjoinProposalReceiveSession(): + return _sendPayjoinProposal(state.inner, persister); + case HasReplyableExceptionReceiveSession(): + throw StateError('Receive session has a replyable exception'); + case MonitorReceiveSession(): + throw StateError( + 'Receive session is monitoring; proposal already sent', + ); + case ClosedReceiveSession(): + throw StateError('Receive session is closed'); + default: + throw StateError('Unexpected receive session state: $state'); } + } - final interactiveReceiver = await request.assumeInteractiveReceiver(); - final inputsNotOwned = await interactiveReceiver.checkInputsNotOwned( - isOwned: hasOwnedInputs, + Future<({Monitor monitor, String psbt})> _checkProposal( + UncheckedOriginalPayload inner, + InMemoryJsonReceiverSessionPersister persister, + bool Function(Uint8List) hasOwnedInputs, + bool Function(Uint8List) hasReceiverOutput, + List inputPairs, + PayjoinReceiverModel receiverModel, + String Function(String) processPsbt, + ) async { + final next = inner.assumeInteractiveReceiver().save(persister: persister); + return _checkInputsNotOwned( + next, + persister, + hasOwnedInputs, + hasReceiverOutput, + inputPairs, + receiverModel, + processPsbt, ); - final inputsNotSeen = await inputsNotOwned.checkNoInputsSeenBefore( - isKnown: (_) => - false, // Assume the wallet has not seen the inputs since it is an interactive wallet + } + + Future<({Monitor monitor, String psbt})> _checkInputsNotOwned( + MaybeInputsOwned inner, + InMemoryJsonReceiverSessionPersister persister, + bool Function(Uint8List) hasOwnedInputs, + bool Function(Uint8List) hasReceiverOutput, + List inputPairs, + PayjoinReceiverModel receiverModel, + String Function(String) processPsbt, + ) async { + final next = inner + .checkInputsNotOwned(isOwned: _IsScriptOwned(hasOwnedInputs)) + .save(persister: persister); + return _checkNoInputsSeenBefore( + next, + persister, + hasReceiverOutput, + inputPairs, + receiverModel, + processPsbt, ); - final receiverOutputs = await inputsNotSeen.identifyReceiverOutputs( - isReceiverOutput: hasReceiverOutput, + } + + Future<({Monitor monitor, String psbt})> _checkNoInputsSeenBefore( + MaybeInputsSeen inner, + InMemoryJsonReceiverSessionPersister persister, + bool Function(Uint8List) hasReceiverOutput, + List inputPairs, + PayjoinReceiverModel receiverModel, + String Function(String) processPsbt, + ) async { + final next = inner + .checkNoInputsSeenBefore(isKnown: _AssumeUnseen()) + .save(persister: persister); + return _identifyReceiverOutputs( + next, + persister, + hasReceiverOutput, + inputPairs, + receiverModel, + processPsbt, ); - final committedOutputs = await receiverOutputs.commitOutputs(); - - final candidateInputs = await Future.wait( - inputPairs.map( - (input) async => await InputPair.newInstance( - txin: TxIn( - previousOutput: OutPoint(txid: input.txId, vout: input.vout), - scriptSig: await Script.newInstance( - rawOutputScript: input.scriptSigRawOutputScript, - ), - sequence: input.sequence, - witness: input.witness, - ), - psbtin: PsbtInput( - witnessUtxo: TxOut( - value: input.value!, - scriptPubkey: input.scriptPubkey, - ), - redeemScript: input.redeemScriptRawOutputScript.isEmpty - ? null - : await Script.newInstance( - rawOutputScript: input.redeemScriptRawOutputScript, - ), - witnessScript: input.witnessScriptRawOutputScript.isEmpty - ? null - : await Script.newInstance( - rawOutputScript: input.witnessScriptRawOutputScript, - ), - ), - ), - ), + } + + Future<({Monitor monitor, String psbt})> _identifyReceiverOutputs( + OutputsUnknown inner, + InMemoryJsonReceiverSessionPersister persister, + bool Function(Uint8List) hasReceiverOutput, + List inputPairs, + PayjoinReceiverModel receiverModel, + String Function(String) processPsbt, + ) async { + final next = inner + .identifyReceiverOutputs( + isReceiverOutput: _IsScriptOwned(hasReceiverOutput), + ) + .save(persister: persister); + return _commitOutputs( + next, + persister, + inputPairs, + receiverModel, + processPsbt, + ); + } + + Future<({Monitor monitor, String psbt})> _commitOutputs( + WantsOutputs inner, + InMemoryJsonReceiverSessionPersister persister, + List inputPairs, + PayjoinReceiverModel receiverModel, + String Function(String) processPsbt, + ) async { + final next = inner.commitOutputs().save(persister: persister); + return _contributeInputs( + next, + persister, + inputPairs, + receiverModel, + processPsbt, ); + } - // Try to select a privacy preserving input pair, else just stick with the - // first possible input pair. - InputPair inputPair = candidateInputs.first; + Future<({Monitor monitor, String psbt})> _contributeInputs( + WantsInputs inner, + InMemoryJsonReceiverSessionPersister persister, + List inputPairs, + PayjoinReceiverModel receiverModel, + String Function(String) processPsbt, + ) async { + final candidates = inputPairs.map(_buildInputPair).toList(); + InputPair? chosen; try { - inputPair = await committedOutputs.tryPreservingPrivacy( - candidateInputs: candidateInputs, - ); + chosen = inner.tryPreservingPrivacy(candidateInputs: candidates); } catch (e) { - logger.log.severe( - message: 'Failed to preserve privacy: Using first input pair.', - error: e, - trace: StackTrace.current, - ); + throw StateError('No inputs available to contribute to payjoin'); } + final next = inner + .contributeInputs(replacementInputs: [chosen]) + .commitInputs() + .save(persister: persister); + return _applyFeeRange(next, persister, receiverModel, processPsbt); + } - final inputsContributed = await committedOutputs.contributeInputs( - replacementInputs: [inputPair], - ); - final inputsCommitted = await inputsContributed.commitInputs(); - final proposal = await inputsCommitted.finalizeProposal( - processPsbt: processPsbt, - maxFeeRateSatPerVb: receiverModel.maxFeeRateSatPerVb, - ); - - // Now that the request is processed and the proposal is ready, send it to - // the sender through the payjoin directory - await _sendPayjoinProposal(proposal); + Future<({Monitor monitor, String psbt})> _applyFeeRange( + WantsFeeRange inner, + InMemoryJsonReceiverSessionPersister persister, + PayjoinReceiverModel receiverModel, + String Function(String) processPsbt, + ) async { + final next = inner + .applyFeeRange( + minFeeRateSatPerVb: null, + maxEffectiveFeeRateSatPerVb: receiverModel.maxFeeRateSatPerVb.toInt(), + ) + .save(persister: persister); + return _finalizeProposal(next, persister, processPsbt); + } - // Update the model with the proposal psbt so it can be known a proposal has - // been sent - final proposalPsbt = await proposal.psbt(); + Future<({Monitor monitor, String psbt})> _finalizeProposal( + ProvisionalProposal inner, + InMemoryJsonReceiverSessionPersister persister, + String Function(String) processPsbt, + ) async { + final next = inner + .finalizeProposal(processPsbt: _ProcessPsbt(processPsbt)) + .save(persister: persister); + return _sendPayjoinProposal(next, persister); + } - final updatedModel = receiverModel.copyWith( - receiver: receiver.toJson(), - proposalPsbt: proposalPsbt, - txId: (await BitcoinTx.fromPsbt(proposalPsbt)).txid, + Future<({Monitor monitor, String psbt})> _sendPayjoinProposal( + PayjoinProposal proposal, + InMemoryJsonReceiverSessionPersister persister, + ) async { + Object? lastError; + for (final relay in PayjoinConstants.ohttpRelayUrls) { + try { + final req = proposal.createPostRequest(ohttpRelay: relay); + final body = await _postBytes( + _dio, + req.request.url, + req.request.body, + req.request.contentType, + ); + // Capture the proposal PSBT here, as it's not available on the monitor typestate. + final psbt = proposal.psbt(); + final monitor = proposal + .processResponse(body: body, ohttpContext: req.clientResponse) + .save(persister: persister); + return (monitor: monitor, psbt: psbt); + } catch (e) { + log('proposal post via $relay failed: $e'); + lastError = e; + continue; + } + } + throw PayjoinNotFoundException( + 'Failed to post payjoin proposal: $lastError', ); + } - logger.log.info( - 'Payjoin request processed and proposal sent for ${receiver.id()}: $proposalPsbt', + InputPair _buildInputPair(PayjoinInputPairModel input) { + return InputPair( + txin: TxIn( + previousOutput: OutPoint(txid: input.txId, vout: input.vout), + scriptSig: Uint8List.fromList(input.scriptSigRawOutputScript), + sequence: input.sequence, + witness: input.witness, + ), + psbtin: PsbtInput( + witnessUtxo: TxOut( + valueSat: (input.value ?? BigInt.zero).toInt(), + scriptPubkey: input.scriptPubkey, + ), + redeemScript: input.redeemScriptRawOutputScript.isEmpty + ? null + : Uint8List.fromList(input.redeemScriptRawOutputScript), + witnessScript: input.witnessScriptRawOutputScript.isEmpty + ? null + : Uint8List.fromList(input.witnessScriptRawOutputScript), + ), + expectedWeight: null, ); - - return updatedModel; } Future startListeningForRequest(PayjoinReceiverModel payjoin) async { @@ -619,36 +850,6 @@ class PdkPayjoinDatasource { } } - Future _sendPayjoinProposal(PayjoinProposal proposal) async { - (Request, ClientResponse)? request; - for (final ohttpRelayUrl in PayjoinConstants.ohttpRelayUrls) { - try { - request = await proposal.extractReq(ohttpRelay: ohttpRelayUrl); - break; - } catch (e) { - log('proposal extractReq exception: $e with relay $ohttpRelayUrl'); - continue; - } - } - if (request == null) { - throw PayjoinNotFoundException('No payjoin proposal found'); - } - - final (req, ohttpCtx) = request; - final res = await _dio.post( - req.url.asString(), - data: req.body, - options: Options( - headers: {'Content-Type': req.contentType}, - responseType: ResponseType.bytes, - ), - ); - await proposal.processRes( - res: res.data as List, - ohttpContext: ohttpCtx, - ); - } - static Future getProposalPsbt({ required V2GetContext context, required Dio dio, From 9cb4f4c9d5d03c30258f4a75502dc185d0f66b08 Mon Sep 17 00:00:00 2001 From: spacebear Date: Tue, 21 Apr 2026 22:22:17 -0400 Subject: [PATCH 11/28] Update receiver isolate entrypoint --- .../datasources/pdk_payjoin_datasource.dart | 144 ++++++++---------- 1 file changed, 64 insertions(+), 80 deletions(-) diff --git a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart index b018ae717b..2f889bdd34 100644 --- a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart +++ b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart @@ -644,28 +644,34 @@ class PdkPayjoinDatasource { final receiverModel = PayjoinReceiverModel.fromJson( data as Map, ); - final receiver = Receiver.fromJson(json: receiverModel.receiver); // Start checking for a payjoin request from the sender periodically Timer.periodic(const Duration(seconds: PayjoinConstants.directoryPollingInterval), ( Timer timer, ) async { - log( - '[Receivers Isolate] Checking for request in receivers isolate for ' - '${receiver.id()}', - ); + log('[Receivers Isolate] Checking for request for ${receiverModel.id}'); try { - final request = await getRequest(receiver: receiver, dio: dio); - if (request != null) { - requests.putIfAbsent(receiver.id(), () async { - log( - '[Receivers Isolate] Request found in receivers isolate for ' - '${receiver.id()}', - ); + final persister = InMemoryJsonReceiverSessionPersister.fromJson( + receiverModel.receiver, + ); + final state = replayReceiverEventLog(persister: persister).state(); + if (state is! InitializedReceiveSession) return; + + final unchecked = await _getUncheckedOriginalPayload( + state.inner, + persister, + dio, + ); + if (unchecked != null) { + requests.putIfAbsent(receiverModel.id, () async { + log('[Receivers Isolate] Request found for ${receiverModel.id}'); + final maybeInputsOwned = unchecked + .assumeInteractiveReceiver() + .save(persister: persister); // The original tx bytes are needed in the main isolate for - // further processing so extract them here and pass them through - // the model - final originalTxBytes = await request + // further processing so extract them here and pass them + // through the model + final originalTxBytes = maybeInputsOwned .extractTxToScheduleBroadcast(); final originalTx = await BitcoinTx.fromBytes(originalTxBytes); final originalTxId = originalTx.txid; @@ -675,12 +681,12 @@ class PdkPayjoinDatasource { ); log( '[Receivers Isolate] Request original Tx ID: $originalTxId and amount: $amountSat for ' - '${receiver.id()}', + '${receiverModel.id}', ); final updatedModel = receiverModel.copyWith( - receiver: receiver.toJson(), + receiver: persister.toJson(), originalTxBytes: originalTxBytes, - originalTxId: originalTxId, + originalTxId: originalTx.txid, amountSat: amountSat, ); @@ -689,23 +695,22 @@ class PdkPayjoinDatasource { // Cancel the timer since the request has been received log( - '[Receivers Isolate] cancelling timer in receivers isolate for ${receiver.id()}', + '[Receivers Isolate] cancelling timer in receivers isolate for ${receiverModel.id}', ); timer.cancel(); log( - '[Receivers Isolate] timer cancelled in receivers isolate for ${receiver.id()}', + '[Receivers Isolate] timer cancelled in receivers isolate for ${receiverModel.id}', ); }); } else { log( '[Receivers Isolate] No valid request found in receivers isolate for ' - '${receiver.id()}', + '${receiverModel.id}', ); } } catch (e) { log( - '[Receivers Isolate] periodic timer get request exception: $e for ' - '${receiver.id()}', + '[Receivers Isolate] periodic timer get request exception: $e for ${receiverModel.id}', ); if (e is PayjoinExpiredException) { // If the request returns an expiry error, mark the receiver as @@ -788,66 +793,40 @@ class PdkPayjoinDatasource { }); } - static Future getRequest({ - required Receiver receiver, - required Dio dio, - }) async { - // The use of ffiError here is a hack, we should change it once payjoin-flutter - // exposes different exceptions for specific errors - Object? ffiError; - try { - receiver.id(); - (Request, ClientResponse)? request; - for (final ohttpRelay in PayjoinConstants.ohttpRelayUrls) { - try { - request = await receiver.extractReq(ohttpRelay: ohttpRelay); - ffiError = null; - log('[${receiver.id()}] receiver extractReq success'); - break; - } catch (e) { - log('[${receiver.id()}] receiver extractReq exception: $e'); - ffiError = e; - continue; - } - } - - if (request == null) { - if (ffiError != null) { - throw ffiError; - } - throw PayjoinNotFoundException( - '[${receiver.id()}] No payjoin request found', - ); - } - - log('[${receiver.id()}] request != null'); - final (req, context) = request; - final ohttpResponse = await dio.post( - req.url.asString(), - data: req.body, - options: Options( - headers: {'Content-Type': req.contentType}, - responseType: ResponseType.bytes, - ), - ); - log('[${receiver.id()}] processing request...'); - final proposal = await receiver.processRes( - body: ohttpResponse.data as List, - ctx: context, - ); - log('[${receiver.id()}] request processed'); - return proposal; - } catch (e) { - log('[${receiver.id()}] getRequest exception: $e'); - if (e == ffiError) { - // TODO: Check for the correct error. - // We just assume the error is an expired error for now. - throw PayjoinExpiredException( - 'Payjoin receiver $receiver.id() expired', + static Future _getUncheckedOriginalPayload( + Initialized initialized, + InMemoryJsonReceiverSessionPersister persister, + Dio dio, + ) async { + Object? lastError; + for (final relay in PayjoinConstants.ohttpRelayUrls) { + try { + final poll = initialized.createPollRequest(ohttpRelay: relay); + final body = await _postBytes( + dio, + poll.request.url, + poll.request.body, + poll.request.contentType, ); + final outcome = initialized + .processResponse(body: body, ctx: poll.clientResponse) + .save(persister: persister); + if (outcome is StasisInitializedTransitionOutcome) return null; + return (outcome as ProgressInitializedTransitionOutcome).inner; + } on ReceiverException catch (e) { + if (_isExpiredString(e)) { + throw PayjoinExpiredException('Payjoin receiver expired: $e'); + } + log('receiver createPollRequest via $relay failed: $e'); + lastError = e; + continue; + } catch (e) { + log('receiver poll via $relay failed: $e'); + lastError = e; + continue; } - return null; } + throw PayjoinNotFoundException('Failed to poll receiver: $lastError'); } static Future getProposalPsbt({ @@ -909,6 +888,11 @@ class PdkPayjoinDatasource { } } + // "Expired" variants aren't exposed publicly as a distinct subtype. + // Tighten to a typed check if the payjoin bindings start exposing variants. + static bool _isExpiredString(Object error) => + error.toString().toLowerCase().contains('expired'); + static Future _postBytes( Dio dio, String url, From d78935a5e532047ee3e17b512ffad74b96b00a3c Mon Sep 17 00:00:00 2001 From: spacebear Date: Tue, 21 Apr 2026 23:07:14 -0400 Subject: [PATCH 12/28] Update sender isolate entrypoint --- .../datasources/pdk_payjoin_datasource.dart | 111 ++++++++---------- 1 file changed, 47 insertions(+), 64 deletions(-) diff --git a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart index 2f889bdd34..6c916d7f9d 100644 --- a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart +++ b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart @@ -731,20 +731,13 @@ class PdkPayjoinDatasource { sendPort.send(receivePort.sendPort); final dio = Dio(); - // Listen for and register new receivers sent from the main isolate + // Listen for and register new senders sent from the main isolate receivePort.listen((data) async { try { log('[Senders Isolate] Received data in senders isolate: $data'); final senderModel = PayjoinSenderModel.fromJson( data as Map, ); - final sender = Sender.fromJson(json: senderModel.sender); - log('[Senders Isolate] Requesting payjoin...'); - final context = await PdkPayjoinDatasource.request( - sender: sender, - dio: dio, - ); - log('[Senders Isolate] Payjoin requested.'); // Periodically check for a proposal from the receiver Timer.periodic( @@ -752,9 +745,18 @@ class PdkPayjoinDatasource { (Timer timer) async { log('[Senders Isolate]Checking for proposal in senders isolate'); try { - final proposalPsbt = await PdkPayjoinDatasource.getProposalPsbt( - context: context, - dio: dio, + final persister = InMemoryJsonSenderSessionPersister.fromJson( + senderModel.sender, + ); + final state = pj + .replaySenderEventLog(persister: persister) + .state(); + if (state is! PollingForProposalSendSession) return; + + final proposalPsbt = await _getProposalPsbt( + state.inner, + persister, + dio, ); if (proposalPsbt != null) { @@ -764,6 +766,7 @@ class PdkPayjoinDatasource { // further processing so send it through the model as well as // its txId. final updatedModel = senderModel.copyWith( + sender: persister.toJson(), proposalPsbt: proposalPsbt, txId: txId, ); @@ -829,63 +832,43 @@ class PdkPayjoinDatasource { throw PayjoinNotFoundException('Failed to poll receiver: $lastError'); } - static Future getProposalPsbt({ - required V2GetContext context, - required Dio dio, - }) async { - // The use of ffiError here is a hack, we should change it once payjoin-flutter - // exposes different exceptions for specific errors - Object? ffiError; - try { - (Request, ClientResponse)? result; - for (final ohttpRelay in PayjoinConstants.ohttpRelayUrls) { - try { - result = await context.extractReq(ohttpRelay: ohttpRelay); - ffiError = null; - log( - 'context extract request success: $result with relay $ohttpRelay', - ); - break; - } catch (e) { - log('context extract request exception: $e with relay $ohttpRelay'); - ffiError = e; - continue; + static Future _getProposalPsbt( + PollingForProposal polling, + InMemoryJsonSenderSessionPersister persister, + Dio dio, + ) async { + Object? lastError; + for (final relay in PayjoinConstants.ohttpRelayUrls) { + try { + final poll = polling.createPollRequest(ohttpRelay: relay); + final body = await _postBytes( + dio, + poll.request.url, + poll.request.body, + poll.request.contentType, + ); + final outcome = polling + .processResponse(response: body, ohttpCtx: poll.ohttpCtx) + .save(persister: persister); + if (outcome is StasisPollingForProposalTransitionOutcome) { + return null; } - } - - if (result == null) { - if (ffiError != null) { - throw ffiError; + return (outcome as ProgressPollingForProposalTransitionOutcome) + .psbtBase64; + } on CreateRequestException catch (e) { + if (_isExpiredString(e)) { + throw PayjoinExpiredException('Payjoin sender expired: $e'); } - throw Exception('All OHTTP relays failed'); - } - - final (req, reqCtx) = result; - - final res = await dio.post( - req.url.asString(), - data: req.body, - options: Options( - headers: {'Content-Type': req.contentType}, - responseType: ResponseType.bytes, - ), - ); - - final proposalPsbt = await context.processResponse( - response: res.data as List, - ohttpCtx: reqCtx, - ); - - return proposalPsbt; - } catch (e) { - log('getProposalPsbt exception: $e'); - if (e == ffiError) { - // TODO: Check for the correct error. - // We just assume the error is an expired error for now. - throw PayjoinExpiredException('Payjoin sender expired'); + log('sender createPollRequest via $relay failed: $e'); + lastError = e; + continue; + } catch (e) { + log('sender poll via $relay failed: $e'); + lastError = e; + continue; } - return null; } + throw PayjoinNotFoundException('Failed to poll sender: $lastError'); } // "Expired" variants aren't exposed publicly as a distinct subtype. From 32143edeed58bc30c4a44133be4a5a8b61f8a5f0 Mon Sep 17 00:00:00 2001 From: spacebear Date: Thu, 23 Apr 2026 19:02:31 -0400 Subject: [PATCH 13/28] Fix integration test wallets setup --- integration_test/payjoin_test.dart | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/integration_test/payjoin_test.dart b/integration_test/payjoin_test.dart index a553b1013f..6396d59d37 100644 --- a/integration_test/payjoin_test.dart +++ b/integration_test/payjoin_test.dart @@ -5,7 +5,7 @@ import 'package:bb_mobile/core/payjoin/domain/entity/payjoin.dart'; import 'package:bb_mobile/core/payjoin/domain/repositories/payjoin_repository.dart'; import 'package:bb_mobile/core/payjoin/domain/usecases/receive_with_payjoin_usecase.dart'; import 'package:bb_mobile/core/payjoin/domain/usecases/send_with_payjoin_usecase.dart'; -import 'package:bb_mobile/core/seed/data/models/seed_model.dart'; +import 'package:bb_mobile/core/seed/data/repository/seed_repository.dart'; import 'package:bb_mobile/core/settings/domain/settings_entity.dart'; import 'package:bb_mobile/core/utils/constants.dart'; import 'package:bb_mobile/core/wallet/data/repositories/wallet_address_repository.dart'; @@ -16,7 +16,6 @@ import 'package:bb_mobile/features/send/domain/usecases/prepare_bitcoin_send_use import 'package:bb_mobile/features/settings/domain/usecases/set_environment_usecase.dart'; import 'package:bb_mobile/locator.dart'; import 'package:bb_mobile/main.dart'; -import 'dart:io' show Platform; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart' show TestWidgetsFlutterBinding; @@ -30,6 +29,7 @@ Future main({bool isInitialized = false}) async { late Wallet senderWallet; final walletRepository = locator(); + final seedRepository = locator(); final addressRepository = locator(); final utxoRepository = locator(); final payjoinRepository = locator(); @@ -37,32 +37,32 @@ Future main({bool isInitialized = false}) async { final sendWithPayjoinUsecase = locator(); final prepareBitcoinSendUsecase = locator(); - final receiverMnemonic = Platform.environment['TEST_ALICE_MNEMONIC']; - final senderMnemonic = Platform.environment['TEST_BOB_MNEMONIC']; + const receiverMnemonic = String.fromEnvironment('TEST_ALICE_MNEMONIC'); + const senderMnemonic = String.fromEnvironment('TEST_BOB_MNEMONIC'); - if (receiverMnemonic == null || receiverMnemonic.isEmpty) { + if (receiverMnemonic.isEmpty) { throw Exception('TEST_ALICE_MNEMONIC environment variable is not set'); } - if (senderMnemonic == null || senderMnemonic.isEmpty) { + if (senderMnemonic.isEmpty) { throw Exception('TEST_BOB_MNEMONIC environment variable is not set'); } setUpAll(() async { await locator().execute(Environment.testnet); - final receiverSeedModel = SeedModel.mnemonic( + final receiverSeed = await seedRepository.createFromMnemonic( mnemonicWords: receiverMnemonic.split(' '), ); - final senderSeedModel = SeedModel.mnemonic( + final senderSeed = await seedRepository.createFromMnemonic( mnemonicWords: senderMnemonic.split(' '), ); receiverWallet = await walletRepository.createWallet( - seed: receiverSeedModel.toEntity(), + seed: receiverSeed, network: Network.bitcoinTestnet, scriptType: ScriptType.bip84, ); senderWallet = await walletRepository.createWallet( - seed: senderSeedModel.toEntity(), + seed: senderSeed, network: Network.bitcoinTestnet, scriptType: ScriptType.bip84, ); From 87e163541386d1a125bec6165a017caa6bab8d76 Mon Sep 17 00:00:00 2001 From: spacebear Date: Thu, 23 Apr 2026 19:03:00 -0400 Subject: [PATCH 14/28] Fix bad state in tests when native crash occurs --- integration_test/payjoin_test.dart | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/integration_test/payjoin_test.dart b/integration_test/payjoin_test.dart index 6396d59d37..f572908057 100644 --- a/integration_test/payjoin_test.dart +++ b/integration_test/payjoin_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:bb_mobile/core/fees/domain/fees_entity.dart'; +import 'package:bb_mobile/core/payjoin/data/datasources/local_payjoin_datasource.dart'; import 'package:bb_mobile/core/payjoin/domain/entity/payjoin.dart'; import 'package:bb_mobile/core/payjoin/domain/repositories/payjoin_repository.dart'; import 'package:bb_mobile/core/payjoin/domain/usecases/receive_with_payjoin_usecase.dart'; @@ -33,6 +34,7 @@ Future main({bool isInitialized = false}) async { final addressRepository = locator(); final utxoRepository = locator(); final payjoinRepository = locator(); + final localPayjoinDatasource = locator(); final receiveWithPayjoinUsecase = locator(); final sendWithPayjoinUsecase = locator(); final prepareBitcoinSendUsecase = locator(); @@ -50,6 +52,34 @@ Future main({bool isInitialized = false}) async { setUpAll(() async { await locator().execute(Environment.testnet); + // Drain any persisted payjoin state so the test starts clean. Ongoing + // payjoins left behind by a previous (possibly crashed) run keep their + // inputs frozen via getUtxosFrozenByOngoingPayjoins(), which would starve + // the sender wallet. _resumePayjoins in PayjoinRepositoryImpl's + // constructor runs unawaited and writes its own updates concurrently, so + // we expire + recheck until the ongoing set stays empty for several polls. + const pollInterval = Duration(milliseconds: 500); + const requiredStableChecks = 3; + const maxIterations = 40; + var stableChecks = 0; + for (var i = 0; i < maxIterations; i++) { + final ongoing = await localPayjoinDatasource.fetchAll( + onlyUnfinished: true, + ); + if (ongoing.isEmpty) { + stableChecks++; + if (stableChecks >= requiredStableChecks) break; + } else { + stableChecks = 0; + for (final payjoin in ongoing) { + await localPayjoinDatasource.update( + payjoin.copyWith(isExpired: true), + ); + } + } + await Future.delayed(pollInterval); + } + final receiverSeed = await seedRepository.createFromMnemonic( mnemonicWords: receiverMnemonic.split(' '), ); From 7dd54186bd870d30efe1a02325c13b42fb3db537 Mon Sep 17 00:00:00 2001 From: spacebear Date: Mon, 27 Apr 2026 15:07:00 -0400 Subject: [PATCH 15/28] Fix race for tests This should be droppable once isolates architecture is replaced --- .../data/datasources/bdk_wallet_datasource.dart | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/lib/core/wallet/data/datasources/bdk_wallet_datasource.dart b/lib/core/wallet/data/datasources/bdk_wallet_datasource.dart index 71e88347a1..31882945ea 100644 --- a/lib/core/wallet/data/datasources/bdk_wallet_datasource.dart +++ b/lib/core/wallet/data/datasources/bdk_wallet_datasource.dart @@ -750,7 +750,12 @@ Future _performFullScan(_SyncParams params) async { ); final bdkWallet = await BdkFacade.createWallet(wallet); - final blockchain = bdk.ElectrumClient( + // Guard against a rustls CryptoProvider install race across concurrent + // sync isolates. electrum-client's install_default check+install is not + // atomic, so two isolates can both see "not installed" and the loser + // fails. On retry the provider is already installed and the check + // short-circuits. + bdk.ElectrumClient buildClient() => bdk.ElectrumClient( url: params.electrumUrl, socks5: params.electrumSocks5?.isNotEmpty == true ? params.electrumSocks5 @@ -759,6 +764,16 @@ Future _performFullScan(_SyncParams params) async { retry: params.electrumRetry.clamp(0, 255), validateDomain: params.electrumValidateDomain, ); + bdk.ElectrumClient blockchain; + try { + blockchain = buildClient(); + } on bdk.CouldNotCreateConnectionElectrumException catch (e) { + if (e.errorMessage.contains('Failed to install CryptoProvider')) { + blockchain = buildClient(); + } else { + rethrow; + } + } final scanRequest = bdkWallet.startFullScan().build(); final update = blockchain.fullScan( request: scanRequest, From 8f924ad9b30f127605d402517a5ffaaf9c06b40f Mon Sep 17 00:00:00 2001 From: spacebear Date: Mon, 4 May 2026 17:27:58 -0400 Subject: [PATCH 16/28] Drop isolates architecture 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 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../datasources/pdk_payjoin_datasource.dart | 373 ++++++------------ .../repository/payjoin_repository_impl.dart | 4 +- 2 files changed, 121 insertions(+), 256 deletions(-) diff --git a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart index 6c916d7f9d..ee366a2224 100644 --- a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart +++ b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:convert'; import 'dart:developer'; -import 'dart:isolate'; import 'dart:typed_data'; import 'package:bb_mobile/core/errors/bull_exception.dart'; @@ -22,13 +21,9 @@ class PdkPayjoinDatasource { final StreamController _proposalSentController; final StreamController _expiredController; - // Background processing - Isolate? _receiversIsolate; - Isolate? _sendersIsolate; - SendPort? _receiversIsolatePort; - SendPort? _sendersIsolatePort; - final Completer _receiversIsolateReady; - final Completer _sendersIsolateReady; + // Per-session polling timers keyed by session id + final Map _receiverTimers = {}; + final Map _senderTimers = {}; PdkPayjoinDatasource({ String payjoinDirectoryUrl = PayjoinConstants.directoryUrl, @@ -37,9 +32,7 @@ class PdkPayjoinDatasource { _dio = dio, _payjoinRequestedController = StreamController.broadcast(), _proposalSentController = StreamController.broadcast(), - _expiredController = StreamController.broadcast(), - _receiversIsolateReady = Completer(), - _sendersIsolateReady = Completer(); + _expiredController = StreamController.broadcast(); Stream get requestsForReceivers => _payjoinRequestedController.stream; @@ -114,8 +107,8 @@ class PdkPayjoinDatasource { ) as PayjoinReceiverModel; - // Start listening for a payjoin request from the sender in an isolate - await startListeningForRequest(model); + // Start listening for a payjoin request from the sender + startListeningForRequest(model); return model; } catch (e) { @@ -173,8 +166,8 @@ class PdkPayjoinDatasource { ) as PayjoinSenderModel; - // Start listening for a payjoin proposal from the receiver in an isolate - await startListeningForProposal(model); + // Start listening for a payjoin proposal from the receiver + startListeningForProposal(model); return model; } @@ -262,7 +255,9 @@ class PdkPayjoinDatasource { }) async { switch (state) { case InitializedReceiveSession(): - throw StateError('Original PSBT is retrieved in the receiver isolate'); + throw StateError( + 'Original PSBT is retrieved in startListeningForRequest', + ); case UncheckedOriginalPayloadReceiveSession(): return _checkProposal( state.inner, @@ -546,267 +541,138 @@ class PdkPayjoinDatasource { ); } - Future startListeningForRequest(PayjoinReceiverModel payjoin) async { - if (_receiversIsolate == null) { - // Start the isolate if it is not running yet - await _spawnReceiversIsolate(); - } - await _receiversIsolateReady.future; - _receiversIsolatePort?.send(payjoin.toJson()); - } - - Future startListeningForProposal(PayjoinSenderModel payjoin) async { - if (_sendersIsolate == null) { - // Start the isolate if it is not running yet - await _spawnSendersIsolate(); - } - await _sendersIsolateReady.future; - _sendersIsolatePort?.send(payjoin.toJson()); + void startListeningForRequest(PayjoinReceiverModel payjoin) { + _receiverTimers[payjoin.id]?.cancel(); + _receiverTimers[payjoin.id] = Timer.periodic( + const Duration(seconds: PayjoinConstants.directoryPollingInterval), + (timer) => _pollReceiverOnce(payjoin, timer), + ); } - /// Starts the isolate to listen for payjoin requests. - Future _spawnReceiversIsolate() async { - // Receive isolate - final receivePort = ReceivePort(); - - // Listen to messages from the receive isolate - receivePort.listen((message) { - if (message is SendPort) { - _receiversIsolatePort = message; - _receiversIsolateReady.complete(); - } else if (message is Map) { - logger.log.info( - 'Received message of found payjoin request in main isolate: $message', - ); - final model = PayjoinReceiverModel.fromJson(message); - - // Send the updated payjoin model to the higher repository layers so it - // can be stored locally and processed further - if (model.isExpired) { - _expiredController.add(model); - } else { - // If not expired, it means a request was received - _payjoinRequestedController.add(model); - } - } - }); - - logger.log.info('Spawning receivers isolate'); - // Spawn the isolate - _receiversIsolate = await Isolate.spawn( - _receiversIsolateEntryPoint, - receivePort.sendPort, + void startListeningForProposal(PayjoinSenderModel payjoin) { + _senderTimers[payjoin.id]?.cancel(); + _senderTimers[payjoin.id] = Timer.periodic( + const Duration(seconds: PayjoinConstants.directoryPollingInterval), + (timer) => _pollSenderOnce(payjoin, timer), ); } - /// Starts the isolate to request and listen for payjoin proposals. - Future _spawnSendersIsolate() async { - // Senders isolate - final receivePort = ReceivePort(); - - // Listen for messages from the senders isolate - receivePort.listen((message) { - if (message is SendPort) { - _sendersIsolatePort = message; - _sendersIsolateReady.complete(); - } else if (message is Map) { - final model = PayjoinSenderModel.fromJson(message); - - // Send the updated payjoin model to the higher repository layers for - // processing and notification to the user - if (model.isExpired) { - _expiredController.add(model); - } else { - // If not expired, it means a proposal was received - _proposalSentController.add(model); + Future _pollReceiverOnce( + PayjoinReceiverModel receiverModel, + Timer timer, + ) async { + log('[receiver poll] checking for request for ${receiverModel.id}'); + try { + final persister = InMemoryJsonReceiverSessionPersister.fromJson( + receiverModel.receiver, + ); + final ReceiveSession state; + try { + state = replayReceiverEventLog(persister: persister).state(); + } on ReceiverReplayException catch (e) { + if (_isExpiredString(e)) { + throw PayjoinExpiredException('Payjoin receiver expired: $e'); } + rethrow; } - }); - - logger.log.info('Spawning senders isolate'); - _sendersIsolate = await Isolate.spawn( - _sendersIsolateEntryPoint, - receivePort.sendPort, - ); - } - - static Future _receiversIsolateEntryPoint(SendPort sendPort) async { - log('[Receivers Isolate] Started _receiversIsolateEntryPoint'); + if (state is! InitializedReceiveSession) return; - final receivePort = ReceivePort(); - sendPort.send(receivePort.sendPort); - final dio = Dio(); - final requests = >{}; - - // Listen for and register new receivers sent from the main isolate - receivePort.listen((data) { - log('[Receivers Isolate] Received data in receivers isolate: $data'); - final receiverModel = PayjoinReceiverModel.fromJson( - data as Map, + final unchecked = await _getUncheckedOriginalPayload( + state.inner, + persister, ); + if (unchecked == null) { + log('[receiver poll] no request yet for ${receiverModel.id}'); + return; + } - // Start checking for a payjoin request from the sender periodically - Timer.periodic(const Duration(seconds: PayjoinConstants.directoryPollingInterval), ( - Timer timer, - ) async { - log('[Receivers Isolate] Checking for request for ${receiverModel.id}'); - try { - final persister = InMemoryJsonReceiverSessionPersister.fromJson( - receiverModel.receiver, - ); - final state = replayReceiverEventLog(persister: persister).state(); - if (state is! InitializedReceiveSession) return; - - final unchecked = await _getUncheckedOriginalPayload( - state.inner, - persister, - dio, - ); - if (unchecked != null) { - requests.putIfAbsent(receiverModel.id, () async { - log('[Receivers Isolate] Request found for ${receiverModel.id}'); - final maybeInputsOwned = unchecked - .assumeInteractiveReceiver() - .save(persister: persister); - // The original tx bytes are needed in the main isolate for - // further processing so extract them here and pass them - // through the model - final originalTxBytes = maybeInputsOwned - .extractTxToScheduleBroadcast(); - final originalTx = await BitcoinTx.fromBytes(originalTxBytes); - final originalTxId = originalTx.txid; - final amountSat = await originalTx.getAmountReceived( - address: receiverModel.address, - isTestnet: receiverModel.isTestnet, - ); - log( - '[Receivers Isolate] Request original Tx ID: $originalTxId and amount: $amountSat for ' - '${receiverModel.id}', - ); - final updatedModel = receiverModel.copyWith( - receiver: persister.toJson(), - originalTxBytes: originalTxBytes, - originalTxId: originalTx.txid, - amountSat: amountSat, - ); - - // Notify the main isolate so it can be processed further - sendPort.send(updatedModel.toJson()); + timer.cancel(); + _receiverTimers.remove(receiverModel.id); - // Cancel the timer since the request has been received - log( - '[Receivers Isolate] cancelling timer in receivers isolate for ${receiverModel.id}', - ); - timer.cancel(); - log( - '[Receivers Isolate] timer cancelled in receivers isolate for ${receiverModel.id}', - ); - }); - } else { - log( - '[Receivers Isolate] No valid request found in receivers isolate for ' - '${receiverModel.id}', - ); - } - } catch (e) { - log( - '[Receivers Isolate] periodic timer get request exception: $e for ${receiverModel.id}', - ); - if (e is PayjoinExpiredException) { - // If the request returns an expiry error, mark the receiver as - // expired and notify the main isolate so it stops polling - final updatedModel = receiverModel.copyWith(isExpired: true); - sendPort.send(updatedModel.toJson()); - timer.cancel(); - } - } - }); - }); + final maybeInputsOwned = unchecked.assumeInteractiveReceiver().save( + persister: persister, + ); + final originalTxBytes = maybeInputsOwned.extractTxToScheduleBroadcast(); + final originalTx = await BitcoinTx.fromBytes(originalTxBytes); + final amountSat = await originalTx.getAmountReceived( + address: receiverModel.address, + isTestnet: receiverModel.isTestnet, + ); + log( + '[receiver poll] request found for ${receiverModel.id}: ' + 'txid=${originalTx.txid} amount=$amountSat', + ); + final updatedModel = receiverModel.copyWith( + receiver: persister.toJson(), + originalTxBytes: originalTxBytes, + originalTxId: originalTx.txid, + amountSat: amountSat, + ); + _payjoinRequestedController.add(updatedModel); + } on PayjoinExpiredException catch (e) { + logger.log.info('[receiver poll] expired for ${receiverModel.id}: $e'); + timer.cancel(); + _receiverTimers.remove(receiverModel.id); + _expiredController.add(receiverModel.copyWith(isExpired: true)); + } catch (e) { + logger.log.info('[receiver poll] ${receiverModel.id}: $e'); + } } - static Future _sendersIsolateEntryPoint(SendPort sendPort) async { - log('[Senders Isolate] Started _sendersIsolateEntryPoint'); - - final receivePort = ReceivePort(); - sendPort.send(receivePort.sendPort); - - final dio = Dio(); - // Listen for and register new senders sent from the main isolate - receivePort.listen((data) async { + Future _pollSenderOnce( + PayjoinSenderModel senderModel, + Timer timer, + ) async { + log('[sender poll] checking for proposal for ${senderModel.id}'); + try { + final persister = InMemoryJsonSenderSessionPersister.fromJson( + senderModel.sender, + ); + final SendSession state; try { - log('[Senders Isolate] Received data in senders isolate: $data'); - final senderModel = PayjoinSenderModel.fromJson( - data as Map, - ); + state = replaySenderEventLog(persister: persister).state(); + } on SenderReplayException catch (e) { + if (_isExpiredString(e)) { + throw PayjoinExpiredException('Payjoin sender expired: $e'); + } + rethrow; + } + if (state is! PollingForProposalSendSession) return; - // Periodically check for a proposal from the receiver - Timer.periodic( - const Duration(seconds: PayjoinConstants.directoryPollingInterval), - (Timer timer) async { - log('[Senders Isolate]Checking for proposal in senders isolate'); - try { - final persister = InMemoryJsonSenderSessionPersister.fromJson( - senderModel.sender, - ); - final state = pj - .replaySenderEventLog(persister: persister) - .state(); - if (state is! PollingForProposalSendSession) return; - - final proposalPsbt = await _getProposalPsbt( - state.inner, - persister, - dio, - ); + final proposalPsbt = await _getProposalPsbt(state.inner, persister); + if (proposalPsbt == null) return; - if (proposalPsbt != null) { - log('[Senders Isolate] Proposal found in senders isolate'); - final txId = (await BitcoinTx.fromPsbt(proposalPsbt)).txid; - // The proposal psbt is needed in the main isolate for - // further processing so send it through the model as well as - // its txId. - final updatedModel = senderModel.copyWith( - sender: persister.toJson(), - proposalPsbt: proposalPsbt, - txId: txId, - ); - - // Notify the main isolate so the payjoin can be processed further - sendPort.send(updatedModel.toJson()); - - // Cancel the timer - timer.cancel(); - } - } catch (e) { - log('[Senders Isolate] periodic timer exception: $e'); - if (e is PayjoinExpiredException) { - // If the request returns an expiry error, mark the receiver as - // expired and notify the main isolate so it stops polling - final updatedModel = senderModel.copyWith(isExpired: true); - sendPort.send(updatedModel.toJson()); - timer.cancel(); - } - } - }, - ); - } catch (e) { - log('[Senders Isolate] Error in listener: $e'); - // Optionally notify the main isolate of the failure - } - }); + timer.cancel(); + _senderTimers.remove(senderModel.id); + + log('[sender poll] proposal found for ${senderModel.id}'); + final txId = (await BitcoinTx.fromPsbt(proposalPsbt)).txid; + final updatedModel = senderModel.copyWith( + sender: persister.toJson(), + proposalPsbt: proposalPsbt, + txId: txId, + ); + _proposalSentController.add(updatedModel); + } on PayjoinExpiredException catch (e) { + logger.log.info('[sender poll] expired for ${senderModel.id}: $e'); + timer.cancel(); + _senderTimers.remove(senderModel.id); + _expiredController.add(senderModel.copyWith(isExpired: true)); + } catch (e) { + logger.log.info('[sender poll] ${senderModel.id}: $e'); + } } - static Future _getUncheckedOriginalPayload( + Future _getUncheckedOriginalPayload( Initialized initialized, InMemoryJsonReceiverSessionPersister persister, - Dio dio, ) async { Object? lastError; for (final relay in PayjoinConstants.ohttpRelayUrls) { try { final poll = initialized.createPollRequest(ohttpRelay: relay); final body = await _postBytes( - dio, + _dio, poll.request.url, poll.request.body, poll.request.contentType, @@ -832,17 +698,16 @@ class PdkPayjoinDatasource { throw PayjoinNotFoundException('Failed to poll receiver: $lastError'); } - static Future _getProposalPsbt( + Future _getProposalPsbt( PollingForProposal polling, InMemoryJsonSenderSessionPersister persister, - Dio dio, ) async { Object? lastError; for (final relay in PayjoinConstants.ohttpRelayUrls) { try { final poll = polling.createPollRequest(ohttpRelay: relay); final body = await _postBytes( - dio, + _dio, poll.request.url, poll.request.body, poll.request.contentType, diff --git a/lib/core/payjoin/data/repository/payjoin_repository_impl.dart b/lib/core/payjoin/data/repository/payjoin_repository_impl.dart index ca4f240d79..07e58ed39a 100644 --- a/lib/core/payjoin/data/repository/payjoin_repository_impl.dart +++ b/lib/core/payjoin/data/repository/payjoin_repository_impl.dart @@ -436,7 +436,7 @@ class PayjoinRepositoryImpl implements PayjoinRepository { if (model.originalTxBytes == null) { // If the original tx bytes are not present, it means the receiver // needs to listen for a payjoin request from the sender. - await _pdkPayjoinDatasource.startListeningForRequest(model); + _pdkPayjoinDatasource.startListeningForRequest(model); } else if (model.proposalPsbt == null) { // If the original tx bytes are present but the proposal psbt is not, // it means the receiver has already received a payjoin request and @@ -449,7 +449,7 @@ class PayjoinRepositoryImpl implements PayjoinRepository { if (model.proposalPsbt == null) { // If the proposal psbt is not present, it means the sender needs to // listen for a payjoin proposal from the receiver. - await _pdkPayjoinDatasource.startListeningForProposal(model); + _pdkPayjoinDatasource.startListeningForProposal(model); } else { // If the proposal psbt is present, it means a payjoin proposal was // already received and it should be processed. From 5b7941865cc8b8d0ce9acca401211cab1df73a9d Mon Sep 17 00:00:00 2001 From: ethicnology Date: Mon, 13 Jul 2026 17:44:33 +0000 Subject: [PATCH 17/28] fix(payjoin): guard polls against overlap, stranding and missed expiry --- .../datasources/pdk_payjoin_datasource.dart | 47 ++++++++++++++++--- lib/core/payjoin/payjoin_locator.dart | 11 ++++- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart index c5b608a089..6451f7cf87 100644 --- a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart +++ b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart @@ -25,6 +25,13 @@ class PdkPayjoinDatasource { final Map _receiverTimers = {}; final Map _senderTimers = {}; + // In-flight guards: Timer.periodic doesn't await its async callback, so a + // slow poll (e.g. an unresponsive OHTTP relay) could otherwise overlap with + // the next tick and process the same session twice — double-emitting the + // request/proposal and cancelling the payjoin downstream. + final Set _receiverPollsInFlight = {}; + final Set _senderPollsInFlight = {}; + PdkPayjoinDatasource({ this._payjoinDirectoryUrl = PayjoinConstants.directoryUrl, required this._dio, @@ -559,8 +566,16 @@ class PdkPayjoinDatasource { PayjoinReceiverModel receiverModel, Timer timer, ) async { + if (!_receiverPollsInFlight.add(receiverModel.id)) return; log('[receiver poll] checking for request for ${receiverModel.id}'); try { + // Local expiry backstop: don't rely solely on the PDK surfacing an + // "expired" error — bound polling by the session's own expiry time. + if (receiverModel.isExpiryTimePassed) { + throw PayjoinExpiredException( + 'Payjoin receiver ${receiverModel.id} expiry time passed', + ); + } final persister = InMemoryJsonReceiverSessionPersister.fromJson( receiverModel.receiver, ); @@ -584,9 +599,6 @@ class PdkPayjoinDatasource { return; } - timer.cancel(); - _receiverTimers.remove(receiverModel.id); - final maybeInputsOwned = unchecked.assumeInteractiveReceiver().save( persister: persister, ); @@ -606,14 +618,23 @@ class PdkPayjoinDatasource { originalTxId: originalTx.txid, amountSat: amountSat, ); + // Only stop polling and emit once all fallible work has succeeded: a + // throw above leaves the timer armed so the next tick retries. Skip + // the emit if this session's polling was stopped in the meantime. + if (!timer.isActive) return; + timer.cancel(); + _receiverTimers.remove(receiverModel.id); _payjoinRequestedController.add(updatedModel); } on PayjoinExpiredException catch (e) { logger.log.info('[receiver poll] expired for ${receiverModel.id}: $e'); + if (!timer.isActive) return; timer.cancel(); _receiverTimers.remove(receiverModel.id); _expiredController.add(receiverModel.copyWith(isExpired: true)); } catch (e) { logger.log.info('[receiver poll] ${receiverModel.id}: $e'); + } finally { + _receiverPollsInFlight.remove(receiverModel.id); } } @@ -621,8 +642,16 @@ class PdkPayjoinDatasource { PayjoinSenderModel senderModel, Timer timer, ) async { + if (!_senderPollsInFlight.add(senderModel.id)) return; log('[sender poll] checking for proposal for ${senderModel.id}'); try { + // Local expiry backstop: don't rely solely on the PDK surfacing an + // "expired" error — bound polling by the session's own expiry time. + if (senderModel.isExpiryTimePassed) { + throw PayjoinExpiredException( + 'Payjoin sender ${senderModel.id} expiry time passed', + ); + } final persister = InMemoryJsonSenderSessionPersister.fromJson( senderModel.sender, ); @@ -640,9 +669,6 @@ class PdkPayjoinDatasource { final proposalPsbt = await _getProposalPsbt(state.inner, persister); if (proposalPsbt == null) return; - timer.cancel(); - _senderTimers.remove(senderModel.id); - log('[sender poll] proposal found for ${senderModel.id}'); final txId = (await BitcoinTx.fromPsbt(proposalPsbt)).txid; final updatedModel = senderModel.copyWith( @@ -650,14 +676,23 @@ class PdkPayjoinDatasource { proposalPsbt: proposalPsbt, txId: txId, ); + // Only stop polling and emit once all fallible work has succeeded: a + // throw above leaves the timer armed so the next tick retries. Skip + // the emit if this session's polling was stopped in the meantime. + if (!timer.isActive) return; + timer.cancel(); + _senderTimers.remove(senderModel.id); _proposalSentController.add(updatedModel); } on PayjoinExpiredException catch (e) { logger.log.info('[sender poll] expired for ${senderModel.id}: $e'); + if (!timer.isActive) return; timer.cancel(); _senderTimers.remove(senderModel.id); _expiredController.add(senderModel.copyWith(isExpired: true)); } catch (e) { logger.log.info('[sender poll] ${senderModel.id}: $e'); + } finally { + _senderPollsInFlight.remove(senderModel.id); } } diff --git a/lib/core/payjoin/payjoin_locator.dart b/lib/core/payjoin/payjoin_locator.dart index c2ebd6b142..056a2bb746 100644 --- a/lib/core/payjoin/payjoin_locator.dart +++ b/lib/core/payjoin/payjoin_locator.dart @@ -27,7 +27,16 @@ class PayjoinLocator { ); locator.registerLazySingleton( - () => PdkPayjoinDatasource(dio: Dio()), + // Timeouts bound how long a single directory/relay poll can hang, so a + // slow OHTTP relay can't hold a polling session in flight for long. + () => PdkPayjoinDatasource( + dio: Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 30), + ), + ), + ), ); } From 135dd687371682ee6b87c88a2e74f313aac986fa Mon Sep 17 00:00:00 2001 From: ethicnology Date: Mon, 13 Jul 2026 17:45:37 +0000 Subject: [PATCH 18/28] fix(wallet): restrict payjoin psbt signer to standard sighashes --- .../data/datasources/bdk_wallet_datasource.dart | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/core/wallet/data/datasources/bdk_wallet_datasource.dart b/lib/core/wallet/data/datasources/bdk_wallet_datasource.dart index ada0b670e3..7cc14e5ae6 100644 --- a/lib/core/wallet/data/datasources/bdk_wallet_datasource.dart +++ b/lib/core/wallet/data/datasources/bdk_wallet_datasource.dart @@ -153,24 +153,29 @@ class BdkWalletDatasource { } /// Returns a synchronous PSBT signer bound to a pre-loaded private bdk - /// wallet. + /// wallet. Uses the same sign options as [signPsbt] — in particular + /// `allowAllSighashes: false`, since this signs the wallet's contribution + /// to an externally-supplied transaction (a payjoin proposal). Future createPsbtSigner({ required PrivateBdkWalletModel wallet, }) async { final bdkWallet = await BdkFacade.createPrivateWallet(wallet); return (String psbtBase64) { final psbt = bdk.Psbt(psbtBase64: psbtBase64); - bdkWallet.sign( + final isFinalized = bdkWallet.sign( psbt: psbt, signOptions: bdk.SignOptions( trustWitnessUtxo: true, assumeHeight: null, - allowAllSighashes: true, + allowAllSighashes: false, tryFinalize: true, signWithTapInternalKey: false, allowGrinding: true, ), ); + if (!isFinalized) { + log.info('Signed PSBT is not finalized'); + } return psbt.serialize(); }; } From 4d7aad48370e5614880fcf4b2234d11b30a013c2 Mon Sep 17 00:00:00 2001 From: ethicnology Date: Mon, 13 Jul 2026 17:48:21 +0000 Subject: [PATCH 19/28] fix(wallet): extend CryptoProvider race guard to dry scans --- .../datasources/bdk_wallet_datasource.dart | 64 +++++++++++-------- 1 file changed, 37 insertions(+), 27 deletions(-) diff --git a/lib/core/wallet/data/datasources/bdk_wallet_datasource.dart b/lib/core/wallet/data/datasources/bdk_wallet_datasource.dart index 7cc14e5ae6..49568c11b3 100644 --- a/lib/core/wallet/data/datasources/bdk_wallet_datasource.dart +++ b/lib/core/wallet/data/datasources/bdk_wallet_datasource.dart @@ -787,30 +787,13 @@ Future _performFullScan(_SyncParams params) async { ); final bdkWallet = await BdkFacade.createWallet(wallet); - // Guard against a rustls CryptoProvider install race across concurrent - // sync isolates. electrum-client's install_default check+install is not - // atomic, so two isolates can both see "not installed" and the loser - // fails. On retry the provider is already installed and the check - // short-circuits. - bdk.ElectrumClient buildClient() => bdk.ElectrumClient( + final blockchain = _createElectrumClient( url: params.electrumUrl, - socks5: params.electrumSocks5?.isNotEmpty == true - ? params.electrumSocks5 - : null, - timeout: params.electrumTimeout.clamp(0, 255), - retry: params.electrumRetry.clamp(0, 255), + socks5: params.electrumSocks5, + timeout: params.electrumTimeout, + retry: params.electrumRetry, validateDomain: params.electrumValidateDomain, ); - bdk.ElectrumClient blockchain; - try { - blockchain = buildClient(); - } on bdk.CouldNotCreateConnectionElectrumException catch (e) { - if (e.errorMessage.contains('Failed to install CryptoProvider')) { - blockchain = buildClient(); - } else { - rethrow; - } - } try { final scanRequest = bdkWallet.startFullScan().build(); final update = blockchain.fullScan( @@ -948,13 +931,11 @@ Future<({BigInt satoshis, int transactions})> _performDryScan( lookahead: 0, ); - final blockchain = bdk.ElectrumClient( + final blockchain = _createElectrumClient( url: params.electrumUrl, - socks5: params.electrumSocks5?.isNotEmpty == true - ? params.electrumSocks5 - : null, - timeout: params.electrumTimeout.clamp(0, 255), - retry: params.electrumRetry.clamp(0, 255), + socks5: params.electrumSocks5, + timeout: params.electrumTimeout, + retry: params.electrumRetry, validateDomain: params.electrumValidateDomain, ); @@ -982,3 +963,32 @@ Future<({BigInt satoshis, int transactions})> _performDryScan( } int _batchSizeFor(int stopGap) => (stopGap ~/ 4).clamp(50, 1000); + +/// Creates a [bdk.ElectrumClient], retrying once on the rustls CryptoProvider +/// install race across concurrent isolates (full scan, dry scan, sync). +/// electrum-client's install_default check+install is not atomic, so two +/// isolates can both see "not installed" and the loser fails. On retry the +/// provider is already installed and the check short-circuits. +bdk.ElectrumClient _createElectrumClient({ + required String url, + required String? socks5, + required int timeout, + required int retry, + required bool validateDomain, +}) { + bdk.ElectrumClient build() => bdk.ElectrumClient( + url: url, + socks5: socks5?.isNotEmpty == true ? socks5 : null, + timeout: timeout.clamp(0, 255), + retry: retry.clamp(0, 255), + validateDomain: validateDomain, + ); + try { + return build(); + } on bdk.CouldNotCreateConnectionElectrumException catch (e) { + if (e.errorMessage.contains('Failed to install CryptoProvider')) { + return build(); + } + rethrow; + } +} From fe8b71261045aa7a44637814ae874e2a57e1d6c8 Mon Sep 17 00:00:00 2001 From: ethicnology Date: Mon, 13 Jul 2026 17:49:36 +0000 Subject: [PATCH 20/28] refactor(payjoin): surface underlying errors in datasource logs --- .../datasources/pdk_payjoin_datasource.dart | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart index 6451f7cf87..0e39ed3c02 100644 --- a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart +++ b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart @@ -58,6 +58,7 @@ class PdkPayjoinDatasource { ); return (ohttpKeys, ohttpRelayUrl); } catch (e) { + log('fetchOhttpKeys via $ohttpRelayUrl failed: $e'); continue; } } @@ -456,7 +457,12 @@ class PdkPayjoinDatasource { try { chosen = inner.tryPreservingPrivacy(candidateInputs: candidates); } catch (e) { - throw StateError('No inputs available to contribute to payjoin'); + // Include the PDK's own rejection reason (e.g. "no candidates + // available for selection" when none of the wallet's UTXOs make a + // privacy-preserving decoy for this payment) rather than a fixed + // generic message, so callers/logs can tell this apart from a genuine + // bug in candidate construction. + throw StateError('No inputs available to contribute to payjoin: $e'); } final next = inner .contributeInputs(replacementInputs: [chosen]) @@ -902,6 +908,13 @@ List _decodeEvents(String? raw) { if (decoded is List) { return decoded.cast(); } - } catch (_) {} + logger.log.warning( + 'Persisted payjoin session event log is not a list; starting empty', + ); + } catch (e) { + logger.log.warning( + 'Failed to decode persisted payjoin session event log: $e', + ); + } return const []; } From db48deacdb84336aad26373d7b40ca8c6490c5d3 Mon Sep 17 00:00:00 2001 From: ethicnology Date: Mon, 13 Jul 2026 17:51:27 +0000 Subject: [PATCH 21/28] test(payjoin): unit-test session persisters and relay fallback --- .../datasources/pdk_payjoin_datasource.dart | 17 +- .../payjoin/pdk_payjoin_datasource_test.dart | 287 ++++++++++++++++++ 2 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 test/core_test/payjoin/pdk_payjoin_datasource_test.dart diff --git a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart index 0e39ed3c02..5302f92371 100644 --- a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart +++ b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart @@ -14,9 +14,20 @@ import 'package:dio/dio.dart'; import 'package:payjoin/payjoin.dart'; import 'package:payjoin/http.dart' show fetchOhttpKeys; +/// Fetches the OHTTP key config published by [directoryUrl] through +/// [ohttpRelayUrl]. Matches the signature of `payjoin/http.dart`'s top-level +/// `fetchOhttpKeys`, which is the default implementation used in production; +/// tests can inject a fake to exercise the relay fallback logic offline. +typedef OhttpKeysFetcher = + Future Function({ + required String ohttpRelayUrl, + required String directoryUrl, + }); + class PdkPayjoinDatasource { final String _payjoinDirectoryUrl; final Dio _dio; + final OhttpKeysFetcher _fetchOhttpKeys; final StreamController _payjoinRequestedController; final StreamController _proposalSentController; final StreamController _expiredController; @@ -35,7 +46,9 @@ class PdkPayjoinDatasource { PdkPayjoinDatasource({ this._payjoinDirectoryUrl = PayjoinConstants.directoryUrl, required this._dio, - }) : _payjoinRequestedController = StreamController.broadcast(), + OhttpKeysFetcher ohttpKeysFetcher = fetchOhttpKeys, + }) : _fetchOhttpKeys = ohttpKeysFetcher, + _payjoinRequestedController = StreamController.broadcast(), _proposalSentController = StreamController.broadcast(), _expiredController = StreamController.broadcast(); @@ -52,7 +65,7 @@ class PdkPayjoinDatasource { }) async { for (final ohttpRelayUrl in PayjoinConstants.ohttpRelayUrls) { try { - final ohttpKeys = await fetchOhttpKeys( + final ohttpKeys = await _fetchOhttpKeys( ohttpRelayUrl: ohttpRelayUrl, directoryUrl: payjoinDirectory, ); diff --git a/test/core_test/payjoin/pdk_payjoin_datasource_test.dart b/test/core_test/payjoin/pdk_payjoin_datasource_test.dart new file mode 100644 index 0000000000..acd8d1022d --- /dev/null +++ b/test/core_test/payjoin/pdk_payjoin_datasource_test.dart @@ -0,0 +1,287 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:bb_mobile/core/payjoin/data/datasources/pdk_payjoin_datasource.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:payjoin/payjoin.dart'; + +// A valid, statically-generated OHTTP key config (offline test fixture, no +// real relay behind it). Generated once via the vendored `bitcoin-ohttp` +// crate sources (`KeyConfig::new(...).encode()`) so it decodes with +// `OhttpKeys.decode` without any network access; see the PR description for +// how to regenerate it if the wire format ever changes. +final _ohttpKeysBytes = Uint8List.fromList([ + 0x01, + 0x00, + 0x16, + 0x04, + 0x52, + 0x73, + 0xda, + 0xa8, + 0xf6, + 0x4c, + 0xdf, + 0x80, + 0x8f, + 0x1e, + 0x94, + 0x03, + 0x98, + 0x09, + 0xf6, + 0x47, + 0xfc, + 0x21, + 0xca, + 0x68, + 0xb2, + 0xbd, + 0x31, + 0xe7, + 0x30, + 0xa9, + 0xc5, + 0x7d, + 0xf3, + 0x68, + 0x84, + 0xb2, + 0xe7, + 0x82, + 0x6e, + 0x2f, + 0xa1, + 0xad, + 0x2b, + 0x9f, + 0x23, + 0x88, + 0x15, + 0x76, + 0x5a, + 0xa6, + 0x7b, + 0x1f, + 0x56, + 0xcc, + 0x72, + 0xc6, + 0x69, + 0x83, + 0x40, + 0x69, + 0x89, + 0x86, + 0x87, + 0x80, + 0xee, + 0x59, + 0x9b, + 0x1f, + 0x00, + 0x04, + 0x00, + 0x01, + 0x00, + 0x03, +]); + +OhttpKeys _fakeOhttpKeys() => OhttpKeys.decode(bytes: _ohttpKeysBytes); + +void main() { + group('InMemoryJsonReceiverSessionPersister', () { + test('save appends events and load returns them in order', () { + final persister = InMemoryJsonReceiverSessionPersister(); + + persister.save('a'); + persister.save('b'); + + expect(persister.load(), ['a', 'b']); + expect(persister.events, ['a', 'b']); + }); + + test('events is unmodifiable', () { + final persister = InMemoryJsonReceiverSessionPersister(); + persister.save('a'); + + expect(() => persister.events.add('b'), throwsUnsupportedError); + }); + + test('close marks the persister as closed', () { + final persister = InMemoryJsonReceiverSessionPersister(); + + expect(persister.isClosed, isFalse); + persister.close(); + expect(persister.isClosed, isTrue); + }); + + test('toJson/fromJson round-trips the event log', () { + final persister = InMemoryJsonReceiverSessionPersister(); + persister.save('a'); + persister.save('b'); + + final restored = InMemoryJsonReceiverSessionPersister.fromJson( + persister.toJson(), + ); + + expect(restored.load(), ['a', 'b']); + }); + + test('fromJson(null) starts with an empty event log', () { + final persister = InMemoryJsonReceiverSessionPersister.fromJson(null); + + expect(persister.load(), isEmpty); + }); + + test('fromJson gracefully handles malformed input', () { + final notJson = InMemoryJsonReceiverSessionPersister.fromJson( + 'not valid json', + ); + final notAList = InMemoryJsonReceiverSessionPersister.fromJson( + jsonEncode({'not': 'a list'}), + ); + + expect(notJson.load(), isEmpty); + expect(notAList.load(), isEmpty); + }); + }); + + group('InMemoryJsonSenderSessionPersister', () { + test('save appends events and load returns them in order', () { + final persister = InMemoryJsonSenderSessionPersister(); + + persister.save('x'); + persister.save('y'); + + expect(persister.load(), ['x', 'y']); + expect(persister.events, ['x', 'y']); + }); + + test('events is unmodifiable', () { + final persister = InMemoryJsonSenderSessionPersister(); + persister.save('x'); + + expect(() => persister.events.add('y'), throwsUnsupportedError); + }); + + test('close marks the persister as closed', () { + final persister = InMemoryJsonSenderSessionPersister(); + + expect(persister.isClosed, isFalse); + persister.close(); + expect(persister.isClosed, isTrue); + }); + + test('toJson/fromJson round-trips the event log', () { + final persister = InMemoryJsonSenderSessionPersister(); + persister.save('x'); + persister.save('y'); + + final restored = InMemoryJsonSenderSessionPersister.fromJson( + persister.toJson(), + ); + + expect(restored.load(), ['x', 'y']); + }); + + test('fromJson(null) starts with an empty event log', () { + final persister = InMemoryJsonSenderSessionPersister.fromJson(null); + + expect(persister.load(), isEmpty); + }); + + test('fromJson gracefully handles malformed input', () { + final notJson = InMemoryJsonSenderSessionPersister.fromJson( + 'not valid json', + ); + final notAList = InMemoryJsonSenderSessionPersister.fromJson( + jsonEncode({'not': 'a list'}), + ); + + expect(notJson.load(), isEmpty); + expect(notAList.load(), isEmpty); + }); + }); + + group('PdkPayjoinDatasource.fetchOhttpKeyAndRelay', () { + // These exercise the multi-relay fallback loop with an injected fetcher, + // entirely offline: PayjoinConstants.ohttpRelayUrls is shuffled on every + // access (see lib/core/utils/constants.dart), so the loop's *order* isn't + // asserted here, only its "try until one works, else give up" contract. + + test('returns the keys and relay for the relay that succeeds', () async { + final keys = _fakeOhttpKeys(); + final datasource = PdkPayjoinDatasource( + dio: Dio(), + ohttpKeysFetcher: + ({ + required String ohttpRelayUrl, + required String directoryUrl, + }) async { + if (ohttpRelayUrl != 'https://pj.bobspacebkk.com') { + throw Exception('relay unavailable: $ohttpRelayUrl'); + } + return keys; + }, + ); + + final (resultKeys, resultRelay) = await datasource.fetchOhttpKeyAndRelay( + payjoinDirectory: 'https://payjo.in', + ); + + expect(resultKeys, same(keys)); + expect(resultRelay, 'https://pj.bobspacebkk.com'); + }); + + test('returns (null, null) when every relay fails', () async { + final attemptedRelays = {}; + final datasource = PdkPayjoinDatasource( + dio: Dio(), + ohttpKeysFetcher: + ({ + required String ohttpRelayUrl, + required String directoryUrl, + }) async { + attemptedRelays.add(ohttpRelayUrl); + throw Exception('relay unavailable: $ohttpRelayUrl'); + }, + ); + + final (resultKeys, resultRelay) = await datasource.fetchOhttpKeyAndRelay( + payjoinDirectory: 'https://payjo.in', + ); + + expect(resultKeys, isNull); + expect(resultRelay, isNull); + // Every known relay must have been attempted before giving up. + expect(attemptedRelays, { + 'https://ohttp.achow101.com', + 'https://pj.bobspacebkk.com', + 'https://ohttp.cakewallet.com', + }); + }); + + test('passes the requested directory URL through to the fetcher', () async { + final seenDirectories = {}; + final datasource = PdkPayjoinDatasource( + dio: Dio(), + ohttpKeysFetcher: + ({ + required String ohttpRelayUrl, + required String directoryUrl, + }) async { + seenDirectories.add(directoryUrl); + throw Exception('relay unavailable: $ohttpRelayUrl'); + }, + ); + + await datasource.fetchOhttpKeyAndRelay( + payjoinDirectory: 'https://my-directory.example', + ); + + expect(seenDirectories, {'https://my-directory.example'}); + }); + }); +} From 1215ed5f1a9db488b65bde7180176a6637f0e28e Mon Sep 17 00:00:00 2001 From: ethicnology Date: Mon, 13 Jul 2026 17:52:26 +0000 Subject: [PATCH 22/28] test(payjoin): keep integration wallets payjoin-capable across runs --- integration_test/payjoin_test.dart | 210 +++++++++++++++++++---------- 1 file changed, 138 insertions(+), 72 deletions(-) diff --git a/integration_test/payjoin_test.dart b/integration_test/payjoin_test.dart index 1cdeb2f648..419184b2a0 100644 --- a/integration_test/payjoin_test.dart +++ b/integration_test/payjoin_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io' show Platform; +import 'package:bb_mobile/core/blockchain/domain/usecases/broadcast_bitcoin_transaction_usecase.dart'; import 'package:bb_mobile/core/fees/domain/fees_entity.dart'; import 'package:bb_mobile/core/payjoin/data/datasources/local_payjoin_datasource.dart'; import 'package:bb_mobile/core/payjoin/domain/entity/payjoin.dart'; @@ -15,6 +16,7 @@ import 'package:bb_mobile/core/wallet/data/repositories/wallet_repository.dart'; import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart'; import 'package:bb_mobile/core/wallet/domain/repositories/wallet_utxo_repository.dart'; import 'package:bb_mobile/core/wallet/domain/usecases/prepare_bitcoin_send_usecase.dart'; +import 'package:bb_mobile/features/send/domain/usecases/sign_bitcoin_tx_usecase.dart'; import 'package:bb_mobile/features/settings/domain/usecases/set_environment_usecase.dart'; import 'package:bb_mobile/locator.dart'; import 'package:bb_mobile/main.dart'; @@ -39,9 +41,63 @@ Future main({bool isInitialized = false}) async { final receiveWithPayjoinUsecase = locator(); final sendWithPayjoinUsecase = locator(); final prepareBitcoinSendUsecase = locator(); + final signBitcoinTxUsecase = locator(); + final broadcastBitcoinTxUsecase = + locator(); + + // Sweeps every UTXO of [walletId] into a single fresh one. rust-payjoin's + // `WantsInputs::try_preserving_privacy` (the receiver's decoy-input + // selection) has a real upstream bug: its documented fallback ("a simple + // consolidation is otherwise chosen if available") never actually runs, + // because the UIH-avoidance pass it falls back from already drains the + // candidate iterator before returning its error — see + // `payjoin::receive::v1::WantsInputs::avoid_uih`/`select_first_candidate` + // (payjoin crate 0.23.0). So a receiver wallet whose UTXO set is a + // scattered mix of dust and mismatched sizes (which is exactly what this + // test's own repeated runs otherwise produce over time) can permanently + // fail to find *any* selection, even though the wallet holds plenty of + // funds. Keeping both wallets down to a single UTXO sidesteps needing that + // broken fallback at all, by giving the heuristic a single, unambiguous, + // appropriately-sized candidate up front. + Future consolidateUtxos(String walletId) async { + final utxos = await utxoRepository.getWalletUtxos(walletId: walletId); + if (utxos.length <= 1) return; + + final selfAddress = await addressRepository.generateNewReceiveAddress( + walletId: walletId, + ); + final prepared = await prepareBitcoinSendUsecase.execute( + walletId: walletId, + address: selfAddress.address, + drain: true, + networkFee: NetworkFee.relativeFromSatPerVbyte(1000.0), + ); + final signed = await signBitcoinTxUsecase.execute( + psbt: prepared.unsignedPsbt, + walletId: walletId, + ); + await broadcastBitcoinTxUsecase.execute(signed.signedPsbt, isPsbt: true); - final receiverMnemonic = Platform.environment['TEST_ALICE_MNEMONIC']; - final senderMnemonic = Platform.environment['TEST_BOB_MNEMONIC']; + // Give the new consolidated utxo a moment to be visible on next sync. + await Future.delayed(const Duration(seconds: 3)); + await walletRepository.getWallets(sync: true); + } + + // The funded-testnet mnemonics come from the environment, via two channels: + // - CI / desktop (`-d linux`): real process env vars, read through + // Platform.environment (see analyze_and_test.yml, which exports them + // from repo secrets). + // - Physical device (`-d `): the app process does NOT inherit + // the CLI's env, so pass them as --dart-define instead, read through + // String.fromEnvironment (compile-time constants baked into the build). + const aliceDefine = String.fromEnvironment('TEST_ALICE_MNEMONIC'); + const bobDefine = String.fromEnvironment('TEST_BOB_MNEMONIC'); + final receiverMnemonic = aliceDefine.isNotEmpty + ? aliceDefine + : Platform.environment['TEST_ALICE_MNEMONIC']; + final senderMnemonic = bobDefine.isNotEmpty + ? bobDefine + : Platform.environment['TEST_BOB_MNEMONIC']; if (receiverMnemonic == null || receiverMnemonic.isEmpty) { throw Exception('TEST_ALICE_MNEMONIC environment variable is not set'); @@ -168,80 +224,90 @@ Future main({bool isInitialized = false}) async { }); }); - test('should work with one receiver and one sender', () async { - // Generate receiver address - final address = await addressRepository.generateNewReceiveAddress( - walletId: receiverWallet.id, - ); - debugPrint('Receive address generated: ${address.address}'); - - // Start a receiver session - final payjoin = await receiveWithPayjoinUsecase.execute( - walletId: receiverWallet.id, - address: address.address, - ); - debugPrint('Payjoin receiver created: ${payjoin.id}'); + test( + 'should work with one receiver and one sender', + () async { + // See consolidateUtxos' doc comment: give the receiver's decoy-input + // selection an unambiguous, appropriately-sized candidate so it + // doesn't hit the rust-payjoin fallback-selection bug. + await consolidateUtxos(receiverWallet.id); + await consolidateUtxos(senderWallet.id); + + // Generate receiver address + final address = await addressRepository.generateNewReceiveAddress( + walletId: receiverWallet.id, + ); + debugPrint('Receive address generated: ${address.address}'); - expect(payjoin.status, PayjoinStatus.started); - // Check that the payjoin uri is correct - final pjUri = Uri.parse(payjoin.pjUri); - expect(pjUri.scheme, 'bitcoin'); - expect(pjUri.path, address.address); - expect(pjUri.queryParameters.containsKey('pj'), true); - - // Build the psbt with the sender wallet - const amountSat = 10000; - const networkFeesSatPerVb = 1000.0; - final preparedBitcoinSend = await prepareBitcoinSendUsecase.execute( - walletId: senderWallet.id, - address: address.address, - amountSat: amountSat, - networkFee: NetworkFee.relativeFromSatPerVbyte(networkFeesSatPerVb), - ); + // Start a receiver session + final payjoin = await receiveWithPayjoinUsecase.execute( + walletId: receiverWallet.id, + address: address.address, + ); + debugPrint('Payjoin receiver created: ${payjoin.id}'); + + expect(payjoin.status, PayjoinStatus.started); + // Check that the payjoin uri is correct + final pjUri = Uri.parse(payjoin.pjUri); + expect(pjUri.scheme, 'bitcoin'); + expect(pjUri.path, address.address); + expect(pjUri.queryParameters.containsKey('pj'), true); + + // Build the psbt with the sender wallet + const amountSat = 10000; + const networkFeesSatPerVb = 1000.0; + final preparedBitcoinSend = await prepareBitcoinSendUsecase.execute( + walletId: senderWallet.id, + address: address.address, + amountSat: amountSat, + networkFee: NetworkFee.relativeFromSatPerVbyte(networkFeesSatPerVb), + ); - final payjoinSender = await sendWithPayjoinUsecase.execute( - walletId: senderWallet.id, - isTestnet: senderWallet.isTestnet, - bip21: pjUri.toString(), - unsignedOriginalPsbt: preparedBitcoinSend.unsignedPsbt, - amountSat: amountSat, - networkFeesSatPerVb: networkFeesSatPerVb, - ); - debugPrint('Payjoin sender created: ${payjoinSender.id}'); - expect(payjoinSender.status, PayjoinStatus.requested); - - // Once the request is sent by the sender, it is automatically fetched - // by the receiver the next time it polls the payjoin directory. - // The receiver will process the request automatically and sends a - // payjoin proposal back to the payjoin directory which should complete - // the payjoin session for the receiver's side. - final didReceiverPropose = await Future.any([ - payjoinReceiverProposedEvent.future, - Future.delayed( - const Duration( - seconds: PayjoinConstants.directoryPollingInterval * 3, + final payjoinSender = await sendWithPayjoinUsecase.execute( + walletId: senderWallet.id, + isTestnet: senderWallet.isTestnet, + bip21: pjUri.toString(), + unsignedOriginalPsbt: preparedBitcoinSend.unsignedPsbt, + amountSat: amountSat, + networkFeesSatPerVb: networkFeesSatPerVb, + ); + debugPrint('Payjoin sender created: ${payjoinSender.id}'); + expect(payjoinSender.status, PayjoinStatus.requested); + + // Once the request is sent by the sender, it is automatically fetched + // by the receiver the next time it polls the payjoin directory. + // The receiver will process the request automatically and sends a + // payjoin proposal back to the payjoin directory which should complete + // the payjoin session for the receiver's side. + final didReceiverPropose = await Future.any([ + payjoinReceiverProposedEvent.future, + Future.delayed( + const Duration( + seconds: PayjoinConstants.directoryPollingInterval * 3, + ), + () => false, ), - () => false, - ), - ]); - expect(didReceiverPropose, true); - - // Once the proposal is sent by the receiver, it is automatically fetched - // by the sender the next time it polls the payjoin directory. - // The sender will process the proposal automatically and broadcast the - // final transaction to the network which should complete the payjoin - // session for the sender's side. - final didSenderComplete = await Future.any([ - payjoinSenderCompletedEvent.future, - Future.delayed( - const Duration( - seconds: PayjoinConstants.directoryPollingInterval * 3, + ]); + expect(didReceiverPropose, true); + + // Once the proposal is sent by the receiver, it is automatically fetched + // by the sender the next time it polls the payjoin directory. + // The sender will process the proposal automatically and broadcast the + // final transaction to the network which should complete the payjoin + // session for the sender's side. + final didSenderComplete = await Future.any([ + payjoinSenderCompletedEvent.future, + Future.delayed( + const Duration( + seconds: PayjoinConstants.directoryPollingInterval * 3, + ), + () => false, ), - () => false, - ), - ]); - expect(didSenderComplete, true); - }); + ]); + expect(didSenderComplete, true); + }, + timeout: const Timeout(Duration(seconds: 120)), + ); test('should successfully resume after a restart', () {}); From dc2cca577fd6159002ba62db2fd93cf84bab518f Mon Sep 17 00:00:00 2001 From: ethicnology Date: Mon, 13 Jul 2026 23:11:44 +0000 Subject: [PATCH 23/28] build(deps): bump payjoin to 0.1.2 The 0.1.2 bindings use the latest uniffi-dart (uniffi 0.31.2), which restores reproducible native builds. --- pubspec.lock | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index db630d9729..032dcd5a87 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1537,10 +1537,10 @@ packages: dependency: "direct main" description: name: payjoin - sha256: a2047b2f2ecb40543f1a8b0d5ea93cf377151ac72463df4096aa73a443cef42e + sha256: "2b8e3f2c0f78e2054fcee9c25a8ce670df3fb4bec7ea66c4524dbac5e5b654eb" url: "https://pub.dev" source: hosted - version: "0.1.1" + version: "0.1.2" permission_handler: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 6855c52a60..152b5948e4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,7 +36,7 @@ dependencies: path: flutter_secure_storage path_provider: ^2.1.5 shared_preferences: ^2.3.0 - payjoin: ^0.1.1 + payjoin: ^0.1.2 qr_flutter: ^4.1.0 dio: ^5.9.0 timeago: ^3.7.1 From 3ea1abb2a10f2917be160d5e5e232e56a1f10b6e Mon Sep 17 00:00:00 2001 From: ethicnology Date: Mon, 13 Jul 2026 23:12:18 +0000 Subject: [PATCH 24/28] fix(payjoin,wallet): address review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _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. --- .../datasources/pdk_payjoin_datasource.dart | 64 ++++++++++++++----- .../data/models/payjoin_input_pair_model.dart | 1 - .../repository/payjoin_repository_impl.dart | 2 +- .../datasources/bdk_wallet_datasource.dart | 12 ++-- 4 files changed, 58 insertions(+), 21 deletions(-) diff --git a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart index 5302f92371..b1a6513f20 100644 --- a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart +++ b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:convert'; import 'dart:developer'; -import 'dart:typed_data'; import 'package:bb_mobile/core/errors/bull_exception.dart'; import 'package:bb_mobile/core/payjoin/data/models/payjoin_input_pair_model.dart'; @@ -11,6 +10,7 @@ import 'package:bb_mobile/core/utils/constants.dart'; import 'package:bb_mobile/core/utils/logger.dart' as logger; import 'package:crypto/crypto.dart'; import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import 'package:payjoin/payjoin.dart'; import 'package:payjoin/http.dart' show fetchOhttpKeys; @@ -126,7 +126,12 @@ class PdkPayjoinDatasource { ) as PayjoinReceiverModel; - // Start listening for a payjoin request from the sender + // Start listening for a payjoin request from the sender. This starts + // polling before the repository persists `model` to the local DB + // (PayjoinRepositoryImpl.createPayjoinReceiver awaits this call, then + // stores the result) — benign today since the first tick is a full + // directoryPollingInterval away, giving the upsert plenty of time, but + // fragile enough to flag: don't start polling any earlier than this. startListeningForRequest(model); return model; @@ -185,7 +190,9 @@ class PdkPayjoinDatasource { ) as PayjoinSenderModel; - // Start listening for a payjoin proposal from the receiver + // Start listening for a payjoin proposal from the receiver. Same + // ordering caveat as startListeningForRequest above: this runs before + // the repository persists `model`, benign given the polling interval. startListeningForProposal(model); return model; @@ -200,7 +207,7 @@ class PdkPayjoinDatasource { for (final relay in PayjoinConstants.ohttpRelayUrls) { try { final reqCtx = withReplyKey.createV2PostRequest(ohttpRelay: relay); - final body = await _postBytes( + final body = await postBytes( _dio, reqCtx.request.url, reqCtx.request.body, @@ -218,6 +225,10 @@ class PdkPayjoinDatasource { } } if (!posted) { + logger.log.warning( + 'Failed to post original PSBT to any OHTTP relay', + error: lastError, + ); throw SendCreationException( 'Failed to post original PSBT to any OHTTP relay: $lastError', ); @@ -257,7 +268,7 @@ class PdkPayjoinDatasource { ); logger.log.info( - 'Payjoin request processed and proposal sent for ${receiverModel.id}: $proposalPsbt', + 'Payjoin request processed and proposal sent for ${receiverModel.id}', ); return updatedModel; @@ -518,7 +529,7 @@ class PdkPayjoinDatasource { for (final relay in PayjoinConstants.ohttpRelayUrls) { try { final req = proposal.createPostRequest(ohttpRelay: relay); - final body = await _postBytes( + final body = await postBytes( _dio, req.request.url, req.request.body, @@ -536,12 +547,28 @@ class PdkPayjoinDatasource { continue; } } + logger.log.warning( + 'Failed to post payjoin proposal to any OHTTP relay', + error: lastError, + ); throw PayjoinNotFoundException( 'Failed to post payjoin proposal: $lastError', ); } InputPair _buildInputPair(PayjoinInputPairModel input) { + // A missing value must never silently become 0: the witness UTXO amount + // is committed in the segwit sighash, so signing over a wrong (zero) + // amount produces an invalid signature. The failure would then surface + // far away, as a generic broadcast rejection that silently cancels the + // payjoin via the original-transaction fallback. Fail loudly instead. + final value = input.value; + if (value == null) { + throw StateError( + 'Cannot build a payjoin input pair without a value for ' + '${input.txId}:${input.vout}', + ); + } return InputPair( txin: TxIn( previousOutput: OutPoint(txid: input.txId, vout: input.vout), @@ -551,7 +578,7 @@ class PdkPayjoinDatasource { ), psbtin: PsbtInput( witnessUtxo: TxOut( - valueSat: (input.value ?? BigInt.zero).toInt(), + valueSat: value.toInt(), scriptPubkey: input.scriptPubkey, ), redeemScript: input.redeemScriptRawOutputScript.isEmpty @@ -723,7 +750,7 @@ class PdkPayjoinDatasource { for (final relay in PayjoinConstants.ohttpRelayUrls) { try { final poll = initialized.createPollRequest(ohttpRelay: relay); - final body = await _postBytes( + final body = await postBytes( _dio, poll.request.url, poll.request.body, @@ -758,7 +785,7 @@ class PdkPayjoinDatasource { for (final relay in PayjoinConstants.ohttpRelayUrls) { try { final poll = polling.createPollRequest(ohttpRelay: relay); - final body = await _postBytes( + final body = await postBytes( _dio, poll.request.url, poll.request.body, @@ -793,7 +820,14 @@ class PdkPayjoinDatasource { static bool _isExpiredString(Object error) => error.toString().toLowerCase().contains('expired'); - static Future _postBytes( + /// Posts [body] to [url] via [dio] and returns the raw response bytes. The + /// single choke point every OHTTP relay call funnels through — exposed for + /// testing so the relay-loop functions' handling of a network failure + /// (including a timeout from the Dio instance's configured + /// connect/receiveTimeout, see PayjoinLocator) can be exercised directly, + /// without needing a live relay or a signed PSBT/session fixture. + @visibleForTesting + static Future postBytes( Dio dio, String url, Uint8List body, @@ -827,10 +861,6 @@ class PayjoinExpiredException extends BullException { PayjoinExpiredException(super.message); } -class OhttpRelaysUnavailableException extends BullException { - OhttpRelaysUnavailableException(super.message); -} - class SendCreationException extends BullException { SendCreationException(super.message); } @@ -919,7 +949,11 @@ List _decodeEvents(String? raw) { try { final decoded = jsonDecode(raw); if (decoded is List) { - return decoded.cast(); + // Eagerly validate every element here, inside the try/catch: `.cast()` + // is a lazy view, so a list containing a non-string entry would slip + // through uncaught and only throw later, deep inside + // replayReceiverEventLog on every poll tick. + return List.from(decoded); } logger.log.warning( 'Persisted payjoin session event log is not a list; starting empty', diff --git a/lib/core/payjoin/data/models/payjoin_input_pair_model.dart b/lib/core/payjoin/data/models/payjoin_input_pair_model.dart index 2f6c87e9c9..d563647495 100644 --- a/lib/core/payjoin/data/models/payjoin_input_pair_model.dart +++ b/lib/core/payjoin/data/models/payjoin_input_pair_model.dart @@ -17,7 +17,6 @@ abstract class PayjoinInputPairModel with _$PayjoinInputPairModel { required Uint8List scriptPubkey, @Default([]) List redeemScriptRawOutputScript, @Default([]) List witnessScriptRawOutputScript, - required, }) = _PayjoinInputPairModel; const PayjoinInputPairModel._(); diff --git a/lib/core/payjoin/data/repository/payjoin_repository_impl.dart b/lib/core/payjoin/data/repository/payjoin_repository_impl.dart index ead0b04d9e..2312e90d00 100644 --- a/lib/core/payjoin/data/repository/payjoin_repository_impl.dart +++ b/lib/core/payjoin/data/repository/payjoin_repository_impl.dart @@ -356,7 +356,7 @@ class PayjoinRepositoryImpl implements PayjoinRepository { final updatedModel = model.copyWith(isExpired: true); await _localPayjoinDatasource.update(updatedModel); // Notify the repository layers that the payjoin has expired - _payjoinStreamController.add(model.toEntity()); + _payjoinStreamController.add(updatedModel.toEntity()); } else if (model is PayjoinReceiverModel) { if (model.originalTxBytes == null) { // If the original tx bytes are not present, it means the receiver diff --git a/lib/core/wallet/data/datasources/bdk_wallet_datasource.dart b/lib/core/wallet/data/datasources/bdk_wallet_datasource.dart index 49568c11b3..7ec4cdbb86 100644 --- a/lib/core/wallet/data/datasources/bdk_wallet_datasource.dart +++ b/lib/core/wallet/data/datasources/bdk_wallet_datasource.dart @@ -162,7 +162,14 @@ class BdkWalletDatasource { final bdkWallet = await BdkFacade.createPrivateWallet(wallet); return (String psbtBase64) { final psbt = bdk.Psbt(psbtBase64: psbtBase64); - final isFinalized = bdkWallet.sign( + // Unlike signPsbt (the sender signing a complete transaction, where a + // non-finalized result is a genuine anomaly), this signs only the + // receiver's own contributed input into a multi-party payjoin + // proposal — the sender's inputs are still unsigned at this point by + // protocol design, so bdk's whole-PSBT finalization check is always + // false here. Don't log it: it isn't an error, and logging it on every + // successful payjoin would read like one. + bdkWallet.sign( psbt: psbt, signOptions: bdk.SignOptions( trustWitnessUtxo: true, @@ -173,9 +180,6 @@ class BdkWalletDatasource { allowGrinding: true, ), ); - if (!isFinalized) { - log.info('Signed PSBT is not finalized'); - } return psbt.serialize(); }; } From 49c7c0fc8caa96db75468d6588ec30ba5d589c02 Mon Sep 17 00:00:00 2001 From: ethicnology Date: Mon, 13 Jul 2026 23:12:54 +0000 Subject: [PATCH 25/28] test(payjoin): cover corrupt-log and relay-timeout edge cases 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. --- .../payjoin/pdk_payjoin_datasource_test.dart | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/test/core_test/payjoin/pdk_payjoin_datasource_test.dart b/test/core_test/payjoin/pdk_payjoin_datasource_test.dart index acd8d1022d..935f75d6ad 100644 --- a/test/core_test/payjoin/pdk_payjoin_datasource_test.dart +++ b/test/core_test/payjoin/pdk_payjoin_datasource_test.dart @@ -4,8 +4,11 @@ import 'dart:typed_data'; import 'package:bb_mobile/core/payjoin/data/datasources/pdk_payjoin_datasource.dart'; import 'package:dio/dio.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:payjoin/payjoin.dart'; +class _MockDio extends Mock implements Dio {} + // A valid, statically-generated OHTTP key config (offline test fixture, no // real relay behind it). Generated once via the vendored `bitcoin-ohttp` // crate sources (`KeyConfig::new(...).encode()`) so it decodes with @@ -142,9 +145,13 @@ void main() { final notAList = InMemoryJsonReceiverSessionPersister.fromJson( jsonEncode({'not': 'a list'}), ); + final listOfNonStrings = InMemoryJsonReceiverSessionPersister.fromJson( + jsonEncode([1, 2, 3]), + ); expect(notJson.load(), isEmpty); expect(notAList.load(), isEmpty); + expect(listOfNonStrings.load(), isEmpty); }); }); @@ -199,9 +206,13 @@ void main() { final notAList = InMemoryJsonSenderSessionPersister.fromJson( jsonEncode({'not': 'a list'}), ); + final listOfNonStrings = InMemoryJsonSenderSessionPersister.fromJson( + jsonEncode([1, 2, 3]), + ); expect(notJson.load(), isEmpty); expect(notAList.load(), isEmpty); + expect(listOfNonStrings.load(), isEmpty); }); }); @@ -284,4 +295,82 @@ void main() { expect(seenDirectories, {'https://my-directory.example'}); }); }); + + group('PdkPayjoinDatasource.postBytes', () { + // Every OHTTP relay call (in fetchOhttpKeyAndRelay's sibling relay-loop + // functions: postOriginalProposal, _getUncheckedOriginalPayload, + // _getProposalPsbt, _sendPayjoinProposal) funnels through this single + // choke point, and PayjoinLocator configures its Dio's + // connect/receiveTimeout specifically so an unresponsive relay can't + // stall a poll indefinitely. These verify the plumbing a real timeout + // exercises: postBytes neither swallows nor transforms the failure, so + // the relay loops' existing catch-and-try-next-relay handling applies to + // it exactly like any other network error — without needing a live relay + // or a signed PSBT/session fixture, which (per the receiver/sender + // typestate walk) isn't practical to construct offline. + + setUpAll(() { + registerFallbackValue(RequestOptions(path: 'https://relay.example.com')); + registerFallbackValue(Options()); + }); + + test('propagates a Dio receive-timeout unwrapped, so relay-loop callers ' + 'can catch it and fall back to the next relay', () async { + final dio = _MockDio(); + when( + () => dio.post>( + any(), + data: any(named: 'data'), + options: any(named: 'options'), + ), + ).thenThrow( + DioException( + requestOptions: RequestOptions(path: 'https://relay.example.com'), + type: DioExceptionType.receiveTimeout, + ), + ); + + expect( + () => PdkPayjoinDatasource.postBytes( + dio, + 'https://relay.example.com', + Uint8List.fromList([1, 2, 3]), + 'message/ohttp-req', + ), + throwsA( + isA().having( + (e) => e.type, + 'type', + DioExceptionType.receiveTimeout, + ), + ), + ); + }); + + test('returns the response bytes on success', () async { + final dio = _MockDio(); + when( + () => dio.post>( + any(), + data: any(named: 'data'), + options: any(named: 'options'), + ), + ).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: 'https://relay.example.com'), + data: [4, 5, 6], + statusCode: 200, + ), + ); + + final result = await PdkPayjoinDatasource.postBytes( + dio, + 'https://relay.example.com', + Uint8List.fromList([1, 2, 3]), + 'message/ohttp-req', + ); + + expect(result, Uint8List.fromList([4, 5, 6])); + }); + }); } From d42d1c2ab0e41f43fd6de4c8cbb422d2c9b17f06 Mon Sep 17 00:00:00 2001 From: ethicnology Date: Mon, 13 Jul 2026 23:13:21 +0000 Subject: [PATCH 26/28] test(payjoin): correct multi-payjoin integration test timeout unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- integration_test/payjoin_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration_test/payjoin_test.dart b/integration_test/payjoin_test.dart index 419184b2a0..76d9c6ac2c 100644 --- a/integration_test/payjoin_test.dart +++ b/integration_test/payjoin_test.dart @@ -491,7 +491,7 @@ Future main({bool isInitialized = false}) async { }, timeout: const Timeout( Duration( - minutes: + seconds: PayjoinConstants.directoryPollingInterval * 3 * numberOfPayjoins, From 08e1ead6b7c12680d5453a503112de5cafffb9b3 Mon Sep 17 00:00:00 2001 From: ethicnology Date: Tue, 14 Jul 2026 11:18:36 +0000 Subject: [PATCH 27/28] fix(payjoin): broadcast original on restart-time expiry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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. --- .../data/repository/payjoin_repository_impl.dart | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/core/payjoin/data/repository/payjoin_repository_impl.dart b/lib/core/payjoin/data/repository/payjoin_repository_impl.dart index 2312e90d00..415220c690 100644 --- a/lib/core/payjoin/data/repository/payjoin_repository_impl.dart +++ b/lib/core/payjoin/data/repository/payjoin_repository_impl.dart @@ -351,12 +351,13 @@ class PayjoinRepositoryImpl implements PayjoinRepository { final models = await _localPayjoinDatasource.fetchAll(onlyUnfinished: true); for (final model in models) { if (model.isExpiryTimePassed) { - // If the payjoin is expired, we should update the model and - // store it as expired so it won't be processed again unnecessarily. - final updatedModel = model.copyWith(isExpired: true); - await _localPayjoinDatasource.update(updatedModel); - // Notify the repository layers that the payjoin has expired - _payjoinStreamController.add(updatedModel.toEntity()); + // A session whose expiry elapsed while the app was closed. Route it + // through the same handler as a live expiry so the receiver's + // original-transaction fallback still fires — otherwise an + // expired-while-closed receiver that already had the sender's + // original tx would silently drop it, leaving the sender's payment + // in limbo (neither payjoin nor fallback ever hits the chain). + await _processExpiredPayjoin(model.copyWith(isExpired: true)); } else if (model is PayjoinReceiverModel) { if (model.originalTxBytes == null) { // If the original tx bytes are not present, it means the receiver From a781234070a4728c6cdd3eb105fbe7b41f7a397f Mon Sep 17 00:00:00 2001 From: ethicnology Date: Tue, 14 Jul 2026 11:19:05 +0000 Subject: [PATCH 28/28] fix(payjoin): bound the request-upload phase with sendTimeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/core/payjoin/payjoin_locator.dart | 6 ++++++ test/core_test/payjoin/pdk_payjoin_datasource_test.dart | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/core/payjoin/payjoin_locator.dart b/lib/core/payjoin/payjoin_locator.dart index 056a2bb746..f9925e61d4 100644 --- a/lib/core/payjoin/payjoin_locator.dart +++ b/lib/core/payjoin/payjoin_locator.dart @@ -29,10 +29,16 @@ class PayjoinLocator { locator.registerLazySingleton( // Timeouts bound how long a single directory/relay poll can hang, so a // slow OHTTP relay can't hold a polling session in flight for long. + // All three phases must be bounded: the per-session in-flight guard + // turns any unbounded await into a permanent stall (the poll's finally + // never runs, the session id is never removed from the in-flight set, + // and every later tick is skipped). sendTimeout bounds the request-body + // upload phase — OHTTP bodies are small, so 10s is ample. () => PdkPayjoinDatasource( dio: Dio( BaseOptions( connectTimeout: const Duration(seconds: 10), + sendTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 30), ), ), diff --git a/test/core_test/payjoin/pdk_payjoin_datasource_test.dart b/test/core_test/payjoin/pdk_payjoin_datasource_test.dart index 935f75d6ad..9703404cd4 100644 --- a/test/core_test/payjoin/pdk_payjoin_datasource_test.dart +++ b/test/core_test/payjoin/pdk_payjoin_datasource_test.dart @@ -301,7 +301,7 @@ void main() { // functions: postOriginalProposal, _getUncheckedOriginalPayload, // _getProposalPsbt, _sendPayjoinProposal) funnels through this single // choke point, and PayjoinLocator configures its Dio's - // connect/receiveTimeout specifically so an unresponsive relay can't + // connect/send/receiveTimeout specifically so an unresponsive relay can't // stall a poll indefinitely. These verify the plumbing a real timeout // exercises: postBytes neither swallows nor transforms the failure, so // the relay loops' existing catch-and-try-next-relay handling applies to