diff --git a/integration_test/payjoin_test.dart b/integration_test/payjoin_test.dart index a553b1013f..f572908057 100644 --- a/integration_test/payjoin_test.dart +++ b/integration_test/payjoin_test.dart @@ -1,11 +1,12 @@ 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'; 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 +17,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,39 +30,69 @@ Future main({bool isInitialized = false}) async { late Wallet senderWallet; final walletRepository = locator(); + final seedRepository = locator(); final addressRepository = locator(); final utxoRepository = locator(); final payjoinRepository = locator(); + final localPayjoinDatasource = locator(); final receiveWithPayjoinUsecase = locator(); 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( + // 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(' '), ); - 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, ); 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/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart index bda194e43e..ee366a2224 100644 --- a/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart +++ b/lib/core/payjoin/data/datasources/pdk_payjoin_datasource.dart @@ -1,6 +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'; @@ -9,12 +9,10 @@ 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_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'; +import 'package:payjoin/payjoin.dart'; +import 'package:payjoin/http.dart' show fetchOhttpKeys; class PdkPayjoinDatasource { final String _payjoinDirectoryUrl; @@ -23,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, @@ -38,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; @@ -50,25 +42,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({ @@ -87,50 +75,40 @@ 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, ) 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) { @@ -148,43 +126,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, @@ -194,626 +166,709 @@ 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; } - Future proposePayjoin({ - required PayjoinReceiverModel receiverModel, - required FutureOr Function(Uint8List) hasOwnedInputs, - required FutureOr Function(Uint8List) hasReceiverOutput, - required List inputPairs, - required FutureOr Function(String) processPsbt, - }) async { - final receiver = Receiver.fromJson(json: receiverModel.receiver); - final request = await getRequest(receiver: receiver, dio: _dio); - - if (request == null) { - throw Exception('No request found'); + 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; + } } - - final interactiveReceiver = await request.assumeInteractiveReceiver(); - final inputsNotOwned = await interactiveReceiver.checkInputsNotOwned( - isOwned: hasOwnedInputs, - ); - final inputsNotSeen = await inputsNotOwned.checkNoInputsSeenBefore( - isKnown: (_) => - false, // Assume the wallet has not seen the inputs since it is an interactive wallet - ); - final receiverOutputs = await inputsNotSeen.identifyReceiverOutputs( - isReceiverOutput: hasReceiverOutput, - ); - 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, - ), - ), - ), - ), - ); - - // Try to select a privacy preserving input pair, else just stick with the - // first possible input pair. - InputPair inputPair = candidateInputs.first; - try { - inputPair = await committedOutputs.tryPreservingPrivacy( - candidateInputs: candidateInputs, - ); - } catch (e) { - logger.log.severe( - message: 'Failed to preserve privacy: Using first input pair.', - error: e, - trace: StackTrace.current, + if (!posted) { + throw SendCreationException( + 'Failed to post original PSBT to any OHTTP relay: $lastError', ); } + } - final inputsContributed = await committedOutputs.contributeInputs( - replacementInputs: [inputPair], + Future proposePayjoin({ + required PayjoinReceiverModel receiverModel, + required bool Function(Uint8List) hasOwnedInputs, + required bool Function(Uint8List) hasReceiverOutput, + required List inputPairs, + required String Function(String) processPsbt, + }) async { + final persister = InMemoryJsonReceiverSessionPersister.fromJson( + receiverModel.receiver, ); - final inputsCommitted = await inputsContributed.commitInputs(); - final proposal = await inputsCommitted.finalizeProposal( + final state = replayReceiverEventLog(persister: persister).state(); + + final result = await processReceiveSession( + state: state, + persister: persister, + hasOwnedInputs: hasOwnedInputs, + hasReceiverOutput: hasReceiverOutput, + inputPairs: inputPairs, + receiverModel: receiverModel, 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); - // Update the model with the proposal psbt so it can be known a proposal has // been sent - final proposalPsbt = await proposal.psbt(); + final proposalPsbt = result.psbt; final updatedModel = receiverModel.copyWith( - receiver: receiver.toJson(), + receiver: persister.toJson(), proposalPsbt: proposalPsbt, txId: (await BitcoinTx.fromPsbt(proposalPsbt)).txid, ); logger.log.info( - 'Payjoin request processed and proposal sent for ${receiver.id()}: $proposalPsbt', + 'Payjoin request processed and proposal sent for ${receiverModel.id}: $proposalPsbt', ); return updatedModel; } - Future startListeningForRequest(PayjoinReceiverModel payjoin) async { - if (_receiversIsolate == null) { - // Start the isolate if it is not running yet - await _spawnReceiversIsolate(); + 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 startListeningForRequest', + ); + 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'); } - 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()); + 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, + ); } - /// 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, + 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, ); } - /// 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); - } - } - }); - - logger.log.info('Spawning senders isolate'); - _sendersIsolate = await Isolate.spawn( - _sendersIsolateEntryPoint, - receivePort.sendPort, + 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, ); } - 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); - 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 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()}', - ); - 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()}', - ); - // 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 - .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 ' - '${receiver.id()}', - ); - final updatedModel = receiverModel.copyWith( - receiver: receiver.toJson(), - originalTxBytes: originalTxBytes, - originalTxId: originalTxId, - amountSat: amountSat, - ); + 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, + ); + } - // Notify the main isolate so it can be processed further - sendPort.send(updatedModel.toJson()); + 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, + ); + } - // Cancel the timer since the request has been received - log( - '[Receivers Isolate] cancelling timer in receivers isolate for ${receiver.id()}', - ); - timer.cancel(); - log( - '[Receivers Isolate] timer cancelled in receivers isolate for ${receiver.id()}', - ); - }); - } else { - log( - '[Receivers Isolate] No valid request found in receivers isolate for ' - '${receiver.id()}', - ); - } - } catch (e) { - log( - '[Receivers Isolate] periodic timer get request exception: $e for ' - '${receiver.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(); - } - } - }); - }); + 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 { + chosen = inner.tryPreservingPrivacy(candidateInputs: candidates); + } catch (e) { + 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); } - 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(); + 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); + } - final receivePort = ReceivePort(); - sendPort.send(receivePort.sendPort); + 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 dio = Dio(); - // Listen for and register new receivers sent from the main isolate - receivePort.listen((data) async { + Future<({Monitor monitor, String psbt})> _sendPayjoinProposal( + PayjoinProposal proposal, + InMemoryJsonReceiverSessionPersister persister, + ) async { + Object? lastError; + for (final relay in PayjoinConstants.ohttpRelayUrls) { 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( - const Duration(seconds: PayjoinConstants.directoryPollingInterval), - (Timer timer) async { - log('[Senders Isolate]Checking for proposal in senders isolate'); - try { - final proposalPsbt = await PdkPayjoinDatasource.getProposalPsbt( - context: context, - dio: dio, - ); - - 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( - 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(); - } - } - }, + 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('[Senders Isolate] Error in listener: $e'); - // Optionally notify the main isolate of the failure + log('proposal post via $relay failed: $e'); + lastError = e; + continue; } - }); + } + throw PayjoinNotFoundException( + 'Failed to post payjoin proposal: $lastError', + ); } - 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; + 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, + ); + } + + void startListeningForRequest(PayjoinReceiverModel payjoin) { + _receiverTimers[payjoin.id]?.cancel(); + _receiverTimers[payjoin.id] = Timer.periodic( + const Duration(seconds: PayjoinConstants.directoryPollingInterval), + (timer) => _pollReceiverOnce(payjoin, timer), + ); + } + + void startListeningForProposal(PayjoinSenderModel payjoin) { + _senderTimers[payjoin.id]?.cancel(); + _senderTimers[payjoin.id] = Timer.periodic( + const Duration(seconds: PayjoinConstants.directoryPollingInterval), + (timer) => _pollSenderOnce(payjoin, timer), + ); + } + + Future _pollReceiverOnce( + PayjoinReceiverModel receiverModel, + Timer timer, + ) async { + log('[receiver poll] checking for request for ${receiverModel.id}'); 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; + 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; } + if (state is! InitializedReceiveSession) return; - if (request == null) { - if (ffiError != null) { - throw ffiError; - } - throw PayjoinNotFoundException( - '[${receiver.id()}] No payjoin request found', - ); + final unchecked = await _getUncheckedOriginalPayload( + state.inner, + persister, + ); + if (unchecked == null) { + log('[receiver poll] no request yet for ${receiverModel.id}'); + return; } - 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, - ), + timer.cancel(); + _receiverTimers.remove(receiverModel.id); + + 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', ); - log('[${receiver.id()}] processing request...'); - final proposal = await receiver.processRes( - body: ohttpResponse.data as List, - ctx: context, + final updatedModel = receiverModel.copyWith( + receiver: persister.toJson(), + originalTxBytes: originalTxBytes, + originalTxId: originalTx.txid, + amountSat: amountSat, ); - log('[${receiver.id()}] request processed'); - return proposal; + _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) { - 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', - ); + logger.log.info('[receiver poll] ${receiverModel.id}: $e'); + } + } + + 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 { + state = replaySenderEventLog(persister: persister).state(); + } on SenderReplayException catch (e) { + if (_isExpiredString(e)) { + throw PayjoinExpiredException('Payjoin sender expired: $e'); + } + rethrow; } - return null; + if (state is! PollingForProposalSendSession) return; + + 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( + 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'); } } - Future _sendPayjoinProposal(PayjoinProposal proposal) async { - (Request, ClientResponse)? request; - for (final ohttpRelayUrl in PayjoinConstants.ohttpRelayUrls) { + Future _getUncheckedOriginalPayload( + Initialized initialized, + InMemoryJsonReceiverSessionPersister persister, + ) async { + Object? lastError; + for (final relay in PayjoinConstants.ohttpRelayUrls) { try { - request = await proposal.extractReq(ohttpRelay: ohttpRelayUrl); - break; + 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('proposal extractReq exception: $e with relay $ohttpRelayUrl'); + log('receiver poll via $relay failed: $e'); + lastError = e; 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, - ); + throw PayjoinNotFoundException('Failed to poll receiver: $lastError'); } - static Future request({ - required Sender sender, - required Dio dio, - }) async { - (Request, V2PostContext)? result; - - for (final ohttpProxyUrl in PayjoinConstants.ohttpRelayUrls) { + Future _getProposalPsbt( + PollingForProposal polling, + InMemoryJsonSenderSessionPersister persister, + ) async { + Object? lastError; + for (final relay in PayjoinConstants.ohttpRelayUrls) { try { - log( - '[Senders Isolate] Extracting V2 request from sender with relay: $ohttpProxyUrl', + final poll = polling.createPollRequest(ohttpRelay: relay); + final body = await _postBytes( + _dio, + poll.request.url, + poll.request.body, + poll.request.contentType, ); - result = await sender.extractV2( - ohttpProxyUrl: await Url.fromStr(ohttpProxyUrl), - ); - break; + final outcome = polling + .processResponse(response: body, ohttpCtx: poll.ohttpCtx) + .save(persister: persister); + if (outcome is StasisPollingForProposalTransitionOutcome) { + return null; + } + return (outcome as ProgressPollingForProposalTransitionOutcome) + .psbtBase64; + } on CreateRequestException catch (e) { + if (_isExpiredString(e)) { + throw PayjoinExpiredException('Payjoin sender expired: $e'); + } + log('sender createPollRequest via $relay failed: $e'); + lastError = e; + continue; } catch (e) { - final msg = (e as dynamic).msg ?? e.toString(); - log('[Senders Isolate] request error: $msg'); - log('[Senders Isolate] Continuing to next OHTTP relay'); + log('sender poll via $relay failed: $e'); + lastError = e; continue; } } + throw PayjoinNotFoundException('Failed to poll sender: $lastError'); + } - 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, + // "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, + Uint8List body, + String contentType, + ) async { + final response = await dio.post>( + url, + data: body, options: Options( - headers: {'Content-Type': req.contentType}, + headers: {'Content-Type': contentType}, responseType: ResponseType.bytes, ), ); - log('[Senders Isolate] Received response from ${req.url.asString()}'); - - final getCtx = await context.processResponse( - response: res.data as List, - ); + return Uint8List.fromList(response.data ?? const []); + } +} - log('[Senders Isolate] Processed response for V2 request: $getCtx'); +class PayjoinNotFoundException extends BullException { + PayjoinNotFoundException(super.message); +} - return getCtx; - } +class ReceiveCreationException extends BullException { + ReceiveCreationException(super.message); +} - 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; - } - } +class NoValidPayjoinBip21Exception extends BullException { + NoValidPayjoinBip21Exception(super.message); +} - if (result == null) { - if (ffiError != null) { - throw ffiError; - } - throw Exception('All OHTTP relays failed'); - } +class PayjoinExpiredException extends BullException { + PayjoinExpiredException(super.message); +} - final (req, reqCtx) = result; +class OhttpRelaysUnavailableException extends BullException { + OhttpRelaysUnavailableException(super.message); +} - final res = await dio.post( - req.url.asString(), - data: req.body, - options: Options( - headers: {'Content-Type': req.contentType}, - responseType: ResponseType.bytes, - ), - ); +class SendCreationException extends BullException { + SendCreationException(super.message); +} - final proposalPsbt = await context.processResponse( - response: res.data as List, - ohttpCtx: reqCtx, - ); +class _IsScriptOwned implements IsScriptOwned { + final bool Function(Uint8List) _fn; + _IsScriptOwned(this._fn); - 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'); - } - return null; - } - } + @override + bool callback(Uint8List script) => _fn(script); } -class InMemoryReceiverPersister { - final Map _store = {}; +/// Assume the wallet has not seen the inputs since it is an interactive wallet +class _AssumeUnseen implements IsOutputKnown { + @override + bool callback(OutPoint outpoint) => false; +} - Future save({required Receiver receiver}) async { - final token = receiver.key(); - _store[token.toBytes().toString()] = receiver; - return token; - } +class _ProcessPsbt implements ProcessPsbt { + final String Function(String) _sign; + _ProcessPsbt(this._sign); - 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; - } + @override + String callback(String psbt) => _sign(psbt); } -class InMemorySenderPersister implements DartSenderPersister { - final Map _store = {}; +class InMemoryJsonReceiverSessionPersister + implements JsonReceiverSessionPersister { + final List _events; + bool _closed; - Future save({required Sender sender}) async { - final token = sender.key(); - logger.log.info('TOKEN SAVE}'); - _store[token.toBytes().toString()] = sender; - return token; - } + InMemoryJsonReceiverSessionPersister([List? initial]) + : _events = [...?initial], + _closed = false; - 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; + 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 dispose() { - _store.clear(); - } + void save(String event) => _events.add(event); @override - bool get isDisposed => _store.isEmpty; -} + List load() => List.from(_events); -class PayjoinNotFoundException extends BullException { - PayjoinNotFoundException(super.message); + @override + void close() => _closed = true; } -class ReceiveCreationException extends BullException { - ReceiveCreationException(super.message); -} +class InMemoryJsonSenderSessionPersister implements JsonSenderSessionPersister { + final List _events; + bool _closed; -class NoValidPayjoinBip21Exception extends BullException { - NoValidPayjoinBip21Exception(super.message); -} + InMemoryJsonSenderSessionPersister([List? initial]) + : _events = [...?initial], + _closed = false; -class PayjoinExpiredException extends BullException { - PayjoinExpiredException(super.message); + 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; } -class OhttpRelaysUnavailableException extends BullException { - OhttpRelaysUnavailableException(super.message); +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 []; } diff --git a/lib/core/payjoin/data/repository/payjoin_repository_impl.dart b/lib/core/payjoin/data/repository/payjoin_repository_impl.dart index 7d8c6ba8ca..07e58ed39a 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'; @@ -437,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 @@ -450,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. @@ -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..31882945ea 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, @@ -718,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 @@ -727,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, 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(), 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